Skip to content

Commit fad617e

Browse files
feat(mcp-server): support proxy and regional placement (#57)
Co-authored-by: Codex <codex@openai.com>
1 parent 83e08cb commit fad617e

8 files changed

Lines changed: 136 additions & 9 deletions

File tree

charts/platform/templates/mcp-server.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ spec:
3939
value: {{ $mcp.sessionBlockSeconds | quote }}
4040
- name: MCP_OPERATION_LEASE_SECONDS
4141
value: {{ $mcp.operationLeaseSeconds | quote }}
42+
{{- if .Values.controlPlane.regions }}
43+
{{- $mcpRegionNames := list }}
44+
{{- range .Values.controlPlane.regions }}
45+
{{- if not .x402Only }}
46+
{{- $mcpRegionNames = append $mcpRegionNames .name }}
47+
{{- end }}
48+
{{- end }}
49+
{{- if $mcpRegionNames }}
50+
- name: MCP_AVAILABLE_REGIONS
51+
value: {{ join "," $mcpRegionNames | quote }}
52+
{{- end }}
53+
{{- end }}
4254
- name: MCP_BILLING_PROVIDER
4355
value: {{ $mcp.billing.provider | quote }}
4456
{{- if eq $mcp.billing.provider "external" }}

services/mcp-server/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ implementation.
3737
| `end_browser_session` || End early |
3838
| `list_browser_sessions` || Recent sessions for this identity |
3939

40+
`create_browser_session` accepts optional `regions` in nearest-first fallback
41+
order and an optional two-letter `proxy_country`. Proxy URLs and credentials are
42+
never accepted from MCP callers; the selected country uses the deployment-owned
43+
proxy preset.
44+
4045
## Billing is an extension point
4146

4247
This service performs the **browser** effect, so it owns operation idempotency
@@ -142,6 +147,7 @@ Bring your own provider by implementing the interface and wiring it in
142147
| `MCP_TOKEN_SIGNING_KEY` | dev key | Signs tokens and derives subjects; rotating it invalidates both |
143148
| `MCP_SESSION_TTL_SECONDS` | `600` | Fixed block of browser time per billed operation |
144149
| `MCP_OPERATION_LEASE_SECONDS` | `120` | When one retry may recover a crashed operation |
150+
| `MCP_AVAILABLE_REGIONS` || Comma-separated region names advertised for nearest-first placement |
145151
| `MCP_BILLING_PROVIDER` | `none` | `none` or `external` |
146152
| `MCP_BILLING_BASE_URL` || Billing service base URL (external only) |
147153
| `MCP_BILLING_AUTH_TOKEN` || Bearer token for that service |

services/mcp-server/src/config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ function num(name: string, fallback: number): number {
99
return Number.isFinite(parsed) ? parsed : fallback;
1010
}
1111

12+
function list(name: string): string[] {
13+
return [...new Set(env(name).split(',').map((value) => value.trim()).filter(Boolean))];
14+
}
15+
1216
function databaseUrl(): string {
1317
const direct = env('DATABASE_URL');
1418
if (direct) return direct;
@@ -39,6 +43,8 @@ export const McpConfig = {
3943
sessionTtlSeconds: num('MCP_SESSION_TTL_SECONDS', 600),
4044
/** How long one worker owns an operation before a retry may recover it. */
4145
operationLeaseSeconds: num('MCP_OPERATION_LEASE_SECONDS', 120),
46+
/** Region names advertised to MCP clients for nearest-first placement. */
47+
availableRegions: list('MCP_AVAILABLE_REGIONS'),
4248
/**
4349
* Billing. `none` (default) meters nothing — the right choice for
4450
* self-hosters. `external` delegates balance/reserve/commit/release to an

services/mcp-server/src/mcp.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,21 @@ describe('billing boundary', () => {
124124
expect((response as any).result.structuredContent.error).toBe('billing_unavailable');
125125
});
126126

127+
test('invalid placement is rejected before reserving credit', async () => {
128+
const billing = new RecordingBilling();
129+
const response = await handleRpc(ctx(billing), {
130+
jsonrpc: '2.0',
131+
id: 70,
132+
method: 'tools/call',
133+
params: {
134+
name: 'create_browser_session',
135+
arguments: { purpose: 'x', idempotency_key: 'bad-placement', regions: [], proxy_country: 'USA' },
136+
},
137+
});
138+
expect((response as any).result.structuredContent.error).toBe('invalid_request');
139+
expect(billing.calls).toEqual([]);
140+
});
141+
127142
test('a refused reservation releases the claim so the same key can be retried', async () => {
128143
const billing = new RecordingBilling({ ok: false, reason: 'insufficient_credit' });
129144
const context = ctx(billing);

services/mcp-server/src/mcp.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export async function handleRpc(ctx: ToolContext, request: JsonRpcRequest): Prom
2828
capabilities: { tools: { listChanged: false } },
2929
serverInfo: { name: 'popcorn', title: 'Popcorn browser sessions', version: '0.1.0' },
3030
instructions:
31-
'Popcorn runs isolated, disposable cloud browsers. Call create_browser_session for a fixed block of browser time, then hand the live-view URL to the human for any login. If this deployment meters usage, get_balance reports remaining credit and a refused operation returns next_action telling the human how to obtain more.',
31+
'Popcorn runs isolated, disposable cloud browsers. Call create_browser_session for a fixed block of browser time, preferring regions closest to the human and listing fallbacks nearest-first. Use proxy_country only when the task needs a particular network exit country. Then hand the live-view URL to the human for any login. If this deployment meters usage, get_balance reports remaining credit and a refused operation returns next_action telling the human how to obtain more.',
3232
});
3333

3434
case 'notifications/initialized':

services/mcp-server/src/popcorn.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,26 @@ export function toSessionView(session: PopcornSession): SessionView {
4545

4646
export type PopcornResult<T> = { ok: true; data: T } | { ok: false; status: number; error: string };
4747

48+
export type CreateSessionInput = {
49+
sessionId: string;
50+
ttlSeconds: number;
51+
metadata: Record<string, unknown>;
52+
/** Popcorn region names, ordered nearest-first with later entries as fallback. */
53+
regions?: string[];
54+
/** Deployment-managed proxy exit country; callers never provide proxy URLs. */
55+
proxyCountry?: string;
56+
};
57+
58+
export function createSessionRequestBody(input: CreateSessionInput): Record<string, unknown> {
59+
return {
60+
sessionId: input.sessionId,
61+
ttlSeconds: input.ttlSeconds,
62+
metadata: input.metadata,
63+
...(input.regions ? { regions: input.regions } : {}),
64+
...(input.proxyCountry ? { proxy: { country: input.proxyCountry } } : {}),
65+
};
66+
}
67+
4868
function authHeader(): Record<string, string> {
4969
return {
5070
authorization: `Bearer ${McpConfig.controlPlaneClientId}:${McpConfig.controlPlaneClientSecret}`,
@@ -70,14 +90,10 @@ async function call<T>(path: string, init: RequestInit): Promise<PopcornResult<T
7090
return { ok: true, data: body as T };
7191
}
7292

73-
export function createSession(input: {
74-
sessionId: string;
75-
ttlSeconds: number;
76-
metadata: Record<string, unknown>;
77-
}): Promise<PopcornResult<PopcornSession>> {
93+
export function createSession(input: CreateSessionInput): Promise<PopcornResult<PopcornSession>> {
7894
return call<PopcornSession>('/v1/sessions', {
7995
method: 'POST',
80-
body: JSON.stringify({ sessionId: input.sessionId, ttlSeconds: input.ttlSeconds, metadata: input.metadata }),
96+
body: JSON.stringify(createSessionRequestBody(input)),
8197
});
8298
}
8399

services/mcp-server/src/tools.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ import type { McpStore } from './store';
77
export type ToolContext = { store: McpStore; subject: string; billing: BillingProvider };
88
export type ToolResult = { content: Array<{ type: 'text'; text: string }>; isError?: boolean; structuredContent?: unknown };
99

10+
const regionItems = {
11+
type: 'string',
12+
minLength: 1,
13+
maxLength: 64,
14+
...(McpConfig.availableRegions.length ? { enum: McpConfig.availableRegions } : {}),
15+
};
16+
1017
function ok(data: unknown): ToolResult {
1118
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], structuredContent: data };
1219
}
@@ -152,7 +159,7 @@ export const TOOL_DEFINITIONS = [
152159
{
153160
name: 'create_browser_session',
154161
description:
155-
`Start one isolated Popcorn browser session. One operation buys one fixed block of ${McpConfig.sessionTtlSeconds} seconds; the duration is not negotiable. Returns session id, live-view URL for the human, CDP URL for the agent, and expiry. The browser is fresh and isolated: no local Chrome profile, cookies, or saved passwords.`,
162+
`Start one isolated Popcorn browser session. One operation buys one fixed block of ${McpConfig.sessionTtlSeconds} seconds; the duration is not negotiable. Prefer a region close to the human to reduce live-view and automation latency. Returns session id, live-view URL for the human, CDP URL for the agent, selected region, and expiry. The browser is fresh and isolated: no local Chrome profile, cookies, or saved passwords.`,
156163
inputSchema: {
157164
type: 'object',
158165
properties: {
@@ -161,6 +168,20 @@ export const TOOL_DEFINITIONS = [
161168
type: 'string',
162169
description: 'Reuse the same key when retrying: you get back the same session, never a second one.',
163170
},
171+
regions: {
172+
type: 'array',
173+
minItems: 1,
174+
maxItems: 8,
175+
items: regionItems,
176+
description:
177+
`Optional Popcorn region names ordered closest-to-human first; later entries are allocation fallbacks. Omit to use the deployment default order.${McpConfig.availableRegions.length ? ` Available regions: ${McpConfig.availableRegions.join(', ')}.` : ''}`,
178+
},
179+
proxy_country: {
180+
type: 'string',
181+
pattern: '^[A-Za-z]{2}$',
182+
description:
183+
'Optional ISO 3166-1 alpha-2 country code for a deployment-managed proxy exit (for example US or IN). This selects a country, never a proxy URL.',
184+
},
164185
},
165186
required: ['purpose', 'idempotency_key'],
166187
additionalProperties: false,
@@ -251,6 +272,33 @@ export async function callTool(ctx: ToolContext, name: string, args: Record<stri
251272
if (!purpose) return fail({ error: 'invalid_request', message: 'purpose is required' });
252273
const key = idempotencyKey(args.idempotency_key);
253274
if (!key) return fail({ error: 'invalid_request', message: 'idempotency_key is required (max 200 characters)' });
275+
let regions: string[] | undefined;
276+
if (args.regions !== undefined) {
277+
if (!Array.isArray(args.regions) || args.regions.length < 1 || args.regions.length > 8
278+
|| args.regions.some((region: unknown) => typeof region !== 'string' || !region.trim() || region.trim().length > 64)) {
279+
return fail({
280+
error: 'invalid_request',
281+
message: 'regions must contain 1-8 Popcorn region names ordered nearest-first',
282+
});
283+
}
284+
regions = [...new Set(args.regions.map((region: string) => region.trim()))];
285+
const unknown = regions.find((region) => McpConfig.availableRegions.length
286+
&& !McpConfig.availableRegions.includes(region));
287+
if (unknown) {
288+
return fail({
289+
error: 'invalid_request',
290+
message: `unknown region: ${unknown}`,
291+
available_regions: McpConfig.availableRegions,
292+
});
293+
}
294+
}
295+
let proxyCountry: string | undefined;
296+
if (args.proxy_country !== undefined) {
297+
if (typeof args.proxy_country !== 'string' || !/^[A-Za-z]{2}$/.test(args.proxy_country.trim())) {
298+
return fail({ error: 'invalid_request', message: 'proxy_country must be a two-letter ISO country code' });
299+
}
300+
proxyCountry = args.proxy_country.trim().toUpperCase();
301+
}
254302
const ref = `session:${ctx.subject}:${key}`;
255303
const sessionId = sessionIdForOperation(ref);
256304

@@ -268,6 +316,8 @@ export async function callTool(ctx: ToolContext, name: string, args: Record<stri
268316
sessionId,
269317
ttlSeconds: McpConfig.sessionTtlSeconds,
270318
metadata: { subject: ctx.subject, purpose },
319+
regions,
320+
proxyCountry,
271321
});
272322
// A recovered operation uses the same deterministic id. The normal API
273323
// reports the already-created effect as 409, so fetch and replay it.
@@ -300,6 +350,7 @@ export async function callTool(ctx: ToolContext, name: string, args: Record<stri
300350
cdp_url: view.agentCdpUrl,
301351
expires_at: view.expiresAt,
302352
region: view.region,
353+
proxy_country: proxyCountry ?? null,
303354
isolation: 'Fresh isolated browser. No local Chrome profile, cookies, or saved passwords.',
304355
human_handoff: 'Send live_view_url to the human for any login; do not ask them for credentials.',
305356
// False means the session is live but its usage has not been confirmed

services/mcp-server/src/wire.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, test } from 'bun:test';
2-
import { toSessionView } from './popcorn';
2+
import { createSessionRequestBody, toSessionView } from './popcorn';
33
import { RESOURCE_URI, issueAccessToken, resourceMatches, verifyAccessToken } from './oauth';
44

55
describe('control-plane wire contract', () => {
@@ -18,6 +18,27 @@ describe('control-plane wire contract', () => {
1818
expect(view.agentCdpUrl).toBe('wss://gw/cdp-internal/s/internal-token/');
1919
expect(toSessionView({ sessionId: 's', cdpUrl: 'wss://gw/cdp/s/restricted-token/' }).agentCdpUrl).toBeNull();
2020
});
21+
22+
test('session placement forwards nearest-first regions and a managed proxy country', () => {
23+
expect(createSessionRequestBody({
24+
sessionId: 's',
25+
ttlSeconds: 600,
26+
metadata: { purpose: 'test' },
27+
regions: ['asia-south1', 'us-central1'],
28+
proxyCountry: 'IN',
29+
})).toEqual({
30+
sessionId: 's',
31+
ttlSeconds: 600,
32+
metadata: { purpose: 'test' },
33+
regions: ['asia-south1', 'us-central1'],
34+
proxy: { country: 'IN' },
35+
});
36+
});
37+
38+
test('session placement omits proxy and region fields when not requested', () => {
39+
expect(createSessionRequestBody({ sessionId: 's', ttlSeconds: 600, metadata: {} }))
40+
.toEqual({ sessionId: 's', ttlSeconds: 600, metadata: {} });
41+
});
2142
});
2243

2344
describe('resource binding', () => {

0 commit comments

Comments
 (0)