dsh-agent-plugins-market 0.5.2 → 0.6.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/README.md +29 -3
- package/README.zh.md +29 -3
- package/client/client.js +1388 -204
- package/client/client.js.map +1 -1
- package/lib/application/catalog.js +537 -71
- package/lib/application/details.js +39 -12
- package/lib/catalog/archive.js +341 -0
- package/lib/catalog/git.js +85 -12
- package/lib/catalog/legacy-root-migration.js +51 -0
- package/lib/catalog/lsp-spec.js +87 -0
- package/lib/catalog/manifests.js +84 -32
- package/lib/catalog/paths.js +65 -7
- package/lib/catalog/scan-pipeline.js +50 -0
- package/lib/catalog/scan-resolvers.js +402 -0
- package/lib/catalog/source-catalog.js +22 -8
- package/lib/catalog/suite-scanner.js +5 -176
- package/lib/catalog/surfaces.js +80 -30
- package/lib/contracts/lsp-status.js +2 -0
- package/lib/contracts/market.js +8 -1
- package/lib/index.js +118 -13
- package/lib/model/state.js +14 -1
- package/lib/model/types.js +21 -2
- package/lib/routes.js +88 -6
- package/lib/runtime/commands-mounts.js +6 -2
- package/lib/runtime/hooks-mounts.js +9 -6
- package/lib/runtime/lsp-direct-config.js +44 -0
- package/lib/runtime/lsp-mounts.js +236 -0
- package/lib/runtime/lsp-status.js +96 -0
- package/lib/runtime/mcp-backend.js +60 -0
- package/lib/runtime/mcp-client/bridge.js +126 -0
- package/lib/runtime/mcp-client/config.js +42 -0
- package/lib/runtime/mcp-client/connection.js +380 -0
- package/lib/runtime/mcp-client/host-seams.js +88 -0
- package/lib/runtime/mcp-client/json-schema-subset.js +181 -0
- package/lib/runtime/mcp-client/oauth.js +379 -0
- package/lib/runtime/mcp-client/tools.js +432 -0
- package/lib/runtime/mcp-client/transport.js +103 -0
- package/lib/runtime/mcp-config.js +196 -54
- package/lib/runtime/mcp-credentials.js +20 -0
- package/lib/runtime/mcp-mounts.js +237 -27
- package/lib/runtime/mcp-overrides.js +64 -6
- package/lib/runtime/mcp-redaction.js +92 -0
- package/lib/runtime/mcp-status.js +76 -32
- package/lib/runtime/reconciler.js +35 -1
- package/lib/runtime/regions.js +56 -0
- package/lib/types/application/catalog.d.ts +191 -14
- package/lib/types/application/details.d.ts +1 -6
- package/lib/types/application/queries.d.ts +28 -1
- package/lib/types/catalog/archive.d.ts +48 -0
- package/lib/types/catalog/git.d.ts +27 -3
- package/lib/types/catalog/legacy-root-migration.d.ts +14 -0
- package/lib/types/catalog/lsp-spec.d.ts +14 -0
- package/lib/types/catalog/manifests.d.ts +16 -1
- package/lib/types/catalog/paths.d.ts +23 -2
- package/lib/types/catalog/scan-pipeline.d.ts +76 -0
- package/lib/types/catalog/scan-resolvers.d.ts +81 -0
- package/lib/types/catalog/source-catalog.d.ts +5 -0
- package/lib/types/catalog/suite-scanner.d.ts +14 -7
- package/lib/types/catalog/surfaces.d.ts +3 -3
- package/lib/types/contracts/lsp-status.d.ts +53 -0
- package/lib/types/contracts/market.d.ts +55 -1
- package/lib/types/contracts/mcp-status.d.ts +14 -2
- package/lib/types/index.d.ts +24 -8
- package/lib/types/model/types.d.ts +52 -3
- package/lib/types/runtime/lsp-direct-config.d.ts +12 -0
- package/lib/types/runtime/lsp-mounts.d.ts +89 -0
- package/lib/types/runtime/lsp-status.d.ts +30 -0
- package/lib/types/runtime/mcp-backend.d.ts +49 -0
- package/lib/types/runtime/mcp-client/bridge.d.ts +37 -0
- package/lib/types/runtime/mcp-client/config.d.ts +123 -0
- package/lib/types/runtime/mcp-client/connection.d.ts +82 -0
- package/lib/types/runtime/mcp-client/host-seams.d.ts +89 -0
- package/lib/types/runtime/mcp-client/json-schema-subset.d.ts +58 -0
- package/lib/types/runtime/mcp-client/oauth.d.ts +121 -0
- package/lib/types/runtime/mcp-client/tools.d.ts +136 -0
- package/lib/types/runtime/mcp-client/transport.d.ts +36 -0
- package/lib/types/runtime/mcp-config.d.ts +29 -12
- package/lib/types/runtime/mcp-credentials.d.ts +9 -0
- package/lib/types/runtime/mcp-mounts.d.ts +56 -6
- package/lib/types/runtime/mcp-overrides.d.ts +23 -4
- package/lib/types/runtime/mcp-redaction.d.ts +7 -0
- package/lib/types/runtime/mcp-status.d.ts +4 -2
- package/lib/types/runtime/reconciler.d.ts +22 -4
- package/lib/types/runtime/regions.d.ts +37 -0
- package/package.json +15 -11
- package/schemas/1.0.0/mcp.schema.json +22 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool bridge: discovers MCP tools, registers them on the harness ToolRuntime
|
|
3
|
+
* under deterministic server-qualified public names, and handles re-sync when
|
|
4
|
+
* the server's tool list changes.
|
|
5
|
+
*
|
|
6
|
+
* Naming contract (mirrored from the harness `dsh-mcp-client` naming
|
|
7
|
+
* invariants): every MCP tool has the stable identity `(serverName, rawName)`;
|
|
8
|
+
* the model-facing public name is `mcp__<serverName>__<rawName>`, normalized
|
|
9
|
+
* to the DeepSeek function-name constraints. The raw name is only ever sent on
|
|
10
|
+
* the wire (`tools/call`); the public name is never parsed to recover it.
|
|
11
|
+
*
|
|
12
|
+
* The host contract below (`ToolDefinition`, `ToolExecution`,
|
|
13
|
+
* `ToolExecutionResult`) is a structural mirror of `@deepseek-ai/dsh-tools`
|
|
14
|
+
* rc.2's registration surface — the runtime accepts the same object shape the
|
|
15
|
+
* upstream bridge registers, so the model-facing behavior is identical.
|
|
16
|
+
*
|
|
17
|
+
* @module runtime/mcp-client/tools
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
21
|
+
import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
22
|
+
import { z } from 'zod';
|
|
23
|
+
import { assertSupportedJsonSchema } from './json-schema-subset.js';
|
|
24
|
+
import { isImageAdmissionError } from './host-seams.js';
|
|
25
|
+
/**
|
|
26
|
+
* DeepSeek function-name contract: at most 64 characters. Wire-protocol
|
|
27
|
+
* constant, not configuration.
|
|
28
|
+
*/
|
|
29
|
+
const MAX_PUBLIC_NAME_LENGTH = 64;
|
|
30
|
+
/** DeepSeek function-name contract: only `[A-Za-z0-9_-]` is allowed. */
|
|
31
|
+
const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g;
|
|
32
|
+
/** Hex chars of the SHA-256 identity hash appended on lossy normalization. */
|
|
33
|
+
const HASH_LENGTH = 12;
|
|
34
|
+
/** Raster formats supported by the durable attachment vocabulary. */
|
|
35
|
+
const IMAGE_MEDIA_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
|
|
36
|
+
/** Canonical RFC 4648 base64, excluding whitespace and URL-safe aliases. */
|
|
37
|
+
const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
38
|
+
/** Raw result record: the bridge owns JSON-value validation after transport. */
|
|
39
|
+
const RawCallToolResultSchema = z.record(z.string(), z.unknown());
|
|
40
|
+
/** List without mutating the SDK's per-page output-validator cache. */
|
|
41
|
+
function listToolsUncached(client, cursor) {
|
|
42
|
+
return client.request({ method: 'tools/list', ...(cursor === undefined ? {} : { params: { cursor } }) }, ListToolsResultSchema);
|
|
43
|
+
}
|
|
44
|
+
/** Call without the SDK pre-validating an output schema the bridge may not support. */
|
|
45
|
+
function callToolUncached(client, rawName, args, exec, opts) {
|
|
46
|
+
return client.request({ method: 'tools/call', params: { name: rawName, arguments: args } }, RawCallToolResultSchema, {
|
|
47
|
+
signal: exec.signal,
|
|
48
|
+
timeout: opts.toolCallTimeoutMs
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Derive the model-facing public name for one MCP tool.
|
|
53
|
+
*
|
|
54
|
+
* Deterministic pure function of `(serverName, rawName)`: the clean case is
|
|
55
|
+
* `mcp__<serverName>__<rawName>` verbatim. When character replacement or
|
|
56
|
+
* truncation to the DeepSeek function-name contract (64 chars,
|
|
57
|
+
* `[A-Za-z0-9_-]`) changes the name, a 12-hex-char SHA-256 hash of the
|
|
58
|
+
* identity is appended so distinct MCP identities never collapse into the
|
|
59
|
+
* same public name.
|
|
60
|
+
*
|
|
61
|
+
* @param serverName - Stable local namespace from bridge config.
|
|
62
|
+
* @param rawName - The MCP server's own tool name.
|
|
63
|
+
* @returns The globally unique, model-facing ToolRuntime name.
|
|
64
|
+
*/
|
|
65
|
+
export function publicToolName(serverName, rawName) {
|
|
66
|
+
const joined = `mcp__${serverName}__${rawName}`;
|
|
67
|
+
const normalized = joined.replace(INVALID_NAME_CHARS, '_');
|
|
68
|
+
if (normalized === joined && normalized.length <= MAX_PUBLIC_NAME_LENGTH)
|
|
69
|
+
return normalized;
|
|
70
|
+
const hash = createHash('sha256').update(`${serverName}\0${rawName}`).digest('hex').slice(0, HASH_LENGTH);
|
|
71
|
+
return `${normalized.slice(0, MAX_PUBLIC_NAME_LENGTH - HASH_LENGTH - 1)}_${hash}`;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Sync the MCP server's tool list into the harness ToolRuntime.
|
|
75
|
+
*
|
|
76
|
+
* Two phases keep the swap safe:
|
|
77
|
+
*
|
|
78
|
+
* 1. Fetch: drain uncached `tools/list` pagination and build the full next
|
|
79
|
+
* generation of `ToolDefinition`s under public names. Any failure here
|
|
80
|
+
* (network error, duplicate raw name in the server's list) rejects and
|
|
81
|
+
* leaves the previous generation registered untouched.
|
|
82
|
+
* 2. Swap: dispose the previous generation, register the new one. A registry
|
|
83
|
+
* conflict here can only mean a foreign registration squats on this
|
|
84
|
+
* server's `mcp__<serverName>__` namespace — the partial generation is
|
|
85
|
+
* rolled back (zero tools from this server) and logged. Initial strict
|
|
86
|
+
* synchronization may propagate the conflict so its parent transaction
|
|
87
|
+
* rejects; ordinary clients and later re-syncs return an empty map.
|
|
88
|
+
*
|
|
89
|
+
* @param client - Connected MCP Client instance used to list and call tools.
|
|
90
|
+
* @param host - The bridge host providing the tool registry and logger.
|
|
91
|
+
* @param opts - Bridge options: server namespace and per-call timeout.
|
|
92
|
+
* @param previous - Disposer map from the prior sync generation; disposed
|
|
93
|
+
* during the swap phase (only after the fetch phase succeeded).
|
|
94
|
+
* @returns A map of registered public tool names to their unregister
|
|
95
|
+
* disposers — the exact set of live registrations owned by this server.
|
|
96
|
+
*/
|
|
97
|
+
export async function syncTools(client, host, opts, previous) {
|
|
98
|
+
// Phase 1: fetch and build the next generation without touching the registry.
|
|
99
|
+
const definitions = new Map();
|
|
100
|
+
let cursor;
|
|
101
|
+
do {
|
|
102
|
+
const response = await listToolsUncached(client, cursor);
|
|
103
|
+
for (const tool of response.tools) {
|
|
104
|
+
const publicName = publicToolName(opts.serverName, tool.name);
|
|
105
|
+
if (definitions.has(publicName)) {
|
|
106
|
+
throw new Error(`mcp-client(${opts.serverName}): server listed tool "${tool.name}" more than once — invalid tool list`);
|
|
107
|
+
}
|
|
108
|
+
definitions.set(publicName, createDefinition(client, host, publicName, tool.name, tool.description ?? '', tool.inputSchema, supportedOutputSchema(tool.outputSchema), tool.execution?.taskSupport === 'required', opts));
|
|
109
|
+
}
|
|
110
|
+
cursor = response.nextCursor;
|
|
111
|
+
} while (cursor);
|
|
112
|
+
// Phase 2: swap generations.
|
|
113
|
+
for (const dispose of previous.values())
|
|
114
|
+
dispose();
|
|
115
|
+
const disposers = new Map();
|
|
116
|
+
try {
|
|
117
|
+
for (const [publicName, definition] of definitions) {
|
|
118
|
+
disposers.set(publicName, host.tools.register(definition));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
// A conflict on an `mcp__<serverName>__`-qualified name means a foreign
|
|
123
|
+
// registration occupies this server's namespace. Roll back so the model
|
|
124
|
+
// sees either the full generation or none of it — never a partial set.
|
|
125
|
+
for (const dispose of disposers.values())
|
|
126
|
+
dispose();
|
|
127
|
+
host.logger.error(`mcp-client(${opts.serverName}): tool registration failed, no tools registered: ${String(error)}`);
|
|
128
|
+
if (opts.registrationFailure === 'throw')
|
|
129
|
+
throw error;
|
|
130
|
+
return new Map();
|
|
131
|
+
}
|
|
132
|
+
return disposers;
|
|
133
|
+
}
|
|
134
|
+
/** Keep a supported advertised schema; unsupported MCP vocabulary falls back to JsonValue. */
|
|
135
|
+
function supportedOutputSchema(candidate) {
|
|
136
|
+
if (candidate === undefined)
|
|
137
|
+
return undefined;
|
|
138
|
+
try {
|
|
139
|
+
assertSupportedJsonSchema(candidate);
|
|
140
|
+
return candidate;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Build one generation-local tool definition and its execution-local rich projections.
|
|
148
|
+
* @param client - connected MCP client used for calls.
|
|
149
|
+
* @param host - bridge host carrying optional attachment and model services.
|
|
150
|
+
* @param publicName - registry-qualified public tool name.
|
|
151
|
+
* @param rawName - MCP wire tool name.
|
|
152
|
+
* @param description - model-facing tool description.
|
|
153
|
+
* @param parameters - MCP input schema.
|
|
154
|
+
* @param structuredSchema - supported structured-output schema, when advertised.
|
|
155
|
+
* @param taskRequired - whether this MCP tool requires unsupported task execution.
|
|
156
|
+
* @param opts - bridge timeout and namespace options.
|
|
157
|
+
* @returns a complete ToolRuntime definition.
|
|
158
|
+
*/
|
|
159
|
+
function createDefinition(client, host, publicName, rawName, description, parameters, structuredSchema, taskRequired, opts) {
|
|
160
|
+
const projections = new WeakMap();
|
|
161
|
+
return {
|
|
162
|
+
name: publicName,
|
|
163
|
+
description,
|
|
164
|
+
parameters,
|
|
165
|
+
output: createOutput(rawName, structuredSchema),
|
|
166
|
+
execute: createExecutor(client, host, rawName, taskRequired, opts, projections),
|
|
167
|
+
finalizeContent(exec, result) {
|
|
168
|
+
const projection = projections.get(exec);
|
|
169
|
+
if (projection === undefined)
|
|
170
|
+
return undefined;
|
|
171
|
+
projections.delete(exec);
|
|
172
|
+
if (result.isError)
|
|
173
|
+
return undefined;
|
|
174
|
+
if (!isDeepStrictEqual(result.value, projection.value))
|
|
175
|
+
return undefined;
|
|
176
|
+
if (!isDeepStrictEqual(result.content, projection.fallback))
|
|
177
|
+
return undefined;
|
|
178
|
+
return projection.content;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/** Build the canonical result schema and existing Native text projection. */
|
|
183
|
+
function createOutput(rawName, structuredSchema) {
|
|
184
|
+
return {
|
|
185
|
+
schema: {
|
|
186
|
+
type: 'object',
|
|
187
|
+
properties: {
|
|
188
|
+
content: { type: 'array', items: {} },
|
|
189
|
+
structuredContent: structuredSchema ?? {}
|
|
190
|
+
},
|
|
191
|
+
required: structuredSchema === undefined ? ['content'] : ['content', 'structuredContent'],
|
|
192
|
+
additionalProperties: false
|
|
193
|
+
},
|
|
194
|
+
render(_args, value) {
|
|
195
|
+
const result = value;
|
|
196
|
+
return [{ type: 'text', text: extractText(result.content, rawName) }];
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Create an execute function for one MCP tool. The executor closes over the
|
|
202
|
+
* raw MCP tool name and sends an uncached `tools/call` request with it (never
|
|
203
|
+
* the public name), with abort signal and timeout, then maps the result to
|
|
204
|
+
* harness ContentBlocks. Owning the raw request prevents the SDK's internal
|
|
205
|
+
* per-page schema cache from pre-validating a different contract.
|
|
206
|
+
*
|
|
207
|
+
* When the MCP server returns `isError: true`, the executor throws so that
|
|
208
|
+
* the ToolRuntime's catch path produces an `isError` result for the model.
|
|
209
|
+
*/
|
|
210
|
+
function createExecutor(client, host, rawName, taskRequired, opts, projections) {
|
|
211
|
+
return async (args, exec) => {
|
|
212
|
+
if (taskRequired) {
|
|
213
|
+
throw new Error(`Tool "${rawName}" requires task-based execution, which this bridge does not support`);
|
|
214
|
+
}
|
|
215
|
+
// The agent loop passes `JSON.parse(model_arguments)` which is usually an
|
|
216
|
+
// object, but can be any JSON value if the model misbehaves (outputs a bare
|
|
217
|
+
// string/number/null). Fallback to {} lets the MCP server produce a
|
|
218
|
+
// specific "missing required param" error the model can learn from.
|
|
219
|
+
const argsObj = (typeof args === 'object' && args !== null ? args : {});
|
|
220
|
+
const result = await callToolUncached(client, rawName, argsObj, exec, opts);
|
|
221
|
+
// The SDK may return a legacy `toolResult` shape; normalize to content array.
|
|
222
|
+
if (!Array.isArray(result.content)) {
|
|
223
|
+
const rendered = 'toolResult' in result ? JSON.stringify(result.toolResult) : '(no output)';
|
|
224
|
+
const text = typeof rendered === 'string' ? rendered : '(no output)';
|
|
225
|
+
if (result.isError === true)
|
|
226
|
+
throw new Error(text);
|
|
227
|
+
return {
|
|
228
|
+
content: [{ type: 'text', text }],
|
|
229
|
+
...(result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {})
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
// Trust boundary: the SDK's return type erases to `any[]` due to the
|
|
233
|
+
// union of CallToolResult | CompatibilityCallToolResult; extractText
|
|
234
|
+
// validates each element.
|
|
235
|
+
const content = result.content;
|
|
236
|
+
const text = extractText(content, rawName);
|
|
237
|
+
// MCP isError → throw so ToolRuntime produces an isError result for the model.
|
|
238
|
+
if (result.isError === true) {
|
|
239
|
+
throw new Error(text);
|
|
240
|
+
}
|
|
241
|
+
const value = {
|
|
242
|
+
content,
|
|
243
|
+
...(result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {})
|
|
244
|
+
};
|
|
245
|
+
if (containsImage(content)) {
|
|
246
|
+
const fallback = [{ type: 'text', text: extractText(content, rawName) }];
|
|
247
|
+
const projected = await prepareImageProjection(host, exec, content, rawName);
|
|
248
|
+
projections.set(exec, { value, fallback, content: projected });
|
|
249
|
+
}
|
|
250
|
+
return value;
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/** Whether an untrusted MCP content array contains a declared image block. */
|
|
254
|
+
function containsImage(content) {
|
|
255
|
+
return content.some(value => isRecord(value) && value.type === 'image');
|
|
256
|
+
}
|
|
257
|
+
/** Narrow one JSON value to a string-keyed object. */
|
|
258
|
+
function isRecord(value) {
|
|
259
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
260
|
+
}
|
|
261
|
+
/** Narrow a declared MIME string to the durable image vocabulary. */
|
|
262
|
+
function isImageMediaType(value) {
|
|
263
|
+
return IMAGE_MEDIA_TYPES.includes(value);
|
|
264
|
+
}
|
|
265
|
+
/** Decode one untrusted MCP image block without accepting base64 aliases. */
|
|
266
|
+
function decodeImage(block) {
|
|
267
|
+
if (block.mimeType === undefined || !isImageMediaType(block.mimeType)) {
|
|
268
|
+
throw new Error('the declared media type is not PNG, JPEG, WebP, or GIF');
|
|
269
|
+
}
|
|
270
|
+
if (block.data === undefined || !CANONICAL_BASE64.test(block.data)) {
|
|
271
|
+
throw new Error('the image data is not canonical base64');
|
|
272
|
+
}
|
|
273
|
+
const data = Buffer.from(block.data, 'base64');
|
|
274
|
+
if (data.toString('base64') !== block.data) {
|
|
275
|
+
throw new Error('the image data is not canonical base64');
|
|
276
|
+
}
|
|
277
|
+
return { data, mediaType: block.mimeType };
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Resolve the active model route and durable store for an image-bearing result.
|
|
281
|
+
* @param host - bridge host with optional services.
|
|
282
|
+
* @param exec - exact tool execution whose agent supplies the latest route.
|
|
283
|
+
* @returns the attachment store after exact positive image-capability proof.
|
|
284
|
+
*/
|
|
285
|
+
async function resolveImageAdmission(host, exec) {
|
|
286
|
+
const attachments = host.getService?.('attachments');
|
|
287
|
+
if (attachments === undefined || typeof attachments.saveImages !== 'function') {
|
|
288
|
+
throw new Error('no attachment store is mounted');
|
|
289
|
+
}
|
|
290
|
+
const routed = exec.agent?.session?.requestHeader?.()?.config;
|
|
291
|
+
const provider = routed?.provider ?? exec.agent?.options?.provider;
|
|
292
|
+
const model = routed?.model ?? exec.agent?.options?.model;
|
|
293
|
+
const llm = host.getService?.('llm');
|
|
294
|
+
if (provider === undefined || model === undefined || llm === undefined) {
|
|
295
|
+
throw new Error('the current model route could not be resolved');
|
|
296
|
+
}
|
|
297
|
+
let info;
|
|
298
|
+
try {
|
|
299
|
+
info = await llm.resolveModelInfo(provider, model, exec.signal);
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
throw new Error('the current model route could not be verified');
|
|
303
|
+
}
|
|
304
|
+
if (info?.inputModalities === undefined || !info.inputModalities.includes('image')) {
|
|
305
|
+
throw new Error(`model "${model}" does not declare image input`);
|
|
306
|
+
}
|
|
307
|
+
if (exec.signal.aborted)
|
|
308
|
+
throw new Error('the tool call was canceled before image storage');
|
|
309
|
+
return attachments;
|
|
310
|
+
}
|
|
311
|
+
/** Stable diagnostic text for an image block that was not admitted. */
|
|
312
|
+
function imageDiagnostic(block, reason) {
|
|
313
|
+
return `[image unavailable: ${block.mimeType ?? 'unknown media type'}; ${reason}; raw image data remains available to programmatic callers]`;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Decode, preflight, and durably save one MCP result's ordered image batch.
|
|
317
|
+
* Any refusal projects every image as text while retaining the canonical raw
|
|
318
|
+
* value for programmatic callers.
|
|
319
|
+
*/
|
|
320
|
+
async function prepareImageProjection(host, exec, content, toolName) {
|
|
321
|
+
const decoded = [];
|
|
322
|
+
const validationErrors = new Map();
|
|
323
|
+
const imageIndexes = [];
|
|
324
|
+
for (const [index, value] of content.entries()) {
|
|
325
|
+
if (!isRecord(value) || value.type !== 'image')
|
|
326
|
+
continue;
|
|
327
|
+
imageIndexes.push(index);
|
|
328
|
+
try {
|
|
329
|
+
decoded.push(decodeImage(value));
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
// decodeImage owns every throw above and always produces Error.
|
|
333
|
+
validationErrors.set(index, error.message);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (validationErrors.size > 0) {
|
|
337
|
+
return projectContent(content, toolName, (block, index) => ({
|
|
338
|
+
type: 'text',
|
|
339
|
+
text: imageDiagnostic(block, validationErrors.get(index) ?? 'another image in the same result was invalid')
|
|
340
|
+
}));
|
|
341
|
+
}
|
|
342
|
+
let attachments;
|
|
343
|
+
try {
|
|
344
|
+
attachments = await resolveImageAdmission(host, exec);
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
// resolveImageAdmission contains provider failures and throws Error only.
|
|
348
|
+
const reason = error.message;
|
|
349
|
+
return projectContent(content, toolName, block => ({ type: 'text', text: imageDiagnostic(block, reason) }));
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
const refs = await attachments.saveImages(decoded);
|
|
353
|
+
const byIndex = new Map(imageIndexes.map((index, offset) => [index, refs[offset]]));
|
|
354
|
+
return projectContent(content, toolName, (_block, index) => ({
|
|
355
|
+
type: 'image',
|
|
356
|
+
attachment: byIndex.get(index)
|
|
357
|
+
}));
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
const reason = isImageAdmissionError(error) ? `image admission rejected the result: ${error.message}` : 'durable image storage rejected the result';
|
|
361
|
+
return projectContent(content, toolName, block => ({
|
|
362
|
+
type: 'text',
|
|
363
|
+
text: imageDiagnostic(block, reason)
|
|
364
|
+
}));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Extract text from an MCP content array into a single string.
|
|
369
|
+
* - text blocks: join with '\n'
|
|
370
|
+
* - image/audio/resource blocks: replaced with a placeholder
|
|
371
|
+
*
|
|
372
|
+
* Defensive: fields that the MCP spec declares required (mimeType, text) are
|
|
373
|
+
* guarded with fallbacks because this is a network trust boundary.
|
|
374
|
+
*/
|
|
375
|
+
function extractText(mcpContent, toolName) {
|
|
376
|
+
const content = projectContent(mcpContent, toolName);
|
|
377
|
+
// The default image projector below also returns text, so this local call
|
|
378
|
+
// cannot produce a core image block.
|
|
379
|
+
return content.map(block => block.text).join('\n');
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Project ordered MCP blocks into the core content vocabulary.
|
|
383
|
+
* Text-like runs are newline-coalesced; admitted images split those runs at
|
|
384
|
+
* their original position.
|
|
385
|
+
*/
|
|
386
|
+
function projectContent(mcpContent, toolName, image = block => ({
|
|
387
|
+
type: 'text',
|
|
388
|
+
text: imageDiagnostic(block, 'this result was not admitted to durable model context')
|
|
389
|
+
})) {
|
|
390
|
+
const projected = [];
|
|
391
|
+
const text = [];
|
|
392
|
+
const flushText = () => {
|
|
393
|
+
if (text.length === 0)
|
|
394
|
+
return;
|
|
395
|
+
projected.push({ type: 'text', text: text.splice(0).join('\n') });
|
|
396
|
+
};
|
|
397
|
+
for (const [index, value] of mcpContent.entries()) {
|
|
398
|
+
if (!isRecord(value)) {
|
|
399
|
+
text.push('[unsupported MCP content block: expected an object]');
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
const block = value;
|
|
403
|
+
switch (block.type) {
|
|
404
|
+
case 'text':
|
|
405
|
+
if (block.text !== undefined)
|
|
406
|
+
text.push(block.text);
|
|
407
|
+
break;
|
|
408
|
+
case 'image':
|
|
409
|
+
flushText();
|
|
410
|
+
projected.push(image(block, index));
|
|
411
|
+
break;
|
|
412
|
+
case 'resource_link':
|
|
413
|
+
if (block.name === undefined || block.uri === undefined) {
|
|
414
|
+
text.push('[resource link unavailable: the MCP block is missing its name or URI]');
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
text.push(`Resource link: ${block.name} (${block.uri})`);
|
|
418
|
+
}
|
|
419
|
+
break;
|
|
420
|
+
case 'audio':
|
|
421
|
+
text.push(`[audio result unsupported: ${block.mimeType ?? 'unknown media type'}; raw audio data remains available to programmatic callers]`);
|
|
422
|
+
break;
|
|
423
|
+
case 'resource':
|
|
424
|
+
text.push('[embedded resource unsupported; raw resource data remains available to programmatic callers]');
|
|
425
|
+
break;
|
|
426
|
+
default:
|
|
427
|
+
text.push(`[unsupported MCP content type: ${block.type}]`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
flushText();
|
|
431
|
+
return projected.length > 0 ? projected : [{ type: 'text', text: `(${toolName} returned no model-visible content)` }];
|
|
432
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport factory: creates the appropriate MCP transport based on the
|
|
3
|
+
* bridge's resolved config. Stdio spawns a child process (with credential
|
|
4
|
+
* scrubbing); Streamable HTTP connects to a URL; legacy SSE connects to a
|
|
5
|
+
* URL with the pre-Streamable-HTTP protocol pairing. Both HTTP transports
|
|
6
|
+
* carry OAuth 2.1 authorization by default, activated by the server's first
|
|
7
|
+
* `401` challenge.
|
|
8
|
+
*
|
|
9
|
+
* @module runtime/mcp-client/transport
|
|
10
|
+
*/
|
|
11
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
12
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
13
|
+
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
14
|
+
import { scrubbedParentEnv } from './host-seams.js';
|
|
15
|
+
import { LoopbackOAuthClientProvider } from './oauth.js';
|
|
16
|
+
/**
|
|
17
|
+
* The subprocess seam's scrubbed parent env (credential-shaped and stale
|
|
18
|
+
* `DSH_*` names dropped), plus the spec's explicit env. The MCP SDK owns the
|
|
19
|
+
* actual spawn, so this transport shares the scrub definition rather than the
|
|
20
|
+
* spawn path.
|
|
21
|
+
*/
|
|
22
|
+
function buildChildEnv(extra) {
|
|
23
|
+
return { ...scrubbedParentEnv(), ...extra };
|
|
24
|
+
}
|
|
25
|
+
/** Whether the headers carry a static `Authorization` (a token flow, not OAuth). */
|
|
26
|
+
function hasStaticAuthorization(headers) {
|
|
27
|
+
return Object.keys(headers).some(key => key.toLowerCase() === 'authorization');
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build the OAuth provider for one HTTP-based generation when the flow is
|
|
31
|
+
* active. OAuth attempts silently by default: the provider only activates on
|
|
32
|
+
* the server's 401 challenge, so servers without auth are unaffected. Skipped
|
|
33
|
+
* when the config opts out or the headers already carry a static
|
|
34
|
+
* Authorization.
|
|
35
|
+
*/
|
|
36
|
+
function buildOAuthProvider(config, credentials, log) {
|
|
37
|
+
const auth = config.auth;
|
|
38
|
+
if (auth?.enabled === false || hasStaticAuthorization(config.headers))
|
|
39
|
+
return undefined;
|
|
40
|
+
return new LoopbackOAuthClientProvider(config.serverName, auth?.storage ?? {}, auth?.scope, log, credentials);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Create an MCP transport from the resolved bridge config.
|
|
44
|
+
*
|
|
45
|
+
* With OAuth active (the default), the Streamable HTTP and SSE transports
|
|
46
|
+
* carry an {@link OAuthClientProvider}: the first `401` from the server
|
|
47
|
+
* starts discovery, dynamic registration, and a loopback browser
|
|
48
|
+
* authorization, and granted tokens persist through the credentials service
|
|
49
|
+
* for later processes. Transport generation identity stays with the caller —
|
|
50
|
+
* the provider binds whichever transport it hands out, so a reconnect builds
|
|
51
|
+
* a fresh generation on the same durable state.
|
|
52
|
+
*
|
|
53
|
+
* @param config - Resolved bridge config discriminated on `transport`.
|
|
54
|
+
* @param credentials - The composition's credential store, when mounted; OAuth
|
|
55
|
+
* state persists only when it is.
|
|
56
|
+
* @param log - Diagnostic sink for the human leg of the flow.
|
|
57
|
+
* @returns The transport plus its OAuth provider, when active — the
|
|
58
|
+
* supervisor queries the provider to hold the generation open while a
|
|
59
|
+
* browser leg is pending.
|
|
60
|
+
*/
|
|
61
|
+
export function createTransport(config, credentials, log = () => { }) {
|
|
62
|
+
switch (config.transport) {
|
|
63
|
+
case 'stdio':
|
|
64
|
+
return {
|
|
65
|
+
transport: new StdioClientTransport({
|
|
66
|
+
command: config.command,
|
|
67
|
+
args: config.args,
|
|
68
|
+
env: buildChildEnv(config.env),
|
|
69
|
+
cwd: config.cwd
|
|
70
|
+
}),
|
|
71
|
+
oauthProvider: undefined
|
|
72
|
+
};
|
|
73
|
+
case 'streamable-http': {
|
|
74
|
+
const oauthProvider = buildOAuthProvider(config, credentials, log);
|
|
75
|
+
// The MCP SDK's StreamableHTTPClientTransport has optional callback
|
|
76
|
+
// properties typed without `| undefined` (exactOptionalPropertyTypes
|
|
77
|
+
// mismatch with the Transport interface); the SDK constructed the
|
|
78
|
+
// object, so the cast records only that widening. `finishAuth` is
|
|
79
|
+
// public on the SDK class and completes the browser leg's code
|
|
80
|
+
// exchange inside this transport generation.
|
|
81
|
+
const transport = new StreamableHTTPClientTransport(new URL(config.url), {
|
|
82
|
+
requestInit: { headers: config.headers },
|
|
83
|
+
...(oauthProvider === undefined ? {} : { authProvider: oauthProvider })
|
|
84
|
+
});
|
|
85
|
+
oauthProvider?.bindTransport(transport);
|
|
86
|
+
return { transport, oauthProvider };
|
|
87
|
+
}
|
|
88
|
+
case 'sse': {
|
|
89
|
+
const oauthProvider = buildOAuthProvider(config, credentials, log);
|
|
90
|
+
// The legacy SSE transport negotiates a separate endpoint POST channel.
|
|
91
|
+
// The SDK applies `requestInit.headers` to both the stream request and
|
|
92
|
+
// every endpoint POST (its `_commonHeaders` merge), so no separate
|
|
93
|
+
// `eventSourceInit` headers wiring is needed — and omitting it keeps
|
|
94
|
+
// the SDK's automatic Authorization injection on 401 retries intact.
|
|
95
|
+
const transport = new SSEClientTransport(new URL(config.url), {
|
|
96
|
+
requestInit: { headers: config.headers },
|
|
97
|
+
...(oauthProvider === undefined ? {} : { authProvider: oauthProvider })
|
|
98
|
+
});
|
|
99
|
+
oauthProvider?.bindTransport(transport);
|
|
100
|
+
return { transport, oauthProvider };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|