@smthrs/mcp 0.0.0-stage → 1.0.0-rc.3

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.
Files changed (128) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/LICENSE +21 -0
  3. package/README.md +111 -2
  4. package/dist/cjs/Diagnostics.d.ts +46 -0
  5. package/dist/cjs/Diagnostics.d.ts.map +1 -0
  6. package/dist/cjs/Diagnostics.js +29 -0
  7. package/dist/cjs/Diagnostics.js.map +7 -0
  8. package/dist/cjs/McpClient.d.ts +304 -0
  9. package/dist/cjs/McpClient.d.ts.map +1 -0
  10. package/dist/cjs/McpClient.js +622 -0
  11. package/dist/cjs/McpClient.js.map +7 -0
  12. package/dist/cjs/McpError.d.ts +44 -0
  13. package/dist/cjs/McpError.d.ts.map +1 -0
  14. package/dist/cjs/McpError.js +41 -0
  15. package/dist/cjs/McpError.js.map +7 -0
  16. package/dist/cjs/McpFlows.d.ts +112 -0
  17. package/dist/cjs/McpFlows.d.ts.map +1 -0
  18. package/dist/cjs/McpFlows.js +127 -0
  19. package/dist/cjs/McpFlows.js.map +7 -0
  20. package/dist/cjs/index.d.ts +39 -0
  21. package/dist/cjs/index.d.ts.map +1 -0
  22. package/dist/cjs/index.js +41 -0
  23. package/dist/cjs/index.js.map +7 -0
  24. package/dist/cjs/internal/DiagnosticReporter.d.ts +17 -0
  25. package/dist/cjs/internal/DiagnosticReporter.d.ts.map +1 -0
  26. package/dist/cjs/internal/DiagnosticReporter.js +56 -0
  27. package/dist/cjs/internal/DiagnosticReporter.js.map +7 -0
  28. package/dist/cjs/internal/HttpTransport.d.ts +67 -0
  29. package/dist/cjs/internal/HttpTransport.d.ts.map +1 -0
  30. package/dist/cjs/internal/HttpTransport.js +298 -0
  31. package/dist/cjs/internal/HttpTransport.js.map +7 -0
  32. package/dist/cjs/internal/JsonLimits.d.ts +29 -0
  33. package/dist/cjs/internal/JsonLimits.d.ts.map +1 -0
  34. package/dist/cjs/internal/JsonLimits.js +51 -0
  35. package/dist/cjs/internal/JsonLimits.js.map +7 -0
  36. package/dist/cjs/internal/Limits.d.ts +36 -0
  37. package/dist/cjs/internal/Limits.d.ts.map +1 -0
  38. package/dist/cjs/internal/Limits.js +34 -0
  39. package/dist/cjs/internal/Limits.js.map +7 -0
  40. package/dist/cjs/internal/Rpc.d.ts +141 -0
  41. package/dist/cjs/internal/Rpc.d.ts.map +1 -0
  42. package/dist/cjs/internal/Rpc.js +92 -0
  43. package/dist/cjs/internal/Rpc.js.map +7 -0
  44. package/dist/cjs/internal/StdioTransport.d.ts +78 -0
  45. package/dist/cjs/internal/StdioTransport.d.ts.map +1 -0
  46. package/dist/cjs/internal/StdioTransport.js +310 -0
  47. package/dist/cjs/internal/StdioTransport.js.map +7 -0
  48. package/dist/cjs/internal/Transport.d.ts +87 -0
  49. package/dist/cjs/internal/Transport.d.ts.map +1 -0
  50. package/dist/cjs/internal/Transport.js +116 -0
  51. package/dist/cjs/internal/Transport.js.map +7 -0
  52. package/dist/cjs/package.json +1 -0
  53. package/dist/esm/Diagnostics.d.ts +46 -0
  54. package/dist/esm/Diagnostics.d.ts.map +1 -0
  55. package/dist/esm/Diagnostics.js +26 -0
  56. package/dist/esm/Diagnostics.js.map +1 -0
  57. package/dist/esm/McpClient.d.ts +304 -0
  58. package/dist/esm/McpClient.d.ts.map +1 -0
  59. package/dist/esm/McpClient.js +671 -0
  60. package/dist/esm/McpClient.js.map +1 -0
  61. package/dist/esm/McpError.d.ts +44 -0
  62. package/dist/esm/McpError.d.ts.map +1 -0
  63. package/dist/esm/McpError.js +43 -0
  64. package/dist/esm/McpError.js.map +1 -0
  65. package/dist/esm/McpFlows.d.ts +112 -0
  66. package/dist/esm/McpFlows.d.ts.map +1 -0
  67. package/dist/esm/McpFlows.js +167 -0
  68. package/dist/esm/McpFlows.js.map +1 -0
  69. package/dist/esm/index.d.ts +39 -0
  70. package/dist/esm/index.d.ts.map +1 -0
  71. package/dist/esm/index.js +39 -0
  72. package/dist/esm/index.js.map +1 -0
  73. package/dist/esm/internal/DiagnosticReporter.d.ts +17 -0
  74. package/dist/esm/internal/DiagnosticReporter.d.ts.map +1 -0
  75. package/dist/esm/internal/DiagnosticReporter.js +44 -0
  76. package/dist/esm/internal/DiagnosticReporter.js.map +1 -0
  77. package/dist/esm/internal/HttpTransport.d.ts +67 -0
  78. package/dist/esm/internal/HttpTransport.d.ts.map +1 -0
  79. package/dist/esm/internal/HttpTransport.js +266 -0
  80. package/dist/esm/internal/HttpTransport.js.map +1 -0
  81. package/dist/esm/internal/JsonLimits.d.ts +29 -0
  82. package/dist/esm/internal/JsonLimits.d.ts.map +1 -0
  83. package/dist/esm/internal/JsonLimits.js +55 -0
  84. package/dist/esm/internal/JsonLimits.js.map +1 -0
  85. package/dist/esm/internal/Limits.d.ts +36 -0
  86. package/dist/esm/internal/Limits.d.ts.map +1 -0
  87. package/dist/esm/internal/Limits.js +41 -0
  88. package/dist/esm/internal/Limits.js.map +1 -0
  89. package/dist/esm/internal/Rpc.d.ts +141 -0
  90. package/dist/esm/internal/Rpc.d.ts.map +1 -0
  91. package/dist/esm/internal/Rpc.js +129 -0
  92. package/dist/esm/internal/Rpc.js.map +1 -0
  93. package/dist/esm/internal/StdioTransport.d.ts +78 -0
  94. package/dist/esm/internal/StdioTransport.d.ts.map +1 -0
  95. package/dist/esm/internal/StdioTransport.js +332 -0
  96. package/dist/esm/internal/StdioTransport.js.map +1 -0
  97. package/dist/esm/internal/Transport.d.ts +87 -0
  98. package/dist/esm/internal/Transport.d.ts.map +1 -0
  99. package/dist/esm/internal/Transport.js +146 -0
  100. package/dist/esm/internal/Transport.js.map +1 -0
  101. package/docs/README.md +139 -0
  102. package/docs/api.md +469 -0
  103. package/docs/concepts/the-session.md +135 -0
  104. package/docs/concepts/tools-as-flows.md +116 -0
  105. package/docs/guides/bound-an-untrusted-server.md +158 -0
  106. package/docs/guides/configure-servers-for-the-cli.md +167 -0
  107. package/docs/guides/connect-a-server.md +161 -0
  108. package/docs/guides/grant-authority-to-mcp-tools.md +130 -0
  109. package/docs/guides/handle-a-failed-tool-call.md +125 -0
  110. package/docs/guides/select-the-tools-a-run-sees.md +92 -0
  111. package/docs/guides/testing.md +132 -0
  112. package/docs/guides/validate-structured-output.md +103 -0
  113. package/docs/installation.md +117 -0
  114. package/docs/quickstart.md +200 -0
  115. package/docs/troubleshooting.md +316 -0
  116. package/package.json +157 -3
  117. package/src/Diagnostics.ts +47 -0
  118. package/src/McpClient.ts +985 -0
  119. package/src/McpError.ts +52 -0
  120. package/src/McpFlows.ts +210 -0
  121. package/src/index.ts +42 -0
  122. package/src/internal/DiagnosticReporter.ts +47 -0
  123. package/src/internal/HttpTransport.ts +400 -0
  124. package/src/internal/JsonLimits.ts +53 -0
  125. package/src/internal/Limits.ts +48 -0
  126. package/src/internal/Rpc.ts +219 -0
  127. package/src/internal/StdioTransport.ts +491 -0
  128. package/src/internal/Transport.ts +178 -0
@@ -0,0 +1,400 @@
1
+ /**
2
+ * JSON-RPC over the MCP Streamable HTTP transport.
3
+ *
4
+ * Every client message is one `POST` to the server's endpoint. A notification
5
+ * is answered with `202 Accepted`; a request is answered either with one
6
+ * `application/json` reply or with a `text/event-stream` whose events carry
7
+ * server notifications, server requests, and finally the reply. The server's
8
+ * `Mcp-Session-Id` from `initialize` is sent on every later message, together
9
+ * with the negotiated `MCP-Protocol-Version`.
10
+ *
11
+ * Requests go through the `HttpClient` in context, which is the host's egress
12
+ * client: its proxy, pinning, and capability checks apply unchanged, and a
13
+ * destination the egress policy denies fails with `connection_closed`.
14
+ *
15
+ * Out of scope: the optional standalone `GET` event stream, resuming a broken
16
+ * event stream with `Last-Event-ID`, and OAuth discovery. An expired session
17
+ * fails rather than re-initializing, so no request is ever replayed.
18
+ *
19
+ * @since 1.0.0-rc.1
20
+ */
21
+
22
+ import { isRecord } from "@smthrs/canonical/Record"
23
+ import * as KernelHttpClient from "@smthrs/kernel/HttpClient"
24
+ import { Deferred, Effect, Option, type Redacted, type Scope, Stream } from "effect"
25
+ import * as HttpClient from "effect/unstable/http/HttpClient"
26
+ import type * as HttpClientError from "effect/unstable/http/HttpClientError"
27
+ import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
28
+ import type { McpError } from "../McpError.ts"
29
+ import * as DiagnosticReporter from "./DiagnosticReporter.ts"
30
+ import * as JsonLimits from "./JsonLimits.ts"
31
+ import * as Limits from "./Limits.ts"
32
+ import * as Rpc from "./Rpc.ts"
33
+ import * as Transport from "./Transport.ts"
34
+
35
+ /**
36
+ * Supplies the bearer credential for a Streamable HTTP server.
37
+ *
38
+ * `token` runs once per HTTP message, so a provider can rotate or refresh the
39
+ * credential between messages. Its failure fails that message unchanged.
40
+ *
41
+ * @category models
42
+ * @since 1.0.0-rc.1
43
+ */
44
+ export interface AuthProvider {
45
+ readonly token: Effect.Effect<Redacted.Redacted<string>, McpError>
46
+ }
47
+
48
+ /**
49
+ * Options accepted by {@link connect}.
50
+ *
51
+ * @category models
52
+ * @since 1.0.0-rc.1
53
+ */
54
+ export interface ConnectOptions {
55
+ /** The name this server is known by, for error messages only. */
56
+ readonly server: string
57
+ /** The server's MCP endpoint: an absolute `http:` or `https:` URL without credentials. */
58
+ readonly url: string
59
+ /** Bearer credential source. Omit for a server that needs none. */
60
+ readonly authProvider?: AuthProvider | undefined
61
+ /** Default deadline for a request/reply exchange. See {@link Transport.defaultRequestTimeoutMs}. */
62
+ readonly requestTimeoutMs?: number | undefined
63
+ /** Maximum UTF-8 bytes accepted in one inbound JSON-RPC message. See {@link Transport.defaultMaxFrameBytes}. */
64
+ readonly maxFrameBytes?: number | undefined
65
+ /** Maximum UTF-8 bytes emitted in one JSON-RPC message. See {@link Transport.defaultMaxOutboundFrameBytes}. */
66
+ readonly maxOutboundFrameBytes?: number | undefined
67
+ }
68
+
69
+ /** Deadline for the best-effort `DELETE` that ends a session on scope close. */
70
+ const sessionCloseMs = 1_000
71
+
72
+ /** A session id is visible ASCII only (0x21-0x7E). */
73
+ const validSessionId = /^[\x21-\x7e]+$/
74
+
75
+ const endpointOf = (url: string): URL | undefined => {
76
+ if (!URL.canParse(url)) return undefined
77
+ const parsed = new URL(url)
78
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return undefined
79
+ if (parsed.username !== "" || parsed.password !== "") return undefined
80
+ return parsed
81
+ }
82
+
83
+ const utf8 = new TextEncoder()
84
+
85
+ const mediaType = (header: string | undefined): string => (header ?? "").split(";")[0]!.trim().toLowerCase()
86
+
87
+ /**
88
+ * Opens a Streamable HTTP session and returns a live {@link Transport.Transport}.
89
+ * No message is sent until the first request; the endpoint and limits are
90
+ * checked first. Closing the calling scope ends the session with a
91
+ * best-effort `DELETE` and rejects later traffic.
92
+ *
93
+ * @category constructors
94
+ * @since 1.0.0-rc.1
95
+ */
96
+ export const connect = (
97
+ options: ConnectOptions
98
+ ): Effect.Effect<Transport.Transport, McpError, HttpClient.HttpClient | Scope.Scope> =>
99
+ Effect.gen(function*() {
100
+ const server = options.server
101
+ const diagnostic = yield* DiagnosticReporter.make(server)
102
+ const requestTimeoutMs = options.requestTimeoutMs ?? Transport.defaultRequestTimeoutMs
103
+ const maxFrameBytes = options.maxFrameBytes ?? Transport.defaultMaxFrameBytes
104
+ const maxOutboundFrameBytes = options.maxOutboundFrameBytes ?? Transport.defaultMaxOutboundFrameBytes
105
+ yield* Limits.checkPositiveIntegers(server, [
106
+ ["requestTimeoutMs", requestTimeoutMs],
107
+ ["maxFrameBytes", maxFrameBytes],
108
+ ["maxOutboundFrameBytes", maxOutboundFrameBytes]
109
+ ])
110
+ const endpoint = endpointOf(options.url)
111
+ if (endpoint === undefined) {
112
+ return yield* Effect.fail(Limits.protocolError(
113
+ server,
114
+ `MCP server "${server}" url must be an absolute http or https URL without credentials`
115
+ ))
116
+ }
117
+ // Each message runs in its own scope, which aborts the HTTP request and
118
+ // releases its body however the exchange ends.
119
+ const client = HttpClient.withScope(yield* HttpClient.HttpClient)
120
+ const scope = yield* Effect.scope
121
+ const closing = yield* Deferred.make<void>()
122
+
123
+ let open = true
124
+ let sessionId: string | undefined
125
+ let protocolVersion: string | undefined
126
+ let nextId = 0
127
+
128
+ const unreachable = (error: HttpClientError.HttpClientError): McpError => {
129
+ diagnostic("transport", error.message)
130
+ return Option.isSome(KernelHttpClient.fromHttpClientError(error))
131
+ ? Transport.closed(server, "is not reachable: egress to its URL is not granted")
132
+ : Transport.closed(server, "is not reachable; transport details withheld")
133
+ }
134
+
135
+ const frameOf = (method: string, message: Rpc.OutboundMessage): Effect.Effect<Uint8Array, McpError> => {
136
+ // Rpc.encode appends the stdio line terminator; an HTTP body has none.
137
+ const frame = Rpc.encode(message).subarray(0, -1)
138
+ return frame.byteLength <= maxOutboundFrameBytes
139
+ ? Effect.succeed(frame)
140
+ : Effect.fail(Limits.protocolError(
141
+ server,
142
+ `MCP server "${server}" tried to send a ${method} frame larger than ${maxOutboundFrameBytes} bytes`
143
+ ))
144
+ }
145
+
146
+ const withSession = (request: HttpClientRequest.HttpClientRequest): HttpClientRequest.HttpClientRequest => {
147
+ let next = request
148
+ if (sessionId !== undefined) next = HttpClientRequest.setHeader(next, "mcp-session-id", sessionId)
149
+ if (protocolVersion !== undefined) {
150
+ next = HttpClientRequest.setHeader(next, "mcp-protocol-version", protocolVersion)
151
+ }
152
+ return next
153
+ }
154
+
155
+ /** Reads a whole body, failing once it passes `maxFrameBytes`. */
156
+ const readBody = (stream: Stream.Stream<Uint8Array, HttpClientError.HttpClientError>) =>
157
+ stream.pipe(
158
+ Stream.mapError(unreachable),
159
+ Stream.runFoldEffect(
160
+ () => ({ chunks: [] as Array<Uint8Array>, bytes: 0 }),
161
+ (body, chunk) => {
162
+ body.bytes += chunk.byteLength
163
+ if (body.bytes > maxFrameBytes) {
164
+ return Effect.fail(Limits.protocolError(server, `MCP frame exceeded ${maxFrameBytes} bytes`))
165
+ }
166
+ body.chunks.push(chunk)
167
+ return Effect.succeed(body)
168
+ }
169
+ ),
170
+ Effect.map((body) => {
171
+ const joined = new Uint8Array(body.bytes)
172
+ let offset = 0
173
+ for (const chunk of body.chunks) {
174
+ joined.set(chunk, offset)
175
+ offset += chunk.byteLength
176
+ }
177
+ return new TextDecoder().decode(joined)
178
+ })
179
+ )
180
+
181
+ const authorize = (request: HttpClientRequest.HttpClientRequest) =>
182
+ options.authProvider === undefined
183
+ ? Effect.succeed(request)
184
+ : Effect.map(options.authProvider.token, (token) => HttpClientRequest.bearerToken(request, token))
185
+
186
+ /**
187
+ * Sends one message and returns the raw response, or fails with a
188
+ * transport, status, or session failure.
189
+ */
190
+ const post = (method: string, message: Rpc.OutboundMessage, dispatched?: { value: boolean }) =>
191
+ Effect.gen(function*() {
192
+ if (!open) return yield* Effect.fail(Transport.closed(server, "connection scope closed"))
193
+ const body = yield* frameOf(method, message)
194
+ const request = yield* authorize(withSession(
195
+ HttpClientRequest.post(endpoint).pipe(
196
+ HttpClientRequest.setHeader("accept", "application/json, text/event-stream"),
197
+ HttpClientRequest.bodyUint8Array(body, "application/json")
198
+ )
199
+ ))
200
+ const sentSession = sessionId !== undefined
201
+ if (dispatched !== undefined) dispatched.value = true
202
+ const response = yield* Effect.mapError(client.execute(request), unreachable)
203
+ if (response.status === 404 && sentSession) {
204
+ return yield* Effect.fail(Transport.closed(server, "ended the session; reconnect to continue"))
205
+ }
206
+ if (response.status < 200 || response.status > 299) {
207
+ diagnostic("remote-error", { status: response.status })
208
+ return yield* Effect.fail(Limits.protocolError(
209
+ server,
210
+ `MCP server "${server}" answered ${method} with HTTP ${response.status}`
211
+ ))
212
+ }
213
+ return response
214
+ })
215
+
216
+ /** Sends a notification or a reply to a server request; the body is ignored. */
217
+ const deliver = (method: string, message: Rpc.OutboundMessage) =>
218
+ Effect.scoped(Effect.flatMap(post(method, message), (response) => Effect.asVoid(readBody(response.stream))))
219
+
220
+ /** Fails `effect` with `connection_closed`, interrupting it, when the scope closes first. */
221
+ const untilClosed = <A>(effect: Effect.Effect<A, McpError>) =>
222
+ Effect.raceFirst(
223
+ effect,
224
+ Effect.andThen(Deferred.await(closing), Effect.fail(Transport.closed(server, "connection scope closed")))
225
+ )
226
+
227
+ /**
228
+ * Handles one inbound JSON-RPC message during request `id`: server
229
+ * requests are answered, notifications dropped, and the reply returned.
230
+ */
231
+ const receive = (method: string, id: number, text: string): Effect.Effect<Option.Option<unknown>, McpError> =>
232
+ Effect.gen(function*() {
233
+ const message = Rpc.parse(text)
234
+ if (message === undefined) {
235
+ return yield* Effect.fail(
236
+ Limits.protocolError(server, `MCP server "${server}" sent a message that is not JSON-RPC`)
237
+ )
238
+ }
239
+ const jsonIssue = JsonLimits.checkParsed(message)
240
+ if (jsonIssue !== undefined) {
241
+ return yield* Effect.fail(
242
+ Limits.protocolError(server, `MCP server "${server}" sent invalid JSON: ${jsonIssue}`)
243
+ )
244
+ }
245
+ const reply = Rpc.classify(message)
246
+ switch (reply._tag) {
247
+ case "Notification":
248
+ return Option.none()
249
+ case "Request": {
250
+ yield* deliver(
251
+ "server-response",
252
+ reply.method === "ping"
253
+ ? { jsonrpc: "2.0", id: reply.id, result: {} }
254
+ : { jsonrpc: "2.0", id: reply.id, error: { code: -32_601, message: "Method not found" } }
255
+ )
256
+ return Option.none()
257
+ }
258
+ case "Malformed":
259
+ return yield* Effect.fail(Limits.protocolError(
260
+ server,
261
+ `MCP server "${server}" sent a malformed JSON-RPC reply: ${reply.reason}`
262
+ ))
263
+ case "UncorrelatedError":
264
+ diagnostic("remote-error", { code: reply.code, message: reply.message, data: reply.data })
265
+ return Option.none()
266
+ }
267
+ if (reply.id !== id) {
268
+ return yield* Effect.fail(Limits.protocolError(
269
+ server,
270
+ `MCP server "${server}" answered ${method} with a reply to another request`
271
+ ))
272
+ }
273
+ if (reply._tag === "Error") {
274
+ diagnostic("remote-error", { code: reply.code, message: reply.message, data: reply.data })
275
+ return yield* Effect.fail(Transport.replyError(server, method, reply))
276
+ }
277
+ return Option.some(reply.result)
278
+ })
279
+
280
+ /** Collects `data:` lines into events; a blank line ends one. */
281
+ const events = (stream: Stream.Stream<Uint8Array, HttpClientError.HttpClientError>) =>
282
+ Transport.lines(server, maxFrameBytes, Stream.mapError(stream, unreachable), { crTerminates: true }).pipe(
283
+ Stream.mapAccumEffect(
284
+ () => ({ data: undefined as string | undefined, bytes: 0 }),
285
+ (event, line) => {
286
+ if (line === "") {
287
+ const data = event.data
288
+ event.data = undefined
289
+ event.bytes = 0
290
+ return Effect.succeed([event, data === undefined ? [] : [data]] as const)
291
+ }
292
+ if (!line.startsWith("data:")) return Effect.succeed([event, []] as const)
293
+ const value = line.slice(line.startsWith("data: ") ? 6 : 5)
294
+ event.bytes += utf8.encode(value).byteLength + (event.data === undefined ? 0 : 1)
295
+ event.data = event.data === undefined ? value : `${event.data}\n${value}`
296
+ return event.bytes > maxFrameBytes
297
+ ? Effect.fail(Limits.protocolError(server, `MCP frame exceeded ${maxFrameBytes} bytes`))
298
+ : Effect.succeed([event, []] as const)
299
+ }
300
+ )
301
+ )
302
+
303
+ const exchange = (method: string, params: unknown, id: number, dispatched: { value: boolean }) =>
304
+ Effect.scoped(Effect.gen(function*() {
305
+ const response = yield* post(method, { jsonrpc: "2.0", id, method, params }, dispatched)
306
+ if (method === "initialize") {
307
+ const header = response.headers["mcp-session-id"]
308
+ if (header !== undefined && !validSessionId.test(header)) {
309
+ return yield* Effect.fail(Limits.protocolError(server, `MCP server "${server}" sent an invalid session id`))
310
+ }
311
+ sessionId = header
312
+ }
313
+ const type = mediaType(response.headers["content-type"])
314
+ let result: Option.Option<unknown>
315
+ if (type === "application/json") {
316
+ result = yield* Effect.flatMap(readBody(response.stream), (text) => receive(method, id, text))
317
+ } else if (type === "text/event-stream") {
318
+ result = Option.flatten(
319
+ yield* events(response.stream).pipe(
320
+ Stream.mapEffect((data) => receive(method, id, data)),
321
+ Stream.filter(Option.isSome),
322
+ Stream.runHead
323
+ )
324
+ )
325
+ } else {
326
+ return yield* Effect.fail(Limits.protocolError(
327
+ server,
328
+ `MCP server "${server}" answered ${method} with an unsupported content type`
329
+ ))
330
+ }
331
+ if (Option.isNone(result)) {
332
+ return yield* Effect.fail(Transport.closed(server, `closed its response before answering ${method}`))
333
+ }
334
+ if (method === "initialize" && isRecord(result.value) && typeof result.value.protocolVersion === "string") {
335
+ protocolVersion = result.value.protocolVersion
336
+ }
337
+ return result.value
338
+ }))
339
+
340
+ const notify = (method: string, params?: unknown, timeoutMs = requestTimeoutMs): Effect.Effect<void, McpError> =>
341
+ Limits.isPositiveInteger(timeoutMs)
342
+ ? untilClosed(deliver(method, { jsonrpc: "2.0", method, params })).pipe(
343
+ Effect.timeoutOrElse({
344
+ duration: timeoutMs,
345
+ orElse: () => Effect.fail(Transport.timeout(server, method, timeoutMs))
346
+ })
347
+ )
348
+ : Effect.fail(Limits.protocolError(server, "MCP notification timeout must be a positive integer"))
349
+
350
+ const request = (
351
+ method: string,
352
+ params?: unknown,
353
+ timeoutMs = requestTimeoutMs
354
+ ): Effect.Effect<unknown, McpError> =>
355
+ Effect.suspend(() => {
356
+ if (!Limits.isPositiveInteger(timeoutMs)) {
357
+ return Effect.fail(Limits.protocolError(server, "MCP request timeout must be a positive integer"))
358
+ }
359
+ const id = ++nextId
360
+ const dispatched = { value: false }
361
+ return exchange(method, params, id, dispatched).pipe(
362
+ // Closing the HTTP response is not a cancellation in MCP; tell the
363
+ // server explicitly, without delaying the deadline being reported.
364
+ Effect.onInterrupt(() =>
365
+ dispatched.value && open && method !== "initialize"
366
+ ? Effect.asVoid(Effect.forkIn(
367
+ Effect.ignore(notify("notifications/cancelled", {
368
+ requestId: id,
369
+ reason: Transport.cancellationReason
370
+ })),
371
+ scope
372
+ ))
373
+ : Effect.void
374
+ ),
375
+ untilClosed,
376
+ Effect.timeoutOrElse({
377
+ duration: timeoutMs,
378
+ orElse: () => Effect.fail(Transport.timeout(server, method, timeoutMs))
379
+ })
380
+ )
381
+ })
382
+
383
+ yield* Effect.addFinalizer(() =>
384
+ Effect.suspend(() => {
385
+ open = false
386
+ const ended = Deferred.succeed(closing, undefined)
387
+ if (sessionId === undefined) return ended
388
+ return Effect.andThen(
389
+ ended,
390
+ Effect.scoped(Effect.flatMap(authorize(withSession(HttpClientRequest.delete(endpoint))), client.execute))
391
+ .pipe(
392
+ Effect.timeout(sessionCloseMs),
393
+ Effect.ignore
394
+ )
395
+ )
396
+ })
397
+ )
398
+
399
+ return { request, notify }
400
+ })
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Resource bounds checked before recursive JSON consumers see server data.
3
+ *
4
+ * @since 1.0.0-rc.0
5
+ */
6
+
7
+ /**
8
+ * Maximum nested containers, counting the wire envelope as the first one.
9
+ *
10
+ * @category constants
11
+ * @since 1.0.0-rc.0
12
+ */
13
+ export const maxDepth = 128
14
+
15
+ /**
16
+ * Checks a JSON.parse result iteratively. This is not for arbitrary JS objects
17
+ * with executable accessors; outbound arguments use guarded descriptors.
18
+ *
19
+ * @category validation
20
+ * @since 1.0.0-rc.0
21
+ */
22
+ export const checkParsed = (value: unknown): string | undefined => {
23
+ const pending = [{ value, depth: 1 }]
24
+ while (pending.length > 0) {
25
+ const current = pending.pop()!
26
+ if (typeof current.value === "number" && !Number.isFinite(current.value)) {
27
+ return "a JSON number is outside the finite range"
28
+ }
29
+ if (typeof current.value !== "object" || current.value === null) continue
30
+ if (current.depth > maxDepth) return `JSON nesting exceeds ${maxDepth} containers`
31
+ for (const member of Object.values(current.value)) {
32
+ pending.push({ value: member, depth: current.depth + 1 })
33
+ }
34
+ }
35
+ return undefined
36
+ }
37
+
38
+ /**
39
+ * Freezes validated acyclic catalog data before it is exposed to consumers.
40
+ * The private dispatcher and public catalog must retain the same contract.
41
+ *
42
+ * @category utils
43
+ * @since 1.0.0-rc.0
44
+ */
45
+ export const freezeParsed = (value: unknown): void => {
46
+ const pending = [value]
47
+ while (pending.length > 0) {
48
+ const current = pending.pop()
49
+ if (typeof current !== "object" || current === null) continue
50
+ Object.freeze(current)
51
+ for (const member of Object.values(current)) pending.push(member)
52
+ }
53
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Positive-integer option bounds shared by the transport and the client.
3
+ *
4
+ * Both `connect` functions resolve their defaults and then reject the first
5
+ * option that is not a positive safe integer, before any process is spawned.
6
+ * The check and its error prose live here so the two option lists cannot
7
+ * drift in wording or in semantics.
8
+ *
9
+ * @since 1.0.0-rc.0
10
+ */
11
+
12
+ import { Effect } from "effect"
13
+ import { McpError } from "../McpError.ts"
14
+
15
+ /**
16
+ * Builds the `protocol_error` failure both modules report for malformed
17
+ * options and frames.
18
+ *
19
+ * @category errors
20
+ * @since 1.0.0-rc.0
21
+ */
22
+ export const protocolError = (server: string, message: string): McpError =>
23
+ new McpError({ code: "protocol_error", message, server })
24
+
25
+ /**
26
+ * Whether a value is a positive safe integer.
27
+ *
28
+ * @category validation
29
+ * @since 1.0.0-rc.0
30
+ */
31
+ export const isPositiveInteger = (value: number): boolean => Number.isSafeInteger(value) && value > 0
32
+
33
+ /**
34
+ * Fails with `protocol_error` naming the first option whose resolved value is
35
+ * not a positive safe integer. Entries are checked in the order given.
36
+ *
37
+ * @category validation
38
+ * @since 1.0.0-rc.0
39
+ */
40
+ export const checkPositiveIntegers = (
41
+ server: string,
42
+ entries: ReadonlyArray<readonly [name: string, value: number]>
43
+ ): Effect.Effect<void, McpError> => {
44
+ const invalid = entries.find(([, value]) => !isPositiveInteger(value))
45
+ return invalid === undefined
46
+ ? Effect.void
47
+ : Effect.fail(protocolError(server, `MCP option "${invalid[0]}" must be a positive integer`))
48
+ }