From 26b70c8ea4e3db3f28568687bc1a5335cb79654f Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Fri, 13 Nov 2026 06:16:57 -0800 Subject: [PATCH] fix(mcp): complete wide retained occurrence inventories (#10356) --- src/server/promptCompletion.ts | 2 +- .../mcp/wideOccurrenceCompletion.test.ts | 34 ++++ tests/fixtures/promptOccurrenceCompletion.ts | 180 ++++++++++++++++++ 3 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 tests/boundary/mcp/wideOccurrenceCompletion.test.ts create mode 100644 tests/fixtures/promptOccurrenceCompletion.ts diff --git a/src/server/promptCompletion.ts b/src/server/promptCompletion.ts index bcac51502e..8127b73f04 100644 --- a/src/server/promptCompletion.ts +++ b/src/server/promptCompletion.ts @@ -175,7 +175,7 @@ const evidenceCandidates = ( if (session === undefined) return []; const candidates: string[] = []; for (const evidence of session.immutableEvidenceBundle().records) - candidates.push(...evidenceValues(kind, evidence)); + for (const value of evidenceValues(kind, evidence)) candidates.push(value); return candidates; }; diff --git a/tests/boundary/mcp/wideOccurrenceCompletion.test.ts b/tests/boundary/mcp/wideOccurrenceCompletion.test.ts new file mode 100644 index 0000000000..305a5e35c3 --- /dev/null +++ b/tests/boundary/mcp/wideOccurrenceCompletion.test.ts @@ -0,0 +1,34 @@ +import { fileURLToPath } from "node:url"; +import { expect } from "vitest"; +import { processTest } from "../../support/process/processFixture.js"; + +processTest.for([3, 130_000])( + "completes an authenticated inventory with %i child occurrences on the server stack", + { timeout: 45_000 }, + async (count, { processes }) => { + // Thread-pool tests have a larger stack than the stdio server. Use a fresh + // main thread and enough heap for the ledger's conservative admission. + const result = await processes.run( + process.execPath, + [ + "--max-old-space-size=4096", + "--import", + "tsx", + fileURLToPath( + new URL( + "../../fixtures/promptOccurrenceCompletion.ts", + import.meta.url, + ), + ), + String(count), + ], + { timeoutMs: 30_000 }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + values: Math.min(count + 1, 100), + total: count + 1, + hasMore: count + 1 > 100, + }); + }, +); diff --git a/tests/fixtures/promptOccurrenceCompletion.ts b/tests/fixtures/promptOccurrenceCompletion.ts new file mode 100644 index 0000000000..8bcd536d65 --- /dev/null +++ b/tests/fixtures/promptOccurrenceCompletion.ts @@ -0,0 +1,180 @@ +import assert from "node:assert/strict"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { AnalysisProviderRegistry } from "../../src/application/binary/AnalysisProviderRegistry.js"; +import { BinarySession } from "../../src/application/binary/BinarySession.js"; +import { SessionProviderRouter } from "../../src/application/binary/SessionProviderRouter.js"; +import type { + ArtifactNode, + ArtifactOccurrence, +} from "../../src/domain/artifactGraph.js"; +import { + artifactGraphDigest, + artifactIdForContent, + artifactManifestId, + occurrenceIdForLocation, +} from "../../src/domain/artifactIdentity.js"; +import { parseArtifactInventoryEvidence } from "../../src/domain/artifactInventoryEvidence.js"; +import { compareUnicodeCodePoints } from "../../src/domain/unicodeCodePointOrder.js"; +import { createEvidence } from "../../src/domain/evidence.js"; +import { createServer } from "../../src/server/createServer.js"; + +// Run on Node's main-thread stack, like the stdio MCP server. No real ZIP or +// provider is involved: these are authenticated, synthetic inventory facts. +const count = Number(process.argv[2]); +const started = performance.now(); +const sha256 = "0".repeat(64); +const root = artifactIdForContent(sha256); +const rootOccurrence = occurrenceIdForLocation({ + rootArtifactId: root, + logicalPath: ".", + entryKind: "file", +}); +const nodes: ArtifactNode[] = [ + { + artifact_id: root, + format: "zip", + sha256, + size: 16_000_000, + media_type: null, + architecture: null, + content_state: "materialized", + limitations: [], + }, +]; +const base: Omit = { + artifact_id: null, + parent_occurrence_id: rootOccurrence, + relationship_coverage: "unknown", + entry_kind: "file", + artifact_kind: "resource", + artifact_format: "file", + declared_size: 0, + compressed_size: 0, + executable: false, + encrypted: false, + hash_status: "not-hashed", + source_location: null, + limitations: [], +}; +const occurrences: ArtifactOccurrence[] = [ + { + ...base, + occurrence_id: rootOccurrence, + artifact_id: root, + parent_occurrence_id: null, + logical_path: ".", + artifact_kind: "container", + artifact_format: "zip", + declared_size: 16_000_000, + compressed_size: null, + hash_status: "verified", + }, + ...Array.from({ length: count }, (_, index): ArtifactOccurrence => { + const logicalPath = `entry${String(index).padStart(6, "0")}`; + return { + ...base, + logical_path: logicalPath, + occurrence_id: occurrenceIdForLocation({ + rootArtifactId: root, + logicalPath, + entryKind: "file", + }), + }; + }), +]; +const graphSha256 = artifactGraphDigest({ + nodes, + occurrences, + edges: [], + contradictions: [], +}); +const evidence = createEvidence( + { path: "synthetic-inventory.zip", sha256, format: "zip" }, + { id: "rea-artifact", name: "REA artifact graph", version: "1" }, + { + operation: "inventory_artifact", + parameters: {}, + confidence: "observed", + authority: "shipped-artifact", + result: { + manifest: { + manifest_id: artifactManifestId(root, graphSha256), + root_artifact_id: root, + root_sha256: sha256, + root_format: "zip", + graph_sha256: graphSha256, + node_count: nodes.length, + occurrence_count: occurrences.length, + edge_count: 0, + }, + nodes, + occurrences, + edges: [], + provenance: [], + integrity_contradictions: [], + coverage: { scope: "container-entries", status: "unknown" }, + limitations: ["Synthetic inventory; no actual ZIP contents are claimed."], + }, + }, +); +assert.equal( + parseArtifactInventoryEvidence(evidence).inventory.occurrences.length, + count + 1, +); +const session = new BinarySession( + SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []), +); +const retained = session.recordEvidence(evidence); +if (!retained.ok) throw retained.error; +const server = createServer({ kind: "session", session }, { environment: {} }); +const client = new Client({ + name: "occurrence-completion-control", + version: "1", +}); +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const complete = async (value: string) => + ( + await client.complete({ + ref: { type: "ref/prompt", name: "compare_application_versions" }, + argument: { name: "focus_occurrence_id", value }, + }) + ).completion; + const expected = occurrences + .map(({ occurrence_id }) => occurrence_id) + .sort(compareUnicodeCodePoints); + const result = await complete("OCC_"); + assert.deepEqual(result, { + values: expected.slice(0, 100), + total: expected.length, + hasMore: expected.length > 100, + }); + // Prefix matching must examine IDs beyond the first completion page. + const last = expected.at(-1)!; + assert.deepEqual(await complete(last), { + values: [last], + total: 1, + hasMore: false, + }); + assert.deepEqual(await complete("missing"), { + values: [], + total: 0, + hasMore: false, + }); + console.log( + JSON.stringify({ + values: result.values.length, + total: result.total, + hasMore: result.hasMore, + elapsed_ms: performance.now() - started, + cpu: process.cpuUsage(), + process_peak_kib: process.resourceUsage().maxRSS, + }), + ); +} finally { + await client.close(); + await server.close(); + await session.close(); +}