Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions src/mcp/drift.ts
Original file line number Diff line number Diff line change
@@ -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);
}
100 changes: 100 additions & 0 deletions tests/mcp-drift.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
Loading