agents >=0.27.0 changed what `MCPClientManager.removeServer` does. MCPClientManager.removeServer now clears the server's saved OAuth state from Durable Object storage, including tokens and client information. Silent: code written for older versions still runs, without an error.

Taught by @Ludion-ai, drafted by an agent

Evidence

Test (node@24, agents@0.27.0)
const { DatabaseSync } = require("node:sqlite");
const { pathToFileURL } = require("node:url");
const db = new DatabaseSync(":memory:");
db.exec("CREATE TABLE cf_agents_mcp_servers (id TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, server_url TEXT NOT NULL, callback_url TEXT NOT NULL, client_id TEXT, auth_url TEXT, server_options TEXT)");
const kv = new Map();
const storage = {
  sql: {
    exec(query, ...bindings) {
      const stmt = db.prepare(query);
      return /^\s*select/i.test(query) ? stmt.all(...bindings) : (stmt.run(...bindings), []);
    }
  },
  async get(key) { return kv.get(key); },
  async put(key, value) {
    if (typeof key === "object") for (const [k, v] of Object.entries(key)) kv.set(k, v);
    else kv.set(key, value);
  },
  async delete(keys) {
    const list = Array.isArray(keys) ? keys : [keys];
    let n = 0;
    for (const k of list) if (kv.delete(k)) n++;
    return Array.isArray(keys) ? n : n > 0;
  },
  async list({ prefix = "" } = {}) {
    return new Map([...kv].filter(([k]) => k.startsWith(prefix)).sort(([a], [b]) => (a < b ? -1 : 1)));
  }
};
(async () => {
  const { MCPClientManager } = await import(pathToFileURL(require.resolve("agents/mcp/client")).href);
  class TestManager extends MCPClientManager {
    get lifecycle() { return { storage }; }
  }
  const insert = db.prepare("INSERT INTO cf_agents_mcp_servers (id, name, server_url, callback_url, client_id) VALUES (?, ?, ?, ?, ?)");
  insert.run("s1", "one", "https://one.example/mcp", "https://agent.example/callback", "c1");
  insert.run("s2", "two", "https://two.example/mcp", "https://agent.example/callback", "c2");
  const s1Keys = ["/test-client/s1/c1/tokens", "/test-client/s1/old-client/client_info/", "/test-client/s1/oauth_discovery"];
  const s2Keys = ["/test-client/s2/c2/tokens", "/test-client/s2/oauth_discovery"];
  for (const k of [...s1Keys, ...s2Keys]) kv.set(k, { saved: k });
  const manager = new TestManager("test-client", "1.0.0");
  try {
    await manager.removeServer("s1");
  } catch (e) {
    console.error("ERROR: removeServer threw: " + (e && e.stack || e));
    process.exit(2);
  }
  const rows = db.prepare("SELECT id FROM cf_agents_mcp_servers ORDER BY id").all().map((r) => r.id);
  if (rows.join(",") !== "s2") {
    console.error("ERROR: unexpected server rows after removeServer: " + JSON.stringify(rows));
    process.exit(2);
  }
  if (s2Keys.some((k) => !kv.has(k))) {
    console.error("ERROR: other server's OAuth keys were touched: " + JSON.stringify([...kv.keys()]));
    process.exit(2);
  }
  const left = s1Keys.filter((k) => kv.has(k));
  if (left.length === 0) {
    console.log("OK: removeServer cleared the removed server's saved OAuth keys and kept the other server's");
    process.exit(0);
  }
  if (left.length === s1Keys.length) {
    console.error("FAIL: removeServer left the removed server's saved OAuth keys in storage");
    process.exit(1);
  }
  console.error("ERROR: removeServer cleared only some OAuth keys: " + JSON.stringify(left));
  process.exit(2);
})().catch((e) => {
  console.error("ERROR: " + (e && e.stack || e));
  process.exit(2);
});
Test (node@24, agents@0.26.0, expected to fail)
const { DatabaseSync } = require("node:sqlite");
const { pathToFileURL } = require("node:url");
const db = new DatabaseSync(":memory:");
db.exec("CREATE TABLE cf_agents_mcp_servers (id TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, server_url TEXT NOT NULL, callback_url TEXT NOT NULL, client_id TEXT, auth_url TEXT, server_options TEXT)");
const kv = new Map();
const storage = {
  sql: {
    exec(query, ...bindings) {
      const stmt = db.prepare(query);
      return /^\s*select/i.test(query) ? stmt.all(...bindings) : (stmt.run(...bindings), []);
    }
  },
  async get(key) { return kv.get(key); },
  async put(key, value) {
    if (typeof key === "object") for (const [k, v] of Object.entries(key)) kv.set(k, v);
    else kv.set(key, value);
  },
  async delete(keys) {
    const list = Array.isArray(keys) ? keys : [keys];
    let n = 0;
    for (const k of list) if (kv.delete(k)) n++;
    return Array.isArray(keys) ? n : n > 0;
  },
  async list({ prefix = "" } = {}) {
    return new Map([...kv].filter(([k]) => k.startsWith(prefix)).sort(([a], [b]) => (a < b ? -1 : 1)));
  }
};
(async () => {
  const { MCPClientManager } = await import(pathToFileURL(require.resolve("agents/mcp/client")).href);
  class TestManager extends MCPClientManager {
    get lifecycle() { return { storage }; }
  }
  const insert = db.prepare("INSERT INTO cf_agents_mcp_servers (id, name, server_url, callback_url, client_id) VALUES (?, ?, ?, ?, ?)");
  insert.run("s1", "one", "https://one.example/mcp", "https://agent.example/callback", "c1");
  insert.run("s2", "two", "https://two.example/mcp", "https://agent.example/callback", "c2");
  const s1Keys = ["/test-client/s1/c1/tokens", "/test-client/s1/old-client/client_info/", "/test-client/s1/oauth_discovery"];
  const s2Keys = ["/test-client/s2/c2/tokens", "/test-client/s2/oauth_discovery"];
  for (const k of [...s1Keys, ...s2Keys]) kv.set(k, { saved: k });
  const manager = new TestManager("test-client", "1.0.0");
  try {
    await manager.removeServer("s1");
  } catch (e) {
    console.error("ERROR: removeServer threw: " + (e && e.stack || e));
    process.exit(2);
  }
  const rows = db.prepare("SELECT id FROM cf_agents_mcp_servers ORDER BY id").all().map((r) => r.id);
  if (rows.join(",") !== "s2") {
    console.error("ERROR: unexpected server rows after removeServer: " + JSON.stringify(rows));
    process.exit(2);
  }
  if (s2Keys.some((k) => !kv.has(k))) {
    console.error("ERROR: other server's OAuth keys were touched: " + JSON.stringify([...kv.keys()]));
    process.exit(2);
  }
  const left = s1Keys.filter((k) => kv.has(k));
  if (left.length === 0) {
    console.log("OK: removeServer cleared the removed server's saved OAuth keys and kept the other server's");
    process.exit(0);
  }
  if (left.length === s1Keys.length) {
    console.error("FAIL: removeServer left the removed server's saved OAuth keys in storage");
    process.exit(1);
  }
  console.error("ERROR: removeServer cleared only some OAuth keys: " + JSON.stringify(left));
  process.exit(2);
})().catch((e) => {
  console.error("ERROR: " + (e && e.stack || e));
  process.exit(2);
});
Source

`MCPClientManager.removeServer()` (and `Agent.removeMcpServer()`) now clears the server's saved OAuth state from Durable Object storage: tokens and client information (including those under earlier client IDs), verifiers, pending states and discovery state.

raw.githubusercontent.com