perf(mcp): smaller results and parallel type walks (perf audit P4)

- get_session_diagnostics no longer embeds the ~5k-character ZIP layout per ticket; it points at
  the static diag_zip_layout resource (read once per conversation). Prompt text updated.
- get_resource accepts `section` to read one top-level part of the rule-authoring trio; the
  section list in the description is derived from the live catalogs. RESOURCE_NAMES now lives in
  resource-catalog.ts as the single source of the name enum and the ResourceName type (the inline
  z.enum baseline entry is retired with it).
- search_knowledge no longer repeats type/title inside metadata.
- search_events walks up to three candidate event types concurrently (TYPE_WALK_CONCURRENCY);
  pages within a type stay sequential, results stay in candidate order, budget/truncation
  semantics unchanged.

Deliberately not done (operator decision): a longer undici keep-alive (runtime dependency for a
single-digit-ms in-region gain; the backend sends no Keep-Alive hint) and precomputed tool JSON
schemas (7-12 ms per request on a dev CPU, would need SDK-registration patching).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-02 03:13:17 +02:00
parent ea7b850ef0
commit 7288220341
10 changed files with 222 additions and 54 deletions
+1 -1
Submodule docs updated: 63dc1699e1...0857248682
@@ -87,7 +87,7 @@ describe('get_session_diagnostics tool', () => {
expect(apiFetchMock).toHaveBeenCalledTimes(1); // fails before minting a ticket
});
it('mints a ticket and returns an absolute downloadUrl + zipMap', async () => {
it('mints a ticket and returns an absolute downloadUrl + a pointer to the layout resource', async () => {
apiFetchMock
.mockResolvedValueOnce(sessionEnvelope('AgentDiagnostics-x.zip'))
.mockResolvedValueOnce({
@@ -106,8 +106,9 @@ describe('get_session_diagnostics tool', () => {
expect(payload.downloadUrl).toBe(`${API_BASE_URL}/api/diagnostics/download?t=ABC`);
expect(payload.sizeBytes).toBe(12345);
expect(payload.destination).toBe('CustomerSas');
expect(payload.zipMap).toBeDefined();
expect(payload.zipMap.files.length).toBeGreaterThan(0);
// The ~5k-character layout is a static resource, read once per conversation — not repeated per ticket.
expect(payload.zipMap).toBeUndefined();
expect(payload.zipLayoutResource).toContain('diag_zip_layout');
expect(payload.instructions).toMatch(/no auth header/i);
});
@@ -0,0 +1,66 @@
/**
* get_resource sections: the rule-authoring trio is tens of KB together, so a caller can read one
* top-level part at a time. The section list in the tool description is derived from the live
* catalogs, and an unknown section is a clear error that names the available ones.
*/
import { describe, it, expect } from 'vitest';
import { McpServer } from '@modelcontextprotocol/server';
import { registerTools } from '../tools.js';
import { runWithCaller } from '../client.js';
import { getResourceContent } from '../resource-catalog.js';
type ToolHandler = (args: Record<string, unknown>, extra: unknown) => Promise<{
content?: Array<{ type: string; text?: string }>;
isError?: boolean;
}>;
const GA = { token: 'ga', isGlobalAdmin: true };
const extra = { signal: new AbortController().signal };
function getResourceTool(): { handler: ToolHandler; description: string } {
const server = new McpServer({ name: 'test', version: '0.0.0' });
registerTools(server, undefined, undefined, undefined, true, true, false);
const registry = (server as unknown as { _registeredTools: Record<string, { handler: ToolHandler; description: string }> })._registeredTools;
return registry.get_resource;
}
function text(r: { content?: Array<{ text?: string }> }): string {
return (r.content ?? []).map((c) => c.text ?? '').join('');
}
describe('get_resource section', () => {
it('returns one top-level part of a large resource', async () => {
const { handler } = getResourceTool();
const res = await runWithCaller(GA, () => handler({ name: 'rule_schemas', section: 'analyzeRuleSchema' }, extra));
const body = JSON.parse(text(res)) as { resource: string; section: string; content: Record<string, unknown> };
expect(res.isError).toBeFalsy();
expect(body.resource).toBe('rule_schemas');
expect(body.section).toBe('analyzeRuleSchema');
expect(body.content).toEqual((getResourceContent('rule_schemas') as Record<string, unknown>).analyzeRuleSchema);
// Strictly smaller than the whole resource — the point of the feature.
expect(text(res).length).toBeLessThan(JSON.stringify(getResourceContent('rule_schemas')).length);
});
it('without a section the whole resource is returned unchanged', async () => {
const { handler } = getResourceTool();
const res = await runWithCaller(GA, () => handler({ name: 'rule_guardrails' }, extra));
expect(JSON.parse(text(res))).toEqual(getResourceContent('rule_guardrails'));
});
it('names the available sections on an unknown section', async () => {
const { handler } = getResourceTool();
const res = await runWithCaller(GA, () => handler({ name: 'rule_authoring_guide', section: 'nope' }, extra));
expect(res.isError).toBe(true);
expect(text(res)).toContain('analyzeRules');
expect(text(res)).toContain('gatherRules');
});
it('lists every object-shaped resource with its sections in the description', () => {
const { description } = getResourceTool();
for (const name of ['rule_authoring_guide', 'rule_schemas', 'rule_guardrails', 'diag_zip_layout'] as const) {
const keys = Object.keys(getResourceContent(name) as Record<string, unknown>);
expect(description).toContain(`${name} → ${keys.join(', ')}`);
}
});
});
@@ -815,3 +815,36 @@ describe('scanLexical (error-code fallback scan)', () => {
expect(scanLexical(docs, [])).toEqual([]);
});
});
describe('fetchEventsViaIndex concurrency', () => {
it('walks up to 3 candidate types at once and still returns events in candidate order', async () => {
let inFlight = 0;
let peak = 0;
// The first candidate is the slowest so a completion-order merge would misorder the result.
const delays: Record<string, number> = { a: 30, b: 10, c: 5, d: 1 };
const fetcher = async (path: string): Promise<Page> => {
const type = new URL(`http://x${path}`).searchParams.get('eventType') ?? '';
inFlight++;
peak = Math.max(peak, inFlight);
await new Promise((resolve) => setTimeout(resolve, delays[type] ?? 1));
inFlight--;
return { events: [{ sessionId: `s-${type}`, eventType: type }] };
};
const res = await fetchEventsViaIndex(['a', 'b', 'c', 'd'], BASE, undefined, { maxPagesPerType: 10, wallClockMs: 60_000 }, fetcher);
expect(peak).toBeGreaterThanOrEqual(2);
expect(peak).toBeLessThanOrEqual(3);
expect(res.truncated).toBe(false);
expect(res.events.map((e) => e._sessionId)).toEqual(['s-a', 's-b', 's-c', 's-d']);
});
it('a single type walk is unaffected by the fan-out (concurrency capped at the candidate count)', async () => {
let calls = 0;
const fetcher = async (): Promise<Page> => { calls++; return { events: [{ sessionId: 's1' }] }; };
const res = await fetchEventsViaIndex(['only'], BASE, undefined, { maxPagesPerType: 10, wallClockMs: 60_000 }, fetcher, Date.now, 3);
expect(calls).toBe(1);
expect(res.events).toHaveLength(1);
});
});
@@ -48,8 +48,6 @@ const ALLOWED_INLINE_ENUMS: Record<string, string> = {
"'low', 'medium', 'high', 'critical'":
'CVE overall-risk filter. Risk ranking lives inline in TableStorageService.AgentApi; ' +
'no C# constants class.',
"'event_types', 'ops_event_types', 'device_properties', 'diag_zip_layout', 'rule_authoring_guide', 'rule_schemas', 'rule_guardrails'":
'get_resource names — the MCP resource catalog itself, pinned by ResourceName in resource-catalog.ts.',
};
/** Every inline z.enum([...]) literal across the tool sources, with its file. */
@@ -81,7 +81,7 @@ export function registerPrompts(server: McpServer, ga: boolean): void {
'first — status, noise-filtered timeline, stats, rule analysis in one shot.\n' +
`2. Call get_session_diagnostics(sessionId="${sessionId}"${tenantId ? `, tenantId="${tenantId}"` : ''}). ` +
'If available=true, DOWNLOAD the ZIP from downloadUrl using your local tools (no auth header — ' +
'it is a short-lived signed ticket), unzip it locally, and read files per the returned zipMap: ' +
'it is a short-lived signed ticket), unzip it locally, and read files in the priority order of the diag_zip_layout resource (get_resource(name="diag_zip_layout")): ' +
'start with AgentState/final-status.json and the agent log (grep [ERROR]/[WARN] first), then ' +
'journal/signal logs. AppWorkload*.log can be hundreds of MB → grep only, never read whole. ' +
'If available=false, note it and continue with backend data only.\n' +
@@ -421,14 +421,21 @@ export const OPS_EVENTS_CATALOG = {
eventTypes: OPS_EVENT_TYPES,
} as const;
export type ResourceName =
| 'event_types'
| 'ops_event_types'
| 'device_properties'
| 'diag_zip_layout'
| 'rule_authoring_guide'
| 'rule_schemas'
| 'rule_guardrails';
/**
* The MCP resource catalog — single source for the get_resource `name` enum and the
* ResourceName type (an MCP-local vocabulary, not a backend one).
*/
export const RESOURCE_NAMES = [
'event_types',
'ops_event_types',
'device_properties',
'diag_zip_layout',
'rule_authoring_guide',
'rule_schemas',
'rule_guardrails',
] as const;
export type ResourceName = (typeof RESOURCE_NAMES)[number];
export function getResourceContent(name: ResourceName): unknown {
switch (name) {
@@ -2,7 +2,7 @@ import { McpServer } from '@modelcontextprotocol/server';
import { z } from 'zod';
import { apiFetch, buildQuery, DEFAULT_FIRST_PAGE_SIZE, effectivePageSize, enforceDelegatedTenant, enforceDelegatedTenantForPage, followNextLink, getCallerUpnDomain, getDelegatedTenantIds, getHomeTenantId, pageSizeForCall, pickGlobalOrTenantPath, scanUntilMatch, scanWithTimeoutFallback } from '../client.js';
import { withToolTelemetry } from '../telemetry.js';
import { getResourceContent, assertKnownEventType } from '../resource-catalog.js';
import { getResourceContent, assertKnownEventType, RESOURCE_NAMES } from '../resource-catalog.js';
import { READ_ONLY, READ_ONLY_OPEN, MUTATING, MAX_RESULT_SIZE_CHARS, LEAN_RAW_EVENT_FIELDS, LEAN_RAW_EVENT_OMISSION, toolResultText, SessionIdSchema, TenantGuidSchema, tenantIdDescription } from './shared.js';
import { toolError } from './error-handler.js';
import { shapeVerdictCalibration } from '../verdict-calibration-shape.js';
@@ -1853,6 +1853,16 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
// Tool: get_resource — returns the same content as the MCP `resources` would,
// but via a regular tool call. Workaround for clients (e.g. Claude Code's HTTP-MCP
// bridge in stateless mode) that don't expose `resources/list` reliably.
/** Top-level keys of an object-shaped resource — the units a caller can read one at a time. */
const resourceSections = (content: unknown): string[] =>
content !== null && typeof content === 'object' && !Array.isArray(content) ? Object.keys(content as Record<string, unknown>) : [];
/** Derived from the live catalogs at registration so the description can never drift from the content. */
const describeResourceSections = (): string =>
RESOURCE_NAMES
.map((name) => ({ name, sections: resourceSections(getResourceContent(name)) }))
.filter((r) => r.sections.length > 0)
.map((r) => `${r.name} → ${r.sections.join(', ')}`)
.join('; ');
server.registerTool(
'get_resource',
{
@@ -1866,18 +1876,36 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
' - "diag_zip_layout": expected file layout of an agent diagnostics ZIP (what get_session_diagnostics returns you for local analysis)\n' +
' - "rule_authoring_guide": complete guide for authoring gather/analyze rules (read FIRST when asked to create a rule; then validate_rule / test_analyze_rule)\n' +
' - "rule_schemas": the JSON Schemas for gather and analyze rules (the exact contract)\n' +
' - "rule_guardrails": on-device collection allowlists a gather rule target must satisfy',
' - "rule_guardrails": on-device collection allowlists a gather rule target must satisfy\n' +
'The rule trio is large (tens of KB together). Pass "section" to read ONE top-level part — ' +
'e.g. section="analyzeRules" of rule_authoring_guide when authoring an analyze rule, or ' +
'section="gatherRuleSchema" of rule_schemas. Sections per resource: ' + describeResourceSections() + '.',
inputSchema: {
name: z
.enum(['event_types', 'ops_event_types', 'device_properties', 'diag_zip_layout', 'rule_authoring_guide', 'rule_schemas', 'rule_guardrails'])
.enum(RESOURCE_NAMES)
.describe('Resource name'),
section: z.string().trim().min(1).max(64).optional()
.describe('Optional top-level key of the resource to return on its own (see the section list in the description). Omit for the whole resource.'),
},
annotations: READ_ONLY_OPEN,
},
async (args) => withToolTelemetry('get_resource', args, async () => {
try {
const data = getResourceContent(args.name);
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
if (!args.section) return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
const sections = resourceSections(data);
if (!sections.includes(args.section)) {
return toolError('get_resource', args, new Error(
sections.length === 0
? `Resource "${args.name}" has no sections — call it without "section".`
: `Resource "${args.name}" has no section "${args.section}". Available sections: ${sections.join(', ')}.`,
));
}
return toolResultText(
{ resource: args.name, section: args.section, content: (data as Record<string, unknown>)[args.section] },
MAX_RESULT_SIZE_CHARS.small,
);
} catch (error: unknown) {
return toolError('get_resource', args, error);
}
@@ -239,14 +239,24 @@ type PageFetcher = (path: string) => Promise<RawEventsPage>;
const defaultPageFetcher: PageFetcher = async (path) => (await apiFetch(path)) as RawEventsPage;
/**
* Candidate event types walked at the same time. One type's pages are a sequential nextLink
* chain, but the types are independent index scans on the backend, so a small fan-out cuts the
* wall-clock of a multi-type query by up to this factor while the backend load per call stays
* bounded (each walk is one budgeted scan at a time).
*/
export const TYPE_WALK_CONCURRENCY = 3;
/**
* Walks /api/raw/events once per candidate event type, following nextLink up to
* the given budget. Returns the merged events (each tagged with `_sessionId`),
* whether any walk was cut short (`truncated`), and whether at least one page
* was fetched (`anySucceeded` — drives the legacy-fallback decision).
* the given budget. Returns the merged events (each tagged with `_sessionId`) in
* candidate order regardless of which walk finished first, whether any walk was
* cut short (`truncated`), and whether at least one page was fetched
* (`anySucceeded` — drives the legacy-fallback decision).
*
* A per-type page error preserves everything accumulated so far, flags
* `truncated`, and moves to the next type — it must NOT collapse the whole
* Up to `concurrency` types are walked at once; within a type the pages stay
* sequential. A per-type page error preserves everything accumulated so far,
* flags `truncated`, and moves to the next type — it must NOT collapse the whole
* search back to the narrow legacy path once real pages have been returned.
*/
export async function fetchEventsViaIndex(
@@ -256,15 +266,17 @@ export async function fetchEventsViaIndex(
budget: FetchBudget,
fetchPage: PageFetcher = defaultPageFetcher,
now: () => number = Date.now,
concurrency: number = TYPE_WALK_CONCURRENCY,
): Promise<{ events: EventEntry[]; truncated: boolean; anySucceeded: boolean }> {
const events: EventEntry[] = [];
const perType: EventEntry[][] = candidates.map(() => []);
let truncated = false;
let anySucceeded = false;
const deadline = now() + budget.wallClockMs;
let nextCandidate = 0;
for (const eventType of candidates) {
if (now() > deadline) { truncated = true; break; }
const walkType = async (index: number): Promise<void> => {
const eventType = candidates[index];
const events = perType[index];
// First page from params; subsequent pages follow the backend's nextLink
// verbatim (followNextLink enforces path-equality against basePath).
let path = followNextLink(basePath, { eventType, tenantId, pageSize: RAW_EVENTS_PAGE_SIZE }, undefined);
@@ -277,7 +289,7 @@ export async function fetchEventsViaIndex(
} catch {
// Preserve accumulated events, flag the gap, try the next type.
truncated = true;
break;
return;
}
anySucceeded = true;
for (const e of page.events ?? []) {
@@ -287,16 +299,30 @@ export async function fetchEventsViaIndex(
pages++;
const link = page.nextLink;
if (!link) break; // this event type fully drained — not a truncation
if (!link) return; // this event type fully drained — not a truncation
if (pages >= budget.maxPagesPerType || now() > deadline) {
truncated = true; // stopped early — a real recall gap
break;
return;
}
path = followNextLink(basePath, {}, link);
}
}
};
return { events, truncated, anySucceeded };
// Each worker takes the next untouched candidate; the deadline is checked before every walk
// starts, so once it passes the remaining candidates are skipped and flagged as a recall gap.
const worker = async (): Promise<void> => {
for (;;) {
const index = nextCandidate++;
if (index >= candidates.length) return;
if (now() > deadline) { truncated = true; return; }
await walkType(index);
}
};
const workers = Math.max(1, Math.min(concurrency, candidates.length));
await Promise.all(Array.from({ length: workers }, worker));
return { events: perType.flat(), truncated, anySucceeded };
}
/**
@@ -1201,16 +1227,21 @@ export function registerSearchTools(
results = results.slice(0, topK);
const formatted = results.map((r) => ({
id: r.id,
score: Math.round(r.score * 1000) / 1000,
type: r.metadata.type,
title: r.metadata.title ?? r.metadata.description ?? r.id,
content: r.text,
metadata: r.metadata,
// Surfaced by literal error-code match rather than (or in addition to) semantic similarity.
matchType: errorCodeHitIds.has(r.id) ? ('error-code' as const) : undefined,
}));
const formatted = results.map((r) => {
// `type` and `title` are lifted to the top level — the remaining metadata (severity,
// category, tags, action) is what a caller cannot get from the lifted fields or `content`.
const { type: _type, title: _title, ...metadata } = r.metadata;
return {
id: r.id,
score: Math.round(r.score * 1000) / 1000,
type: r.metadata.type,
title: r.metadata.title ?? r.metadata.description ?? r.id,
content: r.text,
metadata,
// Surfaced by literal error-code match rather than (or in addition to) semantic similarity.
matchType: errorCodeHitIds.has(r.id) ? ('error-code' as const) : undefined,
};
});
if (formatted.length === 0) logSearchZeroHit('search_knowledge', query);
@@ -7,7 +7,6 @@ import { toolError } from './error-handler.js';
import { assertKnownEventType, assertKnownDevicePropertyKeys } from '../resource-catalog.js';
import { interpolateAnalysisResults } from '../interpolate-rule-template.js';
import { API_BASE_URL } from '../config.js';
import { DIAG_ZIP_MAP } from '../diag-zip-map.js';
import type {
DiagnosticsDownloadTicketResponse,
EnrollmentEvent,
@@ -311,9 +310,11 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
title: 'Get Session Diagnostics (Agent Log ZIP)',
description:
'Returns a short-lived, ready-to-use download URL for the agent DIAGNOSTICS ZIP of a session ' +
'(agent logs, DecisionCore journal/signals, IME logs, final-status.json), plus a "zipMap" ' +
'describing the archive layout. This is the highest-value source for root-causing why an ' +
'enrollment went wrong: correlate the on-device agent log against the backend Events table.\n\n' +
'(agent logs, DecisionCore journal/signals, IME logs, final-status.json). The archive layout and ' +
'the file priority order are the static "diag_zip_layout" resource — read it ONCE via ' +
'get_resource(name="diag_zip_layout"); it is not repeated in this response. This is the ' +
'highest-value source for root-causing why an enrollment went wrong: correlate the on-device ' +
'agent log against the backend Events table.\n\n' +
'CLIENT REQUIREMENT: this needs a client that can download files and run local file/shell tools ' +
'(e.g. Claude Code or another agentic client). A pure chat client (Claude Desktop, claude.ai web) ' +
'has no local filesystem and CANNOT unzip the binary archive — there this tool only yields a ' +
@@ -321,8 +322,8 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
'HOW TO USE: download the ZIP from "downloadUrl" — NO auth header needed, it is a short-lived ' +
'signed ticket (~10 min) — then unzip and analyze it LOCALLY. The backend never unzips or parses ' +
'it; you process it on your side and enrich with get_session_events / query_raw_events / ' +
'search_knowledge. Read files in zipMap priority order; AppWorkload*.log can be hundreds of MB → ' +
'grep, never read whole.\n\n' +
'search_knowledge. Read files in the diag_zip_layout priority order; AppWorkload*.log can be ' +
'hundreds of MB → grep, never read whole.\n\n' +
'If "available" is false there is no uploaded diagnostics package (upload mode may be Off or ' +
'OnFailure on a successful session) — proceed with backend telemetry only. ' +
'Tenant admins get their own tenant\'s diagnostics; ' +
@@ -384,11 +385,14 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
instructions:
'Download the ZIP from downloadUrl with NO auth header (it carries a short-lived signed ' +
'ticket). Unzip it locally and analyze on your side — the backend does not parse it. ' +
'Read files per zipMap.files priority; AppWorkload*.log can be huge → grep only. Then ' +
'correlate the agent log timeline against backend events (get_session_events / ' +
'query_raw_events) and look up rules/patterns via search_knowledge. The download URL ' +
'expires at expiresAt — re-call this tool for a fresh one if needed.',
zipMap: DIAG_ZIP_MAP,
'Read files in the priority order of the diag_zip_layout resource (zipLayoutResource); ' +
'AppWorkload*.log can be huge → grep only. Then correlate the agent log timeline against ' +
'backend events (get_session_events / query_raw_events) and look up rules/patterns via ' +
'search_knowledge. The download URL expires at expiresAt — re-call this tool for a fresh ' +
'one if needed.',
// The layout is static per deployment: one resource read per conversation instead of
// ~5k characters repeated on every ticket.
zipLayoutResource: 'get_resource(name="diag_zip_layout")',
}, MAX_RESULT_SIZE_CHARS.small);
} catch (error: unknown) {
return toolError('get_session_diagnostics', args, error);