@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/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 { SseParser } from '../sse.js'
5
- import type { SseEvent } from '../sse.js'
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 Approval = 'auto' | 'ask' | 'deny'
8
- export interface McpTool {
9
- readonly server: string
10
- readonly slug: string
11
- readonly name: string
12
- readonly prefixed: string
13
- readonly description: string
14
- readonly schema: Readonly<Record<string, unknown>>
15
- readonly approval: Approval
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(() => controller.abort(new KitFailure('timeout', 'MCP initialization timed out')), 10_000)
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('tools/list', cursor === undefined ? {} : { cursor }, signal)
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: typeof tool.description === 'string' ? [...tool.description].slice(0, 1024).join('') : '',
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 (typeof result.nextCursor !== 'string' || !result.nextCursor || cursors.has(result.nextCursor)) throw invalid()
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(name: string, args: Record<string, unknown>, signal: AbortSignal): Promise<McpCallResult> {
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) return { kind: 'failed', code: String(error.rpcCode), message: error.message }
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(() => controller.abort(new KitFailure('timeout', 'MCP request timed out')), 60_000)
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' || host.endsWith('.localhost') || host.endsWith('.local') || !host.includes('.')
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(), { method: 'POST', headers, body, signal: combined, redirect: 'error' }),
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(prefixed: string, args: Record<string, unknown>, signal: AbortSignal): Promise<McpCallResult> {
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(this.backgroundTail.catch(() => {}).then(() => this.refresh(id, this.closing.signal)))
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) return Promise.resolve(new McpSession([], this.closing.signal))
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)) this.background(config.id)
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
+ }
@@ -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
+ }