@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/images/index.ts
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import type { ImagePart, ImageRef } from '@ouraihub/aether-contracts'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
generateImage,
|
|
4
|
+
ImageFailure,
|
|
5
|
+
type ImageConnection,
|
|
6
|
+
type ImageRequest,
|
|
7
|
+
type ImageGeneration,
|
|
8
|
+
type ImageLimits,
|
|
9
|
+
} from '@ouraihub/aether-core/images'
|
|
3
10
|
import type { OutboundHttp } from '@ouraihub/aether-core/ports'
|
|
4
11
|
import type { WorkerContext } from '../context.js'
|
|
5
12
|
|
|
6
|
-
export interface GeneratedImage {
|
|
7
|
-
readonly image: ImagePart
|
|
13
|
+
export interface GeneratedImage extends ImageGeneration {
|
|
8
14
|
readonly reference: ImageRef
|
|
9
15
|
readonly persisted: boolean
|
|
10
16
|
}
|
|
11
17
|
export interface ImageServiceOptions {
|
|
12
18
|
readonly outbound: OutboundHttp
|
|
19
|
+
readonly limits?: ImageLimits
|
|
13
20
|
/** Host stores kind=generated under context.actor and returns a controlled display URL. */
|
|
14
21
|
readonly persist?: (image: ImagePart, context: WorkerContext) => Promise<ImageRef>
|
|
15
22
|
}
|
|
@@ -21,26 +28,46 @@ export class ImageService {
|
|
|
21
28
|
this.options = options
|
|
22
29
|
}
|
|
23
30
|
|
|
24
|
-
async generate(
|
|
25
|
-
|
|
31
|
+
async generate(
|
|
32
|
+
connection: ImageConnection,
|
|
33
|
+
input: ImageRequest,
|
|
34
|
+
context: WorkerContext,
|
|
35
|
+
): Promise<GeneratedImage> {
|
|
36
|
+
const result = await generateImage(
|
|
37
|
+
this.options.outbound,
|
|
38
|
+
connection,
|
|
39
|
+
input,
|
|
40
|
+
context.signal,
|
|
41
|
+
this.options.limits,
|
|
42
|
+
)
|
|
43
|
+
const { image } = result
|
|
26
44
|
if (!this.options.persist) {
|
|
27
45
|
return {
|
|
28
|
-
|
|
46
|
+
...result,
|
|
29
47
|
reference: { mime: image.mime, url: `data:${image.mime};base64,${image.data}` },
|
|
30
48
|
persisted: false,
|
|
31
49
|
}
|
|
32
50
|
}
|
|
33
51
|
try {
|
|
34
52
|
if (context.signal.aborted)
|
|
35
|
-
throw new ImageFailure(
|
|
53
|
+
throw new ImageFailure(
|
|
54
|
+
'cancelled',
|
|
55
|
+
'Image generated; persistence was cancelled',
|
|
56
|
+
'generated',
|
|
57
|
+
)
|
|
36
58
|
const reference = await this.options.persist(image, context)
|
|
37
|
-
return {
|
|
59
|
+
return { ...result, reference, persisted: true }
|
|
38
60
|
} catch {
|
|
39
61
|
// Generation succeeded. Do not claim the provider did nothing or that saving succeeded.
|
|
40
|
-
throw new ImageFailure(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
62
|
+
throw new ImageFailure(
|
|
63
|
+
'internal',
|
|
64
|
+
'Image generated but persistence could not be confirmed',
|
|
65
|
+
'generated',
|
|
66
|
+
{
|
|
67
|
+
field: 'images',
|
|
68
|
+
reason: 'generated_save_failed',
|
|
69
|
+
},
|
|
70
|
+
)
|
|
44
71
|
}
|
|
45
72
|
}
|
|
46
73
|
}
|
package/src/logger.ts
CHANGED
|
@@ -58,18 +58,29 @@ export function createLogger(options: {
|
|
|
58
58
|
}): Logger {
|
|
59
59
|
const routes = new Set(options.routes)
|
|
60
60
|
for (const route of routes)
|
|
61
|
-
if (!/^\/[A-Za-z0-9_/:.{}-]*$/u.test(route) || route.length > 256)
|
|
61
|
+
if (!/^\/[A-Za-z0-9_/:.{}-]*$/u.test(route) || route.length > 256)
|
|
62
|
+
throw new Error('Invalid log route template')
|
|
62
63
|
return Object.freeze({
|
|
63
64
|
log(event: LogEvent, fields: Readonly<Record<string, unknown>>): void {
|
|
64
65
|
if (!Object.hasOwn(EVENTS, event)) throw new Error('Invalid log event')
|
|
65
66
|
const safe: Record<string, LogValue> = {}
|
|
66
67
|
for (const [key, value] of Object.entries(fields)) {
|
|
67
|
-
if (
|
|
68
|
+
if (
|
|
69
|
+
Object.hasOwn(IDENTIFIERS, key) &&
|
|
70
|
+
typeof value === 'string' &&
|
|
71
|
+
/^[A-Za-z0-9_.:-]{1,128}$/u.test(value)
|
|
72
|
+
)
|
|
68
73
|
safe[key] = value
|
|
69
|
-
else if (
|
|
74
|
+
else if (
|
|
75
|
+
Object.hasOwn(COUNTERS, key) &&
|
|
76
|
+
typeof value === 'number' &&
|
|
77
|
+
Number.isSafeInteger(value) &&
|
|
78
|
+
value >= 0
|
|
79
|
+
)
|
|
70
80
|
safe[key] = value
|
|
71
81
|
else if (key === 'usageAvailable' && typeof value === 'boolean') safe[key] = value
|
|
72
|
-
else if (key === 'route' && typeof value === 'string' && routes.has(value))
|
|
82
|
+
else if (key === 'route' && typeof value === 'string' && routes.has(value))
|
|
83
|
+
safe[key] = value
|
|
73
84
|
else if (
|
|
74
85
|
key === 'state' &&
|
|
75
86
|
(value === 'accepted' ||
|
package/src/mcp/auth.ts
CHANGED
|
@@ -2,7 +2,18 @@ import { KitFailure, badRequest } from '@ouraihub/aether-core/error'
|
|
|
2
2
|
import type { Actor } from '../context.js'
|
|
3
3
|
import type { StoredDocument } from '../storage.js'
|
|
4
4
|
import type { ApiKeyRecord, GrantRecord, McpServiceOptions, McpSettings } from './types.js'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
equal,
|
|
7
|
+
forbidden,
|
|
8
|
+
hash,
|
|
9
|
+
notFound,
|
|
10
|
+
object,
|
|
11
|
+
random,
|
|
12
|
+
strings,
|
|
13
|
+
text,
|
|
14
|
+
unique,
|
|
15
|
+
validActor,
|
|
16
|
+
} from './common.js'
|
|
6
17
|
|
|
7
18
|
interface KeyState {
|
|
8
19
|
record: ApiKeyRecord
|
|
@@ -106,11 +117,14 @@ export class McpAuth {
|
|
|
106
117
|
forbidden()
|
|
107
118
|
}
|
|
108
119
|
requested(value: unknown, actor?: Actor): string[] {
|
|
109
|
-
if (!strings(value) || value.length === 0 || value.length > 100)
|
|
120
|
+
if (!strings(value) || value.length === 0 || value.length > 100)
|
|
121
|
+
throw badRequest('scopes', 'invalid')
|
|
110
122
|
const scopes = unique(value)
|
|
111
123
|
if (
|
|
112
124
|
scopes.some(
|
|
113
|
-
(scope) =>
|
|
125
|
+
(scope) =>
|
|
126
|
+
!this.options.scopes.some((known) => known.id === scope) ||
|
|
127
|
+
(actor && !actor.scopes.includes(scope)),
|
|
114
128
|
)
|
|
115
129
|
)
|
|
116
130
|
forbidden()
|
|
@@ -119,17 +133,30 @@ export class McpAuth {
|
|
|
119
133
|
async settings(owner: Actor): Promise<McpSettings> {
|
|
120
134
|
const doc = await this.options.store.get(owner, 'mcp-settings', 'settings')
|
|
121
135
|
if (!doc) return { enabled: false, enabledScopes: [] }
|
|
122
|
-
if (
|
|
136
|
+
if (
|
|
137
|
+
!object(doc.value) ||
|
|
138
|
+
typeof doc.value.enabled !== 'boolean' ||
|
|
139
|
+
!strings(doc.value.enabledScopes)
|
|
140
|
+
)
|
|
123
141
|
throw new KitFailure('internal', 'Invalid MCP settings')
|
|
124
142
|
return { enabled: doc.value.enabled, enabledScopes: doc.value.enabledScopes }
|
|
125
143
|
}
|
|
126
144
|
async saveSettings(actor: Actor, value: Record<string, unknown>): Promise<McpSettings> {
|
|
127
145
|
this.actor(actor)
|
|
128
|
-
if (typeof value.enabled !== 'boolean' || !strings(value.enabledScopes))
|
|
129
|
-
|
|
146
|
+
if (typeof value.enabled !== 'boolean' || !strings(value.enabledScopes))
|
|
147
|
+
throw badRequest('settings', 'invalid')
|
|
148
|
+
const enabledScopes = value.enabledScopes.length
|
|
149
|
+
? this.requested(value.enabledScopes, actor)
|
|
150
|
+
: []
|
|
130
151
|
const current = await this.options.store.get(actor, 'mcp-settings', 'settings')
|
|
131
152
|
const settings = { enabled: value.enabled, enabledScopes }
|
|
132
|
-
await this.options.store.compareAndSet(
|
|
153
|
+
await this.options.store.compareAndSet(
|
|
154
|
+
actor,
|
|
155
|
+
'mcp-settings',
|
|
156
|
+
'settings',
|
|
157
|
+
current?.revision ?? null,
|
|
158
|
+
settings,
|
|
159
|
+
)
|
|
133
160
|
return settings
|
|
134
161
|
}
|
|
135
162
|
async keys(actor: Actor): Promise<ApiKeyRecord[]> {
|
|
@@ -144,7 +171,8 @@ export class McpAuth {
|
|
|
144
171
|
result.push(...page.map((doc) => readKey(doc).record))
|
|
145
172
|
if (page.length < 200) return result
|
|
146
173
|
const next = page[page.length - 1]?.key
|
|
147
|
-
if (!next || (after !== undefined && next <= after))
|
|
174
|
+
if (!next || (after !== undefined && next <= after))
|
|
175
|
+
throw new KitFailure('internal', 'Invalid key pagination')
|
|
148
176
|
after = next
|
|
149
177
|
}
|
|
150
178
|
}
|
|
@@ -160,7 +188,8 @@ export class McpAuth {
|
|
|
160
188
|
result.push(...page.map((doc) => readGrant(doc).record))
|
|
161
189
|
if (page.length < 200) return result
|
|
162
190
|
const next = page[page.length - 1]?.key
|
|
163
|
-
if (!next || (after !== undefined && next <= after))
|
|
191
|
+
if (!next || (after !== undefined && next <= after))
|
|
192
|
+
throw new KitFailure('internal', 'Invalid grant pagination')
|
|
164
193
|
after = next
|
|
165
194
|
}
|
|
166
195
|
}
|
|
@@ -175,7 +204,10 @@ export class McpAuth {
|
|
|
175
204
|
if (!(await this.options.authorizeConsent(actor, scopes, request))) forbidden()
|
|
176
205
|
const now = this.now()
|
|
177
206
|
const expiresAt = input.expiresAt === undefined ? now + 90 * 86400000 : input.expiresAt
|
|
178
|
-
if (
|
|
207
|
+
if (
|
|
208
|
+
expiresAt !== null &&
|
|
209
|
+
(typeof expiresAt !== 'number' || !Number.isFinite(expiresAt) || expiresAt <= now)
|
|
210
|
+
)
|
|
179
211
|
throw badRequest('expiresAt', 'invalid')
|
|
180
212
|
const id = crypto.randomUUID()
|
|
181
213
|
const token = `aether_key_${random()}`
|
|
@@ -249,7 +281,9 @@ export class McpAuth {
|
|
|
249
281
|
const settings = await this.settings(owner)
|
|
250
282
|
if (!settings.enabled) return null
|
|
251
283
|
const effective = scopes.filter(
|
|
252
|
-
(scope) =>
|
|
284
|
+
(scope) =>
|
|
285
|
+
settings.enabledScopes.includes(scope) &&
|
|
286
|
+
this.options.scopes.some((known) => known.id === scope),
|
|
253
287
|
)
|
|
254
288
|
return {
|
|
255
289
|
actor: { ...owner, kind: 'mcp_client', sessionId: null, scopes: effective },
|
|
@@ -302,7 +336,10 @@ export class McpAuth {
|
|
|
302
336
|
const document = await this.options.store.get(owner, 'mcp-grants', grantId)
|
|
303
337
|
if (!document) throw new KitFailure('unauthorized', 'Invalid grant')
|
|
304
338
|
const state = readGrant(document)
|
|
305
|
-
if (
|
|
339
|
+
if (
|
|
340
|
+
!this.activeGrant(state, owner) ||
|
|
341
|
+
(clientId !== undefined && state.record.clientId !== clientId)
|
|
342
|
+
)
|
|
306
343
|
throw new KitFailure('unauthorized', 'Invalid grant')
|
|
307
344
|
if (
|
|
308
345
|
refresh !== undefined &&
|
|
@@ -319,13 +356,24 @@ export class McpAuth {
|
|
|
319
356
|
generation: state.generation + 1,
|
|
320
357
|
}
|
|
321
358
|
await this.options.store.compareAndSet(owner, 'mcp-grants', grantId, document.revision, updated)
|
|
322
|
-
await this.index(await hash(accessToken), {
|
|
323
|
-
await this.options.store.compareAndSet(this.system, 'mcp-refresh-index', await hash(refreshToken), null, {
|
|
359
|
+
await this.index(await hash(accessToken), {
|
|
324
360
|
owner,
|
|
325
|
-
grantId,
|
|
326
|
-
|
|
327
|
-
|
|
361
|
+
id: grantId,
|
|
362
|
+
kind: 'access',
|
|
363
|
+
expiresAt: now + 3600000,
|
|
328
364
|
})
|
|
365
|
+
await this.options.store.compareAndSet(
|
|
366
|
+
this.system,
|
|
367
|
+
'mcp-refresh-index',
|
|
368
|
+
await hash(refreshToken),
|
|
369
|
+
null,
|
|
370
|
+
{
|
|
371
|
+
owner,
|
|
372
|
+
grantId,
|
|
373
|
+
clientId: state.record.clientId,
|
|
374
|
+
audience: `${this.options.origin}/mcp`,
|
|
375
|
+
},
|
|
376
|
+
)
|
|
329
377
|
return {
|
|
330
378
|
access_token: accessToken,
|
|
331
379
|
token_type: 'Bearer',
|
package/src/mcp/client.ts
CHANGED
|
@@ -10,7 +10,12 @@ import {
|
|
|
10
10
|
} from '@ouraihub/aether-core/mcp'
|
|
11
11
|
import type { OutboundHttp } from '@ouraihub/aether-core/ports'
|
|
12
12
|
import { ownerKey, type Actor, type WorkerContext } from '../context.js'
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
isSealedCredential,
|
|
15
|
+
type CredentialPort,
|
|
16
|
+
type CredentialScope,
|
|
17
|
+
type SealedCredential,
|
|
18
|
+
} from '../credentials.js'
|
|
14
19
|
import type { DocumentStore } from '../storage.js'
|
|
15
20
|
|
|
16
21
|
const COLLECTION = 'mcp-client'
|
|
@@ -70,7 +75,8 @@ function bool(value: unknown, field: string, fallback?: boolean): boolean {
|
|
|
70
75
|
return value
|
|
71
76
|
}
|
|
72
77
|
function approval(value: unknown): Approval {
|
|
73
|
-
if (value !== 'auto' && value !== 'ask' && value !== 'deny')
|
|
78
|
+
if (value !== 'auto' && value !== 'ask' && value !== 'deny')
|
|
79
|
+
throw badRequest('approval', 'invalid')
|
|
74
80
|
return value
|
|
75
81
|
}
|
|
76
82
|
function looksSecret(value: string): boolean {
|
|
@@ -95,7 +101,8 @@ function serverUrl(raw: unknown, allowPrivate: boolean): string {
|
|
|
95
101
|
throw badRequest('url', 'credential_query')
|
|
96
102
|
}
|
|
97
103
|
const host = url.hostname.replace(/^\[|\]$/g, '')
|
|
98
|
-
const loopback =
|
|
104
|
+
const loopback =
|
|
105
|
+
host === 'localhost' || host.endsWith('.localhost') || host === '::1' || /^127\./.test(host)
|
|
99
106
|
if (url.protocol === 'http:' && !allowPrivate) throw badRequest('url', 'plain_http')
|
|
100
107
|
// DNS and connection-time restrictions remain the injected outbound port's responsibility.
|
|
101
108
|
const restricted =
|
|
@@ -112,7 +119,8 @@ function maskedUrl(raw: string): string {
|
|
|
112
119
|
url.username = ''
|
|
113
120
|
url.password = ''
|
|
114
121
|
for (const [name, value] of url.searchParams)
|
|
115
|
-
if (Object.hasOwn(SECRET_PARAMS, name.toLowerCase()) || looksSecret(value))
|
|
122
|
+
if (Object.hasOwn(SECRET_PARAMS, name.toLowerCase()) || looksSecret(value))
|
|
123
|
+
url.searchParams.set(name, '***')
|
|
116
124
|
return url.href
|
|
117
125
|
}
|
|
118
126
|
function strings(value: unknown, field: string): Record<string, string> {
|
|
@@ -121,7 +129,12 @@ function strings(value: unknown, field: string): Record<string, string> {
|
|
|
121
129
|
const result: Record<string, string> = {}
|
|
122
130
|
for (const [key, entry] of Object.entries(value)) {
|
|
123
131
|
if (!key || typeof entry !== 'string') throw badRequest(field, 'invalid')
|
|
124
|
-
Object.defineProperty(result, key, {
|
|
132
|
+
Object.defineProperty(result, key, {
|
|
133
|
+
value: entry,
|
|
134
|
+
enumerable: true,
|
|
135
|
+
writable: true,
|
|
136
|
+
configurable: true,
|
|
137
|
+
})
|
|
125
138
|
}
|
|
126
139
|
return result
|
|
127
140
|
}
|
|
@@ -150,7 +163,8 @@ function cached(value: unknown): McpCachedTool {
|
|
|
150
163
|
)
|
|
151
164
|
throw badRequest('tools', 'invalid')
|
|
152
165
|
if ([...value.description].length > 1024) throw badRequest('tools', 'too_large')
|
|
153
|
-
if (new TextEncoder().encode(JSON.stringify(value.schema)).length > 32768)
|
|
166
|
+
if (new TextEncoder().encode(JSON.stringify(value.schema)).length > 32768)
|
|
167
|
+
throw badRequest('tools', 'too_large')
|
|
154
168
|
return {
|
|
155
169
|
name: text(value.name, 'tool'),
|
|
156
170
|
description: value.description,
|
|
@@ -163,12 +177,18 @@ function cached(value: unknown): McpCachedTool {
|
|
|
163
177
|
function toolList(value: unknown): McpCachedTool[] {
|
|
164
178
|
if (!Array.isArray(value) || value.length > 200) throw badRequest('tools', 'invalid')
|
|
165
179
|
const tools = value.map(cached)
|
|
166
|
-
if (new Set(tools.map((tool) => tool.name)).size !== tools.length)
|
|
180
|
+
if (new Set(tools.map((tool) => tool.name)).size !== tools.length)
|
|
181
|
+
throw badRequest('tools', 'duplicate')
|
|
167
182
|
return tools
|
|
168
183
|
}
|
|
169
184
|
function readState(value: unknown): State {
|
|
170
185
|
try {
|
|
171
|
-
if (
|
|
186
|
+
if (
|
|
187
|
+
!object(value) ||
|
|
188
|
+
value.version !== 1 ||
|
|
189
|
+
!Array.isArray(value.servers) ||
|
|
190
|
+
!Array.isArray(value.deleted)
|
|
191
|
+
)
|
|
172
192
|
throw badRequest('mcp', 'invalid')
|
|
173
193
|
fields(value, ['version', 'servers', 'deleted'], 'mcp')
|
|
174
194
|
const deleted = value.deleted.map((id: unknown) => text(id, 'id'))
|
|
@@ -176,7 +196,18 @@ function readState(value: unknown): State {
|
|
|
176
196
|
if (!object(entry)) throw badRequest('mcp', 'invalid')
|
|
177
197
|
fields(
|
|
178
198
|
entry,
|
|
179
|
-
[
|
|
199
|
+
[
|
|
200
|
+
'id',
|
|
201
|
+
'slug',
|
|
202
|
+
'name',
|
|
203
|
+
'url',
|
|
204
|
+
'enabled',
|
|
205
|
+
'trusted',
|
|
206
|
+
'allowPrivate',
|
|
207
|
+
'secret',
|
|
208
|
+
'secretKeys',
|
|
209
|
+
'tools',
|
|
210
|
+
],
|
|
180
211
|
'mcp',
|
|
181
212
|
)
|
|
182
213
|
const slug = text(entry.slug, 'slug')
|
|
@@ -186,7 +217,8 @@ function readState(value: unknown): State {
|
|
|
186
217
|
!(entry.secret === null || isSealedCredential(entry.secret))
|
|
187
218
|
)
|
|
188
219
|
throw badRequest('mcp', 'invalid')
|
|
189
|
-
if (entry.secret !== null)
|
|
220
|
+
if (entry.secret !== null)
|
|
221
|
+
fields(entry.secret, ['version', 'keyId', 'nonce', 'ciphertext'], 'secret')
|
|
190
222
|
if (
|
|
191
223
|
new Set(entry.secretKeys).size !== entry.secretKeys.length ||
|
|
192
224
|
(entry.secret !== null && !entry.secretKeys.length)
|
|
@@ -211,7 +243,8 @@ function readState(value: unknown): State {
|
|
|
211
243
|
new Set(servers.map((server) => server.slug)).size !== servers.length ||
|
|
212
244
|
new Set(deleted).size !== deleted.length ||
|
|
213
245
|
servers.some(
|
|
214
|
-
(server) =>
|
|
246
|
+
(server) =>
|
|
247
|
+
deleted.includes(server.id) || (server.secret === null && server.secretKeys.length !== 0),
|
|
215
248
|
)
|
|
216
249
|
)
|
|
217
250
|
throw badRequest('mcp', 'invalid')
|
|
@@ -221,7 +254,12 @@ function readState(value: unknown): State {
|
|
|
221
254
|
}
|
|
222
255
|
}
|
|
223
256
|
function toolDto(tool: McpCachedTool): ToolDto {
|
|
224
|
-
return {
|
|
257
|
+
return {
|
|
258
|
+
name: tool.name,
|
|
259
|
+
description: tool.description,
|
|
260
|
+
readOnly: tool.readOnly,
|
|
261
|
+
approval: tool.approval,
|
|
262
|
+
}
|
|
225
263
|
}
|
|
226
264
|
function dto(server: Server): ServerDto {
|
|
227
265
|
return {
|
|
@@ -288,7 +326,9 @@ export class McpClientSettings {
|
|
|
288
326
|
const value: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))
|
|
289
327
|
secret = parseSecret(value)
|
|
290
328
|
} catch {
|
|
291
|
-
throw new KitFailure('needs_config', 'MCP credentials are unreadable', {
|
|
329
|
+
throw new KitFailure('needs_config', 'MCP credentials are unreadable', {
|
|
330
|
+
reason: 'key_unreadable',
|
|
331
|
+
})
|
|
292
332
|
} finally {
|
|
293
333
|
bytes?.fill(0)
|
|
294
334
|
}
|
|
@@ -326,7 +366,10 @@ export class McpClientSettings {
|
|
|
326
366
|
const server = snapshot.state.servers.find((entry) => entry.id === id)
|
|
327
367
|
if (!server) throw missing()
|
|
328
368
|
const approvals = new Map(server.tools.map((tool) => [tool.name, tool.approval]))
|
|
329
|
-
server.tools = toolList(tools).map((tool) => ({
|
|
369
|
+
server.tools = toolList(tools).map((tool) => ({
|
|
370
|
+
...tool,
|
|
371
|
+
approval: approvals.get(tool.name) ?? 'ask',
|
|
372
|
+
}))
|
|
330
373
|
await this.commit(owner, snapshot)
|
|
331
374
|
},
|
|
332
375
|
}
|
|
@@ -334,18 +377,31 @@ export class McpClientSettings {
|
|
|
334
377
|
this.runtimes.set(key, runtime)
|
|
335
378
|
return runtime
|
|
336
379
|
}
|
|
337
|
-
private async save(
|
|
338
|
-
|
|
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
|
+
)
|
|
339
390
|
if (input.id !== undefined && input.id !== id) throw badRequest('id', 'mismatch')
|
|
340
391
|
const snapshot = await this.snapshot(actor)
|
|
341
|
-
const existing =
|
|
392
|
+
const existing =
|
|
393
|
+
id === undefined ? undefined : snapshot.state.servers.find((server) => server.id === id)
|
|
342
394
|
if (id !== undefined && !existing) throw missing()
|
|
343
395
|
const slug = text(input.slug, 'slug')
|
|
344
396
|
if (!/^[a-z0-9_]{1,24}$/.test(slug)) throw badRequest('slug', 'invalid')
|
|
345
397
|
if (snapshot.state.servers.some((server) => server.slug === slug && server.id !== id))
|
|
346
|
-
throw new KitFailure('conflict', 'MCP slug already exists', {
|
|
398
|
+
throw new KitFailure('conflict', 'MCP slug already exists', {
|
|
399
|
+
reason: 'duplicate_slug',
|
|
400
|
+
field: 'slug',
|
|
401
|
+
})
|
|
347
402
|
if (!object(input.transport)) throw badRequest('transport', 'invalid')
|
|
348
|
-
if (input.transport.kind === 'stdio')
|
|
403
|
+
if (input.transport.kind === 'stdio')
|
|
404
|
+
throw new KitFailure('unsupported', 'Worker MCP clients support HTTP only')
|
|
349
405
|
fields(input.transport, ['kind', 'url'], 'transport')
|
|
350
406
|
if (input.transport.kind !== 'http') throw badRequest('transport', 'invalid')
|
|
351
407
|
const allowPrivate = bool(input.allowPrivate, 'allowPrivate', false)
|
|
@@ -363,7 +419,9 @@ export class McpClientSettings {
|
|
|
363
419
|
}
|
|
364
420
|
if (input.secret !== undefined) {
|
|
365
421
|
const secret = parseSecret(input.secret)
|
|
366
|
-
server.secretKeys = [
|
|
422
|
+
server.secretKeys = [
|
|
423
|
+
...new Set([...Object.keys(secret.headers), ...Object.keys(secret.query)]),
|
|
424
|
+
].sort()
|
|
367
425
|
if (server.secretKeys.length) {
|
|
368
426
|
const bytes = new TextEncoder().encode(JSON.stringify(secret))
|
|
369
427
|
try {
|
|
@@ -380,7 +438,10 @@ export class McpClientSettings {
|
|
|
380
438
|
}
|
|
381
439
|
async handle(request: Request, context: WorkerContext): Promise<Response | null> {
|
|
382
440
|
const path = new URL(request.url).pathname
|
|
383
|
-
const match =
|
|
441
|
+
const match =
|
|
442
|
+
/^\/api\/v1\/mcp\/servers(?:\/([^/]+)(?:\/(refresh)|\/tools\/([^/]+)\/(approval))?)?$/.exec(
|
|
443
|
+
path,
|
|
444
|
+
)
|
|
384
445
|
if (!match) return null
|
|
385
446
|
let id: string | undefined
|
|
386
447
|
let tool: string | undefined
|
|
@@ -391,8 +452,10 @@ export class McpClientSettings {
|
|
|
391
452
|
throw badRequest('path', 'invalid')
|
|
392
453
|
}
|
|
393
454
|
const actor = context.actor
|
|
394
|
-
if (!id && request.method === 'GET')
|
|
395
|
-
|
|
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)))
|
|
396
459
|
if (id && !match[2] && !match[4] && request.method === 'PUT')
|
|
397
460
|
return Response.json(await this.save(actor, await body(request), id))
|
|
398
461
|
if (id && !match[2] && !match[4] && request.method === 'DELETE') {
|
|
@@ -417,7 +480,9 @@ export class McpClientSettings {
|
|
|
417
480
|
const server = snapshot.state.servers.find((entry) => entry.id === id)
|
|
418
481
|
const cached = server?.tools.find((entry) => entry.name === tool)
|
|
419
482
|
if (!server || !cached) throw missing()
|
|
420
|
-
server.tools = server.tools.map((entry) =>
|
|
483
|
+
server.tools = server.tools.map((entry) =>
|
|
484
|
+
entry === cached ? { ...entry, approval: value } : entry,
|
|
485
|
+
)
|
|
421
486
|
await this.commit(actor, snapshot)
|
|
422
487
|
return Response.json({ ok: true })
|
|
423
488
|
}
|
package/src/mcp/common.ts
CHANGED
|
@@ -9,7 +9,8 @@ export function strings(value: unknown): value is string[] {
|
|
|
9
9
|
return Array.isArray(value) && value.every((item: unknown) => typeof item === 'string')
|
|
10
10
|
}
|
|
11
11
|
export function text(value: unknown, field: string, max = 1024): string {
|
|
12
|
-
if (typeof value !== 'string' || !value.trim() || value.length > max)
|
|
12
|
+
if (typeof value !== 'string' || !value.trim() || value.length > max)
|
|
13
|
+
throw badRequest(field, 'invalid')
|
|
13
14
|
return value
|
|
14
15
|
}
|
|
15
16
|
export function unique(values: readonly string[]): string[] {
|
|
@@ -24,7 +25,9 @@ export function base64url(bytes: Uint8Array): string {
|
|
|
24
25
|
return btoa(value).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '')
|
|
25
26
|
}
|
|
26
27
|
export async function hash(value: string): Promise<string> {
|
|
27
|
-
return base64url(
|
|
28
|
+
return base64url(
|
|
29
|
+
new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))),
|
|
30
|
+
)
|
|
28
31
|
}
|
|
29
32
|
export function equal(a: string, b: string): boolean {
|
|
30
33
|
let difference = a.length ^ b.length
|
|
@@ -33,7 +36,8 @@ export function equal(a: string, b: string): boolean {
|
|
|
33
36
|
return difference === 0
|
|
34
37
|
}
|
|
35
38
|
export async function body(request: Request, max = 65536): Promise<string> {
|
|
36
|
-
if (Number(request.headers.get('Content-Length')) > max)
|
|
39
|
+
if (Number(request.headers.get('Content-Length')) > max)
|
|
40
|
+
throw badRequest('body', 'too_large', max)
|
|
37
41
|
if (!request.body) return ''
|
|
38
42
|
const reader = request.body.getReader()
|
|
39
43
|
const chunks: Uint8Array[] = []
|