fix(mcp): preserve procedure aliases and complete name ambiguity

Derive address suggestions separately from name uniqueness across every
validated inventory row. Keep the sole exact address or an explicit ambiguous
state per name, without retaining a set of all name/address pairs. Reuse the
canonical address/value leaf contract for completion's parsed facts.

Cover order independence, same-address duplicates, later ambiguity and alias
rows beyond the MCP reply limit through the public SDK. Preserve provider
spelling, document context, cold-session admission and cancellation behavior.

Adapted from #4648 at 5b019e1179.

Closes #4623

Co-authored-by: shreyansh1729 <shreyanshiitian1729@gmail.com>
This commit is contained in:
morlutoandshreyansh1729 committed 2026-10-19 08:54:23 +08:00
1 parent 273bb66276
commit d3627b1038
3 files changed
+172 -21

No files matched your search

+7 -5
View File
@@ -108,11 +108,13 @@ list does not establish that the target has no procedures.
| Artifact occurrence | Occurrence IDs present in schema-valid retained artifact inventory pages. |
| Residual unknown | Current non-resolved unknown heads only. |
Suggestions are Unicode-normalized, case-insensitive prefix matches. The server
deduplicates them and sorts by code point for deterministic results. Procedure
completion derives candidates from the complete inventory, so a name is offered
only when it identifies one address in that inventory. A completion reply
carries at most 100 values, the MCP limit. `total` counts every match and
Prefix matching is Unicode-normalized and case-insensitive; suggestions retain
their original spelling. The server deduplicates them and sorts by code point
for deterministic results. Procedure completion derives candidates from the
complete inventory, so a name is offered only when it identifies one address in
that inventory. Every name at a shared address participates in this check,
including later rows that establish ambiguity. A completion reply carries at
most 100 values, the MCP limit. `total` counts every match and
`hasMore` is `true` when values were omitted. Completion has no paging
controls. A longer prefix reaches an omitted value only when it excludes other
matches; matching uses normalized, case-insensitive forms, so a value can stay
+20 -16
View File
@@ -6,12 +6,16 @@ import type { JsonValue } from "../domain/jsonValue.js";
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
import type { BinarySessionPort } from "../application/binary/BinarySessionPort.js";
import type { PromptCompletionKind } from "../contracts/promptContracts.js";
import { addressedValue } from "../contracts/toolOutputSchemaPrimitives.js";
import { currentMcpRequestSignal } from "./EvidenceMcpServer.js";
import { parseArtifactInventoryEvidence } from "../domain/artifactInventoryEvidence.js";
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
import { parseProcessCapture } from "../domain/process/processCaptureParsing.js";
const documentListSchema = z.array(z.string().min(1));
const procedureInventorySchema = z.array(
addressedValue.pick({ address: true, value: true }),
);
const providerStatusSchema = z.object({
analysis_provider_candidates: z.array(
z.object({ provider: z.object({ id: z.string().min(1) }) }),
@@ -99,7 +103,9 @@ const procedureCandidates = async (
session: BinarySessionPort | undefined,
document: string | undefined,
): Promise<readonly string[]> => {
const byAddress = new Map<string, string>();
const addresses = new Set<string>();
// null records proven ambiguity; later rows cannot restore uniqueness.
const addressByName = new Map<string, string | null>();
const input =
document === undefined || document.length === 0 ? {} : { document };
const result = await completionInventoryLookup(
@@ -110,24 +116,22 @@ const procedureCandidates = async (
);
if (result === undefined) return [];
if (result.ok) {
const parsed = z
.array(z.object({ address: z.string(), value: z.string() }))
.safeParse(result.value.result);
const parsed = procedureInventorySchema.safeParse(result.value.result);
if (parsed.success)
for (const item of parsed.data)
if (!byAddress.has(item.address))
byAddress.set(item.address, item.value);
for (const { address, value: name } of parsed.data) {
addresses.add(address);
if (name.length === 0) continue;
const previous = addressByName.get(name);
addressByName.set(
name,
previous === undefined || previous === address ? address : null,
);
}
}
const addressesByName = new Map<string, Set<string>>();
for (const [address, name] of byAddress) {
const addresses = addressesByName.get(name) ?? new Set<string>();
addresses.add(address);
addressesByName.set(name, addresses);
}
const candidates = [...byAddress.keys()];
for (const [name, addresses] of addressesByName)
if (addresses.size === 1 && name.length > 0) candidates.push(name);
const candidates = [...addresses];
for (const [name, address] of addressByName)
if (address !== null) candidates.push(name);
return candidates;
};
@@ -0,0 +1,145 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { expect, it } from "vitest";
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
import { ok } from "../../../src/domain/result.js";
import { createServer } from "../../../src/server/createServer.js";
const provider = { id: "fixed-fixture", name: "Fixed fixture", version: "1" };
const inventory = [
{ address: "0x1000", value: "foo" },
{ address: "0x1000", value: "bar" },
{ address: "0x1000", value: "bar" },
{ address: "0x1000", value: "shared" },
{ address: "0x2000", value: "other" },
{ address: "0x2000", value: "shared" },
{ address: "0x3000", value: "later" },
{ address: "0x3000", value: "later" },
{ address: "0x1000", value: "later" },
];
const completeProcedure = async (client: Client, value: string) =>
(
await client.complete({
ref: { type: "ref/prompt", name: "investigate_feature" },
argument: { name: "procedure", value },
})
).completion;
it.each([false, true])(
"completes every uniquely named procedure across inventory order (reversed: %s)",
async (reversed) => {
const rows = reversed ? [...inventory].reverse() : inventory;
const server = createServer({
kind: "fixed",
analysis: {
execute: async (operation) =>
ok(
createAnalysisExecution(
operation === "list_procedures" ? rows : null,
provider,
),
),
},
});
const client = new Client({ name: "procedure-completion", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const listed = await client.callTool({
name: "list_procedures",
arguments: {},
});
expect(listed.isError).not.toBe(true);
expect(listed.structuredContent).toMatchObject({
normalized_result: rows,
});
expect(await completeProcedure(client, "")).toEqual({
values: ["0x1000", "0x2000", "0x3000", "bar", "foo", "other"],
total: 6,
hasMore: false,
});
expect(await completeProcedure(client, "bar")).toEqual({
values: ["bar"],
total: 1,
hasMore: false,
});
expect(await completeProcedure(client, "foo")).toEqual({
values: ["foo"],
total: 1,
hasMore: false,
});
expect(await completeProcedure(client, "shared")).toEqual({
values: [],
total: 0,
hasMore: false,
});
expect(await completeProcedure(client, "later")).toEqual({
values: [],
total: 0,
hasMore: false,
});
} finally {
await client.close();
await server.close();
}
},
);
it("evaluates later alias rows before the MCP completion limit is applied", async () => {
const rows = Array.from({ length: 120 }, (_, index) => ({
address: `entry:${String(index)}`,
value: `name_${String(index).padStart(3, "0")}`,
}));
rows.push(
{ address: "entry:0", value: "late_alias" },
{ address: "entry:119", value: "name_000" },
);
const server = createServer({
kind: "fixed",
analysis: {
execute: async () => ok(createAnalysisExecution(rows, provider)),
},
});
const client = new Client({
name: "large-completion-inventory",
version: "1",
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
expect(await completeProcedure(client, "name_")).toEqual({
values: Array.from(
{ length: 100 },
(_, index) => `name_${String(index + 1).padStart(3, "0")}`,
),
total: 119,
hasMore: true,
});
expect(await completeProcedure(client, "name_000")).toEqual({
values: [],
total: 0,
hasMore: false,
});
expect(await completeProcedure(client, "LATE_A")).toEqual({
values: ["late_alias"],
total: 1,
hasMore: false,
});
expect(await completeProcedure(client, "entry:119")).toEqual({
values: ["entry:119"],
total: 1,
hasMore: false,
});
} finally {
await client.close();
await server.close();
}
});