@ouraihub/aether-worker 0.0.0-stage → 0.1.0

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