@effect-uai/mcp 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/dist/Client.d.mts +55 -0
  3. package/dist/Client.d.mts.map +1 -0
  4. package/dist/Client.mjs +85 -0
  5. package/dist/Client.mjs.map +1 -0
  6. package/dist/McpError-DWg9BlW5.d.mts +83 -0
  7. package/dist/McpError-DWg9BlW5.d.mts.map +1 -0
  8. package/dist/McpError.d.mts +2 -0
  9. package/dist/McpError.mjs +61 -0
  10. package/dist/McpError.mjs.map +1 -0
  11. package/dist/Toolkit.d.mts +20 -0
  12. package/dist/Toolkit.d.mts.map +1 -0
  13. package/dist/Toolkit.mjs +70 -0
  14. package/dist/Toolkit.mjs.map +1 -0
  15. package/dist/auth-ZDvsxpCo.d.mts +126 -0
  16. package/dist/auth-ZDvsxpCo.d.mts.map +1 -0
  17. package/dist/index.d.mts +4 -0
  18. package/dist/index.mjs +4 -0
  19. package/dist/internal/auth.d.mts +2 -0
  20. package/dist/internal/auth.mjs +25 -0
  21. package/dist/internal/auth.mjs.map +1 -0
  22. package/dist/internal/httpTransport.d.mts +17 -0
  23. package/dist/internal/httpTransport.d.mts.map +1 -0
  24. package/dist/internal/httpTransport.mjs +88 -0
  25. package/dist/internal/httpTransport.mjs.map +1 -0
  26. package/dist/internal/protocol.d.mts +2 -0
  27. package/dist/internal/protocol.mjs +23 -0
  28. package/dist/internal/protocol.mjs.map +1 -0
  29. package/dist/internal/protocols/2025-06-18.d.mts +11 -0
  30. package/dist/internal/protocols/2025-06-18.d.mts.map +1 -0
  31. package/dist/internal/protocols/2025-06-18.mjs +70 -0
  32. package/dist/internal/protocols/2025-06-18.mjs.map +1 -0
  33. package/dist/internal/protocols/2025-11-25.d.mts +2 -0
  34. package/dist/internal/protocols/2025-11-25.mjs +2 -0
  35. package/dist/internal/protocols/2026-07-28.d.mts +16 -0
  36. package/dist/internal/protocols/2026-07-28.d.mts.map +1 -0
  37. package/dist/internal/protocols/2026-07-28.mjs +83 -0
  38. package/dist/internal/protocols/2026-07-28.mjs.map +1 -0
  39. package/dist/internal/rpc.d.mts +2 -0
  40. package/dist/internal/rpc.mjs +78 -0
  41. package/dist/internal/rpc.mjs.map +1 -0
  42. package/dist/internal/schema.d.mts +2 -0
  43. package/dist/internal/schema.mjs +168 -0
  44. package/dist/internal/schema.mjs.map +1 -0
  45. package/dist/internal/stdioTransport.d.mts +15 -0
  46. package/dist/internal/stdioTransport.d.mts.map +1 -0
  47. package/dist/internal/stdioTransport.mjs +46 -0
  48. package/dist/internal/stdioTransport.mjs.map +1 -0
  49. package/dist/protocol-kjC3iAMb.d.mts +35 -0
  50. package/dist/protocol-kjC3iAMb.d.mts.map +1 -0
  51. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  52. package/dist/rpc-Bq95Lio9.d.mts +34 -0
  53. package/dist/rpc-Bq95Lio9.d.mts.map +1 -0
  54. package/dist/schema-D2oUNuo5.d.mts +245 -0
  55. package/dist/schema-D2oUNuo5.d.mts.map +1 -0
  56. package/package.json +70 -0
  57. package/src/Client.ts +180 -0
  58. package/src/McpError.ts +93 -0
  59. package/src/Toolkit.ts +109 -0
  60. package/src/index.ts +3 -0
  61. package/src/internal/auth.ts +48 -0
  62. package/src/internal/httpTransport.ts +171 -0
  63. package/src/internal/protocol.ts +67 -0
  64. package/src/internal/protocols/2025-06-18.ts +110 -0
  65. package/src/internal/protocols/2025-11-25.ts +8 -0
  66. package/src/internal/protocols/2026-07-28.test.ts +131 -0
  67. package/src/internal/protocols/2026-07-28.ts +152 -0
  68. package/src/internal/rpc.test.ts +156 -0
  69. package/src/internal/rpc.ts +189 -0
  70. package/src/internal/schema.ts +223 -0
  71. package/src/internal/stdioTransport.ts +63 -0
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Era-blind JSON-RPC 2.0 correlation core over a `Transport` (cdp.ts model):
3
+ * pending-`Deferred` map keyed by id, reader fiber that fails every pending
4
+ * request on transport close. Era behavior layers on top in era.ts.
5
+ */
6
+ import { Cause, Deferred, Effect, HashMap, Option, Ref, type Scope, Stream } from "effect"
7
+ import * as JSONL from "@effect-uai/core/JSONL"
8
+ import { type McpError, McpProtocolError, McpTransportClosed } from "../McpError.js"
9
+ import {
10
+ decodeInboundMessage,
11
+ type InboundMessage,
12
+ type JsonRpcId,
13
+ errorFrame,
14
+ notificationFrame,
15
+ requestFrame,
16
+ resultFrame,
17
+ } from "./schema.js"
18
+
19
+ /** Per-request transport hints (era headers on HTTP; stdio ignores them). */
20
+ export type SendMeta = {
21
+ readonly headers?: Record<string, string>
22
+ }
23
+
24
+ /** The transport seam: one framed JSON message per `send` / `messages` element. */
25
+ export type Transport = {
26
+ readonly send: (frame: string, meta?: SendMeta) => Effect.Effect<void, McpError>
27
+ readonly messages: Stream.Stream<string, McpError>
28
+ }
29
+
30
+ /** A server-initiated request (has `id`) or notification (no `id`). */
31
+ export type Inbound = {
32
+ readonly id: Option.Option<JsonRpcId>
33
+ readonly method: string
34
+ readonly params: unknown
35
+ }
36
+
37
+ export type McpConnection = {
38
+ readonly request: (
39
+ method: string,
40
+ params?: unknown,
41
+ meta?: SendMeta,
42
+ ) => Effect.Effect<unknown, McpError>
43
+ readonly notify: (
44
+ method: string,
45
+ params?: unknown,
46
+ meta?: SendMeta,
47
+ ) => Effect.Effect<void, McpError>
48
+ /** Answer a server-initiated request (legacy era only). */
49
+ readonly respond: (id: JsonRpcId, result: unknown) => Effect.Effect<void, McpError>
50
+ readonly respondError: (
51
+ id: JsonRpcId,
52
+ code: number,
53
+ message: string,
54
+ ) => Effect.Effect<void, McpError>
55
+ }
56
+
57
+ type Pending = {
58
+ readonly method: string
59
+ readonly deferred: Deferred.Deferred<unknown, McpError>
60
+ }
61
+
62
+ const replyError = (method: string, error: NonNullable<InboundMessage["error"]>): McpError =>
63
+ new McpProtocolError({
64
+ method,
65
+ code: error.code,
66
+ ...(error.message !== undefined ? { reason: error.message } : {}),
67
+ raw: error,
68
+ })
69
+
70
+ /**
71
+ * Open a scoped connection over an already-open transport. `onInbound`
72
+ * receives server-initiated frames; era.ts answers them via `transport.send`.
73
+ */
74
+ export const open = (
75
+ transport: Transport,
76
+ onInbound: (inbound: Inbound) => Effect.Effect<void>,
77
+ ): Effect.Effect<McpConnection, never, Scope.Scope> =>
78
+ Effect.gen(function* () {
79
+ const pending = yield* Ref.make(HashMap.empty<JsonRpcId, Pending>())
80
+ const counter = yield* Ref.make(0)
81
+ const closeCause = yield* Ref.make<unknown>(undefined)
82
+
83
+ // Fail every in-flight request with one error. Shared by transport close
84
+ // and by unattributable server errors: a caller must never be left
85
+ // awaiting a reply that cannot arrive.
86
+ const failAllPending = (toError: (method: string) => McpError): Effect.Effect<void> =>
87
+ Ref.getAndSet(pending, HashMap.empty()).pipe(
88
+ Effect.flatMap((taken) =>
89
+ Effect.forEach(HashMap.values(taken), ({ deferred, method }) =>
90
+ Deferred.fail(deferred, toError(method)),
91
+ ),
92
+ ),
93
+ Effect.asVoid,
94
+ )
95
+
96
+ // An error no pending request can claim: a null id (JSON-RPC raises one
97
+ // before the request could be attributed) or an id we never issued. It
98
+ // correlates to nothing, so everything in flight takes it rather than
99
+ // waiting for a reply that cannot arrive. A *result* nobody claims is a
100
+ // late or duplicate reply, and is simply dropped.
101
+ const unattributable = (error: InboundMessage["error"]): Effect.Effect<void> =>
102
+ error === undefined ? Effect.void : failAllPending((method) => replyError(method, error))
103
+
104
+ const settle = ({ deferred, method }: Pending, message: InboundMessage): Effect.Effect<void> =>
105
+ Effect.asVoid(
106
+ message.error === undefined
107
+ ? Deferred.succeed(deferred, message.result ?? {})
108
+ : Deferred.fail(deferred, replyError(method, message.error)),
109
+ )
110
+
111
+ const reply = (id: JsonRpcId, message: InboundMessage): Effect.Effect<void> =>
112
+ Ref.modify(pending, (m) => [HashMap.get(m, id), HashMap.remove(m, id)]).pipe(
113
+ Effect.flatMap(
114
+ Option.match({
115
+ onNone: () => unattributable(message.error),
116
+ onSome: (entry) => settle(entry, message),
117
+ }),
118
+ ),
119
+ )
120
+
121
+ // A frame carrying a `method` is server-initiated; anything else is a
122
+ // reply, correlated by id.
123
+ const route = (message: InboundMessage): Effect.Effect<void> =>
124
+ Option.match(Option.fromNullishOr(message.method), {
125
+ onNone: () =>
126
+ Option.match(Option.fromNullishOr(message.id), {
127
+ onNone: () => unattributable(message.error),
128
+ onSome: (id) => reply(id, message),
129
+ }),
130
+ onSome: (method) =>
131
+ onInbound({ id: Option.fromNullishOr(message.id), method, params: message.params }),
132
+ })
133
+
134
+ // Unparseable and undecodable frames are dropped: one bad frame must not
135
+ // end an otherwise healthy connection.
136
+ const dispatch = (raw: string): Effect.Effect<void> =>
137
+ JSONL.parseSafe(raw).pipe(
138
+ Effect.flatMap((json) => Effect.option(decodeInboundMessage(json))),
139
+ Effect.flatMap(Option.match({ onNone: () => Effect.void, onSome: route })),
140
+ )
141
+
142
+ // Fail every still-pending request on transport close so callers never hang.
143
+ const failPending = Effect.gen(function* () {
144
+ const raw = yield* Ref.get(closeCause)
145
+ yield* failAllPending(
146
+ (method) => new McpTransportClosed({ method, reason: "connection closed", raw }),
147
+ )
148
+ })
149
+ yield* transport.messages.pipe(
150
+ Stream.runForEach(dispatch),
151
+ Effect.tapCause((cause) => Ref.set(closeCause, Cause.squash(cause))),
152
+ Effect.ensuring(failPending),
153
+ Effect.forkScoped,
154
+ )
155
+
156
+ const request = (
157
+ method: string,
158
+ params?: unknown,
159
+ meta?: SendMeta,
160
+ ): Effect.Effect<unknown, McpError> =>
161
+ Effect.gen(function* () {
162
+ const id: JsonRpcId = yield* Ref.updateAndGet(counter, (n) => n + 1)
163
+ const deferred = yield* Deferred.make<unknown, McpError>()
164
+ yield* Ref.update(pending, (m) => HashMap.set(m, id, { method, deferred }))
165
+ // `ensuring` reclaims the entry on send failure and interruption; on
166
+ // success dispatch has already removed it.
167
+ return yield* transport.send(requestFrame(id, method, params), meta).pipe(
168
+ Effect.flatMap(() => Deferred.await(deferred)),
169
+ Effect.ensuring(Ref.update(pending, (m) => HashMap.remove(m, id))),
170
+ )
171
+ })
172
+
173
+ const notify = (
174
+ method: string,
175
+ params?: unknown,
176
+ meta?: SendMeta,
177
+ ): Effect.Effect<void, McpError> => transport.send(notificationFrame(method, params), meta)
178
+
179
+ const respond = (id: JsonRpcId, result: unknown): Effect.Effect<void, McpError> =>
180
+ transport.send(resultFrame(id, result))
181
+
182
+ const respondError = (
183
+ id: JsonRpcId,
184
+ code: number,
185
+ message: string,
186
+ ): Effect.Effect<void, McpError> => transport.send(errorFrame(id, code, message))
187
+
188
+ return { request, notify, respond, respondError }
189
+ })
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Self-contained MCP wire schemas, both protocol eras. The single source of
3
+ * truth for the wire format; nothing MCP-shaped is imported from effect.
4
+ * Decodes are deliberately loose (unknown extra fields pass through) so
5
+ * spec-compliant servers with richer payloads never fail the client.
6
+ */
7
+ import { Option, Schema } from "effect"
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Protocol versions
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /** Every protocol version this client can speak. */
14
+ export const ProtocolVersion = Schema.Literals(["2026-07-28", "2025-11-25", "2025-06-18"])
15
+ export type ProtocolVersion = typeof ProtocolVersion.Type
16
+
17
+ /** Newest version we speak: the stateless protocol, and what the probe tries first. */
18
+ export const LATEST_VERSION = "2026-07-28" satisfies ProtocolVersion
19
+
20
+ /** The legacy handshake era we negotiate; 2025-11-25 is wire-compatible. */
21
+ export const LEGACY_VERSION = "2025-06-18" satisfies ProtocolVersion
22
+
23
+ const isProtocolVersion = Schema.is(ProtocolVersion)
24
+
25
+ /** Narrow a server-offered version string to one we support. */
26
+ export const asProtocolVersion = (raw: string): Option.Option<ProtocolVersion> =>
27
+ isProtocolVersion(raw) ? Option.some(raw) : Option.none()
28
+
29
+ /**
30
+ * The JSON-RPC methods this client issues. Not to be confused with an HTTP
31
+ * method: on Streamable HTTP every one of these travels as a POST, and the
32
+ * name is mirrored into the `Mcp-Method` header.
33
+ */
34
+ export const McpMethod = Schema.Literals([
35
+ "server/discover",
36
+ "initialize",
37
+ "tools/list",
38
+ "tools/call",
39
+ ])
40
+ export type McpMethod = typeof McpMethod.Type
41
+
42
+ /** Modern `_meta` keys (SEP-2575): every request self-describes its protocol. */
43
+ export const META_PROTOCOL_VERSION = "io.modelcontextprotocol/protocolVersion"
44
+ export const META_CLIENT_INFO = "io.modelcontextprotocol/clientInfo"
45
+ export const META_CLIENT_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities"
46
+ /** Servers identify themselves in each *result's* `_meta`, not at the top level. */
47
+ export const META_SERVER_INFO = "io.modelcontextprotocol/serverInfo"
48
+
49
+ export const CLIENT_INFO = { name: "@effect-uai/mcp", version: "0.12.1" }
50
+
51
+ /** Modern JSON-RPC error codes. */
52
+ export const CODE_HEADER_MISMATCH = -32020
53
+ export const CODE_MISSING_CLIENT_CAPABILITY = -32021
54
+ export const CODE_UNSUPPORTED_PROTOCOL_VERSION = -32022
55
+ export const CODE_METHOD_NOT_FOUND = -32601
56
+
57
+ /**
58
+ * A reply carrying one of these proves the server speaks a modern version, so
59
+ * era detection corrects and retries instead of falling back to `initialize`.
60
+ */
61
+ export const MODERN_ERROR_CODES: ReadonlySet<number> = new Set([
62
+ CODE_HEADER_MISMATCH,
63
+ CODE_MISSING_CLIENT_CAPABILITY,
64
+ CODE_UNSUPPORTED_PROTOCOL_VERSION,
65
+ ])
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // JSON-RPC envelope
69
+ // ---------------------------------------------------------------------------
70
+
71
+ export const JsonRpcId = Schema.Union([Schema.Number, Schema.String])
72
+ export type JsonRpcId = typeof JsonRpcId.Type
73
+
74
+ export const JsonRpcErrorObject = Schema.Struct({
75
+ code: Schema.Number,
76
+ message: Schema.optional(Schema.String),
77
+ data: Schema.optional(Schema.Unknown),
78
+ })
79
+ export type JsonRpcErrorObject = typeof JsonRpcErrorObject.Type
80
+
81
+ /**
82
+ * Any inbound frame: a reply (`id` + `result`/`error`), a server-initiated
83
+ * request (`method` + `id`), or a notification (`method`, no `id`). One
84
+ * loose struct so the reader can classify without a union decode.
85
+ */
86
+ export const InboundMessage = Schema.Struct({
87
+ // Null is legal and meaningful: JSON-RPC uses it for errors raised before a
88
+ // request could be attributed. Rejecting it would drop the frame entirely.
89
+ id: Schema.optional(Schema.NullOr(JsonRpcId)),
90
+ result: Schema.optional(Schema.Unknown),
91
+ error: Schema.optional(JsonRpcErrorObject),
92
+ method: Schema.optional(Schema.String),
93
+ params: Schema.optional(Schema.Unknown),
94
+ })
95
+ export type InboundMessage = typeof InboundMessage.Type
96
+
97
+ export const decodeInboundMessage = Schema.decodeUnknownEffect(InboundMessage)
98
+
99
+ // --- outbound frame builders -----------------------------------------------
100
+
101
+ export const requestFrame = (id: JsonRpcId, method: string, params: unknown): string =>
102
+ JSON.stringify({ jsonrpc: "2.0", id, method, params: params ?? {} })
103
+
104
+ export const notificationFrame = (method: string, params?: unknown): string =>
105
+ JSON.stringify(
106
+ params === undefined ? { jsonrpc: "2.0", method } : { jsonrpc: "2.0", method, params },
107
+ )
108
+
109
+ export const resultFrame = (id: JsonRpcId, result: unknown): string =>
110
+ JSON.stringify({ jsonrpc: "2.0", id, result })
111
+
112
+ export const errorFrame = (id: JsonRpcId, code: number, message: string): string =>
113
+ JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } })
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Shared: tools
117
+ // ---------------------------------------------------------------------------
118
+
119
+ export const ToolInfo = Schema.Struct({
120
+ name: Schema.String,
121
+ description: Schema.optional(Schema.String),
122
+ inputSchema: Schema.Record(Schema.String, Schema.Unknown),
123
+ })
124
+ export type ToolInfo = typeof ToolInfo.Type
125
+
126
+ /**
127
+ * Modern list results may carry cache metadata (`ttlMs` / `cacheScope`);
128
+ * v1 reads but does not act on them (input for a later refresh feature).
129
+ */
130
+ export const ListToolsResult = Schema.Struct({
131
+ tools: Schema.Array(ToolInfo),
132
+ nextCursor: Schema.optional(Schema.String),
133
+ ttlMs: Schema.optional(Schema.Number),
134
+ cacheScope: Schema.optional(Schema.String),
135
+ })
136
+ export type ListToolsResult = typeof ListToolsResult.Type
137
+
138
+ export const decodeListToolsResult = Schema.decodeUnknownEffect(ListToolsResult)
139
+
140
+ // --- content blocks ---------------------------------------------------------
141
+
142
+ export const TextContent = Schema.Struct({
143
+ type: Schema.Literal("text"),
144
+ text: Schema.String,
145
+ })
146
+ export type TextContent = typeof TextContent.Type
147
+
148
+ /** Non-text blocks (image / audio / resource) are placeholder-summarized in v1. */
149
+ export const OtherContent = Schema.Struct({
150
+ type: Schema.String,
151
+ })
152
+
153
+ export const ContentBlock = Schema.Union([TextContent, OtherContent])
154
+ export type ContentBlock = typeof ContentBlock.Type
155
+
156
+ export const isTextContent = Schema.is(TextContent)
157
+
158
+ export const CallToolResult = Schema.Struct({
159
+ content: Schema.optionalKey(Schema.Array(ContentBlock)),
160
+ isError: Schema.optional(Schema.Boolean),
161
+ structuredContent: Schema.optional(Schema.Unknown),
162
+ // Required on modern results; absent on legacy ones.
163
+ resultType: Schema.optional(Schema.String),
164
+ })
165
+ export type CallToolResult = typeof CallToolResult.Type
166
+
167
+ export const decodeCallToolResult = Schema.decodeUnknownEffect(CallToolResult)
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Modern era (2026-07-28)
171
+ // ---------------------------------------------------------------------------
172
+
173
+ export const ServerInfo = Schema.Struct({
174
+ name: Schema.String,
175
+ version: Schema.optional(Schema.String),
176
+ title: Schema.optional(Schema.String),
177
+ })
178
+ export type ServerInfo = typeof ServerInfo.Type
179
+
180
+ /** The subset of a result's `_meta` we read. */
181
+ export const ResultMeta = Schema.Struct({
182
+ [META_SERVER_INFO]: Schema.optional(ServerInfo),
183
+ })
184
+ export type ResultMeta = typeof ResultMeta.Type
185
+
186
+ export const DiscoverResult = Schema.Struct({
187
+ supportedVersions: Schema.Array(Schema.String),
188
+ capabilities: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
189
+ // Spec-conformant servers put identity in `_meta`; a top-level `serverInfo`
190
+ // is accepted too, since that is where the legacy handshake carries it.
191
+ _meta: Schema.optional(ResultMeta),
192
+ serverInfo: Schema.optional(ServerInfo),
193
+ instructions: Schema.optional(Schema.String),
194
+ })
195
+ export type DiscoverResult = typeof DiscoverResult.Type
196
+
197
+ export const decodeDiscoverResult = Schema.decodeUnknownEffect(DiscoverResult)
198
+
199
+ /**
200
+ * `data` of a `-32022` UnsupportedProtocolVersionError reply. Decoded, not
201
+ * hand-parsed: the shape is server-controlled and may carry extra fields.
202
+ */
203
+ export const UnsupportedVersionData = Schema.Struct({
204
+ supported: Schema.optional(Schema.Array(Schema.String)),
205
+ requested: Schema.optional(Schema.NullOr(Schema.String)),
206
+ })
207
+ export type UnsupportedVersionData = typeof UnsupportedVersionData.Type
208
+
209
+ export const decodeUnsupportedVersionData = Schema.decodeUnknownOption(UnsupportedVersionData)
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Legacy era (2025-06-18 / 2025-11-25)
213
+ // ---------------------------------------------------------------------------
214
+
215
+ export const InitializeResult = Schema.Struct({
216
+ protocolVersion: Schema.String,
217
+ capabilities: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
218
+ serverInfo: Schema.optional(ServerInfo),
219
+ instructions: Schema.optional(Schema.String),
220
+ })
221
+ export type InitializeResult = typeof InitializeResult.Type
222
+
223
+ export const decodeInitializeResult = Schema.decodeUnknownEffect(InitializeResult)
@@ -0,0 +1,63 @@
1
+ /**
2
+ * stdio `Transport`: spawn the server, frame stdout as JSONL (one JSON message
3
+ * per line). Scoped, so the child is killed on scope close. Era-blind; the
4
+ * `meta.headers` a caller passes are HTTP-only and ignored here.
5
+ */
6
+ import { Cause, Effect, Queue, type Scope, Stream } from "effect"
7
+ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
8
+ import * as JSONL from "@effect-uai/core/JSONL"
9
+ import { McpConnectFailed, type McpError, McpTransportClosed } from "../McpError.js"
10
+ import type { Transport } from "./rpc.js"
11
+
12
+ export type StdioConfig = {
13
+ readonly command: string
14
+ readonly args?: ReadonlyArray<string>
15
+ readonly env?: Record<string, string>
16
+ readonly cwd?: string
17
+ }
18
+
19
+ const encoder = new TextEncoder()
20
+
21
+ export const make = (
22
+ config: StdioConfig,
23
+ ): Effect.Effect<Transport, McpError, Scope.Scope | ChildProcessSpawner.ChildProcessSpawner> =>
24
+ Effect.gen(function* () {
25
+ // Outbound frames go through a queue rendered as the child's stdin stream;
26
+ // `Queue.end` on scope close lets the child see a clean EOF.
27
+ const outbound = yield* Queue.make<Uint8Array, Cause.Done>()
28
+ const stdin = Stream.fromQueue(outbound)
29
+
30
+ const handle = yield* ChildProcess.make(config.command, [...(config.args ?? [])], {
31
+ ...(config.cwd !== undefined ? { cwd: config.cwd } : {}),
32
+ ...(config.env !== undefined ? { env: config.env } : {}),
33
+ stdin: { stream: stdin, endOnDone: true },
34
+ stdout: "pipe",
35
+ stderr: "pipe",
36
+ killSignal: "SIGTERM",
37
+ forceKillAfter: "2 seconds",
38
+ }).pipe(
39
+ Effect.mapError(
40
+ (cause) =>
41
+ new McpConnectFailed({ reason: `could not spawn ${config.command}`, raw: cause }),
42
+ ),
43
+ )
44
+ yield* Effect.addFinalizer(() => Queue.end(outbound))
45
+
46
+ const send = (frame: string): Effect.Effect<void, McpError> =>
47
+ Queue.offer(outbound, encoder.encode(`${frame}\n`)).pipe(
48
+ Effect.mapError(
49
+ (cause) =>
50
+ new McpTransportClosed({ reason: "server stdin closed", raw: cause }) as McpError,
51
+ ),
52
+ Effect.asVoid,
53
+ )
54
+
55
+ const messages = handle.stdout.pipe(
56
+ JSONL.fromBytes,
57
+ Stream.mapError(
58
+ (cause) => new McpTransportClosed({ reason: "server stdout closed", raw: cause }),
59
+ ),
60
+ )
61
+
62
+ return { send, messages } satisfies Transport
63
+ })