diff --git a/src/mcp/drift.ts b/src/mcp/drift.ts new file mode 100644 index 0000000..ff49d44 --- /dev/null +++ b/src/mcp/drift.ts @@ -0,0 +1,86 @@ +/** Classifies a tool-list drift across an MCP reconnect. Drives the policy in `/mcp reconnect`. */ + +import type { ToolSpec } from "../types.js"; + +/** Ordered by "cache cost" — `identity` and `append` are nearly free; `reorder` is catastrophic. */ +export type DriftKind = "identity" | "append" | "edit" | "reorder" | "remove"; + +export interface DriftReport { + kind: DriftKind; + /** Tool names added by the new spec (relative to `before`). */ + added: string[]; + /** Tool names removed by the new spec (gone from `after`). */ + removed: string[]; + /** Tool names whose name + position match but whose serialized content changed. */ + edited: string[]; +} + +export function classifyToolListDrift( + before: readonly ToolSpec[], + after: readonly ToolSpec[], +): DriftReport { + const beforeNames = before.map(nameOf); + const afterNames = after.map(nameOf); + const beforeSet = new Set(beforeNames); + const afterSet = new Set(afterNames); + + const added = afterNames.filter((n) => !beforeSet.has(n)); + const removed = beforeNames.filter((n) => !afterSet.has(n)); + + const edited: string[] = []; + // Same-position-same-name slots whose serialized content differs. + const sharedLen = Math.min(before.length, after.length); + for (let i = 0; i < sharedLen; i++) { + if (beforeNames[i] === afterNames[i] && hash(before[i]!) !== hash(after[i]!)) { + edited.push(beforeNames[i]!); + } + } + + // Identity: same length, same names in order, same content. + if ( + before.length === after.length && + edited.length === 0 && + beforeNames.every((n, i) => n === afterNames[i]) + ) { + return { kind: "identity", added: [], removed: [], edited: [] }; + } + + // Remove anywhere → catastrophic regardless of other changes. + if (removed.length > 0) { + return { kind: "remove", added, removed, edited }; + } + + // Append: every before-tool stays put with identical content, new ones tacked on the end. + if ( + after.length > before.length && + beforeNames.every((n, i) => n === afterNames[i] && hash(before[i]!) === hash(after[i]!)) + ) { + return { kind: "append", added, removed: [], edited: [] }; + } + + // Same name set as before? Then positions or content changed. + const sameNameSet = + beforeSet.size === afterSet.size && [...beforeSet].every((n) => afterSet.has(n)); + if (sameNameSet) { + const positionsMatch = beforeNames.every((n, i) => n === afterNames[i]); + if (positionsMatch) { + // Names + positions stable, only content edited in place. + return { kind: "edit", added: [], removed: [], edited }; + } + // Same set, different order — cache-wise as bad as a structural change. + return { kind: "reorder", added: [], removed: [], edited }; + } + + // Additions present but NOT clean appends (e.g. inserted in the middle, or + // appended-but-existing-tools-also-edited). Treat as reorder for safety — + // the divergence point is no longer the tail of the list. + return { kind: "reorder", added, removed: [], edited }; +} + +function nameOf(spec: ToolSpec): string { + return spec.function?.name ?? ""; +} + +function hash(spec: ToolSpec): string { + return JSON.stringify(spec); +} diff --git a/tests/mcp-drift.test.ts b/tests/mcp-drift.test.ts new file mode 100644 index 0000000..2a2b69a --- /dev/null +++ b/tests/mcp-drift.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { classifyToolListDrift } from "../src/mcp/drift.js"; +import type { ToolSpec } from "../src/types.js"; + +function tool(name: string, description = "", params: object = { type: "object" }): ToolSpec { + return { + type: "function", + function: { name, description, parameters: params }, + }; +} + +const A = tool("read"); +const B = tool("write"); +const C = tool("search"); +const D = tool("delete"); + +describe("classifyToolListDrift", () => { + it("identity: same length, same names, same content → identity", () => { + const r = classifyToolListDrift([A, B, C], [A, B, C]); + expect(r.kind).toBe("identity"); + expect(r.added).toEqual([]); + expect(r.removed).toEqual([]); + expect(r.edited).toEqual([]); + }); + + it("append: every before-tool unchanged, new tool appended", () => { + const r = classifyToolListDrift([A, B, C], [A, B, C, D]); + expect(r.kind).toBe("append"); + expect(r.added).toEqual(["delete"]); + expect(r.removed).toEqual([]); + expect(r.edited).toEqual([]); + }); + + it("append: multiple tools appended at the end", () => { + const E = tool("touch"); + const r = classifyToolListDrift([A, B], [A, B, C, D, E]); + expect(r.kind).toBe("append"); + expect(r.added).toEqual(["search", "delete", "touch"]); + }); + + it("edit: same names + same order, content of one tool changed", () => { + const aEdited = tool("read", "now reads UTF-8 only"); + const r = classifyToolListDrift([A, B, C], [aEdited, B, C]); + expect(r.kind).toBe("edit"); + expect(r.edited).toEqual(["read"]); + expect(r.added).toEqual([]); + expect(r.removed).toEqual([]); + }); + + it("edit: schema change on the same tool", () => { + const aSchema = tool("read", "", { + type: "object", + properties: { path: { type: "string" }, encoding: { type: "string" } }, + }); + const r = classifyToolListDrift([A], [aSchema]); + expect(r.kind).toBe("edit"); + expect(r.edited).toEqual(["read"]); + }); + + it("remove: a tool present in before is missing from after", () => { + const r = classifyToolListDrift([A, B, C], [A, C]); + expect(r.kind).toBe("remove"); + expect(r.removed).toEqual(["write"]); + expect(r.added).toEqual([]); + }); + + it("remove dominates: even if other tools were added, removal makes it `remove`", () => { + // before: A, B, C → after: A, C, D (B removed, D added) + const r = classifyToolListDrift([A, B, C], [A, C, D]); + expect(r.kind).toBe("remove"); + expect(r.removed).toEqual(["write"]); + expect(r.added).toEqual(["delete"]); + }); + + it("reorder: same name set in a different order", () => { + const r = classifyToolListDrift([A, B, C], [B, A, C]); + expect(r.kind).toBe("reorder"); + }); + + it("reorder: addition NOT at the end (cache-equivalent to a reorder)", () => { + const r = classifyToolListDrift([A, B, C], [A, D, B, C]); + expect(r.kind).toBe("reorder"); + }); + + it("identity with empty lists", () => { + expect(classifyToolListDrift([], []).kind).toBe("identity"); + }); + + it("append onto an empty list", () => { + const r = classifyToolListDrift([], [A, B]); + expect(r.kind).toBe("append"); + expect(r.added).toEqual(["read", "write"]); + }); + + it("remove down to an empty list", () => { + const r = classifyToolListDrift([A, B], []); + expect(r.kind).toBe("remove"); + expect(r.removed).toEqual(["read", "write"]); + }); +});