@hzxwonder/dsh-mcp 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/deepseek-worker/SKILL.md +40 -9
- package/README.md +79 -32
- package/config/routing.md +26 -4
- package/config/worker.patch.yml +12 -2
- package/package.json +5 -1
- package/server.json +21 -6
- package/src/cli.js +4 -0
- package/src/doctor.js +230 -0
- package/src/provider.js +301 -5
- package/src/server.js +12 -7
- package/src/setup-terminal.js +183 -0
- package/src/setup.js +69 -69
- package/src/worker.js +39 -13
- package/src/setup-ui.js +0 -112
package/src/doctor.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { access, readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { basename, delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
6
|
+
import { isDeepStrictEqual, parseArgs, promisify } from 'node:util';
|
|
7
|
+
import { parse } from 'smol-toml';
|
|
8
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
9
|
+
import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
const requiredTool = 'delegate_to_deepseek';
|
|
13
|
+
const reloadHint = 'This checks a new MCP connection, not the running Codex Desktop connection. If dsh is absent there, fully quit and reopen Codex, then start a new chat and inspect its MCP status. No client was restarted.';
|
|
14
|
+
const authHint = 'Auth unsupported is normal for a local stdio MCP server; it does not report the worker API key status. No model/API request was made.';
|
|
15
|
+
const rootHint = 'Each delegation still needs an absolute project_root and explicit thinking_effort; registration does not choose a workspace.';
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async function executablePath(command, env, cwd = process.cwd()) {
|
|
19
|
+
const candidates = isAbsolute(command) || command.includes('/') || command.includes('\\')
|
|
20
|
+
? [resolve(cwd, command)]
|
|
21
|
+
: (env.PATH || '').split(delimiter).filter(Boolean).map((directory) => resolve(cwd, directory, command));
|
|
22
|
+
for (const candidate of candidates) {
|
|
23
|
+
try {
|
|
24
|
+
await access(candidate, constants.X_OK);
|
|
25
|
+
if ((await stat(candidate)).isFile()) return candidate;
|
|
26
|
+
} catch { /* Try the next PATH entry. */ }
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function pathHint(path) {
|
|
32
|
+
return /(?:^|[/\\])(?:_npx|node_modules)(?:[/\\]|$)/.test(path)
|
|
33
|
+
? 'The registered package/cache path is stale or incomplete. Rerun dsh-mcp setup from the current installed version to replace the old cached path.'
|
|
34
|
+
: 'Rerun dsh-mcp setup from an existing installation to refresh the absolute executable and server script paths.';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Report only diagnostic categories, never arbitrary child output, config values, or prompts.
|
|
38
|
+
function startupDetail(error, stderr = '') {
|
|
39
|
+
const text = `${error?.code || ''} ${error?.message || ''}\n${stderr}`;
|
|
40
|
+
const codes = [...new Set(text.match(/\b(?:ERR_MODULE_NOT_FOUND|ERR_REQUIRE_ESM|ERR_PACKAGE_PATH_NOT_EXPORTED|ERR_DLOPEN_FAILED|ERR_UNKNOWN_FILE_EXTENSION|ERR_INVALID_ARG_TYPE|ERR_ACCESS_DENIED|ERR_INVALID_PACKAGE_CONFIG|MODULE_NOT_FOUND|ENOENT|EACCES|ENOTDIR|SyntaxError|ReferenceError|TypeError)\b/g) || [])];
|
|
41
|
+
if (codes.length) return codes.slice(0, 5).join(', ');
|
|
42
|
+
if (/timed?\s*out|timeout|abort/i.test(text)) return 'startup/discovery timed out';
|
|
43
|
+
if (/connection closed|EPIPE/i.test(text)) return 'connection closed during startup/discovery';
|
|
44
|
+
if (/JSON|Unexpected token/i.test(text)) return 'invalid MCP JSON output';
|
|
45
|
+
return 'server startup or MCP protocol error';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function inspectCodexCli(entry, configPath, timeoutMs) {
|
|
49
|
+
const command = await executablePath('codex', getDefaultEnvironment());
|
|
50
|
+
if (!command) return { available: false, ok: null, enabled: null, error: null };
|
|
51
|
+
try {
|
|
52
|
+
const { stdout } = await execFileAsync(command, ['mcp', 'get', 'dsh', '--json'], {
|
|
53
|
+
env: { ...process.env, CODEX_HOME: dirname(configPath) },
|
|
54
|
+
timeout: timeoutMs, maxBuffer: 1024 * 1024, windowsHide: true,
|
|
55
|
+
});
|
|
56
|
+
const effective = JSON.parse(stdout);
|
|
57
|
+
const transport = effective.transport;
|
|
58
|
+
const matches = effective.enabled !== false && transport?.type === 'stdio'
|
|
59
|
+
&& transport.command === entry.command
|
|
60
|
+
&& JSON.stringify(transport.args || []) === JSON.stringify(entry.args || [])
|
|
61
|
+
&& (transport.cwd || null) === (entry.cwd || null)
|
|
62
|
+
&& isDeepStrictEqual(transport.env || {}, entry.env || {})
|
|
63
|
+
&& isDeepStrictEqual(transport.env_vars || [], entry.env_vars || [])
|
|
64
|
+
&& !effective.disabled_tools?.includes(requiredTool)
|
|
65
|
+
&& (!effective.enabled_tools || effective.enabled_tools.includes(requiredTool));
|
|
66
|
+
return {
|
|
67
|
+
available: true, ok: matches, enabled: effective.enabled !== false,
|
|
68
|
+
error: matches ? null : 'The Codex CLI effective dsh entry is disabled or differs from this stdio registration.',
|
|
69
|
+
};
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return { available: true, ok: false, enabled: null, error: `Codex CLI could not read the effective dsh registration (${startupDetail(error)}).` };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function inspectRegistration({
|
|
76
|
+
configPath = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'config.toml'),
|
|
77
|
+
timeoutMs = 12000,
|
|
78
|
+
checkCodexCli = true,
|
|
79
|
+
} = {}) {
|
|
80
|
+
const result = {
|
|
81
|
+
ok: false, configPath: resolve(configPath), serverVersion: null, tools: [],
|
|
82
|
+
codexCli: null, error: null, hints: [authHint, rootHint],
|
|
83
|
+
};
|
|
84
|
+
let client;
|
|
85
|
+
let transport;
|
|
86
|
+
let stderr = '';
|
|
87
|
+
try {
|
|
88
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error('Provide a positive timeoutMs.');
|
|
89
|
+
let config;
|
|
90
|
+
try {
|
|
91
|
+
config = parse(await readFile(result.configPath, 'utf8'));
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw new Error(error.code === 'ENOENT'
|
|
94
|
+
? 'Codex config.toml does not exist. Run dsh-mcp setup for this CODEX_HOME.'
|
|
95
|
+
: 'Codex config.toml cannot be read or parsed. Check its permissions and TOML syntax.');
|
|
96
|
+
}
|
|
97
|
+
const entry = config.mcp_servers?.dsh;
|
|
98
|
+
if (!entry || typeof entry !== 'object') throw new Error('No mcp_servers.dsh entry exists. Run dsh-mcp setup for this CODEX_HOME.');
|
|
99
|
+
if (entry.enabled === false) throw new Error('The dsh MCP entry is disabled. Enable mcp_servers.dsh in the selected Codex config.');
|
|
100
|
+
if ((entry.disabled_tools !== undefined && (!Array.isArray(entry.disabled_tools) || entry.disabled_tools.some((name) => typeof name !== 'string')))
|
|
101
|
+
|| (entry.enabled_tools !== undefined && (!Array.isArray(entry.enabled_tools) || entry.enabled_tools.some((name) => typeof name !== 'string')))) {
|
|
102
|
+
throw new Error('The dsh MCP enabled_tools and disabled_tools must be arrays of tool names.');
|
|
103
|
+
}
|
|
104
|
+
if (entry.disabled_tools?.includes(requiredTool) || (entry.enabled_tools && !entry.enabled_tools.includes(requiredTool))) {
|
|
105
|
+
throw new Error('Codex tool filters hide delegate_to_deepseek. Remove it from disabled_tools and include it in enabled_tools, if configured.');
|
|
106
|
+
}
|
|
107
|
+
if (typeof entry.command !== 'string' || !entry.command.trim() || entry.url) {
|
|
108
|
+
throw new Error('The dsh MCP entry must use a local stdio command. Rerun dsh-mcp setup.');
|
|
109
|
+
}
|
|
110
|
+
if (entry.args !== undefined && (!Array.isArray(entry.args) || entry.args.some((arg) => typeof arg !== 'string'))) {
|
|
111
|
+
throw new Error('The dsh MCP command args must be an array of strings. Rerun dsh-mcp setup.');
|
|
112
|
+
}
|
|
113
|
+
if (entry.env !== undefined && (!entry.env || typeof entry.env !== 'object' || Array.isArray(entry.env)
|
|
114
|
+
|| Object.values(entry.env).some((value) => typeof value !== 'string'))) {
|
|
115
|
+
throw new Error('The dsh MCP env entries must be strings. Check the Codex config.');
|
|
116
|
+
}
|
|
117
|
+
if (entry.env_vars !== undefined && (!Array.isArray(entry.env_vars) || entry.env_vars.some((name) => typeof name !== 'string'))) {
|
|
118
|
+
throw new Error('The dsh MCP env_vars must be an array of variable names. Check the Codex config.');
|
|
119
|
+
}
|
|
120
|
+
if (entry.cwd !== undefined && (typeof entry.cwd !== 'string' || !isAbsolute(entry.cwd))) {
|
|
121
|
+
throw new Error('The dsh MCP cwd must be an absolute directory path. Rerun dsh-mcp setup.');
|
|
122
|
+
}
|
|
123
|
+
const cwd = entry.cwd || process.cwd();
|
|
124
|
+
try {
|
|
125
|
+
if (!(await stat(cwd)).isDirectory()) throw new Error();
|
|
126
|
+
} catch {
|
|
127
|
+
throw new Error('The dsh MCP working directory is missing or inaccessible. Fix cwd or rerun setup.');
|
|
128
|
+
}
|
|
129
|
+
const env = { ...getDefaultEnvironment() };
|
|
130
|
+
for (const name of entry.env_vars || []) {
|
|
131
|
+
if (process.env[name] !== undefined) env[name] = process.env[name];
|
|
132
|
+
}
|
|
133
|
+
Object.assign(env, entry.env || {});
|
|
134
|
+
const command = await executablePath(entry.command, env, cwd);
|
|
135
|
+
if (!command) {
|
|
136
|
+
result.hints.push(pathHint(entry.command));
|
|
137
|
+
throw new Error('The registered dsh executable is missing or not executable. Check its absolute path and the client PATH.');
|
|
138
|
+
}
|
|
139
|
+
if (/^node(?:js)?(?:\.exe)?$/.test(basename(command))) {
|
|
140
|
+
const args = entry.args || [];
|
|
141
|
+
if (!args.some((arg) => ['-e', '--eval', '-p', '--print'].includes(arg))) {
|
|
142
|
+
const script = args.find((arg) => !arg.startsWith('-') && /\.[cm]?js$/.test(arg));
|
|
143
|
+
if (script) {
|
|
144
|
+
try {
|
|
145
|
+
const path = resolve(cwd, script);
|
|
146
|
+
await access(path, constants.R_OK);
|
|
147
|
+
if (!(await stat(path)).isFile()) throw new Error();
|
|
148
|
+
} catch {
|
|
149
|
+
result.hints.push(pathHint(script));
|
|
150
|
+
throw new Error('The registered dsh server script is missing or unreadable. Its source checkout or package cache may have moved.');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (checkCodexCli) result.codexCli = await inspectCodexCli(entry, result.configPath, timeoutMs);
|
|
156
|
+
client = new Client({ name: 'dsh-mcp-doctor', version: '1' });
|
|
157
|
+
transport = new StdioClientTransport({ command, args: entry.args || [], cwd, env, stderr: 'pipe' });
|
|
158
|
+
transport.stderr.on('data', (chunk) => {
|
|
159
|
+
if (stderr.length < 8192) stderr += chunk.toString().slice(0, 8192 - stderr.length);
|
|
160
|
+
});
|
|
161
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
162
|
+
await client.connect(transport, { signal, timeout: timeoutMs });
|
|
163
|
+
const version = client.getServerVersion()?.version;
|
|
164
|
+
result.serverVersion = typeof version === 'string' && /^[\w.+-]{1,100}$/.test(version) ? version : null;
|
|
165
|
+
const names = new Set();
|
|
166
|
+
const cursors = new Set();
|
|
167
|
+
let cursor;
|
|
168
|
+
do {
|
|
169
|
+
const page = await client.listTools(cursor ? { cursor } : undefined, { signal, timeout: timeoutMs });
|
|
170
|
+
for (const tool of page.tools) {
|
|
171
|
+
if (/^[\w.-]{1,128}$/.test(tool.name)) names.add(tool.name);
|
|
172
|
+
}
|
|
173
|
+
cursor = page.nextCursor;
|
|
174
|
+
if (cursor && cursors.has(cursor)) throw new Error('MCP tools/list repeated a pagination cursor.');
|
|
175
|
+
if (cursor) cursors.add(cursor);
|
|
176
|
+
} while (cursor);
|
|
177
|
+
result.tools = [...names].sort();
|
|
178
|
+
if (!names.has(requiredTool)) throw new Error('The MCP server initialized but did not expose delegate_to_deepseek. Rerun setup using the current dsh-mcp package.');
|
|
179
|
+
if (result.codexCli?.ok === false) throw new Error(result.codexCli.error);
|
|
180
|
+
result.ok = true;
|
|
181
|
+
result.hints.push(reloadHint);
|
|
182
|
+
if (result.codexCli?.available === false) result.hints.push('The global codex CLI is not installed or not on PATH. This does not prevent a healthy SDK/Desktop stdio installation.');
|
|
183
|
+
} catch (error) {
|
|
184
|
+
result.error = client
|
|
185
|
+
? `MCP initialize/tools discovery failed (${startupDetail(error, stderr)}).`
|
|
186
|
+
: error.message;
|
|
187
|
+
if (client && /did not expose|pagination cursor|Codex CLI/.test(error.message)) result.error = error.message;
|
|
188
|
+
result.hints.push('After correcting registration/startup errors, rerun dsh-mcp doctor. A saved config file alone does not prove Codex loaded the server.');
|
|
189
|
+
if (client) result.hints.push('Check the registered package dependencies and runtime version; if its npm cache was removed, rerun dsh-mcp setup from the current package.');
|
|
190
|
+
} finally {
|
|
191
|
+
if (transport) await transport.close().catch(() => {});
|
|
192
|
+
if (client) await client.close().catch(() => {});
|
|
193
|
+
}
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function runDoctor() {
|
|
198
|
+
const { values } = parseArgs({
|
|
199
|
+
options: {
|
|
200
|
+
home: { type: 'string' }, 'codex-home': { type: 'string' },
|
|
201
|
+
json: { type: 'boolean' }, help: { type: 'boolean', short: 'h' },
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
if (values.help) {
|
|
205
|
+
console.log('Usage: dsh-mcp doctor [--codex-home PATH] [--home PATH] [--json]\n\nCheck the registered stdio command with MCP initialize and tools/list, without an inference request.\nDefaults to $CODEX_HOME/config.toml or ~/.codex/config.toml. --home checks the registered --dsh-home.\nThis does not inspect or restart the running Codex Desktop connection.');
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const configPath = join(resolve(values['codex-home'] || process.env.CODEX_HOME || join(homedir(), '.codex')), 'config.toml');
|
|
209
|
+
const result = await inspectRegistration({ configPath });
|
|
210
|
+
if (values.home && result.ok) {
|
|
211
|
+
const entry = parse(await readFile(configPath, 'utf8')).mcp_servers.dsh;
|
|
212
|
+
const index = (entry.args || []).indexOf('--dsh-home');
|
|
213
|
+
if (index < 0 || !entry.args[index + 1] || resolve(entry.cwd || process.cwd(), entry.args[index + 1]) !== resolve(values.home)) {
|
|
214
|
+
result.ok = false;
|
|
215
|
+
result.error = 'The registered --dsh-home differs from --home. Rerun setup with the intended --home and --codex-home.';
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (values.json) console.log(JSON.stringify(result, null, 2));
|
|
219
|
+
else {
|
|
220
|
+
console.log(`Codex config: ${result.configPath}`);
|
|
221
|
+
console.log(`MCP registration: ${result.ok ? 'healthy (initialize + tools/list succeeded)' : 'unhealthy'}`);
|
|
222
|
+
if (result.serverVersion) console.log(`Server version: ${result.serverVersion}`);
|
|
223
|
+
console.log(`Tools: ${result.tools.join(', ') || '(none discovered)'}`);
|
|
224
|
+
if (result.codexCli) console.log(`Codex CLI effective config: ${!result.codexCli.available ? 'not installed (optional)' : result.codexCli.ok ? 'matches' : 'failed'}`);
|
|
225
|
+
if (result.error) console.log(`Error: ${result.error}`);
|
|
226
|
+
for (const hint of result.hints) console.log(`- ${hint}`);
|
|
227
|
+
}
|
|
228
|
+
if (!result.ok) process.exitCode = 1;
|
|
229
|
+
return result;
|
|
230
|
+
}
|
package/src/provider.js
CHANGED
|
@@ -1,11 +1,31 @@
|
|
|
1
|
-
|
|
1
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { createModels, createProvider } from '@earendil-works/pi-ai/models';
|
|
4
|
+
import { getBuiltinModels, getBuiltinProviders } from '@earendil-works/pi-ai/providers/all';
|
|
5
|
+
import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy';
|
|
6
|
+
import { openAIResponsesApi } from '@earendil-works/pi-ai/api/openai-responses.lazy';
|
|
7
|
+
import { anthropicMessagesApi } from '@earendil-works/pi-ai/api/anthropic-messages.lazy';
|
|
2
8
|
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
|
|
9
|
+
export const PROTOCOLS = Object.freeze([
|
|
10
|
+
{ id: 'chat-completions', label: 'OpenAI Chat Completions — POST /chat/completions', defaultBaseUrl: 'https://api.deepseek.com/v1', api: 'openai-completions' },
|
|
11
|
+
{ id: 'responses', label: 'OpenAI Responses (Codex) — POST /responses', defaultBaseUrl: 'https://api.openai.com/v1', api: 'openai-responses' },
|
|
12
|
+
{ id: 'anthropic', label: 'Anthropic Messages — POST /v1/messages', defaultBaseUrl: 'https://api.anthropic.com', api: 'anthropic-messages' },
|
|
13
|
+
]);
|
|
14
|
+
export const DEFAULT_PROTOCOL = 'chat-completions';
|
|
15
|
+
export const DEFAULT_BASE_URL = PROTOCOLS[0].defaultBaseUrl;
|
|
16
|
+
const LEGACY_BASE_URL = 'https://api.deepseek.com/anthropic';
|
|
17
|
+
const EFFORTS = ['off', 'low', 'high', 'max'];
|
|
18
|
+
const CONTROLS = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
|
|
19
|
+
const preferredProviders = ['deepseek', 'openai', 'anthropic'];
|
|
20
|
+
const catalog = [...preferredProviders, ...getBuiltinProviders().filter((provider) => !preferredProviders.includes(provider))]
|
|
21
|
+
.flatMap((provider) => getBuiltinModels(provider));
|
|
22
|
+
|
|
23
|
+
export function normalizeBaseUrl(value = DEFAULT_BASE_URL, fallback = DEFAULT_BASE_URL) {
|
|
24
|
+
if (typeof value !== 'string' || CONTROLS.test(value)) {
|
|
25
|
+
throw new Error('Base URL must be one HTTP or HTTPS URL without control characters.');
|
|
6
26
|
}
|
|
7
27
|
let url;
|
|
8
|
-
try { url = new URL(value.trim() ||
|
|
28
|
+
try { url = new URL(value.trim() || fallback); }
|
|
9
29
|
catch { throw new Error('Base URL must be an absolute HTTP or HTTPS URL.'); }
|
|
10
30
|
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
|
11
31
|
throw new Error('Base URL must use HTTP or HTTPS without credentials, a query, or a fragment.');
|
|
@@ -23,3 +43,279 @@ export function parseProviderSettings(text) {
|
|
|
23
43
|
}
|
|
24
44
|
return settings;
|
|
25
45
|
}
|
|
46
|
+
|
|
47
|
+
function modelId(value) {
|
|
48
|
+
if (typeof value !== 'string' || !value.trim() || value !== value.trim() || value.length > 512 || CONTROLS.test(value)) {
|
|
49
|
+
throw new Error('Model ID must be nonempty, at most 512 characters, and contain no control characters or surrounding whitespace.');
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function resolveProviderSettings(input = {}, { legacy = false } = {}) {
|
|
55
|
+
const protocol = input.protocol ?? (legacy ? 'anthropic' : DEFAULT_PROTOCOL);
|
|
56
|
+
const definition = PROTOCOLS.find((item) => item.id === protocol);
|
|
57
|
+
if (!definition) throw new Error('Protocol must be chat-completions, responses, or anthropic.');
|
|
58
|
+
const fallback = legacy && input.protocol == null ? LEGACY_BASE_URL : definition.defaultBaseUrl;
|
|
59
|
+
return {
|
|
60
|
+
protocol,
|
|
61
|
+
baseUrl: normalizeBaseUrl(input.baseUrl ?? fallback, fallback),
|
|
62
|
+
model: modelId(input.model ?? 'deepseek-flash'),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function requireApiKey(apiKey) {
|
|
67
|
+
if (typeof apiKey !== 'string' || !apiKey.trim() || CONTROLS.test(apiKey)) {
|
|
68
|
+
throw new Error('A nonempty API key without control characters is required.');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function boundedSignal(signal, milliseconds) {
|
|
73
|
+
const timeout = AbortSignal.timeout(milliseconds);
|
|
74
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function readModelPage(response) {
|
|
78
|
+
if (response.status >= 300 && response.status < 400) {
|
|
79
|
+
await response.body?.cancel();
|
|
80
|
+
throw new Error('Model discovery refused a redirect; use the provider’s final Base URL.');
|
|
81
|
+
}
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
await response.body?.cancel();
|
|
84
|
+
const detail = [401, 403].includes(response.status) ? ' Credentials were rejected or lack model-list permission.' : '';
|
|
85
|
+
throw new Error(`Model discovery failed (HTTP ${response.status}).${detail}`);
|
|
86
|
+
}
|
|
87
|
+
const chunks = [];
|
|
88
|
+
let bytes = 0;
|
|
89
|
+
for await (const chunk of response.body ?? []) {
|
|
90
|
+
bytes += chunk.length;
|
|
91
|
+
if (bytes > 2 * 1024 * 1024) throw new Error('Model discovery returned an oversized response.');
|
|
92
|
+
chunks.push(chunk);
|
|
93
|
+
}
|
|
94
|
+
let page;
|
|
95
|
+
try { page = JSON.parse(Buffer.concat(chunks).toString('utf8')); }
|
|
96
|
+
catch { throw new Error('Model discovery did not return a valid JSON model list.'); }
|
|
97
|
+
if (!page || !Array.isArray(page.data)) throw new Error('Model discovery did not return a data array.');
|
|
98
|
+
return page;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function discoverModels({ protocol, baseUrl, apiKey, signal }) {
|
|
102
|
+
const settings = resolveProviderSettings({ protocol, baseUrl });
|
|
103
|
+
requireApiKey(apiKey);
|
|
104
|
+
const url = new URL(settings.baseUrl);
|
|
105
|
+
const anthropic = settings.protocol === 'anthropic';
|
|
106
|
+
// The official DSH Messages adapter lists DeepSeek models at /models on this same origin.
|
|
107
|
+
const deepseek = anthropic && url.origin === 'https://api.deepseek.com';
|
|
108
|
+
url.pathname = deepseek ? '/models' : `${url.pathname.replace(/\/+$/, '')}${anthropic && !url.pathname.endsWith('/v1') ? '/v1' : ''}/models`;
|
|
109
|
+
const headers = anthropic ? { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' } : { Authorization: `Bearer ${apiKey}` };
|
|
110
|
+
const requestSignal = boundedSignal(signal, 15000);
|
|
111
|
+
const models = new Map();
|
|
112
|
+
const cursors = new Set();
|
|
113
|
+
try {
|
|
114
|
+
for (let count = 0; count < 100; count++) {
|
|
115
|
+
const page = await readModelPage(await fetch(url, { headers, signal: requestSignal, redirect: 'manual' }));
|
|
116
|
+
if (anthropic && !deepseek && page.has_more !== undefined && typeof page.has_more !== 'boolean') {
|
|
117
|
+
throw new Error('Model discovery returned invalid pagination.');
|
|
118
|
+
}
|
|
119
|
+
for (const entry of page.data) {
|
|
120
|
+
if (!entry || typeof entry !== 'object') throw new Error('Model discovery returned an invalid model entry.');
|
|
121
|
+
const id = modelId(entry.id);
|
|
122
|
+
const name = typeof (entry.display_name ?? entry.name) === 'string'
|
|
123
|
+
? stripVTControlCharacters(entry.display_name ?? entry.name).replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '').slice(0, 256).trim() : '';
|
|
124
|
+
models.set(id, name ? { id, name } : { id });
|
|
125
|
+
}
|
|
126
|
+
if (!anthropic || deepseek || page.has_more !== true) return [...models.values()];
|
|
127
|
+
const cursor = modelId(page.last_id);
|
|
128
|
+
if (!page.data.length || cursors.has(cursor)) throw new Error('Model discovery returned invalid pagination.');
|
|
129
|
+
cursors.add(cursor);
|
|
130
|
+
url.searchParams.set('after_id', cursor);
|
|
131
|
+
}
|
|
132
|
+
throw new Error('Model discovery exceeded its pagination limit.');
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (requestSignal.aborted) throw new Error(signal?.aborted ? 'Model discovery cancelled.' : 'Model discovery timed out.');
|
|
135
|
+
// Never relay provider bodies, fetch causes, URLs, or credential-bearing headers.
|
|
136
|
+
if (/^(Model discovery|Model ID)/.test(error.message)) throw error;
|
|
137
|
+
throw new Error('Model discovery could not reach the selected endpoint. Check the Base URL and network.');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function describeRuntime(settings) {
|
|
142
|
+
const definition = PROTOCOLS.find((item) => item.id === settings.protocol);
|
|
143
|
+
const deepseekModel = catalog.find((item) => item.provider === 'deepseek' && item.id === settings.model);
|
|
144
|
+
const direct = settings.protocol === 'anthropic' && deepseekModel !== undefined;
|
|
145
|
+
const known = direct ? deepseekModel : catalog.find((item) => item.id === settings.model && item.api === definition.api);
|
|
146
|
+
const thinkingEfforts = known?.reasoning ? EFFORTS.filter((effort) => {
|
|
147
|
+
const wire = known.thinkingLevelMap?.[effort];
|
|
148
|
+
return wire !== null && (effort !== 'max' || wire !== undefined);
|
|
149
|
+
}) : ['off'];
|
|
150
|
+
if (!thinkingEfforts.length) throw new Error('The selected model has no supported off, low, high, or max effort in the installed metadata.');
|
|
151
|
+
const route = direct ? 'deepseek-official' : known?.provider ?? 'dsh-mcp';
|
|
152
|
+
const reasoningEfforts = known?.reasoning ? Object.fromEntries(thinkingEfforts.map((effort) => [effort, known.thinkingLevelMap?.[effort] ?? (effort === 'off' ? null : effort)])) : false;
|
|
153
|
+
const model = {
|
|
154
|
+
id: settings.model,
|
|
155
|
+
...(known ? { name: known.name, contextWindow: known.contextWindow, input: known.input } : {}),
|
|
156
|
+
reasoningEfforts,
|
|
157
|
+
};
|
|
158
|
+
const compat = !known && settings.protocol === 'chat-completions'
|
|
159
|
+
? { supportsStore: false, supportsDeveloperRole: false, maxTokensField: 'max_tokens', supportsStrictMode: false } : undefined;
|
|
160
|
+
const profile = {
|
|
161
|
+
apiKeyEnv: 'DEEPSEEK_API_KEY', api: definition.api,
|
|
162
|
+
baseURL: settings.protocol === 'anthropic' ? settings.baseUrl.replace(/\/v1$/, '') : settings.baseUrl,
|
|
163
|
+
models: [model], defaultContextWindow: 32768, defaultMaxTokens: 4096,
|
|
164
|
+
...(compat ? { compat } : {}),
|
|
165
|
+
};
|
|
166
|
+
const directProfile = direct ? {
|
|
167
|
+
apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: settings.baseUrl,
|
|
168
|
+
models: [{ id: settings.model, name: known.name, contextWindow: known.contextWindow, inputModalities: known.input }],
|
|
169
|
+
} : {};
|
|
170
|
+
return { route, known, profile, directProfile, thinkingEfforts };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function buildProviderRuntime(input, apiKey) {
|
|
174
|
+
const settings = resolveProviderSettings(input);
|
|
175
|
+
if (apiKey !== undefined) requireApiKey(apiKey);
|
|
176
|
+
const { route, known, profile, directProfile, thinkingEfforts } = describeRuntime(settings);
|
|
177
|
+
const contextWindow = known?.contextWindow ?? profile.defaultContextWindow;
|
|
178
|
+
const headroomTokens = Math.max(1, Math.floor(contextWindow / 8));
|
|
179
|
+
const compaction = contextWindow < 131072 ? { headroomTokens, maxTokens: Math.min(4096, headroomTokens) } : {};
|
|
180
|
+
return {
|
|
181
|
+
protocol: settings.protocol, model: settings.model, route, thinkingEfforts,
|
|
182
|
+
env: {
|
|
183
|
+
...(apiKey === undefined ? {} : { DEEPSEEK_API_KEY: apiKey }),
|
|
184
|
+
DEEPSEEK_BASE_URL: settings.baseUrl,
|
|
185
|
+
DSH_MCP_PROTOCOL: settings.protocol, DSH_MCP_MODEL: settings.model, DSH_MCP_PROVIDER: route,
|
|
186
|
+
DSH_MCP_PI_PLUGIN: !known?.reasoning && route !== 'deepseek-official' ? fileURLToPath(import.meta.url) : '@deepseek-ai/dsh-llm-pi-ai',
|
|
187
|
+
DSH_MCP_PI_PROVIDERS: JSON.stringify(route === 'deepseek-official' ? {} : { [route]: profile }),
|
|
188
|
+
DSH_MCP_DEEPSEEK_PROFILE: JSON.stringify(directProfile),
|
|
189
|
+
DSH_MCP_COMPACTION_PROFILE: JSON.stringify(compaction),
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function nativeModel(settings, route, known, profile) {
|
|
195
|
+
return {
|
|
196
|
+
...known, id: settings.model, name: known?.name ?? settings.model, api: profile.api,
|
|
197
|
+
provider: route, baseUrl: profile.baseURL, reasoning: known?.reasoning ?? false,
|
|
198
|
+
input: known?.input ?? ['text'], contextWindow: known?.contextWindow ?? profile.defaultContextWindow,
|
|
199
|
+
maxTokens: known?.maxTokens ?? profile.defaultMaxTokens,
|
|
200
|
+
cost: known?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
201
|
+
compat: known?.compat ?? profile.compat,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function nativeApi(protocol) {
|
|
206
|
+
return { 'chat-completions': openAICompletionsApi, responses: openAIResponsesApi, anthropic: anthropicMessagesApi }[protocol]();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Pinned DSH hides the native adapter's valid off level for non-reasoning models.
|
|
210
|
+
// Advertise only that level; the unchanged native stream still receives explicit off.
|
|
211
|
+
export const name = 'dsh-mcp-off-only';
|
|
212
|
+
export const inject = ['llm'];
|
|
213
|
+
export async function apply(ctx) {
|
|
214
|
+
const [{ PiAiAdapter }, { InMemoryCredentialStore, defaultProviderAuthContext }, { resolveRetryPolicy, resolveImageAttachmentAccess }] = await Promise.all([
|
|
215
|
+
import('@deepseek-ai/dsh-llm-pi-ai'),
|
|
216
|
+
import('@earendil-works/pi-ai'),
|
|
217
|
+
import('@deepseek-ai/dsh-llm'),
|
|
218
|
+
]);
|
|
219
|
+
const settings = resolveProviderSettings({
|
|
220
|
+
protocol: process.env.DSH_MCP_PROTOCOL, baseUrl: process.env.DEEPSEEK_BASE_URL, model: process.env.DSH_MCP_MODEL,
|
|
221
|
+
});
|
|
222
|
+
const { route, known, profile, thinkingEfforts } = describeRuntime(settings);
|
|
223
|
+
if (thinkingEfforts.length !== 1 || thinkingEfforts[0] !== 'off' || known?.reasoning) {
|
|
224
|
+
throw new Error('The off-only adapter requires a model without selectable reasoning metadata.');
|
|
225
|
+
}
|
|
226
|
+
const descriptor = nativeModel(settings, route, known, profile);
|
|
227
|
+
const resolveApiKey = async () => {
|
|
228
|
+
const credentials = ctx.get('credentials');
|
|
229
|
+
const apiKey = credentials ? (await credentials.resolve(profile.apiKeyEnv))?.value : process.env[profile.apiKeyEnv];
|
|
230
|
+
requireApiKey(apiKey);
|
|
231
|
+
return apiKey;
|
|
232
|
+
};
|
|
233
|
+
const piProvider = createProvider({
|
|
234
|
+
id: route, baseUrl: profile.baseURL, models: [descriptor], api: nativeApi(settings.protocol),
|
|
235
|
+
auth: { apiKey: { name: 'Configured API key', resolve: async () => ({ auth: { apiKey: await resolveApiKey() } }) } },
|
|
236
|
+
});
|
|
237
|
+
const resolved = {
|
|
238
|
+
...profile, provider: route, displayName: route, piProvider,
|
|
239
|
+
streamIdleTimeoutMs: 300000, maxRequestImageBytes: 20 * 1024 * 1024, maxRequestImages: 50,
|
|
240
|
+
imageOffloadCountQuantum: 10, requestImagePixelBudget: 4194304, requestImageMaxBytes: 4 * 1024 * 1024,
|
|
241
|
+
retryPolicy: resolveRetryPolicy(undefined, 'dsh-mcp'),
|
|
242
|
+
modelErrors: new Map(), configuredMaxTokens: new Map(),
|
|
243
|
+
};
|
|
244
|
+
const profiles = new Map([[route, resolved]]);
|
|
245
|
+
const withOff = (model) => ({
|
|
246
|
+
...model, reasoning: { efforts: [{ id: 'off', name: 'Off', description: 'No explicit reasoning request; unknown providers retain their own defaults.' }] },
|
|
247
|
+
});
|
|
248
|
+
class OffOnlyAdapter extends PiAiAdapter {
|
|
249
|
+
async resolveModel(...args) { return withOff(await super.resolveModel(...args)); }
|
|
250
|
+
async prepareCall(...args) {
|
|
251
|
+
const prepared = await super.prepareCall(...args);
|
|
252
|
+
return { ...prepared, model: withOff(prepared.model) };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
ctx.llm.registerAdapter([route], new OffOnlyAdapter({
|
|
256
|
+
profiles: () => profiles, resolveApiKey,
|
|
257
|
+
auth: { credentials: new InMemoryCredentialStore(), authContext: defaultProviderAuthContext() },
|
|
258
|
+
prepareHeaders: (request, headers) => ctx.waterfall('llm-pi-ai/request-headers', request, headers, () => headers),
|
|
259
|
+
resolveAttachments: () => ctx.get('attachments'),
|
|
260
|
+
resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess(attachments, (path) => ctx.get('fs')?.processPathFromHostPath(path), ref),
|
|
261
|
+
}));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function probeDirect(settings, apiKey, signal, directProfile) {
|
|
265
|
+
const { DeepSeekAdapter, resolveAdapterOptions } = await import('@deepseek-ai/dsh-llm-deepseek');
|
|
266
|
+
const { apiKeyEnv, ...configuration } = directProfile;
|
|
267
|
+
const connection = resolveAdapterOptions({ ...configuration, reasoningEffort: 'off', maxTokens: 32, streamIdleTimeoutMs: 20000, retryPolicy: { mode: 'normal', maxRetries: 0 } });
|
|
268
|
+
const adapter = new DeepSeekAdapter({
|
|
269
|
+
options: () => connection,
|
|
270
|
+
resolveAuth: async () => ({ headers: { 'x-api-key': apiKey } }),
|
|
271
|
+
resolveUserId: () => 'dsh-mcp-connection-check',
|
|
272
|
+
prepareExtensions: async () => ({ fields: {}, accept: async () => {} }),
|
|
273
|
+
});
|
|
274
|
+
let content = false;
|
|
275
|
+
let finished = false;
|
|
276
|
+
for await (const event of adapter.stream({
|
|
277
|
+
provider: 'deepseek-official', model: settings.model, reasoningEffort: 'off', maxTokens: 32, signal,
|
|
278
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: 'Reply with OK.' }] }],
|
|
279
|
+
})) {
|
|
280
|
+
if (event.type === 'text-delta' && event.text?.trim()) content = true;
|
|
281
|
+
if (event.type === 'finish') finished = ['stop', 'max-tokens'].includes(event.reason.kind);
|
|
282
|
+
}
|
|
283
|
+
if (!content || !finished) throw new Error('Connection test did not receive a completed model response.');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export async function testConnection({ protocol, baseUrl, model, apiKey, signal }) {
|
|
287
|
+
const settings = resolveProviderSettings({ protocol, baseUrl, model });
|
|
288
|
+
requireApiKey(apiKey);
|
|
289
|
+
const requestSignal = boundedSignal(signal, 20000);
|
|
290
|
+
const { route, known, profile, directProfile, thinkingEfforts } = describeRuntime(settings);
|
|
291
|
+
try {
|
|
292
|
+
if (route === 'deepseek-official') {
|
|
293
|
+
await probeDirect(settings, apiKey, requestSignal, directProfile);
|
|
294
|
+
} else {
|
|
295
|
+
const descriptor = nativeModel(settings, route, known, profile);
|
|
296
|
+
const api = nativeApi(settings.protocol);
|
|
297
|
+
const models = createModels();
|
|
298
|
+
models.setProvider(createProvider({
|
|
299
|
+
id: route, baseUrl: profile.baseURL, models: [descriptor], api,
|
|
300
|
+
auth: { apiKey: { name: 'Explicit setup key', resolve: async () => ({ auth: { apiKey } }) } },
|
|
301
|
+
}));
|
|
302
|
+
const response = await models.completeSimple(descriptor, {
|
|
303
|
+
messages: [{ role: 'user', content: 'Reply with OK.', timestamp: Date.now() }],
|
|
304
|
+
}, {
|
|
305
|
+
apiKey, signal: requestSignal, reasoning: thinkingEfforts.includes('off') ? undefined : thinkingEfforts[0],
|
|
306
|
+
maxTokens: 32, maxRetries: 0, timeoutMs: 20000, transport: 'sse',
|
|
307
|
+
fetch: (url, options) => fetch(url, { ...options, redirect: 'error' }),
|
|
308
|
+
});
|
|
309
|
+
const hasOutput = response.content.some((part) =>
|
|
310
|
+
part.type === 'text' && part.text?.trim() || part.type === 'thinking' && part.thinking?.trim())
|
|
311
|
+
|| response.usage?.output > 0;
|
|
312
|
+
if (!['stop', 'length'].includes(response.stopReason) || !hasOutput) {
|
|
313
|
+
throw new Error('Connection test did not receive a completed model response.');
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return settings;
|
|
317
|
+
} catch {
|
|
318
|
+
if (requestSignal.aborted) throw new Error(signal?.aborted ? 'Connection test cancelled.' : 'Connection test timed out.');
|
|
319
|
+
throw new Error('Connection test failed: no valid inference response. Check protocol, Base URL, API key, model access, and billing.');
|
|
320
|
+
}
|
|
321
|
+
}
|
package/src/server.js
CHANGED
|
@@ -7,7 +7,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
import { createWorker } from './worker.js';
|
|
9
9
|
import metadata from '../package.json' with { type: 'json' };
|
|
10
|
-
import {
|
|
10
|
+
import { buildProviderRuntime, resolveProviderSettings, parseProviderSettings } from './provider.js';
|
|
11
11
|
|
|
12
12
|
const { values } = parseArgs({ options: {
|
|
13
13
|
'dsh-home': { type: 'string', default: join(homedir(), '.dsh-mcp') },
|
|
@@ -25,20 +25,25 @@ catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
|
25
25
|
let provider = {};
|
|
26
26
|
try { provider = parseProviderSettings(await readFile(join(home, 'settings.json'), 'utf8')); }
|
|
27
27
|
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
28
|
-
const apiKey = process.env.DEEPSEEK_API_KEY
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
const apiKey = process.env.DEEPSEEK_API_KEY?.trim() || stored.DEEPSEEK_API_KEY?.trim() || undefined;
|
|
29
|
+
const savedProvider = resolveProviderSettings(provider, { legacy: !!provider.baseUrl && !provider.protocol });
|
|
30
|
+
const protocol = process.env.DSH_MCP_PROTOCOL || savedProvider.protocol;
|
|
31
|
+
const providerRuntime = buildProviderRuntime({
|
|
32
|
+
protocol,
|
|
33
|
+
baseUrl: process.env.DEEPSEEK_BASE_URL ?? (protocol === savedProvider.protocol ? savedProvider.baseUrl : undefined),
|
|
34
|
+
model: process.env.DSH_MCP_MODEL || savedProvider.model,
|
|
35
|
+
}, apiKey);
|
|
31
36
|
|
|
32
|
-
const worker = createWorker({ home });
|
|
37
|
+
const worker = createWorker({ home, providerRuntime });
|
|
33
38
|
const instructions = await readFile(new URL('../config/routing.md', import.meta.url), 'utf8');
|
|
34
39
|
const server = new McpServer({ name: 'dsh-mcp', version: metadata.version }, { instructions });
|
|
35
40
|
server.registerTool('delegate_to_deepseek', {
|
|
36
41
|
title: 'Delegate a bounded task to DeepSeek Harness',
|
|
37
|
-
description: 'Run one bounded task with DeepSeek
|
|
42
|
+
description: 'Run one bounded task with the configured model through local DeepSeek Harness. Supply the absolute project_root from active Codex context or explicit user authorization. Use read-only for analysis. Select workspace-write explicitly for edits. Supply complete context and acceptance criteria. Pause other writers. Review the actual files and check the result. A completed status does not mean task acceptance.',
|
|
38
43
|
inputSchema: {
|
|
39
44
|
project_root: z.string().min(1).describe('Required absolute project directory from Codex context or explicit user authorization. This is the caller directory grant, not an independently verified workspace ACL.'),
|
|
40
45
|
task: z.string().trim().min(1).max(65536).describe('Self-contained task, allowed files, constraints, acceptance criteria and requested verification.'),
|
|
41
|
-
thinking_effort: z.enum(
|
|
46
|
+
thinking_effort: z.enum(providerRuntime.thinkingEfforts).describe('Required model-supported DSH reasoning effort. Choose explicitly for every call and resume; unsupported levels are rejected, never silently clamped.'),
|
|
42
47
|
cwd: z.string().min(1).default('.').describe('Directory relative to project_root, or an absolute directory within project_root.'),
|
|
43
48
|
permission_mode: z.enum(['read-only', 'workspace-write']).default('read-only'),
|
|
44
49
|
session_id: z.string().min(1).max(256).optional().describe('Previously returned DSH session ID for scoped revision in the same cwd.'),
|