Skip to content

Commit 8b152e3

Browse files
authored
fix: mount MCP activity route (#128)
## Summary\n- mount the existing MCP activity route at /v1/mcp/activity\n- share the same in-memory activity recorder with the MCP streamable HTTP route\n- add an app-level regression test so the owner activity endpoint no longer 404s\n\n## Validation\n- npx vitest run packages/server/src/app.test.ts\n- npm run lint\n- npm run build
1 parent 0e96ae7 commit 8b152e3

10 files changed

Lines changed: 459 additions & 51 deletions

File tree

packages/core/src/mcp/tools.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it, vi } from "vitest";
2+
import { McpActivityRecorder } from "./activity.js";
23
import { MCP_TOOLS } from "./tools.js";
34
import { handleMcpStreamableHttpRequest } from "./server.js";
45
import type { McpConnectionRecord } from "./types.js";
@@ -77,6 +78,41 @@ describe("mcp/tools", () => {
7778
expect(new TextEncoder().encode(body).byteLength).toBeLessThan(3000);
7879
});
7980

81+
it("records activity for MCP HTTP tool calls", async () => {
82+
const activityRecorder = new McpActivityRecorder();
83+
const response = await handleMcpStreamableHttpRequest(
84+
new Request("http://localhost/mcp/test-token", {
85+
method: "POST",
86+
headers: {
87+
"Content-Type": "application/json",
88+
Accept: "application/json, text/event-stream",
89+
},
90+
body: JSON.stringify({
91+
jsonrpc: "2.0",
92+
id: 1,
93+
method: "tools/call",
94+
params: {
95+
name: "list_granted_scopes",
96+
arguments: {},
97+
},
98+
}),
99+
}),
100+
{
101+
connection: createConnection(),
102+
readClient: createMinimalReadClient(),
103+
activityRecorder,
104+
},
105+
);
106+
107+
expect(response.status).toBe(200);
108+
const snapshot = activityRecorder.snapshot();
109+
expect(snapshot.total).toBe(1);
110+
expect(snapshot.events[0]).toMatchObject({
111+
status: "succeeded",
112+
tool: "list_granted_scopes",
113+
});
114+
});
115+
80116
it("MCP HTTP dispatcher returns a typed timeout for stalled tool handlers", async () => {
81117
const readClient = createMinimalReadClient({
82118
readScopeBlocks: vi.fn(
@@ -200,8 +236,10 @@ describe("mcp/tools", () => {
200236
nextCursor: "cursor-2",
201237
page: {
202238
cursor: "cursor-1",
239+
hasMore: true,
203240
maxBytes: 4096,
204-
timeoutMs: 30_000,
241+
nextCursor: "cursor-2",
242+
timeoutMs: 60_000,
205243
returnedBlocks: 1,
206244
},
207245
});

packages/core/src/mcp/tools.ts

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -103,21 +103,21 @@ function uniqueSources(connection: McpConnectionRecord): string[] {
103103
}
104104

105105
const DEFAULT_SEARCH_LIMIT = 5;
106-
const MAX_SEARCH_LIMIT = 20;
107-
const DEFAULT_SEARCH_MAX_SCOPES = 6;
108-
const MAX_SEARCH_SCOPES = 10;
106+
const MAX_SEARCH_LIMIT = 50;
107+
const DEFAULT_SEARCH_MAX_SCOPES = 10;
108+
const MAX_SEARCH_SCOPES = 50;
109109
const DEFAULT_SEARCH_TIMEOUT_MS = 30_000;
110-
const MAX_SEARCH_TIMEOUT_MS = 90_000;
110+
const MAX_SEARCH_TIMEOUT_MS = 300_000;
111111
const DEFAULT_SEARCH_DISCOVERY_TIMEOUT_MS = 2_000;
112-
const MAX_SEARCH_TOTAL_TIMEOUT_MS = 90_000;
113-
const DEFAULT_READ_SCOPE_MAX_BYTES = 16_384;
114-
const MAX_READ_SCOPE_MAX_BYTES = 65_536;
115-
const DEFAULT_READ_SCOPE_TIMEOUT_MS = 30_000;
116-
const MAX_READ_SCOPE_TIMEOUT_MS = 90_000;
117-
const DEFAULT_SEARCH_MAX_BYTES = 8_192;
118-
const MAX_SEARCH_MAX_BYTES = 32_768;
112+
const MAX_SEARCH_TOTAL_TIMEOUT_MS = 300_000;
113+
const DEFAULT_READ_SCOPE_MAX_BYTES = 256 * 1024;
114+
const MAX_READ_SCOPE_MAX_BYTES = 5 * 1024 * 1024;
115+
const DEFAULT_READ_SCOPE_TIMEOUT_MS = 60_000;
116+
const MAX_READ_SCOPE_TIMEOUT_MS = 300_000;
117+
const DEFAULT_SEARCH_MAX_BYTES = 64 * 1024;
118+
const MAX_SEARCH_MAX_BYTES = 1024 * 1024;
119119
const DEFAULT_SCOPE_METADATA_TIMEOUT_MS = 2_000;
120-
const SEARCH_MAX_PAGES_PER_SCOPE = 4;
120+
const SEARCH_MAX_PAGES_PER_SCOPE = 16;
121121
const SEARCH_QUERY_MAX_CHARS = 256;
122122
const SEARCH_SCOPE_MAX_CHARS = 128;
123123
const SEARCH_REQUESTED_SCOPES_LIMIT = MAX_SEARCH_SCOPES * 2;
@@ -599,7 +599,9 @@ const readScope: McpToolDefinition = {
599599
.min(1000)
600600
.max(MAX_READ_SCOPE_TIMEOUT_MS)
601601
.optional()
602-
.describe("Wall-clock read budget in ms. Capped at 90000 by the server."),
602+
.describe(
603+
"Wall-clock read budget in ms. Capped at 300000 by the server.",
604+
),
603605
},
604606
async handler(args, { connection, readClient }) {
605607
const scope = typeof args.scope === "string" ? args.scope : null;
@@ -648,16 +650,19 @@ const readScope: McpToolDefinition = {
648650
timeoutMs,
649651
`read blocks for ${scope}`,
650652
);
653+
const nextCursor = result.nextCursor ?? null;
651654
return textResult({
652655
scope,
653656
grantId: grant.grantId,
654657
collectedAt: result.collectedAt,
655658
contentKind: result.contentKind,
656659
blocks: result.blocks,
657-
nextCursor: result.nextCursor,
660+
...(result.nextCursor ? { nextCursor: result.nextCursor } : {}),
658661
warnings: result.warnings,
659662
page: {
660663
cursor: cursor ?? null,
664+
nextCursor,
665+
hasMore: Boolean(result.nextCursor),
661666
maxBytes,
662667
timeoutMs,
663668
returnedBlocks: result.blocks.length,
@@ -780,7 +785,7 @@ const searchPersonalContext: McpToolDefinition = {
780785
.min(1000)
781786
.max(MAX_SEARCH_TIMEOUT_MS)
782787
.optional()
783-
.describe("Wall-clock budget in ms. Capped at 90000 by the server."),
788+
.describe("Wall-clock budget in ms. Capped at 300000 by the server."),
784789
maxBytes: z.number().int().min(1).max(MAX_SEARCH_MAX_BYTES).optional(),
785790
},
786791
async handler(args, { connection, readClient }) {

packages/lite/src/storage.test.ts

Lines changed: 116 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,7 @@ describe("createPersistentPsLiteStorage", () => {
405405
const page = await storage.readScopeBlocks!(
406406
manifest.scope,
407407
manifest.collectedAt,
408-
{ cursor, maxBytes: 1 },
408+
{ cursor, maxBytes: 16 },
409409
);
410410
seen.push(...page.blocks.map((block) => block.value));
411411
cursor = page.nextCursor;
@@ -414,6 +414,60 @@ describe("createPersistentPsLiteStorage", () => {
414414
expect(seen).toEqual([{ index: 0 }, { index: 1 }, { index: 2 }]);
415415
});
416416

417+
it("pages through a single oversized persistent block", async () => {
418+
const storage = await createPersistentPsLiteStorage(
419+
{ kind: "indexeddb" },
420+
createMemoryPsLitePersistence(),
421+
);
422+
const scope = "chatgpt.conversations";
423+
const collectedAt = "2026-05-08T00:00:00.000Z";
424+
const value = "0123456789".repeat(600);
425+
const sizeBytes = new TextEncoder().encode(value).byteLength;
426+
await storage.writeBlockManifest?.(
427+
scope,
428+
collectedAt,
429+
{
430+
version: 1,
431+
scope,
432+
collectedAt,
433+
contentKind: "text",
434+
blocks: [
435+
{
436+
id: "block-000001",
437+
path: "$.data",
438+
mediaType: "text/plain",
439+
sizeBytes,
440+
},
441+
],
442+
warnings: [],
443+
},
444+
[
445+
{
446+
id: "block-000001",
447+
path: "$.data",
448+
mediaType: "text/plain",
449+
value,
450+
sizeBytes,
451+
},
452+
],
453+
);
454+
455+
const firstPage = await storage.readScopeBlocks!(scope, collectedAt, {
456+
maxBytes: 1024,
457+
});
458+
expect(firstPage.blocks[0]?.value).toBe(value.slice(0, 1024));
459+
expect(firstPage.blocks[0]?.truncated).toBe(true);
460+
expect(firstPage.nextCursor).toBeTruthy();
461+
462+
const secondPage = await storage.readScopeBlocks!(scope, collectedAt, {
463+
cursor: firstPage.nextCursor,
464+
maxBytes: 1024,
465+
});
466+
expect(secondPage.blocks[0]?.value).toBe(value.slice(1024, 2048));
467+
expect(secondPage.blocks[0]?.truncated).toBe(true);
468+
expect(secondPage.nextCursor).toBeTruthy();
469+
});
470+
417471
it("reports missing block manifests without reading raw envelopes", async () => {
418472
const files = new Map<string, ReturnType<typeof createDataFileEnvelope>>();
419473
const readEnvelope = vi.fn(async (path: string) => files.get(path) ?? null);
@@ -478,20 +532,74 @@ describe("createPersistentPsLiteStorage", () => {
478532
blocks: [{ value: { index: 0 } }],
479533
});
480534
});
535+
536+
it("pages through a single oversized memory block", async () => {
537+
const storage = createMemoryPsLiteStorage();
538+
const scope = "chatgpt.conversations";
539+
const collectedAt = "2026-05-08T00:00:00.000Z";
540+
const value = "abcdefghij".repeat(600);
541+
const sizeBytes = new TextEncoder().encode(value).byteLength;
542+
await storage.writeBlockManifest?.(
543+
scope,
544+
collectedAt,
545+
{
546+
version: 1,
547+
scope,
548+
collectedAt,
549+
contentKind: "text",
550+
blocks: [
551+
{
552+
id: "block-000001",
553+
path: "$.data",
554+
mediaType: "text/plain",
555+
sizeBytes,
556+
},
557+
],
558+
warnings: [],
559+
},
560+
[
561+
{
562+
id: "block-000001",
563+
path: "$.data",
564+
mediaType: "text/plain",
565+
value,
566+
sizeBytes,
567+
},
568+
],
569+
);
570+
571+
const firstPage = await storage.readScopeBlocks!(scope, collectedAt, {
572+
maxBytes: 1024,
573+
});
574+
expect(firstPage.blocks[0]?.value).toBe(value.slice(0, 1024));
575+
expect(firstPage.blocks[0]?.truncated).toBe(true);
576+
expect(firstPage.nextCursor).toBeTruthy();
577+
578+
const secondPage = await storage.readScopeBlocks!(scope, collectedAt, {
579+
cursor: firstPage.nextCursor,
580+
maxBytes: 1024,
581+
});
582+
expect(secondPage.blocks[0]?.value).toBe(value.slice(1024, 2048));
583+
expect(secondPage.blocks[0]?.truncated).toBe(true);
584+
expect(secondPage.nextCursor).toBeTruthy();
585+
});
481586
});
482587

483588
function blockFixture(
484589
scope: string,
485590
collectedAt: string,
486591
count: number,
487592
): { manifest: DataBlockManifest; blocks: DataScopeBlock[] } {
488-
const blocks = Array.from({ length: count }, (_, index) => ({
489-
id: `block-${index}`,
490-
path: `$.items[${index}]`,
491-
mediaType: "application/json",
492-
value: { index },
493-
sizeBytes: 10,
494-
}));
593+
const blocks = Array.from({ length: count }, (_, index) => {
594+
const value = { index };
595+
return {
596+
id: `block-${index}`,
597+
path: `$.items[${index}]`,
598+
mediaType: "application/json",
599+
value,
600+
sizeBytes: new TextEncoder().encode(JSON.stringify(value)).byteLength,
601+
};
602+
});
495603
return {
496604
manifest: {
497605
version: 1,

0 commit comments

Comments
 (0)