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,44 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OAuth for MCP servers (#67), on the official client's `OAuthClientProvider`.
|
|
3
|
-
*
|
|
4
|
-
* The hand-written discovery/PKCE/token-exchange layer is GONE. What remains is the part
|
|
5
|
-
* the SDK cannot own: **where the grant is stored**, and **how the operator completes the
|
|
6
|
-
* browser step**. The SDK calls this interface at each stage:
|
|
7
|
-
*
|
|
8
|
-
* clientMetadata() → who we are, for dynamic registration
|
|
9
|
-
* clientInformation() → the client id/secret obtained earlier, or undefined
|
|
10
|
-
* saveClientInformation() → persist what registration returned
|
|
11
|
-
* tokens() → the current grant, or undefined
|
|
12
|
-
* saveTokens() → persist a token PAIR
|
|
13
|
-
* redirectToAuthorization() → the URL to open in a browser
|
|
14
|
-
* saveCodeVerifier() → keep the PKCE verifier for the exchange
|
|
15
|
-
* codeVerifier() → hand it back afterwards
|
|
16
|
-
*
|
|
17
|
-
* Storage choices, unchanged from the previous implementation because they are the
|
|
18
|
-
* security-relevant part and were already right:
|
|
19
|
-
* - **access token: memory only.** It is short-lived and bound to a fingerprint of the
|
|
20
|
-
* client credentials, so a changed client id can never reuse a stale token.
|
|
21
|
-
* - **refresh token + client info: on disk at 0600**, beside the data dir, never in
|
|
22
|
-
* `config.json` (which operators copy around).
|
|
23
|
-
*
|
|
24
|
-
* The redirect is a real LOOPBACK listener: this is a local harness with a browser UI, so
|
|
25
|
-
* the tab can close itself. The reference implementations (easywebui, deepseek-pp) run
|
|
26
|
-
* serverless and need a host-provided browser identity instead.
|
|
27
|
-
*/
|
|
28
1
|
import { createServer } from "node:http";
|
|
29
2
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
30
3
|
import path from "node:path";
|
|
31
|
-
/** how long a started flow waits for the browser */
|
|
32
4
|
export const FLOW_TIMEOUT_MS = 5 * 60_000;
|
|
33
|
-
/**
|
|
34
|
-
* One server's OAuth state.
|
|
35
|
-
*
|
|
36
|
-
* The pieces the SDK needs and the pieces the UI needs live together because they are
|
|
37
|
-
* two views of the same grant: a token saved here is what `status()` reports as
|
|
38
|
-
* authorized.
|
|
39
|
-
*/
|
|
40
5
|
export class ServerAuth {
|
|
41
|
-
/** the URL the browser must be sent to, set by `redirectToAuthorization` */
|
|
42
6
|
authUrl;
|
|
43
7
|
verifier;
|
|
44
8
|
flow = { phase: "idle" };
|
|
@@ -47,60 +11,29 @@ export class ServerAuth {
|
|
|
47
11
|
server;
|
|
48
12
|
redirectUrl;
|
|
49
13
|
persist;
|
|
50
|
-
// Explicit fields, NOT parameter properties (`constructor(readonly x)`): Node's
|
|
51
|
-
// type-stripping loader refuses that syntax and `pnpm test` runs the sources directly
|
|
52
|
-
// (ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX).
|
|
53
14
|
constructor(server, redirectUrl, persist) {
|
|
54
15
|
this.server = server;
|
|
55
16
|
this.redirectUrl = redirectUrl;
|
|
56
17
|
this.persist = persist;
|
|
57
18
|
}
|
|
58
|
-
/** load persisted state (called once, before the server is connected) */
|
|
59
19
|
async hydrate(data) {
|
|
60
20
|
if (!data)
|
|
61
21
|
return;
|
|
62
22
|
this.store = { ...data };
|
|
63
|
-
/**
|
|
64
|
-
* Restore the PKCE verifier alongside the rest.
|
|
65
|
-
*
|
|
66
|
-
* `codeVerifier()` reads the in-memory field and throws when it is empty, so a rebuilt instance
|
|
67
|
-
* would refuse the exchange even though the verifier is on disk. MEASURED cause of Notion's
|
|
68
|
-
* `invalid_grant`: `saveCodeVerifier` never persisted, and even once it does, nothing read the
|
|
69
|
-
* value back into the field that the exchange actually uses.
|
|
70
|
-
*/
|
|
71
23
|
if (data.codeVerifier)
|
|
72
24
|
this.verifier = data.codeVerifier;
|
|
73
25
|
}
|
|
74
|
-
/** the persisted payload, for the store */
|
|
75
26
|
snapshot() {
|
|
76
27
|
return { ...this.store };
|
|
77
28
|
}
|
|
78
|
-
// ---- OAuthClientProvider ----
|
|
79
29
|
get clientMetadata() {
|
|
80
30
|
return {
|
|
81
31
|
client_name: "teapot",
|
|
82
|
-
// a local harness is a PUBLIC client: it cannot keep a secret meaningful, and the
|
|
83
|
-
// SDK sends `token_endpoint_auth_method: none` for exactly this shape
|
|
84
32
|
redirect_uris: [this.redirectUrl],
|
|
85
33
|
grant_types: ["authorization_code", "refresh_token"],
|
|
86
34
|
response_types: ["code"],
|
|
87
35
|
};
|
|
88
36
|
}
|
|
89
|
-
/**
|
|
90
|
-
* Does a recorded redirect URI describe THIS loopback listener?
|
|
91
|
-
*
|
|
92
|
-
* The callback binds an EPHEMERAL port (`server.listen(0)`), so `redirectUrl` carries a different
|
|
93
|
-
* port on every attempt while the registration is bound to the one it was created with. Comparing
|
|
94
|
-
* them literally means "never matches", which re-registers on every flow — and re-registering
|
|
95
|
-
* issues a NEW client_id, which `saveClientInformation` correctly treats as invalidating the
|
|
96
|
-
* grant. MEASURED: Canva's store had `clientId`/`clientInformation` and NO `accessToken`/
|
|
97
|
-
* `refreshToken`, i.e. the authorization kept working and the token was thrown away by this loop.
|
|
98
|
-
*
|
|
99
|
-
* A loopback port is an implementation detail of the listener, not an identity of the client: the
|
|
100
|
-
* authorization server redirects to whatever port the request named, and RFC 8252 §7.3 explicitly
|
|
101
|
-
* allows a loopback redirect to use any port. So the comparison ignores it, and only the
|
|
102
|
-
* scheme/host/path have to agree.
|
|
103
|
-
*/
|
|
104
37
|
redirectMatches(recorded) {
|
|
105
38
|
if (recorded === this.redirectUrl)
|
|
106
39
|
return true;
|
|
@@ -110,52 +43,24 @@ export class ServerAuth {
|
|
|
110
43
|
const loopback = (h) => h === "127.0.0.1" || h === "::1" || h === "localhost";
|
|
111
44
|
if (!loopback(a.hostname) || !loopback(b.hostname))
|
|
112
45
|
return false;
|
|
113
|
-
// any loopback host is interchangeable with any other, and the port is the listener's
|
|
114
46
|
return a.protocol === b.protocol && a.pathname === b.pathname;
|
|
115
47
|
}
|
|
116
48
|
catch {
|
|
117
|
-
return false;
|
|
49
|
+
return false;
|
|
118
50
|
}
|
|
119
51
|
}
|
|
120
52
|
clientInformation() {
|
|
121
53
|
if (!this.store.clientId)
|
|
122
54
|
return undefined;
|
|
123
|
-
/**
|
|
124
|
-
* A registration made with a DIFFERENT redirect URI cannot be reused.
|
|
125
|
-
*
|
|
126
|
-
* The callback listens on an ephemeral port (`server.listen(0)`), so `redirectUrl` carries a
|
|
127
|
-
* new port on every attempt. A registration is bound to the `redirect_uris` it was created
|
|
128
|
-
* with, and the authorize request must match — so a stored registration from another port would
|
|
129
|
-
* have to be re-made.
|
|
130
|
-
*
|
|
131
|
-
* MEASURED from this machine's real store: `clientInformation.redirect_uris` was
|
|
132
|
-
* `["http://127.0.0.1/callback"]` — NO PORT, because it was registered from the placeholder
|
|
133
|
-
* provider in `authFor()` — while every flow runs on `http://127.0.0.1:<port>/callback`. The
|
|
134
|
-
* browser completed the authorization, `codeVerifier` was persisted, and no `access_token` was
|
|
135
|
-
* ever written: the exchange never got past the URI check.
|
|
136
|
-
*
|
|
137
|
-
* Returning `undefined` makes the SDK register afresh against the CURRENT redirect URI, which
|
|
138
|
-
* is the only value that can match. The client id/secret are kept, so anything the server bound
|
|
139
|
-
* to them survives; only the redirect list is re-established.
|
|
140
|
-
*/
|
|
141
55
|
const stored = this.store.clientInformation;
|
|
142
56
|
if (stored) {
|
|
143
57
|
const uris = Array.isArray(stored.redirect_uris)
|
|
144
58
|
? stored.redirect_uris
|
|
145
59
|
: [];
|
|
146
|
-
/**
|
|
147
|
-
* Only a RECORDED mismatch forces a re-registration.
|
|
148
|
-
*
|
|
149
|
-
* An empty list means the server never told us which URI it bound — some registration
|
|
150
|
-
* responses omit `redirect_uris` entirely, and a record written by an older teapot has none.
|
|
151
|
-
* Treating "unknown" as "wrong" would re-register on every single flow and lose whatever the
|
|
152
|
-
* server bound to the client id, so absence keeps the registration.
|
|
153
|
-
*/
|
|
154
60
|
if (uris.length === 0)
|
|
155
61
|
return stored;
|
|
156
62
|
if (uris.some((u) => this.redirectMatches(String(u))))
|
|
157
63
|
return stored;
|
|
158
|
-
// a recorded URI that cannot match this run: re-register rather than send one it never saw
|
|
159
64
|
return undefined;
|
|
160
65
|
}
|
|
161
66
|
return {
|
|
@@ -163,30 +68,11 @@ export class ServerAuth {
|
|
|
163
68
|
...(this.store.clientSecret ? { client_secret: this.store.clientSecret } : {}),
|
|
164
69
|
};
|
|
165
70
|
}
|
|
166
|
-
/**
|
|
167
|
-
* Is the STORED registration unusable for this run?
|
|
168
|
-
*
|
|
169
|
-
* True when a redirect URI was recorded and it is not the one this flow listens on — the same
|
|
170
|
-
* condition `clientInformation()` acts on, factored out so `tokens()` cannot disagree with it.
|
|
171
|
-
*/
|
|
172
|
-
/**
|
|
173
|
-
* Is the STORED registration unusable for this run?
|
|
174
|
-
*
|
|
175
|
-
* The SAME comparison `clientInformation()` uses (`redirectMatches`), factored out so `tokens()`
|
|
176
|
-
* cannot disagree with it about one record. Everything below used to be re-derived here, and the
|
|
177
|
-
* two definitions drifted twice — once reporting stale where the other reused, once the reverse.
|
|
178
|
-
*
|
|
179
|
-
* - no recorded list → not stale. "Unknown" is not "wrong": some registration responses omit
|
|
180
|
-
* `redirect_uris`, and treating absence as a mismatch re-registers on every flow.
|
|
181
|
-
* - a loopback record → matches whatever port this run listens on (see `redirectMatches`), which
|
|
182
|
-
* is what stops the re-register → new client_id → grant-dropped loop.
|
|
183
|
-
* - anything else → stale: a record naming a different host cannot be reused.
|
|
184
|
-
*/
|
|
185
71
|
registrationIsStale() {
|
|
186
72
|
const stored = this.store.clientInformation;
|
|
187
73
|
const uris = Array.isArray(stored?.redirect_uris) ? stored.redirect_uris : [];
|
|
188
74
|
if (uris.length === 0)
|
|
189
|
-
return false;
|
|
75
|
+
return false;
|
|
190
76
|
return !uris.some((u) => this.redirectMatches(String(u)));
|
|
191
77
|
}
|
|
192
78
|
async saveClientInformation(info, _ctx) {
|
|
@@ -196,55 +82,15 @@ export class ServerAuth {
|
|
|
196
82
|
this.store.clientId = i.client_id;
|
|
197
83
|
if (typeof i?.client_secret === "string")
|
|
198
84
|
this.store.clientSecret = i.client_secret;
|
|
199
|
-
/**
|
|
200
|
-
* A NEW client id INVALIDATES the grant issued to the old one.
|
|
201
|
-
*
|
|
202
|
-
* `clientInformation()` returns undefined when the stored registration cannot match this run's
|
|
203
|
-
* redirect URI, which makes the SDK register afresh. Keeping the old tokens across that is fatal:
|
|
204
|
-
* the refresh request authenticates as the NEW client while presenting a refresh token the
|
|
205
|
-
* authorization server bound to the OLD one, and the server answers
|
|
206
|
-
* `invalid_grant: Client ID mismatch` — MEASURED against both Notion and Canva, where every
|
|
207
|
-
* reconnect failed for exactly this reason while the store still held a valid-looking token.
|
|
208
|
-
*
|
|
209
|
-
* Dropping them turns that into an honest "needs a grant": the panel shows 🔑 authorize, and
|
|
210
|
-
* completing it issues a token pair for the client that actually exists.
|
|
211
|
-
*/
|
|
212
85
|
if (previous && typeof i?.client_id === "string" && i.client_id !== previous) {
|
|
213
86
|
this.store.accessToken = undefined;
|
|
214
87
|
this.store.refreshToken = undefined;
|
|
215
88
|
this.store.expiresAt = undefined;
|
|
216
89
|
}
|
|
217
|
-
// keep the whole payload: a future field (a client id issued with metadata) survives
|
|
218
90
|
this.store.clientInformation = info;
|
|
219
91
|
await this.persist(this.server, this.store);
|
|
220
92
|
}
|
|
221
93
|
tokens(_ctx) {
|
|
222
|
-
/**
|
|
223
|
-
* A grant whose client can no longer be reused must not be presented.
|
|
224
|
-
*
|
|
225
|
-
* `clientInformation()` returns undefined when the stored registration cannot match this run's
|
|
226
|
-
* redirect URI, which makes the SDK register afresh — but `tokens()` would still hand back the
|
|
227
|
-
* token pair the OLD client obtained. The refresh then authenticates as the new client while
|
|
228
|
-
* presenting the old client's refresh token, and the server answers
|
|
229
|
-
* `invalid_grant: Client ID mismatch` (MEASURED against Notion and Canva: every reconnect failed
|
|
230
|
-
* this way while the store held a token that looked valid).
|
|
231
|
-
*
|
|
232
|
-
* Returning nothing makes the flow an honest "needs a grant" instead: the panel offers 🔑, and
|
|
233
|
-
* completing it issues a pair for the client that actually exists.
|
|
234
|
-
*/
|
|
235
|
-
/**
|
|
236
|
-
* A registration we cannot vouch for means DO NOT PRESENT the grant — never DELETE it.
|
|
237
|
-
*
|
|
238
|
-
* This used to clear the stored tokens here, and clearing is not the same as declining: the
|
|
239
|
-
* store persists on the next write, so an "I cannot verify this" answer from a placeholder
|
|
240
|
-
* provider ERASED a working grant minutes after it was obtained (MEASURED on this machine:
|
|
241
|
-
* both Notion's and Canva's `accessToken`/`refreshToken` were gone from mcp-oauth.json while the
|
|
242
|
-
* authorizations themselves had succeeded).
|
|
243
|
-
*
|
|
244
|
-
* Declining is reversible — the next connect that CAN verify the registration reads the tokens
|
|
245
|
-
* again. Erasing is not: it costs the operator a full re-authorization for a transient
|
|
246
|
-
* uncertainty.
|
|
247
|
-
*/
|
|
248
94
|
if (this.store.accessToken && this.registrationIsStale())
|
|
249
95
|
return undefined;
|
|
250
96
|
if (!this.store.accessToken)
|
|
@@ -259,8 +105,6 @@ export class ServerAuth {
|
|
|
259
105
|
}
|
|
260
106
|
async saveTokens(tokens, _ctx) {
|
|
261
107
|
this.store.accessToken = tokens.access_token;
|
|
262
|
-
// PERSIST A ROTATED REFRESH TOKEN IMMEDIATELY: a server that rotates invalidates the
|
|
263
|
-
// previous one, so keeping the old token breaks the NEXT refresh with invalid_grant.
|
|
264
108
|
if (tokens.refresh_token)
|
|
265
109
|
this.store.refreshToken = tokens.refresh_token;
|
|
266
110
|
if (typeof tokens.expires_in === "number" && tokens.expires_in > 0)
|
|
@@ -277,75 +121,17 @@ export class ServerAuth {
|
|
|
277
121
|
async saveCodeVerifier(verifier) {
|
|
278
122
|
this.verifier = verifier;
|
|
279
123
|
this.store.codeVerifier = verifier;
|
|
280
|
-
/**
|
|
281
|
-
* PERSIST it, or the exchange cannot complete after the instance is rebuilt.
|
|
282
|
-
*
|
|
283
|
-
* The verifier is the secret half of PKCE: the authorization request carries its SHA-256
|
|
284
|
-
* challenge and the token request must present the verifier itself. Keeping it only in memory
|
|
285
|
-
* means any path that constructs a fresh `ServerAuth` — a restart while the browser tab is open,
|
|
286
|
-
* a reconnect, a second `authFor()` — exchanges with no verifier and the authorization server
|
|
287
|
-
* answers `invalid_grant`.
|
|
288
|
-
*
|
|
289
|
-
* MEASURED against the real store: `clientId`/`clientSecret`/`clientInformation` were written
|
|
290
|
-
* but `codeVerifier` was ABSENT, because this method was the one mutation that never persisted.
|
|
291
|
-
* Notion's `/token` returns `invalid_grant` for exactly this shape.
|
|
292
|
-
*/
|
|
293
124
|
await this.persist(this.server, this.store);
|
|
294
125
|
}
|
|
295
126
|
codeVerifier() {
|
|
296
|
-
/**
|
|
297
|
-
* Fall back to the STORE, not just the in-memory field.
|
|
298
|
-
*
|
|
299
|
-
* The field is populated by `saveCodeVerifier` (same instance) or by `hydrate` (a rebuilt one).
|
|
300
|
-
* Reading the store as a last resort means an instance that was hydrated before this field was
|
|
301
|
-
* restored still completes the exchange instead of throwing — the failure mode is a silent
|
|
302
|
-
* `invalid_grant` from the server, so being generous here costs nothing.
|
|
303
|
-
*/
|
|
304
127
|
const v = this.verifier ?? this.store.codeVerifier;
|
|
305
128
|
if (!v)
|
|
306
129
|
throw new Error("no PKCE verifier — the authorization flow did not start here");
|
|
307
130
|
return v;
|
|
308
131
|
}
|
|
309
|
-
/**
|
|
310
|
-
* The SDK's discovery cache — the reason Notion's exchange never completed.
|
|
311
|
-
*
|
|
312
|
-
* The OAuth flow is split across TWO transport instances: the one that hits the 401 and
|
|
313
|
-
* redirects the browser, and the one built after the callback arrives. The SDK's own contract
|
|
314
|
-
* says this state "MUST persist with the same durability as `codeVerifier` (survives the
|
|
315
|
-
* redirect round-trip)", because the issuer recorded at redirect time is what the authorization
|
|
316
|
-
* response's `iss` is validated against (RFC 9207) when the code is redeemed.
|
|
317
|
-
*
|
|
318
|
-
* MEASURED on this machine's real store after a BROWSER-COMPLETED Notion authorization:
|
|
319
|
-
* `codeVerifier` present, `clientInformation.redirect_uris` a real ephemeral port
|
|
320
|
-
* (`http://127.0.0.1:37209/callback`), and no `accessToken` — the redeeming instance had
|
|
321
|
-
* re-discovered a different issuer binding, so the exchange never produced tokens. Both earlier
|
|
322
|
-
* fixes (persist the verifier, re-register on a redirect mismatch) were necessary and neither was
|
|
323
|
-
* sufficient.
|
|
324
|
-
*/
|
|
325
132
|
discoveryState() {
|
|
326
133
|
return this.store.discoveryState;
|
|
327
134
|
}
|
|
328
|
-
/**
|
|
329
|
-
* The issuer this client believes it is talking to, from our OWN discovery record.
|
|
330
|
-
*
|
|
331
|
-
* Needed because the SDK treats a missing `iss` on the callback as an ATTACK when the server's
|
|
332
|
-
* metadata declares `authorization_response_iss_parameter_supported` (RFC 9207), and throws
|
|
333
|
-
* `IssuerMismatchError` before the code is ever redeemed.
|
|
334
|
-
*
|
|
335
|
-
* Notion declares that support and then does not send `iss`. MEASURED against
|
|
336
|
-
* `mcp.notion.com/.well-known/oauth-authorization-server`:
|
|
337
|
-
* `authorization_response_iss_parameter_supported: true`, while its real redirect carries only
|
|
338
|
-
* `code` and `state`. Reproduced directly against the SDK with this machine's stored grant:
|
|
339
|
-
* `IssuerMismatchError: expected "https://mcp.notion.com", received undefined` — i.e. every
|
|
340
|
-
* browser-completed authorization was discarded at the last step, which is the "コールバック踏んだ
|
|
341
|
-
* のに Unauthorized" report.
|
|
342
|
-
*
|
|
343
|
-
* Supplying the issuer ourselves is sound here: the code arrived on OUR loopback listener, and
|
|
344
|
-
* this value comes from the discovery document we fetched before sending the browser out. RFC 9207
|
|
345
|
-
* exists to stop a MIX-UP attack, and there is no mix-up to detect when the client already knows
|
|
346
|
-
* which authorization server it directed the user to. The SDK's own check
|
|
347
|
-
* (`iss !== expectedIssuer`) still runs, so a genuinely wrong issuer is still rejected.
|
|
348
|
-
*/
|
|
349
135
|
expectedIssuer() {
|
|
350
136
|
const meta = this.store.discoveryState?.authorizationServerMetadata;
|
|
351
137
|
if (typeof meta?.issuer === "string" && meta.issuer)
|
|
@@ -357,11 +143,8 @@ export class ServerAuth {
|
|
|
357
143
|
if (!state || typeof state !== "object")
|
|
358
144
|
return;
|
|
359
145
|
this.store.discoveryState = state;
|
|
360
|
-
// same durability as the verifier: the instance that redeems the code is not this one
|
|
361
146
|
await this.persist(this.server, this.store);
|
|
362
147
|
}
|
|
363
|
-
// ---- teapot-facing ----
|
|
364
|
-
/** the url the browser must open, once `redirectToAuthorization` has been called */
|
|
365
148
|
pendingUrl() {
|
|
366
149
|
return this.flow.phase === "authorizing" ? this.flow.url : undefined;
|
|
367
150
|
}
|
|
@@ -369,7 +152,6 @@ export class ServerAuth {
|
|
|
369
152
|
this.lastError = message;
|
|
370
153
|
this.flow = { phase: "error", message };
|
|
371
154
|
}
|
|
372
|
-
/** drop the grant (disconnect) */
|
|
373
155
|
async clear() {
|
|
374
156
|
this.store = {};
|
|
375
157
|
this.verifier = undefined;
|
|
@@ -380,7 +162,6 @@ export class ServerAuth {
|
|
|
380
162
|
}
|
|
381
163
|
status() {
|
|
382
164
|
const expiresAt = this.store.expiresAt ?? null;
|
|
383
|
-
// a token with no advertised expiry counts as usable; the SDK refreshes on 401 anyway
|
|
384
165
|
const fresh = Boolean(this.store.accessToken) && (expiresAt === null || Date.now() < expiresAt - 60_000);
|
|
385
166
|
return {
|
|
386
167
|
pending: this.flow.phase === "authorizing",
|
|
@@ -393,13 +174,6 @@ export class ServerAuth {
|
|
|
393
174
|
};
|
|
394
175
|
}
|
|
395
176
|
}
|
|
396
|
-
/**
|
|
397
|
-
* The loopback listener an OAuth flow needs.
|
|
398
|
-
*
|
|
399
|
-
* Bound to 127.0.0.1, never 0.0.0.0: this endpoint accepts an authorization code and must
|
|
400
|
-
* not be reachable from the network. The `state` check lives in the caller (the SDK
|
|
401
|
-
* generates and verifies it), so this only has to deliver `code` and `iss`.
|
|
402
|
-
*/
|
|
403
177
|
export async function listenForCallback() {
|
|
404
178
|
return await new Promise((resolve, reject) => {
|
|
405
179
|
const server = createServer();
|
|
@@ -421,7 +195,6 @@ export async function listenForCallback() {
|
|
|
421
195
|
return;
|
|
422
196
|
}
|
|
423
197
|
if (!code) {
|
|
424
|
-
// a favicon or a stray probe: keep listening rather than failing the flow
|
|
425
198
|
res.writeHead(404, { "content-type": "text/plain" }).end("not the oauth callback");
|
|
426
199
|
return;
|
|
427
200
|
}
|
|
@@ -458,24 +231,6 @@ export async function listenForCallback() {
|
|
|
458
231
|
});
|
|
459
232
|
});
|
|
460
233
|
}
|
|
461
|
-
/** the page the operator sees in the callback tab */
|
|
462
|
-
/**
|
|
463
|
-
* The page the browser lands on after authorizing.
|
|
464
|
-
*
|
|
465
|
-
* ## Why it does not close itself any more
|
|
466
|
-
*
|
|
467
|
-
* It used to end with `setTimeout(() => window.close(), 1200)`. Reported as "Notionから認証終わって
|
|
468
|
-
* http://127.0.0.1:41621/callback?code=xxx に飛ぶはずなのに、dev toolsを見るとリダイレクトされてる
|
|
469
|
-
* んだけど、ブラウザ画面ではリダイレクトされてない(謎)" — the redirect DOES happen, the page renders,
|
|
470
|
-
* and then it vanishes on its own. Nothing in DevTools distinguishes "loaded and closed itself" from
|
|
471
|
-
* "never navigated", so the symptom reads as a broken callback.
|
|
472
|
-
*
|
|
473
|
-
* It also only works for a tab a script opened: `window.close()` silently fails on a
|
|
474
|
-
* user-navigated tab, so the behaviour differed by browser and by how the flow started.
|
|
475
|
-
*
|
|
476
|
-
* Saying what happened and leaving the tab alone is both honest and unambiguous. The operator
|
|
477
|
-
* closes it when they are done reading.
|
|
478
|
-
*/
|
|
479
234
|
function page(title, body) {
|
|
480
235
|
return `<!doctype html><meta charset="utf-8"><title>${title}</title>
|
|
481
236
|
<style>body{font:15px system-ui;margin:12vh auto;max-width:34rem;text-align:center;color:#222}
|
|
@@ -483,12 +238,6 @@ h1{font-size:1.1rem}p{color:#555}</style>
|
|
|
483
238
|
<h1>${title}</h1><p>${body}</p>
|
|
484
239
|
<p>You can close this tab and return to teapot.</p>`;
|
|
485
240
|
}
|
|
486
|
-
/**
|
|
487
|
-
* Grants for every server, persisted together.
|
|
488
|
-
*
|
|
489
|
-
* One file rather than one per server: the set is small, and a single atomic write keeps
|
|
490
|
-
* a rotation from racing a second write.
|
|
491
|
-
*/
|
|
492
241
|
export class McpAuthStore {
|
|
493
242
|
file;
|
|
494
243
|
cache = null;
|
|
@@ -500,7 +249,6 @@ export class McpAuthStore {
|
|
|
500
249
|
return this.cache;
|
|
501
250
|
try {
|
|
502
251
|
const raw = JSON.parse(await readFile(this.file, "utf8"));
|
|
503
|
-
// tolerate hand edits: keep only entries that look like ours
|
|
504
252
|
const out = {};
|
|
505
253
|
for (const [k, v] of Object.entries(raw ?? {}))
|
|
506
254
|
if (v && typeof v === "object")
|
|
@@ -523,7 +271,6 @@ export class McpAuthStore {
|
|
|
523
271
|
await writeFile(this.file, JSON.stringify(all, null, 2), { mode: 0o600 });
|
|
524
272
|
}
|
|
525
273
|
catch {
|
|
526
|
-
/* a failed write must not break a live session */
|
|
527
274
|
}
|
|
528
275
|
}
|
|
529
276
|
}
|