teapot-coding-agent 0.37.58 → 0.37.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/acp/adapter.js +4 -72
  2. package/dist/agent/agent.js +52 -2246
  3. package/dist/agent/compact-rules.js +0 -53
  4. package/dist/agent/editorconfig.js +4 -56
  5. package/dist/agent/goal-audit.js +0 -34
  6. package/dist/agent/key-pool.js +0 -52
  7. package/dist/agent/kill.js +1 -13
  8. package/dist/agent/llm.js +8 -309
  9. package/dist/agent/mcp-oauth-manager.js +2 -255
  10. package/dist/agent/mcp-registry.js +1 -188
  11. package/dist/agent/mcp.js +1 -85
  12. package/dist/agent/meta-tools.js +0 -26
  13. package/dist/agent/plugins.js +1 -81
  14. package/dist/agent/provider-headers.js +1 -28
  15. package/dist/agent/skills.js +8 -94
  16. package/dist/agent/tools.js +16 -809
  17. package/dist/bus.js +1 -2
  18. package/dist/config-schema.js +0 -40
  19. package/dist/home.js +1 -19
  20. package/dist/index.js +0 -136
  21. package/dist/log/events.js +3 -195
  22. package/dist/master.js +31 -1176
  23. package/dist/migrations/index.js +0 -55
  24. package/dist/migrations/providers.js +0 -13
  25. package/dist/migrations/registry.js +0 -9
  26. package/dist/migrations/session-titles.js +3 -43
  27. package/dist/migrations/sessions.js +2 -20
  28. package/dist/model-meta.js +2 -74
  29. package/dist/scheduler/cron.js +2 -17
  30. package/dist/server/api.js +12 -778
  31. package/dist/server/image-resize.js +1 -64
  32. package/dist/server/term-size.js +1 -120
  33. package/dist/server/terminals.js +0 -159
  34. package/dist/session-title.js +2 -35
  35. package/package.json +1 -1
  36. package/public/assets/index-DhJx7VDq.js +36 -0
  37. package/public/assets/index-DhJx7VDq.js.map +1 -0
  38. package/public/assets/{shiki-BaLUHSNL.js → shiki-LBMkaWYb.js} +2 -2
  39. package/public/assets/shiki-LBMkaWYb.js.map +1 -0
  40. package/public/index.html +1 -1
  41. package/public/assets/index-D_f1aal3.js +0 -36
  42. package/public/assets/index-D_f1aal3.js.map +0 -1
  43. package/public/assets/shiki-BaLUHSNL.js.map +0 -1
@@ -1,40 +1,10 @@
1
- /**
2
- * The MCP server registry: connections, tool discovery, dispatch, and the OAuth
3
- * flow's plumbing.
4
- *
5
- * Why a registry instead of connecting per agent: several agents usually want the same
6
- * server, and a stdio server is a PROCESS. The pool keys by server name, so N agents share
7
- * one connection — and a `tools/call` is still per-agent, because the arguments are.
8
- *
9
- * Naming. Two servers may both expose `search`, and a server may expose a name that
10
- * collides with a built-in tool (`bash`, `read_file`). Every MCP tool is therefore exposed
11
- * as `mcp__<server>__<tool>`, which cannot collide with a built-in (those have no `__`)
12
- * and tells the model where a result came from.
13
- *
14
- * OAuth lives here because it is per SERVER (one grant, shared by every agent) while the
15
- * protocol itself belongs to the SDK — see `mcp-oauth-manager.ts` for the provider.
16
- */
17
- /**
18
- * `connectMcp` is imported LAZILY, and only as a value.
19
- *
20
- * A static import here pulls `mcp.ts`, which lazily loads `@modelcontextprotocol/client` — but a
21
- * static EDGE from this module means the bundler/runtime must resolve the whole file graph at load.
22
- * MEASURED: this module alone cost +48.7MB RSS. The registry is constructed at boot on every
23
- * machine, while a connection is only attempted when a server is actually configured.
24
- */
25
1
  const connectMcp = async (...args) => (await import("./mcp.js")).connectMcp(...args);
26
2
  import { FLOW_TIMEOUT_MS, McpAuthStore, ServerAuth, listenForCallback, } from "./mcp-oauth-manager.js";
27
- /** the prefix a model-visible MCP tool name carries */
28
3
  export const MCP_PREFIX = "mcp__";
29
- /** `mcp__server__tool` — the flat name handed to the LLM */
30
4
  export function flattenToolName(server, tool) {
31
- // `__` is the separator, so a server or tool name containing it would be ambiguous.
32
- // Collapse runs rather than rejecting: a server called "my__server" is unusual but not
33
- // worth failing over, and the mapping stays stable.
34
5
  const s = server.replace(/__/g, "_");
35
6
  return `${MCP_PREFIX}${s}__${tool}`;
36
7
  }
37
- /** pull the parts back out of a flat name, or null when it is not an MCP tool */
38
8
  export function splitToolName(flat) {
39
9
  if (!flat.startsWith(MCP_PREFIX))
40
10
  return null;
@@ -49,14 +19,9 @@ export class McpRegistry {
49
19
  auths = new Map();
50
20
  store = null;
51
21
  flows = new Map();
52
- /**
53
- * Point the registry at its grant store. Called once at boot; until then OAuth is
54
- * inert (no grant is read from an assumed location).
55
- */
56
22
  init(dataDir) {
57
23
  this.store = new McpAuthStore(dataDir);
58
24
  }
59
- /** replace the configured server list (on boot and after a settings save) */
60
25
  apply(configs) {
61
26
  const wanted = new Set(configs.map((c) => c.name));
62
27
  for (const [name, st] of this.servers) {
@@ -67,7 +32,6 @@ export class McpRegistry {
67
32
  }
68
33
  for (const cfg of configs) {
69
34
  const prev = this.servers.get(cfg.name);
70
- // a config EDIT means the old connection is pointing at the wrong thing
71
35
  const changed = Boolean(prev) && JSON.stringify(prev.config) !== JSON.stringify(cfg);
72
36
  if (changed)
73
37
  void prev.connection?.close();
@@ -91,45 +55,22 @@ export class McpRegistry {
91
55
  states() {
92
56
  return [...this.servers.values()];
93
57
  }
94
- /** the state of ONE server, or undefined when it is not configured */
95
58
  state(name) {
96
59
  return this.servers.get(name);
97
60
  }
98
- /**
99
- * Record an out-of-band successful connection for a SAVED server.
100
- *
101
- * `/api/mcp/test` opens a throwaway connection (it must not disturb the live one) but the result is
102
- * real evidence about the same config, so the row's badge has to reflect it. Without this a server
103
- * that failed at boot stayed `● error` after a test successfully listed its tools.
104
- */
105
61
  recordSuccess(name, tools) {
106
62
  const st = this.servers.get(name);
107
63
  if (!st)
108
- return; // testing an unsaved edit must not invent an entry
64
+ return;
109
65
  st.status = "connected";
110
66
  st.tools = tools;
111
67
  st.lastError = undefined;
112
68
  st.lastChecked = Date.now();
113
69
  st.oauthRequired = false;
114
70
  }
115
- /** the OAuth state of one server, for the settings panel */
116
71
  oauthStatus(name) {
117
72
  return this.auths.get(name)?.status() ?? { pending: false, authorized: false, expiresAt: null, hasRefreshToken: false, flow: { phase: "idle" } };
118
73
  }
119
- /**
120
- * The provider for one server, created on demand.
121
- *
122
- * The redirect URL here is used for CLIENT REGISTRATION, not just for the authorization request:
123
- * `clientMetadata.redirect_uris` is what gets registered on the first connect. A portless
124
- * placeholder therefore registers a redirect URI the real flow can never match — MEASURED, that
125
- * is exactly what happened (stored `redirect_uris: ["http://127.0.0.1/callback"]`), and it is why
126
- * a completed Notion authorization left no access token.
127
- *
128
- * `ServerAuth.clientInformation()` now refuses a registration whose URI differs from the current
129
- * one, so a stale record is re-made instead of reused. This value still cannot carry the real
130
- * port — none is known until `startOAuth` opens one — but it is now harmless, and keeping a
131
- * long-lived provider bound to a port that is not listening would be worse.
132
- */
133
74
  async authFor(name) {
134
75
  if (!this.store)
135
76
  return undefined;
@@ -137,25 +78,11 @@ export class McpRegistry {
137
78
  if (existing)
138
79
  return existing;
139
80
  const stored = await this.store.get(name);
140
- // a flow needs a redirect; the SDK only reads it during authorize, and
141
- // `startOAuth` replaces the instance with one carrying the real port
142
81
  const auth = new ServerAuth(name, "http://127.0.0.1/callback", (server, data) => this.store.set(server, data));
143
82
  await auth.hydrate(stored);
144
83
  this.auths.set(name, auth);
145
84
  return auth;
146
85
  }
147
- /**
148
- * Try a configuration WITHOUT disturbing the saved connection, using the SAVED credentials.
149
- *
150
- * `test` used `connectMcp(cfg)` with no auth provider "on purpose", so an authorized server
151
- * answered 401 and the row said "needs a grant (🔑 authorize)" while the same server was
152
- * `● connected` with 65 tools two lines above — reported as "Notionは 🔑 authorized なのに
153
- * Error POSTing to endpoint · needs a grant". A test that ignores the token it is testing cannot
154
- * answer the question the button asks.
155
- *
156
- * The connection is throwaway (closed by the caller) and the registry entry is only touched on
157
- * success, so a test against an unsaved edit cannot invent or corrupt an entry.
158
- */
159
86
  async testConfig(cfg) {
160
87
  const auth = await this.authFor(cfg.name);
161
88
  const authArg = auth ? { provider: auth } : undefined;
@@ -167,7 +94,6 @@ export class McpRegistry {
167
94
  await conn.close();
168
95
  }
169
96
  }
170
- /** connect one server (idempotent) and discover its tools */
171
97
  async connect(name) {
172
98
  const st = this.servers.get(name);
173
99
  if (!st)
@@ -177,19 +103,6 @@ export class McpRegistry {
177
103
  return st;
178
104
  }
179
105
  if (st.status === "connected" && st.connection) {
180
- /**
181
- * A cached connection is not proof the server is still there.
182
- *
183
- * This used to return immediately, so a server that restarted kept `status: "connected"` with
184
- * its OLD tool list while the transport underneath was dead. The UI's `test` button opened a
185
- * FRESH connection (`testConfig`), so it reported success and the tools appeared — and the
186
- * agent, going through this cached path, could not call any of them. Reported as "test をすると
187
- * 接続に成功するのに、エージェントからは呼べない".
188
- *
189
- * A cheap liveness probe settles it: a server that answers is reused, one that does not is
190
- * dropped so the reconnect below builds a new transport. `tools()` is the cheapest call that
191
- * exercises the transport for real (unlike reading the cached list).
192
- */
193
106
  try {
194
107
  st.tools = await st.connection.tools();
195
108
  st.lastChecked = Date.now();
@@ -220,11 +133,6 @@ export class McpRegistry {
220
133
  st.lastChecked = Date.now();
221
134
  st.connection = undefined;
222
135
  st.tools = [];
223
- // The SDK reports a missing grant as an UnauthorizedError (or an HTTP 401); both
224
- // mean "authorize", which is a different situation from "broken".
225
- // The SDK reports a missing grant as an HTTP error carrying the STATUS. Reading
226
- // `status` is reliable; matching the message text is not, because the SDK's message
227
- // is just "Error POSTing to endpoint: <body>" and a server may send an empty body —
228
136
  const status = err.status;
229
137
  const msg = err.message ?? "";
230
138
  const needsAuth = status === 401 ||
@@ -237,24 +145,20 @@ export class McpRegistry {
237
145
  throw err;
238
146
  }
239
147
  }
240
- /** connect every enabled server; failures are recorded, never thrown */
241
148
  async connectAll() {
242
149
  await Promise.all([...this.servers.keys()].map(async (n) => {
243
150
  try {
244
151
  await this.connect(n);
245
152
  }
246
153
  catch {
247
- /* recorded in the state; one dead server must not stop the others */
248
154
  }
249
155
  }));
250
156
  }
251
- /** every tool from every CONNECTED server, flattened */
252
157
  allTools() {
253
158
  return [...this.servers.values()]
254
159
  .filter((s) => s.status === "connected")
255
160
  .flatMap((s) => s.tools);
256
161
  }
257
- /** dispatch a flattened name; returns null when it is not an MCP tool */
258
162
  async call(flatName, args) {
259
163
  const parts = splitToolName(flatName);
260
164
  if (!parts)
@@ -262,8 +166,6 @@ export class McpRegistry {
262
166
  const st = this.servers.get(parts.server);
263
167
  if (!st)
264
168
  return { ok: false, text: `no such MCP server: ${parts.server}` };
265
- // a call for a configured-but-unconnected server connects on demand: adding one
266
- // mid-session must not need a restart
267
169
  if (st.status !== "connected" || !st.connection) {
268
170
  try {
269
171
  await this.connect(parts.server);
@@ -277,14 +179,6 @@ export class McpRegistry {
277
179
  return { ok: r.ok, text: r.text };
278
180
  }
279
181
  catch (err) {
280
- /**
281
- * A failed call means the transport is suspect, not just this one call.
282
- *
283
- * Leaving `status: "connected"` made the next attempt reuse the same dead connection, so one
284
- * server restart broke every subsequent call until the process restarted. Dropping the
285
- * connection here lets `connect()` rebuild it on the next attempt — and the tools list is
286
- * cleared with it, because a stale list is what let the UI show tools that could not be called.
287
- */
288
182
  const st = this.servers.get(parts.server);
289
183
  if (st) {
290
184
  st.connection = undefined;
@@ -296,24 +190,8 @@ export class McpRegistry {
296
190
  return { ok: false, text: `MCP call failed: ${err.message}` };
297
191
  }
298
192
  }
299
- /**
300
- * Start an OAuth flow.
301
- *
302
- * Opens the loopback listener, points the provider at it, and makes a connect attempt
303
- * so the SDK performs discovery and produces the authorization URL. The returned URL is
304
- * what the UI opens; the promise behind it settles when the browser comes back.
305
- */
306
- /**
307
- * Register a server that is not in the saved config yet, so a flow can run against it.
308
- *
309
- * The settings panel lets the operator authorize a server before saving it — the alternative was
310
- * save → the dialog closes → reopen → authorize → save again. The grant lives in the OAuth store
311
- * keyed by NAME, so nothing here needs to reach `config.mcpServers`; a later `apply()` with the
312
- * saved list replaces this entry.
313
- */
314
193
  registerUnsaved(cfg) {
315
194
  const existing = this.servers.get(cfg.name);
316
- // never clobber a live connection with a draft
317
195
  if (existing?.connection)
318
196
  return;
319
197
  if (existing)
@@ -331,7 +209,6 @@ export class McpRegistry {
331
209
  if (existing)
332
210
  await this.finishFlow(name, existing);
333
211
  const loop = await listenForCallback();
334
- // a provider bound to THIS listener's port
335
212
  const auth = new ServerAuth(name, loop.redirectUrl, (server, data) => this.store.set(server, data));
336
213
  if (this.store)
337
214
  await auth.hydrate(await this.store.get(name));
@@ -343,26 +220,10 @@ export class McpRegistry {
343
220
  close: loop.close,
344
221
  };
345
222
  this.flows.set(name, pending);
346
- // A connect attempt is what drives the SDK: it discovers the authorization server,
347
- // registers a client if needed, and calls redirectToAuthorization before throwing.
348
223
  st.oauthRequired = true;
349
- /**
350
- * KEEP the connection from the 401, not just its side effects.
351
- *
352
- * `connectMcp` throws on the 401 that starts the flow, but the transport inside it is the ONE
353
- * that will redeem the code — a transport cannot be re-started ("already started"), so building
354
- * a second one here and calling `finishAuth` on it is not equivalent. MEASURED: calling
355
- * `connectMcp` again in the callback handler produced another Unauthorized (no token yet) and
356
- * the exchange never ran, which is why BOTH Notion and Canva reported
357
- * `UnauthorizedError: Unauthorized`.
358
- *
359
- * `pendingConn` holds the instance whose provider already did discovery; the callback leg
360
- * finishes the exchange on it and gets a fresh, usable connection back.
361
- */
362
224
  let pendingConn = null;
363
225
  try {
364
226
  const conn = await connectMcp(st.config, { provider: auth });
365
- // already authorized (a stored refresh token was good): no flow needed
366
227
  st.connection = conn;
367
228
  st.tools = await conn.tools();
368
229
  st.status = "connected";
@@ -371,10 +232,6 @@ export class McpRegistry {
371
232
  return { url: "" };
372
233
  }
373
234
  catch (err) {
374
- /**
375
- * The failed attempt still produced a connection object with a live provider. `connectMcp`
376
- * only closes the client when NO provider was supplied, precisely so this path can continue.
377
- */
378
235
  pendingConn = err.connection ?? null;
379
236
  const url = auth.pendingUrl();
380
237
  if (!url) {
@@ -384,44 +241,12 @@ export class McpRegistry {
384
241
  throw new Error(`could not start the authorization flow: ${message}`);
385
242
  }
386
243
  st.status = "authorizing";
387
- // complete in the background once the browser arrives
388
244
  void (async () => {
389
245
  try {
390
246
  const { code, iss } = await loop.wait(FLOW_TIMEOUT_MS);
391
- /**
392
- * THE CONNECTION FROM THE 401, not a new one.
393
- *
394
- * This used to call `connectMcp` again, which — with no token yet — 401s a SECOND time.
395
- * `finishAuth` therefore never ran, and both Notion and Canva surfaced
396
- * `UnauthorizedError: Unauthorized` after a completed browser authorization. The
397
- * exchange could only ever happen on the transport that started the flow.
398
- */
399
247
  const conn = pendingConn;
400
248
  if (!conn)
401
249
  throw new Error("the authorization flow lost its connection");
402
- /**
403
- * Supply `iss` from OUR discovery record when the callback omitted it.
404
- *
405
- * RFC 9207 §2 says a server that advertises
406
- * `authorization_response_iss_parameter_supported: true` MUST include `iss` in the
407
- * response, and §2.4 says clients MUST reject a response without it. Notion advertises
408
- * `true` and then sends only `code` and `state`, so a strictly-conforming client discards
409
- * every completed authorization — which is "Notionがコールバック踏んだのに Unauthorized".
410
- *
411
- * §2.4 also allows the opposite: "clients ... MAY accept authorization responses that do
412
- * not contain the iss parameter ... Local policy or configuration can determine whether to
413
- * accept such responses". We accept them, using the issuer we recorded BEFORE sending the
414
- * browser out, because that is the value the mix-up check needs.
415
- *
416
- * no iss -> IssuerMismatchError "received undefined" (the reported failure)
417
- * correct iss -> the token endpoint is reached (invalid_grant for a dummy code)
418
- * wrong iss -> IssuerMismatchError "received https://evil.example"
419
- * so the check is not disabled — a genuinely mismatched issuer is still rejected.
420
- */
421
- /**
422
- * `finishAuth` returns a NEW connection: the instance that redeemed the code cannot serve
423
- * requests (a transport is one-shot), so storing `conn` here would leave a dead handle.
424
- */
425
250
  const live = await conn.finishAuth(code, iss ?? auth.expectedIssuer());
426
251
  await conn.close().catch(() => { });
427
252
  st.connection = live;
@@ -432,15 +257,6 @@ export class McpRegistry {
432
257
  await this.finishFlow(name, pending);
433
258
  }
434
259
  catch (e) {
435
- /**
436
- * Report the CAUSE, not just the message.
437
- *
438
- * `UnauthorizedError` defaults its message to the bare string "Unauthorized" when the SDK
439
- * constructs it without one (measured in the SDK source: `super(message ?? "Unauthorized")`).
440
- * That is what the panel was showing, and it says nothing about which step failed — the
441
- * exchange, the reconnect after it, or the original 401. The class name and any `cause`
442
- * are the difference between "try again" and a diagnosis.
443
- */
444
260
  const err = e;
445
261
  const name = err.constructor?.name ?? "Error";
446
262
  const cause = err.cause instanceof Error ? err.cause.message : String(err.cause ?? "");
@@ -458,13 +274,11 @@ export class McpRegistry {
458
274
  return { url };
459
275
  }
460
276
  }
461
- /** close a flow's listener and record its outcome */
462
277
  async finishFlow(name, f) {
463
278
  if (this.flows.get(name) === f)
464
279
  this.flows.delete(name);
465
280
  await f.close().catch(() => { });
466
281
  }
467
- /** forget a grant (disconnect) */
468
282
  async disconnect(name) {
469
283
  const auth = this.auths.get(name);
470
284
  if (auth)
@@ -490,5 +304,4 @@ export class McpRegistry {
490
304
  }
491
305
  }
492
306
  }
493
- /** process-wide registry: one connection per server, shared by every agent */
494
307
  export const mcp = new McpRegistry();
package/dist/agent/mcp.js CHANGED
@@ -1,30 +1,4 @@
1
- /**
2
- * MCP client (#67), on `@modelcontextprotocol/client` v2.
3
- *
4
- * The hand-written protocol layer is GONE. It had been carrying Streamable HTTP, legacy
5
- * HTTP+SSE and stdio, each with its own JSON-RPC framing, session-id handling and SSE
6
- * parsing — which is exactly the surface the official client now owns, and protocol
7
- * details (new protocol versions, new transports, task-based calls) move under it.
8
- *
9
- * What teapot keeps is the part that is NOT protocol: which servers are configured, how
10
- * one connection is shared across agents, tool-name namespacing, and the OAuth STORE
11
- * (where the grant lives). The SDK supplies the discovery and the flow.
12
- *
13
- * Discovery deliberately uses the SDK's own `buildDiscoveryUrls` order rather than a
14
- * hand-rolled one. MEASURED against the live servers before the rewrite:
15
- *
16
- * GitHub (issuer https://github.com/login/oauth) serves ONLY
17
- * https://github.com/.well-known/oauth-authorization-server/login/oauth 200
18
- * https://github.com/login/oauth/.well-known/oauth-authorization-server 404
19
- * Notion (issuer https://mcp.notion.com, no path) serves the root form.
20
- *
21
- * and the SDK tries exactly: RFC 8414 pre-path oauth → pre-path OIDC → post-path OIDC.
22
- * It does NOT try the post-path `oauth-authorization-server` shape, which is correct —
23
- * no MCP server is known to publish it, and guessing more shapes risks accepting a
24
- * document from a different issuer.
25
- */
26
1
  let sdkPromise;
27
- /** resolved once, on the first connection attempt */
28
2
  async function mcpSdk() {
29
3
  if (!sdkPromise) {
30
4
  sdkPromise = (async () => {
@@ -37,34 +11,26 @@ async function mcpSdk() {
37
11
  }
38
12
  return sdkPromise;
39
13
  }
40
- /** pull the text out of an MCP tool result's content array */
41
14
  function resultText(result) {
42
15
  const parts = [];
43
16
  for (const c of result?.content ?? []) {
44
17
  if (c?.type === "text" && typeof c.text === "string")
45
18
  parts.push(c.text);
46
- // name the parts we cannot render rather than dropping them silently: a json or
47
- // image part vanishing would look like an empty answer
48
19
  else if (c?.type)
49
20
  parts.push(`[${c.type} content omitted]`);
50
21
  }
51
22
  return { text: parts.join("\n") || "(no content)", ok: result?.isError !== true };
52
23
  }
53
- /** normalise what a server reports into teapot's tool shape */
54
24
  function toTools(list, server) {
55
25
  const out = [];
56
26
  for (const t of list?.tools ?? []) {
57
27
  const name = typeof t.name === "string" ? t.name : "";
58
28
  if (!name)
59
- continue; // a nameless tool is unusable either way
29
+ continue;
60
30
  const schema = t.inputSchema;
61
31
  out.push({
62
32
  name,
63
33
  description: typeof t.description === "string" ? t.description : "",
64
- // The SDK VALIDATES that every tool carries an object `inputSchema`, so this
65
- // fallback is only a type guard — a server that omits it fails at `tools()` before
66
- // reaching here. Kept so a future protocol version that makes it optional does not
67
- // hand the model an undefined schema.
68
34
  inputSchema: schema && typeof schema === "object" && !Array.isArray(schema)
69
35
  ? schema
70
36
  : { type: "object", properties: {} },
@@ -73,17 +39,9 @@ function toTools(list, server) {
73
39
  }
74
40
  return out;
75
41
  }
76
- /**
77
- * Connect to one configured server.
78
- *
79
- * `auth` is optional: without it the transports send only the static headers, which is
80
- * the common case (a local server, or a static API key).
81
- */
82
42
  function buildConnection(cfg, client, session, auth) {
83
43
  return {
84
44
  async tools() {
85
- // one page is the norm; follow the cursor so a server that paginates is not
86
- // silently truncated
87
45
  const all = [];
88
46
  let cursor;
89
47
  const seen = new Set();
@@ -105,25 +63,9 @@ function buildConnection(cfg, client, session, auth) {
105
63
  return resultText(res);
106
64
  },
107
65
  async finishAuth(code, iss) {
108
- // a capability test, not `instanceof`: the SDK classes do not exist until the import has run
109
66
  if (typeof session.finishAuth !== "function")
110
67
  throw new Error("this transport has no OAuth flow");
111
- /**
112
- * Exchange on THIS transport — the PKCE verifier and the discovery record are read from the
113
- * provider, but the request is issued with this instance's fetch/init.
114
- */
115
68
  await session.finishAuth(iss === undefined ? code : new URLSearchParams({ code, iss }));
116
- /**
117
- * …then build a FRESH connection, because this one cannot be revived.
118
- *
119
- * A transport is one-shot: after the 401 that started the flow, connecting again throws
120
- * "already started" even without `close()`. MEASURED, and it is why the flow appeared to
121
- * complete and then report `UnauthorizedError: Unauthorized` — `client.connect` on a spent
122
- * transport fails with no message of its own, and the SDK's UnauthorizedError defaults its
123
- * text to the word "Unauthorized".
124
- *
125
- * The provider is shared, so the new instance reads the tokens the exchange just stored.
126
- */
127
69
  await client.close().catch(() => { });
128
70
  return await connectMcp(cfg, auth);
129
71
  },
@@ -146,8 +88,6 @@ export async function connectMcp(cfg, auth) {
146
88
  args: cfg.args ?? [],
147
89
  env: { ...process.env, ...(cfg.env ?? {}) },
148
90
  ...(cfg.cwd ? { cwd: cfg.cwd } : {}),
149
- // a server that logs to stdout can corrupt the frame stream; the SDK's stderr
150
- // option ("inherit" by default) is the place to say otherwise
151
91
  stderr: "inherit",
152
92
  });
153
93
  }
@@ -158,8 +98,6 @@ export async function connectMcp(cfg, auth) {
158
98
  if (!["http:", "https:"].includes(url.protocol))
159
99
  throw new Error(`MCP server "${cfg.name}" must use http(s), not ${url.protocol}`);
160
100
  session = new sdk.SSEClientTransport(url, {
161
- // `manual`, NOT follow: an MCP endpoint that redirects is nearly always an HTML
162
- // login page, and following it replaces a clear auth problem with a parse error
163
101
  redirectPolicy: "same-origin",
164
102
  ...(cfg.headers ? { requestInit: { headers: cfg.headers } } : {}),
165
103
  ...(auth ? { authProvider: auth.provider } : {}),
@@ -181,30 +119,8 @@ export async function connectMcp(cfg, auth) {
181
119
  await client.connect(session);
182
120
  }
183
121
  catch (err) {
184
- /**
185
- * A 401 is NOT a dead connection — it is the first half of the OAuth flow.
186
- *
187
- * `client.connect()` failing with UnauthorizedError means the server wants a grant. Closing the
188
- * client here used to destroy the only transport that could complete it: a transport cannot be
189
- * re-started ("StreamableHTTPClientTransport already started!") and `finishAuth()` exchanges the
190
- * code through THIS instance's provider, so the second `connect()` after `finishAuth` always
191
- * threw — surfacing as a bare `UnauthorizedError: Unauthorized` even though the browser had
192
- * completed the authorization and the token WAS saved.
193
- *
194
- * MEASURED: connect → 401; close; connect again → "already started". And with the exchange
195
- * succeeding, the tokens were present and the connection still failed.
196
- *
197
- * So the client stays open when the caller supplied an auth provider (it may need to finish the
198
- * flow on it). Without a provider there is nothing to complete, so it is closed as before.
199
- */
200
122
  if (!auth)
201
123
  await client.close().catch(() => { });
202
- /**
203
- * Attach the connection so the OAuth caller can CONTINUE on this exact instance.
204
- *
205
- * The transport that received the 401 is the one that must redeem the code (a transport cannot
206
- * be restarted), so the caller needs a handle to it — rebuilding one loses the flow.
207
- */
208
124
  if (auth) {
209
125
  const conn = buildConnection(cfg, client, session, auth);
210
126
  throw Object.assign(err, { connection: conn });
@@ -1,27 +1,3 @@
1
- /**
2
- * The META tools: the ones the AGENT LOOP handles itself instead of dispatching to
3
- * `executeTool` (finish, the goal/todo/memory/feedback tools, ask_user…).
4
- *
5
- * ## Why this is a table and not two lists
6
- *
7
- * A meta tool used to be declared in TWO hand-maintained places: a 389-line block of
8
- * `if (call.function.name === "…")` in `runTurnsUntilIdle`, and its spec among the
9
- * literals in `allToolSpecs`. Nothing connected them, so the failure modes were silent:
10
- *
11
- * - a spec with no branch → the model calls a tool that falls through to
12
- * `executeTool`, which answers "unknown tool" even though the prompt advertised it;
13
- * - a branch with no spec → dead code, and the model never learns the tool exists.
14
- *
15
- * Named in one place, both mistakes become a missing key rather than a runtime surprise.
16
- * `META_TOOL_NAMES` is derived from this table, so `allToolSpecs()` cannot advertise a
17
- * meta tool the loop does not handle (see `test/meta-tools.test.ts`).
18
- */
19
- /**
20
- * Every meta tool, in the order the model sees them.
21
- *
22
- * Adding one: add the entry here AND the matching branch in `runTurnsUntilIdle`. The test
23
- * asserts the two agree, so a half-done addition fails rather than shipping.
24
- */
25
1
  export const META_TOOLS = [
26
2
  {
27
3
  name: "finish",
@@ -310,9 +286,7 @@ export const META_TOOLS = [
310
286
  },
311
287
  },
312
288
  ];
313
- /** the names the tool loop must handle itself, in wire order */
314
289
  export const META_TOOL_NAMES = META_TOOLS.map((t) => t.name);
315
- /** just the specs, for splicing into the full tool list */
316
290
  export function metaToolSpecs() {
317
291
  return META_TOOLS.map((t) => t.spec);
318
292
  }