@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.
- package/CHANGELOG.md +55 -0
- package/README.md +73 -5
- package/dist/client/adapt-result.d.ts +9 -1
- package/dist/client/adapt-result.d.ts.map +1 -1
- package/dist/client/adapt-result.js +28 -10
- package/dist/client/adapt-result.js.map +1 -1
- package/dist/client/client-handlers.js +7 -1
- package/dist/client/client-handlers.js.map +1 -1
- package/dist/client/client.d.ts.map +1 -1
- package/dist/client/client.js +47 -71
- package/dist/client/client.js.map +1 -1
- package/dist/client/inbound-filters.js +101 -1
- package/dist/client/inbound-filters.js.map +1 -1
- package/dist/client/index.d.ts +2 -1
- package/dist/client/index.js +2 -1
- package/dist/client/managed.d.ts +35 -0
- package/dist/client/managed.d.ts.map +1 -0
- package/dist/client/managed.js +136 -0
- package/dist/client/managed.js.map +1 -0
- package/dist/client/mcp-resource-reader.js +1 -1
- package/dist/client/mcp-resource-reader.js.map +1 -1
- package/dist/client/to-tools-run.js +119 -0
- package/dist/client/to-tools-run.js.map +1 -0
- package/dist/client/to-tools.d.ts +8 -0
- package/dist/client/to-tools.d.ts.map +1 -1
- package/dist/client/to-tools.js +27 -4
- package/dist/client/to-tools.js.map +1 -1
- package/dist/client/types.d.ts +12 -3
- package/dist/client/types.d.ts.map +1 -1
- package/dist/errors/index.d.ts +3 -3
- package/dist/errors/index.js +1 -1
- package/dist/errors/index.js.map +1 -1
- package/dist/helpers/identity.d.ts +11 -0
- package/dist/helpers/identity.d.ts.map +1 -1
- package/dist/helpers/identity.js +13 -2
- package/dist/helpers/identity.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -6
- package/dist/index.js.map +1 -1
- package/dist/package.js +6 -0
- package/dist/package.js.map +1 -0
- package/dist/registry/json-schema.js +26 -8
- package/dist/registry/json-schema.js.map +1 -1
- package/dist/transport/types.d.ts +9 -0
- package/package.json +13 -12
- package/src/client/adapt-result.ts +302 -0
- package/src/client/client-handlers.ts +215 -0
- package/src/client/client.ts +725 -0
- package/src/client/defer-loading.ts +108 -0
- package/src/client/inbound-filters.ts +246 -0
- package/src/client/index.ts +42 -0
- package/src/client/managed.ts +222 -0
- package/src/client/mcp-resource-reader.ts +183 -0
- package/src/client/pinning.ts +48 -0
- package/src/client/to-tools-run.ts +178 -0
- package/src/client/to-tools.ts +294 -0
- package/src/client/transport-factory.ts +117 -0
- package/src/client/types.ts +422 -0
- package/src/errors/index.ts +170 -0
- package/src/helpers/identity.ts +128 -0
- package/src/helpers/index.ts +8 -0
- package/src/helpers/validate-config.ts +95 -0
- package/src/index.ts +49 -0
- package/src/oauth/bridge.ts +143 -0
- package/src/oauth/index.ts +18 -0
- package/src/oauth/library.ts +61 -0
- package/src/registry/json-schema.ts +402 -0
- package/src/transport/index.ts +15 -0
- 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
|
+
}
|