@frockbot/plugin-mcp 0.0.0 → 0.1.1

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.
@@ -0,0 +1,330 @@
1
+ /**
2
+ * The durable state one `mcp-remote-oauth` Connection carries, beside its
3
+ * server record, in the User Durable Object.
4
+ *
5
+ * Three shapes, and the split between them is the whole security argument:
6
+ *
7
+ * - {@link McpOAuthRecordV1} is the *non-secret* half — the endpoints, the
8
+ * registered `client_id`, the resource indicator, and which credential
9
+ * generation currently holds the access and refresh tokens. It is safe to
10
+ * read, and nothing in it opens anything.
11
+ * - {@link McpOAuthPendingV1} is one authorization in flight. It holds the PKCE
12
+ * verifier, which is why it is keyed by `authorizationStateId` and consumed
13
+ * transactionally: a second callback presenting a spent id finds nothing and
14
+ * changes nothing.
15
+ * - {@link McpAuthorizationStartsV1} is the per-User start quota, in a fixed
16
+ * window, because discovery costs outbound requests and a durable record.
17
+ *
18
+ * The tokens themselves are in neither. They are sealed credential generations
19
+ * in the keyring (ADR 0008): the access token under the Connection's own id, so
20
+ * a Bot's mount can lease it, and the refresh token under a derived id that has
21
+ * no active generation at all — which is what makes it unleasable rather than
22
+ * merely un-leased.
23
+ */
24
+
25
+ export const MCP_OAUTH_RECORD_PREFIX = "mcp-oauth:";
26
+ export const MCP_OAUTH_PENDING_PREFIX = "mcp-oauth-pending:";
27
+ export const MCP_OAUTH_STARTS_KEY = "mcp-oauth-starts";
28
+
29
+ export function mcpOAuthRecordKeyV1(connectionId: string): string {
30
+ return `${MCP_OAUTH_RECORD_PREFIX}${connectionId}`;
31
+ }
32
+
33
+ export function mcpOAuthPendingKeyV1(authorizationStateId: string): string {
34
+ return `${MCP_OAUTH_PENDING_PREFIX}${authorizationStateId}`;
35
+ }
36
+
37
+ /**
38
+ * Where the refresh token's sealed generation lives.
39
+ *
40
+ * A derived Connection id rather than a second field on the same one, because
41
+ * `plugin-credentials` leases *the active generation of a Connection id* and
42
+ * nothing else. This id never has an active generation — the refresh token is
43
+ * staged and never activated — so `leaseToolCredential` cannot reach it even by
44
+ * mistake, and `openLease` has nothing to open. The User Durable Object reads
45
+ * it with `readStagedApiKey`, which needs no lease and never leaves the object.
46
+ */
47
+ /**
48
+ * The Connection id a *fresh* authorization will create.
49
+ *
50
+ * Derived from the command id rather than random, because the signed callback
51
+ * state has to name the Connection before the User Durable Object has minted
52
+ * one — and an identity the gateway guesses and the object invents separately
53
+ * is two identities. Deriving it also makes a replayed start idempotent: the
54
+ * second one finds the Connection the first created instead of adding another.
55
+ */
56
+ export function mcpAuthorizationConnectionIdV1(commandId: string): string {
57
+ return `mcp-${commandId}`.slice(0, 128);
58
+ }
59
+
60
+ export function mcpRefreshCredentialIdV1(connectionId: string): string {
61
+ return `${connectionId}#refresh`;
62
+ }
63
+
64
+ export interface McpOAuthRecordV1 {
65
+ schemaVersion: 1;
66
+ connectionId: string;
67
+ issuer: string;
68
+ authorizationEndpoint: string;
69
+ tokenEndpoint: string;
70
+ registrationEndpoint?: string;
71
+ revocationEndpoint?: string;
72
+ clientId: string;
73
+ /** The RFC 8707 resource indicator every token for this server is bound to. */
74
+ resource: string;
75
+ scope?: string;
76
+ redirectUri: string;
77
+ /** The Connection generation whose sealed access token is active. */
78
+ accessGeneration?: string;
79
+ /** The staged, never-activated generation holding the refresh token. */
80
+ refreshGeneration?: string;
81
+ /** When the access token expires, epoch milliseconds. */
82
+ accessExpiresAt?: number;
83
+ updatedAt: string;
84
+ }
85
+
86
+ export interface McpOAuthPendingV1 {
87
+ schemaVersion: 1;
88
+ authorizationStateId: string;
89
+ connectionId: string;
90
+ /** PKCE, RFC 7636. Never in the state token, never sent to a client. */
91
+ codeVerifier: string;
92
+ clientId: string;
93
+ tokenEndpoint: string;
94
+ revocationEndpoint?: string;
95
+ authorizationEndpoint: string;
96
+ registrationEndpoint?: string;
97
+ issuer: string;
98
+ resource: string;
99
+ scope?: string;
100
+ redirectUri: string;
101
+ /** The Connection generation this authorization will activate on success. */
102
+ generation: string;
103
+ returnTarget: "browser" | "desktop";
104
+ nativeReturnNonce?: string;
105
+ expiresAt: number;
106
+ createdAt: string;
107
+ }
108
+
109
+ export interface McpAuthorizationStartsV1 {
110
+ schemaVersion: 1;
111
+ windowStartedAt: number;
112
+ count: number;
113
+ }
114
+
115
+ function record(value: unknown, label: string): Record<string, unknown> {
116
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
117
+ throw new Error(`${label} is invalid`);
118
+ }
119
+ return value as Record<string, unknown>;
120
+ }
121
+
122
+ function exact(
123
+ value: Record<string, unknown>,
124
+ allowed: readonly string[],
125
+ label: string,
126
+ ): void {
127
+ const permitted = new Set(allowed);
128
+ for (const key of Object.keys(value)) {
129
+ if (!permitted.has(key)) {
130
+ throw new Error(`${label} carries unknown field "${key}"`);
131
+ }
132
+ }
133
+ }
134
+
135
+ function text(value: unknown, label: string, maximum: number): string {
136
+ if (
137
+ typeof value !== "string" ||
138
+ value.length === 0 ||
139
+ value.length > maximum
140
+ ) {
141
+ throw new Error(`${label} is invalid`);
142
+ }
143
+ return value;
144
+ }
145
+
146
+ function optionalText(
147
+ value: unknown,
148
+ label: string,
149
+ maximum: number,
150
+ ): string | undefined {
151
+ return value === undefined ? undefined : text(value, label, maximum);
152
+ }
153
+
154
+ function epoch(value: unknown, label: string): number {
155
+ if (!Number.isSafeInteger(value) || (value as number) < 0) {
156
+ throw new Error(`${label} is invalid`);
157
+ }
158
+ return value as number;
159
+ }
160
+
161
+ export function decodeMcpOAuthRecordV1(input: unknown): McpOAuthRecordV1 {
162
+ const value = record(input, "MCP OAuth record");
163
+ exact(
164
+ value,
165
+ [
166
+ "schemaVersion",
167
+ "connectionId",
168
+ "issuer",
169
+ "authorizationEndpoint",
170
+ "tokenEndpoint",
171
+ "registrationEndpoint",
172
+ "revocationEndpoint",
173
+ "clientId",
174
+ "resource",
175
+ "scope",
176
+ "redirectUri",
177
+ "accessGeneration",
178
+ "refreshGeneration",
179
+ "accessExpiresAt",
180
+ "updatedAt",
181
+ ],
182
+ "MCP OAuth record",
183
+ );
184
+ if (value.schemaVersion !== 1) {
185
+ throw new Error("MCP OAuth record schemaVersion is unsupported");
186
+ }
187
+ const registrationEndpoint = optionalText(
188
+ value.registrationEndpoint,
189
+ "MCP OAuth registration endpoint",
190
+ 2_048,
191
+ );
192
+ const revocationEndpoint = optionalText(
193
+ value.revocationEndpoint,
194
+ "MCP OAuth revocation endpoint",
195
+ 2_048,
196
+ );
197
+ const scope = optionalText(value.scope, "MCP OAuth scope", 1_024);
198
+ const accessGeneration = optionalText(
199
+ value.accessGeneration,
200
+ "MCP OAuth access generation",
201
+ 128,
202
+ );
203
+ const refreshGeneration = optionalText(
204
+ value.refreshGeneration,
205
+ "MCP OAuth refresh generation",
206
+ 128,
207
+ );
208
+ return {
209
+ schemaVersion: 1,
210
+ connectionId: text(value.connectionId, "MCP OAuth connectionId", 128),
211
+ issuer: text(value.issuer, "MCP OAuth issuer", 2_048),
212
+ authorizationEndpoint: text(
213
+ value.authorizationEndpoint,
214
+ "MCP OAuth authorization endpoint",
215
+ 2_048,
216
+ ),
217
+ tokenEndpoint: text(value.tokenEndpoint, "MCP OAuth token endpoint", 2_048),
218
+ ...(registrationEndpoint === undefined ? {} : { registrationEndpoint }),
219
+ ...(revocationEndpoint === undefined ? {} : { revocationEndpoint }),
220
+ clientId: text(value.clientId, "MCP OAuth client id", 512),
221
+ resource: text(value.resource, "MCP OAuth resource", 2_048),
222
+ ...(scope === undefined ? {} : { scope }),
223
+ redirectUri: text(value.redirectUri, "MCP OAuth redirect uri", 2_048),
224
+ ...(accessGeneration === undefined ? {} : { accessGeneration }),
225
+ ...(refreshGeneration === undefined ? {} : { refreshGeneration }),
226
+ ...(value.accessExpiresAt === undefined
227
+ ? {}
228
+ : { accessExpiresAt: epoch(value.accessExpiresAt, "MCP OAuth expiry") }),
229
+ updatedAt: text(value.updatedAt, "MCP OAuth updatedAt", 64),
230
+ };
231
+ }
232
+
233
+ export function decodeMcpOAuthPendingV1(input: unknown): McpOAuthPendingV1 {
234
+ const value = record(input, "MCP OAuth pending authorization");
235
+ exact(
236
+ value,
237
+ [
238
+ "schemaVersion",
239
+ "authorizationStateId",
240
+ "connectionId",
241
+ "codeVerifier",
242
+ "clientId",
243
+ "tokenEndpoint",
244
+ "revocationEndpoint",
245
+ "authorizationEndpoint",
246
+ "registrationEndpoint",
247
+ "issuer",
248
+ "resource",
249
+ "scope",
250
+ "redirectUri",
251
+ "generation",
252
+ "returnTarget",
253
+ "nativeReturnNonce",
254
+ "expiresAt",
255
+ "createdAt",
256
+ ],
257
+ "MCP OAuth pending authorization",
258
+ );
259
+ if (value.schemaVersion !== 1) {
260
+ throw new Error(
261
+ "MCP OAuth pending authorization schemaVersion is unsupported",
262
+ );
263
+ }
264
+ if (value.returnTarget !== "browser" && value.returnTarget !== "desktop") {
265
+ throw new Error("MCP OAuth pending authorization returnTarget is invalid");
266
+ }
267
+ const revocationEndpoint = optionalText(
268
+ value.revocationEndpoint,
269
+ "MCP OAuth revocation endpoint",
270
+ 2_048,
271
+ );
272
+ const registrationEndpoint = optionalText(
273
+ value.registrationEndpoint,
274
+ "MCP OAuth registration endpoint",
275
+ 2_048,
276
+ );
277
+ const scope = optionalText(value.scope, "MCP OAuth scope", 1_024);
278
+ const nativeReturnNonce = optionalText(
279
+ value.nativeReturnNonce,
280
+ "MCP OAuth nativeReturnNonce",
281
+ 128,
282
+ );
283
+ return {
284
+ schemaVersion: 1,
285
+ authorizationStateId: text(
286
+ value.authorizationStateId,
287
+ "MCP OAuth authorizationStateId",
288
+ 128,
289
+ ),
290
+ connectionId: text(value.connectionId, "MCP OAuth connectionId", 128),
291
+ codeVerifier: text(value.codeVerifier, "MCP OAuth code verifier", 256),
292
+ clientId: text(value.clientId, "MCP OAuth client id", 512),
293
+ tokenEndpoint: text(value.tokenEndpoint, "MCP OAuth token endpoint", 2_048),
294
+ ...(revocationEndpoint === undefined ? {} : { revocationEndpoint }),
295
+ authorizationEndpoint: text(
296
+ value.authorizationEndpoint,
297
+ "MCP OAuth authorization endpoint",
298
+ 2_048,
299
+ ),
300
+ ...(registrationEndpoint === undefined ? {} : { registrationEndpoint }),
301
+ issuer: text(value.issuer, "MCP OAuth issuer", 2_048),
302
+ resource: text(value.resource, "MCP OAuth resource", 2_048),
303
+ ...(scope === undefined ? {} : { scope }),
304
+ redirectUri: text(value.redirectUri, "MCP OAuth redirect uri", 2_048),
305
+ generation: text(value.generation, "MCP OAuth generation", 128),
306
+ returnTarget: value.returnTarget,
307
+ ...(nativeReturnNonce === undefined ? {} : { nativeReturnNonce }),
308
+ expiresAt: epoch(value.expiresAt, "MCP OAuth pending expiry"),
309
+ createdAt: text(value.createdAt, "MCP OAuth createdAt", 64),
310
+ };
311
+ }
312
+
313
+ export function decodeMcpAuthorizationStartsV1(
314
+ input: unknown,
315
+ ): McpAuthorizationStartsV1 {
316
+ const value = record(input, "MCP authorization start ledger");
317
+ exact(
318
+ value,
319
+ ["schemaVersion", "windowStartedAt", "count"],
320
+ "MCP authorization start ledger",
321
+ );
322
+ if (value.schemaVersion !== 1) {
323
+ throw new Error("MCP authorization start ledger is unsupported");
324
+ }
325
+ return {
326
+ schemaVersion: 1,
327
+ windowStartedAt: epoch(value.windowStartedAt, "window start"),
328
+ count: epoch(value.count, "start count"),
329
+ };
330
+ }