Skip to content

Commit 953940c

Browse files
committed
Pass auth through to the server for MCP
1 parent 95f7fd7 commit 953940c

11 files changed

Lines changed: 312 additions & 74 deletions

File tree

src/model/account/account-store.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,22 @@ export class AccountStore {
100100
: undefined;
101101
}
102102

103+
// Expose the JWT directly, for delegating auth to remote services (e.g. HTK local server
104+
// for MCP, public endpoint cloud server).
105+
get userJwt(): string {
106+
if (this.isLoggedIn && this.accountDataLastUpdated !== Infinity) {
107+
// We read accountDataLastUpdated just to set up a subscription, so this will
108+
// re-calculate on each JWT update, since we can't observe localStorage directly.
109+
// It's never actually Infinity.
110+
111+
const jwt = localStorage.getItem('last_jwt');
112+
if (!jwt) throw new Error("No JWT found for logged in user");
113+
return jwt;
114+
} else {
115+
throw new Error("Can't get JWT for logged out user");
116+
}
117+
}
118+
103119
private updateUser = flow(function * (this: AccountStore) {
104120
this.user = yield getLatestUserData();
105121
this.accountDataLastUpdated = Date.now();

src/services/ui-api/api-interface.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ export function initializeUiApi(stores: {
2121

2222
registerAllOperations(
2323
registry,
24-
{ eventsStore, proxyStore, interceptorStore },
24+
{ eventsStore, proxyStore, interceptorStore, accountStore },
2525
() => eventsStore.events
2626
);
2727

2828
// Connect to the server's WebSocket bridge (for MCP and external tool access).
2929
const authToken = new URLSearchParams(window.location.search).get('authToken') ?? undefined;
30-
startServerOperationBridge(registry, authToken);
30+
startServerOperationBridge(registry, authToken, accountStore);
3131
}

src/services/ui-api/api-registry.ts

Lines changed: 69 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,62 @@
1-
import { Operation, OperationResult } from './api-types';
2-
3-
const PRO_REQUIRED_ERROR: OperationResult = {
4-
success: false,
5-
error: {
6-
code: 'PRO_REQUIRED',
7-
message: 'This feature requires an HTTP Toolkit Pro subscription. ' +
8-
'Get Pro at https://httptoolkit.com/pricing/ to unlock ' +
9-
'programmatic access to HTTP Toolkit via MCP, CLI, and more.'
10-
}
11-
};
1+
import { Operation, OperationDefinition, OperationResult } from './api-types';
2+
3+
function tierRequiredError(tier: string): OperationResult {
4+
const displayTier = tier.charAt(0).toUpperCase() + tier.slice(1);
5+
return {
6+
success: false,
7+
error: {
8+
code: `TIER_REQUIRED_${tier.toUpperCase()}`,
9+
message: `This feature requires an HTTP Toolkit ${displayTier} subscription. ` +
10+
`Get ${displayTier} at https://httptoolkit.com/pricing/ to unlock ` +
11+
'programmatic access to HTTP Toolkit via MCP, CLI, and more.'
12+
}
13+
};
14+
}
1215

1316
export class OperationRegistry {
1417

1518
private operations = new Map<string, Operation>();
19+
private sessionLimitCounters = new Map<string, number>();
1620

1721
constructor(private isPaidUser: () => boolean) {}
1822

1923
register(op: Operation): void {
2024
this.operations.set(op.definition.name, op);
2125
}
2226

23-
getDefinitions() {
24-
return Array.from(this.operations.values()).map(op => op.definition);
27+
private getUserTier(): 'free' | 'pro' {
28+
return this.isPaidUser() ? 'pro' : 'free';
2529
}
2630

27-
async execute(name: string, params: Record<string, unknown>): Promise<OperationResult> {
28-
if (!this.isPaidUser()) {
29-
return PRO_REQUIRED_ERROR;
30-
}
31+
getDefinitions(): OperationDefinition[] {
32+
const tier = this.getUserTier();
33+
34+
return Array.from(this.operations.values())
35+
.filter(op => {
36+
const { tiers } = op.definition;
37+
// Hide operations whose tiers don't include the current user's tier
38+
// (e.g. account.upgrade has tiers: ['free'] — hidden from pro users)
39+
if (!tiers.includes(tier)) return false;
40+
return true;
41+
})
42+
.map(op => {
43+
if (tier === 'pro') return op.definition;
44+
45+
// Augment descriptions for free users so AI agents understand limits
46+
let description = op.definition.description;
47+
const { tiers, sessionLimit } = op.definition;
48+
49+
if (sessionLimit) {
50+
description += `\n\n[Free tier] Limited to ${sessionLimit} calls per session. ` +
51+
'Use account.upgrade to subscribe to Pro for unlimited access.';
52+
}
53+
54+
if (description === op.definition.description) return op.definition;
55+
return { ...op.definition, description };
56+
});
57+
}
3158

59+
async execute(name: string, params: Record<string, unknown>): Promise<OperationResult> {
3260
const op = this.operations.get(name);
3361
if (!op) {
3462
return {
@@ -40,6 +68,30 @@ export class OperationRegistry {
4068
};
4169
}
4270

71+
const tier = this.getUserTier();
72+
const { tiers, sessionLimit } = op.definition;
73+
74+
if (!tiers.includes(tier)) {
75+
const requiredTier = tiers[0]; // The first listed tier the user doesn't have
76+
return tierRequiredError(requiredTier);
77+
}
78+
79+
// Check session limits for free users
80+
if (tier === 'free' && sessionLimit) {
81+
const count = this.sessionLimitCounters.get(name) ?? 0;
82+
if (count >= sessionLimit) {
83+
return {
84+
success: false,
85+
error: {
86+
code: 'SESSION_LIMIT',
87+
message: `Free users are limited to ${sessionLimit} calls per session. ` +
88+
'Upgrade to HTTP Toolkit Pro for unlimited access: https://httptoolkit.com/pricing/'
89+
}
90+
};
91+
}
92+
this.sessionLimitCounters.set(name, count + 1);
93+
}
94+
4395
try {
4496
const result = await op.handler(params);
4597
// JSON roundtrip to strip MobX observables, class instances, and other

src/services/ui-api/api-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export interface OperationDefinition {
66
inputSchema: JSONSchema7;
77
outputSchema: JSONSchema7;
88
category: string;
9+
tiers: Array<'free' | 'pro'>;
10+
sessionLimit?: number;
911
annotations?: {
1012
readOnlyHint?: boolean;
1113
destructiveHint?: boolean;
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Operation } from '../api-types';
2+
import { AccountStore } from '../../../model/account/account-store';
3+
4+
export function registerAccountOperations(
5+
registry: { register(op: Operation): void },
6+
accountStore: AccountStore
7+
): void {
8+
registry.register(accountUpgradeOperation(accountStore));
9+
}
10+
11+
function accountUpgradeOperation(accountStore: AccountStore): Operation {
12+
return {
13+
definition: {
14+
name: 'account.upgrade',
15+
description: 'Start the upgrade process to HTTP Toolkit Pro. ' +
16+
'Opens the subscription signup flow in the HTTP Toolkit desktop app. ' +
17+
'Pro unlocks advanced remote control & MCP features including unlimited data ' +
18+
'access, interceptor activation, CLI control, and all future operations, in ' +
19+
'addition to all core UI features like advanced debugging, import/export, ' +
20+
'automated rewriting rules, persistent sessions, and advanced configuration. ' +
21+
'Starting this flow doesn\'t commit you to purchasing - the initial step just ' +
22+
'lists the plans and features available so you can make a decision.',
23+
category: 'account',
24+
tiers: ['free'],
25+
annotations: { readOnlyHint: false },
26+
inputSchema: {
27+
type: 'object',
28+
properties: {}
29+
},
30+
outputSchema: {
31+
type: 'object',
32+
properties: {
33+
message: { type: 'string' }
34+
}
35+
}
36+
},
37+
handler: async () => {
38+
accountStore.getPro('mcp');
39+
return {
40+
success: true,
41+
data: {
42+
message: 'The upgrade flow has been started in the HTTP Toolkit desktop app. ' +
43+
'Please complete the checkout process there.'
44+
}
45+
};
46+
}
47+
};
48+
}

src/services/ui-api/operations/event-operations.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ function eventsListOperation(
4848
'Uses the same filter syntax as the UI search bar. ' +
4949
'See https://httptoolkit.com/docs/reference/view-page/#filtering-intercepted-traffic for full docs.',
5050
category: 'events',
51+
tiers: ['free', 'pro'],
5152
annotations: { readOnlyHint: true },
5253
inputSchema: {
5354
type: 'object',
@@ -169,6 +170,7 @@ function eventsGetOutlineOperation(
169170
'headers, status, timing, and body sizes, but not the body content itself. ' +
170171
'Use events.get-request-body or events.get-response-body to retrieve bodies.',
171172
category: 'events',
173+
tiers: ['free', 'pro'],
172174
annotations: { readOnlyHint: true },
173175
inputSchema: {
174176
type: 'object',
@@ -225,6 +227,8 @@ function eventsGetRequestBodyOperation(
225227
description: 'Get the request body of a captured HTTP exchange. ' +
226228
'Use offset and maxLength to retrieve specific ranges of large bodies.',
227229
category: 'events',
230+
tiers: ['free', 'pro'],
231+
sessionLimit: 50,
228232
annotations: { readOnlyHint: true },
229233
inputSchema: {
230234
type: 'object',
@@ -255,6 +259,8 @@ function eventsGetResponseBodyOperation(
255259
description: 'Get the response body of a captured HTTP exchange. ' +
256260
'Use offset and maxLength to retrieve specific ranges of large bodies.',
257261
category: 'events',
262+
tiers: ['free', 'pro'],
263+
sessionLimit: 50,
258264
annotations: { readOnlyHint: true },
259265
inputSchema: {
260266
type: 'object',
@@ -282,6 +288,7 @@ function eventsClearOperation(eventsStore: EventsStore): Operation {
282288
name: 'events.clear',
283289
description: 'Clear all captured events. By default, pinned events are preserved.',
284290
category: 'events',
291+
tiers: ['free', 'pro'],
285292
annotations: { readOnlyHint: false, destructiveHint: true },
286293
inputSchema: {
287294
type: 'object',

src/services/ui-api/operations/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,25 @@ import { OperationRegistry } from '../api-registry';
22
import { registerEventOperations } from './event-operations';
33
import { registerProxyOperations } from './proxy-operations';
44
import { registerInterceptorOperations } from './interceptor-operations';
5+
import { registerAccountOperations } from './account-operations';
56
import { CollectedEvent } from '../../../types';
67
import { EventsStore } from '../../../model/events/events-store';
78
import { ProxyStore } from '../../../model/proxy-store';
89
import { InterceptorStore } from '../../../model/interception/interceptor-store';
10+
import { AccountStore } from '../../../model/account/account-store';
911

1012
export function registerAllOperations(
1113
registry: OperationRegistry,
1214
stores: {
1315
eventsStore: EventsStore;
1416
proxyStore: ProxyStore;
1517
interceptorStore: InterceptorStore;
18+
accountStore: AccountStore;
1619
},
1720
getEvents: () => ReadonlyArray<CollectedEvent>
1821
): void {
1922
registerEventOperations(registry, stores.eventsStore, getEvents);
2023
registerProxyOperations(registry, stores.proxyStore);
2124
registerInterceptorOperations(registry, stores.interceptorStore);
25+
registerAccountOperations(registry, stores.accountStore);
2226
}

src/services/ui-api/operations/interceptor-operations.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ function interceptorsListOperation(interceptorStore: InterceptorStore): Operatio
3333
description: 'List available interceptors and their current status. ' +
3434
'Shows which interceptors are supported, activable, and currently active.',
3535
category: 'interceptors',
36+
tiers: ['free', 'pro'],
3637
annotations: { readOnlyHint: true },
3738
inputSchema: {
3839
type: 'object',
@@ -81,6 +82,7 @@ function interceptorsActivateOperation(interceptorStore: InterceptorStore): Oper
8182
'fresh-terminal, system-proxy). Interactive interceptors like docker-attach, ' +
8283
'android-adb, electron etc. require the full UI.',
8384
category: 'interceptors',
85+
tiers: ['pro'],
8486
annotations: { readOnlyHint: false },
8587
inputSchema: {
8688
type: 'object',

src/services/ui-api/operations/proxy-operations.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ function proxyGetConfigOperation(proxyStore: ProxyStore): Operation {
1515
description: 'Get the current proxy configuration: port, certificate path, ' +
1616
'certificate fingerprint, and external network addresses.',
1717
category: 'proxy',
18+
tiers: ['free', 'pro'],
1819
annotations: { readOnlyHint: true },
1920
inputSchema: {
2021
type: 'object',

0 commit comments

Comments
 (0)