@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,985 @@
1
+ /**
2
+ * A minimal MCP client covering the `initialize` handshake, `tools/list`, and
3
+ * `tools/call`, over stdio or Streamable HTTP.
4
+ *
5
+ * This is deliberately not a general MCP SDK. Smithers has exactly one
6
+ * consumer of an MCP session: {@link McpFlows}, which needs a tool catalog
7
+ * and a way to invoke one entry from it, so the client exposes only that.
8
+ * Resources, prompts, sampling, and roots are not wired up; add them here
9
+ * when a flow adapter needs them, not speculatively.
10
+ *
11
+ * @since 1.0.0-rc.0
12
+ */
13
+
14
+ import { isRecord } from "@smthrs/canonical/Record"
15
+ import { Effect, Exit, Result, Schema, Scope } from "effect"
16
+ import type * as HttpClient from "effect/unstable/http/HttpClient"
17
+ import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
18
+ import * as DiagnosticReporter from "./internal/DiagnosticReporter.ts"
19
+ import * as HttpTransport from "./internal/HttpTransport.ts"
20
+ import * as JsonLimits from "./internal/JsonLimits.ts"
21
+ import * as Limits from "./internal/Limits.ts"
22
+ import * as StdioTransport from "./internal/StdioTransport.ts"
23
+ import * as Transport from "./internal/Transport.ts"
24
+ import { McpError } from "./McpError.ts"
25
+
26
+ /**
27
+ * One remote tool as the server describes it.
28
+ *
29
+ * @category models
30
+ * @since 1.0.0-rc.0
31
+ */
32
+ export interface ToolDescription {
33
+ readonly name: string
34
+ readonly description: string | undefined
35
+ /** The tool's parameter shape, as a JSON Schema document with `type: "object"`. */
36
+ readonly inputSchema: Record<string, unknown>
37
+ /** The tool's structured result shape, when the server disclosed one. */
38
+ readonly outputSchema: Record<string, unknown> | undefined
39
+ }
40
+
41
+ /**
42
+ * The result of one `tools/call`.
43
+ *
44
+ * MCP tool content is a small union (text, image, embedded resource, …); this
45
+ * client passes every block through by shape rather than modeling the union,
46
+ * since {@link McpFlows} only needs to hand the blocks back to the caller.
47
+ *
48
+ * @category models
49
+ * @since 1.0.0-rc.0
50
+ */
51
+ export interface ToolResult {
52
+ readonly content: ReadonlyArray<Record<string, unknown>>
53
+ readonly isError: boolean
54
+ readonly structuredContent: Record<string, unknown> | undefined
55
+ }
56
+
57
+ /**
58
+ * A live MCP session, holding the tool catalog fetched at connect time and a
59
+ * way to call one of its entries.
60
+ *
61
+ * @category models
62
+ * @since 1.0.0-rc.0
63
+ */
64
+ export interface McpClient {
65
+ readonly server: string
66
+ readonly tools: ReadonlyArray<ToolDescription>
67
+ /**
68
+ * Calls one catalogued tool. An unknown name fails with `tool_not_found`
69
+ * before a JSON-RPC frame is written. Declared structured output is checked
70
+ * against the supported output-schema subset before it is returned.
71
+ */
72
+ readonly callTool: (name: string, args: Record<string, unknown>) => Effect.Effect<ToolResult, McpError>
73
+ }
74
+
75
+ /**
76
+ * Session and catalog limits shared by every transport.
77
+ *
78
+ * @category models
79
+ * @since 1.0.0-rc.1
80
+ */
81
+ export interface ClientOptions {
82
+ /** The name this server is known by, for flow naming and error messages. */
83
+ readonly server: string
84
+ /** Deadline for each initialize/catalog request. See {@link defaultHandshakeTimeoutMs}. */
85
+ readonly handshakeTimeoutMs?: number | undefined
86
+ /** Maximum tools accepted across every catalog page. See {@link defaultMaxTools}. */
87
+ readonly maxTools?: number | undefined
88
+ /**
89
+ * Maximum UTF-8 bytes in a tool name. Names also cannot be `.` or `..`, or
90
+ * contain `/`, a control or format character (Unicode categories Cc and Cf,
91
+ * which include bidi and zero-width marks), U+2028, U+2029, or a lone
92
+ * surrogate. See {@link defaultMaxToolNameBytes}.
93
+ */
94
+ readonly maxToolNameBytes?: number | undefined
95
+ /**
96
+ * Maximum UTF-8 bytes of one tool's model-facing text: its description plus
97
+ * its JSON-encoded `inputSchema`. See {@link defaultMaxToolDocumentBytes}.
98
+ */
99
+ readonly maxToolDocumentBytes?: number | undefined
100
+ /** Maximum pages walked while fetching the catalog. See {@link defaultMaxCatalogPages}. */
101
+ readonly maxCatalogPages?: number | undefined
102
+ }
103
+
104
+ /**
105
+ * A server spawned as a child process and spoken to over its stdio.
106
+ *
107
+ * @category models
108
+ * @since 1.0.0-rc.1
109
+ */
110
+ export interface StdioConnectOptions extends StdioTransport.ConnectOptions, ClientOptions {
111
+ readonly url?: never
112
+ }
113
+
114
+ /**
115
+ * Supplies the bearer credential for a Streamable HTTP server. `token` runs
116
+ * once per HTTP message, so a provider can rotate the credential; its failure
117
+ * fails that message unchanged.
118
+ *
119
+ * @category models
120
+ * @since 1.0.0-rc.1
121
+ */
122
+ export type AuthProvider = HttpTransport.AuthProvider
123
+
124
+ /**
125
+ * A remote server reached over MCP Streamable HTTP through the `HttpClient`
126
+ * in context.
127
+ *
128
+ * @category models
129
+ * @since 1.0.0-rc.1
130
+ */
131
+ export interface HttpConnectOptions extends HttpTransport.ConnectOptions, ClientOptions {
132
+ readonly command?: never
133
+ }
134
+
135
+ /**
136
+ * Options accepted by {@link connect}: a stdio command or an HTTP `url`.
137
+ *
138
+ * @category models
139
+ * @since 1.0.0-rc.0
140
+ */
141
+ export type ConnectOptions = StdioConnectOptions | HttpConnectOptions
142
+
143
+ /**
144
+ * The services {@link connect} needs for the given options: a process spawner
145
+ * for stdio, an `HttpClient` for a `url`.
146
+ *
147
+ * @category models
148
+ * @since 1.0.0-rc.1
149
+ */
150
+ export type Requirements<O extends ConnectOptions> = O extends { readonly url: string } ? HttpClient.HttpClient
151
+ : ChildProcessSpawner
152
+
153
+ const isHttp = (options: ConnectOptions): options is HttpConnectOptions => options.url !== undefined
154
+
155
+ const PositiveInteger = Schema.Int.check(Schema.isGreaterThan(0))
156
+
157
+ /**
158
+ * Authoritative decoder for a persisted stdio MCP server entry.
159
+ *
160
+ * The schema requires non-empty server and command names, string arguments,
161
+ * a plain string-valued environment record, and positive-integer limits.
162
+ *
163
+ * @category schemas
164
+ * @since 1.0.0-rc.0
165
+ */
166
+ export const ConnectOptionsSchema = Schema.Struct({
167
+ server: Schema.NonEmptyString,
168
+ command: Schema.NonEmptyString,
169
+ args: Schema.Array(Schema.String),
170
+ cwd: Schema.optional(Schema.NonEmptyString),
171
+ env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
172
+ handshakeTimeoutMs: Schema.optional(PositiveInteger),
173
+ requestTimeoutMs: Schema.optional(PositiveInteger),
174
+ queueCapacity: Schema.optional(PositiveInteger),
175
+ maxFrameBytes: Schema.optional(PositiveInteger),
176
+ maxOutboundFrameBytes: Schema.optional(PositiveInteger),
177
+ maxStderrBytes: Schema.optional(PositiveInteger),
178
+ maxTools: Schema.optional(PositiveInteger),
179
+ maxToolNameBytes: Schema.optional(PositiveInteger),
180
+ maxToolDocumentBytes: Schema.optional(PositiveInteger),
181
+ maxCatalogPages: Schema.optional(PositiveInteger)
182
+ })
183
+
184
+ /** An absolute `http:` or `https:` URL without credentials: userinfo would put a secret in the file. */
185
+ const isEndpoint = (url: string): boolean => {
186
+ if (!URL.canParse(url)) return false
187
+ const parsed = new URL(url)
188
+ return (parsed.protocol === "http:" || parsed.protocol === "https:") && parsed.username === "" &&
189
+ parsed.password === ""
190
+ }
191
+
192
+ /**
193
+ * Authoritative decoder for a persisted Streamable HTTP MCP server entry.
194
+ *
195
+ * The schema requires a non-empty server name, an absolute `http:` or
196
+ * `https:` `url` without userinfo, and positive-integer limits. A bearer
197
+ * credential is never stored in the entry: `bearerTokenEnv` names the
198
+ * environment variable the host reads it from and turns into an
199
+ * {@link AuthProvider}.
200
+ *
201
+ * @category schemas
202
+ * @since 1.0.0-rc.1
203
+ */
204
+ export const HttpConnectOptionsSchema = Schema.Struct({
205
+ server: Schema.NonEmptyString,
206
+ url: Schema.String.check(Schema.makeFilter(isEndpoint, { expected: "an http: or https: URL without credentials" })),
207
+ bearerTokenEnv: Schema.optionalKey(Schema.String.check(Schema.isPattern(/^[A-Za-z_][A-Za-z0-9_]*$/))),
208
+ handshakeTimeoutMs: Schema.optional(PositiveInteger),
209
+ requestTimeoutMs: Schema.optional(PositiveInteger),
210
+ maxFrameBytes: Schema.optional(PositiveInteger),
211
+ maxOutboundFrameBytes: Schema.optional(PositiveInteger),
212
+ maxTools: Schema.optional(PositiveInteger),
213
+ maxToolNameBytes: Schema.optional(PositiveInteger),
214
+ maxToolDocumentBytes: Schema.optional(PositiveInteger),
215
+ maxCatalogPages: Schema.optional(PositiveInteger)
216
+ })
217
+
218
+ /**
219
+ * Frozen identity disclosed to every MCP server during initialization.
220
+ *
221
+ * @category constants
222
+ * @since 1.0.0-rc.0
223
+ */
224
+ export const clientInfo: { readonly name: string; readonly version: string } = Object.freeze({
225
+ name: "smithers",
226
+ version: "1.0.0-rc.3"
227
+ })
228
+
229
+ /**
230
+ * MCP revisions whose `tools/list` and `tools/call` shapes this client
231
+ * decodes. The frozen list always proposes `2025-06-18` first.
232
+ *
233
+ * @category constants
234
+ * @since 1.0.0-rc.0
235
+ */
236
+ export const supportedProtocolVersions: ReadonlyArray<string> = Object.freeze([
237
+ "2025-06-18",
238
+ "2025-03-26",
239
+ "2024-11-05"
240
+ ])
241
+
242
+ /**
243
+ * Default deadline for each MCP handshake request.
244
+ *
245
+ * @category constants
246
+ * @since 1.0.0-rc.0
247
+ */
248
+ export const defaultHandshakeTimeoutMs = 10_000
249
+
250
+ /**
251
+ * Default deadline for each tool request.
252
+ *
253
+ * @category constants
254
+ * @since 1.0.0-rc.0
255
+ */
256
+ export const defaultRequestTimeoutMs = Transport.defaultRequestTimeoutMs
257
+
258
+ /**
259
+ * Default number of outbound frames allowed to wait in memory.
260
+ *
261
+ * @category constants
262
+ * @since 1.0.0-rc.0
263
+ */
264
+ export const defaultQueueCapacity = StdioTransport.defaultQueueCapacity
265
+
266
+ /**
267
+ * Default maximum inbound JSON-RPC frame size.
268
+ *
269
+ * @category constants
270
+ * @since 1.0.0-rc.0
271
+ */
272
+ export const defaultMaxFrameBytes = Transport.defaultMaxFrameBytes
273
+
274
+ /**
275
+ * Default maximum outbound JSON-RPC frame size.
276
+ *
277
+ * @category constants
278
+ * @since 1.0.0-rc.0
279
+ */
280
+ export const defaultMaxOutboundFrameBytes = Transport.defaultMaxOutboundFrameBytes
281
+
282
+ /**
283
+ * Default maximum child-stderr tail retained for connection diagnostics.
284
+ *
285
+ * @category constants
286
+ * @since 1.0.0-rc.0
287
+ */
288
+ export const defaultMaxStderrBytes = StdioTransport.defaultMaxStderrBytes
289
+
290
+ /**
291
+ * Default maximum number of tools in a remote catalog.
292
+ *
293
+ * @category constants
294
+ * @since 1.0.0-rc.0
295
+ */
296
+ export const defaultMaxTools = 256
297
+
298
+ /**
299
+ * Default maximum UTF-8 byte length of one remote tool name.
300
+ *
301
+ * @category constants
302
+ * @since 1.0.0-rc.0
303
+ */
304
+ export const defaultMaxToolNameBytes = 128
305
+
306
+ /**
307
+ * Default maximum UTF-8 bytes of one tool's description plus its JSON-encoded
308
+ * `inputSchema`, the server-authored text a model reads for that tool.
309
+ *
310
+ * @category constants
311
+ * @since 1.0.0-rc.1
312
+ */
313
+ export const defaultMaxToolDocumentBytes = 65_536
314
+
315
+ /**
316
+ * Default maximum number of remote catalog pages.
317
+ *
318
+ * @category constants
319
+ * @since 1.0.0-rc.0
320
+ */
321
+ export const defaultMaxCatalogPages = 32
322
+
323
+ /**
324
+ * Maximum nested JSON containers, including the JSON-RPC envelope. Fixed so
325
+ * accepted server schemas and values remain safe for recursive consumers.
326
+ *
327
+ * @category constants
328
+ * @since 1.0.0-rc.0
329
+ */
330
+ export const maxJsonDepth = JsonLimits.maxDepth
331
+
332
+ const invalidResponse = (server: string, message: string): McpError =>
333
+ new McpError({ code: "invalid_response", message, server })
334
+
335
+ const asInitialize = (server: string, result: unknown): Result.Result<void, McpError> => {
336
+ if (!isRecord(result)) {
337
+ return Result.fail(Limits.protocolError(
338
+ server,
339
+ `MCP server "${server}" returned a malformed initialize result: result is not an object`
340
+ ))
341
+ }
342
+ if (typeof result.protocolVersion !== "string") {
343
+ return Result.fail(Limits.protocolError(
344
+ server,
345
+ `MCP server "${server}" returned a malformed initialize result: protocolVersion is not a string`
346
+ ))
347
+ }
348
+ if (!supportedProtocolVersions.includes(result.protocolVersion)) {
349
+ return Result.fail(Limits.protocolError(
350
+ server,
351
+ `MCP server "${server}" speaks an unsupported protocol version; this client speaks ${
352
+ supportedProtocolVersions.join(", ")
353
+ }`
354
+ ))
355
+ }
356
+ if (!isRecord(result.capabilities)) {
357
+ return Result.fail(Limits.protocolError(
358
+ server,
359
+ `MCP server "${server}" returned a malformed initialize result: capabilities is not an object`
360
+ ))
361
+ }
362
+ if (!Object.hasOwn(result.capabilities, "tools") || !isRecord(result.capabilities.tools)) {
363
+ return Result.fail(Limits.protocolError(
364
+ server,
365
+ `MCP server "${server}" does not serve tools: its initialize result declares no tools capability`
366
+ ))
367
+ }
368
+ return Result.succeed(undefined)
369
+ }
370
+
371
+ type CatalogLimits = {
372
+ readonly maxTools: number
373
+ readonly maxToolNameBytes: number
374
+ readonly maxToolDocumentBytes: number
375
+ }
376
+
377
+ type ToolPage = {
378
+ readonly nextCursor: string | undefined
379
+ }
380
+
381
+ const nameEncoder = new TextEncoder()
382
+
383
+ // Cc covers C0, DEL, and C1. Cf covers bidi overrides and isolates (U+202A-202E,
384
+ // U+2066-2069), zero-width marks (U+200B-200F, U+2060, U+FEFF), and soft hyphen:
385
+ // all invisible text that makes `mcp/<server>/<tool>` read as another tool.
386
+ // Cs catches a lone surrogate, which UTF-8 encoding would silently replace.
387
+ const forbiddenToolNameCharacter = /[/\p{Cc}\p{Cf}\p{Cs}\u2028\u2029]/u
388
+
389
+ const isForbiddenToolName = (name: string): boolean =>
390
+ name === "." || name === ".." || forbiddenToolNameCharacter.test(name)
391
+
392
+ const asToolPage = (
393
+ server: string,
394
+ result: unknown,
395
+ limits: CatalogLimits,
396
+ seen: Set<string>,
397
+ described: Array<ToolDescription>
398
+ ): Result.Result<ToolPage, McpError> => {
399
+ const tools = isRecord(result) ? result.tools : undefined
400
+ if (!Array.isArray(tools)) {
401
+ return Result.fail(invalidResponse(
402
+ server,
403
+ `MCP server "${server}" returned a tools/list result with no tools array`
404
+ ))
405
+ }
406
+ for (const [index, tool] of tools.entries()) {
407
+ if (described.length >= limits.maxTools) {
408
+ return Result.fail(invalidResponse(
409
+ server,
410
+ `MCP server "${server}" returned more than ${limits.maxTools} tools`
411
+ ))
412
+ }
413
+ if (!isRecord(tool)) {
414
+ return Result.fail(invalidResponse(
415
+ server,
416
+ `MCP server "${server}" returned tools[${index}], which is not an object`
417
+ ))
418
+ }
419
+ const record = tool
420
+ if (typeof record.name !== "string" || record.name === "") {
421
+ return Result.fail(invalidResponse(server, `MCP server "${server}" returned tools[${index}] with no name`))
422
+ }
423
+ if (nameEncoder.encode(record.name).byteLength > limits.maxToolNameBytes) {
424
+ return Result.fail(invalidResponse(
425
+ server,
426
+ `MCP server "${server}" returned a tool name longer than ${limits.maxToolNameBytes} bytes`
427
+ ))
428
+ }
429
+ if (isForbiddenToolName(record.name)) {
430
+ return Result.fail(invalidResponse(
431
+ server,
432
+ `MCP server "${server}" returned a tool name that is "." or "..", or contains "/" or an invisible or control character`
433
+ ))
434
+ }
435
+ if (seen.has(record.name)) {
436
+ return Result.fail(invalidResponse(
437
+ server,
438
+ `MCP server "${server}" returned a duplicate tool name at catalog index ${index}`
439
+ ))
440
+ }
441
+ if (!isRecord(record.inputSchema) || record.inputSchema.type !== "object") {
442
+ return Result.fail(invalidResponse(
443
+ server,
444
+ `MCP server "${server}" returned a tool whose inputSchema is not a JSON Schema object of type "object"`
445
+ ))
446
+ }
447
+ const description = typeof record.description === "string" ? record.description : undefined
448
+ const documentBytes = nameEncoder.encode(description ?? "").byteLength +
449
+ nameEncoder.encode(JSON.stringify(record.inputSchema)).byteLength
450
+ if (documentBytes > limits.maxToolDocumentBytes) {
451
+ return Result.fail(invalidResponse(
452
+ server,
453
+ `MCP server "${server}" returned a tool description and inputSchema longer than ${limits.maxToolDocumentBytes} bytes`
454
+ ))
455
+ }
456
+ let outputSchema: Record<string, unknown> | undefined
457
+ if (Object.hasOwn(record, "outputSchema")) {
458
+ if (!isRecord(record.outputSchema)) {
459
+ return Result.fail(invalidResponse(
460
+ server,
461
+ `MCP server "${server}" returned a tool whose outputSchema is not a JSON object`
462
+ ))
463
+ }
464
+ outputSchema = record.outputSchema
465
+ }
466
+ seen.add(record.name)
467
+ described.push({
468
+ name: record.name,
469
+ description,
470
+ inputSchema: record.inputSchema,
471
+ outputSchema
472
+ })
473
+ }
474
+
475
+ const nextCursor = isRecord(result) && Object.hasOwn(result, "nextCursor") ? result.nextCursor : undefined
476
+ if (nextCursor === undefined) return Result.succeed({ nextCursor: undefined })
477
+ if (typeof nextCursor !== "string" || nextCursor === "") {
478
+ return Result.fail(invalidResponse(
479
+ server,
480
+ `MCP server "${server}" returned a tools/list cursor that is not a non-empty string`
481
+ ))
482
+ }
483
+ return Result.succeed({ nextCursor })
484
+ }
485
+
486
+ // Container guards deliberately inspect only the shape; traversal below owns
487
+ // cooperative interruption. Schema.Int excludes unsafe integers, unlike JSON Schema.
488
+ const outputTypes = {
489
+ null: Schema.Null,
490
+ boolean: Schema.Boolean,
491
+ object: Schema.declare(isRecord),
492
+ array: Schema.declare(Array.isArray),
493
+ number: Schema.Number,
494
+ string: Schema.String,
495
+ integer: Schema.Number.check(Schema.makeFilter(Number.isInteger))
496
+ }
497
+
498
+ // Every traversal step consumes a slice slot, including enum-key construction.
499
+ // Yielding keeps validation interruptible after the transport has completed.
500
+ const runJsonWork = <A>(work: Generator<void, A>): Effect.Effect<A> =>
501
+ Effect.gen(function*() {
502
+ while (true) {
503
+ for (let steps = 0; steps < 1_024; steps += 1) {
504
+ const next = work.next()
505
+ if (next.done) return next.value
506
+ }
507
+ yield* Effect.yieldNow
508
+ }
509
+ })
510
+
511
+ // Keys are canonical JSON: object order is irrelevant, array order is not.
512
+ // Inputs here are depth-checked parsed JSON, never caller-owned arguments.
513
+ const enumKey = function*(value: unknown): Generator<void, string> {
514
+ yield
515
+ if (Array.isArray(value)) {
516
+ const items: Array<string> = []
517
+ for (const item of value) items.push(yield* enumKey(item))
518
+ return `[${items.join(",")}]`
519
+ }
520
+ if (isRecord(value)) {
521
+ const members: Array<string> = []
522
+ for (const key of Object.keys(value).sort()) {
523
+ members.push(`${JSON.stringify(key)}:${yield* enumKey(value[key])}`)
524
+ }
525
+ return `{${members.join(",")}}`
526
+ }
527
+ return JSON.stringify(value)
528
+ }
529
+
530
+ type OutputValidator = (value: unknown, path: string) => Generator<void, JsonIssue | undefined>
531
+
532
+ /**
533
+ * Compile the supported output-schema subset once per catalog. Effect Schema
534
+ * owns type unions; this cooperative compatibility traversal retains composite
535
+ * enums, own-property requirements and ignored unsupported keywords. Importing
536
+ * the whole document would enforce constraints this client does not support.
537
+ */
538
+ const compileOutputSchema = function*(schema: Record<string, unknown>): Generator<void, OutputValidator> {
539
+ yield
540
+ const types = new Set<keyof typeof outputTypes>()
541
+ for (const candidate of Array.isArray(schema.type) ? schema.type : [schema.type]) {
542
+ yield
543
+ if (typeof candidate === "string" && Object.hasOwn(outputTypes, candidate)) {
544
+ types.add(candidate as keyof typeof outputTypes)
545
+ }
546
+ }
547
+ const accepts = Schema.is(
548
+ types.size === 0 ? Schema.Unknown : Schema.Union([...types].map((type) => outputTypes[type]))
549
+ )
550
+ const typeReason = `expected ${[...types].join(" or ")}`
551
+ let enumIndex: Set<string> | undefined
552
+ if (Array.isArray(schema.enum)) {
553
+ enumIndex = new Set()
554
+ for (const member of schema.enum) enumIndex.add(yield* enumKey(member))
555
+ }
556
+ const required: Array<string> = []
557
+ if (Array.isArray(schema.required)) {
558
+ for (const key of schema.required) {
559
+ yield
560
+ if (typeof key === "string") required.push(key)
561
+ }
562
+ }
563
+ const properties: Array<readonly [string, OutputValidator]> = []
564
+ if (isRecord(schema.properties)) {
565
+ for (const [key, property] of Object.entries(schema.properties)) {
566
+ yield
567
+ if (isRecord(property)) properties.push([key, yield* compileOutputSchema(property)])
568
+ }
569
+ }
570
+ const items = isRecord(schema.items) ? yield* compileOutputSchema(schema.items) : undefined
571
+
572
+ return function*(value, path) {
573
+ yield
574
+ if (enumIndex !== undefined && !enumIndex.has(yield* enumKey(value))) {
575
+ return { path, reason: "expected a declared enum value" }
576
+ }
577
+ if (!accepts(value)) return { path, reason: typeReason }
578
+ if (isRecord(value)) {
579
+ for (const key of required) {
580
+ yield
581
+ if (!Object.hasOwn(value, key)) return { path: `${path}.${key}`, reason: "required property is missing" }
582
+ }
583
+ for (const [key, validate] of properties) {
584
+ yield
585
+ if (!Object.hasOwn(value, key)) continue
586
+ const issue = yield* validate(value[key], `${path}.${key}`)
587
+ if (issue !== undefined) return issue
588
+ }
589
+ }
590
+ if (Array.isArray(value) && items !== undefined) {
591
+ for (const [index, item] of value.entries()) {
592
+ const issue = yield* items(item, `${path}[${index}]`)
593
+ if (issue !== undefined) return issue
594
+ }
595
+ }
596
+ return undefined
597
+ }
598
+ }
599
+
600
+ const asToolResult = function*(
601
+ server: string,
602
+ result: unknown,
603
+ outputSchema: Record<string, unknown> | undefined,
604
+ diagnostic: (source: "invalid-response", detail: unknown) => void,
605
+ validate: OutputValidator | undefined
606
+ ): Generator<void, Result.Result<ToolResult, McpError>> {
607
+ if (!isRecord(result)) {
608
+ return Result.fail(invalidResponse(
609
+ server,
610
+ `MCP server "${server}" returned a tools/call result that is not an object`
611
+ ))
612
+ }
613
+ const hasContent = Object.hasOwn(result, "content")
614
+ const hasStructuredContent = Object.hasOwn(result, "structuredContent")
615
+ if (!hasContent && !hasStructuredContent) {
616
+ return Result.fail(invalidResponse(
617
+ server,
618
+ `MCP server "${server}" returned a tools/call result with no content array`
619
+ ))
620
+ }
621
+ if (hasContent && !Array.isArray(result.content)) {
622
+ return Result.fail(invalidResponse(
623
+ server,
624
+ `MCP server "${server}" returned a tools/call result with no content array`
625
+ ))
626
+ }
627
+ const content: Array<Record<string, unknown>> = []
628
+ const blocks = hasContent ? result.content as Array<unknown> : []
629
+ for (const [index, block] of blocks.entries()) {
630
+ yield
631
+ if (!isRecord(block)) {
632
+ return Result.fail(invalidResponse(
633
+ server,
634
+ `MCP server "${server}" returned a tools/call result whose content[${index}] is not an object`
635
+ ))
636
+ }
637
+ content.push(block)
638
+ }
639
+ if (Object.hasOwn(result, "isError") && typeof result.isError !== "boolean") {
640
+ return Result.fail(invalidResponse(
641
+ server,
642
+ `MCP server "${server}" returned a tools/call result whose isError is not a boolean`
643
+ ))
644
+ }
645
+ let structuredContent: Record<string, unknown> | undefined
646
+ if (hasStructuredContent) {
647
+ if (!isRecord(result.structuredContent)) {
648
+ return Result.fail(invalidResponse(
649
+ server,
650
+ `MCP server "${server}" returned a tools/call result whose structuredContent is not a JSON object`
651
+ ))
652
+ }
653
+ structuredContent = result.structuredContent
654
+ if (validate !== undefined) {
655
+ const issue = yield* validate(structuredContent, "structuredContent")
656
+ if (issue !== undefined) {
657
+ diagnostic("invalid-response", { issue, outputSchema })
658
+ return Result.fail(invalidResponse(
659
+ server,
660
+ `MCP server "${server}" returned structuredContent that its own outputSchema rejects: ${issue.reason}; property path withheld`
661
+ ))
662
+ }
663
+ }
664
+ }
665
+ return Result.succeed({
666
+ content,
667
+ isError: result.isError === true,
668
+ structuredContent
669
+ })
670
+ }
671
+
672
+ interface JsonObject {
673
+ [key: string]: JsonValue
674
+ }
675
+
676
+ type JsonValue = null | boolean | number | string | Array<JsonValue> | JsonObject
677
+
678
+ type JsonIssue = {
679
+ readonly path: string
680
+ readonly reason: string
681
+ }
682
+
683
+ type JsonPath = string | { readonly parent: JsonPath; key: string | number }
684
+
685
+ const renderJsonPath = (path: JsonPath): string =>
686
+ typeof path === "string" ?
687
+ path :
688
+ `${renderJsonPath(path.parent)}${typeof path.key === "number" ? `[${path.key}]` : `.${path.key}`}`
689
+
690
+ const jsonFailure = (path: JsonPath, reason: string): Result.Result<JsonValue, JsonIssue> =>
691
+ Result.fail({ path: renderJsonPath(path), reason })
692
+
693
+ const reflect = <A>(thunk: () => A): Result.Result<A, string> => {
694
+ try {
695
+ return Result.succeed(thunk())
696
+ } catch {
697
+ return Result.fail("a property that threw when read")
698
+ }
699
+ }
700
+
701
+ const ownDescriptors = (object: object): Result.Result<PropertyDescriptorMap, string> =>
702
+ reflect(() => Object.getOwnPropertyDescriptors(object))
703
+
704
+ const isAccessor = (descriptor: PropertyDescriptor): boolean =>
705
+ Object.hasOwn(descriptor, "get") || Object.hasOwn(descriptor, "set")
706
+
707
+ type JsonBudget = { remaining: number }
708
+
709
+ // Lower bounds on encoded size avoid expanding repeated references into an
710
+ // enormous tree before the transport's exact UTF-8 frame check. String/key
711
+ // UTF-16 length is a lower bound even with escapes and surrogate pairs.
712
+ const spendJson = (budget: JsonBudget, bytes: number): boolean => {
713
+ budget.remaining -= bytes
714
+ return budget.remaining >= 0
715
+ }
716
+
717
+ const snapshotJson = (
718
+ value: unknown,
719
+ path: JsonPath,
720
+ ancestors: Set<object>,
721
+ budget: JsonBudget
722
+ ): Result.Result<JsonValue, JsonIssue> => {
723
+ const bytes = typeof value === "string" ?
724
+ value.length + 2
725
+ : typeof value === "number" || typeof value === "boolean" ?
726
+ String(value).length
727
+ : value === null ?
728
+ 4
729
+ : 2
730
+ if (!spendJson(budget, bytes)) return jsonFailure(path, "JSON expansion exceeds the outbound frame budget")
731
+ if (value === null) return Result.succeed(null)
732
+ if (typeof value === "boolean" || typeof value === "string") return Result.succeed(value)
733
+ if (typeof value === "number") {
734
+ return Number.isFinite(value) ? Result.succeed(value) : jsonFailure(path, "a non-finite number")
735
+ }
736
+ if (typeof value === "undefined") return jsonFailure(path, "undefined")
737
+ if (typeof value === "bigint") return jsonFailure(path, "a bigint")
738
+ if (typeof value === "function") return jsonFailure(path, "a function")
739
+ if (typeof value === "symbol") return jsonFailure(path, "a symbol")
740
+
741
+ if (ancestors.has(value)) return jsonFailure(path, "a cyclic reference")
742
+ // Arguments live inside the wire envelope and its params object.
743
+ if (ancestors.size + 3 > maxJsonDepth) return jsonFailure(path, `JSON nesting exceeds ${maxJsonDepth} containers`)
744
+ const array = reflect(() => Array.isArray(value))
745
+ if (Result.isFailure(array)) return jsonFailure(path, array.failure)
746
+ if (array.success) {
747
+ const length = reflect(() => (value as Array<unknown>).length)
748
+ if (Result.isFailure(length)) return jsonFailure(path, length.failure)
749
+ if (!Number.isSafeInteger(length.success) || length.success < 0) {
750
+ return jsonFailure(path, "a property that threw when read")
751
+ }
752
+ if (!spendJson(budget, Math.max(0, length.success - 1))) {
753
+ return jsonFailure(path, "JSON expansion exceeds the outbound frame budget")
754
+ }
755
+ const descriptors = ownDescriptors(value)
756
+ if (Result.isFailure(descriptors)) return jsonFailure(path, descriptors.failure)
757
+ ancestors.add(value)
758
+ const copied: Array<JsonValue> = []
759
+ const memberPath: JsonPath = { parent: path, key: 0 }
760
+ for (let index = 0; index < length.success; index += 1) {
761
+ memberPath.key = index
762
+ // Missing slots must not resolve through Object.prototype.
763
+ const descriptor = Object.hasOwn(descriptors.success, index) ? descriptors.success[index] : undefined
764
+ if (descriptor !== undefined && isAccessor(descriptor)) {
765
+ ancestors.delete(value)
766
+ return jsonFailure(memberPath, "an accessor property")
767
+ }
768
+ const member = descriptor === undefined ? undefined : descriptor.value
769
+ const snapshot = snapshotJson(member, memberPath, ancestors, budget)
770
+ if (Result.isFailure(snapshot)) {
771
+ ancestors.delete(value)
772
+ return snapshot
773
+ }
774
+ copied.push(snapshot.success)
775
+ }
776
+ ancestors.delete(value)
777
+ return Result.succeed(copied)
778
+ }
779
+
780
+ const object = value as Record<string, unknown>
781
+ const prototype = reflect(() => Object.getPrototypeOf(object))
782
+ if (Result.isFailure(prototype)) return jsonFailure(path, prototype.failure)
783
+ if (prototype.success !== Object.prototype && prototype.success !== null) {
784
+ return jsonFailure(path, "an object with a non-plain prototype")
785
+ }
786
+ const symbols = reflect(() => Object.getOwnPropertySymbols(object))
787
+ if (Result.isFailure(symbols)) return jsonFailure(path, symbols.failure)
788
+ const descriptors = ownDescriptors(object)
789
+ if (Result.isFailure(descriptors)) return jsonFailure(path, descriptors.failure)
790
+ for (const key of symbols.success) {
791
+ if (Object.hasOwn(descriptors.success, key) && descriptors.success[key]!.enumerable) {
792
+ return jsonFailure(path, "a symbol-keyed property")
793
+ }
794
+ }
795
+
796
+ ancestors.add(object)
797
+ const copied: JsonObject = {}
798
+ let members = 0
799
+ const memberPath: JsonPath = { parent: path, key: "" }
800
+ for (const key of Object.keys(descriptors.success)) {
801
+ memberPath.key = key
802
+ const descriptor = descriptors.success[key]!
803
+ if (descriptor.enumerable !== true) continue
804
+ if (!spendJson(budget, key.length + 3 + Math.min(1, members++))) {
805
+ ancestors.delete(object)
806
+ return jsonFailure(path, "JSON expansion exceeds the outbound frame budget")
807
+ }
808
+ if (isAccessor(descriptor)) {
809
+ ancestors.delete(object)
810
+ return jsonFailure(memberPath, "an accessor property")
811
+ }
812
+ const snapshot = snapshotJson(descriptor.value, memberPath, ancestors, budget)
813
+ if (Result.isFailure(snapshot)) {
814
+ ancestors.delete(object)
815
+ return snapshot
816
+ }
817
+ Object.defineProperty(copied, key, {
818
+ configurable: true,
819
+ enumerable: true,
820
+ value: snapshot.success,
821
+ writable: true
822
+ })
823
+ }
824
+ ancestors.delete(object)
825
+ return Result.succeed(copied)
826
+ }
827
+
828
+ const snapshotArguments = (
829
+ server: string,
830
+ args: Record<string, unknown>,
831
+ diagnostic: (source: "invalid-arguments", detail: unknown) => void,
832
+ maxBytes: number
833
+ ): Result.Result<Record<string, unknown>, McpError> => {
834
+ const snapshot = snapshotJson(args, "arguments", new Set(), { remaining: maxBytes })
835
+ if (Result.isFailure(snapshot)) {
836
+ diagnostic("invalid-arguments", snapshot.failure)
837
+ return Result.fail(Limits.protocolError(
838
+ server,
839
+ `MCP server "${server}" was sent a tool argument that is not JSON: ${snapshot.failure.reason}; property path withheld`
840
+ ))
841
+ }
842
+ if (!isRecord(snapshot.success)) {
843
+ return Result.fail(Limits.protocolError(server, `MCP server "${server}" tool arguments must be a JSON object`))
844
+ }
845
+ return Result.succeed(snapshot.success)
846
+ }
847
+
848
+ /**
849
+ * Connects to an MCP server over stdio (`command`) or Streamable HTTP (`url`),
850
+ * completes the `initialize` handshake, and fetches its tool catalog once, up
851
+ * front. Failed or interrupted setup closes its subprocess, I/O fibers, or
852
+ * HTTP session before returning to the caller; a successful session stays
853
+ * open until the caller scope closes.
854
+ *
855
+ * The tool catalog is a snapshot: a server that changes its tools after
856
+ * connecting (a `notifications/tools/list_changed` push) is not re-polled.
857
+ * {@link McpFlows} rebuilds by reconnecting to refresh.
858
+ * Catalog input schemas must declare `type: "object"`. A later tool result may
859
+ * omit `content` when it carries `structuredContent`; a declared output schema
860
+ * is enforced for the documented keyword subset.
861
+ *
862
+ * @category constructors
863
+ * @since 1.0.0-rc.0
864
+ */
865
+ export const connect = <O extends ConnectOptions>(
866
+ options: O
867
+ ): Effect.Effect<McpClient, McpError, Requirements<O> | Scope.Scope> =>
868
+ Effect.acquireUseRelease(
869
+ Effect.flatMap(Effect.scope, Scope.fork),
870
+ (scope) =>
871
+ Effect.gen(function*() {
872
+ const diagnostic = yield* DiagnosticReporter.make(options.server)
873
+ const decodeResponse = <A>(response: unknown, decoded: Result.Result<A, McpError>) =>
874
+ Effect.fromResult(decoded).pipe(
875
+ Effect.tapError(() => Effect.sync(() => diagnostic("invalid-response", response)))
876
+ )
877
+ const handshakeTimeoutMs = options.handshakeTimeoutMs ?? defaultHandshakeTimeoutMs
878
+ const maxArgumentBytes = options.maxOutboundFrameBytes ?? defaultMaxOutboundFrameBytes
879
+ const maxTools = options.maxTools ?? defaultMaxTools
880
+ const maxToolNameBytes = options.maxToolNameBytes ?? defaultMaxToolNameBytes
881
+ const maxToolDocumentBytes = options.maxToolDocumentBytes ?? defaultMaxToolDocumentBytes
882
+ const maxCatalogPages = options.maxCatalogPages ?? defaultMaxCatalogPages
883
+ yield* Limits.checkPositiveIntegers(options.server, [
884
+ ["handshakeTimeoutMs", handshakeTimeoutMs],
885
+ ["maxTools", maxTools],
886
+ ["maxToolNameBytes", maxToolNameBytes],
887
+ ["maxToolDocumentBytes", maxToolDocumentBytes],
888
+ ["maxCatalogPages", maxCatalogPages]
889
+ ])
890
+
891
+ const transport: Transport.Transport = yield* (isHttp(options)
892
+ ? HttpTransport.connect(options)
893
+ : StdioTransport.connect(options))
894
+
895
+ const initialized = yield* transport.request(
896
+ "initialize",
897
+ {
898
+ protocolVersion: supportedProtocolVersions[0],
899
+ capabilities: {},
900
+ clientInfo
901
+ },
902
+ handshakeTimeoutMs
903
+ )
904
+ yield* decodeResponse(initialized, asInitialize(options.server, initialized))
905
+ // A notification, not a request: the server never replies to it, and the
906
+ // handshake is not complete until the client sends it.
907
+ yield* transport.notify("notifications/initialized", undefined, handshakeTimeoutMs)
908
+
909
+ const tools: Array<ToolDescription> = []
910
+ const toolNames = new Set<string>()
911
+ const cursors = new Set<string>()
912
+ let params: Record<string, unknown> = {}
913
+ let pageCount = 0
914
+ while (true) {
915
+ const listed = yield* transport.request("tools/list", params, handshakeTimeoutMs)
916
+ pageCount += 1
917
+ const page = yield* decodeResponse(
918
+ listed,
919
+ asToolPage(
920
+ options.server,
921
+ listed,
922
+ { maxTools, maxToolNameBytes, maxToolDocumentBytes },
923
+ toolNames,
924
+ tools
925
+ )
926
+ )
927
+ if (page.nextCursor === undefined) break
928
+ if (cursors.has(page.nextCursor)) {
929
+ diagnostic("invalid-response", listed)
930
+ return yield* Effect.fail(invalidResponse(
931
+ options.server,
932
+ `MCP server "${options.server}" repeated a tools/list cursor`
933
+ ))
934
+ }
935
+ if (pageCount >= maxCatalogPages) {
936
+ return yield* Effect.fail(invalidResponse(
937
+ options.server,
938
+ `MCP server "${options.server}" returned more than ${maxCatalogPages} tools/list pages`
939
+ ))
940
+ }
941
+ cursors.add(page.nextCursor)
942
+ params = { cursor: page.nextCursor }
943
+ }
944
+ JsonLimits.freezeParsed(tools)
945
+ const validators = new Map<string, OutputValidator>()
946
+ for (const tool of tools) {
947
+ if (tool.outputSchema !== undefined) {
948
+ validators.set(tool.name, yield* runJsonWork(compileOutputSchema(tool.outputSchema)))
949
+ }
950
+ }
951
+
952
+ const callTool = (name: string, args: Record<string, unknown>): Effect.Effect<ToolResult, McpError> => {
953
+ const tool = tools.find((candidate) => candidate.name === name)
954
+ if (tool === undefined) {
955
+ return Effect.fail(
956
+ new McpError({
957
+ code: "tool_not_found",
958
+ message: `MCP server "${options.server}" has no requested tool`,
959
+ server: options.server
960
+ })
961
+ )
962
+ }
963
+ const snapshot = snapshotArguments(options.server, args, diagnostic, maxArgumentBytes)
964
+ if (Result.isFailure(snapshot)) {
965
+ // Do not inspect the rejected object again: it may contain throwing
966
+ // accessors or proxies. Even diagnostic observers see only the safe
967
+ // rejection, never a second traversal of executable user properties.
968
+ return Effect.fail(snapshot.failure)
969
+ }
970
+ return Effect.flatMap(
971
+ transport.request("tools/call", { name, arguments: snapshot.success }),
972
+ (result) =>
973
+ Effect.flatMap(
974
+ runJsonWork(asToolResult(options.server, result, tool.outputSchema, diagnostic, validators.get(name))),
975
+ (decoded) => decodeResponse(result, decoded)
976
+ )
977
+ )
978
+ }
979
+
980
+ return { server: options.server, tools, callTool }
981
+ }).pipe(Scope.provide(scope)),
982
+ // Closing a failed attempt also detaches it from the caller's scope.
983
+ // Successful sessions remain owned by that scope until it closes.
984
+ (scope, exit) => Exit.isFailure(exit) ? Scope.close(scope, exit) : Effect.void
985
+ ) as Effect.Effect<McpClient, McpError, Requirements<O> | Scope.Scope>