@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,491 @@
1
+ /**
2
+ * Newline-delimited JSON-RPC transport over a spawned MCP server's stdio.
3
+ *
4
+ * This module owns exactly the connection lifecycle and request/reply
5
+ * correlation an MCP session needs: spawn once, write frames in, read frames
6
+ * out, match replies to the request that asked for them. It knows nothing
7
+ * about `initialize`, `tools/list`, or `tools/call`. It answers the protocol's
8
+ * liveness `ping` with an empty result, and unsupported server requests with
9
+ * method-not-found. {@link McpClient} owns feature negotiation and tool calls.
10
+ *
11
+ * Server-initiated notifications are received and dropped. A future caller
12
+ * that needs `notifications/*` (for example a progress stream) is the reason
13
+ * to add a subscription surface here rather than threading one more parameter
14
+ * through every constructor now.
15
+ *
16
+ * @since 1.0.0-rc.0
17
+ */
18
+
19
+ import * as Redaction from "@smthrs/journal/Redaction"
20
+ import * as ChildProcessEnvironment from "@smthrs/kernel/ChildProcessEnvironment"
21
+ import { Deferred, Effect, Exit, Fiber, HashMap, Option, Queue, Ref, Stream } from "effect"
22
+ import type { Scope } from "effect"
23
+ import * as ChildProcess from "effect/unstable/process/ChildProcess"
24
+ import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
25
+ import { McpError } from "../McpError.ts"
26
+ import * as DiagnosticReporter from "./DiagnosticReporter.ts"
27
+ import * as JsonLimits from "./JsonLimits.ts"
28
+ import * as Limits from "./Limits.ts"
29
+ import * as Rpc from "./Rpc.ts"
30
+ import * as Transport from "./Transport.ts"
31
+
32
+ /**
33
+ * The retained stderr diagnostic: the line redactor, which owns every line
34
+ * still open or arriving, and the redacted lines it has released, capped to
35
+ * the configured byte tail.
36
+ */
37
+ interface StderrTail {
38
+ readonly redactor: Redaction.LineRedactor
39
+ readonly redacted: string
40
+ }
41
+
42
+ const emptyStderrTail = (): StderrTail => ({
43
+ redactor: Redaction.lineRedactor(Redaction.diagnosticRules),
44
+ redacted: ""
45
+ })
46
+
47
+ const utf8 = new TextEncoder()
48
+
49
+ /** The last `limit` bytes of `text`, cut on a UTF-8 character boundary. */
50
+ const tailBytes = (text: string, limit: number): string => {
51
+ const bytes = utf8.encode(text)
52
+ if (bytes.byteLength <= limit) return text
53
+ let start = bytes.byteLength - limit
54
+ // Skip continuation bytes so the tail starts on a whole character.
55
+ while (start < bytes.byteLength && (bytes[start]! & 0xc0) === 0x80) start += 1
56
+ return new TextDecoder().decode(bytes.subarray(start))
57
+ }
58
+
59
+ /**
60
+ * Adds decoded stderr text. The redactor takes each complete line and the
61
+ * part of a line still arriving, holds a value that spans lines (a private
62
+ * key block, a string `util.inspect` split with `+`) until it closes, bounds
63
+ * an overlong line itself, and returns only what is safe to show.
64
+ */
65
+ const appendStderr = (current: StderrTail, text: string, limit: number): StderrTail => {
66
+ const segments = text.split("\n")
67
+ const last = segments.pop()!
68
+ let redacted = current.redacted
69
+ for (const segment of segments) {
70
+ for (const clean of current.redactor.line(segment)) redacted += `${clean}\n`
71
+ }
72
+ if (last !== "") current.redactor.part(last)
73
+ return { redactor: current.redactor, redacted: tailBytes(redacted, limit) }
74
+ }
75
+
76
+ /**
77
+ * The diagnostic an observer receives: the released tail and what the
78
+ * redactor would release now, whitespace flattened, capped to the last
79
+ * `limit` bytes. The cap runs after redaction, so it can only cut a
80
+ * placeholder, never a credential.
81
+ */
82
+ const renderStderr = (current: StderrTail, limit: number): string =>
83
+ tailBytes(`${current.redacted}${current.redactor.peek().join("\n")}`.replace(/\s+/g, " ").trim(), limit)
84
+
85
+ /**
86
+ * Options accepted by {@link connect}.
87
+ *
88
+ * @category models
89
+ * @since 1.0.0-rc.0
90
+ */
91
+ export interface ConnectOptions {
92
+ /** The name this server is known by, for error messages only. */
93
+ readonly server: string
94
+ readonly command: string
95
+ readonly args: ReadonlyArray<string>
96
+ readonly cwd?: string | undefined
97
+ /** Values merged into the bootstrap allowlist rather than the full host environment. */
98
+ readonly env?: Record<string, string | undefined> | undefined
99
+ /** Default deadline for a request/reply exchange. See {@link Transport.defaultRequestTimeoutMs}. */
100
+ readonly requestTimeoutMs?: number | undefined
101
+ /** Maximum number of frames waiting to be written. See {@link defaultQueueCapacity}. */
102
+ readonly queueCapacity?: number | undefined
103
+ /** Maximum UTF-8 bytes accepted in one inbound JSON-RPC frame. See {@link Transport.defaultMaxFrameBytes}. */
104
+ readonly maxFrameBytes?: number | undefined
105
+ /** Maximum UTF-8 bytes emitted in one JSON-RPC frame. See {@link Transport.defaultMaxOutboundFrameBytes}. */
106
+ readonly maxOutboundFrameBytes?: number | undefined
107
+ /** Maximum diagnostic stderr bytes retained in memory. See {@link defaultMaxStderrBytes}. */
108
+ readonly maxStderrBytes?: number | undefined
109
+ }
110
+
111
+ /**
112
+ * Default number of outbound frames allowed to wait in memory.
113
+ *
114
+ * @category constants
115
+ * @since 1.0.0-rc.0
116
+ */
117
+ export const defaultQueueCapacity = 64
118
+
119
+ /**
120
+ * Default maximum child-stderr tail retained for connection diagnostics.
121
+ *
122
+ * @category constants
123
+ * @since 1.0.0-rc.0
124
+ */
125
+ export const defaultMaxStderrBytes = 2048
126
+
127
+ const diagnosticErrorCodes: ReadonlySet<string> = new Set(["spawn_failed", "timeout", "connection_closed"])
128
+ // Exit and stdout EOF can precede the parent's final stderr read. Await the
129
+ // actual drainer, with a finite fallback for a child/descendant holding its
130
+ // stderr pipe open. Caller interruption never has to wait out this budget.
131
+ const terminalStderrDrainMs = 250
132
+
133
+ type Pending = {
134
+ readonly deferred: Deferred.Deferred<unknown, McpError>
135
+ readonly method: string
136
+ }
137
+
138
+ type OutboundFrame = {
139
+ readonly frame: Uint8Array
140
+ readonly request?: {
141
+ readonly id: number
142
+ cancelled: boolean
143
+ dispatched: boolean
144
+ }
145
+ }
146
+
147
+ type ConnectionState = {
148
+ readonly _tag: "Open"
149
+ readonly pending: HashMap.HashMap<number, Pending>
150
+ } | {
151
+ readonly _tag: "Closed"
152
+ readonly error: Deferred.Deferred<McpError>
153
+ }
154
+
155
+ /** Stdout lines, without the blank lines servers commonly emit between frames. */
156
+ const frames = (
157
+ server: string,
158
+ maxFrameBytes: number,
159
+ stream: Stream.Stream<Uint8Array, unknown>
160
+ ): Stream.Stream<string, unknown | McpError> =>
161
+ Transport.lines(server, maxFrameBytes, stream).pipe(Stream.filter((line) => line.trim() !== ""))
162
+
163
+ /**
164
+ * Spawns an MCP server over stdio and returns a live {@link Transport}.
165
+ *
166
+ * The connection is scoped: the writer and reader loops are daemon fibers
167
+ * forked into the calling scope, and closing that scope tears the process
168
+ * down with it. Every request pending when the connection closes fails with
169
+ * `connection_closed` instead of hanging forever.
170
+ *
171
+ * Stdout that does not claim JSON-RPC is ignored because servers commonly log
172
+ * there. Once an object carries its own `jsonrpc` property, a malformed version
173
+ * or reply closes the connection with `protocol_error`.
174
+ *
175
+ * @category constructors
176
+ * @since 1.0.0-rc.0
177
+ */
178
+ export const connect = (
179
+ options: ConnectOptions
180
+ ): Effect.Effect<Transport.Transport, McpError, ChildProcessSpawner | Scope.Scope> =>
181
+ Effect.gen(function*() {
182
+ const diagnostic = yield* DiagnosticReporter.make(options.server)
183
+ const requestTimeoutMs = options.requestTimeoutMs ?? Transport.defaultRequestTimeoutMs
184
+ const queueCapacity = options.queueCapacity ?? defaultQueueCapacity
185
+ const maxFrameBytes = options.maxFrameBytes ?? Transport.defaultMaxFrameBytes
186
+ const maxOutboundFrameBytes = options.maxOutboundFrameBytes ?? Transport.defaultMaxOutboundFrameBytes
187
+ const maxStderrBytes = options.maxStderrBytes ?? defaultMaxStderrBytes
188
+ yield* Limits.checkPositiveIntegers(options.server, [
189
+ ["requestTimeoutMs", requestTimeoutMs],
190
+ ["queueCapacity", queueCapacity],
191
+ ["maxFrameBytes", maxFrameBytes],
192
+ ["maxOutboundFrameBytes", maxOutboundFrameBytes],
193
+ ["maxStderrBytes", maxStderrBytes]
194
+ ])
195
+
196
+ const handle = yield* ChildProcess.make(options.command, options.args, {
197
+ cwd: options.cwd,
198
+ env: ChildProcessEnvironment.make(process.env, options.env),
199
+ extendEnv: false,
200
+ stdin: "pipe",
201
+ stdout: "pipe",
202
+ // Stderr is diagnostic only: a scoped drainer below retains at most the
203
+ // configured byte tail, and stderr failure never fails the connection.
204
+ stderr: "pipe"
205
+ }).pipe(
206
+ Effect.mapError((error) => {
207
+ diagnostic("spawn", error.message)
208
+ return new McpError({
209
+ code: "spawn_failed",
210
+ message: `Failed to start MCP server "${options.server}"; process details withheld`,
211
+ server: options.server
212
+ })
213
+ })
214
+ )
215
+
216
+ // Stderr is redacted a whole line at a time as it arrives, and only the
217
+ // redacted text is capped. Capping raw bytes first could cut a
218
+ // credential's recognizable prefix and leave its remainder unredacted.
219
+ const stderrState = yield* Ref.make<StderrTail>(emptyStderrTail())
220
+ const stderrDecoder = new TextDecoder()
221
+ const stderrDrainer = yield* handle.stderr.pipe(
222
+ Stream.runForEach((chunk) =>
223
+ Ref.update(
224
+ stderrState,
225
+ (current) => appendStderr(current, stderrDecoder.decode(chunk, { stream: true }), maxStderrBytes)
226
+ )
227
+ ),
228
+ Effect.ignore,
229
+ Effect.forkScoped
230
+ )
231
+
232
+ const withStderr = (error: McpError): Effect.Effect<McpError> => {
233
+ if (!diagnosticErrorCodes.has(error.code)) return Effect.succeed(error)
234
+ return Effect.map(Ref.get(stderrState), (current) => {
235
+ const rendered = renderStderr(current, maxStderrBytes)
236
+ if (rendered !== "") diagnostic("stderr", rendered)
237
+ return rendered === ""
238
+ ? error
239
+ : new McpError({
240
+ code: error.code,
241
+ message: `${error.message} (stderr diagnostic withheld)`,
242
+ server: error.server
243
+ })
244
+ })
245
+ }
246
+
247
+ const nextId = yield* Ref.make(0)
248
+ const outbound = yield* Queue.bounded<OutboundFrame>(queueCapacity)
249
+ const terminalError = yield* Deferred.make<McpError>()
250
+ const state = yield* Ref.make<ConnectionState>({ _tag: "Open", pending: HashMap.empty() })
251
+
252
+ // Define these before starting the reader: a server can send a request
253
+ // immediately on connection, before our first outbound request exists.
254
+ const enqueue = (frame: OutboundFrame): Effect.Effect<void, McpError> =>
255
+ Effect.flatMap(Queue.offer(outbound, frame), (offered) =>
256
+ offered
257
+ ? Effect.void
258
+ : Effect.flatMap(Deferred.await(terminalError), Effect.fail))
259
+
260
+ const frameOf = (method: string, message: Rpc.OutboundMessage): Effect.Effect<Uint8Array, McpError> =>
261
+ Effect.try({
262
+ try: () => Rpc.encode(message),
263
+ catch: () =>
264
+ Limits.protocolError(options.server, `MCP server "${options.server}" could not encode a ${method} frame`)
265
+ }).pipe(
266
+ Effect.flatMap((frame) =>
267
+ frame.byteLength <= maxOutboundFrameBytes
268
+ ? Effect.succeed(frame)
269
+ : Effect.fail(
270
+ Limits.protocolError(
271
+ options.server,
272
+ `MCP server "${options.server}" tried to send a ${method} frame larger than ${maxOutboundFrameBytes} bytes`
273
+ )
274
+ )
275
+ )
276
+ )
277
+
278
+ /** Closes once, fails every waiter, and rejects all future traffic. */
279
+ const closeWith = (baseError: McpError, drainStderr = true) =>
280
+ Effect.uninterruptible(Effect.gen(function*() {
281
+ const waiters = yield* Ref.modify(state, (current) =>
282
+ current._tag === "Closed"
283
+ ? [undefined, current] as const
284
+ : [Array.from(HashMap.values(current.pending)), { _tag: "Closed", error: terminalError }] as const)
285
+ if (waiters === undefined) return
286
+ // Stop admission immediately. Pending requests and blocked/new offers
287
+ // share the terminal result, so none can tear down the stderr reader
288
+ // merely because a different process signal won the close race.
289
+ yield* Queue.shutdown(outbound)
290
+ yield* (drainStderr && diagnosticErrorCodes.has(baseError.code)
291
+ ? Fiber.await(stderrDrainer).pipe(
292
+ Effect.asVoid,
293
+ Effect.timeoutOrElse({ duration: terminalStderrDrainMs, orElse: () => Effect.void }),
294
+ Effect.interruptible
295
+ )
296
+ : Effect.void).pipe(Effect.ensuring(Effect.gen(function*() {
297
+ // Even interruption while draining settles every waiter. Awaiting
298
+ // the drainer's fiber does not interrupt that fiber on timeout.
299
+ const error = yield* withStderr(baseError)
300
+ yield* Deferred.succeed(terminalError, error)
301
+ yield* Effect.forEach(waiters, (pending) => Deferred.fail(pending.deferred, error), {
302
+ discard: true
303
+ })
304
+ })))
305
+ }))
306
+
307
+ // Writer: drains outbound frames into the process's stdin for the life of
308
+ // the connection scope. A write failure is the same "connection is gone"
309
+ // fact the reader loop reports, so it collapses pending requests too.
310
+ // Pull one record at a time: batching would mark later records dispatched
311
+ // while an earlier stdin write is still blocked. Check and mark without a
312
+ // yield so request cleanup cannot interleave with the dispatch decision.
313
+ yield* Stream.fromEffectRepeat(Queue.take(outbound)).pipe(
314
+ Stream.filter((record) => {
315
+ if (record.request === undefined) return true
316
+ if (record.request.cancelled) return false
317
+ record.request.dispatched = true
318
+ return true
319
+ }),
320
+ Stream.map((record) => record.frame),
321
+ Stream.run(handle.stdin),
322
+ Effect.onExit((exit) => closeWith(Transport.closed(options.server, "stdin closed"), !Exit.hasInterrupts(exit))),
323
+ Effect.forkScoped
324
+ )
325
+
326
+ // Reader: one line of stdout is one JSON-RPC message. A validated reply
327
+ // resolves its pending request by id; malformed tagged messages close the
328
+ // whole connection, while stdout noise, notifications, and unknown ids drop.
329
+ yield* frames(options.server, maxFrameBytes, handle.stdout).pipe(
330
+ Stream.runForEach((line) =>
331
+ Effect.gen(function*() {
332
+ const message = Rpc.parse(line)
333
+ if (message === undefined) return
334
+ const jsonIssue = JsonLimits.checkParsed(message)
335
+ if (jsonIssue !== undefined) {
336
+ return yield* Effect.fail(
337
+ Limits.protocolError(options.server, `MCP server "${options.server}" sent invalid JSON: ${jsonIssue}`)
338
+ )
339
+ }
340
+ const reply = Rpc.classify(message)
341
+ if (reply._tag === "Notification") return
342
+ if (reply._tag === "Request") {
343
+ const response: Rpc.OutboundMessage = reply.method === "ping"
344
+ ? { jsonrpc: "2.0", id: reply.id, result: {} }
345
+ : { jsonrpc: "2.0", id: reply.id, error: { code: -32_601, message: "Method not found" } }
346
+ // Opposite-direction ids are independent, even when they equal an
347
+ // active tool request's id. Responses share the bounded writer and
348
+ // size guard, never creating another pending request of our own.
349
+ return yield* frameOf("server-response", response).pipe(
350
+ Effect.flatMap((frame) => enqueue({ frame })),
351
+ Effect.timeoutOrElse({
352
+ duration: requestTimeoutMs,
353
+ orElse: () =>
354
+ Effect.fail(Transport.timeout(options.server, "server-response admission", requestTimeoutMs))
355
+ })
356
+ )
357
+ }
358
+ if (reply._tag === "Malformed") {
359
+ return yield* Effect.fail(
360
+ Limits.protocolError(
361
+ options.server,
362
+ `MCP server "${options.server}" sent a malformed JSON-RPC reply: ${reply.reason}`
363
+ )
364
+ )
365
+ }
366
+ if (reply._tag === "UncorrelatedError") {
367
+ diagnostic("remote-error", { code: reply.code, message: reply.message, data: reply.data })
368
+ return
369
+ }
370
+ const pending = yield* Ref.modify(state, (current) => {
371
+ if (current._tag === "Closed") return [Option.none<Pending>(), current] as const
372
+ return [
373
+ HashMap.get(current.pending, reply.id),
374
+ { ...current, pending: HashMap.remove(current.pending, reply.id) }
375
+ ] as const
376
+ })
377
+ if (Option.isNone(pending)) return
378
+ if (reply._tag === "Error") {
379
+ diagnostic("remote-error", { code: reply.code, message: reply.message, data: reply.data })
380
+ yield* Deferred.fail(
381
+ pending.value.deferred,
382
+ Transport.replyError(options.server, pending.value.method, reply)
383
+ )
384
+ } else {
385
+ yield* Deferred.succeed(pending.value.deferred, reply.result)
386
+ }
387
+ })
388
+ ),
389
+ Effect.matchEffect({
390
+ onFailure: (error) =>
391
+ closeWith(
392
+ error instanceof McpError ? error : Transport.closed(options.server, "stdout failed")
393
+ ),
394
+ // A clean EOF is still a closed MCP connection. Node reports an
395
+ // ordinary child exit by ending stdout successfully.
396
+ onSuccess: () => closeWith(Transport.closed(options.server, "stdout closed"))
397
+ }),
398
+ Effect.forkScoped
399
+ )
400
+
401
+ // Some process implementations expose exit before stdout observes EOF.
402
+ // Treat either signal as the same terminal transition.
403
+ yield* handle.exitCode.pipe(
404
+ Effect.flatMap((exitCode) => closeWith(Transport.closed(options.server, `exited with code ${exitCode}`))),
405
+ Effect.catch(() => closeWith(Transport.closed(options.server, "process exited"))),
406
+ Effect.forkScoped
407
+ )
408
+
409
+ // Finalizers run in reverse order. Record scope closure before interrupting
410
+ // the I/O fibers, whose cleanup must not replace it with "stdin closed".
411
+ // Scope closure also tears down the child, so no cancellation is needed.
412
+ yield* Effect.addFinalizer(() => closeWith(Transport.closed(options.server, "connection scope closed"), false))
413
+
414
+ const takePending = (id: number): Effect.Effect<boolean> =>
415
+ Ref.modify(state, (current) => {
416
+ if (current._tag === "Closed") return [false, current]
417
+ const present = HashMap.has(current.pending, id)
418
+ return [present, { ...current, pending: HashMap.remove(current.pending, id) }]
419
+ })
420
+
421
+ const request = (
422
+ method: string,
423
+ params?: unknown,
424
+ timeoutMs = requestTimeoutMs
425
+ ): Effect.Effect<unknown, McpError> =>
426
+ Effect.gen(function*() {
427
+ if (!Limits.isPositiveInteger(timeoutMs)) {
428
+ return yield* Effect.fail(
429
+ Limits.protocolError(options.server, "MCP request timeout must be a positive integer")
430
+ )
431
+ }
432
+ const id = yield* Ref.updateAndGet(nextId, (n) => n + 1)
433
+ const deferred = yield* Deferred.make<unknown, McpError>()
434
+ const frame = yield* frameOf(method, { jsonrpc: "2.0", id, method, params })
435
+ const requestState = { id, cancelled: false, dispatched: false }
436
+ return yield* Effect.gen(function*() {
437
+ const registration = yield* Ref.modify(state, (current) =>
438
+ current._tag === "Closed"
439
+ ? [current.error, current] as const
440
+ : [undefined, {
441
+ ...current,
442
+ pending: HashMap.set(current.pending, id, { deferred, method })
443
+ }] as const)
444
+ if (registration !== undefined) return yield* Effect.flatMap(Deferred.await(registration), Effect.fail)
445
+ yield* enqueue({ frame, request: requestState })
446
+ return yield* Deferred.await(deferred)
447
+ }).pipe(
448
+ Effect.ensuring(Effect.gen(function*() {
449
+ requestState.cancelled = true
450
+ const pending = yield* takePending(id)
451
+ if (!requestState.dispatched || !pending || method === "initialize") return
452
+ yield* frameOf("notifications/cancelled", {
453
+ jsonrpc: "2.0",
454
+ method: "notifications/cancelled",
455
+ params: { requestId: requestState.id, reason: Transport.cancellationReason }
456
+ }).pipe(
457
+ // Best-effort cancellation cannot delay the deadline it reports.
458
+ Effect.flatMap((frame) => Effect.sync(() => Queue.offerUnsafe(outbound, { frame }))),
459
+ Effect.ignore
460
+ )
461
+ })),
462
+ Effect.timeoutOrElse({
463
+ duration: timeoutMs,
464
+ orElse: () => Effect.flatMap(withStderr(Transport.timeout(options.server, method, timeoutMs)), Effect.fail)
465
+ })
466
+ )
467
+ })
468
+
469
+ const notify = (
470
+ method: string,
471
+ params?: unknown,
472
+ timeoutMs = requestTimeoutMs
473
+ ): Effect.Effect<void, McpError> => {
474
+ if (!Limits.isPositiveInteger(timeoutMs)) {
475
+ return Effect.fail(Limits.protocolError(options.server, "MCP notification timeout must be a positive integer"))
476
+ }
477
+ return Effect.gen(function*() {
478
+ const current = yield* Ref.get(state)
479
+ if (current._tag === "Closed") return yield* Effect.flatMap(Deferred.await(current.error), Effect.fail)
480
+ const frame = yield* frameOf(method, { jsonrpc: "2.0", method, params })
481
+ yield* enqueue({ frame })
482
+ }).pipe(
483
+ Effect.timeoutOrElse({
484
+ duration: timeoutMs,
485
+ orElse: () => Effect.flatMap(withStderr(Transport.timeout(options.server, method, timeoutMs)), Effect.fail)
486
+ })
487
+ )
488
+ }
489
+
490
+ return { request, notify }
491
+ })
@@ -0,0 +1,178 @@
1
+ /**
2
+ * The request/notify surface both MCP transports provide, and the failure
3
+ * wording they share. {@link McpClient} speaks to this interface only; stdio
4
+ * and Streamable HTTP differ in how a frame travels, not in what it means.
5
+ *
6
+ * @since 1.0.0-rc.1
7
+ */
8
+
9
+ import { Effect, Stream } from "effect"
10
+ import { McpError } from "../McpError.ts"
11
+ import * as Limits from "./Limits.ts"
12
+ import type * as Rpc from "./Rpc.ts"
13
+
14
+ /**
15
+ * One live connection to an MCP server.
16
+ *
17
+ * @category models
18
+ * @since 1.0.0-rc.0
19
+ */
20
+ export interface Transport {
21
+ /** Sends a request and resolves with its `result`, or fails with the server's `error`. */
22
+ readonly request: (method: string, params?: unknown, timeoutMs?: number) => Effect.Effect<unknown, McpError>
23
+ /** Sends a notification, bounding delivery by the optional positive-integer deadline. */
24
+ readonly notify: (method: string, params?: unknown, timeoutMs?: number) => Effect.Effect<void, McpError>
25
+ }
26
+
27
+ /**
28
+ * Default request deadline.
29
+ *
30
+ * @category constants
31
+ * @since 1.0.0-rc.0
32
+ */
33
+ export const defaultRequestTimeoutMs = 120_000
34
+
35
+ /**
36
+ * Default maximum inbound JSON-RPC frame size (one MiB).
37
+ *
38
+ * @category constants
39
+ * @since 1.0.0-rc.0
40
+ */
41
+ export const defaultMaxFrameBytes = 1024 * 1024
42
+
43
+ /**
44
+ * Default maximum outbound JSON-RPC frame size (one MiB).
45
+ *
46
+ * @category constants
47
+ * @since 1.0.0-rc.0
48
+ */
49
+ export const defaultMaxOutboundFrameBytes = 1024 * 1024
50
+
51
+ /**
52
+ * The reason sent with a best-effort `notifications/cancelled`.
53
+ *
54
+ * @category constants
55
+ * @since 1.0.0-rc.1
56
+ */
57
+ export const cancellationReason = "request no longer awaited"
58
+
59
+ /**
60
+ * A `connection_closed` failure naming the server.
61
+ *
62
+ * @category errors
63
+ * @since 1.0.0-rc.1
64
+ */
65
+ export const closed = (server: string, reason: string): McpError =>
66
+ new McpError({ code: "connection_closed", message: `MCP server "${server}" ${reason}`, server })
67
+
68
+ /**
69
+ * A `timeout` failure naming the server, method and deadline.
70
+ *
71
+ * @category errors
72
+ * @since 1.0.0-rc.1
73
+ */
74
+ export const timeout = (server: string, method: string, timeoutMs: number): McpError =>
75
+ new McpError({
76
+ code: "timeout",
77
+ message: `MCP server "${server}" did not answer ${method} within ${timeoutMs}ms`,
78
+ server
79
+ })
80
+
81
+ /**
82
+ * The model-facing failure for a correlated JSON-RPC error reply. Remote text
83
+ * is withheld; the caller reports it to Diagnostics.
84
+ *
85
+ * @category errors
86
+ * @since 1.0.0-rc.1
87
+ */
88
+ export const replyError = (
89
+ server: string,
90
+ method: string,
91
+ reply: Extract<Rpc.Reply, { readonly _tag: "Error" }>
92
+ ): McpError => {
93
+ // Servers do not standardize unknown-tool prose, so this heuristic stays
94
+ // limited to the two MCP error codes and an explicit tool plus absence phrase.
95
+ const remoteUnknownTool = (reply.code === -32_601 || reply.code === -32_602) &&
96
+ /\btool\b/i.test(reply.message) &&
97
+ /\b(?:unknown|unrecognized|no such|not found)\b/i.test(reply.message)
98
+ return new McpError({
99
+ code: method === "tools/call"
100
+ ? remoteUnknownTool ? "tool_not_found" : "tool_failed"
101
+ : "protocol_error",
102
+ message: `MCP server "${server}" failed ${method} (${reply.code}); remote details withheld`,
103
+ server
104
+ })
105
+ }
106
+
107
+ /**
108
+ * Splits a byte stream into lines in linear time, retaining one bounded
109
+ * partial line. Lines end at LF, with a CR before the LF dropped; with
110
+ * `crTerminates`, as server-sent events require, a lone CR also ends a line.
111
+ * Blank lines are kept: server-sent events use them as delimiters.
112
+ *
113
+ * @category constructors
114
+ * @since 1.0.0-rc.1
115
+ */
116
+ export const lines = <E>(
117
+ server: string,
118
+ maxLineBytes: number,
119
+ stream: Stream.Stream<Uint8Array, E>,
120
+ options: { readonly crTerminates?: boolean } = {}
121
+ ): Stream.Stream<string, E | McpError> => {
122
+ type PartialLine = { pieces: Array<Uint8Array>; bytes: number; skipLf: boolean }
123
+ const crTerminates = options.crTerminates === true
124
+ const decoder = new TextDecoder()
125
+ const decode = (partial: PartialLine): string => {
126
+ const joined = new Uint8Array(partial.bytes)
127
+ let offset = 0
128
+ for (const piece of partial.pieces) {
129
+ joined.set(piece, offset)
130
+ offset += piece.byteLength
131
+ }
132
+ const end = joined[partial.bytes - 1] === 0x0d ? partial.bytes - 1 : partial.bytes
133
+ return decoder.decode(joined.subarray(0, end))
134
+ }
135
+ const terminator = (chunk: Uint8Array, from: number): number => {
136
+ if (!crTerminates) return chunk.indexOf(0x0a, from)
137
+ for (let index = from; index < chunk.byteLength; index += 1) {
138
+ if (chunk[index] === 0x0a || chunk[index] === 0x0d) return index
139
+ }
140
+ return -1
141
+ }
142
+ const tooLong = () => Effect.fail(Limits.protocolError(server, `MCP frame exceeded ${maxLineBytes} bytes`))
143
+ return stream.pipe(
144
+ Stream.mapAccumEffect(
145
+ (): PartialLine => ({ pieces: [], bytes: 0, skipLf: false }),
146
+ (partial, chunk) => {
147
+ const complete: Array<string> = []
148
+ const append = (piece: Uint8Array): boolean => {
149
+ if (piece.byteLength === 0) return true
150
+ const bytes = partial.bytes + piece.byteLength
151
+ // A final CR may be the first half of CRLF. Allow that one byte
152
+ // beyond the cap, but count it if more frame content follows.
153
+ const contentBytes = bytes - (piece[piece.byteLength - 1] === 0x0d ? 1 : 0)
154
+ if (contentBytes > maxLineBytes) return false
155
+ partial.pieces.push(piece)
156
+ partial.bytes = bytes
157
+ return true
158
+ }
159
+ // The LF of a CRLF split across chunks ends nothing new.
160
+ let start = partial.skipLf && chunk[0] === 0x0a ? 1 : 0
161
+ partial.skipLf = false
162
+ for (let end = terminator(chunk, start); end !== -1; end = terminator(chunk, start)) {
163
+ if (!append(chunk.subarray(start, end))) return tooLong()
164
+ complete.push(decode(partial))
165
+ partial = { pieces: [], bytes: 0, skipLf: false }
166
+ start = end + 1
167
+ if (chunk[end] === 0x0d) {
168
+ if (start === chunk.byteLength) partial.skipLf = true
169
+ else if (chunk[start] === 0x0a) start += 1
170
+ }
171
+ }
172
+ if (!append(chunk.subarray(start))) return tooLong()
173
+ return Effect.succeed([partial, complete] as const)
174
+ },
175
+ { onHalt: (partial) => partial.bytes === 0 ? [] : [decode(partial)] }
176
+ )
177
+ )
178
+ }