@ouraihub/aether-worker 0.1.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.
- package/package.json +5 -4
- package/src/app.ts +52 -16
- package/src/context.ts +9 -3
- package/src/conversations/action.ts +31 -8
- package/src/conversations/index.ts +144 -141
- package/src/conversations/model.ts +36 -11
- package/src/conversations/routes.ts +101 -0
- package/src/coordinator-helpers.ts +131 -0
- package/src/coordinator.ts +54 -126
- package/src/credentials.ts +22 -4
- package/src/files.ts +17 -5
- package/src/host.ts +10 -2
- package/src/identity.ts +54 -25
- package/src/images/index.ts +39 -12
- package/src/logger.ts +15 -4
- package/src/mcp/auth.ts +65 -17
- package/src/mcp/client.ts +89 -24
- package/src/mcp/common.ts +7 -3
- package/src/mcp/index.ts +74 -21
- package/src/mcp/oauth.ts +82 -23
- package/src/mcp/types.ts +9 -2
- package/src/outbound.ts +23 -8
- package/src/providers/catalog.ts +63 -0
- package/src/providers/http.ts +88 -0
- package/src/providers/index.ts +103 -163
- package/src/providers/model-options.ts +28 -0
- package/src/runtime-input.ts +44 -0
- package/src/runtime-tools.ts +298 -0
- package/src/runtime.ts +105 -264
- package/src/search/index.ts +89 -23
- package/src/skills/persistence.ts +18 -5
- package/src/skills/service.ts +60 -13
- package/src/storage.ts +108 -13
- package/src/testing/fakes.ts +97 -0
package/src/mcp/index.ts
CHANGED
|
@@ -26,7 +26,11 @@ function rpc(id: unknown, result: unknown, error = false): Response {
|
|
|
26
26
|
...(error ? { error: result } : { result }),
|
|
27
27
|
}
|
|
28
28
|
if (encoder.encode(JSON.stringify(message)).byteLength > MAX_RESULT_BYTES)
|
|
29
|
-
return json({
|
|
29
|
+
return json({
|
|
30
|
+
jsonrpc: '2.0',
|
|
31
|
+
id: message.id,
|
|
32
|
+
error: { code: -32603, message: 'Result budget exceeded' },
|
|
33
|
+
})
|
|
30
34
|
return json(message)
|
|
31
35
|
}
|
|
32
36
|
function rpcError(id: unknown, code: number, message: string): Response {
|
|
@@ -79,7 +83,8 @@ export class McpService {
|
|
|
79
83
|
!url.pathname.startsWith('/.well-known/oauth-')
|
|
80
84
|
)
|
|
81
85
|
return null
|
|
82
|
-
if (url.origin !== this.options.origin || url.protocol !== 'https:')
|
|
86
|
+
if (url.origin !== this.options.origin || url.protocol !== 'https:')
|
|
87
|
+
return json({ error: 'invalid_request' }, 400)
|
|
83
88
|
if (url.pathname === '/mcp') return this.mcp(request)
|
|
84
89
|
return this.oauth.handle(request)
|
|
85
90
|
}
|
|
@@ -99,12 +104,18 @@ export class McpService {
|
|
|
99
104
|
!value.enabledScopes.every((scope: unknown) => typeof scope === 'string')
|
|
100
105
|
)
|
|
101
106
|
throw badRequest('enabledScopes', 'invalid')
|
|
102
|
-
const requested = value.enabledScopes.length
|
|
107
|
+
const requested = value.enabledScopes.length
|
|
108
|
+
? this.auth.requested(value.enabledScopes, context.actor)
|
|
109
|
+
: []
|
|
103
110
|
if (!(await this.options.authorizeConsent(context.actor, requested, request))) forbidden()
|
|
104
|
-
return json({
|
|
111
|
+
return json({
|
|
112
|
+
...(await this.auth.saveSettings(context.actor, value)),
|
|
113
|
+
scopes: this.options.scopes,
|
|
114
|
+
})
|
|
105
115
|
}
|
|
106
116
|
}
|
|
107
|
-
if (path === '/api/v1/mcp/grants' && request.method === 'GET')
|
|
117
|
+
if (path === '/api/v1/mcp/grants' && request.method === 'GET')
|
|
118
|
+
return json(await this.auth.grants(context.actor))
|
|
108
119
|
if (path === '/api/v1/mcp/api-keys') {
|
|
109
120
|
if (request.method === 'GET') return json(await this.auth.keys(context.actor))
|
|
110
121
|
if (request.method === 'POST')
|
|
@@ -114,26 +125,43 @@ export class McpService {
|
|
|
114
125
|
if (revoke && request.method === 'POST') {
|
|
115
126
|
const id = revoke[2]
|
|
116
127
|
if (!id) throw badRequest('id', 'invalid')
|
|
117
|
-
await this.auth.revoke(
|
|
128
|
+
await this.auth.revoke(
|
|
129
|
+
context.actor,
|
|
130
|
+
revoke[1] === 'grants' ? 'grant' : 'key',
|
|
131
|
+
decodeURIComponent(id),
|
|
132
|
+
)
|
|
118
133
|
return json({ ok: true })
|
|
119
134
|
}
|
|
120
135
|
return json({ code: 'not_found', message: 'Route not found' }, 404)
|
|
121
136
|
}
|
|
122
137
|
private async mcp(request: Request): Promise<Response> {
|
|
123
138
|
const origin = request.headers.get('Origin')
|
|
124
|
-
if (
|
|
139
|
+
if (
|
|
140
|
+
origin !== null &&
|
|
141
|
+
![this.options.origin, ...(this.options.mcpOrigins ?? [])].includes(origin)
|
|
142
|
+
)
|
|
125
143
|
return json({ error: 'forbidden' }, 403)
|
|
126
|
-
if (request.method !== 'POST')
|
|
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"' : '')
|
|
127
150
|
const bearer = /^Bearer[\t ]+([^\s,]+)$/iu.exec(request.headers.get('Authorization') ?? '')?.[1]
|
|
128
151
|
const authentication = bearer ? await this.auth.authenticate(bearer) : null
|
|
129
152
|
if (!authentication)
|
|
130
153
|
return json({ error: 'unauthorized' }, 401, {
|
|
131
|
-
'WWW-Authenticate':
|
|
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"`,
|
|
132
159
|
})
|
|
133
|
-
if (this.options.scopes.some((scope) => scope.id === 'mcp:access') && !authentication.scopes.includes('mcp:access'))
|
|
134
|
-
return json({ error: 'forbidden' }, 403)
|
|
135
160
|
const maxBytes =
|
|
136
|
-
this.options.tools.reduce(
|
|
161
|
+
this.options.tools.reduce(
|
|
162
|
+
(max, tool) => Math.max(max, tool.maxRequestBytes ?? 65536),
|
|
163
|
+
65536,
|
|
164
|
+
) + 8192
|
|
137
165
|
let message: unknown
|
|
138
166
|
try {
|
|
139
167
|
message = JSON.parse(await body(request, maxBytes))
|
|
@@ -146,20 +174,32 @@ export class McpService {
|
|
|
146
174
|
!object(message) ||
|
|
147
175
|
message.jsonrpc !== '2.0' ||
|
|
148
176
|
typeof message.method !== 'string' ||
|
|
149
|
-
('id' in message &&
|
|
177
|
+
('id' in message &&
|
|
178
|
+
typeof message.id !== 'string' &&
|
|
179
|
+
typeof message.id !== 'number' &&
|
|
180
|
+
message.id !== null)
|
|
150
181
|
)
|
|
151
182
|
return rpcError(null, -32600, 'Invalid request')
|
|
152
183
|
if (!('id' in message)) {
|
|
153
|
-
if (
|
|
184
|
+
if (
|
|
185
|
+
message.method === 'notifications/initialized' ||
|
|
186
|
+
message.method === 'notifications/cancelled'
|
|
187
|
+
)
|
|
154
188
|
return new Response(null, { status: 202 })
|
|
155
189
|
return rpcError(null, -32600, 'Invalid request')
|
|
156
190
|
}
|
|
157
191
|
const id = message.id
|
|
158
192
|
if (message.method === 'initialize') {
|
|
159
|
-
if (
|
|
160
|
-
|
|
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')
|
|
161
199
|
return rpc(id, {
|
|
162
|
-
protocolVersion: message.params.protocolVersion
|
|
200
|
+
protocolVersion: ['2024-11-05', '2025-03-26'].includes(message.params.protocolVersion)
|
|
201
|
+
? message.params.protocolVersion
|
|
202
|
+
: '2025-03-26',
|
|
163
203
|
capabilities: { tools: {} },
|
|
164
204
|
serverInfo: { name: 'Aether MCP', version: '1.0.0' },
|
|
165
205
|
})
|
|
@@ -208,18 +248,31 @@ export class McpService {
|
|
|
208
248
|
if (signal.aborted || !(await this.options.authorizeTool(auth.actor, tool, args, signal)))
|
|
209
249
|
return rpcError(id, -32001, 'Forbidden')
|
|
210
250
|
if (signal.aborted) return rpcError(id, -32603, 'Cancelled')
|
|
211
|
-
const result = await tool.handler(args, {
|
|
251
|
+
const result = await tool.handler(args, {
|
|
252
|
+
actor: auth.actor,
|
|
253
|
+
tokenId: auth.tokenId,
|
|
254
|
+
scopes: auth.scopes,
|
|
255
|
+
signal,
|
|
256
|
+
})
|
|
212
257
|
if (result === undefined) throw new KitFailure('internal', 'Invalid tool result')
|
|
213
258
|
const payload = object(result)
|
|
214
|
-
? {
|
|
259
|
+
? {
|
|
260
|
+
content: [{ type: 'text', text: JSON.stringify(result) }],
|
|
261
|
+
structuredContent: result,
|
|
262
|
+
isError: false,
|
|
263
|
+
}
|
|
215
264
|
: {
|
|
216
|
-
content: [
|
|
265
|
+
content: [
|
|
266
|
+
{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) },
|
|
267
|
+
],
|
|
217
268
|
isError: false,
|
|
218
269
|
}
|
|
219
270
|
const serialized = JSON.stringify({ jsonrpc: '2.0', id, result: payload })
|
|
220
271
|
if (encoder.encode(serialized).byteLength > MAX_RESULT_BYTES)
|
|
221
272
|
return rpcError(id, -32603, 'Result budget exceeded')
|
|
222
|
-
return new Response(serialized, {
|
|
273
|
+
return new Response(serialized, {
|
|
274
|
+
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
|
|
275
|
+
})
|
|
223
276
|
} catch (error) {
|
|
224
277
|
const safe = safeError(error)
|
|
225
278
|
const code =
|
package/src/mcp/oauth.ts
CHANGED
|
@@ -53,7 +53,10 @@ function redirectAllowed(value: string): boolean {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
function redirect(target: string): Response {
|
|
56
|
-
return new Response(null, {
|
|
56
|
+
return new Response(null, {
|
|
57
|
+
status: 302,
|
|
58
|
+
headers: { Location: target, 'Cache-Control': 'no-store' },
|
|
59
|
+
})
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
export class McpOAuth {
|
|
@@ -67,7 +70,11 @@ export class McpOAuth {
|
|
|
67
70
|
const url = new URL(request.url)
|
|
68
71
|
const origin = this.options.origin
|
|
69
72
|
const resource = `${origin}/mcp`
|
|
70
|
-
if (
|
|
73
|
+
if (
|
|
74
|
+
(url.pathname === '/.well-known/oauth-protected-resource' ||
|
|
75
|
+
url.pathname === '/.well-known/oauth-protected-resource/mcp') &&
|
|
76
|
+
request.method === 'GET'
|
|
77
|
+
)
|
|
71
78
|
return json({
|
|
72
79
|
resource,
|
|
73
80
|
authorization_servers: [origin],
|
|
@@ -86,15 +93,22 @@ export class McpOAuth {
|
|
|
86
93
|
token_endpoint_auth_methods_supported: ['none'],
|
|
87
94
|
scopes_supported: this.options.scopes.map((scope) => scope.id),
|
|
88
95
|
})
|
|
89
|
-
if (url.pathname === '/oauth/register' && request.method === 'POST')
|
|
90
|
-
|
|
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
|
+
)
|
|
91
102
|
return this.authorize(request)
|
|
92
103
|
if (url.pathname === '/oauth/token' && request.method === 'POST') {
|
|
93
104
|
try {
|
|
94
105
|
return await this.token(request)
|
|
95
106
|
} catch (error) {
|
|
96
107
|
if (!(error instanceof KitFailure)) throw error
|
|
97
|
-
return json(
|
|
108
|
+
return json(
|
|
109
|
+
{ error: error.code === 'bad_request' ? 'invalid_request' : 'invalid_grant' },
|
|
110
|
+
400,
|
|
111
|
+
)
|
|
98
112
|
}
|
|
99
113
|
}
|
|
100
114
|
if (url.pathname.startsWith('/oauth/') || url.pathname.startsWith('/.well-known/oauth-'))
|
|
@@ -110,7 +124,10 @@ export class McpOAuth {
|
|
|
110
124
|
input.redirect_uris.some((uri) => uri.length > 2048 || !redirectAllowed(uri))
|
|
111
125
|
)
|
|
112
126
|
throw badRequest('redirect_uris', 'invalid')
|
|
113
|
-
if (
|
|
127
|
+
if (
|
|
128
|
+
input.token_endpoint_auth_method !== undefined &&
|
|
129
|
+
input.token_endpoint_auth_method !== 'none'
|
|
130
|
+
)
|
|
114
131
|
throw badRequest('token_endpoint_auth_method', 'unsupported')
|
|
115
132
|
if (
|
|
116
133
|
input.grant_types !== undefined &&
|
|
@@ -123,7 +140,10 @@ export class McpOAuth {
|
|
|
123
140
|
(!strings(input.response_types) || input.response_types.some((type) => type !== 'code'))
|
|
124
141
|
)
|
|
125
142
|
throw badRequest('response_types', 'unsupported')
|
|
126
|
-
if (
|
|
143
|
+
if (
|
|
144
|
+
input.client_name !== undefined &&
|
|
145
|
+
(typeof input.client_name !== 'string' || input.client_name.length > 128)
|
|
146
|
+
)
|
|
127
147
|
throw badRequest('client_name', 'invalid')
|
|
128
148
|
const record: OAuthClient = {
|
|
129
149
|
id: crypto.randomUUID(),
|
|
@@ -157,18 +177,31 @@ export class McpOAuth {
|
|
|
157
177
|
'resource',
|
|
158
178
|
])
|
|
159
179
|
if (query.getAll(key).length > 1) throw badRequest(key, 'duplicate')
|
|
160
|
-
const record = await this.options.store.get(
|
|
180
|
+
const record = await this.options.store.get(
|
|
181
|
+
this.auth.system,
|
|
182
|
+
'mcp-clients',
|
|
183
|
+
query.get('client_id') ?? '',
|
|
184
|
+
)
|
|
161
185
|
if (!record || !client(record.value)) throw badRequest('client_id', 'invalid')
|
|
162
186
|
const redirectUri = query.get('redirect_uri') ?? ''
|
|
163
|
-
if (!record.value.redirectUris.includes(redirectUri))
|
|
187
|
+
if (!record.value.redirectUris.includes(redirectUri))
|
|
188
|
+
throw badRequest('redirect_uri', 'mismatch')
|
|
164
189
|
if (query.get('response_type') !== 'code' || query.get('code_challenge_method') !== 'S256')
|
|
165
190
|
throw badRequest('response_type', 'unsupported')
|
|
166
191
|
const challenge = query.get('code_challenge') ?? ''
|
|
167
192
|
if (!/^[A-Za-z0-9_-]{43}$/u.test(challenge)) throw badRequest('code_challenge', 'invalid')
|
|
168
193
|
const resource = `${this.options.origin}/mcp`
|
|
169
|
-
if (query.has('resource') && query.get('resource') !== resource)
|
|
194
|
+
if (query.has('resource') && query.get('resource') !== resource)
|
|
195
|
+
throw badRequest('resource', 'invalid')
|
|
170
196
|
const scopes = this.auth.requested((query.get('scope') ?? '').split(/\s+/u).filter(Boolean))
|
|
171
|
-
return {
|
|
197
|
+
return {
|
|
198
|
+
client: record.value,
|
|
199
|
+
redirectUri,
|
|
200
|
+
scopes,
|
|
201
|
+
challenge,
|
|
202
|
+
state: query.get('state') ?? '',
|
|
203
|
+
resource,
|
|
204
|
+
}
|
|
172
205
|
}
|
|
173
206
|
private async authorize(request: Request): Promise<Response> {
|
|
174
207
|
const parsed = await this.parse(request)
|
|
@@ -178,7 +211,8 @@ export class McpOAuth {
|
|
|
178
211
|
this.auth.actor(actor)
|
|
179
212
|
this.auth.requested(parsed.scopes, actor)
|
|
180
213
|
const settings = await this.auth.settings(actor)
|
|
181
|
-
if (!settings.enabled || parsed.scopes.some((scope) => !settings.enabledScopes.includes(scope)))
|
|
214
|
+
if (!settings.enabled || parsed.scopes.some((scope) => !settings.enabledScopes.includes(scope)))
|
|
215
|
+
forbidden()
|
|
182
216
|
const requestHash = await hash(new URL(request.url).searchParams.toString())
|
|
183
217
|
if (request.method === 'GET') {
|
|
184
218
|
const csrf = random()
|
|
@@ -196,7 +230,8 @@ export class McpOAuth {
|
|
|
196
230
|
headers: {
|
|
197
231
|
'Content-Type': 'text/html; charset=utf-8',
|
|
198
232
|
'Cache-Control': 'no-store',
|
|
199
|
-
'Content-Security-Policy':
|
|
233
|
+
'Content-Security-Policy':
|
|
234
|
+
"default-src 'none'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
|
|
200
235
|
'Referrer-Policy': 'no-referrer',
|
|
201
236
|
'X-Content-Type-Options': 'nosniff',
|
|
202
237
|
},
|
|
@@ -222,11 +257,19 @@ export class McpOAuth {
|
|
|
222
257
|
})
|
|
223
258
|
const target = new URL(parsed.redirectUri)
|
|
224
259
|
if (parsed.state) target.searchParams.set('state', parsed.state)
|
|
225
|
-
if (
|
|
260
|
+
if (
|
|
261
|
+
form.get('decision') !== 'approve' ||
|
|
262
|
+
!(await this.options.authorizeConsent(actor, parsed.scopes, request))
|
|
263
|
+
) {
|
|
226
264
|
target.searchParams.set('error', 'access_denied')
|
|
227
265
|
return redirect(target.toString())
|
|
228
266
|
}
|
|
229
|
-
const grant = await this.auth.createGrant(
|
|
267
|
+
const grant = await this.auth.createGrant(
|
|
268
|
+
actor,
|
|
269
|
+
parsed.client.id,
|
|
270
|
+
parsed.redirectUri,
|
|
271
|
+
parsed.scopes,
|
|
272
|
+
)
|
|
230
273
|
const code = random()
|
|
231
274
|
await this.options.store.compareAndSet(this.auth.system, 'mcp-codes', await hash(code), null, {
|
|
232
275
|
owner: actor,
|
|
@@ -242,10 +285,15 @@ export class McpOAuth {
|
|
|
242
285
|
return redirect(target.toString())
|
|
243
286
|
}
|
|
244
287
|
private async form(request: Request): Promise<URLSearchParams> {
|
|
245
|
-
if (
|
|
288
|
+
if (
|
|
289
|
+
!(request.headers.get('Content-Type') ?? '')
|
|
290
|
+
.toLowerCase()
|
|
291
|
+
.startsWith('application/x-www-form-urlencoded')
|
|
292
|
+
)
|
|
246
293
|
throw badRequest('body', 'form_required')
|
|
247
294
|
const form = new URLSearchParams(await body(request, 16384))
|
|
248
|
-
for (const key of new Set(form.keys()))
|
|
295
|
+
for (const key of new Set(form.keys()))
|
|
296
|
+
if (form.getAll(key).length !== 1) throw badRequest(key, 'duplicate')
|
|
249
297
|
return form
|
|
250
298
|
}
|
|
251
299
|
private async token(request: Request): Promise<Response> {
|
|
@@ -255,8 +303,13 @@ export class McpOAuth {
|
|
|
255
303
|
throw badRequest('resource', 'invalid')
|
|
256
304
|
if (form.get('grant_type') === 'refresh_token')
|
|
257
305
|
return json(await this.auth.refresh(form.get('refresh_token') ?? '', clientId))
|
|
258
|
-
if (form.get('grant_type') !== 'authorization_code')
|
|
259
|
-
|
|
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
|
+
)
|
|
260
313
|
if (!document || !object(document.value)) throw new KitFailure('unauthorized', 'Invalid grant')
|
|
261
314
|
const value = document.value
|
|
262
315
|
const verifier = form.get('code_verifier') ?? ''
|
|
@@ -275,10 +328,16 @@ export class McpOAuth {
|
|
|
275
328
|
!equal(await hash(verifier), value.challenge)
|
|
276
329
|
)
|
|
277
330
|
throw new KitFailure('unauthorized', 'Invalid grant')
|
|
278
|
-
await this.options.store.compareAndSet(
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
+
)
|
|
282
341
|
return json(await this.auth.issueTokens(value.owner, value.grantId, undefined, clientId))
|
|
283
342
|
}
|
|
284
343
|
}
|
package/src/mcp/types.ts
CHANGED
|
@@ -18,7 +18,10 @@ export interface ExposedMcpTool {
|
|
|
18
18
|
readonly idempotent: boolean
|
|
19
19
|
readonly maxRequestBytes?: number
|
|
20
20
|
readonly validate: (args: Readonly<Record<string, unknown>>) => boolean
|
|
21
|
-
readonly handler: (
|
|
21
|
+
readonly handler: (
|
|
22
|
+
args: Readonly<Record<string, unknown>>,
|
|
23
|
+
context: McpCallContext,
|
|
24
|
+
) => Promise<unknown>
|
|
22
25
|
}
|
|
23
26
|
export interface McpCallContext {
|
|
24
27
|
readonly actor: Actor
|
|
@@ -36,7 +39,11 @@ export interface McpServiceOptions {
|
|
|
36
39
|
readonly tools: readonly ExposedMcpTool[]
|
|
37
40
|
readonly resolveIdentity: (request: Request) => Promise<Actor | null>
|
|
38
41
|
/** Must check the actor may grant every requested scope, not just that login exists. */
|
|
39
|
-
readonly authorizeConsent: (
|
|
42
|
+
readonly authorizeConsent: (
|
|
43
|
+
actor: Actor,
|
|
44
|
+
scopes: readonly string[],
|
|
45
|
+
request: Request,
|
|
46
|
+
) => Promise<boolean>
|
|
40
47
|
/** Must check resource/business access immediately before external tool dispatch. */
|
|
41
48
|
readonly authorizeTool: (
|
|
42
49
|
actor: Actor,
|
package/src/outbound.ts
CHANGED
|
@@ -48,7 +48,12 @@ function privateLiteral(host: string): boolean {
|
|
|
48
48
|
)
|
|
49
49
|
}
|
|
50
50
|
if (!/^\d+(?:\.\d+){3}$/u.test(host))
|
|
51
|
-
return
|
|
51
|
+
return (
|
|
52
|
+
host === 'localhost' ||
|
|
53
|
+
host.endsWith('.localhost') ||
|
|
54
|
+
host.endsWith('.local') ||
|
|
55
|
+
!host.includes('.')
|
|
56
|
+
)
|
|
52
57
|
const parts = host.split('.').map(Number)
|
|
53
58
|
const a = parts[0],
|
|
54
59
|
b = parts[1],
|
|
@@ -81,11 +86,13 @@ export function createWorkerOutbound(options: WorkerOutboundOptions): OutboundHt
|
|
|
81
86
|
} catch {
|
|
82
87
|
throw badRequest('allowedOrigins', 'invalid')
|
|
83
88
|
}
|
|
84
|
-
if (url.origin !== origin || url.username || url.password)
|
|
89
|
+
if (url.origin !== origin || url.username || url.password)
|
|
90
|
+
throw badRequest('allowedOrigins', 'invalid')
|
|
85
91
|
return origin
|
|
86
92
|
}),
|
|
87
93
|
)
|
|
88
|
-
if (origins.size === 0)
|
|
94
|
+
if (origins.size === 0)
|
|
95
|
+
throw new KitFailure('needs_config', 'Outbound origins must be explicitly configured')
|
|
89
96
|
const privateOrigins = new Set(options.privateGateway?.allowedOrigins ?? [])
|
|
90
97
|
for (const origin of privateOrigins) {
|
|
91
98
|
let target: URL
|
|
@@ -126,7 +133,9 @@ export function createWorkerOutbound(options: WorkerOutboundOptions): OutboundHt
|
|
|
126
133
|
!local &&
|
|
127
134
|
(!gateway ||
|
|
128
135
|
gateway.security !==
|
|
129
|
-
(privateTarget
|
|
136
|
+
(privateTarget
|
|
137
|
+
? 'explicit-private-targets-at-connect-time'
|
|
138
|
+
: 'public-addresses-at-connect-time'))
|
|
130
139
|
)
|
|
131
140
|
throw new KitFailure('needs_config', 'Trusted connect-time outbound gateway is required')
|
|
132
141
|
const abort = new AbortController()
|
|
@@ -169,7 +178,8 @@ export function createWorkerOutbound(options: WorkerOutboundOptions): OutboundHt
|
|
|
169
178
|
init.signal?.addEventListener('abort', callerAbort, { once: true })
|
|
170
179
|
abort.signal.addEventListener('abort', onAbort, { once: true })
|
|
171
180
|
if (init.signal?.aborted) callerAbort()
|
|
172
|
-
if (abort.signal.aborted)
|
|
181
|
+
if (abort.signal.aborted)
|
|
182
|
+
throw failure ?? new KitFailure('cancelled', 'Outbound request cancelled')
|
|
173
183
|
try {
|
|
174
184
|
const headers = new Headers(init.headers)
|
|
175
185
|
headers.set('User-Agent', 'Aether-Worker/1')
|
|
@@ -184,7 +194,8 @@ export function createWorkerOutbound(options: WorkerOutboundOptions): OutboundHt
|
|
|
184
194
|
const pending = local
|
|
185
195
|
? (options.localFetch ?? fetch)(target.href, request)
|
|
186
196
|
: gateway?.binding.fetch(target.href, request)
|
|
187
|
-
if (!pending)
|
|
197
|
+
if (!pending)
|
|
198
|
+
throw new KitFailure('needs_config', 'Trusted connect-time outbound gateway is required')
|
|
188
199
|
void pending.then(
|
|
189
200
|
(response) => {
|
|
190
201
|
if (abort.signal.aborted) void response.body?.cancel().catch(() => {})
|
|
@@ -192,7 +203,8 @@ export function createWorkerOutbound(options: WorkerOutboundOptions): OutboundHt
|
|
|
192
203
|
() => {},
|
|
193
204
|
)
|
|
194
205
|
const aborted = Promise.withResolvers<never>()
|
|
195
|
-
const rejectAbort = () =>
|
|
206
|
+
const rejectAbort = () =>
|
|
207
|
+
aborted.reject(failure ?? new KitFailure('cancelled', 'Outbound request cancelled'))
|
|
196
208
|
abort.signal.addEventListener('abort', rejectAbort, { once: true })
|
|
197
209
|
let response: Response
|
|
198
210
|
try {
|
|
@@ -209,7 +221,10 @@ export function createWorkerOutbound(options: WorkerOutboundOptions): OutboundHt
|
|
|
209
221
|
throw badRequest('url', 'redirect_forbidden')
|
|
210
222
|
}
|
|
211
223
|
const length = response.headers.get('content-length')
|
|
212
|
-
if (
|
|
224
|
+
if (
|
|
225
|
+
length !== null &&
|
|
226
|
+
(!/^\d+$/u.test(length) || Number(length) > limits.maxResponseBytes)
|
|
227
|
+
) {
|
|
213
228
|
void response.body?.cancel().catch(() => {})
|
|
214
229
|
throw badRequest('response', 'too_large', limits.maxResponseBytes)
|
|
215
230
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { isContract, type Provider } from '@ouraihub/aether-contracts'
|
|
2
|
+
import { KitFailure } from '@ouraihub/aether-core/error'
|
|
3
|
+
import { inferReasoning } from '@ouraihub/aether-core/providers/infer'
|
|
4
|
+
import { isSealedCredential, type SealedCredential } from '../credentials.js'
|
|
5
|
+
|
|
6
|
+
export interface Secret {
|
|
7
|
+
endpointId: string
|
|
8
|
+
keyId: string
|
|
9
|
+
sealed: SealedCredential
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Catalog {
|
|
13
|
+
providers: Provider[]
|
|
14
|
+
secrets: Secret[]
|
|
15
|
+
inferred: string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
19
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseCatalog(value: unknown): Catalog {
|
|
23
|
+
if (
|
|
24
|
+
!record(value) ||
|
|
25
|
+
!Array.isArray(value.providers) ||
|
|
26
|
+
!Array.isArray(value.secrets) ||
|
|
27
|
+
!Array.isArray(value.inferred)
|
|
28
|
+
)
|
|
29
|
+
throw new KitFailure('incompatible_db', 'Invalid provider catalog')
|
|
30
|
+
const providers: Provider[] = []
|
|
31
|
+
const secrets: Secret[] = []
|
|
32
|
+
const inferred: string[] = []
|
|
33
|
+
for (const item of value.providers) {
|
|
34
|
+
if (!isContract('Provider', item))
|
|
35
|
+
throw new KitFailure('incompatible_db', 'Invalid provider document')
|
|
36
|
+
providers.push(item)
|
|
37
|
+
}
|
|
38
|
+
for (const item of value.secrets) {
|
|
39
|
+
if (
|
|
40
|
+
!record(item) ||
|
|
41
|
+
typeof item.endpointId !== 'string' ||
|
|
42
|
+
typeof item.keyId !== 'string' ||
|
|
43
|
+
!isSealedCredential(item.sealed)
|
|
44
|
+
)
|
|
45
|
+
throw new KitFailure('incompatible_db', 'Invalid provider credential')
|
|
46
|
+
secrets.push({ endpointId: item.endpointId, keyId: item.keyId, sealed: item.sealed })
|
|
47
|
+
}
|
|
48
|
+
for (const item of value.inferred) {
|
|
49
|
+
if (typeof item !== 'string')
|
|
50
|
+
throw new KitFailure('incompatible_db', 'Invalid reasoning declaration')
|
|
51
|
+
inferred.push(item)
|
|
52
|
+
}
|
|
53
|
+
const automatic = new Set(inferred)
|
|
54
|
+
for (const provider of providers)
|
|
55
|
+
for (const endpoint of provider.endpoints)
|
|
56
|
+
for (const model of endpoint.models)
|
|
57
|
+
if (automatic.has(JSON.stringify([endpoint.id, model.id])))
|
|
58
|
+
model.reasoning =
|
|
59
|
+
model.modelType === 'chat'
|
|
60
|
+
? inferReasoning(model.id, endpoint.protocol, endpoint.baseUrl, model.maxOutput)
|
|
61
|
+
: null
|
|
62
|
+
return { providers, secrets, inferred }
|
|
63
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isContract,
|
|
3
|
+
type ModelFilter,
|
|
4
|
+
type ProviderSave,
|
|
5
|
+
type EndpointKeySave,
|
|
6
|
+
type ProbeTarget,
|
|
7
|
+
} from '@ouraihub/aether-contracts'
|
|
8
|
+
import { KitFailure, badRequest } from '@ouraihub/aether-core/error'
|
|
9
|
+
import type { WorkerContext } from '../context.js'
|
|
10
|
+
import type { ProviderService } from './index.js'
|
|
11
|
+
|
|
12
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
13
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function handleProviderRequest(
|
|
17
|
+
service: ProviderService,
|
|
18
|
+
request: Request,
|
|
19
|
+
context: WorkerContext,
|
|
20
|
+
authorizeReveal: (request: Request, context: WorkerContext) => Promise<void>,
|
|
21
|
+
): Promise<Response | null> {
|
|
22
|
+
const url = new URL(request.url)
|
|
23
|
+
const path = url.pathname
|
|
24
|
+
const root = '/api/v1/providers'
|
|
25
|
+
if (path !== root && !path.startsWith(`${root}/`)) return null
|
|
26
|
+
const actor = context.actor
|
|
27
|
+
const json = (value: unknown): Response => Response.json(value)
|
|
28
|
+
if (path === root && request.method === 'GET') return json(await service.list(actor))
|
|
29
|
+
if (path === root && request.method === 'POST') {
|
|
30
|
+
const value: unknown = await request.json()
|
|
31
|
+
if (!isContract('ProviderSave', value)) throw badRequest('provider', 'invalid')
|
|
32
|
+
return json(await service.save(actor, value as ProviderSave))
|
|
33
|
+
}
|
|
34
|
+
if (path === `${root}/model-options` && request.method === 'GET') {
|
|
35
|
+
const value: unknown = {
|
|
36
|
+
...(url.searchParams.has('modelType')
|
|
37
|
+
? { modelType: url.searchParams.get('modelType') }
|
|
38
|
+
: {}),
|
|
39
|
+
...(url.searchParams.has('supportsTools')
|
|
40
|
+
? { supportsTools: url.searchParams.get('supportsTools') === 'true' }
|
|
41
|
+
: {}),
|
|
42
|
+
}
|
|
43
|
+
if (
|
|
44
|
+
url.searchParams.has('supportsTools') &&
|
|
45
|
+
!['true', 'false'].includes(url.searchParams.get('supportsTools') ?? '')
|
|
46
|
+
)
|
|
47
|
+
throw badRequest('supportsTools', 'invalid')
|
|
48
|
+
if (!isContract('ModelFilter', value)) throw badRequest('filter', 'invalid')
|
|
49
|
+
return json(await service.modelOptions(actor, value as ModelFilter))
|
|
50
|
+
}
|
|
51
|
+
if (path === `${root}/probe` && request.method === 'POST') {
|
|
52
|
+
const value: unknown = await request.json()
|
|
53
|
+
if (!isContract('ProbeTarget', value)) throw badRequest('target', 'invalid')
|
|
54
|
+
return json(await service.probe(actor, value as ProbeTarget, context.signal))
|
|
55
|
+
}
|
|
56
|
+
if (path === `${root}/keys` && request.method === 'POST') {
|
|
57
|
+
const value: unknown = await request.json()
|
|
58
|
+
if (!isContract('EndpointKeySave', value)) throw badRequest('key', 'invalid')
|
|
59
|
+
return json(await service.saveKey(actor, value as EndpointKeySave))
|
|
60
|
+
}
|
|
61
|
+
const reveal = path.match(/^\/api\/v1\/providers\/keys\/([^/]+)\/reveal$/)
|
|
62
|
+
if (reveal && request.method === 'POST') {
|
|
63
|
+
await authorizeReveal(request, context)
|
|
64
|
+
return json(await service.revealKey(actor, decodeURIComponent(reveal[1] ?? '')))
|
|
65
|
+
}
|
|
66
|
+
const key = path.match(/^\/api\/v1\/providers\/([^/]+)\/keys\/([^/]+)$/)
|
|
67
|
+
if (key && request.method === 'DELETE')
|
|
68
|
+
return json(
|
|
69
|
+
await service.removeKey(
|
|
70
|
+
actor,
|
|
71
|
+
decodeURIComponent(key[1] ?? ''),
|
|
72
|
+
decodeURIComponent(key[2] ?? ''),
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
const enabled = path.match(/^\/api\/v1\/providers\/([^/]+)\/enabled$/)
|
|
76
|
+
if (enabled && request.method === 'POST') {
|
|
77
|
+
const value: unknown = await request.json()
|
|
78
|
+
if (!record(value) || Object.keys(value).length !== 1 || typeof value.enabled !== 'boolean')
|
|
79
|
+
throw badRequest('enabled', 'invalid')
|
|
80
|
+
return json(
|
|
81
|
+
await service.setEnabled(actor, decodeURIComponent(enabled[1] ?? ''), value.enabled),
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
const provider = path.match(/^\/api\/v1\/providers\/([^/]+)$/)
|
|
85
|
+
if (provider && request.method === 'DELETE')
|
|
86
|
+
return json(await service.remove(actor, decodeURIComponent(provider[1] ?? '')))
|
|
87
|
+
throw new KitFailure('not_found', 'Unknown route', { entity: 'route' })
|
|
88
|
+
}
|