Skip to content

Commit 24cdc6d

Browse files
fix(local-mcp): harden and test the MCP relay authorization boundary
designateRelayedMcpServers/handleMcpRelayRequest, the requestId dedupe, and the expiry check had no coverage, so a regression in the per-run relay authorization check would have shipped silently. Added tests driving the real watch()/SSE entrypoint for: undesignated server, duplicate requestId, expired request, and fire-and-forget execution. Also fixed relayDesignations leaking one entry per relayed run for the life of the app session (never deleted); it now evicts once a run reaches a terminal status. Cleaned up stopWatcher's redundant double map lookup and named the dedupe LRU's magic 1000 cap.
1 parent 4e0cea6 commit 24cdc6d

2 files changed

Lines changed: 221 additions & 6 deletions

File tree

packages/core/src/cloud-task/cloud-task.test.ts

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3076,3 +3076,211 @@ describe("CloudTaskService", () => {
30763076
).toHaveLength(3);
30773077
});
30783078
});
3079+
3080+
describe("CloudTaskService MCP relay", () => {
3081+
let relayService: CloudTaskService;
3082+
let mcpRelayExecutor: {
3083+
execute: ReturnType<typeof vi.fn>;
3084+
closeRun: ReturnType<typeof vi.fn>;
3085+
};
3086+
3087+
beforeEach(() => {
3088+
const scopedLog = {
3089+
debug: vi.fn(),
3090+
info: vi.fn(),
3091+
warn: vi.fn(),
3092+
error: vi.fn(),
3093+
};
3094+
const loggerMock = { ...scopedLog, scope: vi.fn(() => scopedLog) };
3095+
const analyticsMock = { track: vi.fn() };
3096+
mcpRelayExecutor = {
3097+
execute: vi.fn(async () => ({
3098+
payload: { jsonrpc: "2.0", id: 1, result: {} },
3099+
})),
3100+
closeRun: vi.fn(async () => {}),
3101+
};
3102+
relayService = new CloudTaskService(
3103+
mockAuthService as never,
3104+
analyticsMock as never,
3105+
loggerMock,
3106+
mcpRelayExecutor as never,
3107+
);
3108+
3109+
mockNetFetch.mockReset();
3110+
mockStreamFetch.mockReset();
3111+
mockStreamTokenFetch.mockReset();
3112+
mockStreamTokenFetch.mockImplementation(() =>
3113+
Promise.resolve(
3114+
createJsonResponse({ token: "test-token", stream_base_url: null }),
3115+
),
3116+
);
3117+
mockAuthService.authenticatedFetch.mockReset();
3118+
vi.stubGlobal("fetch", fetchRouter);
3119+
mockAuthService.authenticatedFetch.mockImplementation(
3120+
async (input: string | Request, init?: RequestInit) => {
3121+
return fetchRouter(input, {
3122+
...init,
3123+
headers: {
3124+
...(init?.headers ?? {}),
3125+
Authorization: "Bearer token",
3126+
},
3127+
});
3128+
},
3129+
);
3130+
});
3131+
3132+
afterEach(() => {
3133+
relayService.unwatchAll();
3134+
vi.unstubAllGlobals();
3135+
});
3136+
3137+
function mcpRequestSseLine(
3138+
overrides: Partial<{
3139+
requestId: string;
3140+
server: string;
3141+
expiresAt: string;
3142+
}> = {},
3143+
): string {
3144+
const event = {
3145+
type: "mcp_request",
3146+
requestId: overrides.requestId ?? "req-1",
3147+
server: overrides.server ?? "slack",
3148+
payload: { jsonrpc: "2.0", id: 1, method: "initialize" },
3149+
expiresAt:
3150+
overrides.expiresAt ?? new Date(Date.now() + 60_000).toISOString(),
3151+
};
3152+
return `data: ${JSON.stringify(event)}\n\n`;
3153+
}
3154+
3155+
function watchRun(runId: string): void {
3156+
mockNetFetch.mockResolvedValueOnce(
3157+
createJsonResponse({
3158+
id: runId,
3159+
status: "in_progress",
3160+
stage: null,
3161+
output: null,
3162+
error_message: null,
3163+
branch: "main",
3164+
updated_at: "2026-01-01T00:00:00Z",
3165+
}),
3166+
);
3167+
relayService.watch({
3168+
taskId: "task-1",
3169+
runId,
3170+
apiHost: "https://app.example.com",
3171+
teamId: 2,
3172+
resumeFromEntryCount: 0,
3173+
});
3174+
}
3175+
3176+
it("drops a relay request for a server the run never designated", async () => {
3177+
mockStreamFetch.mockResolvedValueOnce(
3178+
createOpenSseResponse(mcpRequestSseLine({ server: "slack" })),
3179+
);
3180+
relayService.designateRelayedMcpServers("run-1", ["grafana"]);
3181+
watchRun("run-1");
3182+
3183+
await waitFor(() => mockStreamFetch.mock.calls.length > 0);
3184+
await new Promise((resolve) => setTimeout(resolve, 20));
3185+
3186+
expect(mcpRelayExecutor.execute).not.toHaveBeenCalled();
3187+
});
3188+
3189+
it("executes a designated relay request exactly once and posts mcp_response", async () => {
3190+
mockStreamFetch.mockResolvedValueOnce(
3191+
createOpenSseResponse(
3192+
mcpRequestSseLine({ requestId: "req-1", server: "slack" }) +
3193+
mcpRequestSseLine({ requestId: "req-1", server: "slack" }),
3194+
),
3195+
);
3196+
mockNetFetch.mockResolvedValueOnce(createJsonResponse({ result: {} }));
3197+
relayService.designateRelayedMcpServers("run-1", ["slack"]);
3198+
watchRun("run-1");
3199+
3200+
await waitFor(() => mcpRelayExecutor.execute.mock.calls.length > 0);
3201+
// The duplicate line above must not trigger a second execution.
3202+
await new Promise((resolve) => setTimeout(resolve, 20));
3203+
3204+
expect(mcpRelayExecutor.execute).toHaveBeenCalledOnce();
3205+
expect(mcpRelayExecutor.execute).toHaveBeenCalledWith(
3206+
"run-1",
3207+
"slack",
3208+
expect.objectContaining({ method: "initialize" }),
3209+
);
3210+
3211+
await waitFor(() =>
3212+
mockNetFetch.mock.calls.some(([url]) =>
3213+
(url as string).includes("/command/"),
3214+
),
3215+
);
3216+
const commandCall = mockNetFetch.mock.calls.find(([url]) =>
3217+
(url as string).includes("/command/"),
3218+
);
3219+
const body = JSON.parse((commandCall?.[1] as RequestInit).body as string);
3220+
expect(body).toEqual(
3221+
expect.objectContaining({
3222+
method: "mcp_response",
3223+
params: {
3224+
requestId: "req-1",
3225+
server: "slack",
3226+
payload: { jsonrpc: "2.0", id: 1, result: {} },
3227+
},
3228+
}),
3229+
);
3230+
});
3231+
3232+
it("evicts a run's relay designation once the run reaches a terminal status", async () => {
3233+
mockStreamFetch.mockResolvedValueOnce(
3234+
createOpenSseResponse(
3235+
`data: ${JSON.stringify({
3236+
type: "task_run_state",
3237+
status: "completed",
3238+
updated_at: "2026-01-01T00:00:01Z",
3239+
})}\n\n${mcpRequestSseLine({ server: "slack" })}`,
3240+
),
3241+
);
3242+
relayService.designateRelayedMcpServers("run-1", ["slack"]);
3243+
watchRun("run-1");
3244+
3245+
await waitFor(() => mockStreamFetch.mock.calls.length > 0);
3246+
await new Promise((resolve) => setTimeout(resolve, 20));
3247+
3248+
expect(mcpRelayExecutor.execute).not.toHaveBeenCalled();
3249+
});
3250+
3251+
it("drops an expired relay request without executing it", async () => {
3252+
mockStreamFetch.mockResolvedValueOnce(
3253+
createOpenSseResponse(
3254+
mcpRequestSseLine({
3255+
server: "slack",
3256+
expiresAt: new Date(Date.now() - 60_000).toISOString(),
3257+
}),
3258+
),
3259+
);
3260+
relayService.designateRelayedMcpServers("run-1", ["slack"]);
3261+
watchRun("run-1");
3262+
3263+
await waitFor(() => mockStreamFetch.mock.calls.length > 0);
3264+
await new Promise((resolve) => setTimeout(resolve, 20));
3265+
3266+
expect(mcpRelayExecutor.execute).not.toHaveBeenCalled();
3267+
});
3268+
3269+
it("sends no mcp_response for a fire-and-forget relay execution", async () => {
3270+
mcpRelayExecutor.execute.mockResolvedValueOnce({});
3271+
mockStreamFetch.mockResolvedValueOnce(
3272+
createOpenSseResponse(mcpRequestSseLine({ server: "slack" })),
3273+
);
3274+
relayService.designateRelayedMcpServers("run-1", ["slack"]);
3275+
watchRun("run-1");
3276+
3277+
await waitFor(() => mcpRelayExecutor.execute.mock.calls.length > 0);
3278+
await new Promise((resolve) => setTimeout(resolve, 20));
3279+
3280+
expect(
3281+
mockNetFetch.mock.calls.some(([url]) =>
3282+
(url as string).includes("/command/"),
3283+
),
3284+
).toBe(false);
3285+
});
3286+
});

packages/core/src/cloud-task/cloud-task.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const SSE_HEALTHY_CONNECTION_MS = 60_000;
4242
const EVENT_BATCH_FLUSH_MS = 16;
4343
const EVENT_BATCH_MAX_SIZE = 50;
4444
const SESSION_LOG_PAGE_LIMIT = 5_000;
45+
const MAX_HANDLED_RELAY_REQUEST_IDS = 1_000;
4546

4647
// Authoritative end-of-stream sentinel, matched on the SSE event name (event.event, not data.type).
4748
// The client stops on it without consulting run status.
@@ -400,7 +401,7 @@ export class CloudTaskService extends TypedEventEmitter<CloudTaskEvents> {
400401
private markRelayRequestHandled(requestId: string): void {
401402
this.handledRelayRequestIds.add(requestId);
402403
this.handledRelayRequestOrder.push(requestId);
403-
if (this.handledRelayRequestOrder.length > 1000) {
404+
if (this.handledRelayRequestOrder.length > MAX_HANDLED_RELAY_REQUEST_IDS) {
404405
const evicted = this.handledRelayRequestOrder.shift();
405406
if (evicted) this.handledRelayRequestIds.delete(evicted);
406407
}
@@ -771,15 +772,15 @@ export class CloudTaskService extends TypedEventEmitter<CloudTaskEvents> {
771772
}
772773

773774
private stopWatcher(key: string): void {
774-
const stopping = this.watchers.get(key);
775-
if (stopping && this.relayDesignations.has(stopping.runId)) {
775+
const watcher = this.watchers.get(key);
776+
if (!watcher) return;
777+
778+
if (this.relayDesignations.has(watcher.runId)) {
776779
// No watcher → no relay events → nothing executes; release the run's
777780
// live server connections (stdio children included). They reopen
778781
// lazily if the run is watched again.
779-
void this.mcpRelayExecutor?.closeRun?.(stopping.runId).catch(() => {});
782+
void this.mcpRelayExecutor?.closeRun?.(watcher.runId).catch(() => {});
780783
}
781-
const watcher = this.watchers.get(key);
782-
if (!watcher) return;
783784

784785
watcher.sseAbortController?.abort();
785786

@@ -1801,6 +1802,12 @@ export class CloudTaskService extends TypedEventEmitter<CloudTaskEvents> {
18011802
watcher.lastStatusUpdatedAt = updatedAt;
18021803
}
18031804

1805+
// A terminal run gets no further relay requests; drop its designation so
1806+
// the map doesn't grow for the lifetime of the app session.
1807+
if (isTerminalStatus(watcher.lastStatus)) {
1808+
this.relayDesignations.delete(watcher.runId);
1809+
}
1810+
18041811
return changed;
18051812
}
18061813

0 commit comments

Comments
 (0)