@hzxwonder/dsh-mcp 0.4.0 → 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/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
+ }
@@ -0,0 +1,77 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <meta charset="utf-8">
4
+ <meta name="viewport" content="width=device-width, initial-scale=1">
5
+ <title>DSH 实时进度</title>
6
+ <style nonce="__NONCE__">
7
+ :root { color-scheme: dark; --bg:#0f172a; --panel:#1b2336; --text:#f8fafc; --muted:#b2bdcc; --border:#475569; --accent:#4ade80; }
8
+ * { box-sizing:border-box; }
9
+ body { margin:0; background:var(--bg); color:var(--text); font:16px/1.6 system-ui,sans-serif; }
10
+ main { max-width:900px; margin:auto; padding:24px; }
11
+ header { display:flex; gap:16px; align-items:center; justify-content:space-between; flex-wrap:wrap; }
12
+ h1 { margin:0; font-size:24px; }
13
+ p { color:var(--muted); margin:8px 0 20px; }
14
+ #status { color:var(--accent); }
15
+ button { cursor:pointer; min-height:44px; padding:8px 16px; color:var(--text); background:var(--panel); border:1px solid var(--border); border-radius:8px; font:inherit; }
16
+ button:hover { border-color:var(--accent); }
17
+ button:focus-visible { outline:2px solid var(--text); outline-offset:3px; }
18
+ ol { list-style:none; margin:20px 0; padding:0; }
19
+ li { margin:12px 0; padding:16px; background:var(--panel); border:1px solid var(--border); border-radius:10px; }
20
+ .label { color:var(--muted); font-size:13px; }
21
+ pre { margin:8px 0 0; white-space:pre-wrap; overflow-wrap:anywhere; font:14px/1.65 ui-monospace,monospace; }
22
+ @media(max-width:480px) { main { padding:16px; } }
23
+ </style>
24
+ <main>
25
+ <header><h1>DSH 实时进度</h1><button id="pause" type="button" aria-pressed="false">暂停更新</button></header>
26
+ <p>执行阶段、工具调用与公开输出 · 当前任务最近 200 条记录</p>
27
+ <div id="status" role="status">正在连接…</div>
28
+ <p id="updated">等待任务开始</p>
29
+ <ol id="events" aria-label="执行记录"></ol>
30
+ </main>
31
+ <script nonce="__NONCE__">
32
+ const token = location.hash.slice(1);
33
+ const status = document.getElementById('status');
34
+ const updated = document.getElementById('updated');
35
+ const list = document.getElementById('events');
36
+ const pause = document.getElementById('pause');
37
+ let paused = false;
38
+ let last = -1;
39
+ let active = false;
40
+ pause.addEventListener('click', () => {
41
+ paused = !paused;
42
+ pause.textContent = paused ? '继续更新' : '暂停更新';
43
+ pause.setAttribute('aria-pressed', String(paused));
44
+ if (paused) status.textContent = '已暂停显示 · 任务继续运行';
45
+ else refresh();
46
+ });
47
+ async function refresh() {
48
+ if (paused || document.hidden || active) return;
49
+ active = true;
50
+ try {
51
+ const response = await fetch('/events', { headers:{ Authorization:'Bearer ' + token }, cache:'no-store' });
52
+ if (!response.ok) throw new Error();
53
+ const { events } = await response.json();
54
+ const latest = events.at(-1);
55
+ status.textContent = latest?.type === 'run_end' ? latest.message : events.length ? '运行中' : '已连接 · 等待委派任务';
56
+ updated.textContent = '连接更新于 ' + new Date().toLocaleTimeString();
57
+ if ((latest?.id ?? 0) !== last) {
58
+ last = latest?.id ?? 0;
59
+ list.replaceChildren(...events.map(event => {
60
+ const item = document.createElement('li');
61
+ const label = document.createElement('div');
62
+ label.className = 'label';
63
+ label.textContent = new Date(event.time).toLocaleTimeString() + ' · ' + event.type;
64
+ const text = document.createElement('pre');
65
+ text.textContent = event.message;
66
+ item.append(label, text);
67
+ return item;
68
+ }));
69
+ }
70
+ } catch { status.textContent = '连接已断开 · 请重新打开监视页'; }
71
+ finally { active = false; }
72
+ }
73
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); });
74
+ setInterval(refresh, 1000);
75
+ refresh();
76
+ </script>
77
+ </html>
package/src/monitor.js ADDED
@@ -0,0 +1,76 @@
1
+ import { createServer } from 'node:http';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { readFile } from 'node:fs/promises';
4
+
5
+ // This projection is kept in memory and served to the user's browser only.
6
+ export function createMonitor({ secret } = {}) {
7
+ const token = randomBytes(32).toString('hex');
8
+ const events = [];
9
+ let sequence = 0;
10
+ let server;
11
+ let starting;
12
+ let origin;
13
+ const clean = (value) => {
14
+ const text = typeof value === 'string' ? value : JSON.stringify(value);
15
+ return (secret ? text.replaceAll(secret, '[REDACTED]') : text).slice(0, 8000);
16
+ };
17
+ const publish = (event) => {
18
+ let message;
19
+ switch (event.type) {
20
+ case 'run_start': events.length = 0; message = `Started · ${event.model} · ${event.permission_mode}`; break;
21
+ case 'status':
22
+ if (!['turn_start', 'step_start', 'step_end', 'turn_end'].includes(event.phase)) return;
23
+ message = `${event.phase}${Number.isInteger(event.step) ? ` · step ${event.step}` : ''}`;
24
+ break;
25
+ case 'text': message = event.text; break;
26
+ case 'tool_call': message = `${event.tool}\n${JSON.stringify(event.input, null, 2)}`; break;
27
+ case 'tool_result': message = `${event.status}\n${event.result ?? ''}`; break;
28
+ case 'run_end': message = `Finished · ${event.status}`; break;
29
+ default: return; // Never forward thinking, prompts, session paths, or stderr.
30
+ }
31
+ if (typeof message !== 'string') return;
32
+ events.push({ id: ++sequence, time: new Date().toISOString(), type: event.type, message: clean(message) });
33
+ if (events.length > 200) events.shift();
34
+ };
35
+ return {
36
+ publish,
37
+ async start() {
38
+ if (!starting) starting = (async () => {
39
+ const html = await readFile(new URL('./monitor.html', import.meta.url), 'utf8');
40
+ const nonce = randomBytes(16).toString('hex');
41
+ server = createServer((req, res) => {
42
+ res.setHeader('Cache-Control', 'no-store');
43
+ res.setHeader('Referrer-Policy', 'no-referrer');
44
+ res.setHeader('X-Content-Type-Options', 'nosniff');
45
+ res.setHeader('Content-Security-Policy', `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; form-action 'none'`);
46
+ if (req.headers.host !== new URL(origin).host || (req.headers.origin && req.headers.origin !== origin)) {
47
+ res.writeHead(403).end(); return;
48
+ }
49
+ if (req.method !== 'GET') { res.writeHead(405).end(); return; }
50
+ if (req.url === '/') {
51
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
52
+ res.end(html.replaceAll('__NONCE__', nonce)); return;
53
+ }
54
+ if (req.url !== '/events') { res.writeHead(404).end(); return; }
55
+ if (req.headers.authorization !== `Bearer ${token}`) { res.writeHead(401).end(); return; }
56
+ res.setHeader('Content-Type', 'application/json');
57
+ res.end(JSON.stringify({ events }));
58
+ });
59
+ await new Promise((resolve, reject) => {
60
+ server.once('error', reject);
61
+ server.listen(0, '127.0.0.1', resolve);
62
+ });
63
+ origin = `http://127.0.0.1:${server.address().port}`;
64
+ server.unref();
65
+ return `${origin}/#${token}`;
66
+ })().catch((error) => { starting = undefined; throw error; });
67
+ return starting;
68
+ },
69
+ async close() {
70
+ if (starting) await starting.catch(() => {});
71
+ if (!server?.listening) return;
72
+ server.closeAllConnections();
73
+ await new Promise((resolve) => server.close(resolve));
74
+ },
75
+ };
76
+ }