@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.
- package/LICENSE +789 -0
- package/migrations/0001.sql +22 -0
- package/package.json +37 -3
- package/src/app.ts +327 -0
- package/src/cloudflare.ts +49 -0
- package/src/context.ts +49 -0
- package/src/conversations/action.ts +234 -0
- package/src/conversations/index.ts +477 -0
- package/src/conversations/model.ts +402 -0
- package/src/coordinator.ts +560 -0
- package/src/credentials.ts +153 -0
- package/src/files.ts +105 -0
- package/src/host.ts +17 -0
- package/src/identity.ts +198 -0
- package/src/images/index.ts +46 -0
- package/src/index.ts +30 -0
- package/src/logger.ts +87 -0
- package/src/mcp/auth.ts +350 -0
- package/src/mcp/client.ts +426 -0
- package/src/mcp/common.ts +120 -0
- package/src/mcp/index.ts +239 -0
- package/src/mcp/oauth.ts +284 -0
- package/src/mcp/types.ts +74 -0
- package/src/outbound.ts +305 -0
- package/src/providers/index.ts +505 -0
- package/src/runtime.ts +635 -0
- package/src/search/index.ts +431 -0
- package/src/skills/index.ts +1 -0
- package/src/skills/persistence.ts +140 -0
- package/src/skills/service.ts +203 -0
- package/src/storage.ts +198 -0
- package/README.md +0 -3
package/src/mcp/index.ts
ADDED
|
@@ -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
|
+
}
|
package/src/mcp/oauth.ts
ADDED
|
@@ -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
|
+
}
|
package/src/mcp/types.ts
ADDED
|
@@ -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
|
+
}
|