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
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);
});
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);
});
`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.