@graphorin/mcp 0.5.0

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 (70) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/LICENSE +21 -0
  3. package/README.md +296 -0
  4. package/dist/client/adapt-result.d.ts +25 -0
  5. package/dist/client/adapt-result.d.ts.map +1 -0
  6. package/dist/client/adapt-result.js +174 -0
  7. package/dist/client/adapt-result.js.map +1 -0
  8. package/dist/client/client-handlers.js +134 -0
  9. package/dist/client/client-handlers.js.map +1 -0
  10. package/dist/client/client.d.ts +21 -0
  11. package/dist/client/client.d.ts.map +1 -0
  12. package/dist/client/client.js +358 -0
  13. package/dist/client/client.js.map +1 -0
  14. package/dist/client/defer-loading.d.ts +7 -0
  15. package/dist/client/defer-loading.d.ts.map +1 -0
  16. package/dist/client/defer-loading.js +81 -0
  17. package/dist/client/defer-loading.js.map +1 -0
  18. package/dist/client/inbound-filters.js +65 -0
  19. package/dist/client/inbound-filters.js.map +1 -0
  20. package/dist/client/index.d.ts +7 -0
  21. package/dist/client/index.js +7 -0
  22. package/dist/client/mcp-resource-reader.d.ts +22 -0
  23. package/dist/client/mcp-resource-reader.d.ts.map +1 -0
  24. package/dist/client/mcp-resource-reader.js +113 -0
  25. package/dist/client/mcp-resource-reader.js.map +1 -0
  26. package/dist/client/pinning.js +41 -0
  27. package/dist/client/pinning.js.map +1 -0
  28. package/dist/client/to-tools.d.ts +41 -0
  29. package/dist/client/to-tools.d.ts.map +1 -0
  30. package/dist/client/to-tools.js +125 -0
  31. package/dist/client/to-tools.js.map +1 -0
  32. package/dist/client/transport-factory.js +86 -0
  33. package/dist/client/transport-factory.js.map +1 -0
  34. package/dist/client/types.d.ts +353 -0
  35. package/dist/client/types.d.ts.map +1 -0
  36. package/dist/errors/index.d.ts +120 -0
  37. package/dist/errors/index.d.ts.map +1 -0
  38. package/dist/errors/index.js +88 -0
  39. package/dist/errors/index.js.map +1 -0
  40. package/dist/helpers/identity.d.ts +22 -0
  41. package/dist/helpers/identity.d.ts.map +1 -0
  42. package/dist/helpers/identity.js +85 -0
  43. package/dist/helpers/identity.js.map +1 -0
  44. package/dist/helpers/index.d.ts +3 -0
  45. package/dist/helpers/index.js +4 -0
  46. package/dist/helpers/validate-config.d.ts +16 -0
  47. package/dist/helpers/validate-config.d.ts.map +1 -0
  48. package/dist/helpers/validate-config.js +61 -0
  49. package/dist/helpers/validate-config.js.map +1 -0
  50. package/dist/index.d.ts +60 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +58 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/oauth/bridge.d.ts +59 -0
  55. package/dist/oauth/bridge.d.ts.map +1 -0
  56. package/dist/oauth/bridge.js +75 -0
  57. package/dist/oauth/bridge.js.map +1 -0
  58. package/dist/oauth/index.d.ts +3 -0
  59. package/dist/oauth/index.js +4 -0
  60. package/dist/oauth/library.d.ts +23 -0
  61. package/dist/oauth/library.d.ts.map +1 -0
  62. package/dist/oauth/library.js +27 -0
  63. package/dist/oauth/library.js.map +1 -0
  64. package/dist/registry/json-schema.js +215 -0
  65. package/dist/registry/json-schema.js.map +1 -0
  66. package/dist/transport/index.d.ts +4 -0
  67. package/dist/transport/index.js +4 -0
  68. package/dist/transport/types.d.ts +93 -0
  69. package/dist/transport/types.d.ts.map +1 -0
  70. package/package.json +105 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # @graphorin/mcp
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Initial release. The `@graphorin/mcp` package ships the typed
8
+ Model Context Protocol client used by Graphorin agents, the
9
+ standalone server, and the CLI. The release surface includes the
10
+ three transports (`stdio`, `streamable-http`, `sse`), the typed
11
+ `MCPClient` (`listTools` / `listResources` / `listPrompts` /
12
+ `callTool` / `readResource` / `getPrompt` / `close`), the
13
+ strategy-aware `toTools()` adapter (per-server inbound
14
+ prompt-injection sanitization, deferred-loading auto-default at
15
+ the 10-tool threshold, structured-content + outputSchema
16
+ round-trip with backward-compatible `TextContent` mirror,
17
+ per-server result envelope overrides, collision-strategy +
18
+ per-server priority, per-server preferredModel + side-effect
19
+ class overrides), the pluggable `EventStore` contract for
20
+ resumable Streamable HTTP sessions (`Mcp-Session-Id` +
21
+ `Last-Event-ID` handshake), the OAuth bridge backed by
22
+ `@graphorin/security/oauth`, the typed error hierarchy, and the
23
+ helper functions consumed by `graphorin auth` (CLI). See the
24
+ package `README.md` for the full surface inventory and the
25
+ workspace `CHANGELOG.md` for the rollup release notes.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oleksiy Stepurenko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,296 @@
1
+ # @graphorin/mcp
2
+
3
+ > Model Context Protocol client for the Graphorin framework.
4
+
5
+ `@graphorin/mcp` ships the typed MCP client used by Graphorin
6
+ agents, the standalone server, and the CLI. The package wraps the
7
+ official `@modelcontextprotocol/sdk@^1.29.0` client primitives and
8
+ adds the Graphorin-specific glue: the `toTools()` adapter that
9
+ bridges MCP tool descriptors into the typed Graphorin tool
10
+ registry, the inbound prompt-injection sanitization defaults for
11
+ MCP-derived tools, the structured-content + outputSchema
12
+ round-trip with backward-compatible TextContent mirror, the
13
+ deferred-loading auto-default at the 10-tool threshold, the
14
+ collision-strategy + per-server priority handoff to the tool
15
+ registry, honest Streamable HTTP session semantics, and the OAuth
16
+ bridge that resolves bearer headers from
17
+ the existing outbound OAuth subsystem in `@graphorin/security`.
18
+
19
+ ## Highlights
20
+
21
+ - **Three transports out of the box.** `'stdio'` is the primary
22
+ transport for local MCP servers started as a child process;
23
+ `'streamable-http'` is the current default for remote servers
24
+ with optional Streamable HTTP session support; `'sse'` is the
25
+ deprecated legacy transport, kept for back-compat with servers
26
+ that have not migrated yet (the runtime emits one WARN per
27
+ process on selection).
28
+ - **Typed `MCPClient` surface.** `listTools` / `listResources` /
29
+ `listPrompts` / `callTool` / `readResource` / `getPrompt` /
30
+ `close` plus the strategy-aware `toTools(...)` adapter.
31
+ - **Strict default for MCP-derived tools.** Every `Tool` produced
32
+ by `MCPClient.toTools()` defaults to the
33
+ `'detect-and-strip-and-wrap'` inbound prompt-injection
34
+ sanitization policy (mirrored on tool-result bodies at execution
35
+ time) and the `'sandboxed'` sandbox policy. The trust class is
36
+ pinned to `'mcp-derived'` so the agent runtime's per-step
37
+ preamble fires regardless of body-level overrides.
38
+ - **Auto-deferral at the 10-tool threshold.** When the MCP
39
+ server's `listTools()` returns more than `deferLoadingThreshold`
40
+ entries (default `10`), the per-server default flips
41
+ `defer_loading: true` for every produced tool; an INFO-log
42
+ records the threshold and the tool names; the
43
+ `tool.retrieval.deferred.total` counter is incremented per
44
+ affected tool.
45
+ - **Structured-content round-trip.** When the MCP server
46
+ advertises an `outputSchema` on a tool definition (per the
47
+ current MCP spec) and returns `structuredContent` on `callTool`,
48
+ the adapter validates the structured form against the converted
49
+ Zod-compatible schema and surfaces it as the typed `Tool.execute`
50
+ output. The unstructured `content[]` is preserved in
51
+ `ToolReturn.contentParts` (including the backward-compatible
52
+ `TextContent` mirror); pre-spec servers that emit only `content[]`
53
+ fall through to the legacy concatenated-text behaviour.
54
+ - **Collision resolution at the registry boundary.** The client
55
+ exposes `serverIdentity`, `collisionStrategy` (default
56
+ `'auto-prefix'`), and the optional per-client `priority` field;
57
+ the registry consumes the trio when its strategy-aware
58
+ `assertNoDuplicates(strategy, ctx)` overload runs.
59
+ - **OAuth integration.** `createOAuthAuthorizationProvider({...})`
60
+ wraps the existing `refreshOAuthSession(...)` helper from
61
+ `@graphorin/security/oauth`, resolves the bearer header on every
62
+ request, debounces concurrent refreshes via the OAuth
63
+ subsystem's in-flight de-duplication, and emits the
64
+ `mcp.auth.expired` lifecycle event when a refresh fails.
65
+ - **Streamable HTTP sessions.** The client persists the assigned
66
+ `Mcp-Session-Id` and the SDK transport auto-reconnects with
67
+ `Last-Event-ID` after a transient disconnect. Event replay is the
68
+ SERVER's responsibility per the Streamable HTTP spec — the client
69
+ surfaces `sessionIdPresent` (stateful routing detected; not a
70
+ replay guarantee).
71
+
72
+ ## Stable sub-paths
73
+
74
+ ```ts
75
+ import { createMCPClient } from '@graphorin/mcp/client';
76
+ import { createOAuthAuthorizationProvider } from '@graphorin/mcp/oauth';
77
+ import {
78
+ formatMCPServerName,
79
+ validateMCPServerConfig,
80
+ } from '@graphorin/mcp/helpers';
81
+ import { MCPConnectionError, MCPProtocolError } from '@graphorin/mcp/errors';
82
+ import type {
83
+ MCPTransportConfig,
84
+ ServerIdentity,
85
+ } from '@graphorin/mcp/transport';
86
+ ```
87
+
88
+ ## Quick start
89
+
90
+ ```ts
91
+ import { createMCPClient } from '@graphorin/mcp';
92
+
93
+ // Local MCP server over stdio.
94
+ const fileSystem = await createMCPClient({
95
+ transport: {
96
+ kind: 'stdio',
97
+ command: 'pnpm',
98
+ args: ['dlx', '@modelcontextprotocol/server-filesystem', '/Users/me/Documents'],
99
+ },
100
+ });
101
+
102
+ // Remote MCP server over Streamable HTTP.
103
+ const issues = await createMCPClient({
104
+ transport: {
105
+ kind: 'streamable-http',
106
+ url: 'https://issues.example.com/mcp',
107
+ headers: { Authorization: 'Bearer ${TOKEN}' },
108
+ },
109
+ });
110
+
111
+ const tools = [
112
+ ...(await fileSystem.toTools({ namespace: 'fs' })),
113
+ ...(await issues.toTools({ namespace: 'issues' })),
114
+ ];
115
+
116
+ // `tools` is ready to register with `@graphorin/tools`'s
117
+ // `createToolRegistry({...})`; the agent runtime consumes the
118
+ // resulting registry as part of its per-step planner.
119
+
120
+ await fileSystem.close();
121
+ await issues.close();
122
+ ```
123
+
124
+ ## Inbound sanitization policy reference
125
+
126
+ The per-server `inboundSanitization` field controls how
127
+ imperative-pattern matches in tool result bodies (and tool
128
+ descriptions, at registration time) are handled:
129
+
130
+ | Policy | Description |
131
+ |---------------------------------|-----------------------------------------------------------------------------|
132
+ | `'pass-through'` | No scan; bytes-equal forwarding (use only for trusted in-house servers). |
133
+ | `'detect-and-flag'` | Scan; emit a flag span attribute + audit row but do not modify the body. |
134
+ | `'detect-and-strip'` | Replace each match with the `[REDACTED:imperative-pattern]` literal token. |
135
+ | `'detect-and-wrap'` | Wrap the body in `<<<untrusted_content>>>` without stripping matches. |
136
+ | `'detect-and-strip-and-wrap'` | **Default for MCP-derived tools.** Both strip matches and wrap the body. |
137
+
138
+ Tool descriptions are always sanitized at registration time using
139
+ the `'detect-and-strip'` variant of the configured policy (the
140
+ wrap envelope is reserved for tool-result bodies inside the
141
+ conversation history; the description goes into the per-step tool
142
+ catalogue verbatim aside from the strip pass).
143
+
144
+ ## OAuth integration
145
+
146
+ The package re-exports the OAuth library functions as the
147
+ operator-facing CLI helpers `mcpAuthLogin`, `mcpAuthListSessions`,
148
+ `mcpAuthRefresh`, `mcpAuthRevoke`, and `mcpAuthStatus`. They wrap
149
+ the lower-level `loginInteractive`, `listOAuthSessions`,
150
+ `refreshOAuthSession`, `revokeOAuthSession`, and `getOAuthStatus`
151
+ helpers in `@graphorin/security/oauth`. The corresponding CLI
152
+ binaries land in Phase 15.
153
+
154
+ ```ts
155
+ import {
156
+ createInMemoryOAuthServerStore,
157
+ loginInteractive,
158
+ } from '@graphorin/security/oauth';
159
+ import {
160
+ createOAuthAuthorizationProvider,
161
+ createMCPClient,
162
+ } from '@graphorin/mcp';
163
+
164
+ const storage = createInMemoryOAuthServerStore();
165
+ await loginInteractive({
166
+ serverId: 'issues-mcp',
167
+ serverUrl: 'https://issues.example.com/mcp',
168
+ storage,
169
+ });
170
+
171
+ const authProvider = createOAuthAuthorizationProvider({
172
+ serverId: 'issues-mcp',
173
+ storage,
174
+ });
175
+
176
+ const issues = await createMCPClient({
177
+ transport: {
178
+ kind: 'streamable-http',
179
+ url: 'https://issues.example.com/mcp',
180
+ },
181
+ // The client installs a per-request fetch-wrapper that calls
182
+ // `authProvider.resolveHeader()` on every outgoing request, so the
183
+ // refresh-ahead window fires automatically and a long-lived session
184
+ // survives token expiry without re-creating the client. Do **not**
185
+ // resolve the token once into static `headers` — that pins a single
186
+ // token and defeats the refresh.
187
+ authProvider,
188
+ });
189
+ ```
190
+
191
+ For a rare pre-shared token, pass `bearerToken` instead (mutually
192
+ exclusive with `authProvider`; supplying both throws
193
+ `MCPInvalidConfigError`):
194
+
195
+ ```ts
196
+ const issues = await createMCPClient({
197
+ transport: { kind: 'streamable-http', url: 'https://issues.example.com/mcp' },
198
+ bearerToken: process.env.ISSUES_MCP_TOKEN!,
199
+ });
200
+ ```
201
+
202
+ ## Streamable HTTP sessions
203
+
204
+ When the MCP server assigns an `Mcp-Session-Id` on `initialize`, the
205
+ client persists it for stateful routing and exposes
206
+ `client.sessionIdPresent` (`client.resumable` is its deprecated
207
+ alias). A session id is **not** a replay guarantee: per the
208
+ Streamable HTTP spec, event replay is the **server's**
209
+ responsibility, and the SDK transport already auto-reconnects with
210
+ the `Last-Event-ID` header after a transient disconnect — no client
211
+ configuration needed. (The former client-side `eventStore` option
212
+ was removed: a client-held store cannot drive replay; passing the
213
+ legacy option logs a warning.)
214
+
215
+ ### Reverse-proxy operational note
216
+
217
+ When a Graphorin client connects to a Streamable HTTP MCP server
218
+ through a reverse proxy (nginx, HAProxy, AWS ALB, Cloudflare, GCP
219
+ Load Balancer), the proxy MUST be configured to disable response
220
+ buffering on the SSE-style streaming response. Without the
221
+ directive the symptoms are:
222
+
223
+ - The client never receives `tool.execute.progress` /
224
+ `tool.execute.partial` events until the tool completes (defeats
225
+ the streaming purpose).
226
+ - The connection appears to hang from the client side until the
227
+ proxy buffer is full.
228
+ - Intermediate keep-alive timeouts fire because the proxy thinks
229
+ the connection is idle.
230
+
231
+ #### Canonical nginx snippet
232
+
233
+ ```nginx
234
+ location /mcp {
235
+ proxy_buffering off;
236
+ proxy_cache off;
237
+ chunked_transfer_encoding on;
238
+ proxy_read_timeout 600s;
239
+ }
240
+ ```
241
+
242
+ #### Analogous notes for other reverse proxies
243
+
244
+ - **AWS ALB.** Enable stickiness, raise the idle-timeout to at
245
+ least 600 s, and confirm WebSocket-style upgrade compatibility
246
+ is enabled.
247
+ - **Cloudflare.** Disable "Always Online" caching for the MCP
248
+ route and raise the proxy timeout via Cloudflare Tunnel.
249
+ - **GCP Load Balancer.** Configure the backend service with
250
+ `connectionDraining.drainingTimeoutSec >= 600` and
251
+ `timeoutSec >= 600`.
252
+
253
+ ## Errors
254
+
255
+ Every error class extends `GraphorinMCPError` and carries a
256
+ stable lowercase `kind` discriminator and an actionable `hint`
257
+ field where applicable:
258
+
259
+ - `MCPConnectionError` — transport could not be established or was dropped.
260
+ - `MCPProtocolError` — JSON-RPC / MCP protocol-level error.
261
+ - `MCPAuthError` — authentication / authorization failure.
262
+ - `MCPToolNotFoundError` — the requested tool is not registered with the server.
263
+ - `MCPCallTimeoutError` — tool call exceeded the configured timeout (variant `'session-lost'` for the resume-handshake-lost path).
264
+ - `MCPCancelledError` — call was cancelled by an `AbortSignal`.
265
+ - `MCPInvalidConfigError` — the supplied `MCPTransportConfig` is invalid.
266
+ - `MCPTransportNotSupportedError` — the supplied configuration requested an unsupported transport / capability combination (variant `'transport-resumable-not-supported'` for resumable sessions on `'stdio'` / `'sse'`).
267
+
268
+ ## Acceptance & testing
269
+
270
+ - 109 unit + integration + property tests run under Vitest with
271
+ no network calls (verified by the workspace
272
+ `pnpm run check-no-network` script).
273
+ - Integration tests use the SDK's `InMemoryTransport` linked-pair
274
+ + a configurable in-process `Server` fixture; no child
275
+ processes are spawned and no network round-trips happen during
276
+ the test run.
277
+ - Coverage thresholds: 80 % statements, 80 % lines, 80 %
278
+ functions, 70 % branches.
279
+
280
+ ## Dependencies
281
+
282
+ | Dependency | Pin | Purpose |
283
+ |----------------------------------|------------------|-----------------------------------------------|
284
+ | `@modelcontextprotocol/sdk` | `^1.29.0` | Underlying MCP client + transport primitives. |
285
+ | `@graphorin/core` | workspace | `Tool` / `MessageContent` / `ZodLikeSchema`. |
286
+ | `@graphorin/security` | workspace | OAuth client + lifecycle event surface. |
287
+ | `@graphorin/observability` | workspace | Imperative-pattern catalogue. |
288
+ | `@graphorin/tools` | workspace | Inbound sanitization helper + counter registry. |
289
+
290
+ ## License
291
+
292
+ MIT — © 2026 Oleksiy Stepurenko.
293
+
294
+ ---
295
+
296
+ **Project Graphorin** · v0.5.0 · MIT License · © 2026 Oleksiy Stepurenko · <https://github.com/o-stepper/graphorin>
@@ -0,0 +1,25 @@
1
+ import { ServerIdentity } from "../transport/types.js";
2
+ import { MCPCallToolResult } from "./types.js";
3
+ import { ToolReturn, ZodLikeSchema } from "@graphorin/core";
4
+
5
+ //#region src/client/adapt-result.d.ts
6
+
7
+ /** Arguments for {@link adaptCallResult}. */
8
+ interface AdaptCallResultArgs {
9
+ readonly result: MCPCallToolResult;
10
+ readonly outputSchema?: ZodLikeSchema<unknown> | undefined;
11
+ readonly serverIdentity: ServerIdentity;
12
+ readonly toolName: string;
13
+ readonly logger?: (level: 'debug' | 'info' | 'warn' | 'error', message: string, fields?: Record<string, unknown>) => void;
14
+ }
15
+ /**
16
+ * Convert an MCP `CallToolResult` into a typed Graphorin `ToolReturn`,
17
+ * handling the structured-content + outputSchema round-trip and the
18
+ * backward-compatible TextContent mirror.
19
+ *
20
+ * @internal
21
+ */
22
+ declare function adaptCallResult(args: AdaptCallResultArgs): ToolReturn<unknown>;
23
+ //#endregion
24
+ export { adaptCallResult };
25
+ //# sourceMappingURL=adapt-result.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapt-result.d.ts","names":[],"sources":["../../src/client/adapt-result.ts"],"sourcesContent":[],"mappings":";;;;;;;UAmBiB,mBAAA;mBACE;0BACO;2BACC;;2FAKd;;;;;;;;;iBAWG,eAAA,OAAsB,sBAAsB"}
@@ -0,0 +1,174 @@
1
+ import { MCPToolExecutionError } from "../errors/index.js";
2
+ import { incrementCounter } from "@graphorin/tools/audit";
3
+
4
+ //#region src/client/adapt-result.ts
5
+ /**
6
+ * Convert an MCP `CallToolResult` into a typed Graphorin `ToolReturn`,
7
+ * handling the structured-content + outputSchema round-trip and the
8
+ * backward-compatible TextContent mirror.
9
+ *
10
+ * @internal
11
+ */
12
+ function adaptCallResult(args) {
13
+ const { result, outputSchema, serverIdentity, toolName } = args;
14
+ if (result.isError === true) {
15
+ const errorText = (result.content ?? []).filter((p) => p.type === "text").map((p) => p.text).join("\n");
16
+ incrementCounter("mcp.call.tool-error.total", {
17
+ server: serverIdentity.id,
18
+ tool: toolName
19
+ });
20
+ throw new MCPToolExecutionError(errorText.length > 0 ? errorText : `MCP tool '${toolName}' reported an error result.`, { metadata: { tool: toolName } });
21
+ }
22
+ const contentParts = [];
23
+ const resourceLinkPreviews = [];
24
+ for (const part of result.content) {
25
+ if (part.type === "resource_link") {
26
+ incrementCounter("mcp.resource-link.emitted.total", {
27
+ server: serverIdentity.id,
28
+ tool: toolName
29
+ });
30
+ resourceLinkPreviews.push(formatResourceLinkPreview(part));
31
+ }
32
+ const messagePart = mcpContentToMessageContent(part);
33
+ if (messagePart !== void 0) contentParts.push(messagePart);
34
+ }
35
+ const textParts = (result.content ?? []).filter((p) => p.type === "text");
36
+ const outputSegments = [];
37
+ for (const part of result.content ?? []) switch (part.type) {
38
+ case "text":
39
+ outputSegments.push(part.text);
40
+ break;
41
+ case "image":
42
+ case "audio":
43
+ outputSegments.push(describeBinaryPart(part.type, part.mimeType, part.data));
44
+ break;
45
+ case "resource":
46
+ if (part.resource.text !== void 0) outputSegments.push(part.resource.text);
47
+ else if (part.resource.blob !== void 0) outputSegments.push(`[resource ${part.resource.uri} ${part.resource.mimeType ?? "application/octet-stream"}, ~${approxDecodedSize(part.resource.blob)} — full payload in contentParts]`);
48
+ else outputSegments.push(`Resource ${part.resource.uri}`);
49
+ break;
50
+ case "resource_link": break;
51
+ }
52
+ const concatenatedText = [...outputSegments, ...resourceLinkPreviews].join("\n");
53
+ if (result.structuredContent !== void 0) if (outputSchema !== void 0) {
54
+ const validation = outputSchema.safeParse(result.structuredContent);
55
+ if (validation.success) {
56
+ incrementCounter("mcp.structured-content.emitted.total", {
57
+ server: serverIdentity.id,
58
+ tool: toolName
59
+ });
60
+ const finalParts = [...contentParts];
61
+ if (textParts.length === 0) finalParts.push({
62
+ type: "text",
63
+ text: JSON.stringify(result.structuredContent)
64
+ });
65
+ return Object.freeze({
66
+ output: validation.data,
67
+ contentParts: Object.freeze(finalParts)
68
+ });
69
+ }
70
+ incrementCounter("mcp.structured-content.validation.failure.total", {
71
+ server: serverIdentity.id,
72
+ tool: toolName
73
+ });
74
+ args.logger?.("warn", "mcp.structured-content.validation.failed: payload mirrored as JSON text", {
75
+ server: serverIdentity.id,
76
+ tool: toolName
77
+ });
78
+ const fallbackParts = [...contentParts];
79
+ if (textParts.length === 0) fallbackParts.push({
80
+ type: "text",
81
+ text: JSON.stringify(result.structuredContent)
82
+ });
83
+ return Object.freeze({
84
+ output: concatenatedText.length > 0 ? concatenatedText : JSON.stringify(result.structuredContent),
85
+ contentParts: Object.freeze(fallbackParts)
86
+ });
87
+ } else {
88
+ incrementCounter("mcp.structured-content.emitted.total", {
89
+ server: serverIdentity.id,
90
+ tool: toolName
91
+ });
92
+ const finalParts = [...contentParts];
93
+ if (textParts.length === 0) finalParts.push({
94
+ type: "text",
95
+ text: JSON.stringify(result.structuredContent)
96
+ });
97
+ return Object.freeze({
98
+ output: result.structuredContent,
99
+ contentParts: Object.freeze(finalParts)
100
+ });
101
+ }
102
+ return Object.freeze({
103
+ output: concatenatedText,
104
+ contentParts: Object.freeze(contentParts)
105
+ });
106
+ }
107
+ function mcpContentToMessageContent(part) {
108
+ switch (part.type) {
109
+ case "text": return {
110
+ type: "text",
111
+ text: part.text
112
+ };
113
+ case "image": return {
114
+ type: "image",
115
+ image: decodeBase64(part.data),
116
+ mimeType: part.mimeType
117
+ };
118
+ case "audio": return {
119
+ type: "audio",
120
+ audio: decodeBase64(part.data),
121
+ mimeType: part.mimeType
122
+ };
123
+ case "resource": {
124
+ const text = part.resource.text;
125
+ if (text !== void 0) return {
126
+ type: "text",
127
+ text
128
+ };
129
+ const blob = part.resource.blob;
130
+ if (blob !== void 0) return {
131
+ type: "file",
132
+ file: decodeBase64(blob),
133
+ mimeType: part.resource.mimeType ?? "application/octet-stream"
134
+ };
135
+ return {
136
+ type: "text",
137
+ text: `Resource ${part.resource.uri}`
138
+ };
139
+ }
140
+ case "resource_link": return {
141
+ type: "text",
142
+ text: formatResourceLinkPreview(part)
143
+ };
144
+ }
145
+ }
146
+ /**
147
+ * Render the compact, model-facing preview for a `resource_link`. The
148
+ * `uri` doubles as the `read_result` handle — an
149
+ * {@link import('./mcp-resource-reader.js').createMcpResourceReader}
150
+ * resolves it on demand via {@link MCPClient.readResource}.
151
+ */
152
+ function formatResourceLinkPreview(part) {
153
+ const label = part.title === void 0 || part.title.length === 0 ? part.name : part.title;
154
+ const meta = [];
155
+ if (part.mimeType !== void 0) meta.push(part.mimeType);
156
+ if (part.size !== void 0) meta.push(`${part.size} bytes`);
157
+ return `[resource_link] ${label}${meta.length === 0 ? "" : ` (${meta.join(", ")})`}${part.description === void 0 || part.description.length === 0 ? "" : ` — ${part.description}`}\nFetch the full contents on demand with read_result, handle: ${part.uri}`;
158
+ }
159
+ /** Human-readable size of a base64 payload's decoded bytes (MC-8). */
160
+ function approxDecodedSize(base64) {
161
+ const bytes = Math.floor(base64.length * 3 / 4);
162
+ return bytes >= 1024 ? `${Math.round(bytes / 1024)}kB` : `${bytes}B`;
163
+ }
164
+ /** Text descriptor for a non-text content part (MC-8). */
165
+ function describeBinaryPart(kind, mimeType, data) {
166
+ return `[${kind} ${mimeType}, ~${approxDecodedSize(data)} — full payload in contentParts]`;
167
+ }
168
+ function decodeBase64(value) {
169
+ return Uint8Array.from(Buffer.from(value, "base64"));
170
+ }
171
+
172
+ //#endregion
173
+ export { adaptCallResult };
174
+ //# sourceMappingURL=adapt-result.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapt-result.js","names":["contentParts: MessageContent[]","resourceLinkPreviews: string[]","outputSegments: string[]","meta: string[]"],"sources":["../../src/client/adapt-result.ts"],"sourcesContent":["/**\n * Result adaptation for the `toTools()` adapter (extracted from\n * `to-tools.ts` per F-MCP-001).\n *\n * Converts an MCP `CallToolResult` into a typed Graphorin `ToolReturn`,\n * handling the structured-content + outputSchema round-trip and the\n * backward-compatible TextContent mirror, and mapping each MCP content\n * part onto a Graphorin {@link MessageContent}.\n *\n * @packageDocumentation\n */\n\nimport type { MessageContent, ToolReturn, ZodLikeSchema } from '@graphorin/core';\nimport { incrementCounter } from '@graphorin/tools/audit';\nimport { MCPToolExecutionError } from '../errors/index.js';\nimport type { ServerIdentity } from '../transport/types.js';\nimport type { MCPCallToolResult, MCPContentPart } from './types.js';\n\n/** Arguments for {@link adaptCallResult}. */\nexport interface AdaptCallResultArgs {\n readonly result: MCPCallToolResult;\n readonly outputSchema?: ZodLikeSchema<unknown> | undefined;\n readonly serverIdentity: ServerIdentity;\n readonly toolName: string;\n readonly logger?: (\n level: 'debug' | 'info' | 'warn' | 'error',\n message: string,\n fields?: Record<string, unknown>,\n ) => void;\n}\n\n/**\n * Convert an MCP `CallToolResult` into a typed Graphorin `ToolReturn`,\n * handling the structured-content + outputSchema round-trip and the\n * backward-compatible TextContent mirror.\n *\n * @internal\n */\nexport function adaptCallResult(args: AdaptCallResultArgs): ToolReturn<unknown> {\n const { result, outputSchema, serverIdentity, toolName } = args;\n // MC-4: the SDK deliberately does NOT throw on isError results — the\n // failure marker must not launder into a successful ToolReturn. Throw\n // the typed error so the executor records a real failure; the server's\n // text rides in the message for model self-correction.\n if (result.isError === true) {\n const errorText = (result.content ?? [])\n .filter((p): p is { type: 'text'; text: string } => p.type === 'text')\n .map((p) => p.text)\n .join('\\n');\n incrementCounter('mcp.call.tool-error.total', {\n server: serverIdentity.id,\n tool: toolName,\n });\n throw new MCPToolExecutionError(\n errorText.length > 0 ? errorText : `MCP tool '${toolName}' reported an error result.`,\n { metadata: { tool: toolName } },\n );\n }\n const contentParts: MessageContent[] = [];\n // `resource_link` parts are NOT inlined: each contributes a compact\n // preview (carrying the `uri` as a result handle) so the model fetches\n // the body on demand via `read_result` instead of inflating context.\n const resourceLinkPreviews: string[] = [];\n for (const part of result.content) {\n if (part.type === 'resource_link') {\n incrementCounter('mcp.resource-link.emitted.total', {\n server: serverIdentity.id,\n tool: toolName,\n });\n resourceLinkPreviews.push(formatResourceLinkPreview(part));\n }\n const messagePart = mcpContentToMessageContent(part);\n if (messagePart !== undefined) contentParts.push(messagePart);\n }\n const textParts = (result.content ?? []).filter(\n (p): p is { type: 'text'; text: string } => p.type === 'text',\n );\n // MC-8: the typed `output` is the ONLY channel the agent loop\n // serialises into the tool message — non-text parts must leave a\n // text trace there (the full payloads stay in `contentParts`), and\n // embedded resource TEXT joins the concatenation outright.\n const outputSegments: string[] = [];\n for (const part of result.content ?? []) {\n switch (part.type) {\n case 'text':\n outputSegments.push(part.text);\n break;\n case 'image':\n case 'audio':\n outputSegments.push(describeBinaryPart(part.type, part.mimeType, part.data));\n break;\n case 'resource': {\n if (part.resource.text !== undefined) {\n outputSegments.push(part.resource.text);\n } else if (part.resource.blob !== undefined) {\n outputSegments.push(\n `[resource ${part.resource.uri} ${part.resource.mimeType ?? 'application/octet-stream'}, ~${approxDecodedSize(part.resource.blob)} — full payload in contentParts]`,\n );\n } else {\n outputSegments.push(`Resource ${part.resource.uri}`);\n }\n break;\n }\n case 'resource_link':\n // Joined below via the read_result preview.\n break;\n }\n }\n const concatenatedText = [...outputSegments, ...resourceLinkPreviews].join('\\n');\n\n if (result.structuredContent !== undefined) {\n if (outputSchema !== undefined) {\n const validation = outputSchema.safeParse(result.structuredContent);\n if (validation.success) {\n incrementCounter('mcp.structured-content.emitted.total', {\n server: serverIdentity.id,\n tool: toolName,\n });\n const finalParts = [...contentParts];\n if (textParts.length === 0) {\n finalParts.push({ type: 'text', text: JSON.stringify(result.structuredContent) });\n }\n return Object.freeze({\n output: validation.data,\n contentParts: Object.freeze(finalParts),\n });\n }\n incrementCounter('mcp.structured-content.validation.failure.total', {\n server: serverIdentity.id,\n tool: toolName,\n });\n // MC-11: a schema mismatch must not silently drop the payload —\n // log and mirror the structured content as JSON text, exactly\n // like the no-schema branch does.\n args.logger?.(\n 'warn',\n 'mcp.structured-content.validation.failed: payload mirrored as JSON text',\n { server: serverIdentity.id, tool: toolName },\n );\n const fallbackParts = [...contentParts];\n if (textParts.length === 0) {\n fallbackParts.push({ type: 'text', text: JSON.stringify(result.structuredContent) });\n }\n return Object.freeze({\n output:\n concatenatedText.length > 0 ? concatenatedText : JSON.stringify(result.structuredContent),\n contentParts: Object.freeze(fallbackParts),\n });\n } else {\n incrementCounter('mcp.structured-content.emitted.total', {\n server: serverIdentity.id,\n tool: toolName,\n });\n const finalParts = [...contentParts];\n if (textParts.length === 0) {\n finalParts.push({ type: 'text', text: JSON.stringify(result.structuredContent) });\n }\n return Object.freeze({\n output: result.structuredContent,\n contentParts: Object.freeze(finalParts),\n });\n }\n }\n\n return Object.freeze({\n output: concatenatedText,\n contentParts: Object.freeze(contentParts),\n });\n}\n\nfunction mcpContentToMessageContent(part: MCPContentPart): MessageContent | undefined {\n switch (part.type) {\n case 'text':\n return { type: 'text', text: part.text };\n case 'image':\n return {\n type: 'image',\n image: decodeBase64(part.data),\n mimeType: part.mimeType,\n };\n case 'audio':\n return {\n type: 'audio',\n audio: decodeBase64(part.data),\n mimeType: part.mimeType,\n };\n case 'resource': {\n const text = part.resource.text;\n if (text !== undefined) {\n return { type: 'text', text };\n }\n const blob = part.resource.blob;\n if (blob !== undefined) {\n return {\n type: 'file',\n file: decodeBase64(blob),\n mimeType: part.resource.mimeType ?? 'application/octet-stream',\n };\n }\n return { type: 'text', text: `Resource ${part.resource.uri}` };\n }\n case 'resource_link':\n return { type: 'text', text: formatResourceLinkPreview(part) };\n }\n}\n\n/**\n * Render the compact, model-facing preview for a `resource_link`. The\n * `uri` doubles as the `read_result` handle — an\n * {@link import('./mcp-resource-reader.js').createMcpResourceReader}\n * resolves it on demand via {@link MCPClient.readResource}.\n */\nfunction formatResourceLinkPreview(\n part: Extract<MCPContentPart, { type: 'resource_link' }>,\n): string {\n const label = part.title === undefined || part.title.length === 0 ? part.name : part.title;\n const meta: string[] = [];\n if (part.mimeType !== undefined) meta.push(part.mimeType);\n if (part.size !== undefined) meta.push(`${part.size} bytes`);\n const metaStr = meta.length === 0 ? '' : ` (${meta.join(', ')})`;\n const desc =\n part.description === undefined || part.description.length === 0 ? '' : ` — ${part.description}`;\n return `[resource_link] ${label}${metaStr}${desc}\\nFetch the full contents on demand with read_result, handle: ${part.uri}`;\n}\n\n/** Human-readable size of a base64 payload's decoded bytes (MC-8). */\nfunction approxDecodedSize(base64: string): string {\n const bytes = Math.floor((base64.length * 3) / 4);\n return bytes >= 1024 ? `${Math.round(bytes / 1024)}kB` : `${bytes}B`;\n}\n\n/** Text descriptor for a non-text content part (MC-8). */\nfunction describeBinaryPart(kind: 'image' | 'audio', mimeType: string, data: string): string {\n return `[${kind} ${mimeType}, ~${approxDecodedSize(data)} — full payload in contentParts]`;\n}\n\nfunction decodeBase64(value: string): Uint8Array {\n return Uint8Array.from(Buffer.from(value, 'base64'));\n}\n"],"mappings":"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"}