@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.
- package/LICENSE +789 -0
- package/migrations/0001.sql +22 -0
- package/package.json +38 -3
- package/src/app.ts +363 -0
- package/src/cloudflare.ts +49 -0
- package/src/context.ts +55 -0
- package/src/conversations/action.ts +257 -0
- package/src/conversations/index.ts +480 -0
- package/src/conversations/model.ts +427 -0
- package/src/conversations/routes.ts +101 -0
- package/src/coordinator-helpers.ts +131 -0
- package/src/coordinator.ts +488 -0
- package/src/credentials.ts +171 -0
- package/src/files.ts +117 -0
- package/src/host.ts +25 -0
- package/src/identity.ts +227 -0
- package/src/images/index.ts +73 -0
- package/src/index.ts +30 -0
- package/src/logger.ts +98 -0
- package/src/mcp/auth.ts +398 -0
- package/src/mcp/client.ts +491 -0
- package/src/mcp/common.ts +124 -0
- package/src/mcp/index.ts +292 -0
- package/src/mcp/oauth.ts +343 -0
- package/src/mcp/types.ts +81 -0
- package/src/outbound.ts +320 -0
- package/src/providers/catalog.ts +63 -0
- package/src/providers/http.ts +88 -0
- package/src/providers/index.ts +445 -0
- 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 +476 -0
- package/src/search/index.ts +497 -0
- package/src/skills/index.ts +1 -0
- package/src/skills/persistence.ts +153 -0
- package/src/skills/service.ts +250 -0
- package/src/storage.ts +293 -0
- package/src/testing/fakes.ts +97 -0
- package/README.md +0 -3
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import type { ServerDto, ToolDto } from '@ouraihub/aether-contracts'
|
|
2
|
+
import { badRequest, KitFailure } from '@ouraihub/aether-core/error'
|
|
3
|
+
import { isRecord as object } from '@ouraihub/aether-core/guard'
|
|
4
|
+
import {
|
|
5
|
+
McpRuntime,
|
|
6
|
+
type Approval,
|
|
7
|
+
type McpCachedTool,
|
|
8
|
+
type McpConfigStore,
|
|
9
|
+
type McpServerConfig,
|
|
10
|
+
} from '@ouraihub/aether-core/mcp'
|
|
11
|
+
import type { OutboundHttp } from '@ouraihub/aether-core/ports'
|
|
12
|
+
import { ownerKey, type Actor, type WorkerContext } from '../context.js'
|
|
13
|
+
import {
|
|
14
|
+
isSealedCredential,
|
|
15
|
+
type CredentialPort,
|
|
16
|
+
type CredentialScope,
|
|
17
|
+
type SealedCredential,
|
|
18
|
+
} from '../credentials.js'
|
|
19
|
+
import type { DocumentStore } from '../storage.js'
|
|
20
|
+
|
|
21
|
+
const COLLECTION = 'mcp-client'
|
|
22
|
+
const KEY = 'servers'
|
|
23
|
+
const SECRET_PARAMS: Readonly<Record<string, true>> = {
|
|
24
|
+
key: true,
|
|
25
|
+
api_key: true,
|
|
26
|
+
apikey: true,
|
|
27
|
+
token: true,
|
|
28
|
+
access_token: true,
|
|
29
|
+
ak: true,
|
|
30
|
+
sk: true,
|
|
31
|
+
secret: true,
|
|
32
|
+
client_secret: true,
|
|
33
|
+
password: true,
|
|
34
|
+
signature: true,
|
|
35
|
+
}
|
|
36
|
+
const SECRET_PATTERN =
|
|
37
|
+
/(?:\b(?:sk-|ghp_|gho_|github_pat_|xoxb-|xoxp-|AKIA|AIza|glpat-)[A-Za-z0-9_./+=-]{0,}|\bBearer\s+[A-Za-z0-9_./+=-]{20,}|-----BEGIN[^\r\n]*PRIVATE KEY)/g
|
|
38
|
+
|
|
39
|
+
interface Secret {
|
|
40
|
+
headers: Record<string, string>
|
|
41
|
+
query: Record<string, string>
|
|
42
|
+
}
|
|
43
|
+
interface Server {
|
|
44
|
+
id: string
|
|
45
|
+
slug: string
|
|
46
|
+
name: string
|
|
47
|
+
url: string
|
|
48
|
+
enabled: boolean
|
|
49
|
+
trusted: boolean
|
|
50
|
+
allowPrivate: boolean
|
|
51
|
+
secret: SealedCredential | null
|
|
52
|
+
secretKeys: string[]
|
|
53
|
+
tools: McpCachedTool[]
|
|
54
|
+
}
|
|
55
|
+
interface State {
|
|
56
|
+
version: 1
|
|
57
|
+
servers: Server[]
|
|
58
|
+
deleted: string[]
|
|
59
|
+
}
|
|
60
|
+
interface Snapshot {
|
|
61
|
+
revision: number | null
|
|
62
|
+
state: State
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function fields(value: object, allowed: readonly string[], field: string): void {
|
|
66
|
+
if (Object.keys(value).some((key) => !allowed.includes(key))) throw badRequest(field, 'invalid')
|
|
67
|
+
}
|
|
68
|
+
function text(value: unknown, field: string): string {
|
|
69
|
+
if (typeof value !== 'string' || !value.trim()) throw badRequest(field, 'required')
|
|
70
|
+
return value
|
|
71
|
+
}
|
|
72
|
+
function bool(value: unknown, field: string, fallback?: boolean): boolean {
|
|
73
|
+
if (value === undefined && fallback !== undefined) return fallback
|
|
74
|
+
if (typeof value !== 'boolean') throw badRequest(field, 'invalid')
|
|
75
|
+
return value
|
|
76
|
+
}
|
|
77
|
+
function approval(value: unknown): Approval {
|
|
78
|
+
if (value !== 'auto' && value !== 'ask' && value !== 'deny')
|
|
79
|
+
throw badRequest('approval', 'invalid')
|
|
80
|
+
return value
|
|
81
|
+
}
|
|
82
|
+
function looksSecret(value: string): boolean {
|
|
83
|
+
for (const match of value.matchAll(SECRET_PATTERN))
|
|
84
|
+
if (match[0].length >= 20 || match[0].startsWith('-----BEGIN')) return true
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
function serverUrl(raw: unknown, allowPrivate: boolean): string {
|
|
88
|
+
const input = text(raw, 'url')
|
|
89
|
+
let url: URL
|
|
90
|
+
try {
|
|
91
|
+
url = new URL(input)
|
|
92
|
+
} catch {
|
|
93
|
+
throw badRequest('url', 'invalid_url')
|
|
94
|
+
}
|
|
95
|
+
if (url.username || url.password) throw badRequest('url', 'userinfo')
|
|
96
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') throw badRequest('url', 'scheme')
|
|
97
|
+
if (!url.hostname) throw badRequest('url', 'invalid_url')
|
|
98
|
+
if (url.hash) throw badRequest('url', 'fragment')
|
|
99
|
+
for (const [name, value] of url.searchParams) {
|
|
100
|
+
if (Object.hasOwn(SECRET_PARAMS, name.toLowerCase()) || looksSecret(value))
|
|
101
|
+
throw badRequest('url', 'credential_query')
|
|
102
|
+
}
|
|
103
|
+
const host = url.hostname.replace(/^\[|\]$/g, '')
|
|
104
|
+
const loopback =
|
|
105
|
+
host === 'localhost' || host.endsWith('.localhost') || host === '::1' || /^127\./.test(host)
|
|
106
|
+
if (url.protocol === 'http:' && !allowPrivate) throw badRequest('url', 'plain_http')
|
|
107
|
+
// DNS and connection-time restrictions remain the injected outbound port's responsibility.
|
|
108
|
+
const restricted =
|
|
109
|
+
/^(?:0\.|10\.|169\.254\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.|198\.(?:18|19)\.|(?:22[4-9]|23\d|24\d|25[0-5])\.)/.test(
|
|
110
|
+
host,
|
|
111
|
+
) ||
|
|
112
|
+
host === '::' ||
|
|
113
|
+
/^(?:f[cd][0-9a-f]{2}:|fe[89ab][0-9a-f]:|ff[0-9a-f]{2}:)/i.test(host)
|
|
114
|
+
if ((restricted || loopback) && !allowPrivate) throw badRequest('url', 'blocked_address')
|
|
115
|
+
return url.href
|
|
116
|
+
}
|
|
117
|
+
function maskedUrl(raw: string): string {
|
|
118
|
+
const url = new URL(raw)
|
|
119
|
+
url.username = ''
|
|
120
|
+
url.password = ''
|
|
121
|
+
for (const [name, value] of url.searchParams)
|
|
122
|
+
if (Object.hasOwn(SECRET_PARAMS, name.toLowerCase()) || looksSecret(value))
|
|
123
|
+
url.searchParams.set(name, '***')
|
|
124
|
+
return url.href
|
|
125
|
+
}
|
|
126
|
+
function strings(value: unknown, field: string): Record<string, string> {
|
|
127
|
+
if (value === undefined) return {}
|
|
128
|
+
if (!object(value)) throw badRequest(field, 'invalid')
|
|
129
|
+
const result: Record<string, string> = {}
|
|
130
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
131
|
+
if (!key || typeof entry !== 'string') throw badRequest(field, 'invalid')
|
|
132
|
+
Object.defineProperty(result, key, {
|
|
133
|
+
value: entry,
|
|
134
|
+
enumerable: true,
|
|
135
|
+
writable: true,
|
|
136
|
+
configurable: true,
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
return result
|
|
140
|
+
}
|
|
141
|
+
function parseSecret(value: unknown): Secret {
|
|
142
|
+
if (!object(value)) throw badRequest('secret', 'invalid')
|
|
143
|
+
fields(value, ['headers', 'query', 'env'], 'secret')
|
|
144
|
+
if (value.env !== undefined && Object.keys(strings(value.env, 'secret')).length)
|
|
145
|
+
throw badRequest('secret', 'transport_mismatch')
|
|
146
|
+
const headers = strings(value.headers, 'secret.headers')
|
|
147
|
+
const query = strings(value.query, 'secret.query')
|
|
148
|
+
for (const [name, entry] of Object.entries(headers)) {
|
|
149
|
+
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name) || /[^\t\x20-\x7e\x80-\xff]/.test(entry))
|
|
150
|
+
throw badRequest('headers', 'invalid')
|
|
151
|
+
}
|
|
152
|
+
return { headers, query }
|
|
153
|
+
}
|
|
154
|
+
function cached(value: unknown): McpCachedTool {
|
|
155
|
+
if (!object(value)) throw badRequest('tools', 'invalid')
|
|
156
|
+
fields(value, ['name', 'description', 'schema', 'readOnly', 'approval', 'fetchedAt'], 'tools')
|
|
157
|
+
if (
|
|
158
|
+
typeof value.description !== 'string' ||
|
|
159
|
+
!object(value.schema) ||
|
|
160
|
+
typeof value.fetchedAt !== 'number' ||
|
|
161
|
+
!Number.isSafeInteger(value.fetchedAt) ||
|
|
162
|
+
value.fetchedAt < 0
|
|
163
|
+
)
|
|
164
|
+
throw badRequest('tools', 'invalid')
|
|
165
|
+
if ([...value.description].length > 1024) throw badRequest('tools', 'too_large')
|
|
166
|
+
if (new TextEncoder().encode(JSON.stringify(value.schema)).length > 32768)
|
|
167
|
+
throw badRequest('tools', 'too_large')
|
|
168
|
+
return {
|
|
169
|
+
name: text(value.name, 'tool'),
|
|
170
|
+
description: value.description,
|
|
171
|
+
schema: value.schema,
|
|
172
|
+
readOnly: bool(value.readOnly, 'readOnly'),
|
|
173
|
+
approval: approval(value.approval),
|
|
174
|
+
fetchedAt: value.fetchedAt,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function toolList(value: unknown): McpCachedTool[] {
|
|
178
|
+
if (!Array.isArray(value) || value.length > 200) throw badRequest('tools', 'invalid')
|
|
179
|
+
const tools = value.map(cached)
|
|
180
|
+
if (new Set(tools.map((tool) => tool.name)).size !== tools.length)
|
|
181
|
+
throw badRequest('tools', 'duplicate')
|
|
182
|
+
return tools
|
|
183
|
+
}
|
|
184
|
+
function readState(value: unknown): State {
|
|
185
|
+
try {
|
|
186
|
+
if (
|
|
187
|
+
!object(value) ||
|
|
188
|
+
value.version !== 1 ||
|
|
189
|
+
!Array.isArray(value.servers) ||
|
|
190
|
+
!Array.isArray(value.deleted)
|
|
191
|
+
)
|
|
192
|
+
throw badRequest('mcp', 'invalid')
|
|
193
|
+
fields(value, ['version', 'servers', 'deleted'], 'mcp')
|
|
194
|
+
const deleted = value.deleted.map((id: unknown) => text(id, 'id'))
|
|
195
|
+
const servers = value.servers.map((entry: unknown): Server => {
|
|
196
|
+
if (!object(entry)) throw badRequest('mcp', 'invalid')
|
|
197
|
+
fields(
|
|
198
|
+
entry,
|
|
199
|
+
[
|
|
200
|
+
'id',
|
|
201
|
+
'slug',
|
|
202
|
+
'name',
|
|
203
|
+
'url',
|
|
204
|
+
'enabled',
|
|
205
|
+
'trusted',
|
|
206
|
+
'allowPrivate',
|
|
207
|
+
'secret',
|
|
208
|
+
'secretKeys',
|
|
209
|
+
'tools',
|
|
210
|
+
],
|
|
211
|
+
'mcp',
|
|
212
|
+
)
|
|
213
|
+
const slug = text(entry.slug, 'slug')
|
|
214
|
+
if (
|
|
215
|
+
!/^[a-z0-9_]{1,24}$/.test(slug) ||
|
|
216
|
+
!Array.isArray(entry.secretKeys) ||
|
|
217
|
+
!(entry.secret === null || isSealedCredential(entry.secret))
|
|
218
|
+
)
|
|
219
|
+
throw badRequest('mcp', 'invalid')
|
|
220
|
+
if (entry.secret !== null)
|
|
221
|
+
fields(entry.secret, ['version', 'keyId', 'nonce', 'ciphertext'], 'secret')
|
|
222
|
+
if (
|
|
223
|
+
new Set(entry.secretKeys).size !== entry.secretKeys.length ||
|
|
224
|
+
(entry.secret !== null && !entry.secretKeys.length)
|
|
225
|
+
)
|
|
226
|
+
throw badRequest('mcp', 'invalid')
|
|
227
|
+
const allowPrivate = bool(entry.allowPrivate, 'allowPrivate')
|
|
228
|
+
return {
|
|
229
|
+
id: text(entry.id, 'id'),
|
|
230
|
+
slug,
|
|
231
|
+
name: text(entry.name, 'name'),
|
|
232
|
+
url: serverUrl(entry.url, allowPrivate),
|
|
233
|
+
enabled: bool(entry.enabled, 'enabled'),
|
|
234
|
+
trusted: bool(entry.trusted, 'trusted'),
|
|
235
|
+
allowPrivate,
|
|
236
|
+
secret: entry.secret,
|
|
237
|
+
secretKeys: entry.secretKeys.map((key: unknown) => text(key, 'secretKeys')),
|
|
238
|
+
tools: toolList(entry.tools),
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
if (
|
|
242
|
+
new Set(servers.map((server) => server.id)).size !== servers.length ||
|
|
243
|
+
new Set(servers.map((server) => server.slug)).size !== servers.length ||
|
|
244
|
+
new Set(deleted).size !== deleted.length ||
|
|
245
|
+
servers.some(
|
|
246
|
+
(server) =>
|
|
247
|
+
deleted.includes(server.id) || (server.secret === null && server.secretKeys.length !== 0),
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
throw badRequest('mcp', 'invalid')
|
|
251
|
+
return { version: 1, servers, deleted }
|
|
252
|
+
} catch {
|
|
253
|
+
throw new KitFailure('internal', 'Invalid stored MCP configuration')
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function toolDto(tool: McpCachedTool): ToolDto {
|
|
257
|
+
return {
|
|
258
|
+
name: tool.name,
|
|
259
|
+
description: tool.description,
|
|
260
|
+
readOnly: tool.readOnly,
|
|
261
|
+
approval: tool.approval,
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function dto(server: Server): ServerDto {
|
|
265
|
+
return {
|
|
266
|
+
id: server.id,
|
|
267
|
+
slug: server.slug,
|
|
268
|
+
name: server.name,
|
|
269
|
+
transport: { kind: 'http', url: maskedUrl(server.url) },
|
|
270
|
+
enabled: server.enabled,
|
|
271
|
+
trusted: server.trusted,
|
|
272
|
+
allowPrivate: server.allowPrivate,
|
|
273
|
+
secretKeys: server.secretKeys,
|
|
274
|
+
tools: server.tools.map(toolDto),
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function missing(): KitFailure {
|
|
278
|
+
return new KitFailure('not_found', 'MCP server or tool not found')
|
|
279
|
+
}
|
|
280
|
+
async function body(request: Request): Promise<Record<string, unknown>> {
|
|
281
|
+
let value: unknown
|
|
282
|
+
try {
|
|
283
|
+
value = await request.json()
|
|
284
|
+
} catch {
|
|
285
|
+
throw badRequest('body', 'invalid_json')
|
|
286
|
+
}
|
|
287
|
+
if (!object(value)) throw badRequest('body', 'invalid')
|
|
288
|
+
return value
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export class McpClientSettings {
|
|
292
|
+
private readonly runtimes = new Map<string, McpRuntime>()
|
|
293
|
+
private readonly store: DocumentStore
|
|
294
|
+
private readonly credentials: CredentialPort
|
|
295
|
+
private readonly outbound: OutboundHttp
|
|
296
|
+
constructor(store: DocumentStore, credentials: CredentialPort, outbound: OutboundHttp) {
|
|
297
|
+
this.store = store
|
|
298
|
+
this.credentials = credentials
|
|
299
|
+
this.outbound = outbound
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private scope(actor: Actor, id: string): CredentialScope {
|
|
303
|
+
return {
|
|
304
|
+
applicationId: actor.applicationId,
|
|
305
|
+
tenantId: actor.tenantId,
|
|
306
|
+
subjectId: actor.subjectId,
|
|
307
|
+
purpose: 'mcp',
|
|
308
|
+
recordId: id,
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
private async snapshot(actor: Actor): Promise<Snapshot> {
|
|
312
|
+
const document = await this.store.get(actor, COLLECTION, KEY)
|
|
313
|
+
return document
|
|
314
|
+
? { revision: document.revision, state: readState(document.value) }
|
|
315
|
+
: { revision: null, state: { version: 1, servers: [], deleted: [] } }
|
|
316
|
+
}
|
|
317
|
+
private async commit(actor: Actor, snapshot: Snapshot): Promise<void> {
|
|
318
|
+
await this.store.compareAndSet(actor, COLLECTION, KEY, snapshot.revision, snapshot.state)
|
|
319
|
+
}
|
|
320
|
+
private async config(actor: Actor, server: Server): Promise<McpServerConfig> {
|
|
321
|
+
let secret: Secret = { headers: {}, query: {} }
|
|
322
|
+
if (server.secret) {
|
|
323
|
+
let bytes: Uint8Array | undefined
|
|
324
|
+
try {
|
|
325
|
+
bytes = await this.credentials.open(this.scope(actor, server.id), server.secret)
|
|
326
|
+
const value: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))
|
|
327
|
+
secret = parseSecret(value)
|
|
328
|
+
} catch {
|
|
329
|
+
throw new KitFailure('needs_config', 'MCP credentials are unreadable', {
|
|
330
|
+
reason: 'key_unreadable',
|
|
331
|
+
})
|
|
332
|
+
} finally {
|
|
333
|
+
bytes?.fill(0)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
id: server.id,
|
|
338
|
+
slug: server.slug,
|
|
339
|
+
name: server.name,
|
|
340
|
+
url: server.url,
|
|
341
|
+
enabled: server.enabled,
|
|
342
|
+
trusted: server.trusted,
|
|
343
|
+
allowPrivate: server.allowPrivate,
|
|
344
|
+
headers: secret.headers,
|
|
345
|
+
query: secret.query,
|
|
346
|
+
tools: server.tools,
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
runtime(actor: Actor): McpRuntime {
|
|
350
|
+
const key = ownerKey(actor)
|
|
351
|
+
const existing = this.runtimes.get(key)
|
|
352
|
+
if (existing) return existing
|
|
353
|
+
const owner: Actor = { ...actor, scopes: [...actor.scopes] }
|
|
354
|
+
const configs: McpConfigStore = {
|
|
355
|
+
list: async () => {
|
|
356
|
+
const { state } = await this.snapshot(owner)
|
|
357
|
+
return Promise.all(state.servers.map((server) => this.config(owner, server)))
|
|
358
|
+
},
|
|
359
|
+
get: async (id) => {
|
|
360
|
+
const { state } = await this.snapshot(owner)
|
|
361
|
+
const server = state.servers.find((entry) => entry.id === id)
|
|
362
|
+
return server ? this.config(owner, server) : null
|
|
363
|
+
},
|
|
364
|
+
replaceTools: async (id, tools) => {
|
|
365
|
+
const snapshot = await this.snapshot(owner)
|
|
366
|
+
const server = snapshot.state.servers.find((entry) => entry.id === id)
|
|
367
|
+
if (!server) throw missing()
|
|
368
|
+
const approvals = new Map(server.tools.map((tool) => [tool.name, tool.approval]))
|
|
369
|
+
server.tools = toolList(tools).map((tool) => ({
|
|
370
|
+
...tool,
|
|
371
|
+
approval: approvals.get(tool.name) ?? 'ask',
|
|
372
|
+
}))
|
|
373
|
+
await this.commit(owner, snapshot)
|
|
374
|
+
},
|
|
375
|
+
}
|
|
376
|
+
const runtime = new McpRuntime(this.outbound, configs)
|
|
377
|
+
this.runtimes.set(key, runtime)
|
|
378
|
+
return runtime
|
|
379
|
+
}
|
|
380
|
+
private async save(
|
|
381
|
+
actor: Actor,
|
|
382
|
+
input: Record<string, unknown>,
|
|
383
|
+
id?: string,
|
|
384
|
+
): Promise<ServerDto[]> {
|
|
385
|
+
fields(
|
|
386
|
+
input,
|
|
387
|
+
['id', 'slug', 'name', 'transport', 'secret', 'enabled', 'trusted', 'allowPrivate'],
|
|
388
|
+
'body',
|
|
389
|
+
)
|
|
390
|
+
if (input.id !== undefined && input.id !== id) throw badRequest('id', 'mismatch')
|
|
391
|
+
const snapshot = await this.snapshot(actor)
|
|
392
|
+
const existing =
|
|
393
|
+
id === undefined ? undefined : snapshot.state.servers.find((server) => server.id === id)
|
|
394
|
+
if (id !== undefined && !existing) throw missing()
|
|
395
|
+
const slug = text(input.slug, 'slug')
|
|
396
|
+
if (!/^[a-z0-9_]{1,24}$/.test(slug)) throw badRequest('slug', 'invalid')
|
|
397
|
+
if (snapshot.state.servers.some((server) => server.slug === slug && server.id !== id))
|
|
398
|
+
throw new KitFailure('conflict', 'MCP slug already exists', {
|
|
399
|
+
reason: 'duplicate_slug',
|
|
400
|
+
field: 'slug',
|
|
401
|
+
})
|
|
402
|
+
if (!object(input.transport)) throw badRequest('transport', 'invalid')
|
|
403
|
+
if (input.transport.kind === 'stdio')
|
|
404
|
+
throw new KitFailure('unsupported', 'Worker MCP clients support HTTP only')
|
|
405
|
+
fields(input.transport, ['kind', 'url'], 'transport')
|
|
406
|
+
if (input.transport.kind !== 'http') throw badRequest('transport', 'invalid')
|
|
407
|
+
const allowPrivate = bool(input.allowPrivate, 'allowPrivate', false)
|
|
408
|
+
const server: Server = {
|
|
409
|
+
id: existing?.id ?? crypto.randomUUID(),
|
|
410
|
+
slug,
|
|
411
|
+
name: text(input.name, 'name'),
|
|
412
|
+
url: serverUrl(input.transport.url, allowPrivate),
|
|
413
|
+
enabled: bool(input.enabled, 'enabled', false),
|
|
414
|
+
trusted: bool(input.trusted, 'trusted', false),
|
|
415
|
+
allowPrivate,
|
|
416
|
+
secret: existing?.secret ?? null,
|
|
417
|
+
secretKeys: existing?.secretKeys ?? [],
|
|
418
|
+
tools: existing?.tools ?? [],
|
|
419
|
+
}
|
|
420
|
+
if (input.secret !== undefined) {
|
|
421
|
+
const secret = parseSecret(input.secret)
|
|
422
|
+
server.secretKeys = [
|
|
423
|
+
...new Set([...Object.keys(secret.headers), ...Object.keys(secret.query)]),
|
|
424
|
+
].sort()
|
|
425
|
+
if (server.secretKeys.length) {
|
|
426
|
+
const bytes = new TextEncoder().encode(JSON.stringify(secret))
|
|
427
|
+
try {
|
|
428
|
+
server.secret = await this.credentials.seal(this.scope(actor, server.id), bytes)
|
|
429
|
+
} finally {
|
|
430
|
+
bytes.fill(0)
|
|
431
|
+
}
|
|
432
|
+
} else server.secret = null
|
|
433
|
+
}
|
|
434
|
+
if (existing) snapshot.state.servers[snapshot.state.servers.indexOf(existing)] = server
|
|
435
|
+
else snapshot.state.servers.push(server)
|
|
436
|
+
await this.commit(actor, snapshot)
|
|
437
|
+
return snapshot.state.servers.map(dto)
|
|
438
|
+
}
|
|
439
|
+
async handle(request: Request, context: WorkerContext): Promise<Response | null> {
|
|
440
|
+
const path = new URL(request.url).pathname
|
|
441
|
+
const match =
|
|
442
|
+
/^\/api\/v1\/mcp\/servers(?:\/([^/]+)(?:\/(refresh)|\/tools\/([^/]+)\/(approval))?)?$/.exec(
|
|
443
|
+
path,
|
|
444
|
+
)
|
|
445
|
+
if (!match) return null
|
|
446
|
+
let id: string | undefined
|
|
447
|
+
let tool: string | undefined
|
|
448
|
+
try {
|
|
449
|
+
id = match[1] === undefined ? undefined : decodeURIComponent(match[1])
|
|
450
|
+
tool = match[3] === undefined ? undefined : decodeURIComponent(match[3])
|
|
451
|
+
} catch {
|
|
452
|
+
throw badRequest('path', 'invalid')
|
|
453
|
+
}
|
|
454
|
+
const actor = context.actor
|
|
455
|
+
if (!id && request.method === 'GET')
|
|
456
|
+
return Response.json((await this.snapshot(actor)).state.servers.map(dto))
|
|
457
|
+
if (!id && request.method === 'POST')
|
|
458
|
+
return Response.json(await this.save(actor, await body(request)))
|
|
459
|
+
if (id && !match[2] && !match[4] && request.method === 'PUT')
|
|
460
|
+
return Response.json(await this.save(actor, await body(request), id))
|
|
461
|
+
if (id && !match[2] && !match[4] && request.method === 'DELETE') {
|
|
462
|
+
const snapshot = await this.snapshot(actor)
|
|
463
|
+
if (!snapshot.state.servers.some((server) => server.id === id)) throw missing()
|
|
464
|
+
snapshot.state.servers = snapshot.state.servers.filter((server) => server.id !== id)
|
|
465
|
+
snapshot.state.deleted.push(id)
|
|
466
|
+
await this.commit(actor, snapshot)
|
|
467
|
+
return Response.json(snapshot.state.servers.map(dto))
|
|
468
|
+
}
|
|
469
|
+
if (id && match[2] && request.method === 'POST') {
|
|
470
|
+
await this.runtime(actor).refresh(id, context.signal)
|
|
471
|
+
const server = (await this.snapshot(actor)).state.servers.find((entry) => entry.id === id)
|
|
472
|
+
if (!server) throw missing()
|
|
473
|
+
return Response.json(server.tools.map(toolDto))
|
|
474
|
+
}
|
|
475
|
+
if (id && tool && match[4] && request.method === 'PUT') {
|
|
476
|
+
const input = await body(request)
|
|
477
|
+
fields(input, ['approval'], 'body')
|
|
478
|
+
const value = approval(input.approval)
|
|
479
|
+
const snapshot = await this.snapshot(actor)
|
|
480
|
+
const server = snapshot.state.servers.find((entry) => entry.id === id)
|
|
481
|
+
const cached = server?.tools.find((entry) => entry.name === tool)
|
|
482
|
+
if (!server || !cached) throw missing()
|
|
483
|
+
server.tools = server.tools.map((entry) =>
|
|
484
|
+
entry === cached ? { ...entry, approval: value } : entry,
|
|
485
|
+
)
|
|
486
|
+
await this.commit(actor, snapshot)
|
|
487
|
+
return Response.json({ ok: true })
|
|
488
|
+
}
|
|
489
|
+
throw badRequest('method', 'unsupported')
|
|
490
|
+
}
|
|
491
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { KitFailure, badRequest } from '@ouraihub/aether-core/error'
|
|
2
|
+
import type { Actor } from '../context.js'
|
|
3
|
+
import type { DocumentStore, StoredDocument } from '../storage.js'
|
|
4
|
+
|
|
5
|
+
export function object(value: unknown): value is Record<string, unknown> {
|
|
6
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
7
|
+
}
|
|
8
|
+
export function strings(value: unknown): value is string[] {
|
|
9
|
+
return Array.isArray(value) && value.every((item: unknown) => typeof item === 'string')
|
|
10
|
+
}
|
|
11
|
+
export function text(value: unknown, field: string, max = 1024): string {
|
|
12
|
+
if (typeof value !== 'string' || !value.trim() || value.length > max)
|
|
13
|
+
throw badRequest(field, 'invalid')
|
|
14
|
+
return value
|
|
15
|
+
}
|
|
16
|
+
export function unique(values: readonly string[]): string[] {
|
|
17
|
+
return [...new Set(values)]
|
|
18
|
+
}
|
|
19
|
+
export function random(): string {
|
|
20
|
+
return base64url(crypto.getRandomValues(new Uint8Array(32)))
|
|
21
|
+
}
|
|
22
|
+
export function base64url(bytes: Uint8Array): string {
|
|
23
|
+
let value = ''
|
|
24
|
+
for (const byte of bytes) value += String.fromCharCode(byte)
|
|
25
|
+
return btoa(value).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '')
|
|
26
|
+
}
|
|
27
|
+
export async function hash(value: string): Promise<string> {
|
|
28
|
+
return base64url(
|
|
29
|
+
new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))),
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
export function equal(a: string, b: string): boolean {
|
|
33
|
+
let difference = a.length ^ b.length
|
|
34
|
+
for (let index = 0; index < Math.max(a.length, b.length); index++)
|
|
35
|
+
difference |= (a.charCodeAt(index) || 0) ^ (b.charCodeAt(index) || 0)
|
|
36
|
+
return difference === 0
|
|
37
|
+
}
|
|
38
|
+
export async function body(request: Request, max = 65536): Promise<string> {
|
|
39
|
+
if (Number(request.headers.get('Content-Length')) > max)
|
|
40
|
+
throw badRequest('body', 'too_large', max)
|
|
41
|
+
if (!request.body) return ''
|
|
42
|
+
const reader = request.body.getReader()
|
|
43
|
+
const chunks: Uint8Array[] = []
|
|
44
|
+
let size = 0
|
|
45
|
+
try {
|
|
46
|
+
while (true) {
|
|
47
|
+
const next = await reader.read()
|
|
48
|
+
if (next.done) break
|
|
49
|
+
size += next.value.byteLength
|
|
50
|
+
if (size > max) {
|
|
51
|
+
await reader.cancel()
|
|
52
|
+
throw badRequest('body', 'too_large', max)
|
|
53
|
+
}
|
|
54
|
+
chunks.push(next.value)
|
|
55
|
+
}
|
|
56
|
+
} finally {
|
|
57
|
+
reader.releaseLock()
|
|
58
|
+
}
|
|
59
|
+
const bytes = new Uint8Array(size)
|
|
60
|
+
let offset = 0
|
|
61
|
+
for (const chunk of chunks) {
|
|
62
|
+
bytes.set(chunk, offset)
|
|
63
|
+
offset += chunk.byteLength
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
|
67
|
+
} catch {
|
|
68
|
+
throw badRequest('body', 'invalid_utf8')
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export async function jsonBody(request: Request, max?: number): Promise<Record<string, unknown>> {
|
|
72
|
+
let value: unknown
|
|
73
|
+
try {
|
|
74
|
+
value = JSON.parse(await body(request, max))
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error instanceof KitFailure) throw error
|
|
77
|
+
throw badRequest('body', 'invalid_json')
|
|
78
|
+
}
|
|
79
|
+
if (!object(value)) throw badRequest('body', 'object_required')
|
|
80
|
+
return value
|
|
81
|
+
}
|
|
82
|
+
export function json(value: unknown, status = 200, extra?: HeadersInit): Response {
|
|
83
|
+
const headers = new Headers(extra)
|
|
84
|
+
headers.set('Content-Type', 'application/json')
|
|
85
|
+
headers.set('Cache-Control', 'no-store')
|
|
86
|
+
headers.set('X-Content-Type-Options', 'nosniff')
|
|
87
|
+
return new Response(JSON.stringify(value), { status, headers })
|
|
88
|
+
}
|
|
89
|
+
export function forbidden(): never {
|
|
90
|
+
throw new KitFailure('forbidden', 'Access denied')
|
|
91
|
+
}
|
|
92
|
+
export function notFound(): never {
|
|
93
|
+
throw new KitFailure('not_found', 'Record not found')
|
|
94
|
+
}
|
|
95
|
+
export function validActor(value: unknown, applicationId: string): value is Actor {
|
|
96
|
+
return (
|
|
97
|
+
object(value) &&
|
|
98
|
+
value.applicationId === applicationId &&
|
|
99
|
+
typeof value.subjectId === 'string' &&
|
|
100
|
+
value.subjectId.length > 0 &&
|
|
101
|
+
(value.kind === 'user' || value.kind === 'service' || value.kind === 'mcp_client') &&
|
|
102
|
+
(value.tenantId === null || typeof value.tenantId === 'string') &&
|
|
103
|
+
(value.sessionId === null || typeof value.sessionId === 'string') &&
|
|
104
|
+
strings(value.scopes)
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
export async function required(
|
|
108
|
+
store: DocumentStore,
|
|
109
|
+
actor: Actor,
|
|
110
|
+
collection: string,
|
|
111
|
+
key: string,
|
|
112
|
+
): Promise<StoredDocument> {
|
|
113
|
+
const document = await store.get(actor, collection, key)
|
|
114
|
+
if (!document) return notFound()
|
|
115
|
+
return document
|
|
116
|
+
}
|
|
117
|
+
export function escapeHtml(value: string): string {
|
|
118
|
+
return value
|
|
119
|
+
.replaceAll('&', '&')
|
|
120
|
+
.replaceAll('<', '<')
|
|
121
|
+
.replaceAll('>', '>')
|
|
122
|
+
.replaceAll('"', '"')
|
|
123
|
+
.replaceAll("'", ''')
|
|
124
|
+
}
|