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