diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 8459666..644ce51 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -1,9 +1,14 @@ -import type { Plugin } from "@opencode-ai/plugin"; +import type { Config, Plugin } from "@opencode-ai/plugin"; import type { Auth } from "@opencode-ai/sdk/v2"; +import type { McpServerConfig } from "@cursor/sdk"; import { resolveCursorApiKey } from "../api-key.js"; import { discoverModels, toOpencodeModels } from "../model-discovery.js"; import { buildModelV2Map, PROVIDER_ID, providerNpm } from "./model-v2.js"; -import { translateMcpServers } from "./mcp-config.js"; +import { + findUnshareableOAuthServers, + type McpStatusMap, + translateMcpServers, +} from "./mcp-config.js"; import { buildCursorTools } from "./cursor-tools.js"; function apiKeyFromAuth(auth: Auth | undefined): string | undefined { @@ -28,6 +33,17 @@ export const CursorPlugin: Plugin = async (input) => { // back to the CURSOR_API_KEY env var when the loader hasn't run. let capturedApiKey: string | undefined; + // opencode client + MCP-forwarding settings captured at config time so the + // per-turn chat.params hook can re-forward the *live* MCP server set + // (reflecting mid-session enable/disable) rather than the startup snapshot. + const client = input?.client; + const directory = input?.directory; + let forwardMcp = true; + let userMcp: Record = {}; + // OAuth servers we've already warned about, so the toast fires once per + // server rather than on every turn. + const warnedOAuth = new Set(); + return { auth: { provider: PROVIDER_ID, @@ -68,10 +84,10 @@ export const CursorPlugin: Plugin = async (input) => { // Forward opencode's configured MCP servers to the Cursor // agent so it can use the same servers. Opt out via // `provider.cursor.options.forwardMcp: false`. - const forwardMcp = existingOptions["forwardMcp"] !== false; - const userMcp = (existingOptions["mcpServers"] ?? {}) as Record< + forwardMcp = existingOptions["forwardMcp"] !== false; + userMcp = (existingOptions["mcpServers"] ?? {}) as Record< string, - unknown + McpServerConfig >; const mcpServers = forwardMcp ? { ...userMcp, ...translateMcpServers(config.mcp) } @@ -115,6 +131,54 @@ export const CursorPlugin: Plugin = async (input) => { if (input.agent === "plan" && output.options["mode"] === undefined) { output.options["mode"] = "plan"; } + + // Dynamically re-forward MCP servers from opencode's *live* state so + // mid-session enable/disable reaches the Cursor agent (the config hook + // only snapshots the set once, at startup). `client.mcp.status()` is the + // runtime truth (connected/disabled/...) and `client.config.get()` + // supplies the launch specs. On any failure we leave the static snapshot + // (already baked into the provider options) in place. + if (forwardMcp && client) { + try { + const query = directory ? { query: { directory } } : undefined; + const [cfgRes, statusRes] = await Promise.all([ + client.config.get(), + client.mcp.status(query), + ]); + const liveMcp = (cfgRes?.data as Config | undefined)?.mcp; + const status = statusRes?.data as McpStatusMap | undefined; + if (status) { + output.options["mcpServers"] = { + ...userMcp, + ...translateMcpServers(liveMcp, status), + }; + // Notify (once) about OAuth servers we can't forward: opencode + // holds their token and it never reaches config.mcp, so the + // Cursor agent can't connect. Only those without a shareable + // client registration are skipped; ones with a clientId are + // forwarded with an `auth` block for the agent's own OAuth flow. + const unshareable = findUnshareableOAuthServers( + liveMcp, + status, + ).filter((name) => !warnedOAuth.has(name)); + if (unshareable.length > 0) { + for (const name of unshareable) warnedOAuth.add(name); + const plural = unshareable.length > 1; + void client.tui + .showToast({ + body: { + title: "Cursor MCP", + message: `Skipped OAuth MCP server${plural ? "s" : ""}: ${unshareable.join(", ")}. opencode's token can't be shared with the Cursor agent; configure an OAuth clientId to forward ${plural ? "them" : "it"}.`, + variant: "warning", + }, + }) + .catch(() => {}); + } + } + } catch { + // Keep the static snapshot; live forwarding is best-effort. + } + } }, tool: { diff --git a/src/plugin/mcp-config.ts b/src/plugin/mcp-config.ts index 97a4f06..cccf2e5 100644 --- a/src/plugin/mcp-config.ts +++ b/src/plugin/mcp-config.ts @@ -5,6 +5,77 @@ import type { McpServerConfig } from "@cursor/sdk"; type OpencodeMcp = NonNullable; type OpencodeMcpEntry = OpencodeMcp[string]; +/** + * Live MCP server status, keyed by server name, as reported by opencode's + * `client.mcp.status()`. Only the `status` field is consumed; `"connected"` + * means the server is currently usable. Mirrors the SDK's `McpStatus` union + * without importing it (keeps this module dependency-light). + */ +export type McpStatusMap = Record; + +/** opencode runtime statuses that mean a server still needs OAuth to connect. */ +const NEEDS_AUTH_STATUS = new Set(["needs_auth", "needs_client_registration"]); + +/** The OAuth client registration on a remote entry, or undefined when none. */ +function oauthConfig( + entry: OpencodeMcpEntry, +): { clientId?: string; clientSecret?: string; scope?: string } | undefined { + if (entry.type !== "remote") return undefined; + // `oauth` is `McpOAuthConfig | false | undefined`; both false and undefined + // are falsy, so a truthy value is the client-registration object. + return entry.oauth ? entry.oauth : undefined; +} + +/** + * Map opencode's OAuth client registration to the Cursor SDK's `auth` block so + * the Cursor agent can run its own OAuth flow. Returns undefined when there is + * no `clientId` to share (e.g. RFC 7591 dynamic registration) — opencode's + * access token itself never reaches `config.mcp`, so a bare URL would fail. + */ +function toCursorAuth( + oauth: + | { clientId?: string; clientSecret?: string; scope?: string } + | undefined, +): + | { CLIENT_ID: string; CLIENT_SECRET?: string; scopes?: string[] } + | undefined { + if (!oauth?.clientId) return undefined; + const scopes = oauth.scope?.split(/\s+/).filter(Boolean); + return { + CLIENT_ID: oauth.clientId, + ...(oauth.clientSecret ? { CLIENT_SECRET: oauth.clientSecret } : {}), + ...(scopes && scopes.length > 0 ? { scopes } : {}), + }; +} + +/** + * Names of remote servers that require OAuth but cannot be forwarded to the + * Cursor agent because no shareable client registration exists (dynamic + * registration, or a `needs_auth` runtime status with no configured + * `clientId`). The plugin surfaces these to the user instead of silently + * forwarding a spec that would 401. + */ +export function findUnshareableOAuthServers( + mcp: Config["mcp"], + status?: McpStatusMap, +): string[] { + const names: string[] = []; + if (!mcp) return names; + for (const [name, entry] of Object.entries(mcp) as Array< + [string, OpencodeMcpEntry] + >) { + if (!entry || entry.type !== "remote") continue; + if (!status && entry.enabled === false) continue; + const s = status?.[name]?.status; + if (status && s !== "connected" && !NEEDS_AUTH_STATUS.has(s ?? "")) + continue; + const oauth = oauthConfig(entry); + const needsOAuth = Boolean(oauth) || NEEDS_AUTH_STATUS.has(s ?? ""); + if (needsOAuth && !toCursorAuth(oauth)) names.push(name); + } + return names; +} + /** * Translate opencode's configured MCP servers (`config.mcp`) into the Cursor * SDK's `McpServerConfig` shape so the same servers can be handed @@ -12,11 +83,15 @@ type OpencodeMcpEntry = OpencodeMcp[string]; * * MCP servers are independent processes addressed by a launch spec, so opencode * and the Cursor agent can each connect to the same server. Disabled entries - * (`enabled: false`) are skipped. opencode-only fields with no Cursor - * equivalent (timeout, oauth) are dropped. + * (`enabled: false`) are skipped. The `timeout` field is dropped (no Cursor + * equivalent). OAuth is mapped where possible: a remote server's `oauth` client + * registration becomes Cursor's `auth` block so the agent runs its own OAuth + * flow; servers needing OAuth with no shareable `clientId` are skipped (the + * plugin reports them via {@link findUnshareableOAuthServers}). */ export function translateMcpServers( mcp: Config["mcp"], + status?: McpStatusMap, ): Record { const out: Record = {}; if (!mcp) return out; @@ -24,7 +99,18 @@ export function translateMcpServers( for (const [name, entry] of Object.entries(mcp) as Array< [string, OpencodeMcpEntry] >) { - if (!entry || entry.enabled === false) continue; + if (!entry) continue; + + // When a live status map is supplied (per-turn dynamic forwarding), it is + // the source of truth: forward only servers opencode has currently + // connected, so mid-session enable/disable propagates to the Cursor agent. + // Without it (the startup config snapshot), fall back to the static + // `enabled` flag. + if (status) { + if (status[name]?.status !== "connected") continue; + } else if (entry.enabled === false) { + continue; + } if (entry.type === "local") { const [command, ...args] = entry.command ?? []; @@ -39,12 +125,20 @@ export function translateMcpServers( }; } else if (entry.type === "remote") { if (!entry.url) continue; + const oauth = oauthConfig(entry); + const auth = toCursorAuth(oauth); + // OAuth server with no shareable client registration: opencode holds the + // token and it never lands in config.mcp, so skip rather than forward a + // bare URL that would 401. The plugin notifies the user (see + // findUnshareableOAuthServers). + if (oauth && !auth) continue; out[name] = { type: "http", url: entry.url, ...(entry.headers && Object.keys(entry.headers).length > 0 ? { headers: entry.headers } : {}), + ...(auth ? { auth } : {}), }; } } diff --git a/src/provider/language-model.ts b/src/provider/language-model.ts index 8d6d318..7cdcd62 100644 --- a/src/provider/language-model.ts +++ b/src/provider/language-model.ts @@ -23,7 +23,11 @@ import { } from "./stream-map.js"; import { resolveControls } from "./controls.js"; import { acquireAgent, getSessionRecord } from "./session-pool.js"; -import { classifyTurn, fingerprint } from "./transcript-fingerprint.js"; +import { + classifyTurn, + fingerprint, + mcpServersFingerprint, +} from "./transcript-fingerprint.js"; export interface CursorModelConfig { /** Provider id used for logging and the providerOptions key (e.g. "cursor"). */ @@ -112,6 +116,14 @@ export class CursorLanguageModel implements LanguageModelV3 { typeof providerOptions?.["sessionID"] === "string" ? (providerOptions["sessionID"] as string) : undefined; + // MCP servers may be re-forwarded per turn by the plugin's chat.params hook + // (reflecting live opencode enable/disable). When present, the dynamic set + // wins over the static startup snapshot baked into config.mcpServers. + const dynamicMcp = providerOptions?.["mcpServers"] as + | Record + | undefined; + const mcpServers = dynamicMcp ?? this.config.mcpServers; + const mcpHash = mcpServersFingerprint(mcpServers); // `session` defaults to "auto" (fingerprint-guarded reuse); `true` is an // alias for "auto"; `false` keeps the per-turn-fresh full-transcript path. const sessionEnabled = (this.config.session ?? "auto") !== false; @@ -129,7 +141,9 @@ export class CursorLanguageModel implements LanguageModelV3 { const usePool = sessionEnabled && Boolean(sessionID) && !explicitAgentId; let resumeAgentId: string | undefined = explicitAgentId; let poolKey: string | undefined; - let record: { systemHash: string; userHashes: string[] } | undefined; + let record: + | { systemHash: string; userHashes: string[]; mcpHash?: string } + | undefined; if (usePool) { const classification = ephemeral ? { @@ -138,15 +152,22 @@ export class CursorLanguageModel implements LanguageModelV3 { } : classifyTurn(getSessionRecord(sessionID!), options.prompt); switch (classification.kind) { - case "continuation": - resumeAgentId = getSessionRecord(sessionID!)?.agentId; + case "continuation": { + const prev = getSessionRecord(sessionID!); + // A resumed agent keeps its original MCP servers, so only resume + // when the live MCP set is unchanged; otherwise create fresh so the + // new server set takes effect (re-pooled under the same session). + if (prev?.mcpHash === mcpHash) { + resumeAgentId = prev?.agentId; + } poolKey = sessionID; - record = classification.fingerprint; + record = { ...classification.fingerprint, mcpHash }; break; + } case "new": case "divergence": poolKey = sessionID; - record = classification.fingerprint; + record = { ...classification.fingerprint, mcpHash }; break; case "side-call": // fresh ephemeral agent; pool left untouched. @@ -174,7 +195,7 @@ export class CursorLanguageModel implements LanguageModelV3 { ...(this.config.sandbox !== undefined ? { sandbox: this.config.sandbox } : {}), - ...(this.config.mcpServers ? { mcpServers: this.config.mcpServers } : {}), + ...(mcpServers ? { mcpServers } : {}), ...(this.config.agents ? { agents: this.config.agents } : {}), ...(poolKey ? { name: `opencode/${sessionID!.slice(-8)}` } : {}), ...(resumeAgentId ? { resumeAgentId } : {}), diff --git a/src/provider/session-pool.ts b/src/provider/session-pool.ts index 011a9ef..19a8421 100644 --- a/src/provider/session-pool.ts +++ b/src/provider/session-pool.ts @@ -50,7 +50,7 @@ export interface AcquireAgentParams { */ poolKey?: string; /** Fingerprint of the current prompt, stored when `poolKey` is set. */ - record?: { systemHash: string; userHashes: string[] }; + record?: { systemHash: string; userHashes: string[]; mcpHash?: string }; } export interface AcquiredAgent { @@ -111,6 +111,9 @@ export async function acquireAgent( agentId: agent.agentId, systemHash: params.record.systemHash, userHashes: params.record.userHashes, + ...(params.record.mcpHash !== undefined + ? { mcpHash: params.record.mcpHash } + : {}), }); } diff --git a/src/provider/transcript-fingerprint.ts b/src/provider/transcript-fingerprint.ts index d25292a..c683c68 100644 --- a/src/provider/transcript-fingerprint.ts +++ b/src/provider/transcript-fingerprint.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import type { LanguageModelV3Prompt } from "@ai-sdk/provider"; +import type { McpServerConfig } from "@cursor/sdk"; /** * Per-session bookkeeping that lets the provider decide, on each turn, whether @@ -21,6 +22,12 @@ export interface TranscriptRecord { systemHash: string; /** Ordered hash per user message (text + a stable image token). */ userHashes: string[]; + /** + * Hash of the MCP server set the pooled agent was created with. A resumed + * Cursor agent keeps its original MCP servers, so when this changes between + * turns the pool must create a fresh agent rather than resume. + */ + mcpHash?: string; } /** What kind of turn this is relative to the session's last recorded state. */ @@ -44,6 +51,19 @@ function sha(input: string): string { return createHash("sha256").update(input).digest("hex"); } +/** + * Stable hash of the MCP server set handed to `Agent.create`. Keys are sorted + * so map ordering never changes the result; empty/undefined sets hash to "". + */ +export function mcpServersFingerprint( + servers: Record | undefined, +): string { + if (!servers) return ""; + const keys = Object.keys(servers).sort(); + if (keys.length === 0) return ""; + return sha(JSON.stringify(keys.map((k) => [k, servers[k]]))); +} + /** Stable key for one user message: its text plus a token per attached image. */ function userMessageKey( message: Extract, diff --git a/test/mcp-config.test.ts b/test/mcp-config.test.ts index 2999fd3..bac3c98 100644 --- a/test/mcp-config.test.ts +++ b/test/mcp-config.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import type { Config } from "@opencode-ai/plugin"; -import { translateMcpServers } from "../src/plugin/mcp-config.js"; +import { + findUnshareableOAuthServers, + translateMcpServers, +} from "../src/plugin/mcp-config.js"; import plugin from "../src/plugin/index.js"; describe("translateMcpServers", () => { @@ -67,6 +70,93 @@ describe("translateMcpServers", () => { }); }); +describe("findUnshareableOAuthServers", () => { + it("flags OAuth remotes with no clientId, not ones with a clientId", () => { + const mcp: Config["mcp"] = { + dynamic: { type: "remote", url: "https://dyn", oauth: {} }, + configured: { + type: "remote", + url: "https://cfg", + oauth: { clientId: "cid" }, + }, + plain: { type: "remote", url: "https://plain" }, + local: { type: "local", command: ["node"] }, + }; + expect(findUnshareableOAuthServers(mcp)).toEqual(["dynamic"]); + }); + + it("flags needs_auth servers with no usable clientId from the live status", () => { + const mcp: Config["mcp"] = { + needsauth: { type: "remote", url: "https://na" }, + connected: { type: "remote", url: "https://ok" }, + }; + const status = { + needsauth: { status: "needs_auth" }, + connected: { status: "connected" }, + }; + expect(findUnshareableOAuthServers(mcp, status)).toEqual(["needsauth"]); + }); + + it("maps a remote server's OAuth clientId to Cursor's auth block", () => { + const mcp: Config["mcp"] = { + notion: { + type: "remote", + url: "https://notion", + oauth: { clientId: "cid", clientSecret: "sec", scope: "read write" }, + }, + }; + expect(translateMcpServers(mcp)).toEqual({ + notion: { + type: "http", + url: "https://notion", + auth: { + CLIENT_ID: "cid", + CLIENT_SECRET: "sec", + scopes: ["read", "write"], + }, + }, + }); + }); + + it("skips a remote OAuth server with no shareable clientId (dynamic registration)", () => { + const mcp: Config["mcp"] = { + notion: { type: "remote", url: "https://notion", oauth: {} }, + plain: { type: "remote", url: "https://plain" }, + }; + expect(translateMcpServers(mcp)).toEqual({ + plain: { type: "http", url: "https://plain" }, + }); + }); + + it("treats oauth:false as a plain http server", () => { + const mcp: Config["mcp"] = { + plain: { type: "remote", url: "https://plain", oauth: false }, + }; + expect(translateMcpServers(mcp)).toEqual({ + plain: { type: "http", url: "https://plain" }, + }); + }); + + it("with a live status map, forwards only connected servers (ignoring `enabled`)", () => { + const mcp: Config["mcp"] = { + // enabled:false in config, but opencode connected it mid-session + live: { type: "local", command: ["node", "s.js"], enabled: false }, + // enabled in config, but disconnected mid-session + gone: { type: "local", command: ["node", "g.js"] }, + // failed to connect -> not forwarded + broken: { type: "remote", url: "https://broken" }, + }; + const status = { + live: { status: "connected" }, + gone: { status: "disabled" }, + broken: { status: "failed" }, + }; + expect(translateMcpServers(mcp, status)).toEqual({ + live: { type: "stdio", command: "node", args: ["s.js"] }, + }); + }); +}); + describe("plugin config hook MCP forwarding", () => { it("forwards opencode's configured MCP servers into provider.cursor.options", async () => { const hooks = await plugin({} as never); @@ -91,3 +181,153 @@ describe("plugin config hook MCP forwarding", () => { expect(opts.mcpServers).toBeUndefined(); }); }); + +describe("chat.params dynamic MCP re-forwarding", () => { + // A mock opencode client returning live config + MCP status. + function fakeClient( + mcp: Config["mcp"], + status: Record, + ) { + const toasts: Array<{ message: string; variant: string }> = []; + return { + toasts, + config: { get: async () => ({ data: { mcp } }) }, + mcp: { status: async () => ({ data: status }) }, + tui: { + showToast: async (opts: { + body: { message: string; variant: string }; + }) => { + toasts.push(opts.body); + return { data: true }; + }, + }, + }; + } + + const chatInput = (over: Record = {}) => ({ + sessionID: "s1", + agent: "build", + model: { providerID: "cursor", modelID: "m" }, + ...over, + }); + + it("injects the live (connected-only) MCP set into output.options", async () => { + const client = fakeClient( + { + serena: { type: "local", command: ["serena", "start"] }, + notion: { type: "remote", url: "https://notion", enabled: false }, + }, + { serena: { status: "connected" }, notion: { status: "connected" } }, + ); + const hooks = await plugin({ client } as never); + // config hook must run first to capture forwardMcp/userMcp + provider opts. + await hooks.config!({ mcp: {} } as Config); + + const output: Record = { options: {} }; + await hooks["chat.params"]!(chatInput() as never, output as never); + const opts = output.options as Record; + expect(opts.sessionID).toBe("s1"); + expect(opts.mcpServers).toEqual({ + serena: { type: "stdio", command: "serena", args: ["start"] }, + notion: { type: "http", url: "https://notion" }, + }); + }); + + it("drops servers opencode disconnected mid-session", async () => { + const client = fakeClient( + { + serena: { type: "local", command: ["serena"] }, + notion: { type: "remote", url: "https://notion" }, + }, + { serena: { status: "connected" }, notion: { status: "disabled" } }, + ); + const hooks = await plugin({ client } as never); + await hooks.config!({ mcp: {} } as Config); + + const output: Record = { options: {} }; + await hooks["chat.params"]!(chatInput() as never, output as never); + const opts = output.options as Record; + expect(opts.mcpServers).toEqual({ + serena: { type: "stdio", command: "serena" }, + }); + }); + + it("does not re-forward for non-cursor models", async () => { + const client = fakeClient( + { serena: { type: "local", command: ["serena"] } }, + { serena: { status: "connected" } }, + ); + const hooks = await plugin({ client } as never); + await hooks.config!({ mcp: {} } as Config); + + const output: Record = { options: {} }; + await hooks["chat.params"]!( + chatInput({ model: { providerID: "anthropic", modelID: "x" } }) as never, + output as never, + ); + expect( + (output.options as Record).mcpServers, + ).toBeUndefined(); + }); + + it("skips re-forwarding when forwardMcp is false", async () => { + const client = fakeClient( + { serena: { type: "local", command: ["serena"] } }, + { serena: { status: "connected" } }, + ); + const hooks = await plugin({ client } as never); + await hooks.config!({ + mcp: {}, + provider: { cursor: { options: { forwardMcp: false } } }, + } as Config); + + const output: Record = { options: {} }; + await hooks["chat.params"]!(chatInput() as never, output as never); + expect( + (output.options as Record).mcpServers, + ).toBeUndefined(); + }); + + it("forwards an OAuth server with a clientId (as auth) and skips one without", async () => { + const client = fakeClient( + { + configured: { + type: "remote", + url: "https://cfg", + oauth: { clientId: "cid" }, + }, + dynamic: { type: "remote", url: "https://dyn", oauth: {} }, + }, + { configured: { status: "connected" }, dynamic: { status: "connected" } }, + ); + const hooks = await plugin({ client } as never); + await hooks.config!({ mcp: {} } as Config); + + const output: Record = { options: {} }; + await hooks["chat.params"]!(chatInput() as never, output as never); + expect((output.options as Record).mcpServers).toEqual({ + configured: { + type: "http", + url: "https://cfg", + auth: { CLIENT_ID: "cid" }, + }, + }); + expect(client.toasts).toHaveLength(1); + expect(client.toasts[0]!.variant).toBe("warning"); + expect(client.toasts[0]!.message).toContain("dynamic"); + }); + + it("warns about an OAuth server only once across turns", async () => { + const client = fakeClient( + { dynamic: { type: "remote", url: "https://dyn", oauth: {} } }, + { dynamic: { status: "connected" } }, + ); + const hooks = await plugin({ client } as never); + await hooks.config!({ mcp: {} } as Config); + + const output: Record = { options: {} }; + await hooks["chat.params"]!(chatInput() as never, output as never); + await hooks["chat.params"]!(chatInput() as never, output as never); + expect(client.toasts).toHaveLength(1); + }); +}); diff --git a/test/session-pool.test.ts b/test/session-pool.test.ts index 8b079e9..28f96a7 100644 --- a/test/session-pool.test.ts +++ b/test/session-pool.test.ts @@ -76,6 +76,20 @@ describe("acquireAgent", () => { expect(getPooledAgentId("s1")).toBe("a2"); }); + it("persists mcpHash in the pooled record when provided", async () => { + create.mockResolvedValue(fakeAgent("a1")); + await acquireAgent({ + ...base, + poolKey: "s1", + record: { ...rec, mcpHash: "mcp-v1" }, + }); + expect(getSessionRecord("s1")).toEqual({ + agentId: "a1", + ...rec, + mcpHash: "mcp-v1", + }); + }); + it("re-pools a new record under the same session (divergence)", async () => { create.mockResolvedValueOnce(fakeAgent("a1")); await acquireAgent({ ...base, poolKey: "s1", record: rec }); diff --git a/test/transcript-fingerprint.test.ts b/test/transcript-fingerprint.test.ts index 9c0dd07..dc0fc31 100644 --- a/test/transcript-fingerprint.test.ts +++ b/test/transcript-fingerprint.test.ts @@ -3,6 +3,7 @@ import type { LanguageModelV3Prompt } from "@ai-sdk/provider"; import { classifyTurn, fingerprint, + mcpServersFingerprint, type TranscriptRecord, } from "../src/provider/transcript-fingerprint.js"; @@ -91,6 +92,36 @@ describe("classifyTurn", () => { }); }); +describe("mcpServersFingerprint", () => { + it("hashes empty/undefined sets to the same empty string", () => { + expect(mcpServersFingerprint(undefined)).toBe(""); + expect(mcpServersFingerprint({})).toBe(""); + }); + + it("is independent of key insertion order", () => { + const a = mcpServersFingerprint({ + serena: { type: "stdio", command: "serena" }, + ctx: { type: "http", url: "https://x" }, + }); + const b = mcpServersFingerprint({ + ctx: { type: "http", url: "https://x" }, + serena: { type: "stdio", command: "serena" }, + }); + expect(a).toBe(b); + }); + + it("changes when a server is added or removed", () => { + const one = mcpServersFingerprint({ + serena: { type: "stdio", command: "serena" }, + }); + const two = mcpServersFingerprint({ + serena: { type: "stdio", command: "serena" }, + ctx: { type: "http", url: "https://x" }, + }); + expect(one).not.toBe(two); + }); +}); + describe("fingerprint", () => { it("is stable for identical prompts and ignores assistant content", () => { const a = fingerprint([sys("S"), user("hi"), assistant("one")]);