@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.
- package/LICENSE +21 -0
- package/dist/Client.d.mts +55 -0
- package/dist/Client.d.mts.map +1 -0
- package/dist/Client.mjs +85 -0
- package/dist/Client.mjs.map +1 -0
- package/dist/McpError-DWg9BlW5.d.mts +83 -0
- package/dist/McpError-DWg9BlW5.d.mts.map +1 -0
- package/dist/McpError.d.mts +2 -0
- package/dist/McpError.mjs +61 -0
- package/dist/McpError.mjs.map +1 -0
- package/dist/Toolkit.d.mts +20 -0
- package/dist/Toolkit.d.mts.map +1 -0
- package/dist/Toolkit.mjs +70 -0
- package/dist/Toolkit.mjs.map +1 -0
- package/dist/auth-ZDvsxpCo.d.mts +126 -0
- package/dist/auth-ZDvsxpCo.d.mts.map +1 -0
- package/dist/index.d.mts +4 -0
- package/dist/index.mjs +4 -0
- package/dist/internal/auth.d.mts +2 -0
- package/dist/internal/auth.mjs +25 -0
- package/dist/internal/auth.mjs.map +1 -0
- package/dist/internal/httpTransport.d.mts +17 -0
- package/dist/internal/httpTransport.d.mts.map +1 -0
- package/dist/internal/httpTransport.mjs +88 -0
- package/dist/internal/httpTransport.mjs.map +1 -0
- package/dist/internal/protocol.d.mts +2 -0
- package/dist/internal/protocol.mjs +23 -0
- package/dist/internal/protocol.mjs.map +1 -0
- package/dist/internal/protocols/2025-06-18.d.mts +11 -0
- package/dist/internal/protocols/2025-06-18.d.mts.map +1 -0
- package/dist/internal/protocols/2025-06-18.mjs +70 -0
- package/dist/internal/protocols/2025-06-18.mjs.map +1 -0
- package/dist/internal/protocols/2025-11-25.d.mts +2 -0
- package/dist/internal/protocols/2025-11-25.mjs +2 -0
- package/dist/internal/protocols/2026-07-28.d.mts +16 -0
- package/dist/internal/protocols/2026-07-28.d.mts.map +1 -0
- package/dist/internal/protocols/2026-07-28.mjs +83 -0
- package/dist/internal/protocols/2026-07-28.mjs.map +1 -0
- package/dist/internal/rpc.d.mts +2 -0
- package/dist/internal/rpc.mjs +78 -0
- package/dist/internal/rpc.mjs.map +1 -0
- package/dist/internal/schema.d.mts +2 -0
- package/dist/internal/schema.mjs +168 -0
- package/dist/internal/schema.mjs.map +1 -0
- package/dist/internal/stdioTransport.d.mts +15 -0
- package/dist/internal/stdioTransport.d.mts.map +1 -0
- package/dist/internal/stdioTransport.mjs +46 -0
- package/dist/internal/stdioTransport.mjs.map +1 -0
- package/dist/protocol-kjC3iAMb.d.mts +35 -0
- package/dist/protocol-kjC3iAMb.d.mts.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/rpc-Bq95Lio9.d.mts +34 -0
- package/dist/rpc-Bq95Lio9.d.mts.map +1 -0
- package/dist/schema-D2oUNuo5.d.mts +245 -0
- package/dist/schema-D2oUNuo5.d.mts.map +1 -0
- package/package.json +70 -0
- package/src/Client.ts +180 -0
- package/src/McpError.ts +93 -0
- package/src/Toolkit.ts +109 -0
- package/src/index.ts +3 -0
- package/src/internal/auth.ts +48 -0
- package/src/internal/httpTransport.ts +171 -0
- package/src/internal/protocol.ts +67 -0
- package/src/internal/protocols/2025-06-18.ts +110 -0
- package/src/internal/protocols/2025-11-25.ts +8 -0
- package/src/internal/protocols/2026-07-28.test.ts +131 -0
- package/src/internal/protocols/2026-07-28.ts +152 -0
- package/src/internal/rpc.test.ts +156 -0
- package/src/internal/rpc.ts +189 -0
- package/src/internal/schema.ts +223 -0
- package/src/internal/stdioTransport.ts +63 -0
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@effect-uai/mcp",
|
|
3
|
+
"version": "0.12.1",
|
|
4
|
+
"description": "Model Context Protocol client provider for @effect-uai/core.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"agents",
|
|
7
|
+
"ai",
|
|
8
|
+
"effect",
|
|
9
|
+
"mcp",
|
|
10
|
+
"model-context-protocol",
|
|
11
|
+
"tools"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/betalyra/effect-uai/tree/main/packages/providers/mcp",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/betalyra/effect-uai/issues"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Betalyra",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/betalyra/effect-uai",
|
|
22
|
+
"directory": "packages/providers/mcp"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"src",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"type": "module",
|
|
31
|
+
"main": "./dist/index.mjs",
|
|
32
|
+
"types": "./dist/index.d.mts",
|
|
33
|
+
"exports": {
|
|
34
|
+
".": {
|
|
35
|
+
"types": "./dist/index.d.mts",
|
|
36
|
+
"import": "./dist/index.mjs"
|
|
37
|
+
},
|
|
38
|
+
"./Client": {
|
|
39
|
+
"types": "./dist/Client.d.mts",
|
|
40
|
+
"import": "./dist/Client.mjs"
|
|
41
|
+
},
|
|
42
|
+
"./Toolkit": {
|
|
43
|
+
"types": "./dist/Toolkit.d.mts",
|
|
44
|
+
"import": "./dist/Toolkit.mjs"
|
|
45
|
+
},
|
|
46
|
+
"./McpError": {
|
|
47
|
+
"types": "./dist/McpError.d.mts",
|
|
48
|
+
"import": "./dist/McpError.mjs"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@effect/platform-node": "4.0.0-rc.111",
|
|
57
|
+
"@effect/vitest": "4.0.0-rc.111",
|
|
58
|
+
"effect": "4.0.0-rc.111",
|
|
59
|
+
"typescript": "^7.0.2",
|
|
60
|
+
"@effect-uai/core": "0.12.1"
|
|
61
|
+
},
|
|
62
|
+
"peerDependencies": {
|
|
63
|
+
"@effect-uai/core": ">=0.5.0 <1",
|
|
64
|
+
"effect": ">=4.0.0-rc.111 <5.0.0"
|
|
65
|
+
},
|
|
66
|
+
"scripts": {
|
|
67
|
+
"build": "tsdown",
|
|
68
|
+
"typecheck": "tsc --noEmit"
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/Client.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `connect` opens a scoped MCP client: transport -> rpc core -> protocol probe.
|
|
3
|
+
* The protocol era is a property of the server, so it is negotiated once here
|
|
4
|
+
* and fixed for the connection's lifetime. The client surface is era-uniform.
|
|
5
|
+
*/
|
|
6
|
+
import { Context, Effect, Layer, Option, Ref, type Scope } from "effect"
|
|
7
|
+
import type { HttpClient } from "effect/unstable/http"
|
|
8
|
+
import type { ChildProcessSpawner } from "effect/unstable/process"
|
|
9
|
+
import { McpConnectFailed, type McpError, McpProtocolError } from "./McpError.js"
|
|
10
|
+
import { Auth, type TokenSource } from "./internal/auth.js"
|
|
11
|
+
import * as Handshake from "./internal/protocols/2025-06-18.js"
|
|
12
|
+
import * as Stateless from "./internal/protocols/2026-07-28.js"
|
|
13
|
+
import type { Protocol } from "./internal/protocol.js"
|
|
14
|
+
import { type McpConnection, open } from "./internal/rpc.js"
|
|
15
|
+
import {
|
|
16
|
+
type CallToolResult,
|
|
17
|
+
decodeCallToolResult,
|
|
18
|
+
decodeListToolsResult,
|
|
19
|
+
LATEST_VERSION,
|
|
20
|
+
type McpMethod,
|
|
21
|
+
type ProtocolVersion,
|
|
22
|
+
type ServerInfo,
|
|
23
|
+
type ToolInfo,
|
|
24
|
+
} from "./internal/schema.js"
|
|
25
|
+
import * as HttpTransport from "./internal/httpTransport.js"
|
|
26
|
+
import * as StdioTransport from "./internal/stdioTransport.js"
|
|
27
|
+
|
|
28
|
+
export { Auth, type TokenSource }
|
|
29
|
+
|
|
30
|
+
/** `auto` runs the probe; a pinned version skips detection. */
|
|
31
|
+
export type ProtocolPin = "auto" | ProtocolVersion
|
|
32
|
+
|
|
33
|
+
export type McpClientConfig =
|
|
34
|
+
| {
|
|
35
|
+
readonly transport: "http"
|
|
36
|
+
readonly url: string
|
|
37
|
+
/** Static, non-auth headers sent on every request. */
|
|
38
|
+
readonly headers?: Record<string, string>
|
|
39
|
+
/** Omit for a public server. See `Auth` for the three shapes. */
|
|
40
|
+
readonly auth?: Auth
|
|
41
|
+
readonly protocol?: ProtocolPin
|
|
42
|
+
}
|
|
43
|
+
| {
|
|
44
|
+
readonly transport: "stdio"
|
|
45
|
+
readonly command: string
|
|
46
|
+
readonly args?: ReadonlyArray<string>
|
|
47
|
+
readonly env?: Record<string, string>
|
|
48
|
+
readonly cwd?: string
|
|
49
|
+
readonly protocol?: ProtocolPin
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Services a client needs, by transport. Both are listed because the config is
|
|
54
|
+
* a runtime value: an app that only uses one still provides the other's layer,
|
|
55
|
+
* which is cheap (`FetchHttpClient.layer` / `NodeServices.layer`).
|
|
56
|
+
*/
|
|
57
|
+
export type McpRequirements = HttpClient.HttpClient | ChildProcessSpawner.ChildProcessSpawner
|
|
58
|
+
|
|
59
|
+
export type McpServerInfo = ServerInfo & {
|
|
60
|
+
/** The negotiated protocol version: the only era signal callers need. */
|
|
61
|
+
readonly protocolVersion: ProtocolVersion
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type McpClient = {
|
|
65
|
+
readonly listTools: Effect.Effect<ReadonlyArray<ToolInfo>, McpError>
|
|
66
|
+
readonly callTool: (name: string, args: unknown) => Effect.Effect<CallToolResult, McpError>
|
|
67
|
+
readonly serverInfo: McpServerInfo
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const undecodable = (method: McpMethod, detail: string, raw: unknown): McpError =>
|
|
71
|
+
new McpProtocolError({ method, reason: `undecodable ${method} result${detail}`, raw })
|
|
72
|
+
|
|
73
|
+
const makeClient = (connection: McpConnection, protocol: Protocol): McpClient => {
|
|
74
|
+
const request = (method: McpMethod, params: unknown) =>
|
|
75
|
+
connection.request(method, protocol.envelope(method, params), protocol.meta(method, params))
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
listTools: request("tools/list", {}).pipe(
|
|
79
|
+
Effect.flatMap((raw) =>
|
|
80
|
+
decodeListToolsResult(raw).pipe(
|
|
81
|
+
Effect.mapError(() => undecodable("tools/list", "", raw)),
|
|
82
|
+
Effect.map((result) => result.tools),
|
|
83
|
+
),
|
|
84
|
+
),
|
|
85
|
+
),
|
|
86
|
+
|
|
87
|
+
callTool: (name, args) =>
|
|
88
|
+
request("tools/call", { name, arguments: args ?? {} }).pipe(
|
|
89
|
+
Effect.flatMap((raw) =>
|
|
90
|
+
decodeCallToolResult(raw).pipe(
|
|
91
|
+
Effect.mapError(() => undecodable("tools/call", ` for ${name}`, raw)),
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
|
|
96
|
+
serverInfo: { ...protocol.serverInfo, protocolVersion: protocol.version },
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Try the stateless protocol first, then the handshake era. The stateless
|
|
102
|
+
* probe yields `None` only when the server proves it is not stateless (an
|
|
103
|
+
* error that is not one of the recognized modern codes), which is exactly the
|
|
104
|
+
* spec's era discriminator. A pinned `protocol` skips detection.
|
|
105
|
+
*
|
|
106
|
+
* The result is cached for the connection's lifetime by the caller: era is a
|
|
107
|
+
* property of the server, not of a request.
|
|
108
|
+
*/
|
|
109
|
+
const negotiate = (
|
|
110
|
+
connection: McpConnection,
|
|
111
|
+
pin: ProtocolPin,
|
|
112
|
+
): Effect.Effect<Protocol, McpError, Scope.Scope> => {
|
|
113
|
+
if (pin === LATEST_VERSION) return required(Stateless.probe(connection))
|
|
114
|
+
if (pin !== "auto") return required(Handshake.probe(connection))
|
|
115
|
+
return Stateless.probe(connection).pipe(
|
|
116
|
+
Effect.flatMap(
|
|
117
|
+
Option.match({
|
|
118
|
+
onNone: () => required(Handshake.probe(connection)),
|
|
119
|
+
onSome: Effect.succeed<Protocol>,
|
|
120
|
+
}),
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const required = (
|
|
126
|
+
probe: Effect.Effect<Option.Option<Protocol>, McpError, Scope.Scope>,
|
|
127
|
+
): Effect.Effect<Protocol, McpError, Scope.Scope> =>
|
|
128
|
+
probe.pipe(
|
|
129
|
+
Effect.flatMap(
|
|
130
|
+
Option.match({
|
|
131
|
+
onNone: () =>
|
|
132
|
+
Effect.fail(
|
|
133
|
+
new McpConnectFailed({ reason: "the server does not speak a supported MCP protocol" }),
|
|
134
|
+
),
|
|
135
|
+
onSome: Effect.succeed<Protocol>,
|
|
136
|
+
}),
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Open a scoped client. The connection lives exactly as long as the enclosing
|
|
142
|
+
* scope, so `Effect.scoped`, a `Layer`, or `Stream.scoped` all give correct
|
|
143
|
+
* teardown with no explicit close.
|
|
144
|
+
*/
|
|
145
|
+
export const connect = (
|
|
146
|
+
config: McpClientConfig,
|
|
147
|
+
): Effect.Effect<McpClient, McpError, Scope.Scope | McpRequirements> =>
|
|
148
|
+
Effect.gen(function* () {
|
|
149
|
+
const transport = yield* config.transport === "http"
|
|
150
|
+
? HttpTransport.make(config)
|
|
151
|
+
: StdioTransport.make(config)
|
|
152
|
+
|
|
153
|
+
// Inbound handling belongs to the protocol, which is not chosen until the
|
|
154
|
+
// probe completes, so the reader reads it from a Ref that starts empty.
|
|
155
|
+
// Frames arriving before then can only be legacy noise; ignore them.
|
|
156
|
+
const protocolRef = yield* Ref.make(Option.none<Protocol>())
|
|
157
|
+
const connection = yield* open(transport, (inbound) =>
|
|
158
|
+
Ref.get(protocolRef).pipe(
|
|
159
|
+
Effect.flatMap(
|
|
160
|
+
Option.match({
|
|
161
|
+
onNone: () => Effect.void,
|
|
162
|
+
onSome: (protocol) => protocol.onInbound(inbound),
|
|
163
|
+
}),
|
|
164
|
+
),
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
const protocol = yield* negotiate(connection, config.protocol ?? "auto")
|
|
169
|
+
yield* Ref.set(protocolRef, Option.some(protocol))
|
|
170
|
+
|
|
171
|
+
return makeClient(connection, protocol)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
export class Mcp extends Context.Service<Mcp, McpClient>()(
|
|
175
|
+
"@betalyra/effect-uai/providers/mcp/Mcp",
|
|
176
|
+
) {}
|
|
177
|
+
|
|
178
|
+
/** DI convenience for the single-server app: connects when the layer builds. */
|
|
179
|
+
export const layer = (config: McpClientConfig): Layer.Layer<Mcp, McpError, McpRequirements> =>
|
|
180
|
+
Layer.effect(Mcp, connect(config))
|
package/src/McpError.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Data, Match } from "effect"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Transport could not establish: DNS / TLS / non-2xx on the first request,
|
|
5
|
+
* a failed spawn, or a failed era probe. The raw transport error on `raw`.
|
|
6
|
+
*/
|
|
7
|
+
export class McpConnectFailed extends Data.TaggedError("McpConnectFailed")<{
|
|
8
|
+
reason?: string
|
|
9
|
+
raw?: unknown
|
|
10
|
+
}> {}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Negotiation ended on a protocol version this client does not support
|
|
14
|
+
* (e.g. a 2025-03-26-only server, or a modern server whose `supported`
|
|
15
|
+
* list has no mutual version). `offered` is what the server proposed.
|
|
16
|
+
*/
|
|
17
|
+
export class McpUnsupportedProtocol extends Data.TaggedError("McpUnsupportedProtocol")<{
|
|
18
|
+
offered: ReadonlyArray<string>
|
|
19
|
+
reason?: string
|
|
20
|
+
}> {}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A JSON-RPC error reply (carrying its `code`), a reply that failed to
|
|
24
|
+
* decode, or an unsupported protocol feature (v1 fails `input_required`
|
|
25
|
+
* results here). `method` names the request that was in flight.
|
|
26
|
+
*/
|
|
27
|
+
export class McpProtocolError extends Data.TaggedError("McpProtocolError")<{
|
|
28
|
+
method?: string
|
|
29
|
+
code?: number
|
|
30
|
+
reason?: string
|
|
31
|
+
raw?: unknown
|
|
32
|
+
}> {}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The connection dropped with a request in flight, or a legacy session
|
|
36
|
+
* expired (HTTP 404 on a known `Mcp-Session-Id`). Not resumable; the
|
|
37
|
+
* remedy is reconnecting and re-issuing the request.
|
|
38
|
+
*/
|
|
39
|
+
export class McpTransportClosed extends Data.TaggedError("McpTransportClosed")<{
|
|
40
|
+
method?: string
|
|
41
|
+
reason?: string
|
|
42
|
+
raw?: unknown
|
|
43
|
+
}> {}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The server answered `401`. `resourceMetadataUrl` is the RFC 9728
|
|
47
|
+
* Protected Resource Metadata pointer parsed from `WWW-Authenticate`,
|
|
48
|
+
* when present; an OAuth layer starts discovery there.
|
|
49
|
+
*/
|
|
50
|
+
export class McpAuthRequired extends Data.TaggedError("McpAuthRequired")<{
|
|
51
|
+
resourceMetadataUrl?: string
|
|
52
|
+
wwwAuthenticate?: string
|
|
53
|
+
}> {}
|
|
54
|
+
|
|
55
|
+
/** A `TokenSource` / OAuth grant failed to mint or refresh a token. */
|
|
56
|
+
export class McpAuthError extends Data.TaggedError("McpAuthError")<{
|
|
57
|
+
reason?: string
|
|
58
|
+
raw?: unknown
|
|
59
|
+
}> {}
|
|
60
|
+
|
|
61
|
+
export type McpError =
|
|
62
|
+
| McpConnectFailed
|
|
63
|
+
| McpUnsupportedProtocol
|
|
64
|
+
| McpProtocolError
|
|
65
|
+
| McpTransportClosed
|
|
66
|
+
| McpAuthRequired
|
|
67
|
+
| McpAuthError
|
|
68
|
+
|
|
69
|
+
const withReason = (base: string, reason: string | undefined): string =>
|
|
70
|
+
reason === undefined ? base : `${base}: ${reason}`
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Short human-readable description of an error, for logs and model-facing
|
|
74
|
+
* failure messages (`Toolkit.describeFailures(McpError.describe)`). Prose,
|
|
75
|
+
* not a contract; branch on `_tag` instead.
|
|
76
|
+
*/
|
|
77
|
+
export const describe: (e: McpError) => string = Match.type<McpError>().pipe(
|
|
78
|
+
Match.discriminatorsExhaustive("_tag")({
|
|
79
|
+
McpConnectFailed: (e) => withReason("could not connect to the MCP server", e.reason),
|
|
80
|
+
McpUnsupportedProtocol: (e) =>
|
|
81
|
+
`the MCP server only offers unsupported protocol versions (${e.offered.join(", ")})`,
|
|
82
|
+
McpProtocolError: (e) =>
|
|
83
|
+
withReason(
|
|
84
|
+
`the MCP server rejected ${e.method ?? "the request"}${
|
|
85
|
+
e.code === undefined ? "" : ` (${e.code})`
|
|
86
|
+
}`,
|
|
87
|
+
e.reason,
|
|
88
|
+
),
|
|
89
|
+
McpTransportClosed: (e) => withReason("the MCP connection closed", e.reason),
|
|
90
|
+
McpAuthRequired: () => "the MCP server requires authentication",
|
|
91
|
+
McpAuthError: (e) => withReason("MCP authentication failed", e.reason),
|
|
92
|
+
}),
|
|
93
|
+
)
|
package/src/Toolkit.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn a connected MCP server into a core `Toolkit`, one `LocalTool` per MCP
|
|
3
|
+
* tool. Effectful because the tool list is fetched; composes with
|
|
4
|
+
* `Toolkit.compose` like any other kit.
|
|
5
|
+
*/
|
|
6
|
+
import { Effect, Option } from "effect"
|
|
7
|
+
import * as Tool from "@effect-uai/core/Tool"
|
|
8
|
+
import * as CoreToolkit from "@effect-uai/core/Toolkit"
|
|
9
|
+
import type { McpClient } from "./Client.js"
|
|
10
|
+
import { type McpError, McpProtocolError } from "./McpError.js"
|
|
11
|
+
import {
|
|
12
|
+
type CallToolResult,
|
|
13
|
+
type ContentBlock,
|
|
14
|
+
isTextContent,
|
|
15
|
+
type ToolInfo,
|
|
16
|
+
} from "./internal/schema.js"
|
|
17
|
+
|
|
18
|
+
export type McpToolkitOptions = {
|
|
19
|
+
/** Namespace every tool as `<prefix>__<name>`; recommended when composing servers. */
|
|
20
|
+
readonly prefix?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The server owns validation, so the input schema is a passthrough: it carries
|
|
25
|
+
* the server's JSON Schema verbatim (the model sees the real thing) and
|
|
26
|
+
* accepts whatever the model produced. `strict: false` because server schemas
|
|
27
|
+
* are not written to OpenAI strict rules.
|
|
28
|
+
*/
|
|
29
|
+
const passthroughSchema = (tool: ToolInfo): Tool.ToolInputSchema<unknown> => ({
|
|
30
|
+
"~standard": {
|
|
31
|
+
version: 1,
|
|
32
|
+
vendor: "effect-uai-mcp",
|
|
33
|
+
validate: (value: unknown) => ({ value }),
|
|
34
|
+
jsonSchema: {
|
|
35
|
+
input: () => tool.inputSchema,
|
|
36
|
+
output: () => ({ type: "object" }),
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const textOf = (block: ContentBlock): string =>
|
|
42
|
+
isTextContent(block) ? block.text : `[${block.type} content omitted]`
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Serialize a successful result for the model: `structuredContent` when the
|
|
46
|
+
* server provides it, otherwise the joined text blocks. Non-text blocks become
|
|
47
|
+
* a short placeholder until the loop grows a multimodal tool-output path.
|
|
48
|
+
*/
|
|
49
|
+
const outputOf = (result: CallToolResult): unknown =>
|
|
50
|
+
result.structuredContent ?? (result.content ?? []).map(textOf).join("\n")
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Two failure channels, deliberately: `isError` is the server telling the
|
|
54
|
+
* model it went wrong, so it becomes a model-visible `ToolFailed`; an
|
|
55
|
+
* `input_required` result is a protocol capability gap the model cannot fix,
|
|
56
|
+
* so it stays a typed `McpError`.
|
|
57
|
+
*/
|
|
58
|
+
const interpret = (
|
|
59
|
+
name: string,
|
|
60
|
+
result: CallToolResult,
|
|
61
|
+
): Effect.Effect<unknown, McpError | Tool.ToolFailed> => {
|
|
62
|
+
if (result.isError === true) {
|
|
63
|
+
return Tool.fail(String(outputOf(result)), { kind: "tool_failed" })
|
|
64
|
+
}
|
|
65
|
+
if (result.resultType === "input_required") {
|
|
66
|
+
return Effect.fail(
|
|
67
|
+
new McpProtocolError({
|
|
68
|
+
method: "tools/call",
|
|
69
|
+
reason: `${name} requires interactive input (MRTR), unsupported by this client`,
|
|
70
|
+
}),
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
return Effect.succeed(outputOf(result))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const runTool =
|
|
77
|
+
(client: McpClient, name: string) =>
|
|
78
|
+
(input: unknown): Effect.Effect<unknown, McpError | Tool.ToolFailed> =>
|
|
79
|
+
client.callTool(name, input).pipe(Effect.flatMap((result) => interpret(name, result)))
|
|
80
|
+
|
|
81
|
+
const toLocalTool = (client: McpClient, info: ToolInfo) =>
|
|
82
|
+
Tool.make({
|
|
83
|
+
name: info.name,
|
|
84
|
+
description: info.description ?? info.name,
|
|
85
|
+
inputSchema: passthroughSchema(info),
|
|
86
|
+
run: runTool(client, info.name),
|
|
87
|
+
strict: false,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build a `Toolkit` from the server's advertised tools. The list is snapshotted
|
|
92
|
+
* here; a tool that later vanishes surfaces through `Toolkit.run`'s graceful
|
|
93
|
+
* `unknown_tool` path.
|
|
94
|
+
*/
|
|
95
|
+
export const mcpToolkit = (
|
|
96
|
+
client: McpClient,
|
|
97
|
+
options?: McpToolkitOptions,
|
|
98
|
+
): Effect.Effect<CoreToolkit.Toolkit, McpError> =>
|
|
99
|
+
client.listTools.pipe(
|
|
100
|
+
Effect.map((tools) => CoreToolkit.fromArray(tools.map((info) => toLocalTool(client, info)))),
|
|
101
|
+
Effect.map((kit) =>
|
|
102
|
+
Option.fromNullishOr(options?.prefix).pipe(
|
|
103
|
+
Option.match({
|
|
104
|
+
onNone: () => kit,
|
|
105
|
+
onSome: (prefix) => CoreToolkit.namespace(prefix, kit),
|
|
106
|
+
}),
|
|
107
|
+
),
|
|
108
|
+
),
|
|
109
|
+
)
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth resolution for the HTTP transport. Era-blind: the stateless and
|
|
3
|
+
* handshake protocols both ride an OAuth 2.1 resource server, and neither
|
|
4
|
+
* knows this file exists. Tokens stay `Redacted` until the wire boundary.
|
|
5
|
+
*/
|
|
6
|
+
import { Data, Effect, Option, Redacted } from "effect"
|
|
7
|
+
import type { McpAuthError } from "../McpError.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* An Effect yielding a bearer token. Any external OAuth, vault, or secret
|
|
11
|
+
* manager plugs in here. Read per request, so a producer minting expensive
|
|
12
|
+
* tokens caches internally (as `OAuth.clientCredentials` does).
|
|
13
|
+
*/
|
|
14
|
+
export type TokenSource = Effect.Effect<Redacted.Redacted<string>, McpAuthError>
|
|
15
|
+
|
|
16
|
+
export type Auth = Data.TaggedEnum<{
|
|
17
|
+
/** A fixed token: the turnkey case for a token-gated server. */
|
|
18
|
+
Static: { readonly token: Redacted.Redacted<string>; readonly scheme?: string }
|
|
19
|
+
/** Bring your own, re-read before every request. */
|
|
20
|
+
TokenSource: { readonly token: TokenSource; readonly scheme?: string }
|
|
21
|
+
/** Built-in OAuth 2.1, which produces a `TokenSource` and rides the same seam. */
|
|
22
|
+
OAuth: { readonly source: TokenSource; readonly scheme?: string }
|
|
23
|
+
}>
|
|
24
|
+
|
|
25
|
+
export const Auth = Data.taggedEnum<Auth>()
|
|
26
|
+
|
|
27
|
+
const tokenOf: (auth: Auth) => TokenSource = Auth.$match({
|
|
28
|
+
Static: ({ token }) => Effect.succeed(token),
|
|
29
|
+
TokenSource: ({ token }) => token,
|
|
30
|
+
OAuth: ({ source }) => source,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The `Authorization` header for one request, or none for a public server.
|
|
35
|
+
* `Redacted.value` is unwrapped only here.
|
|
36
|
+
*/
|
|
37
|
+
export const authHeaders = (
|
|
38
|
+
auth: Option.Option<Auth>,
|
|
39
|
+
): Effect.Effect<Record<string, string>, McpAuthError> =>
|
|
40
|
+
Option.match(auth, {
|
|
41
|
+
onNone: () => Effect.succeed({}),
|
|
42
|
+
onSome: (a) =>
|
|
43
|
+
tokenOf(a).pipe(
|
|
44
|
+
Effect.map((token) => ({
|
|
45
|
+
authorization: `${a.scheme ?? "Bearer"} ${Redacted.value(token)}`,
|
|
46
|
+
})),
|
|
47
|
+
),
|
|
48
|
+
})
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streamable HTTP `Transport`. Each client message is its own POST; the server
|
|
3
|
+
* answers with either one JSON object or a request-scoped SSE stream ending in
|
|
4
|
+
* the JSON-RPC response, and a client MUST accept both. Frames from every
|
|
5
|
+
* in-flight POST demux into one `messages` stream, which the rpc core
|
|
6
|
+
* correlates by id.
|
|
7
|
+
*
|
|
8
|
+
* Interrupting a request aborts its POST, which is exactly the spec's
|
|
9
|
+
* cancellation signal (closing the response stream).
|
|
10
|
+
*/
|
|
11
|
+
import { Cause, Effect, Match, Option, Queue, Ref, type Scope, Stream } from "effect"
|
|
12
|
+
import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/unstable/http"
|
|
13
|
+
import * as SSE from "@effect-uai/core/SSE"
|
|
14
|
+
import {
|
|
15
|
+
McpAuthRequired,
|
|
16
|
+
McpConnectFailed,
|
|
17
|
+
type McpError,
|
|
18
|
+
McpProtocolError,
|
|
19
|
+
McpTransportClosed,
|
|
20
|
+
} from "../McpError.js"
|
|
21
|
+
import { type Auth, authHeaders } from "./auth.js"
|
|
22
|
+
import type { SendMeta, Transport } from "./rpc.js"
|
|
23
|
+
|
|
24
|
+
export type HttpConfig = {
|
|
25
|
+
readonly url: string
|
|
26
|
+
/** Static, non-auth headers applied to every request. */
|
|
27
|
+
readonly headers?: Record<string, string>
|
|
28
|
+
/** Omit for a public server. Resolved per request, so a rotating source works. */
|
|
29
|
+
readonly auth?: Auth
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** RFC 9728: the `resource_metadata` pointer on a 401's `WWW-Authenticate`. */
|
|
33
|
+
const resourceMetadataUrl = (wwwAuthenticate: string): string | undefined =>
|
|
34
|
+
/resource_metadata="([^"]+)"/.exec(wwwAuthenticate)?.[1]
|
|
35
|
+
|
|
36
|
+
const unauthorized = (response: HttpClientResponse.HttpClientResponse): McpError => {
|
|
37
|
+
const header = response.headers["www-authenticate"]
|
|
38
|
+
const pointer = header === undefined ? undefined : resourceMetadataUrl(header)
|
|
39
|
+
return new McpAuthRequired({
|
|
40
|
+
...(pointer !== undefined ? { resourceMetadataUrl: pointer } : {}),
|
|
41
|
+
...(header !== undefined ? { wwwAuthenticate: header } : {}),
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A non-2xx that is not a 401. The body still matters: a modern server answers
|
|
47
|
+
* `400` for `-32022` / `-32021` / `-32020` and `404` for `-32601`, and era
|
|
48
|
+
* detection reads those codes off the JSON-RPC body. So the body is forwarded
|
|
49
|
+
* into `messages` whenever it parses as JSON, and only a bodyless failure
|
|
50
|
+
* becomes a transport error.
|
|
51
|
+
*/
|
|
52
|
+
const isJsonRpcBody = (body: string): boolean => body.trimStart().startsWith("{")
|
|
53
|
+
|
|
54
|
+
export const make = (
|
|
55
|
+
config: HttpConfig,
|
|
56
|
+
): Effect.Effect<Transport, McpError, Scope.Scope | HttpClient.HttpClient> =>
|
|
57
|
+
Effect.gen(function* () {
|
|
58
|
+
const client = yield* HttpClient.HttpClient
|
|
59
|
+
const inbox = yield* Queue.make<string, Cause.Done>()
|
|
60
|
+
yield* Effect.addFinalizer(() => Queue.end(inbox))
|
|
61
|
+
|
|
62
|
+
// The legacy era's `Mcp-Session-Id` is a transport concern, not a protocol
|
|
63
|
+
// one: capture whatever the server mints and echo it from then on, so the
|
|
64
|
+
// protocol implementations stay transport-agnostic. Stateless servers
|
|
65
|
+
// never send one, and this stays empty.
|
|
66
|
+
const session = yield* Ref.make(Option.none<string>())
|
|
67
|
+
|
|
68
|
+
const post = (frame: string, meta?: SendMeta): Effect.Effect<void, McpError> =>
|
|
69
|
+
Effect.gen(function* () {
|
|
70
|
+
const sessionId = yield* Ref.get(session)
|
|
71
|
+
const auth = yield* authHeaders(Option.fromNullishOr(config.auth))
|
|
72
|
+
const request = HttpClientRequest.post(config.url).pipe(
|
|
73
|
+
HttpClientRequest.setHeaders({
|
|
74
|
+
"content-type": "application/json",
|
|
75
|
+
// Both response shapes are legal for any request.
|
|
76
|
+
accept: "application/json, text/event-stream",
|
|
77
|
+
...config.headers,
|
|
78
|
+
...auth,
|
|
79
|
+
...meta?.headers,
|
|
80
|
+
...Option.match(sessionId, {
|
|
81
|
+
onNone: () => ({}),
|
|
82
|
+
onSome: (id) => ({ "Mcp-Session-Id": id }),
|
|
83
|
+
}),
|
|
84
|
+
}),
|
|
85
|
+
// The content type must be set on the body: it overrides any header.
|
|
86
|
+
HttpClientRequest.bodyText(frame, "application/json"),
|
|
87
|
+
)
|
|
88
|
+
const response = yield* client
|
|
89
|
+
.execute(request)
|
|
90
|
+
.pipe(
|
|
91
|
+
Effect.mapError(
|
|
92
|
+
(cause) => new McpConnectFailed({ reason: "MCP request failed", raw: cause }),
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
yield* captureSession(response)
|
|
96
|
+
yield* ingest(response)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const captureSession = (
|
|
100
|
+
response: HttpClientResponse.HttpClientResponse,
|
|
101
|
+
): Effect.Effect<void> => {
|
|
102
|
+
const minted = response.headers["mcp-session-id"]
|
|
103
|
+
return minted === undefined ? Effect.void : Ref.set(session, Option.some(minted))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const ingest = (
|
|
107
|
+
response: HttpClientResponse.HttpClientResponse,
|
|
108
|
+
): Effect.Effect<void, McpError> =>
|
|
109
|
+
Match.value(response.status).pipe(
|
|
110
|
+
Match.when(401, () => Effect.fail(unauthorized(response))),
|
|
111
|
+
Match.when(404, () => expiredOrBody(response)),
|
|
112
|
+
// 202 answers a notification and carries no body.
|
|
113
|
+
Match.when(202, () => Effect.void),
|
|
114
|
+
Match.orElse(() => readFrames(response)),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
// A 404 against a session we hold means the server dropped it; the caller
|
|
118
|
+
// reconnects rather than retrying into a dead session.
|
|
119
|
+
const expiredOrBody = (
|
|
120
|
+
response: HttpClientResponse.HttpClientResponse,
|
|
121
|
+
): Effect.Effect<void, McpError> =>
|
|
122
|
+
Ref.get(session).pipe(
|
|
123
|
+
Effect.flatMap((held) =>
|
|
124
|
+
Option.isSome(held)
|
|
125
|
+
? Effect.fail(new McpTransportClosed({ reason: "the server expired this session" }))
|
|
126
|
+
: readBody(response),
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
const readFrames = (
|
|
131
|
+
response: HttpClientResponse.HttpClientResponse,
|
|
132
|
+
): Effect.Effect<void, McpError> =>
|
|
133
|
+
(response.headers["content-type"] ?? "").includes("text/event-stream")
|
|
134
|
+
? readSse(response)
|
|
135
|
+
: readBody(response)
|
|
136
|
+
|
|
137
|
+
const readSse = (
|
|
138
|
+
response: HttpClientResponse.HttpClientResponse,
|
|
139
|
+
): Effect.Effect<void, McpError> =>
|
|
140
|
+
response.stream.pipe(
|
|
141
|
+
SSE.fromBytes,
|
|
142
|
+
Stream.runForEach((event) => Queue.offer(inbox, event.data)),
|
|
143
|
+
Effect.mapError(
|
|
144
|
+
(cause) => new McpTransportClosed({ reason: "SSE stream failed", raw: cause }),
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
const readBody = (
|
|
149
|
+
response: HttpClientResponse.HttpClientResponse,
|
|
150
|
+
): Effect.Effect<void, McpError> =>
|
|
151
|
+
response.text.pipe(
|
|
152
|
+
Effect.mapError(
|
|
153
|
+
(cause) => new McpTransportClosed({ reason: "could not read response", raw: cause }),
|
|
154
|
+
),
|
|
155
|
+
Effect.flatMap((body) =>
|
|
156
|
+
isJsonRpcBody(body)
|
|
157
|
+
? Effect.asVoid(Queue.offer(inbox, body))
|
|
158
|
+
: Effect.fail(
|
|
159
|
+
new McpProtocolError({
|
|
160
|
+
code: response.status,
|
|
161
|
+
reason: `HTTP ${response.status} with a non-JSON-RPC body`,
|
|
162
|
+
raw: body.slice(0, 500),
|
|
163
|
+
}),
|
|
164
|
+
),
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
// The POST runs on the caller's fiber so interruption aborts it, which is
|
|
169
|
+
// the spec's cancellation signal.
|
|
170
|
+
return { send: post, messages: Stream.fromQueue(inbox) } satisfies Transport
|
|
171
|
+
})
|