teapot-coding-agent 0.37.59 → 0.37.61
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.
- package/dist/acp/adapter.js +4 -72
- package/dist/agent/agent.js +61 -2247
- package/dist/agent/compact-rules.js +0 -53
- package/dist/agent/editorconfig.js +4 -56
- package/dist/agent/goal-audit.js +0 -34
- package/dist/agent/key-pool.js +0 -52
- package/dist/agent/kill.js +1 -13
- package/dist/agent/llm.js +9 -310
- package/dist/agent/mcp-oauth-manager.js +2 -255
- package/dist/agent/mcp-registry.js +1 -188
- package/dist/agent/mcp.js +1 -85
- package/dist/agent/meta-tools.js +0 -26
- package/dist/agent/plugins.js +1 -81
- package/dist/agent/provider-headers.js +1 -28
- package/dist/agent/skills.js +8 -94
- package/dist/agent/tools.js +16 -809
- package/dist/bus.js +1 -2
- package/dist/config-schema.js +0 -40
- package/dist/home.js +1 -19
- package/dist/index.js +0 -136
- package/dist/log/events.js +3 -195
- package/dist/master.js +31 -1180
- package/dist/migrations/index.js +0 -55
- package/dist/migrations/providers.js +0 -13
- package/dist/migrations/registry.js +0 -9
- package/dist/migrations/session-titles.js +3 -43
- package/dist/migrations/sessions.js +2 -20
- package/dist/model-meta.js +166 -142
- package/dist/scheduler/cron.js +2 -17
- package/dist/server/api.js +12 -778
- package/dist/server/image-resize.js +1 -64
- package/dist/server/term-size.js +1 -120
- package/dist/server/terminals.js +0 -159
- package/dist/session-title.js +2 -35
- package/package.json +1 -1
- package/public/assets/{index-KzHw4Sqo.js → index-C1eqcSbW.js} +4 -4
- package/public/assets/index-C1eqcSbW.js.map +1 -0
- package/public/assets/{shiki-BwDjoZdu.js → shiki-DaLn_bCe.js} +2 -2
- package/public/assets/shiki-DaLn_bCe.js.map +1 -0
- package/public/index.html +1 -1
- package/public/assets/index-KzHw4Sqo.js.map +0 -1
- package/public/assets/shiki-BwDjoZdu.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;
|
|
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;
|
|
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 });
|
package/dist/agent/meta-tools.js
CHANGED
|
@@ -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
|
}
|