@graphorin/mcp 0.6.0 → 0.7.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 +55 -0
  2. package/README.md +73 -5
  3. package/dist/client/adapt-result.d.ts +9 -1
  4. package/dist/client/adapt-result.d.ts.map +1 -1
  5. package/dist/client/adapt-result.js +28 -10
  6. package/dist/client/adapt-result.js.map +1 -1
  7. package/dist/client/client-handlers.js +7 -1
  8. package/dist/client/client-handlers.js.map +1 -1
  9. package/dist/client/client.d.ts.map +1 -1
  10. package/dist/client/client.js +47 -71
  11. package/dist/client/client.js.map +1 -1
  12. package/dist/client/inbound-filters.js +101 -1
  13. package/dist/client/inbound-filters.js.map +1 -1
  14. package/dist/client/index.d.ts +2 -1
  15. package/dist/client/index.js +2 -1
  16. package/dist/client/managed.d.ts +35 -0
  17. package/dist/client/managed.d.ts.map +1 -0
  18. package/dist/client/managed.js +136 -0
  19. package/dist/client/managed.js.map +1 -0
  20. package/dist/client/mcp-resource-reader.js +1 -1
  21. package/dist/client/mcp-resource-reader.js.map +1 -1
  22. package/dist/client/to-tools-run.js +119 -0
  23. package/dist/client/to-tools-run.js.map +1 -0
  24. package/dist/client/to-tools.d.ts +8 -0
  25. package/dist/client/to-tools.d.ts.map +1 -1
  26. package/dist/client/to-tools.js +27 -4
  27. package/dist/client/to-tools.js.map +1 -1
  28. package/dist/client/types.d.ts +12 -3
  29. package/dist/client/types.d.ts.map +1 -1
  30. package/dist/errors/index.d.ts +3 -3
  31. package/dist/errors/index.js +1 -1
  32. package/dist/errors/index.js.map +1 -1
  33. package/dist/helpers/identity.d.ts +11 -0
  34. package/dist/helpers/identity.d.ts.map +1 -1
  35. package/dist/helpers/identity.js +13 -2
  36. package/dist/helpers/identity.js.map +1 -1
  37. package/dist/index.d.ts +4 -4
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +6 -6
  40. package/dist/index.js.map +1 -1
  41. package/dist/package.js +6 -0
  42. package/dist/package.js.map +1 -0
  43. package/dist/registry/json-schema.js +26 -8
  44. package/dist/registry/json-schema.js.map +1 -1
  45. package/dist/transport/types.d.ts +9 -0
  46. package/package.json +13 -12
  47. package/src/client/adapt-result.ts +302 -0
  48. package/src/client/client-handlers.ts +215 -0
  49. package/src/client/client.ts +725 -0
  50. package/src/client/defer-loading.ts +108 -0
  51. package/src/client/inbound-filters.ts +246 -0
  52. package/src/client/index.ts +42 -0
  53. package/src/client/managed.ts +222 -0
  54. package/src/client/mcp-resource-reader.ts +183 -0
  55. package/src/client/pinning.ts +48 -0
  56. package/src/client/to-tools-run.ts +178 -0
  57. package/src/client/to-tools.ts +294 -0
  58. package/src/client/transport-factory.ts +117 -0
  59. package/src/client/types.ts +422 -0
  60. package/src/errors/index.ts +170 -0
  61. package/src/helpers/identity.ts +128 -0
  62. package/src/helpers/index.ts +8 -0
  63. package/src/helpers/validate-config.ts +95 -0
  64. package/src/index.ts +49 -0
  65. package/src/oauth/bridge.ts +143 -0
  66. package/src/oauth/index.ts +18 -0
  67. package/src/oauth/library.ts +61 -0
  68. package/src/registry/json-schema.ts +402 -0
  69. package/src/transport/index.ts +15 -0
  70. package/src/transport/types.ts +108 -0
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Result adaptation for the `toTools()` adapter (extracted from
3
+ * `to-tools.ts` per F-MCP-001).
4
+ *
5
+ * Converts an MCP `CallToolResult` into a typed Graphorin `ToolReturn`,
6
+ * handling the structured-content + outputSchema round-trip and the
7
+ * backward-compatible TextContent mirror, and mapping each MCP content
8
+ * part onto a Graphorin {@link MessageContent}.
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+
13
+ import type {
14
+ InboundSanitizationPolicy,
15
+ MessageContent,
16
+ ToolReturn,
17
+ ZodLikeSchema,
18
+ } from '@graphorin/core';
19
+ import { toolReturn } from '@graphorin/core';
20
+ import { incrementCounter } from '@graphorin/tools/audit';
21
+ import { applyInboundSanitization } from '@graphorin/tools/inbound';
22
+ import { MCPToolExecutionError } from '../errors/index.js';
23
+ import type { ServerIdentity } from '../transport/types.js';
24
+ import type { MCPCallToolResult, MCPContentPart } from './types.js';
25
+
26
+ /** Arguments for {@link adaptCallResult}. */
27
+ export interface AdaptCallResultArgs {
28
+ readonly result: MCPCallToolResult;
29
+ readonly outputSchema?: ZodLikeSchema<unknown> | undefined;
30
+ readonly serverIdentity: ServerIdentity;
31
+ readonly toolName: string;
32
+ /**
33
+ * Effective per-server inbound-sanitization policy, applied to the
34
+ * `isError` text before it rides into `MCPToolExecutionError` (the
35
+ * executor never sanitizes the error path, so the trust-aware MCP
36
+ * boundary must). Defaults to the MCP-strict
37
+ * `'detect-and-strip-and-wrap'` when omitted.
38
+ */
39
+ readonly inboundSanitization?: InboundSanitizationPolicy;
40
+ readonly logger?: (
41
+ level: 'debug' | 'info' | 'warn' | 'error',
42
+ message: string,
43
+ fields?: Record<string, unknown>,
44
+ ) => void;
45
+ }
46
+
47
+ /**
48
+ * Convert an MCP `CallToolResult` into a typed Graphorin `ToolReturn`,
49
+ * handling the structured-content + outputSchema round-trip and the
50
+ * backward-compatible TextContent mirror.
51
+ *
52
+ * @internal
53
+ */
54
+ export function adaptCallResult(args: AdaptCallResultArgs): ToolReturn<unknown> {
55
+ const { result, outputSchema, serverIdentity, toolName } = args;
56
+ // MC-4: the SDK deliberately does NOT throw on isError results - the
57
+ // failure marker must not launder into a successful ToolReturn. Throw
58
+ // the typed error so the executor records a real failure; the server's
59
+ // text rides in the message for model self-correction.
60
+ if (result.isError === true) {
61
+ const errorText = (result.content ?? [])
62
+ .filter((p): p is { type: 'text'; text: string } => p.type === 'text')
63
+ .map((p) => p.text)
64
+ .join('\n');
65
+ incrementCounter('mcp.call.tool-error.total', {
66
+ server: serverIdentity.id,
67
+ tool: toolName,
68
+ });
69
+ // W-017: the error text is mcp-derived and reaches the model as
70
+ // `ToolError.message` WITHOUT passing the executor's success-path
71
+ // sanitization (`describe(executeError)` never sanitizes - it
72
+ // cannot tell an untrusted MCP error apart from a trusted
73
+ // first-party one). The trust class IS known here, so sanitize at
74
+ // the source with the same policy the adapted tool declares:
75
+ // parity with the success path, including an operator's explicit
76
+ // `pass-through` override.
77
+ let message = errorText;
78
+ if (errorText.length > 0) {
79
+ const outcome = applyInboundSanitization({
80
+ body: errorText,
81
+ policy: args.inboundSanitization ?? 'detect-and-strip-and-wrap',
82
+ trustClass: 'mcp-derived',
83
+ toolName,
84
+ contentOrigin: `mcp:tool-error:${serverIdentity.id}`,
85
+ failClosed: false,
86
+ });
87
+ if (outcome.patternsHit.length > 0) {
88
+ incrementCounter('mcp.tool-error.injection-flagged.total', {
89
+ server: serverIdentity.id,
90
+ tool: toolName,
91
+ });
92
+ }
93
+ message = outcome.body;
94
+ }
95
+ throw new MCPToolExecutionError(
96
+ message.length > 0 ? message : `MCP tool '${toolName}' reported an error result.`,
97
+ { metadata: { tool: toolName } },
98
+ );
99
+ }
100
+ const contentParts: MessageContent[] = [];
101
+ // `resource_link` parts are NOT inlined: each contributes a compact
102
+ // preview (carrying the `uri` as a result handle) so the model fetches
103
+ // the body on demand via `read_result` instead of inflating context.
104
+ const resourceLinkPreviews: string[] = [];
105
+ for (const part of result.content) {
106
+ if (part.type === 'resource_link') {
107
+ incrementCounter('mcp.resource-link.emitted.total', {
108
+ server: serverIdentity.id,
109
+ tool: toolName,
110
+ });
111
+ resourceLinkPreviews.push(formatResourceLinkPreview(part, serverIdentity.id));
112
+ }
113
+ const messagePart = mcpContentToMessageContent(part, serverIdentity.id);
114
+ if (messagePart !== undefined) contentParts.push(messagePart);
115
+ }
116
+ const textParts = (result.content ?? []).filter(
117
+ (p): p is { type: 'text'; text: string } => p.type === 'text',
118
+ );
119
+ // MC-8: the typed `output` is the ONLY channel the agent loop
120
+ // serialises into the tool message - non-text parts must leave a
121
+ // text trace there (the full payloads stay in `contentParts`), and
122
+ // embedded resource TEXT joins the concatenation outright.
123
+ const outputSegments: string[] = [];
124
+ for (const part of result.content ?? []) {
125
+ switch (part.type) {
126
+ case 'text':
127
+ outputSegments.push(part.text);
128
+ break;
129
+ case 'image':
130
+ case 'audio':
131
+ outputSegments.push(describeBinaryPart(part.type, part.mimeType, part.data));
132
+ break;
133
+ case 'resource': {
134
+ if (part.resource.text !== undefined) {
135
+ outputSegments.push(part.resource.text);
136
+ } else if (part.resource.blob !== undefined) {
137
+ outputSegments.push(
138
+ `[resource ${part.resource.uri} ${part.resource.mimeType ?? 'application/octet-stream'}, ~${approxDecodedSize(part.resource.blob)} - full payload in contentParts]`,
139
+ );
140
+ } else {
141
+ outputSegments.push(`Resource ${part.resource.uri}`);
142
+ }
143
+ break;
144
+ }
145
+ case 'resource_link':
146
+ // Joined below via the read_result preview.
147
+ break;
148
+ }
149
+ }
150
+ const concatenatedText = [...outputSegments, ...resourceLinkPreviews].join('\n');
151
+
152
+ if (result.structuredContent !== undefined) {
153
+ if (outputSchema !== undefined) {
154
+ const validation = outputSchema.safeParse(result.structuredContent);
155
+ if (validation.success) {
156
+ incrementCounter('mcp.structured-content.emitted.total', {
157
+ server: serverIdentity.id,
158
+ tool: toolName,
159
+ });
160
+ const finalParts = [...contentParts];
161
+ if (textParts.length === 0) {
162
+ finalParts.push({ type: 'text', text: JSON.stringify(result.structuredContent) });
163
+ }
164
+ return Object.freeze(
165
+ toolReturn({ output: validation.data, contentParts: Object.freeze(finalParts) }),
166
+ );
167
+ }
168
+ incrementCounter('mcp.structured-content.validation.failure.total', {
169
+ server: serverIdentity.id,
170
+ tool: toolName,
171
+ });
172
+ // MC-11: a schema mismatch must not silently drop the payload -
173
+ // log and mirror the structured content as JSON text, exactly
174
+ // like the no-schema branch does.
175
+ args.logger?.(
176
+ 'warn',
177
+ 'mcp.structured-content.validation.failed: payload mirrored as JSON text',
178
+ { server: serverIdentity.id, tool: toolName },
179
+ );
180
+ const fallbackParts = [...contentParts];
181
+ if (textParts.length === 0) {
182
+ fallbackParts.push({ type: 'text', text: JSON.stringify(result.structuredContent) });
183
+ }
184
+ return Object.freeze(
185
+ toolReturn({
186
+ output:
187
+ concatenatedText.length > 0
188
+ ? concatenatedText
189
+ : JSON.stringify(result.structuredContent),
190
+ contentParts: Object.freeze(fallbackParts),
191
+ }),
192
+ );
193
+ } else {
194
+ incrementCounter('mcp.structured-content.emitted.total', {
195
+ server: serverIdentity.id,
196
+ tool: toolName,
197
+ });
198
+ const finalParts = [...contentParts];
199
+ if (textParts.length === 0) {
200
+ finalParts.push({ type: 'text', text: JSON.stringify(result.structuredContent) });
201
+ }
202
+ return Object.freeze(
203
+ toolReturn({ output: result.structuredContent, contentParts: Object.freeze(finalParts) }),
204
+ );
205
+ }
206
+ }
207
+
208
+ return Object.freeze(
209
+ toolReturn({ output: concatenatedText, contentParts: Object.freeze(contentParts) }),
210
+ );
211
+ }
212
+
213
+ function mcpContentToMessageContent(
214
+ part: MCPContentPart,
215
+ serverId: string,
216
+ ): MessageContent | undefined {
217
+ switch (part.type) {
218
+ case 'text':
219
+ return { type: 'text', text: part.text };
220
+ case 'image':
221
+ return {
222
+ type: 'image',
223
+ image: decodeBase64(part.data),
224
+ mimeType: part.mimeType,
225
+ };
226
+ case 'audio':
227
+ return {
228
+ type: 'audio',
229
+ audio: decodeBase64(part.data),
230
+ mimeType: part.mimeType,
231
+ };
232
+ case 'resource': {
233
+ const text = part.resource.text;
234
+ if (text !== undefined) {
235
+ return { type: 'text', text };
236
+ }
237
+ const blob = part.resource.blob;
238
+ if (blob !== undefined) {
239
+ return {
240
+ type: 'file',
241
+ file: decodeBase64(blob),
242
+ mimeType: part.resource.mimeType ?? 'application/octet-stream',
243
+ };
244
+ }
245
+ return { type: 'text', text: `Resource ${part.resource.uri}` };
246
+ }
247
+ case 'resource_link':
248
+ return { type: 'text', text: formatResourceLinkPreview(part, serverId) };
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Render the compact, model-facing preview for a `resource_link`. The
254
+ * handle is SCOPED to the originating server (mcp-skills-06):
255
+ * `mcp:<serverId>:<uri>` - an
256
+ * {@link import('./mcp-resource-reader.js').createMcpResourceReader}
257
+ * parses the prefix and consults ONLY that server, so a malicious
258
+ * server's link (or a prompt-injected model) cannot fetch a resource
259
+ * from a different, more-trusted server through the handle.
260
+ */
261
+ function formatResourceLinkPreview(
262
+ part: Extract<MCPContentPart, { type: 'resource_link' }>,
263
+ serverId: string,
264
+ ): string {
265
+ const label = part.title === undefined || part.title.length === 0 ? part.name : part.title;
266
+ const meta: string[] = [];
267
+ if (part.mimeType !== undefined) meta.push(part.mimeType);
268
+ if (part.size !== undefined) meta.push(`${part.size} bytes`);
269
+ const metaStr = meta.length === 0 ? '' : ` (${meta.join(', ')})`;
270
+ const desc =
271
+ part.description === undefined || part.description.length === 0 ? '' : ` - ${part.description}`;
272
+ return `[resource_link] ${label}${metaStr}${desc}\nFetch the full contents on demand with read_result, handle: ${scopedResourceHandle(serverId, part.uri)}`;
273
+ }
274
+
275
+ /**
276
+ * Build the server-scoped `read_result` handle for an MCP resource
277
+ * (mcp-skills-06): `mcp:<serverId>:<uri>`.
278
+ *
279
+ * @stable
280
+ */
281
+ export function scopedResourceHandle(serverId: string, uri: string): string {
282
+ // W-140: the transport-derived id (W-016) routinely contains ':'
283
+ // (localhost:3001) which would break the `mcp:<id>:<uri>` grammar -
284
+ // percent-encode the id segment; the reader decodes before matching.
285
+ // Handles are ephemeral (minted per result), so no migration.
286
+ return `mcp:${encodeURIComponent(serverId)}:${uri}`;
287
+ }
288
+
289
+ /** Human-readable size of a base64 payload's decoded bytes (MC-8). */
290
+ function approxDecodedSize(base64: string): string {
291
+ const bytes = Math.floor((base64.length * 3) / 4);
292
+ return bytes >= 1024 ? `${Math.round(bytes / 1024)}kB` : `${bytes}B`;
293
+ }
294
+
295
+ /** Text descriptor for a non-text content part (MC-8). */
296
+ function describeBinaryPart(kind: 'image' | 'audio', mimeType: string, data: string): string {
297
+ return `[${kind} ${mimeType}, ~${approxDecodedSize(data)} - full payload in contentParts]`;
298
+ }
299
+
300
+ function decodeBase64(value: string): Uint8Array {
301
+ return Uint8Array.from(Buffer.from(value, 'base64'));
302
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Client-side request handlers for server-initiated MCP requests
3
+ * (WI-13 / P2-2): **elicitation** (`elicitation/create`) and **sampling**
4
+ * (`sampling/createMessage`).
5
+ *
6
+ * Both are gated: the client advertises a capability and registers a
7
+ * handler *only* when the operator supplies the matching callback on
8
+ * {@link CreateMCPClientOptions}. A conforming server will not issue a
9
+ * request for an un-advertised capability, so the default client is inert
10
+ * (no implicit prompting, no implicit model calls - R4).
11
+ *
12
+ * The SDK request/result schemas are kept inside this module; the public
13
+ * surface speaks only the Graphorin-typed {@link MCPElicitationRequest} /
14
+ * {@link MCPSamplingRequest} boundary.
15
+ *
16
+ * @packageDocumentation
17
+ */
18
+
19
+ import { incrementCounter } from '@graphorin/tools/audit';
20
+ import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
21
+ import {
22
+ type ClientCapabilities,
23
+ CreateMessageRequestSchema,
24
+ ElicitRequestSchema,
25
+ ErrorCode,
26
+ McpError,
27
+ } from '@modelcontextprotocol/sdk/types.js';
28
+ import type {
29
+ MCPElicitationHandler,
30
+ MCPSamplingContent,
31
+ MCPSamplingHandler,
32
+ MCPSamplingRequest,
33
+ } from './types.js';
34
+
35
+ /** Mutable holder so handlers can label counters with the server id. */
36
+ export interface ServerIdRef {
37
+ current: string;
38
+ }
39
+
40
+ /** Options for {@link registerClientRequestHandlers}. */
41
+ export interface ClientRequestHandlerOptions {
42
+ readonly elicitation?: MCPElicitationHandler;
43
+ readonly sampling?: MCPSamplingHandler;
44
+ readonly serverIdRef: ServerIdRef;
45
+ }
46
+
47
+ /**
48
+ * Compute the {@link ClientCapabilities} to advertise on `initialize`,
49
+ * based on which server-initiated handlers the operator supplied.
50
+ * Returns `undefined` when none are configured (advertise nothing).
51
+ *
52
+ * Elicitation is advertised as `{ form: {} }` (W-141): the handler
53
+ * supports form-mode only, and the 2025-11-25 spec expresses that as
54
+ * the `form` sub-capability. A bare `{}` is spec-equivalent through
55
+ * the backward-compat preprocess rule, but the explicit shape states
56
+ * what is actually supported (and matches the handler's contract).
57
+ */
58
+ export function computeClientCapabilities(opts: {
59
+ readonly elicitation?: unknown;
60
+ readonly sampling?: unknown;
61
+ }): ClientCapabilities | undefined {
62
+ if (opts.elicitation === undefined && opts.sampling === undefined) return undefined;
63
+ return {
64
+ ...(opts.elicitation === undefined ? {} : { elicitation: { form: {} } }),
65
+ ...(opts.sampling === undefined ? {} : { sampling: {} }),
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Register the elicitation / sampling request handlers on `sdkClient`,
71
+ * one per supplied callback. Must be called **before** `connect(...)` so
72
+ * the handlers are in place when the session begins delivering
73
+ * server-initiated requests.
74
+ */
75
+ export function registerClientRequestHandlers(
76
+ sdkClient: Client,
77
+ opts: ClientRequestHandlerOptions,
78
+ ): void {
79
+ const { serverIdRef } = opts;
80
+
81
+ if (opts.elicitation !== undefined) {
82
+ const handler = opts.elicitation;
83
+ sdkClient.setRequestHandler(ElicitRequestSchema, async (request, extra) => {
84
+ incrementCounter('mcp.elicitation.requested.total', { server: serverIdRef.current });
85
+ const params = request.params;
86
+ // mcp-skills-05 (SEP-1036): URL-mode elicitation carries a URL,
87
+ // not a form schema. The old handler surfaced it as an
88
+ // empty-schema FORM with the URL invisible - decline it honestly
89
+ // until URL elicitation is supported. (We advertise the `form`
90
+ // sub-capability only, so a conforming server never sends this.)
91
+ if ((params as { readonly mode?: unknown }).mode === 'url') {
92
+ incrementCounter('mcp.elicitation.declined.total', {
93
+ server: serverIdRef.current,
94
+ action: 'decline',
95
+ });
96
+ throw new McpError(
97
+ ErrorCode.InvalidRequest,
98
+ "URL-mode elicitation is not supported by this client (only 'form' is advertised).",
99
+ );
100
+ }
101
+ const requestedSchema =
102
+ 'requestedSchema' in params && params.requestedSchema !== undefined
103
+ ? (params.requestedSchema as Readonly<Record<string, unknown>>)
104
+ : { type: 'object', properties: {} };
105
+ const result = await handler(
106
+ { message: params.message, requestedSchema },
107
+ { signal: extra.signal },
108
+ );
109
+ if (result.action === 'accept') {
110
+ incrementCounter('mcp.elicitation.accepted.total', { server: serverIdRef.current });
111
+ return result.content === undefined
112
+ ? { action: 'accept' }
113
+ : { action: 'accept', content: result.content };
114
+ }
115
+ incrementCounter('mcp.elicitation.declined.total', {
116
+ server: serverIdRef.current,
117
+ action: result.action,
118
+ });
119
+ return { action: result.action };
120
+ });
121
+ }
122
+
123
+ if (opts.sampling !== undefined) {
124
+ const handler = opts.sampling;
125
+ sdkClient.setRequestHandler(CreateMessageRequestSchema, async (request, extra) => {
126
+ incrementCounter('mcp.sampling.requested.total', { server: serverIdRef.current });
127
+ const p = request.params;
128
+ // mcp-skills-05: protocol 2025-11-25 - "The client MUST return an
129
+ // error if this field is provided but
130
+ // ClientCapabilities.sampling.tools is not declared". We do not
131
+ // declare it; pre-fix a tools-carrying request was silently
132
+ // answered as a plain completion.
133
+ const withTools = p as { readonly tools?: unknown; readonly toolChoice?: unknown };
134
+ if (withTools.tools !== undefined || withTools.toolChoice !== undefined) {
135
+ throw new McpError(
136
+ ErrorCode.InvalidRequest,
137
+ 'sampling with tools is not supported by this client (ClientCapabilities.sampling.tools is not declared).',
138
+ );
139
+ }
140
+ const mp = p.modelPreferences;
141
+ const samplingRequest: MCPSamplingRequest = {
142
+ messages: p.messages.map((m) => ({
143
+ role: m.role,
144
+ // MC-13: keep EVERY block - a text+image message must reach the
145
+ // operator's handler whole, not truncated to its first block.
146
+ content: contentBlocks(m.content).map((block) => toGraphorinContent(block)),
147
+ })),
148
+ maxTokens: p.maxTokens,
149
+ ...(p.systemPrompt === undefined ? {} : { systemPrompt: p.systemPrompt }),
150
+ ...(p.temperature === undefined ? {} : { temperature: p.temperature }),
151
+ ...(p.stopSequences === undefined ? {} : { stopSequences: p.stopSequences }),
152
+ ...(mp === undefined
153
+ ? {}
154
+ : {
155
+ modelPreferences: {
156
+ ...(mp.hints === undefined
157
+ ? {}
158
+ : { hints: mp.hints.map((h) => (h.name === undefined ? {} : { name: h.name })) }),
159
+ ...(mp.costPriority === undefined ? {} : { costPriority: mp.costPriority }),
160
+ ...(mp.speedPriority === undefined ? {} : { speedPriority: mp.speedPriority }),
161
+ ...(mp.intelligencePriority === undefined
162
+ ? {}
163
+ : { intelligencePriority: mp.intelligencePriority }),
164
+ },
165
+ }),
166
+ ...(p.includeContext === undefined ? {} : { includeContext: p.includeContext }),
167
+ };
168
+ const result = await handler(samplingRequest, { signal: extra.signal });
169
+ incrementCounter('mcp.sampling.completed.total', { server: serverIdRef.current });
170
+ return {
171
+ role: result.role,
172
+ content: result.content,
173
+ model: result.model,
174
+ ...(result.stopReason === undefined ? {} : { stopReason: result.stopReason }),
175
+ };
176
+ });
177
+ }
178
+ }
179
+
180
+ /** Normalise SDK message content (block | block[]) to a block array (MC-13). */
181
+ function contentBlocks(
182
+ content: unknown,
183
+ ): ReadonlyArray<{ readonly type?: unknown; [k: string]: unknown }> {
184
+ const raw = Array.isArray(content) ? content : [content];
185
+ const blocks = raw.filter(
186
+ (b): b is { readonly type?: unknown; [k: string]: unknown } =>
187
+ b !== null && typeof b === 'object',
188
+ );
189
+ return blocks.length > 0 ? blocks : [{ type: 'text', text: '' }];
190
+ }
191
+
192
+ /** Map an SDK content block to the Graphorin sampling-content union. */
193
+ function toGraphorinContent(block: {
194
+ readonly type?: unknown;
195
+ [k: string]: unknown;
196
+ }): MCPSamplingContent {
197
+ if (block.type === 'image') {
198
+ return {
199
+ type: 'image',
200
+ data: String(block.data ?? ''),
201
+ mimeType: String(block.mimeType ?? 'application/octet-stream'),
202
+ };
203
+ }
204
+ if (block.type === 'audio') {
205
+ return {
206
+ type: 'audio',
207
+ data: String(block.data ?? ''),
208
+ mimeType: String(block.mimeType ?? 'application/octet-stream'),
209
+ };
210
+ }
211
+ if (block.type === 'text') {
212
+ return { type: 'text', text: String(block.text ?? '') };
213
+ }
214
+ return { type: 'text', text: JSON.stringify(block) };
215
+ }