Skip to content

Commit cd38774

Browse files
maciejwitowskiclaudetnunamak
authored
fix(mcp): steer read_scope vs search by scope size so the client self-selects (#131)
## Problem When testing the MCP connect flow against `app-dev.vana.org`, Claude reliably **hung on "Working"** after issuing `read_scope` on large scopes (`spotify.playlists` 546 KB, `chatgpt.conversations` 1.6 MB), while small scopes (`linkedin.profile` 39 KB) returned instantly. The reads *succeeded* on the PS side — but a full read of a large scope comes back as many 256 KB pages the client must walk via `nextCursor`, and that pagination loop stalls the agent. The client had **no reliable signal** to choose read vs search, and the signal it did have was actively misleading: `list_granted_scopes` emitted `searchRecommended: false` for large/huge scopes. Under that field name a model reads it as *"search not recommended → read the whole thing"* — i.e. it steers toward the exact slow path. (The flag actually meant "eligible for un-named broad search," not "should you search this.") The goal: the client should pick the right tool **by itself**, no operator hand-holding. ## Changes - **Directive routing field.** `isSearchRecommended` → `recommendAccessForScope`, emitting `recommendedAccess: "read" | "search" | "wait"` per scope: - tiny/small/medium ready → `read` - large/huge / unknown size → `search` (+ reason: name the scope explicitly; a full read returns many pages via nextCursor) - still indexing → `wait` - **In-band steer at point of action.** `read_scope` now fetches lightweight index-only metadata (best-effort, never blocks the read) and attaches a `guidance` hint **only when the scope is large enough that search is better** — so even if the model skips `list_granted_scopes`, the first page tells it to pivot. Small reads get no extra noise. - **Sharper tool descriptions.** `list_granted_scopes` ("Call first, then follow recommendedAccess"); `read_scope` ("best for tiny/small/medium; for large/huge prefer search_personal_context"); `search_personal_context` ("preferred way to pull from large/huge scopes… name scopes explicitly"). ## Scope / non-goals This is a tool-definition + advisory-signal fix. It does **not** address the deeper pagination-completion issue (large-scope `nextCursor` loops through the browser PS); that's runtime/storage and tracked separately. ## Testing - `npx vitest run packages/core/src/mcp/tools.test.ts packages/core/src/mcp/read-client.test.ts` → **22/22 pass** (updated `list_granted_scopes` expectations to `recommendedAccess`; added coverage for the `read_scope` guidance steer — present for large scopes, absent for small). - `npm run build`, `npm run lint` (tsc --noEmit), `npm run format:check` → clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Tim Nunamaker <tnunamak@gmail.com>
1 parent 4dc329c commit cd38774

5 files changed

Lines changed: 258 additions & 35 deletions

File tree

packages/core/src/mcp/read-client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export interface McpDataReadClient {
5353
/**
5454
* Return lightweight planning metadata for a scope using the storage index
5555
* only — no network call, no block read, no auth. Used by list_granted_scopes
56-
* to populate sizeBytes/sizeClass/searchRecommended without reading data.
56+
* to populate sizeBytes/sizeClass/recommendedAccess without reading data.
5757
* Returns null when the scope has no local index entry.
5858
*/
5959
getScopeMetadata(scope: string): Promise<McpScopeMetadata | null>;

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

Lines changed: 104 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,103 @@ describe("mcp/tools", () => {
245245
});
246246
});
247247

248+
it("read_scope attaches search guidance for large scopes and omits it for small ones", async () => {
249+
const block = {
250+
id: "b1",
251+
path: "$.items[0:1]",
252+
mediaType: "application/json",
253+
value: { id: 1 },
254+
sizeBytes: 20,
255+
};
256+
const makeClient = (sizeBytes: number) =>
257+
createMinimalReadClient({
258+
getScopeMetadata: vi.fn(async (scope: string) => ({
259+
scope,
260+
collectedAt: "2026-06-05T00:00:00Z",
261+
sizeBytes,
262+
hasBlocks: true,
263+
})),
264+
readScopeBlocks: vi.fn().mockResolvedValue({
265+
scope: "instagram.profile",
266+
collectedAt: "2026-06-05T00:00:00Z",
267+
contentKind: "json",
268+
blocks: [block],
269+
nextCursor: "cursor-2",
270+
warnings: [],
271+
}),
272+
});
273+
274+
// Large scope (~5MB) → guidance steering to search, read still served.
275+
const large = await getTool("read_scope").handler(
276+
{ scope: "instagram.profile" },
277+
{
278+
connection: createConnection(),
279+
readClient: makeClient(5_000_000) as never,
280+
},
281+
);
282+
const largePayload = JSON.parse(large.content[0].text);
283+
expect(largePayload.blocks).toHaveLength(1);
284+
expect(largePayload.guidance).toMatchObject({
285+
recommendedAccess: "search",
286+
sizeClass: "large",
287+
});
288+
289+
// Small scope (5KB) → no guidance noise.
290+
const small = await getTool("read_scope").handler(
291+
{ scope: "instagram.profile" },
292+
{
293+
connection: createConnection(),
294+
readClient: makeClient(5_000) as never,
295+
},
296+
);
297+
expect(JSON.parse(small.content[0].text).guidance).toBeUndefined();
298+
});
299+
300+
it("read_scope does not wait for stalled metadata guidance", async () => {
301+
const readClient = createMinimalReadClient({
302+
getScopeMetadata: vi.fn(
303+
() =>
304+
new Promise(() => {
305+
// Deliberately never resolves: guidance is advisory only.
306+
}),
307+
),
308+
readScopeBlocks: vi.fn().mockResolvedValue({
309+
scope: "instagram.profile",
310+
collectedAt: "2026-06-05T00:00:00Z",
311+
contentKind: "json",
312+
blocks: [
313+
{
314+
id: "b1",
315+
path: "$.profile",
316+
mediaType: "application/json",
317+
value: { username: "tim" },
318+
sizeBytes: 18,
319+
},
320+
],
321+
warnings: [],
322+
}),
323+
});
324+
325+
const started = Date.now();
326+
const result = await getTool("read_scope").handler(
327+
{ scope: "instagram.profile", timeoutMs: 1000 },
328+
{
329+
connection: createConnection(),
330+
readClient: readClient as never,
331+
},
332+
);
333+
const elapsed = Date.now() - started;
334+
335+
expect(elapsed).toBeLessThan(500);
336+
expect(result.isError).toBeUndefined();
337+
expect(JSON.parse(result.content[0].text)).toMatchObject({
338+
scope: "instagram.profile",
339+
page: {
340+
returnedBlocks: 1,
341+
},
342+
});
343+
});
344+
248345
it("read_scope returns a typed timeout instead of hanging on a stalled read", async () => {
249346
const readClient = createMinimalReadClient({
250347
readScopeBlocks: vi.fn(
@@ -743,15 +840,15 @@ describe("mcp/tools", () => {
743840
payload.scopes.map((s: { scope: string }) => [s.scope, s]),
744841
);
745842

746-
// instagram.profile: 5KB → tiny → searchRecommended: true
843+
// instagram.profile: 5KB → tiny → read the whole scope directly
747844
expect(byScope["instagram.profile"].dataStatus).toBe("ready");
748-
expect(byScope["instagram.profile"].searchRecommended).toBe(true);
845+
expect(byScope["instagram.profile"].recommendedAccess).toBe("read");
749846
expect(byScope["instagram.profile"].sizeBytes).toBe(5_000);
750847
expect(byScope["instagram.profile"].sizeClass).toBe("tiny");
751848

752-
// chatgpt.history: 15MB → huge → searchRecommended: false
849+
// chatgpt.history: 15MB → huge → steer to search instead of a full read
753850
expect(byScope["chatgpt.history"].dataStatus).toBe("ready");
754-
expect(byScope["chatgpt.history"].searchRecommended).toBe(false);
851+
expect(byScope["chatgpt.history"].recommendedAccess).toBe("search");
755852
expect(byScope["chatgpt.history"].sizeClass).toBe("huge");
756853
expect(byScope["chatgpt.history"].reason).toBeDefined();
757854
});
@@ -769,7 +866,7 @@ describe("mcp/tools", () => {
769866
const payload = JSON.parse(result.content[0].text);
770867
for (const scope of payload.scopes) {
771868
expect(scope.dataStatus).toBe("needs_refresh");
772-
expect(scope.searchRecommended).toBe(false);
869+
expect(scope.recommendedAccess).toBe("wait");
773870
}
774871
});
775872

@@ -801,7 +898,7 @@ describe("mcp/tools", () => {
801898
(scope: { scope: string }) => scope.scope === "instagram.profile",
802899
);
803900
expect(profile.dataStatus).toBe("indexing");
804-
expect(profile.searchRecommended).toBe(false);
901+
expect(profile.recommendedAccess).toBe("wait");
805902
expect(profile.reason).toContain("indexing");
806903
});
807904

@@ -836,7 +933,7 @@ describe("mcp/tools", () => {
836933
expect.objectContaining({
837934
scope: "instagram.profile",
838935
dataStatus: "indexing",
839-
searchRecommended: false,
936+
recommendedAccess: "wait",
840937
}),
841938
]);
842939
});

packages/core/src/mcp/tools.ts

Lines changed: 73 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -359,30 +359,42 @@ function classifySizeBytes(sizeBytes: number | undefined): SizeClass {
359359
return "huge";
360360
}
361361

362-
function isSearchRecommended(
362+
type RecommendedAccess = "read" | "search" | "wait";
363+
364+
// Directive guidance the MCP client (e.g. Claude) uses to pick read_scope vs
365+
// search_personal_context for a scope WITHOUT operator intervention. The signal
366+
// must be unambiguous: a misnamed flag here (the old `searchRecommended: false`
367+
// on large scopes) reads to a model as "don't search → read the whole thing",
368+
// which is exactly the slow path — a full read of a large scope returns many
369+
// pages it must walk via nextCursor and tends to stall the agent loop.
370+
function recommendAccessForScope(
363371
sizeClass: SizeClass,
364372
hasBlocks: boolean,
365-
): { recommended: boolean; reason?: string } {
373+
): { recommendedAccess: RecommendedAccess; reason: string } {
366374
if (!hasBlocks) {
367375
return {
368-
recommended: false,
376+
recommendedAccess: "wait",
369377
reason:
370-
"bounded block data is still indexing or unavailable; retry shortly",
378+
"bounded block data is still indexing or unavailable; retry shortly before reading or searching",
371379
};
372380
}
373381
if (sizeClass === "large" || sizeClass === "huge") {
374382
return {
375-
recommended: false,
376-
reason: `scope is ${sizeClass}; use explicit scopes with cursor to search safely`,
383+
recommendedAccess: "search",
384+
reason: `scope is ${sizeClass}; prefer search_personal_context and name this scope explicitly. A full read_scope returns many pages you must walk via nextCursor.`,
377385
};
378386
}
379387
if (sizeClass === "unknown") {
380388
return {
381-
recommended: false,
382-
reason: "scope size unknown; pass explicitly to search",
389+
recommendedAccess: "search",
390+
reason:
391+
"scope size is unknown; prefer search_personal_context and name this scope explicitly",
383392
};
384393
}
385-
return { recommended: true };
394+
return {
395+
recommendedAccess: "read",
396+
reason: `scope is ${sizeClass}; read_scope returns it in one or few pages. search_personal_context also works for targeted lookups.`,
397+
};
386398
}
387399

388400
async function resolveSearchScopes({
@@ -523,7 +535,7 @@ const listGrantedScopes: McpToolDefinition = {
523535
name: "list_granted_scopes",
524536
title: "List granted scopes",
525537
description:
526-
"List granted scopes with dataStatus, sizeClass, searchRecommended, and sizeBytes. Call first.",
538+
"List granted scopes with dataStatus, sizeClass, sizeBytes, and recommendedAccess ('read' for small scopes, 'search' for large/huge scopes, 'wait' while indexing). Call first, then follow recommendedAccess.",
527539
inputSchema: {},
528540
async handler(_args, { connection, readClient }) {
529541
const grantedScopes = uniqueScopes(connection);
@@ -543,7 +555,7 @@ const listGrantedScopes: McpToolDefinition = {
543555
source: scope.split(".")[0],
544556
dataStatus: "indexing" as const,
545557
sizeClass: "unknown" as SizeClass,
546-
searchRecommended: false,
558+
recommendedAccess: "wait" as RecommendedAccess,
547559
reason:
548560
"scope metadata is still indexing or temporarily unavailable; retry shortly",
549561
};
@@ -554,12 +566,12 @@ const listGrantedScopes: McpToolDefinition = {
554566
scope,
555567
dataStatus: "needs_refresh" as const,
556568
sizeClass: "unknown" as SizeClass,
557-
searchRecommended: false,
569+
recommendedAccess: "wait" as RecommendedAccess,
558570
reason: "no local data found; refresh your data connection",
559571
};
560572
}
561573
const sizeClass = classifySizeBytes(meta.sizeBytes);
562-
const { recommended, reason } = isSearchRecommended(
574+
const { recommendedAccess, reason } = recommendAccessForScope(
563575
sizeClass,
564576
meta.hasBlocks,
565577
);
@@ -572,8 +584,8 @@ const listGrantedScopes: McpToolDefinition = {
572584
: ("indexing" as const),
573585
sizeBytes: meta.sizeBytes,
574586
sizeClass,
575-
searchRecommended: recommended,
576-
...(reason ? { reason } : {}),
587+
recommendedAccess,
588+
reason,
577589
};
578590
}),
579591
);
@@ -585,7 +597,7 @@ const readScope: McpToolDefinition = {
585597
name: "read_scope",
586598
title: "Read scope",
587599
description:
588-
"Read one approved scope as bounded blocks. Page with nextCursor for large scopes.",
600+
"Read one full approved scope as bounded blocks. Best for tiny/small/medium scopes (see sizeClass from list_granted_scopes). For large/huge scopes prefer search_personal_context — a full read returns many pages you must walk via nextCursor and is slow. The response includes a `guidance` hint when a scope is large enough that search is the better tool.",
589601
inputSchema: {
590602
scope: z
591603
.string()
@@ -639,6 +651,49 @@ const readScope: McpToolDefinition = {
639651
1000,
640652
MAX_READ_SCOPE_TIMEOUT_MS,
641653
);
654+
655+
// In-band steer: the model may call read_scope on a large scope without
656+
// first consulting list_granted_scopes. Fetch lightweight planning metadata
657+
// (storage-index only, no network/auth) opportunistically so we can attach a
658+
// `guidance` hint when it is already available. This is advisory only: it
659+
// must never delay the read or consume the read timeout budget.
660+
let guidance:
661+
| {
662+
sizeClass: SizeClass;
663+
sizeBytes?: number;
664+
recommendedAccess: RecommendedAccess;
665+
reason: string;
666+
}
667+
| undefined;
668+
if (typeof readClient.getScopeMetadata === "function") {
669+
void withTimeout(
670+
readClient.getScopeMetadata(scope),
671+
DEFAULT_SCOPE_METADATA_TIMEOUT_MS,
672+
`scope metadata for ${scope}`,
673+
)
674+
.then((meta) => {
675+
if (!meta) return;
676+
const sizeClass = classifySizeBytes(meta.sizeBytes);
677+
const recommendation = recommendAccessForScope(
678+
sizeClass,
679+
meta.hasBlocks,
680+
);
681+
// Only steer when search is the better tool; a normal small read gets
682+
// no extra noise.
683+
if (recommendation.recommendedAccess === "search") {
684+
guidance = {
685+
sizeClass,
686+
sizeBytes: meta.sizeBytes,
687+
recommendedAccess: recommendation.recommendedAccess,
688+
reason: recommendation.reason,
689+
};
690+
}
691+
})
692+
.catch(() => {
693+
// Advisory only — serve the read without guidance.
694+
});
695+
}
696+
642697
try {
643698
const result = await withTimeout(
644699
readClient.readScopeBlocks({
@@ -658,6 +713,7 @@ const readScope: McpToolDefinition = {
658713
contentKind: result.contentKind,
659714
blocks: result.blocks,
660715
...(result.nextCursor ? { nextCursor: result.nextCursor } : {}),
716+
...(guidance ? { guidance } : {}),
661717
warnings: result.warnings,
662718
page: {
663719
cursor: cursor ?? null,
@@ -762,7 +818,7 @@ const searchPersonalContext: McpToolDefinition = {
762818
name: "search_personal_context",
763819
title: "Search personal context",
764820
description:
765-
"Search approved scopes. Omit scopes for small ready data; pass scopes to target. Continue with nextSearchCursor.",
821+
"Search approved scopes by keyword — the preferred way to pull from large/huge scopes without reading every page. Omit scopes to sweep all small ready data; name scopes (including large ones) to target them. Continue with nextSearchCursor.",
766822
inputSchema: {
767823
query: z.string().min(1).max(SEARCH_QUERY_MAX_CHARS),
768824
scopes: z

0 commit comments

Comments
 (0)