@ouraihub/aether-worker 0.0.0-stage → 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,292 @@
1
+ import { KitFailure, safeError, badRequest } from '@ouraihub/aether-core/error'
2
+ import type { WorkerContext } from '../context.js'
3
+ import { McpClientSettings } from './client.js'
4
+ import { McpAuth } from './auth.js'
5
+ import { McpOAuth } from './oauth.js'
6
+ import { body, forbidden, json, jsonBody, object, validActor } from './common.js'
7
+ import type { Authentication } from './auth.js'
8
+ import type { ExposedMcpTool, McpServiceOptions } from './types.js'
9
+ export type {
10
+ ApiKeyRecord,
11
+ GrantRecord,
12
+ McpScope,
13
+ McpSettings,
14
+ ExposedMcpTool,
15
+ McpCallContext,
16
+ McpServiceOptions,
17
+ } from './types.js'
18
+ export { McpClientSettings } from './client.js'
19
+
20
+ const MAX_RESULT_BYTES = 2 * 1024 * 1024
21
+ const encoder = new TextEncoder()
22
+ function rpc(id: unknown, result: unknown, error = false): Response {
23
+ const message = {
24
+ jsonrpc: '2.0',
25
+ id: typeof id === 'string' || typeof id === 'number' ? id : null,
26
+ ...(error ? { error: result } : { result }),
27
+ }
28
+ if (encoder.encode(JSON.stringify(message)).byteLength > MAX_RESULT_BYTES)
29
+ return json({
30
+ jsonrpc: '2.0',
31
+ id: message.id,
32
+ error: { code: -32603, message: 'Result budget exceeded' },
33
+ })
34
+ return json(message)
35
+ }
36
+ function rpcError(id: unknown, code: number, message: string): Response {
37
+ return rpc(id, { code, message }, true)
38
+ }
39
+
40
+ export class McpService {
41
+ private readonly auth: McpAuth
42
+ private readonly oauth: McpOAuth
43
+ private readonly clients: McpClientSettings
44
+ readonly options: McpServiceOptions
45
+ constructor(options: McpServiceOptions) {
46
+ this.options = options
47
+ const origin = new URL(options.origin)
48
+ if (origin.origin !== options.origin || origin.protocol !== 'https:' || !options.applicationId)
49
+ throw badRequest('origin', 'https_origin_required')
50
+ const scopeIds = new Set<string>()
51
+ for (const scope of options.scopes) {
52
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9:_.-]{0,127}$/u.test(scope.id) || scopeIds.has(scope.id))
53
+ throw badRequest('scopes', 'invalid_registration')
54
+ scopeIds.add(scope.id)
55
+ }
56
+ const tools = new Set<string>()
57
+ for (const tool of options.tools) {
58
+ if (
59
+ !/^[A-Za-z0-9_.-]{1,128}$/u.test(tool.name) ||
60
+ tools.has(tool.name) ||
61
+ !scopeIds.has(tool.scope) ||
62
+ !object(tool.inputSchema) ||
63
+ !Number.isSafeInteger(tool.maxRequestBytes ?? 65536) ||
64
+ (tool.maxRequestBytes ?? 65536) < 1 ||
65
+ (tool.maxRequestBytes ?? 65536) > MAX_RESULT_BYTES
66
+ )
67
+ throw badRequest('tools', 'invalid_registration')
68
+ tools.add(tool.name)
69
+ }
70
+ this.auth = new McpAuth(options)
71
+ this.oauth = new McpOAuth(options, this.auth)
72
+ this.clients = new McpClientSettings(options.store, options.credentials, options.outbound)
73
+ }
74
+ client(context: WorkerContext) {
75
+ if (!validActor(context.actor, this.options.applicationId)) forbidden()
76
+ return this.clients.runtime(context.actor)
77
+ }
78
+ async handlePublic(request: Request): Promise<Response | null> {
79
+ const url = new URL(request.url)
80
+ if (
81
+ url.pathname !== '/mcp' &&
82
+ !url.pathname.startsWith('/oauth/') &&
83
+ !url.pathname.startsWith('/.well-known/oauth-')
84
+ )
85
+ return null
86
+ if (url.origin !== this.options.origin || url.protocol !== 'https:')
87
+ return json({ error: 'invalid_request' }, 400)
88
+ if (url.pathname === '/mcp') return this.mcp(request)
89
+ return this.oauth.handle(request)
90
+ }
91
+ async handle(request: Request, context: WorkerContext): Promise<Response | null> {
92
+ const path = new URL(request.url).pathname
93
+ if (!path.startsWith('/api/v1/mcp')) return null
94
+ this.auth.actor(context.actor)
95
+ const clientResponse = await this.clients.handle(request, context)
96
+ if (clientResponse) return clientResponse
97
+ if (path === '/api/v1/mcp/settings') {
98
+ if (request.method === 'GET')
99
+ return json({ ...(await this.auth.settings(context.actor)), scopes: this.options.scopes })
100
+ if (request.method === 'PUT') {
101
+ const value = await jsonBody(request)
102
+ if (
103
+ !Array.isArray(value.enabledScopes) ||
104
+ !value.enabledScopes.every((scope: unknown) => typeof scope === 'string')
105
+ )
106
+ throw badRequest('enabledScopes', 'invalid')
107
+ const requested = value.enabledScopes.length
108
+ ? this.auth.requested(value.enabledScopes, context.actor)
109
+ : []
110
+ if (!(await this.options.authorizeConsent(context.actor, requested, request))) forbidden()
111
+ return json({
112
+ ...(await this.auth.saveSettings(context.actor, value)),
113
+ scopes: this.options.scopes,
114
+ })
115
+ }
116
+ }
117
+ if (path === '/api/v1/mcp/grants' && request.method === 'GET')
118
+ return json(await this.auth.grants(context.actor))
119
+ if (path === '/api/v1/mcp/api-keys') {
120
+ if (request.method === 'GET') return json(await this.auth.keys(context.actor))
121
+ if (request.method === 'POST')
122
+ return json(await this.auth.issueKey(context.actor, await jsonBody(request), request), 201)
123
+ }
124
+ const revoke = /^\/api\/v1\/mcp\/(grants|api-keys)\/([^/]+)\/revoke$/u.exec(path)
125
+ if (revoke && request.method === 'POST') {
126
+ const id = revoke[2]
127
+ if (!id) throw badRequest('id', 'invalid')
128
+ await this.auth.revoke(
129
+ context.actor,
130
+ revoke[1] === 'grants' ? 'grant' : 'key',
131
+ decodeURIComponent(id),
132
+ )
133
+ return json({ ok: true })
134
+ }
135
+ return json({ code: 'not_found', message: 'Route not found' }, 404)
136
+ }
137
+ private async mcp(request: Request): Promise<Response> {
138
+ const origin = request.headers.get('Origin')
139
+ if (
140
+ origin !== null &&
141
+ ![this.options.origin, ...(this.options.mcpOrigins ?? [])].includes(origin)
142
+ )
143
+ return json({ error: 'forbidden' }, 403)
144
+ if (request.method !== 'POST')
145
+ return new Response(null, { status: 405, headers: { Allow: 'POST' } })
146
+ const requiresAccess = this.options.scopes.some((scope) => scope.id === 'mcp:access')
147
+ const challenge =
148
+ `Bearer resource_metadata="${this.options.origin}/.well-known/oauth-protected-resource/mcp"` +
149
+ (requiresAccess ? ', scope="mcp:access"' : '')
150
+ const bearer = /^Bearer[\t ]+([^\s,]+)$/iu.exec(request.headers.get('Authorization') ?? '')?.[1]
151
+ const authentication = bearer ? await this.auth.authenticate(bearer) : null
152
+ if (!authentication)
153
+ return json({ error: 'unauthorized' }, 401, {
154
+ 'WWW-Authenticate': challenge,
155
+ })
156
+ if (requiresAccess && !authentication.scopes.includes('mcp:access'))
157
+ return json({ error: 'insufficient_scope' }, 403, {
158
+ 'WWW-Authenticate': `${challenge}, error="insufficient_scope"`,
159
+ })
160
+ const maxBytes =
161
+ this.options.tools.reduce(
162
+ (max, tool) => Math.max(max, tool.maxRequestBytes ?? 65536),
163
+ 65536,
164
+ ) + 8192
165
+ let message: unknown
166
+ try {
167
+ message = JSON.parse(await body(request, maxBytes))
168
+ } catch (error) {
169
+ if (error instanceof KitFailure && error.code === 'bad_request')
170
+ return json({ code: 'bad_request', message: 'Invalid request body' }, 413)
171
+ return rpcError(null, -32700, 'Parse error')
172
+ }
173
+ if (
174
+ !object(message) ||
175
+ message.jsonrpc !== '2.0' ||
176
+ typeof message.method !== 'string' ||
177
+ ('id' in message &&
178
+ typeof message.id !== 'string' &&
179
+ typeof message.id !== 'number' &&
180
+ message.id !== null)
181
+ )
182
+ return rpcError(null, -32600, 'Invalid request')
183
+ if (!('id' in message)) {
184
+ if (
185
+ message.method === 'notifications/initialized' ||
186
+ message.method === 'notifications/cancelled'
187
+ )
188
+ return new Response(null, { status: 202 })
189
+ return rpcError(null, -32600, 'Invalid request')
190
+ }
191
+ const id = message.id
192
+ if (message.method === 'initialize') {
193
+ if (
194
+ !object(message.params) ||
195
+ typeof message.params.protocolVersion !== 'string' ||
196
+ !message.params.protocolVersion.trim()
197
+ )
198
+ return rpcError(id, -32602, 'Invalid protocol version')
199
+ return rpc(id, {
200
+ protocolVersion: ['2024-11-05', '2025-03-26'].includes(message.params.protocolVersion)
201
+ ? message.params.protocolVersion
202
+ : '2025-03-26',
203
+ capabilities: { tools: {} },
204
+ serverInfo: { name: 'Aether MCP', version: '1.0.0' },
205
+ })
206
+ }
207
+ const protocol = request.headers.get('MCP-Protocol-Version')
208
+ if (protocol !== null && !['2024-11-05', '2025-03-26'].includes(protocol))
209
+ return rpcError(id, -32602, 'Unsupported protocol version')
210
+ if (message.method === 'ping') return rpc(id, {})
211
+ if (message.method === 'tools/list')
212
+ return rpc(id, {
213
+ tools: this.options.tools
214
+ .filter((tool) => authentication.scopes.includes(tool.scope))
215
+ .map((tool) => ({
216
+ name: tool.name,
217
+ description: tool.description,
218
+ inputSchema: tool.inputSchema,
219
+ annotations: {
220
+ readOnlyHint: tool.readOnly,
221
+ destructiveHint: tool.destructive,
222
+ idempotentHint: tool.idempotent,
223
+ },
224
+ })),
225
+ })
226
+ if (message.method !== 'tools/call') return rpcError(id, -32601, 'Method not found')
227
+ if (!object(message.params) || typeof message.params.name !== 'string')
228
+ return rpcError(id, -32602, 'Invalid parameters')
229
+ const toolName = message.params.name
230
+ const tool = this.options.tools.find((item) => item.name === toolName)
231
+ if (!tool) return rpcError(id, -32004, 'Tool not found')
232
+ if (!authentication.scopes.includes(tool.scope)) return rpcError(id, -32001, 'Forbidden')
233
+ const args: unknown = 'arguments' in message.params ? message.params.arguments : {}
234
+ if (!object(args)) return rpcError(id, -32602, 'Invalid parameters')
235
+ if (encoder.encode(JSON.stringify(args)).byteLength > (tool.maxRequestBytes ?? 65536))
236
+ return rpcError(id, -32602, 'Arguments too large')
237
+ if (!tool.validate(args)) return rpcError(id, -32602, 'Invalid parameters')
238
+ return this.call(id, tool, args, authentication, request.signal)
239
+ }
240
+ private async call(
241
+ id: unknown,
242
+ tool: ExposedMcpTool,
243
+ args: Record<string, unknown>,
244
+ auth: Authentication,
245
+ signal: AbortSignal,
246
+ ): Promise<Response> {
247
+ try {
248
+ if (signal.aborted || !(await this.options.authorizeTool(auth.actor, tool, args, signal)))
249
+ return rpcError(id, -32001, 'Forbidden')
250
+ if (signal.aborted) return rpcError(id, -32603, 'Cancelled')
251
+ const result = await tool.handler(args, {
252
+ actor: auth.actor,
253
+ tokenId: auth.tokenId,
254
+ scopes: auth.scopes,
255
+ signal,
256
+ })
257
+ if (result === undefined) throw new KitFailure('internal', 'Invalid tool result')
258
+ const payload = object(result)
259
+ ? {
260
+ content: [{ type: 'text', text: JSON.stringify(result) }],
261
+ structuredContent: result,
262
+ isError: false,
263
+ }
264
+ : {
265
+ content: [
266
+ { type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) },
267
+ ],
268
+ isError: false,
269
+ }
270
+ const serialized = JSON.stringify({ jsonrpc: '2.0', id, result: payload })
271
+ if (encoder.encode(serialized).byteLength > MAX_RESULT_BYTES)
272
+ return rpcError(id, -32603, 'Result budget exceeded')
273
+ return new Response(serialized, {
274
+ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
275
+ })
276
+ } catch (error) {
277
+ const safe = safeError(error)
278
+ const code =
279
+ safe.code === 'bad_request'
280
+ ? -32602
281
+ : safe.code === 'not_found'
282
+ ? -32004
283
+ : safe.code === 'conflict'
284
+ ? -32009
285
+ : -32603
286
+ return rpc(id, { code, message: safe.message, data: safe }, true)
287
+ }
288
+ }
289
+ }
290
+ export function createMcpService(options: McpServiceOptions): McpService {
291
+ return new McpService(options)
292
+ }
@@ -0,0 +1,343 @@
1
+ import { KitFailure, badRequest } from '@ouraihub/aether-core/error'
2
+ import type { McpServiceOptions } from './types.js'
3
+ import type { McpAuth } from './auth.js'
4
+ import {
5
+ body,
6
+ equal,
7
+ escapeHtml,
8
+ forbidden,
9
+ hash,
10
+ json,
11
+ jsonBody,
12
+ object,
13
+ random,
14
+ strings,
15
+ validActor,
16
+ } from './common.js'
17
+
18
+ interface OAuthClient {
19
+ id: string
20
+ name: string
21
+ redirectUris: readonly string[]
22
+ createdAt: number
23
+ }
24
+ interface Authorization {
25
+ client: OAuthClient
26
+ redirectUri: string
27
+ scopes: string[]
28
+ challenge: string
29
+ state: string
30
+ resource: string
31
+ }
32
+ function client(value: unknown): value is OAuthClient {
33
+ return (
34
+ object(value) &&
35
+ typeof value.id === 'string' &&
36
+ typeof value.name === 'string' &&
37
+ strings(value.redirectUris) &&
38
+ typeof value.createdAt === 'number'
39
+ )
40
+ }
41
+ function redirectAllowed(value: string): boolean {
42
+ try {
43
+ const url = new URL(value)
44
+ return (
45
+ !url.username &&
46
+ !url.password &&
47
+ !url.hash &&
48
+ (url.protocol === 'https:' ||
49
+ (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))
50
+ )
51
+ } catch {
52
+ return false
53
+ }
54
+ }
55
+ function redirect(target: string): Response {
56
+ return new Response(null, {
57
+ status: 302,
58
+ headers: { Location: target, 'Cache-Control': 'no-store' },
59
+ })
60
+ }
61
+
62
+ export class McpOAuth {
63
+ readonly options: McpServiceOptions
64
+ readonly auth: McpAuth
65
+ constructor(options: McpServiceOptions, auth: McpAuth) {
66
+ this.options = options
67
+ this.auth = auth
68
+ }
69
+ async handle(request: Request): Promise<Response | null> {
70
+ const url = new URL(request.url)
71
+ const origin = this.options.origin
72
+ const resource = `${origin}/mcp`
73
+ if (
74
+ (url.pathname === '/.well-known/oauth-protected-resource' ||
75
+ url.pathname === '/.well-known/oauth-protected-resource/mcp') &&
76
+ request.method === 'GET'
77
+ )
78
+ return json({
79
+ resource,
80
+ authorization_servers: [origin],
81
+ bearer_methods_supported: ['header'],
82
+ scopes_supported: this.options.scopes.map((scope) => scope.id),
83
+ })
84
+ if (url.pathname === '/.well-known/oauth-authorization-server' && request.method === 'GET')
85
+ return json({
86
+ issuer: origin,
87
+ authorization_endpoint: `${origin}/oauth/authorize`,
88
+ token_endpoint: `${origin}/oauth/token`,
89
+ registration_endpoint: `${origin}/oauth/register`,
90
+ response_types_supported: ['code'],
91
+ grant_types_supported: ['authorization_code', 'refresh_token'],
92
+ code_challenge_methods_supported: ['S256'],
93
+ token_endpoint_auth_methods_supported: ['none'],
94
+ scopes_supported: this.options.scopes.map((scope) => scope.id),
95
+ })
96
+ if (url.pathname === '/oauth/register' && request.method === 'POST')
97
+ return this.register(request)
98
+ if (
99
+ url.pathname === '/oauth/authorize' &&
100
+ (request.method === 'GET' || request.method === 'POST')
101
+ )
102
+ return this.authorize(request)
103
+ if (url.pathname === '/oauth/token' && request.method === 'POST') {
104
+ try {
105
+ return await this.token(request)
106
+ } catch (error) {
107
+ if (!(error instanceof KitFailure)) throw error
108
+ return json(
109
+ { error: error.code === 'bad_request' ? 'invalid_request' : 'invalid_grant' },
110
+ 400,
111
+ )
112
+ }
113
+ }
114
+ if (url.pathname.startsWith('/oauth/') || url.pathname.startsWith('/.well-known/oauth-'))
115
+ return json({ error: 'invalid_request' }, 400)
116
+ return null
117
+ }
118
+ private async register(request: Request): Promise<Response> {
119
+ const input = await jsonBody(request, 16384)
120
+ if (
121
+ !strings(input.redirect_uris) ||
122
+ input.redirect_uris.length === 0 ||
123
+ input.redirect_uris.length > 10 ||
124
+ input.redirect_uris.some((uri) => uri.length > 2048 || !redirectAllowed(uri))
125
+ )
126
+ throw badRequest('redirect_uris', 'invalid')
127
+ if (
128
+ input.token_endpoint_auth_method !== undefined &&
129
+ input.token_endpoint_auth_method !== 'none'
130
+ )
131
+ throw badRequest('token_endpoint_auth_method', 'unsupported')
132
+ if (
133
+ input.grant_types !== undefined &&
134
+ (!strings(input.grant_types) ||
135
+ input.grant_types.some((type) => type !== 'authorization_code' && type !== 'refresh_token'))
136
+ )
137
+ throw badRequest('grant_types', 'unsupported')
138
+ if (
139
+ input.response_types !== undefined &&
140
+ (!strings(input.response_types) || input.response_types.some((type) => type !== 'code'))
141
+ )
142
+ throw badRequest('response_types', 'unsupported')
143
+ if (
144
+ input.client_name !== undefined &&
145
+ (typeof input.client_name !== 'string' || input.client_name.length > 128)
146
+ )
147
+ throw badRequest('client_name', 'invalid')
148
+ const record: OAuthClient = {
149
+ id: crypto.randomUUID(),
150
+ name: typeof input.client_name === 'string' ? input.client_name : 'MCP client',
151
+ redirectUris: [...new Set(input.redirect_uris)],
152
+ createdAt: this.auth.now(),
153
+ }
154
+ await this.options.store.compareAndSet(this.auth.system, 'mcp-clients', record.id, null, record)
155
+ return json(
156
+ {
157
+ client_id: record.id,
158
+ client_name: record.name,
159
+ redirect_uris: record.redirectUris,
160
+ token_endpoint_auth_method: 'none',
161
+ grant_types: ['authorization_code', 'refresh_token'],
162
+ response_types: ['code'],
163
+ },
164
+ 201,
165
+ )
166
+ }
167
+ private async parse(request: Request): Promise<Authorization> {
168
+ const query = new URL(request.url).searchParams
169
+ for (const key of [
170
+ 'client_id',
171
+ 'redirect_uri',
172
+ 'response_type',
173
+ 'scope',
174
+ 'code_challenge',
175
+ 'code_challenge_method',
176
+ 'state',
177
+ 'resource',
178
+ ])
179
+ if (query.getAll(key).length > 1) throw badRequest(key, 'duplicate')
180
+ const record = await this.options.store.get(
181
+ this.auth.system,
182
+ 'mcp-clients',
183
+ query.get('client_id') ?? '',
184
+ )
185
+ if (!record || !client(record.value)) throw badRequest('client_id', 'invalid')
186
+ const redirectUri = query.get('redirect_uri') ?? ''
187
+ if (!record.value.redirectUris.includes(redirectUri))
188
+ throw badRequest('redirect_uri', 'mismatch')
189
+ if (query.get('response_type') !== 'code' || query.get('code_challenge_method') !== 'S256')
190
+ throw badRequest('response_type', 'unsupported')
191
+ const challenge = query.get('code_challenge') ?? ''
192
+ if (!/^[A-Za-z0-9_-]{43}$/u.test(challenge)) throw badRequest('code_challenge', 'invalid')
193
+ const resource = `${this.options.origin}/mcp`
194
+ if (query.has('resource') && query.get('resource') !== resource)
195
+ throw badRequest('resource', 'invalid')
196
+ const scopes = this.auth.requested((query.get('scope') ?? '').split(/\s+/u).filter(Boolean))
197
+ return {
198
+ client: record.value,
199
+ redirectUri,
200
+ scopes,
201
+ challenge,
202
+ state: query.get('state') ?? '',
203
+ resource,
204
+ }
205
+ }
206
+ private async authorize(request: Request): Promise<Response> {
207
+ const parsed = await this.parse(request)
208
+ const actor = await this.options.resolveIdentity(request)
209
+ if (!actor || !validActor(actor, this.options.applicationId) || actor.kind !== 'user')
210
+ return json({ error: 'login_required' }, 401)
211
+ this.auth.actor(actor)
212
+ this.auth.requested(parsed.scopes, actor)
213
+ const settings = await this.auth.settings(actor)
214
+ if (!settings.enabled || parsed.scopes.some((scope) => !settings.enabledScopes.includes(scope)))
215
+ forbidden()
216
+ const requestHash = await hash(new URL(request.url).searchParams.toString())
217
+ if (request.method === 'GET') {
218
+ const csrf = random()
219
+ await this.options.store.compareAndSet(actor, 'mcp-consent', await hash(csrf), null, {
220
+ requestHash,
221
+ sessionId: actor.sessionId,
222
+ expiresAt: this.auth.now() + 600000,
223
+ used: false,
224
+ })
225
+ const scopeLabels = parsed.scopes.map(
226
+ (scope) => this.options.scopes.find((item) => item.id === scope)?.label ?? scope,
227
+ )
228
+ const html = `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Authorize MCP client</title><body><main><h1>Authorize ${escapeHtml(parsed.client.name)}</h1><p>Grant the following permissions?</p><ul>${scopeLabels.map((label) => `<li>${escapeHtml(label)}</li>`).join('')}</ul><form method="post" action="${escapeHtml(new URL(request.url).pathname + new URL(request.url).search)}"><input type="hidden" name="csrf" value="${csrf}"><button name="decision" value="approve">Approve</button><button name="decision" value="deny">Deny</button></form></main></body></html>`
229
+ return new Response(html, {
230
+ headers: {
231
+ 'Content-Type': 'text/html; charset=utf-8',
232
+ 'Cache-Control': 'no-store',
233
+ 'Content-Security-Policy':
234
+ "default-src 'none'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
235
+ 'Referrer-Policy': 'no-referrer',
236
+ 'X-Content-Type-Options': 'nosniff',
237
+ },
238
+ })
239
+ }
240
+ if (request.headers.get('Origin') !== this.options.origin) forbidden()
241
+ const form = await this.form(request)
242
+ const csrf = form.get('csrf') ?? ''
243
+ const document = await this.options.store.get(actor, 'mcp-consent', await hash(csrf))
244
+ if (
245
+ !document ||
246
+ !object(document.value) ||
247
+ document.value.used !== false ||
248
+ document.value.requestHash !== requestHash ||
249
+ document.value.sessionId !== actor.sessionId ||
250
+ typeof document.value.expiresAt !== 'number' ||
251
+ document.value.expiresAt <= this.auth.now()
252
+ )
253
+ forbidden()
254
+ await this.options.store.compareAndSet(actor, 'mcp-consent', document.key, document.revision, {
255
+ ...document.value,
256
+ used: true,
257
+ })
258
+ const target = new URL(parsed.redirectUri)
259
+ if (parsed.state) target.searchParams.set('state', parsed.state)
260
+ if (
261
+ form.get('decision') !== 'approve' ||
262
+ !(await this.options.authorizeConsent(actor, parsed.scopes, request))
263
+ ) {
264
+ target.searchParams.set('error', 'access_denied')
265
+ return redirect(target.toString())
266
+ }
267
+ const grant = await this.auth.createGrant(
268
+ actor,
269
+ parsed.client.id,
270
+ parsed.redirectUri,
271
+ parsed.scopes,
272
+ )
273
+ const code = random()
274
+ await this.options.store.compareAndSet(this.auth.system, 'mcp-codes', await hash(code), null, {
275
+ owner: actor,
276
+ clientId: parsed.client.id,
277
+ redirectUri: parsed.redirectUri,
278
+ challenge: parsed.challenge,
279
+ grantId: grant.id,
280
+ expiresAt: this.auth.now() + 120000,
281
+ used: false,
282
+ resource: parsed.resource,
283
+ })
284
+ target.searchParams.set('code', code)
285
+ return redirect(target.toString())
286
+ }
287
+ private async form(request: Request): Promise<URLSearchParams> {
288
+ if (
289
+ !(request.headers.get('Content-Type') ?? '')
290
+ .toLowerCase()
291
+ .startsWith('application/x-www-form-urlencoded')
292
+ )
293
+ throw badRequest('body', 'form_required')
294
+ const form = new URLSearchParams(await body(request, 16384))
295
+ for (const key of new Set(form.keys()))
296
+ if (form.getAll(key).length !== 1) throw badRequest(key, 'duplicate')
297
+ return form
298
+ }
299
+ private async token(request: Request): Promise<Response> {
300
+ const form = await this.form(request)
301
+ const clientId = form.get('client_id') ?? ''
302
+ if (form.has('resource') && form.get('resource') !== `${this.options.origin}/mcp`)
303
+ throw badRequest('resource', 'invalid')
304
+ if (form.get('grant_type') === 'refresh_token')
305
+ return json(await this.auth.refresh(form.get('refresh_token') ?? '', clientId))
306
+ if (form.get('grant_type') !== 'authorization_code')
307
+ throw badRequest('grant_type', 'unsupported')
308
+ const document = await this.options.store.get(
309
+ this.auth.system,
310
+ 'mcp-codes',
311
+ await hash(form.get('code') ?? ''),
312
+ )
313
+ if (!document || !object(document.value)) throw new KitFailure('unauthorized', 'Invalid grant')
314
+ const value = document.value
315
+ const verifier = form.get('code_verifier') ?? ''
316
+ if (
317
+ value.resource !== `${this.options.origin}/mcp` ||
318
+ !validActor(value.owner, this.options.applicationId) ||
319
+ value.owner.kind !== 'user' ||
320
+ value.used !== false ||
321
+ typeof value.expiresAt !== 'number' ||
322
+ value.expiresAt <= this.auth.now() ||
323
+ value.clientId !== clientId ||
324
+ value.redirectUri !== form.get('redirect_uri') ||
325
+ typeof value.challenge !== 'string' ||
326
+ typeof value.grantId !== 'string' ||
327
+ !/^[A-Za-z0-9._~-]{43,128}$/u.test(verifier) ||
328
+ !equal(await hash(verifier), value.challenge)
329
+ )
330
+ throw new KitFailure('unauthorized', 'Invalid grant')
331
+ await this.options.store.compareAndSet(
332
+ this.auth.system,
333
+ 'mcp-codes',
334
+ document.key,
335
+ document.revision,
336
+ {
337
+ ...value,
338
+ used: true,
339
+ },
340
+ )
341
+ return json(await this.auth.issueTokens(value.owner, value.grantId, undefined, clientId))
342
+ }
343
+ }