@ouraihub/aether-core 0.1.0 → 0.1.2
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 +2 -2
- package/src/engine/index.ts +95 -129
- package/src/engine/machine.ts +322 -0
- package/src/engine/model.ts +28 -16
- package/src/engine/prepare.ts +120 -34
- package/src/engine/tools.ts +59 -142
- package/src/error.ts +7 -2
- package/src/guard.ts +3 -1
- package/src/history.ts +9 -2
- package/src/images/index.ts +212 -39
- package/src/images/validation.ts +276 -0
- package/src/mcp/index.ts +83 -245
- package/src/mcp/protocol.ts +207 -0
- package/src/mcp/types.ts +49 -0
- package/src/pricing.ts +10 -2
- package/src/protocol/decoder.ts +39 -14
- package/src/protocol/request.ts +52 -17
- package/src/providers/connection.ts +16 -5
- package/src/providers/infer.ts +11 -6
- package/src/providers/probe.ts +20 -6
- package/src/risk.ts +13 -3
- package/src/schema.ts +34 -8
- package/src/search/execution.ts +21 -6
- package/src/search/index.ts +11 -2
- package/src/search/policy.ts +43 -10
- package/src/search/vendor.ts +42 -9
- package/src/skills/content.ts +13 -3
- package/src/skills/index.ts +6 -1
- package/src/skills/package.ts +29 -7
- package/src/skills/service.ts +54 -13
- package/src/skills/validation.ts +38 -9
package/src/mcp/index.ts
CHANGED
|
@@ -1,242 +1,40 @@
|
|
|
1
1
|
import { KitFailure, badRequest, cancelled } from '../error.js'
|
|
2
2
|
import { isRecord as record } from '../guard.js'
|
|
3
3
|
import type { OutboundHttp } from '../ports.js'
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
abortFailure,
|
|
6
|
+
abortable,
|
|
7
|
+
budget,
|
|
8
|
+
encoder,
|
|
9
|
+
invalid,
|
|
10
|
+
MAX_MESSAGE_BYTES,
|
|
11
|
+
parseMcpCallResult,
|
|
12
|
+
normalize,
|
|
13
|
+
readReply,
|
|
14
|
+
reason,
|
|
15
|
+
RequestFailure,
|
|
16
|
+
RpcFailure,
|
|
17
|
+
} from './protocol.js'
|
|
18
|
+
import type {
|
|
19
|
+
McpCachedTool,
|
|
20
|
+
McpCallResult,
|
|
21
|
+
McpConfigStore,
|
|
22
|
+
McpServerConfig,
|
|
23
|
+
McpTool,
|
|
24
|
+
} from './types.js'
|
|
6
25
|
|
|
7
|
-
export type
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
export type McpCallResult =
|
|
18
|
-
| {
|
|
19
|
-
readonly kind: 'completed'
|
|
20
|
-
readonly content: readonly Record<string, unknown>[]
|
|
21
|
-
readonly structuredContent?: Record<string, unknown>
|
|
22
|
-
readonly isError: boolean
|
|
23
|
-
}
|
|
24
|
-
| { readonly kind: 'failed'; readonly code: string; readonly message: string }
|
|
25
|
-
| { readonly kind: 'unknown'; readonly reason: string }
|
|
26
|
-
export interface McpCachedTool {
|
|
27
|
-
readonly name: string
|
|
28
|
-
readonly description: string
|
|
29
|
-
readonly schema: Readonly<Record<string, unknown>>
|
|
30
|
-
readonly readOnly: boolean
|
|
31
|
-
readonly approval: Approval
|
|
32
|
-
readonly fetchedAt: number
|
|
33
|
-
}
|
|
34
|
-
export interface McpServerConfig {
|
|
35
|
-
readonly id: string
|
|
36
|
-
readonly slug: string
|
|
37
|
-
readonly name: string
|
|
38
|
-
readonly url: string
|
|
39
|
-
readonly enabled: boolean
|
|
40
|
-
readonly trusted: boolean
|
|
41
|
-
readonly allowPrivate: boolean
|
|
42
|
-
readonly headers: Readonly<Record<string, string>>
|
|
43
|
-
readonly query: Readonly<Record<string, string>>
|
|
44
|
-
readonly tools: readonly McpCachedTool[]
|
|
45
|
-
}
|
|
46
|
-
export interface McpConfigStore {
|
|
47
|
-
list(): Promise<readonly McpServerConfig[]>
|
|
48
|
-
get(id: string): Promise<McpServerConfig | null>
|
|
49
|
-
replaceTools(id: string, tools: readonly McpCachedTool[]): Promise<void>
|
|
50
|
-
}
|
|
26
|
+
export type {
|
|
27
|
+
Approval,
|
|
28
|
+
McpCachedTool,
|
|
29
|
+
McpCallResult,
|
|
30
|
+
McpConfigStore,
|
|
31
|
+
McpServerConfig,
|
|
32
|
+
McpTool,
|
|
33
|
+
} from './types.js'
|
|
34
|
+
export { MAX_MESSAGE_BYTES, parseMcpCallResult } from './protocol.js'
|
|
51
35
|
|
|
52
|
-
export const MAX_MESSAGE_BYTES = 2 * 1024 * 1024
|
|
53
36
|
const MAX_SCHEMA_BYTES = 32 * 1024
|
|
54
37
|
const CACHE_AGE_MS = 10 * 60 * 1000
|
|
55
|
-
const encoder = new TextEncoder()
|
|
56
|
-
const invalid = (): KitFailure => new KitFailure('upstream', 'Invalid MCP response', { reason: 'invalid_response' })
|
|
57
|
-
const budget = (): KitFailure => new KitFailure('upstream', 'MCP message limit exceeded', { reason: 'budget_exceeded' })
|
|
58
|
-
const abortFailure = (signal: AbortSignal): KitFailure =>
|
|
59
|
-
signal.reason instanceof KitFailure ? signal.reason : cancelled()
|
|
60
|
-
|
|
61
|
-
function abortable<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
62
|
-
const { promise, resolve, reject } = Promise.withResolvers<T>()
|
|
63
|
-
const abort = () => {
|
|
64
|
-
signal.removeEventListener('abort', abort)
|
|
65
|
-
reject(abortFailure(signal))
|
|
66
|
-
}
|
|
67
|
-
if (signal.aborted) reject(abortFailure(signal))
|
|
68
|
-
else signal.addEventListener('abort', abort, { once: true })
|
|
69
|
-
operation.then(
|
|
70
|
-
(value) => {
|
|
71
|
-
signal.removeEventListener('abort', abort)
|
|
72
|
-
resolve(value)
|
|
73
|
-
},
|
|
74
|
-
(error) => {
|
|
75
|
-
signal.removeEventListener('abort', abort)
|
|
76
|
-
reject(error)
|
|
77
|
-
},
|
|
78
|
-
)
|
|
79
|
-
return promise
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
class RequestFailure extends Error {
|
|
83
|
-
readonly failure: KitFailure
|
|
84
|
-
readonly dispatched: boolean
|
|
85
|
-
readonly reason: string
|
|
86
|
-
readonly expired: boolean
|
|
87
|
-
constructor(failure: KitFailure, dispatched: boolean, reason: string, expired = false) {
|
|
88
|
-
super(failure.message)
|
|
89
|
-
this.failure = failure
|
|
90
|
-
this.dispatched = dispatched
|
|
91
|
-
this.reason = reason
|
|
92
|
-
this.expired = expired
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
class RpcFailure extends KitFailure {
|
|
96
|
-
readonly rpcCode: number
|
|
97
|
-
constructor(rpcCode: number) {
|
|
98
|
-
super('upstream', 'MCP server rejected the request', { reason: 'rpc_error' })
|
|
99
|
-
this.rpcCode = rpcCode
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
function normalize(error: unknown, signal: AbortSignal): KitFailure {
|
|
103
|
-
if (signal.aborted) return abortFailure(signal)
|
|
104
|
-
if (error instanceof RequestFailure) return error.failure
|
|
105
|
-
return error instanceof KitFailure ? error : new KitFailure('network', 'MCP connection failed')
|
|
106
|
-
}
|
|
107
|
-
function reason(error: KitFailure): string {
|
|
108
|
-
const detail = error.details?.reason
|
|
109
|
-
return typeof detail === 'string' && ['invalid_response', 'budget_exceeded'].includes(detail)
|
|
110
|
-
? detail
|
|
111
|
-
: error.code === 'cancelled'
|
|
112
|
-
? 'cancelled'
|
|
113
|
-
: error.code === 'timeout'
|
|
114
|
-
? 'timeout'
|
|
115
|
-
: 'network'
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function envelope(value: unknown, id: number): Record<string, unknown> {
|
|
119
|
-
if (!record(value) || value.jsonrpc !== '2.0' || value.id !== id || 'result' in value === 'error' in value)
|
|
120
|
-
throw invalid()
|
|
121
|
-
if ('error' in value) {
|
|
122
|
-
if (
|
|
123
|
-
!record(value.error) ||
|
|
124
|
-
!Number.isInteger(value.error.code) ||
|
|
125
|
-
typeof value.error.code !== 'number' ||
|
|
126
|
-
typeof value.error.message !== 'string'
|
|
127
|
-
)
|
|
128
|
-
throw invalid()
|
|
129
|
-
throw new RpcFailure(value.error.code)
|
|
130
|
-
}
|
|
131
|
-
if (!record(value.result)) throw invalid()
|
|
132
|
-
return value.result
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** Validates without flattening nontext content or interpreting extension fields. */
|
|
136
|
-
export function parseMcpCallResult(value: unknown): McpCallResult {
|
|
137
|
-
if (
|
|
138
|
-
!record(value) ||
|
|
139
|
-
!Array.isArray(value.content) ||
|
|
140
|
-
('isError' in value && typeof value.isError !== 'boolean') ||
|
|
141
|
-
('structuredContent' in value && !record(value.structuredContent))
|
|
142
|
-
)
|
|
143
|
-
return { kind: 'unknown', reason: 'invalid_response' }
|
|
144
|
-
const content: Record<string, unknown>[] = []
|
|
145
|
-
for (const item of value.content) {
|
|
146
|
-
if (
|
|
147
|
-
!record(item) ||
|
|
148
|
-
typeof item.type !== 'string' ||
|
|
149
|
-
!item.type.trim() ||
|
|
150
|
-
(item.type === 'text' && typeof item.text !== 'string')
|
|
151
|
-
)
|
|
152
|
-
return { kind: 'unknown', reason: 'invalid_response' }
|
|
153
|
-
content.push(item)
|
|
154
|
-
}
|
|
155
|
-
return {
|
|
156
|
-
kind: 'completed',
|
|
157
|
-
content,
|
|
158
|
-
isError: value.isError === true,
|
|
159
|
-
...(record(value.structuredContent) ? { structuredContent: value.structuredContent } : {}),
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
async function readReply(response: Response, id: number, signal: AbortSignal): Promise<Record<string, unknown>> {
|
|
164
|
-
const sse = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase() === 'text/event-stream'
|
|
165
|
-
const contentType = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase()
|
|
166
|
-
if (!sse && contentType !== 'application/json') throw invalid()
|
|
167
|
-
if (!response.body) throw invalid()
|
|
168
|
-
const reader = response.body.getReader()
|
|
169
|
-
const parser = sse ? new SseParser(MAX_MESSAGE_BYTES, MAX_MESSAGE_BYTES) : null
|
|
170
|
-
const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false })
|
|
171
|
-
let text = ''
|
|
172
|
-
let bytes = 0
|
|
173
|
-
const eventsReply = (events: readonly { data: string }[]): Record<string, unknown> | null => {
|
|
174
|
-
for (const event of events) {
|
|
175
|
-
if (encoder.encode(event.data).byteLength > MAX_MESSAGE_BYTES) throw budget()
|
|
176
|
-
let value: unknown
|
|
177
|
-
try {
|
|
178
|
-
value = JSON.parse(event.data)
|
|
179
|
-
} catch {
|
|
180
|
-
throw invalid()
|
|
181
|
-
}
|
|
182
|
-
if (!record(value) || value.id !== id) continue
|
|
183
|
-
return envelope(value, id)
|
|
184
|
-
}
|
|
185
|
-
return null
|
|
186
|
-
}
|
|
187
|
-
try {
|
|
188
|
-
for (;;) {
|
|
189
|
-
const chunk = await abortable(reader.read(), signal)
|
|
190
|
-
if (chunk.done) break
|
|
191
|
-
if (parser) {
|
|
192
|
-
let events: SseEvent[]
|
|
193
|
-
try {
|
|
194
|
-
events = parser.feed(chunk.value)
|
|
195
|
-
} catch (error) {
|
|
196
|
-
if (error instanceof KitFailure && error.details?.reason === 'sse_limit') throw budget()
|
|
197
|
-
throw invalid()
|
|
198
|
-
}
|
|
199
|
-
const result = eventsReply(events)
|
|
200
|
-
if (result) return result
|
|
201
|
-
} else {
|
|
202
|
-
bytes += chunk.value.byteLength
|
|
203
|
-
if (bytes > MAX_MESSAGE_BYTES) throw budget()
|
|
204
|
-
try {
|
|
205
|
-
text += decoder.decode(chunk.value, { stream: true })
|
|
206
|
-
} catch {
|
|
207
|
-
throw invalid()
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
if (parser) {
|
|
212
|
-
let events: SseEvent[]
|
|
213
|
-
try {
|
|
214
|
-
events = parser.finish()
|
|
215
|
-
} catch (error) {
|
|
216
|
-
if (error instanceof KitFailure && error.details?.reason === 'sse_limit') throw budget()
|
|
217
|
-
throw invalid()
|
|
218
|
-
}
|
|
219
|
-
const result = eventsReply(events)
|
|
220
|
-
if (result) return result
|
|
221
|
-
throw invalid()
|
|
222
|
-
}
|
|
223
|
-
try {
|
|
224
|
-
text += decoder.decode()
|
|
225
|
-
} catch {
|
|
226
|
-
throw invalid()
|
|
227
|
-
}
|
|
228
|
-
let value: unknown
|
|
229
|
-
try {
|
|
230
|
-
value = JSON.parse(text)
|
|
231
|
-
} catch {
|
|
232
|
-
throw invalid()
|
|
233
|
-
}
|
|
234
|
-
return envelope(value, id)
|
|
235
|
-
} finally {
|
|
236
|
-
void reader.cancel().catch(() => {})
|
|
237
|
-
reader.releaseLock()
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
38
|
|
|
241
39
|
class HttpConnection {
|
|
242
40
|
private sessionId: string | null = null
|
|
@@ -270,7 +68,10 @@ class HttpConnection {
|
|
|
270
68
|
|
|
271
69
|
private async handshake(signal: AbortSignal): Promise<void> {
|
|
272
70
|
const controller = new AbortController()
|
|
273
|
-
const timer = setTimeout(
|
|
71
|
+
const timer = setTimeout(
|
|
72
|
+
() => controller.abort(new KitFailure('timeout', 'MCP initialization timed out')),
|
|
73
|
+
10_000,
|
|
74
|
+
)
|
|
274
75
|
const combined = AbortSignal.any([signal, controller.signal])
|
|
275
76
|
try {
|
|
276
77
|
const result = await this.request(
|
|
@@ -314,7 +115,11 @@ class HttpConnection {
|
|
|
314
115
|
const old = new Map(this.config.tools.map((tool) => [tool.name, tool.approval]))
|
|
315
116
|
let cursor: string | undefined
|
|
316
117
|
for (let page = 0; page < 10; page++) {
|
|
317
|
-
const result = await this.request(
|
|
118
|
+
const result = await this.request(
|
|
119
|
+
'tools/list',
|
|
120
|
+
cursor === undefined ? {} : { cursor },
|
|
121
|
+
signal,
|
|
122
|
+
)
|
|
318
123
|
if (!Array.isArray(result.tools)) throw invalid()
|
|
319
124
|
for (const tool of result.tools) {
|
|
320
125
|
if (
|
|
@@ -332,7 +137,10 @@ class HttpConnection {
|
|
|
332
137
|
if (encoder.encode(JSON.stringify(tool.inputSchema)).byteLength > MAX_SCHEMA_BYTES) continue
|
|
333
138
|
tools.push({
|
|
334
139
|
name: tool.name,
|
|
335
|
-
description:
|
|
140
|
+
description:
|
|
141
|
+
typeof tool.description === 'string'
|
|
142
|
+
? [...tool.description].slice(0, 1024).join('')
|
|
143
|
+
: '',
|
|
336
144
|
schema: tool.inputSchema,
|
|
337
145
|
readOnly: record(tool.annotations) && tool.annotations.readOnlyHint === true,
|
|
338
146
|
approval: old.get(tool.name) ?? 'ask',
|
|
@@ -340,14 +148,23 @@ class HttpConnection {
|
|
|
340
148
|
})
|
|
341
149
|
}
|
|
342
150
|
if (!('nextCursor' in result)) return tools
|
|
343
|
-
if (
|
|
151
|
+
if (
|
|
152
|
+
typeof result.nextCursor !== 'string' ||
|
|
153
|
+
!result.nextCursor ||
|
|
154
|
+
cursors.has(result.nextCursor)
|
|
155
|
+
)
|
|
156
|
+
throw invalid()
|
|
344
157
|
cursors.add(result.nextCursor)
|
|
345
158
|
cursor = result.nextCursor
|
|
346
159
|
}
|
|
347
160
|
throw budget()
|
|
348
161
|
}
|
|
349
162
|
|
|
350
|
-
async call(
|
|
163
|
+
async call(
|
|
164
|
+
name: string,
|
|
165
|
+
args: Record<string, unknown>,
|
|
166
|
+
signal: AbortSignal,
|
|
167
|
+
): Promise<McpCallResult> {
|
|
351
168
|
try {
|
|
352
169
|
await this.initialize(signal)
|
|
353
170
|
} catch (error) {
|
|
@@ -357,7 +174,8 @@ class HttpConnection {
|
|
|
357
174
|
try {
|
|
358
175
|
return parseMcpCallResult(await this.request('tools/call', { name, arguments: args }, signal))
|
|
359
176
|
} catch (error) {
|
|
360
|
-
if (error instanceof RpcFailure)
|
|
177
|
+
if (error instanceof RpcFailure)
|
|
178
|
+
return { kind: 'failed', code: String(error.rpcCode), message: error.message }
|
|
361
179
|
if (error instanceof RequestFailure && error.dispatched) {
|
|
362
180
|
if (error.expired) this.reset()
|
|
363
181
|
return { kind: 'unknown', reason: error.reason }
|
|
@@ -374,7 +192,10 @@ class HttpConnection {
|
|
|
374
192
|
notification = false,
|
|
375
193
|
): Promise<Record<string, unknown>> {
|
|
376
194
|
const controller = new AbortController()
|
|
377
|
-
const timer = setTimeout(
|
|
195
|
+
const timer = setTimeout(
|
|
196
|
+
() => controller.abort(new KitFailure('timeout', 'MCP request timed out')),
|
|
197
|
+
60_000,
|
|
198
|
+
)
|
|
378
199
|
const combined = AbortSignal.any([signal, controller.signal])
|
|
379
200
|
let dispatched = false
|
|
380
201
|
let expired = false
|
|
@@ -414,7 +235,10 @@ class HttpConnection {
|
|
|
414
235
|
(a === 203 && b === 0 && c === 113)
|
|
415
236
|
} else
|
|
416
237
|
restricted =
|
|
417
|
-
host === 'localhost' ||
|
|
238
|
+
host === 'localhost' ||
|
|
239
|
+
host.endsWith('.localhost') ||
|
|
240
|
+
host.endsWith('.local') ||
|
|
241
|
+
!host.includes('.')
|
|
418
242
|
if (restricted) throw badRequest('url', 'blocked_address')
|
|
419
243
|
}
|
|
420
244
|
for (const [key, value] of Object.entries(this.config.query)) url.searchParams.set(key, value)
|
|
@@ -440,7 +264,13 @@ class HttpConnection {
|
|
|
440
264
|
if (combined.aborted) throw abortFailure(combined)
|
|
441
265
|
dispatched = true
|
|
442
266
|
const response = await abortable(
|
|
443
|
-
this.outbound.fetch(url.toString(), {
|
|
267
|
+
this.outbound.fetch(url.toString(), {
|
|
268
|
+
method: 'POST',
|
|
269
|
+
headers,
|
|
270
|
+
body,
|
|
271
|
+
signal: combined,
|
|
272
|
+
redirect: 'error',
|
|
273
|
+
}),
|
|
444
274
|
combined,
|
|
445
275
|
)
|
|
446
276
|
if (!response.ok) {
|
|
@@ -502,7 +332,11 @@ export class McpSession {
|
|
|
502
332
|
tools(): readonly McpTool[] {
|
|
503
333
|
return this.catalogue
|
|
504
334
|
}
|
|
505
|
-
async call(
|
|
335
|
+
async call(
|
|
336
|
+
prefixed: string,
|
|
337
|
+
args: Record<string, unknown>,
|
|
338
|
+
signal: AbortSignal,
|
|
339
|
+
): Promise<McpCallResult> {
|
|
506
340
|
const combined = AbortSignal.any([signal, this.shutdownSignal])
|
|
507
341
|
if (combined.aborted) return { kind: 'failed', code: 'cancelled', message: 'Cancelled' }
|
|
508
342
|
const target = this.calls.get(prefixed)
|
|
@@ -556,7 +390,9 @@ export class McpRuntime {
|
|
|
556
390
|
|
|
557
391
|
private background(id: string): void {
|
|
558
392
|
if (this.closing.signal.aborted || this.refreshing.has(id)) return
|
|
559
|
-
const task = this.track(
|
|
393
|
+
const task = this.track(
|
|
394
|
+
this.backgroundTail.catch(() => {}).then(() => this.refresh(id, this.closing.signal)),
|
|
395
|
+
)
|
|
560
396
|
this.backgroundTail = task
|
|
561
397
|
this.refreshing.set(id, task)
|
|
562
398
|
void task.then(
|
|
@@ -566,7 +402,8 @@ export class McpRuntime {
|
|
|
566
402
|
}
|
|
567
403
|
|
|
568
404
|
session(signal: AbortSignal): Promise<McpSession> {
|
|
569
|
-
if (signal.aborted || this.closing.signal.aborted)
|
|
405
|
+
if (signal.aborted || this.closing.signal.aborted)
|
|
406
|
+
return Promise.resolve(new McpSession([], this.closing.signal))
|
|
570
407
|
const combined = AbortSignal.any([signal, this.closing.signal])
|
|
571
408
|
return this.track(this.prepare(combined))
|
|
572
409
|
}
|
|
@@ -585,7 +422,8 @@ export class McpRuntime {
|
|
|
585
422
|
const connection = new HttpConnection(this.outbound, config)
|
|
586
423
|
if (config.tools.length) {
|
|
587
424
|
servers.push({ config, connection })
|
|
588
|
-
if (config.tools.some((tool) => Date.now() - tool.fetchedAt > CACHE_AGE_MS))
|
|
425
|
+
if (config.tools.some((tool) => Date.now() - tool.fetchedAt > CACHE_AGE_MS))
|
|
426
|
+
this.background(config.id)
|
|
589
427
|
} else {
|
|
590
428
|
try {
|
|
591
429
|
const tools = await connection.discover(signal)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { KitFailure, cancelled } from '../error.js'
|
|
2
|
+
import { isRecord as record } from '../guard.js'
|
|
3
|
+
import { SseParser } from '../sse.js'
|
|
4
|
+
import type { SseEvent } from '../sse.js'
|
|
5
|
+
import type { McpCallResult } from './types.js'
|
|
6
|
+
|
|
7
|
+
export const MAX_MESSAGE_BYTES = 2 * 1024 * 1024
|
|
8
|
+
|
|
9
|
+
export const encoder = new TextEncoder()
|
|
10
|
+
|
|
11
|
+
export const invalid = (): KitFailure =>
|
|
12
|
+
new KitFailure('upstream', 'Invalid MCP response', { reason: 'invalid_response' })
|
|
13
|
+
export const budget = (): KitFailure =>
|
|
14
|
+
new KitFailure('upstream', 'MCP message limit exceeded', { reason: 'budget_exceeded' })
|
|
15
|
+
export const abortFailure = (signal: AbortSignal): KitFailure =>
|
|
16
|
+
signal.reason instanceof KitFailure ? signal.reason : cancelled()
|
|
17
|
+
|
|
18
|
+
export function abortable<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
19
|
+
const { promise, resolve, reject } = Promise.withResolvers<T>()
|
|
20
|
+
const abort = () => {
|
|
21
|
+
signal.removeEventListener('abort', abort)
|
|
22
|
+
reject(abortFailure(signal))
|
|
23
|
+
}
|
|
24
|
+
if (signal.aborted) reject(abortFailure(signal))
|
|
25
|
+
else signal.addEventListener('abort', abort, { once: true })
|
|
26
|
+
operation.then(
|
|
27
|
+
(value) => {
|
|
28
|
+
signal.removeEventListener('abort', abort)
|
|
29
|
+
resolve(value)
|
|
30
|
+
},
|
|
31
|
+
(error) => {
|
|
32
|
+
signal.removeEventListener('abort', abort)
|
|
33
|
+
reject(error)
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
return promise
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class RequestFailure extends Error {
|
|
40
|
+
readonly failure: KitFailure
|
|
41
|
+
readonly dispatched: boolean
|
|
42
|
+
readonly reason: string
|
|
43
|
+
readonly expired: boolean
|
|
44
|
+
constructor(failure: KitFailure, dispatched: boolean, reason: string, expired = false) {
|
|
45
|
+
super(failure.message)
|
|
46
|
+
this.failure = failure
|
|
47
|
+
this.dispatched = dispatched
|
|
48
|
+
this.reason = reason
|
|
49
|
+
this.expired = expired
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export class RpcFailure extends KitFailure {
|
|
53
|
+
readonly rpcCode: number
|
|
54
|
+
constructor(rpcCode: number) {
|
|
55
|
+
super('upstream', 'MCP server rejected the request', { reason: 'rpc_error' })
|
|
56
|
+
this.rpcCode = rpcCode
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function normalize(error: unknown, signal: AbortSignal): KitFailure {
|
|
60
|
+
if (signal.aborted) return abortFailure(signal)
|
|
61
|
+
if (error instanceof RequestFailure) return error.failure
|
|
62
|
+
return error instanceof KitFailure ? error : new KitFailure('network', 'MCP connection failed')
|
|
63
|
+
}
|
|
64
|
+
export function reason(error: KitFailure): string {
|
|
65
|
+
const detail = error.details?.reason
|
|
66
|
+
return typeof detail === 'string' && ['invalid_response', 'budget_exceeded'].includes(detail)
|
|
67
|
+
? detail
|
|
68
|
+
: error.code === 'cancelled'
|
|
69
|
+
? 'cancelled'
|
|
70
|
+
: error.code === 'timeout'
|
|
71
|
+
? 'timeout'
|
|
72
|
+
: 'network'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function envelope(value: unknown, id: number): Record<string, unknown> {
|
|
76
|
+
if (
|
|
77
|
+
!record(value) ||
|
|
78
|
+
value.jsonrpc !== '2.0' ||
|
|
79
|
+
value.id !== id ||
|
|
80
|
+
'result' in value === 'error' in value
|
|
81
|
+
)
|
|
82
|
+
throw invalid()
|
|
83
|
+
if ('error' in value) {
|
|
84
|
+
if (
|
|
85
|
+
!record(value.error) ||
|
|
86
|
+
!Number.isInteger(value.error.code) ||
|
|
87
|
+
typeof value.error.code !== 'number' ||
|
|
88
|
+
typeof value.error.message !== 'string'
|
|
89
|
+
)
|
|
90
|
+
throw invalid()
|
|
91
|
+
throw new RpcFailure(value.error.code)
|
|
92
|
+
}
|
|
93
|
+
if (!record(value.result)) throw invalid()
|
|
94
|
+
return value.result
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Validates without flattening nontext content or interpreting extension fields. */
|
|
98
|
+
export function parseMcpCallResult(value: unknown): McpCallResult {
|
|
99
|
+
if (
|
|
100
|
+
!record(value) ||
|
|
101
|
+
!Array.isArray(value.content) ||
|
|
102
|
+
('isError' in value && typeof value.isError !== 'boolean') ||
|
|
103
|
+
('structuredContent' in value && !record(value.structuredContent))
|
|
104
|
+
)
|
|
105
|
+
return { kind: 'unknown', reason: 'invalid_response' }
|
|
106
|
+
const content: Record<string, unknown>[] = []
|
|
107
|
+
for (const item of value.content) {
|
|
108
|
+
if (
|
|
109
|
+
!record(item) ||
|
|
110
|
+
typeof item.type !== 'string' ||
|
|
111
|
+
!item.type.trim() ||
|
|
112
|
+
(item.type === 'text' && typeof item.text !== 'string')
|
|
113
|
+
)
|
|
114
|
+
return { kind: 'unknown', reason: 'invalid_response' }
|
|
115
|
+
content.push(item)
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
kind: 'completed',
|
|
119
|
+
content,
|
|
120
|
+
isError: value.isError === true,
|
|
121
|
+
...(record(value.structuredContent) ? { structuredContent: value.structuredContent } : {}),
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function readReply(
|
|
126
|
+
response: Response,
|
|
127
|
+
id: number,
|
|
128
|
+
signal: AbortSignal,
|
|
129
|
+
): Promise<Record<string, unknown>> {
|
|
130
|
+
const sse =
|
|
131
|
+
response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase() ===
|
|
132
|
+
'text/event-stream'
|
|
133
|
+
const contentType = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase()
|
|
134
|
+
if (!sse && contentType !== 'application/json') throw invalid()
|
|
135
|
+
if (!response.body) throw invalid()
|
|
136
|
+
const reader = response.body.getReader()
|
|
137
|
+
const parser = sse ? new SseParser(MAX_MESSAGE_BYTES, MAX_MESSAGE_BYTES) : null
|
|
138
|
+
const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false })
|
|
139
|
+
let text = ''
|
|
140
|
+
let bytes = 0
|
|
141
|
+
const eventsReply = (events: readonly { data: string }[]): Record<string, unknown> | null => {
|
|
142
|
+
for (const event of events) {
|
|
143
|
+
if (encoder.encode(event.data).byteLength > MAX_MESSAGE_BYTES) throw budget()
|
|
144
|
+
let value: unknown
|
|
145
|
+
try {
|
|
146
|
+
value = JSON.parse(event.data)
|
|
147
|
+
} catch {
|
|
148
|
+
throw invalid()
|
|
149
|
+
}
|
|
150
|
+
if (!record(value) || value.id !== id) continue
|
|
151
|
+
return envelope(value, id)
|
|
152
|
+
}
|
|
153
|
+
return null
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
for (;;) {
|
|
157
|
+
const chunk = await abortable(reader.read(), signal)
|
|
158
|
+
if (chunk.done) break
|
|
159
|
+
if (parser) {
|
|
160
|
+
let events: SseEvent[]
|
|
161
|
+
try {
|
|
162
|
+
events = parser.feed(chunk.value)
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (error instanceof KitFailure && error.details?.reason === 'sse_limit') throw budget()
|
|
165
|
+
throw invalid()
|
|
166
|
+
}
|
|
167
|
+
const result = eventsReply(events)
|
|
168
|
+
if (result) return result
|
|
169
|
+
} else {
|
|
170
|
+
bytes += chunk.value.byteLength
|
|
171
|
+
if (bytes > MAX_MESSAGE_BYTES) throw budget()
|
|
172
|
+
try {
|
|
173
|
+
text += decoder.decode(chunk.value, { stream: true })
|
|
174
|
+
} catch {
|
|
175
|
+
throw invalid()
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (parser) {
|
|
180
|
+
let events: SseEvent[]
|
|
181
|
+
try {
|
|
182
|
+
events = parser.finish()
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error instanceof KitFailure && error.details?.reason === 'sse_limit') throw budget()
|
|
185
|
+
throw invalid()
|
|
186
|
+
}
|
|
187
|
+
const result = eventsReply(events)
|
|
188
|
+
if (result) return result
|
|
189
|
+
throw invalid()
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
text += decoder.decode()
|
|
193
|
+
} catch {
|
|
194
|
+
throw invalid()
|
|
195
|
+
}
|
|
196
|
+
let value: unknown
|
|
197
|
+
try {
|
|
198
|
+
value = JSON.parse(text)
|
|
199
|
+
} catch {
|
|
200
|
+
throw invalid()
|
|
201
|
+
}
|
|
202
|
+
return envelope(value, id)
|
|
203
|
+
} finally {
|
|
204
|
+
void reader.cancel().catch(() => {})
|
|
205
|
+
reader.releaseLock()
|
|
206
|
+
}
|
|
207
|
+
}
|
package/src/mcp/types.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export type Approval = 'auto' | 'ask' | 'deny'
|
|
2
|
+
|
|
3
|
+
export interface McpTool {
|
|
4
|
+
readonly server: string
|
|
5
|
+
readonly slug: string
|
|
6
|
+
readonly name: string
|
|
7
|
+
readonly prefixed: string
|
|
8
|
+
readonly description: string
|
|
9
|
+
readonly schema: Readonly<Record<string, unknown>>
|
|
10
|
+
readonly approval: Approval
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type McpCallResult =
|
|
14
|
+
| {
|
|
15
|
+
readonly kind: 'completed'
|
|
16
|
+
readonly content: readonly Record<string, unknown>[]
|
|
17
|
+
readonly structuredContent?: Record<string, unknown>
|
|
18
|
+
readonly isError: boolean
|
|
19
|
+
}
|
|
20
|
+
| { readonly kind: 'failed'; readonly code: string; readonly message: string }
|
|
21
|
+
| { readonly kind: 'unknown'; readonly reason: string }
|
|
22
|
+
|
|
23
|
+
export interface McpCachedTool {
|
|
24
|
+
readonly name: string
|
|
25
|
+
readonly description: string
|
|
26
|
+
readonly schema: Readonly<Record<string, unknown>>
|
|
27
|
+
readonly readOnly: boolean
|
|
28
|
+
readonly approval: Approval
|
|
29
|
+
readonly fetchedAt: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface McpServerConfig {
|
|
33
|
+
readonly id: string
|
|
34
|
+
readonly slug: string
|
|
35
|
+
readonly name: string
|
|
36
|
+
readonly url: string
|
|
37
|
+
readonly enabled: boolean
|
|
38
|
+
readonly trusted: boolean
|
|
39
|
+
readonly allowPrivate: boolean
|
|
40
|
+
readonly headers: Readonly<Record<string, string>>
|
|
41
|
+
readonly query: Readonly<Record<string, string>>
|
|
42
|
+
readonly tools: readonly McpCachedTool[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface McpConfigStore {
|
|
46
|
+
list(): Promise<readonly McpServerConfig[]>
|
|
47
|
+
get(id: string): Promise<McpServerConfig | null>
|
|
48
|
+
replaceTools(id: string, tools: readonly McpCachedTool[]): Promise<void>
|
|
49
|
+
}
|