dsh-agent-plugins-market 0.6.2 → 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/README.md +117 -111
- package/README.zh.md +112 -98
- package/client/client.js +547 -540
- package/client/client.js.map +1 -1
- package/lib/application/catalog-context.js +181 -0
- package/lib/application/catalog.js +217 -1110
- package/lib/application/details.js +6 -6
- package/lib/application/install-store.js +95 -0
- package/lib/application/lsp-service.js +97 -0
- package/lib/application/mcp-service.js +246 -0
- package/lib/application/panel-resources.js +29 -12
- package/lib/application/ports.js +21 -0
- package/lib/application/project-agent-roles.js +1 -1
- package/lib/application/snapshot-cache.js +160 -0
- package/lib/application/source-store.js +521 -0
- package/lib/catalog/archive.js +34 -29
- package/lib/catalog/component-files.js +14 -6
- package/lib/catalog/fs-probes.js +43 -0
- package/lib/catalog/manifests.js +53 -92
- package/lib/catalog/native-project.js +2 -2
- package/lib/catalog/paths.js +66 -55
- package/lib/catalog/project-config.js +2 -1
- package/lib/catalog/project-hooks.js +9 -1
- package/lib/catalog/scan-resolvers.js +29 -45
- package/lib/catalog/skills-parse.js +14 -10
- package/lib/catalog/source-catalog.js +3 -2
- package/lib/catalog/suite-components.js +5 -5
- package/lib/catalog/suite-scanner.js +3 -1
- package/lib/catalog/surfaces.js +5 -26
- package/lib/catalog/validate.js +7 -11
- package/lib/contracts/market.js +1 -0
- package/lib/index.js +91 -208
- package/lib/model/types.js +26 -3
- package/lib/routes.js +45 -15
- package/lib/runtime/agent-role-names.js +70 -0
- package/lib/runtime/agent-role-router.js +116 -102
- package/lib/runtime/commands-mounts.js +9 -6
- package/lib/runtime/deadline.js +28 -0
- package/lib/runtime/feedback-tool.js +94 -43
- package/lib/runtime/hooks-mounts.js +9 -11
- package/lib/runtime/host-locale.js +10 -21
- package/lib/runtime/lsp-direct-config.js +19 -19
- package/lib/runtime/lsp-mounts.js +159 -68
- package/lib/runtime/lsp-server-state.js +4 -4
- package/lib/runtime/lsp-status.js +1 -1
- package/lib/runtime/mcp-auth-record.js +42 -0
- package/lib/runtime/mcp-backend.js +6 -5
- package/lib/runtime/mcp-client/config.js +0 -1
- package/lib/runtime/mcp-client/connection.js +32 -37
- package/lib/runtime/mcp-client/host-contract.js +10 -0
- package/lib/runtime/mcp-client/oauth.js +5 -7
- package/lib/runtime/mcp-client/plugin-identity.js +28 -0
- package/lib/runtime/mcp-client/projection.js +202 -0
- package/lib/runtime/mcp-client/tools.js +4 -189
- package/lib/runtime/mcp-config.js +29 -10
- package/lib/runtime/mcp-direct-config.js +33 -16
- package/lib/runtime/mcp-mounts.js +35 -75
- package/lib/runtime/mcp-overrides.js +5 -5
- package/lib/runtime/mcp-redaction.js +3 -2
- package/lib/runtime/mcp-status.js +7 -8
- package/lib/runtime/mount-lifecycle.js +73 -0
- package/lib/runtime/profile-seam.js +321 -0
- package/lib/runtime/project-runtime.js +11 -8
- package/lib/runtime/reconcile-scheduler.js +99 -0
- package/lib/runtime/reconciler.js +4 -0
- package/lib/runtime/server-config.js +6 -4
- package/lib/runtime/settings-namespace.js +136 -0
- package/lib/runtime/skills-provider.js +5 -3
- package/lib/runtime/source-auto-update.js +70 -0
- package/lib/{model/state.js → runtime/state-store.js} +9 -11
- package/lib/runtime/storage-migration.js +35 -18
- package/lib/runtime/subagent-catalog.js +5 -4
- package/lib/runtime/user-commands.js +2 -2
- package/lib/runtime/user-panels.js +31 -21
- package/lib/runtime/user-store.js +12 -13
- package/lib/types/application/catalog-context.d.ts +118 -0
- package/lib/types/application/catalog.d.ts +99 -304
- package/lib/types/application/install-store.d.ts +21 -0
- package/lib/types/application/lsp-service.d.ts +36 -0
- package/lib/types/application/mcp-service.d.ts +71 -0
- package/lib/types/application/panel-resources.d.ts +8 -0
- package/lib/types/application/ports.d.ts +72 -0
- package/lib/types/application/queries.d.ts +13 -26
- package/lib/types/application/snapshot-cache.d.ts +51 -0
- package/lib/types/application/source-store.d.ts +100 -0
- package/lib/types/catalog/archive.d.ts +3 -11
- package/lib/types/catalog/component-files.d.ts +4 -0
- package/lib/types/catalog/fs-probes.d.ts +10 -0
- package/lib/types/catalog/manifests.d.ts +17 -16
- package/lib/types/catalog/native-project.d.ts +2 -2
- package/lib/types/catalog/paths.d.ts +47 -15
- package/lib/types/catalog/scan-pipeline.d.ts +3 -3
- package/lib/types/catalog/scan-resolvers.d.ts +3 -9
- package/lib/types/catalog/skills-parse.d.ts +0 -7
- package/lib/types/catalog/source-catalog.d.ts +3 -3
- package/lib/types/catalog/suite-scanner.d.ts +8 -6
- package/lib/types/catalog/validate.d.ts +0 -2
- package/lib/types/contracts/lsp-status.d.ts +35 -0
- package/lib/types/contracts/market.d.ts +3 -2
- package/lib/types/contracts/mcp-status.d.ts +4 -0
- package/lib/types/model/types.d.ts +29 -5
- package/lib/types/runtime/agent-role-names.d.ts +6 -0
- package/lib/types/runtime/agent-role-router.d.ts +54 -51
- package/lib/types/runtime/deadline.d.ts +18 -0
- package/lib/types/runtime/feedback-tool.d.ts +33 -11
- package/lib/types/runtime/hooks-mounts.d.ts +1 -1
- package/lib/types/runtime/host-locale.d.ts +1 -1
- package/lib/types/runtime/lsp-direct-config.d.ts +3 -3
- package/lib/types/runtime/lsp-mounts.d.ts +62 -19
- package/lib/types/runtime/lsp-status.d.ts +2 -1
- package/lib/types/runtime/mcp-auth-record.d.ts +29 -0
- package/lib/types/runtime/mcp-backend.d.ts +7 -4
- package/lib/types/runtime/mcp-client/config.d.ts +0 -2
- package/lib/types/runtime/mcp-client/host-contract.d.ts +78 -0
- package/lib/types/runtime/mcp-client/oauth.d.ts +1 -1
- package/lib/types/runtime/mcp-client/plugin-identity.d.ts +16 -0
- package/lib/types/runtime/mcp-client/projection.d.ts +45 -0
- package/lib/types/runtime/mcp-client/tools.d.ts +5 -72
- package/lib/types/runtime/mcp-config.d.ts +25 -4
- package/lib/types/runtime/mcp-direct-config.d.ts +18 -4
- package/lib/types/runtime/mcp-mounts.d.ts +10 -15
- package/lib/types/runtime/mcp-overrides.d.ts +1 -1
- package/lib/types/runtime/mcp-status.d.ts +1 -1
- package/lib/types/runtime/mount-lifecycle.d.ts +53 -0
- package/lib/types/runtime/profile-seam.d.ts +64 -0
- package/lib/types/runtime/reconcile-scheduler.d.ts +45 -0
- package/lib/types/runtime/reconciler.d.ts +2 -0
- package/lib/types/runtime/settings-namespace.d.ts +76 -0
- package/lib/types/runtime/source-auto-update.d.ts +33 -0
- package/lib/types/{model/state.d.ts → runtime/state-store.d.ts} +2 -2
- package/lib/types/runtime/storage-migration.d.ts +5 -3
- package/lib/types/runtime/subagent-catalog.d.ts +2 -0
- package/lib/types/runtime/user-panels.d.ts +8 -11
- package/lib/types/runtime/user-store.d.ts +2 -4
- package/package.json +48 -21
- package/schemas/README.md +1 -1
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural mirrors of the harness tool-registration surface the bridge
|
|
3
|
+
* registers against: the runtime accepts the same object shape the upstream
|
|
4
|
+
* `@deepseek-ai/dsh-tools` bridge registers, so the model-facing behavior is
|
|
5
|
+
* identical. Mirrored rather than imported so the market never loads a second
|
|
6
|
+
* copy of the host runtime.
|
|
7
|
+
*
|
|
8
|
+
* @module runtime/mcp-client/host-contract
|
|
9
|
+
*/
|
|
10
|
+
export {};
|
|
@@ -22,18 +22,16 @@
|
|
|
22
22
|
* then last exactly as long as the process.
|
|
23
23
|
*
|
|
24
24
|
* Ported from the harness `dsh-mcp-client` OAuth implementation (upstream
|
|
25
|
-
* commits archived at `docs/upstream-proposal/patches/`); the record format is
|
|
25
|
+
* commits archived at `docs/developer/upstream-proposal/patches/`); the record format is
|
|
26
26
|
* byte-compatible, so grants written by either implementation are interchangeable.
|
|
27
27
|
*
|
|
28
28
|
* @module runtime/mcp-client/oauth
|
|
29
29
|
*/
|
|
30
30
|
import { createServer } from 'node:http';
|
|
31
31
|
import { credentialKey, scrubbedParentEnv } from './host-seams.js';
|
|
32
|
+
import { PLUGIN_NAME, PLUGIN_VERSION } from './plugin-identity.js';
|
|
32
33
|
/** OAuth client identity presented during dynamic registration. */
|
|
33
|
-
const CLIENT_NAME =
|
|
34
|
-
/** Software id/version advertised in RFC 7591 registration metadata. */
|
|
35
|
-
const CLIENT_SOFTWARE_ID = 'dsh-agent-plugins-market';
|
|
36
|
-
const CLIENT_SOFTWARE_VERSION = '0.1.0';
|
|
34
|
+
const CLIENT_NAME = `DeepSeek Harness (${PLUGIN_NAME})`;
|
|
37
35
|
/** Credential-record scope for MCP OAuth state; per-server ids follow it. */
|
|
38
36
|
export const MCP_AUTH_RECORD_SCOPE = 'mcp-auth';
|
|
39
37
|
/** Loopback callback budget: the browser must land within five minutes. */
|
|
@@ -146,8 +144,8 @@ export class LoopbackOAuthClientProvider {
|
|
|
146
144
|
// Public client: PKCE carries the proof; no client secret exists.
|
|
147
145
|
token_endpoint_auth_method: 'none',
|
|
148
146
|
...(this.scope === undefined ? {} : { scope: this.scope }),
|
|
149
|
-
software_id:
|
|
150
|
-
software_version:
|
|
147
|
+
software_id: PLUGIN_NAME,
|
|
148
|
+
software_version: PLUGIN_VERSION
|
|
151
149
|
};
|
|
152
150
|
}
|
|
153
151
|
async clientInformation() {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This plugin's own identity as advertised to the servers it talks to: the MCP
|
|
3
|
+
* client implementation name and version, and the RFC 7591 `software_id` /
|
|
4
|
+
* `software_version` carried in OAuth dynamic-registration metadata.
|
|
5
|
+
*
|
|
6
|
+
* The version is read from the installed manifest through Node's package
|
|
7
|
+
* self-reference, so a release bumps exactly one number — `package.json`. A
|
|
8
|
+
* copy that can no longer resolve its own manifest (a bundle that inlined this
|
|
9
|
+
* module) reports {@link UNKNOWN_VERSION} rather than failing the connection.
|
|
10
|
+
*
|
|
11
|
+
* @module runtime/mcp-client/plugin-identity
|
|
12
|
+
*/
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
/** Name npm and the harness know this plugin by. */
|
|
15
|
+
export const PLUGIN_NAME = 'dsh-agent-plugins-market';
|
|
16
|
+
/** Reported when the plugin cannot read its own manifest; never a released version. */
|
|
17
|
+
const UNKNOWN_VERSION = '0.0.0';
|
|
18
|
+
function readVersion() {
|
|
19
|
+
try {
|
|
20
|
+
const manifest = createRequire(import.meta.url)('dsh-agent-plugins-market/package.json');
|
|
21
|
+
return typeof manifest.version === 'string' && manifest.version !== '' ? manifest.version : UNKNOWN_VERSION;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return UNKNOWN_VERSION;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** This plugin's release version. */
|
|
28
|
+
export const PLUGIN_VERSION = readVersion();
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP result projection: turn the content blocks an MCP server returns into
|
|
3
|
+
* the host content vocabulary the model sees.
|
|
4
|
+
*
|
|
5
|
+
* Text-like blocks collapse into newline-joined text runs; declared images are
|
|
6
|
+
* decoded, admitted against the live model route, and saved to the durable
|
|
7
|
+
* attachment store. Every refusal — an unsupported media type, a model that
|
|
8
|
+
* does not declare image input, a storage fault — projects the block as text
|
|
9
|
+
* explaining why, and the canonical raw value stays available to programmatic
|
|
10
|
+
* callers in the tool result.
|
|
11
|
+
*
|
|
12
|
+
* Everything here reads an untrusted network payload: the MCP server is an
|
|
13
|
+
* external process and the SDK's declared-required fields may be absent at
|
|
14
|
+
* runtime, so each block is narrowed field by field.
|
|
15
|
+
*
|
|
16
|
+
* @module runtime/mcp-client/projection
|
|
17
|
+
*/
|
|
18
|
+
import { isImageAdmissionError } from './host-seams.js';
|
|
19
|
+
/** Raster formats supported by the durable attachment vocabulary. */
|
|
20
|
+
const IMAGE_MEDIA_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
|
|
21
|
+
/** Canonical RFC 4648 base64, excluding whitespace and URL-safe aliases. */
|
|
22
|
+
const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
23
|
+
/** Whether an untrusted MCP content array contains a declared image block. */
|
|
24
|
+
export function containsImage(content) {
|
|
25
|
+
return content.some(value => isRecord(value) && value.type === 'image');
|
|
26
|
+
}
|
|
27
|
+
/** Narrow one JSON value to a string-keyed object. */
|
|
28
|
+
function isRecord(value) {
|
|
29
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
30
|
+
}
|
|
31
|
+
/** Narrow a declared MIME string to the durable image vocabulary. */
|
|
32
|
+
function isImageMediaType(value) {
|
|
33
|
+
return IMAGE_MEDIA_TYPES.includes(value);
|
|
34
|
+
}
|
|
35
|
+
/** Decode one untrusted MCP image block without accepting base64 aliases. */
|
|
36
|
+
function decodeImage(block) {
|
|
37
|
+
if (block.mimeType === undefined || !isImageMediaType(block.mimeType)) {
|
|
38
|
+
throw new Error('the declared media type is not PNG, JPEG, WebP, or GIF');
|
|
39
|
+
}
|
|
40
|
+
if (block.data === undefined || !CANONICAL_BASE64.test(block.data)) {
|
|
41
|
+
throw new Error('the image data is not canonical base64');
|
|
42
|
+
}
|
|
43
|
+
const data = Buffer.from(block.data, 'base64');
|
|
44
|
+
if (data.toString('base64') !== block.data) {
|
|
45
|
+
throw new Error('the image data is not canonical base64');
|
|
46
|
+
}
|
|
47
|
+
return { data, mediaType: block.mimeType };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the active model route and durable store for an image-bearing result.
|
|
51
|
+
* @param host - bridge host with optional services.
|
|
52
|
+
* @param exec - exact tool execution whose agent supplies the latest route.
|
|
53
|
+
* @returns the attachment store after exact positive image-capability proof.
|
|
54
|
+
*/
|
|
55
|
+
async function resolveImageAdmission(host, exec) {
|
|
56
|
+
const attachments = host.getService?.('attachments');
|
|
57
|
+
if (attachments === undefined || typeof attachments.saveImages !== 'function') {
|
|
58
|
+
throw new Error('no attachment store is mounted');
|
|
59
|
+
}
|
|
60
|
+
const routed = exec.agent?.session?.requestHeader?.()?.config;
|
|
61
|
+
const provider = routed?.provider ?? exec.agent?.options?.provider;
|
|
62
|
+
const model = routed?.model ?? exec.agent?.options?.model;
|
|
63
|
+
const llm = host.getService?.('llm');
|
|
64
|
+
if (provider === undefined || model === undefined || llm === undefined) {
|
|
65
|
+
throw new Error('the current model route could not be resolved');
|
|
66
|
+
}
|
|
67
|
+
let info;
|
|
68
|
+
try {
|
|
69
|
+
info = await llm.resolveModelInfo(provider, model, exec.signal);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
throw new Error('the current model route could not be verified');
|
|
73
|
+
}
|
|
74
|
+
if (info?.inputModalities === undefined || !info.inputModalities.includes('image')) {
|
|
75
|
+
throw new Error(`model "${model}" does not declare image input`);
|
|
76
|
+
}
|
|
77
|
+
if (exec.signal.aborted)
|
|
78
|
+
throw new Error('the tool call was canceled before image storage');
|
|
79
|
+
return attachments;
|
|
80
|
+
}
|
|
81
|
+
/** Stable diagnostic text for an image block that was not admitted. */
|
|
82
|
+
function imageDiagnostic(block, reason) {
|
|
83
|
+
return `[image unavailable: ${block.mimeType ?? 'unknown media type'}; ${reason}; raw image data remains available to programmatic callers]`;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Decode, preflight, and durably save one MCP result's ordered image batch.
|
|
87
|
+
* Any refusal projects every image as text while retaining the canonical raw
|
|
88
|
+
* value for programmatic callers.
|
|
89
|
+
*/
|
|
90
|
+
export async function prepareImageProjection(host, exec, content, toolName) {
|
|
91
|
+
const decoded = [];
|
|
92
|
+
const validationErrors = new Map();
|
|
93
|
+
const imageIndexes = [];
|
|
94
|
+
for (const [index, value] of content.entries()) {
|
|
95
|
+
if (!isRecord(value) || value.type !== 'image')
|
|
96
|
+
continue;
|
|
97
|
+
imageIndexes.push(index);
|
|
98
|
+
try {
|
|
99
|
+
decoded.push(decodeImage(value));
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
// decodeImage owns every throw above and always produces Error.
|
|
103
|
+
validationErrors.set(index, error.message);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (validationErrors.size > 0) {
|
|
107
|
+
return projectContent(content, toolName, (block, index) => ({
|
|
108
|
+
type: 'text',
|
|
109
|
+
text: imageDiagnostic(block, validationErrors.get(index) ?? 'another image in the same result was invalid')
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
let attachments;
|
|
113
|
+
try {
|
|
114
|
+
attachments = await resolveImageAdmission(host, exec);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
// resolveImageAdmission contains provider failures and throws Error only.
|
|
118
|
+
const reason = error.message;
|
|
119
|
+
return projectContent(content, toolName, block => ({ type: 'text', text: imageDiagnostic(block, reason) }));
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const refs = await attachments.saveImages(decoded);
|
|
123
|
+
const byIndex = new Map(imageIndexes.map((index, offset) => [index, refs[offset]]));
|
|
124
|
+
return projectContent(content, toolName, (_block, index) => ({
|
|
125
|
+
type: 'image',
|
|
126
|
+
attachment: byIndex.get(index)
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
const reason = isImageAdmissionError(error) ? `image admission rejected the result: ${error.message}` : 'durable image storage rejected the result';
|
|
131
|
+
return projectContent(content, toolName, block => ({
|
|
132
|
+
type: 'text',
|
|
133
|
+
text: imageDiagnostic(block, reason)
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Extract text from an MCP content array into a single string.
|
|
139
|
+
* - text blocks: join with '\n'
|
|
140
|
+
* - image/audio/resource blocks: replaced with a placeholder
|
|
141
|
+
*
|
|
142
|
+
* Defensive: fields that the MCP spec declares required (mimeType, text) are
|
|
143
|
+
* guarded with fallbacks because this is a network trust boundary.
|
|
144
|
+
*/
|
|
145
|
+
export function extractText(mcpContent, toolName) {
|
|
146
|
+
const content = projectContent(mcpContent, toolName);
|
|
147
|
+
// The default image projector below also returns text, so this local call
|
|
148
|
+
// cannot produce a core image block.
|
|
149
|
+
return content.map(block => block.text).join('\n');
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Project ordered MCP blocks into the core content vocabulary.
|
|
153
|
+
* Text-like runs are newline-coalesced; admitted images split those runs at
|
|
154
|
+
* their original position.
|
|
155
|
+
*/
|
|
156
|
+
function projectContent(mcpContent, toolName, image = block => ({
|
|
157
|
+
type: 'text',
|
|
158
|
+
text: imageDiagnostic(block, 'this result was not admitted to durable model context')
|
|
159
|
+
})) {
|
|
160
|
+
const projected = [];
|
|
161
|
+
const text = [];
|
|
162
|
+
const flushText = () => {
|
|
163
|
+
if (text.length === 0)
|
|
164
|
+
return;
|
|
165
|
+
projected.push({ type: 'text', text: text.splice(0).join('\n') });
|
|
166
|
+
};
|
|
167
|
+
for (const [index, value] of mcpContent.entries()) {
|
|
168
|
+
if (!isRecord(value)) {
|
|
169
|
+
text.push('[unsupported MCP content block: expected an object]');
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const block = value;
|
|
173
|
+
switch (block.type) {
|
|
174
|
+
case 'text':
|
|
175
|
+
if (block.text !== undefined)
|
|
176
|
+
text.push(block.text);
|
|
177
|
+
break;
|
|
178
|
+
case 'image':
|
|
179
|
+
flushText();
|
|
180
|
+
projected.push(image(block, index));
|
|
181
|
+
break;
|
|
182
|
+
case 'resource_link':
|
|
183
|
+
if (block.name === undefined || block.uri === undefined) {
|
|
184
|
+
text.push('[resource link unavailable: the MCP block is missing its name or URI]');
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
text.push(`Resource link: ${block.name} (${block.uri})`);
|
|
188
|
+
}
|
|
189
|
+
break;
|
|
190
|
+
case 'audio':
|
|
191
|
+
text.push(`[audio result unsupported: ${block.mimeType ?? 'unknown media type'}; raw audio data remains available to programmatic callers]`);
|
|
192
|
+
break;
|
|
193
|
+
case 'resource':
|
|
194
|
+
text.push('[embedded resource unsupported; raw resource data remains available to programmatic callers]');
|
|
195
|
+
break;
|
|
196
|
+
default:
|
|
197
|
+
text.push(`[unsupported MCP content type: ${block.type}]`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
flushText();
|
|
201
|
+
return projected.length > 0 ? projected : [{ type: 'text', text: `(${toolName} returned no model-visible content)` }];
|
|
202
|
+
}
|
|
@@ -9,10 +9,9 @@
|
|
|
9
9
|
* to the DeepSeek function-name constraints. The raw name is only ever sent on
|
|
10
10
|
* the wire (`tools/call`); the public name is never parsed to recover it.
|
|
11
11
|
*
|
|
12
|
-
* The
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* upstream bridge registers, so the model-facing behavior is identical.
|
|
12
|
+
* The registration surface this module targets is mirrored by
|
|
13
|
+
* `./host-contract.js`; MCP results are mapped into host content blocks by
|
|
14
|
+
* `./projection.js`.
|
|
16
15
|
*
|
|
17
16
|
* @module runtime/mcp-client/tools
|
|
18
17
|
*/
|
|
@@ -21,7 +20,7 @@ import { isDeepStrictEqual } from 'node:util';
|
|
|
21
20
|
import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
22
21
|
import { z } from 'zod';
|
|
23
22
|
import { assertSupportedJsonSchema } from './json-schema-subset.js';
|
|
24
|
-
import {
|
|
23
|
+
import { containsImage, extractText, prepareImageProjection } from './projection.js';
|
|
25
24
|
/**
|
|
26
25
|
* DeepSeek function-name contract: at most 64 characters. Wire-protocol
|
|
27
26
|
* constant, not configuration.
|
|
@@ -31,10 +30,6 @@ const MAX_PUBLIC_NAME_LENGTH = 64;
|
|
|
31
30
|
const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g;
|
|
32
31
|
/** Hex chars of the SHA-256 identity hash appended on lossy normalization. */
|
|
33
32
|
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
33
|
/** Raw result record: the bridge owns JSON-value validation after transport. */
|
|
39
34
|
const RawCallToolResultSchema = z.record(z.string(), z.unknown());
|
|
40
35
|
/** List without mutating the SDK's per-page output-validator cache. */
|
|
@@ -253,183 +248,3 @@ function createExecutor(client, host, rawName, taskRequired, opts, projections)
|
|
|
253
248
|
return value;
|
|
254
249
|
};
|
|
255
250
|
}
|
|
256
|
-
/** Whether an untrusted MCP content array contains a declared image block. */
|
|
257
|
-
function containsImage(content) {
|
|
258
|
-
return content.some(value => isRecord(value) && value.type === 'image');
|
|
259
|
-
}
|
|
260
|
-
/** Narrow one JSON value to a string-keyed object. */
|
|
261
|
-
function isRecord(value) {
|
|
262
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
263
|
-
}
|
|
264
|
-
/** Narrow a declared MIME string to the durable image vocabulary. */
|
|
265
|
-
function isImageMediaType(value) {
|
|
266
|
-
return IMAGE_MEDIA_TYPES.includes(value);
|
|
267
|
-
}
|
|
268
|
-
/** Decode one untrusted MCP image block without accepting base64 aliases. */
|
|
269
|
-
function decodeImage(block) {
|
|
270
|
-
if (block.mimeType === undefined || !isImageMediaType(block.mimeType)) {
|
|
271
|
-
throw new Error('the declared media type is not PNG, JPEG, WebP, or GIF');
|
|
272
|
-
}
|
|
273
|
-
if (block.data === undefined || !CANONICAL_BASE64.test(block.data)) {
|
|
274
|
-
throw new Error('the image data is not canonical base64');
|
|
275
|
-
}
|
|
276
|
-
const data = Buffer.from(block.data, 'base64');
|
|
277
|
-
if (data.toString('base64') !== block.data) {
|
|
278
|
-
throw new Error('the image data is not canonical base64');
|
|
279
|
-
}
|
|
280
|
-
return { data, mediaType: block.mimeType };
|
|
281
|
-
}
|
|
282
|
-
/**
|
|
283
|
-
* Resolve the active model route and durable store for an image-bearing result.
|
|
284
|
-
* @param host - bridge host with optional services.
|
|
285
|
-
* @param exec - exact tool execution whose agent supplies the latest route.
|
|
286
|
-
* @returns the attachment store after exact positive image-capability proof.
|
|
287
|
-
*/
|
|
288
|
-
async function resolveImageAdmission(host, exec) {
|
|
289
|
-
const attachments = host.getService?.('attachments');
|
|
290
|
-
if (attachments === undefined || typeof attachments.saveImages !== 'function') {
|
|
291
|
-
throw new Error('no attachment store is mounted');
|
|
292
|
-
}
|
|
293
|
-
const routed = exec.agent?.session?.requestHeader?.()?.config;
|
|
294
|
-
const provider = routed?.provider ?? exec.agent?.options?.provider;
|
|
295
|
-
const model = routed?.model ?? exec.agent?.options?.model;
|
|
296
|
-
const llm = host.getService?.('llm');
|
|
297
|
-
if (provider === undefined || model === undefined || llm === undefined) {
|
|
298
|
-
throw new Error('the current model route could not be resolved');
|
|
299
|
-
}
|
|
300
|
-
let info;
|
|
301
|
-
try {
|
|
302
|
-
info = await llm.resolveModelInfo(provider, model, exec.signal);
|
|
303
|
-
}
|
|
304
|
-
catch {
|
|
305
|
-
throw new Error('the current model route could not be verified');
|
|
306
|
-
}
|
|
307
|
-
if (info?.inputModalities === undefined || !info.inputModalities.includes('image')) {
|
|
308
|
-
throw new Error(`model "${model}" does not declare image input`);
|
|
309
|
-
}
|
|
310
|
-
if (exec.signal.aborted)
|
|
311
|
-
throw new Error('the tool call was canceled before image storage');
|
|
312
|
-
return attachments;
|
|
313
|
-
}
|
|
314
|
-
/** Stable diagnostic text for an image block that was not admitted. */
|
|
315
|
-
function imageDiagnostic(block, reason) {
|
|
316
|
-
return `[image unavailable: ${block.mimeType ?? 'unknown media type'}; ${reason}; raw image data remains available to programmatic callers]`;
|
|
317
|
-
}
|
|
318
|
-
/**
|
|
319
|
-
* Decode, preflight, and durably save one MCP result's ordered image batch.
|
|
320
|
-
* Any refusal projects every image as text while retaining the canonical raw
|
|
321
|
-
* value for programmatic callers.
|
|
322
|
-
*/
|
|
323
|
-
async function prepareImageProjection(host, exec, content, toolName) {
|
|
324
|
-
const decoded = [];
|
|
325
|
-
const validationErrors = new Map();
|
|
326
|
-
const imageIndexes = [];
|
|
327
|
-
for (const [index, value] of content.entries()) {
|
|
328
|
-
if (!isRecord(value) || value.type !== 'image')
|
|
329
|
-
continue;
|
|
330
|
-
imageIndexes.push(index);
|
|
331
|
-
try {
|
|
332
|
-
decoded.push(decodeImage(value));
|
|
333
|
-
}
|
|
334
|
-
catch (error) {
|
|
335
|
-
// decodeImage owns every throw above and always produces Error.
|
|
336
|
-
validationErrors.set(index, error.message);
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
if (validationErrors.size > 0) {
|
|
340
|
-
return projectContent(content, toolName, (block, index) => ({
|
|
341
|
-
type: 'text',
|
|
342
|
-
text: imageDiagnostic(block, validationErrors.get(index) ?? 'another image in the same result was invalid')
|
|
343
|
-
}));
|
|
344
|
-
}
|
|
345
|
-
let attachments;
|
|
346
|
-
try {
|
|
347
|
-
attachments = await resolveImageAdmission(host, exec);
|
|
348
|
-
}
|
|
349
|
-
catch (error) {
|
|
350
|
-
// resolveImageAdmission contains provider failures and throws Error only.
|
|
351
|
-
const reason = error.message;
|
|
352
|
-
return projectContent(content, toolName, block => ({ type: 'text', text: imageDiagnostic(block, reason) }));
|
|
353
|
-
}
|
|
354
|
-
try {
|
|
355
|
-
const refs = await attachments.saveImages(decoded);
|
|
356
|
-
const byIndex = new Map(imageIndexes.map((index, offset) => [index, refs[offset]]));
|
|
357
|
-
return projectContent(content, toolName, (_block, index) => ({
|
|
358
|
-
type: 'image',
|
|
359
|
-
attachment: byIndex.get(index)
|
|
360
|
-
}));
|
|
361
|
-
}
|
|
362
|
-
catch (error) {
|
|
363
|
-
const reason = isImageAdmissionError(error) ? `image admission rejected the result: ${error.message}` : 'durable image storage rejected the result';
|
|
364
|
-
return projectContent(content, toolName, block => ({
|
|
365
|
-
type: 'text',
|
|
366
|
-
text: imageDiagnostic(block, reason)
|
|
367
|
-
}));
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
/**
|
|
371
|
-
* Extract text from an MCP content array into a single string.
|
|
372
|
-
* - text blocks: join with '\n'
|
|
373
|
-
* - image/audio/resource blocks: replaced with a placeholder
|
|
374
|
-
*
|
|
375
|
-
* Defensive: fields that the MCP spec declares required (mimeType, text) are
|
|
376
|
-
* guarded with fallbacks because this is a network trust boundary.
|
|
377
|
-
*/
|
|
378
|
-
function extractText(mcpContent, toolName) {
|
|
379
|
-
const content = projectContent(mcpContent, toolName);
|
|
380
|
-
// The default image projector below also returns text, so this local call
|
|
381
|
-
// cannot produce a core image block.
|
|
382
|
-
return content.map(block => block.text).join('\n');
|
|
383
|
-
}
|
|
384
|
-
/**
|
|
385
|
-
* Project ordered MCP blocks into the core content vocabulary.
|
|
386
|
-
* Text-like runs are newline-coalesced; admitted images split those runs at
|
|
387
|
-
* their original position.
|
|
388
|
-
*/
|
|
389
|
-
function projectContent(mcpContent, toolName, image = block => ({
|
|
390
|
-
type: 'text',
|
|
391
|
-
text: imageDiagnostic(block, 'this result was not admitted to durable model context')
|
|
392
|
-
})) {
|
|
393
|
-
const projected = [];
|
|
394
|
-
const text = [];
|
|
395
|
-
const flushText = () => {
|
|
396
|
-
if (text.length === 0)
|
|
397
|
-
return;
|
|
398
|
-
projected.push({ type: 'text', text: text.splice(0).join('\n') });
|
|
399
|
-
};
|
|
400
|
-
for (const [index, value] of mcpContent.entries()) {
|
|
401
|
-
if (!isRecord(value)) {
|
|
402
|
-
text.push('[unsupported MCP content block: expected an object]');
|
|
403
|
-
continue;
|
|
404
|
-
}
|
|
405
|
-
const block = value;
|
|
406
|
-
switch (block.type) {
|
|
407
|
-
case 'text':
|
|
408
|
-
if (block.text !== undefined)
|
|
409
|
-
text.push(block.text);
|
|
410
|
-
break;
|
|
411
|
-
case 'image':
|
|
412
|
-
flushText();
|
|
413
|
-
projected.push(image(block, index));
|
|
414
|
-
break;
|
|
415
|
-
case 'resource_link':
|
|
416
|
-
if (block.name === undefined || block.uri === undefined) {
|
|
417
|
-
text.push('[resource link unavailable: the MCP block is missing its name or URI]');
|
|
418
|
-
}
|
|
419
|
-
else {
|
|
420
|
-
text.push(`Resource link: ${block.name} (${block.uri})`);
|
|
421
|
-
}
|
|
422
|
-
break;
|
|
423
|
-
case 'audio':
|
|
424
|
-
text.push(`[audio result unsupported: ${block.mimeType ?? 'unknown media type'}; raw audio data remains available to programmatic callers]`);
|
|
425
|
-
break;
|
|
426
|
-
case 'resource':
|
|
427
|
-
text.push('[embedded resource unsupported; raw resource data remains available to programmatic callers]');
|
|
428
|
-
break;
|
|
429
|
-
default:
|
|
430
|
-
text.push(`[unsupported MCP content type: ${block.type}]`);
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
flushText();
|
|
434
|
-
return projected.length > 0 ? projected : [{ type: 'text', text: `(${toolName} returned no model-visible content)` }];
|
|
435
|
-
}
|
|
@@ -20,6 +20,22 @@ import { PLUGIN_ROOT_VARIABLES, PLUGIN_DATA_VARIABLES } from '../model/layouts.j
|
|
|
20
20
|
const SERVER_NAME_MAX = 32;
|
|
21
21
|
const PLACEHOLDER = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
|
|
22
22
|
const BUILTIN_PLACEHOLDERS = new Set([...PLUGIN_ROOT_VARIABLES, ...PLUGIN_DATA_VARIABLES]);
|
|
23
|
+
/**
|
|
24
|
+
* The effective view of one suite's `mcp.json` servers: the user's per-server
|
|
25
|
+
* overrides applied, every row flagged with whether it is used at all, and each
|
|
26
|
+
* row's credential references read off the effective shape. Mounting, status
|
|
27
|
+
* and detail rendering read this one projection, so a server cannot be listed
|
|
28
|
+
* as enabled while mounting something else.
|
|
29
|
+
*/
|
|
30
|
+
export function effectiveMcpServers(suite, overrides = {}) {
|
|
31
|
+
const rows = [];
|
|
32
|
+
for (const [serverKey, source] of Object.entries(suite.mcp?.servers ?? {})) {
|
|
33
|
+
const override = overrides[serverKey];
|
|
34
|
+
const server = applyOverride(source, override);
|
|
35
|
+
rows.push({ serverKey, server, override, enabled: override?.enabled !== false, credentialRefs: credentialRefsInServer(server) });
|
|
36
|
+
}
|
|
37
|
+
return rows;
|
|
38
|
+
}
|
|
23
39
|
/**
|
|
24
40
|
* Build one mount request per supported mcp.json server, resolving every
|
|
25
41
|
* `${NAME}` through the credential resolver before a child process or HTTP
|
|
@@ -29,31 +45,34 @@ const BUILTIN_PLACEHOLDERS = new Set([...PLUGIN_ROOT_VARIABLES, ...PLUGIN_DATA_V
|
|
|
29
45
|
* @param overrides user-owned per-server overrides (url/headers/env/args
|
|
30
46
|
* replacement plus enable/disable); applied before mount. Disabled servers
|
|
31
47
|
* are omitted entirely.
|
|
32
|
-
* @returns mount requests
|
|
33
|
-
*
|
|
48
|
+
* @returns mount requests, per-server failures, and the credential references
|
|
49
|
+
* the effective servers need. serverName collisions are reported by the
|
|
50
|
+
* mount registry, not here.
|
|
34
51
|
*/
|
|
35
52
|
export async function toMcpMounts(suite, pluginDataRoot, overrides = {}, resolver = { resolve: async () => undefined }) {
|
|
36
53
|
if (suite.mcp === undefined)
|
|
37
|
-
return { mounts: [], failures: [] };
|
|
54
|
+
return { mounts: [], failures: [], credentialRefs: [] };
|
|
38
55
|
const mounts = [];
|
|
39
56
|
const failures = [];
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (
|
|
57
|
+
const credentialRefs = new Set();
|
|
58
|
+
for (const row of effectiveMcpServers(suite, overrides)) {
|
|
59
|
+
if (!row.enabled)
|
|
43
60
|
continue;
|
|
44
|
-
const
|
|
61
|
+
for (const ref of row.credentialRefs)
|
|
62
|
+
credentialRefs.add(ref);
|
|
45
63
|
try {
|
|
46
|
-
const
|
|
64
|
+
const target = suite.mcp.root === undefined ? suite : { ...suite, root: suite.mcp.root };
|
|
65
|
+
const result = await toResolvedMount(target, row.serverKey, row.server, pluginDataRoot, resolver);
|
|
47
66
|
if (result.failure !== undefined)
|
|
48
67
|
failures.push(result.failure);
|
|
49
68
|
if (result.request !== undefined)
|
|
50
69
|
mounts.push(result.request);
|
|
51
70
|
}
|
|
52
71
|
catch {
|
|
53
|
-
failures.push({ serverKey, code: 'credential-error', credentialRefs:
|
|
72
|
+
failures.push({ serverKey: row.serverKey, code: 'credential-error', credentialRefs: row.credentialRefs, reason: 'credential lookup failed' });
|
|
54
73
|
}
|
|
55
74
|
}
|
|
56
|
-
return { mounts, failures };
|
|
75
|
+
return { mounts, failures, credentialRefs: [...credentialRefs] };
|
|
57
76
|
}
|
|
58
77
|
/** Find external credential references used by one MCP server definition. */
|
|
59
78
|
export function credentialRefsInServer(server) {
|
|
@@ -1,56 +1,73 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
|
|
3
4
|
import { MCP_SCHEMA_ID, validateAgainstSchema, validateMcpJson } from '../catalog/validate.js';
|
|
5
|
+
import { effectiveSurfaces } from '../model/types.js';
|
|
4
6
|
export const USER_MCP_SOURCE = '@user-mcp';
|
|
5
7
|
export const USER_MCP_SUITE = 'user-mcp';
|
|
6
|
-
/**
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* User-created services reuse the owned bridge lifecycle without changing host configuration.
|
|
10
|
+
*
|
|
11
|
+
* The declarations live in the shared Agent layout root
|
|
12
|
+
* (`~/.agents/mcp.json`), so a service the user adds by hand is ordinary
|
|
13
|
+
* `mcpServers` JSON in the same place other Agent tools look.
|
|
14
|
+
*
|
|
15
|
+
* The suite always carries an `mcp` document — an absent or malformed
|
|
16
|
+
* `mcp.json` leaves an empty one — so callers read `.mcp.servers` directly
|
|
17
|
+
* instead of re-checking the optional field.
|
|
18
|
+
*/
|
|
19
|
+
export async function loadUserMcpSuite(agentsRoot) {
|
|
20
|
+
const path = userMcpPath(agentsRoot);
|
|
9
21
|
let mcp = { schema: MCP_SCHEMA_ID, servers: {} };
|
|
10
22
|
const errors = [];
|
|
11
23
|
try {
|
|
12
|
-
mcp = await validateUserMcp(
|
|
24
|
+
mcp = await validateUserMcp(agentsRoot, JSON.parse(await readFile(path, 'utf8')));
|
|
13
25
|
}
|
|
14
26
|
catch (error) {
|
|
15
27
|
if (error.code !== 'ENOENT')
|
|
16
|
-
errors.push(`mcp
|
|
28
|
+
errors.push(`mcp.json: ${error instanceof Error ? error.message : String(error)}`);
|
|
17
29
|
}
|
|
18
30
|
return {
|
|
19
31
|
sourceId: USER_MCP_SOURCE,
|
|
20
32
|
id: USER_MCP_SUITE,
|
|
21
|
-
root:
|
|
33
|
+
root: agentsRoot,
|
|
22
34
|
manifest: { layout: 'agent-plugin-v1', path, id: USER_MCP_SUITE, name: USER_MCP_SUITE },
|
|
23
35
|
skills: [],
|
|
24
36
|
mcp,
|
|
25
37
|
surfaces: { skills: 0, mcp: Object.keys(mcp.servers).length, commands: 0, agents: 0, hooks: 0, lsp: 0 },
|
|
26
38
|
dimension: 'user',
|
|
27
39
|
enabled: true,
|
|
40
|
+
// A user-created suite has no install entry and no overrides, so every
|
|
41
|
+
// surface keeps its enabled default.
|
|
42
|
+
activeSurfaces: effectiveSurfaces(undefined),
|
|
28
43
|
installedAt: 'user',
|
|
29
44
|
errors
|
|
30
45
|
};
|
|
31
46
|
}
|
|
32
|
-
async function validateUserMcp(
|
|
47
|
+
async function validateUserMcp(agentsRoot, raw) {
|
|
33
48
|
const errors = await validateAgainstSchema(MCP_SCHEMA_ID, raw);
|
|
34
49
|
if (errors.length > 0)
|
|
35
50
|
throw new Error(`invalid MCP configuration: ${errors.join('; ')}`);
|
|
36
|
-
const result = await validateMcpJson(
|
|
51
|
+
const result = await validateMcpJson(agentsRoot, raw);
|
|
37
52
|
if (result.config === undefined || result.errors.length > 0)
|
|
38
53
|
throw new Error(result.errors.join('; '));
|
|
39
54
|
return result.config;
|
|
40
55
|
}
|
|
41
56
|
/** Add one service atomically; reject collisions and malformed config before writing. */
|
|
42
|
-
export async function addUserMcpServer(
|
|
57
|
+
export async function addUserMcpServer(agentsRoot, name, server) {
|
|
43
58
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name))
|
|
44
59
|
throw new Error('invalid MCP server name');
|
|
45
|
-
const suite = await loadUserMcpSuite(
|
|
60
|
+
const suite = await loadUserMcpSuite(agentsRoot);
|
|
46
61
|
if (suite.errors.length > 0)
|
|
47
62
|
throw new Error(suite.errors.join('; '));
|
|
48
63
|
if (Object.hasOwn(suite.mcp.servers, name))
|
|
49
64
|
throw new Error(`MCP server "${name}" already exists`);
|
|
50
65
|
const document = { $schema: MCP_SCHEMA_ID, mcpServers: { ...suite.mcp.servers, [name]: server } };
|
|
51
|
-
await validateUserMcp(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
66
|
+
await validateUserMcp(agentsRoot, document);
|
|
67
|
+
const path = userMcpPath(agentsRoot);
|
|
68
|
+
await writeFileAtomic(path, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, dirMode: 0o700 });
|
|
69
|
+
}
|
|
70
|
+
/** The user's hand-written MCP declaration file: `<agentsRoot>/mcp.json`. */
|
|
71
|
+
export function userMcpPath(agentsRoot) {
|
|
72
|
+
return join(agentsRoot, 'mcp.json');
|
|
56
73
|
}
|