dsh-desktop-kit 2.2.1 → 2.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/README.md +114 -27
- package/client.js +3270 -32
- package/docs/AGENT-HARNESS.md +144 -0
- package/docs/ARCHITECTURE.md +38 -7
- package/docs/COMPATIBILITY.md +4 -2
- package/docs/DEVELOPMENT.md +56 -0
- package/docs/INSTALL.md +20 -11
- package/docs/LAPTOP-INSTALL.md +96 -0
- package/docs/ROADMAP-3.0.md +353 -0
- package/docs/ROADMAP.md +1 -1
- package/docs/SUBAGENTS.md +25 -0
- package/docs/TEST-0.2.2.md +5 -5
- package/docs/TEST-2.3.0.md +49 -0
- package/docs/TEST-2.7.2.md +46 -0
- package/docs/TEST-2.7.3.md +20 -0
- package/docs/TOKENS.md +2 -2
- package/docs/TROUBLESHOOTING.md +27 -0
- package/docs/UI-QA.md +28 -0
- package/docs/images/peak-toast-peak.png +0 -0
- package/docs/images/peak-toast-soon.png +0 -0
- package/docs/preview/peak-toast.html +155 -0
- package/index.js +18 -1
- package/package.json +26 -6
- package/plugins/dsh-agent-extensions/README.md +107 -0
- package/plugins/dsh-agent-extensions/client.js +157 -0
- package/plugins/dsh-agent-extensions/index.js +174 -0
- package/plugins/dsh-agent-extensions/lib/agents.js +76 -0
- package/plugins/dsh-agent-extensions/lib/api.js +53 -0
- package/plugins/dsh-agent-extensions/lib/checkpoints.js +103 -0
- package/plugins/dsh-agent-extensions/lib/commands.js +255 -0
- package/plugins/dsh-agent-extensions/lib/hooks.js +182 -0
- package/plugins/dsh-agent-extensions/lib/policy.js +84 -0
- package/plugins/dsh-agent-extensions/lib/project.js +144 -0
- package/plugins/dsh-agent-extensions/lib/scaffold.js +151 -0
- package/plugins/dsh-agent-extensions/package.json +20 -0
- package/plugins/dsh-agent-extensions/screenshots/agents-1440.png +0 -0
- package/plugins/dsh-agent-extensions/screenshots/agents-390.png +0 -0
- package/plugins/dsh-browser-qa/README.md +221 -0
- package/plugins/dsh-browser-qa/cordis.patch.yml +4 -0
- package/plugins/dsh-browser-qa/index.js +165 -0
- package/plugins/dsh-browser-qa/lib/cdp.js +224 -0
- package/plugins/dsh-browser-qa/lib/chrome.js +466 -0
- package/plugins/dsh-browser-qa/lib/pages.js +552 -0
- package/plugins/dsh-browser-qa/lib/qa.js +827 -0
- package/plugins/dsh-browser-qa/lib/ui-gate.js +256 -0
- package/plugins/dsh-browser-qa/package.json +31 -0
- package/plugins/dsh-checkpoints/README.md +22 -0
- package/plugins/dsh-checkpoints/cordis.patch.yml +3 -0
- package/plugins/dsh-checkpoints/index.js +80 -0
- package/plugins/dsh-checkpoints/lib/checkpoints.js +111 -0
- package/plugins/dsh-checkpoints/package.json +25 -0
- package/plugins/dsh-github-review/README.md +131 -0
- package/plugins/dsh-github-review/cordis.patch.yml +3 -0
- package/plugins/dsh-github-review/index.js +536 -0
- package/plugins/dsh-github-review/lib/gh.js +494 -0
- package/plugins/dsh-github-review/lib/normalize.js +662 -0
- package/plugins/dsh-github-review/package.json +28 -0
- package/plugins/dsh-guardrails/README.md +264 -0
- package/plugins/dsh-guardrails/cordis.patch.yml +5 -0
- package/plugins/dsh-guardrails/index.js +389 -0
- package/plugins/dsh-guardrails/lib/hooks.js +262 -0
- package/plugins/dsh-guardrails/lib/modes.js +77 -0
- package/plugins/dsh-guardrails/lib/policy.js +592 -0
- package/plugins/dsh-guardrails/lib/scan.js +451 -0
- package/plugins/dsh-guardrails/lib/secrets.js +274 -0
- package/plugins/dsh-guardrails/package.json +26 -0
- package/plugins/dsh-mcp-manager/README.md +78 -0
- package/plugins/dsh-mcp-manager/client.js +373 -0
- package/plugins/dsh-mcp-manager/index.js +159 -0
- package/plugins/dsh-mcp-manager/lib/api.js +250 -0
- package/plugins/dsh-mcp-manager/lib/client.js +219 -0
- package/plugins/dsh-mcp-manager/lib/config.js +217 -0
- package/plugins/dsh-mcp-manager/lib/manager.js +146 -0
- package/plugins/dsh-mcp-manager/package.json +18 -0
- package/plugins/dsh-mcp-manager/screenshots/modal-390.png +0 -0
- package/plugins/dsh-mcp-manager/screenshots/settings-1440.png +0 -0
- package/plugins/dsh-mcp-manager/screenshots/settings-390.png +0 -0
- package/plugins/dsh-memory-bridge/README.md +161 -0
- package/plugins/dsh-memory-bridge/client.js +2264 -0
- package/plugins/dsh-memory-bridge/cordis.patch.yml +5 -0
- package/plugins/dsh-memory-bridge/docs/images/conflicts.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/hero.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/settings-dark.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/settings-light.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/settings-mobile.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/setup.png +0 -0
- package/plugins/dsh-memory-bridge/icon.svg +13 -0
- package/plugins/dsh-memory-bridge/index.js +378 -0
- package/plugins/dsh-memory-bridge/lib/bridge.js +636 -0
- package/plugins/dsh-memory-bridge/lib/github.js +473 -0
- package/plugins/dsh-memory-bridge/lib/merge.js +205 -0
- package/plugins/dsh-memory-bridge/lib/portable.js +314 -0
- package/plugins/dsh-memory-bridge/lib/state.js +565 -0
- package/plugins/dsh-memory-bridge/lib/sync.js +984 -0
- package/plugins/dsh-memory-bridge/locale/en.json +6 -0
- package/plugins/dsh-memory-bridge/locale/ru.json +6 -0
- package/plugins/dsh-memory-bridge/package.json +67 -0
- package/plugins/dsh-peak-guard/README.md +16 -3
- package/plugins/dsh-peak-guard/client.js +223 -8
- package/plugins/dsh-peak-guard/package.json +1 -1
- package/plugins/dsh-plan-mode/README.md +24 -0
- package/plugins/dsh-plan-mode/cordis.patch.yml +3 -0
- package/plugins/dsh-plan-mode/index.js +103 -0
- package/plugins/dsh-plan-mode/lib/plan.js +89 -0
- package/plugins/dsh-plan-mode/package.json +18 -0
- package/plugins/dsh-profile-transfer/client.js +277 -17
- package/plugins/dsh-profile-transfer/index.js +10 -0
- package/plugins/dsh-profile-transfer/package.json +3 -1
- package/plugins/dsh-profile-transfer/transfer.js +101 -2
- package/plugins/dsh-project-memory/index.js +92 -2
- package/plugins/dsh-project-memory/package.json +1 -1
- package/plugins/dsh-repo-map/README.md +26 -0
- package/plugins/dsh-repo-map/cordis.patch.yml +3 -0
- package/plugins/dsh-repo-map/index.js +99 -0
- package/plugins/dsh-repo-map/lib/repo-map.js +170 -0
- package/plugins/dsh-repo-map/package.json +25 -0
- package/plugins/dsh-skill-autopilot/index.js +135 -11
- package/skills/delivery-gate/SKILL.md +31 -0
- package/skills/git-and-ci/SKILL.md +5 -1
- package/skills/manifest.json +31 -6
- package/skills/requirements-and-planning/SKILL.md +3 -0
- package/skills/research-first/SKILL.md +35 -0
- package/skills/research-first/references/query-patterns.md +20 -0
- package/skills/subagent-playbook/SKILL.md +36 -0
- package/skills/subagent-playbook/references/prompts.md +39 -0
- package/skills/ui-craft/SKILL.md +43 -0
- package/skills/visual-qa/SKILL.md +29 -0
- package/skills/visual-qa/references/commands.md +32 -0
- package/skills-baseline.json +219 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticated Connection RPC for the MCP Settings section.
|
|
3
|
+
*/
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
globalConfigPath,
|
|
8
|
+
loadConfig,
|
|
9
|
+
loadSecrets,
|
|
10
|
+
removeGlobalServer,
|
|
11
|
+
saveSecrets,
|
|
12
|
+
setGlobalServerFlags,
|
|
13
|
+
upsertGlobalServer,
|
|
14
|
+
} from "./config.js";
|
|
15
|
+
|
|
16
|
+
export const RPC_NAMESPACE = "dsh-mcp-manager";
|
|
17
|
+
const ROUTE_BASE = "/api/dsh-mcp-manager";
|
|
18
|
+
|
|
19
|
+
function okEnvelope(rpcId, value) {
|
|
20
|
+
return Response.json({ type: "server-response", rpcId, result: { ok: true, value } });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function errorEnvelope(rpcId, code, message) {
|
|
24
|
+
return Response.json({ type: "server-response", rpcId, result: { ok: false, error: { code, message, details: {} } } });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function readEnvelope(request) {
|
|
28
|
+
const body = await request.json();
|
|
29
|
+
if (!body || body.type !== "client-request" || typeof body.rpcId !== "string" || typeof body.method !== "string") {
|
|
30
|
+
throw new Error("bad Connection envelope");
|
|
31
|
+
}
|
|
32
|
+
return { rpcId: body.rpcId, method: body.method, payload: body.payload ?? {} };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function publicServer(server) {
|
|
36
|
+
return {
|
|
37
|
+
id: server.id,
|
|
38
|
+
scope: server.scope,
|
|
39
|
+
transport: server.url ? "http" : "stdio",
|
|
40
|
+
command: server.command,
|
|
41
|
+
argsCount: server.args.length,
|
|
42
|
+
url: server.url ? (() => { try { return new URL(server.url).origin; } catch { return "invalid"; } })() : "",
|
|
43
|
+
enabled: server.enabled,
|
|
44
|
+
trusted: server.trusted,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function presets() {
|
|
49
|
+
return [
|
|
50
|
+
{
|
|
51
|
+
id: "github",
|
|
52
|
+
title: "GitHub",
|
|
53
|
+
subtitle: "Issues, pull requests, code search и Actions",
|
|
54
|
+
transport: "stdio",
|
|
55
|
+
command: "npx",
|
|
56
|
+
args: ["-y", "@modelcontextprotocol/server-github"],
|
|
57
|
+
tokenEnv: "GITHUB_TOKEN",
|
|
58
|
+
tokenLabel: "GitHub token",
|
|
59
|
+
tokenPlaceholder: "ghp_… или github_pat_…",
|
|
60
|
+
trusted: true,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "postgres",
|
|
64
|
+
title: "PostgreSQL",
|
|
65
|
+
subtitle: "Схема и read-only запросы; отдельная роль обязательна",
|
|
66
|
+
transport: "stdio",
|
|
67
|
+
command: "npx",
|
|
68
|
+
args: ["-y", "@modelcontextprotocol/server-postgres", "postgresql://readonly@localhost:5432/postgres"],
|
|
69
|
+
tokenEnv: "PGPASSWORD",
|
|
70
|
+
tokenLabel: "Пароль read-only роли",
|
|
71
|
+
tokenPlaceholder: "пароль",
|
|
72
|
+
trusted: true,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "filesystem",
|
|
76
|
+
title: "Filesystem",
|
|
77
|
+
subtitle: "Доступ к папке проекта; без токена",
|
|
78
|
+
transport: "stdio",
|
|
79
|
+
command: "npx",
|
|
80
|
+
args: ["-y", "@modelcontextprotocol/server-filesystem", "${cwd}"],
|
|
81
|
+
trusted: true,
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function configSnapshot(manager) {
|
|
87
|
+
const config = loadConfig({ cwd: process.cwd() });
|
|
88
|
+
const status = new Map(manager.status().map((row) => [row.id, row]));
|
|
89
|
+
return {
|
|
90
|
+
globalConfig: globalConfigPath(),
|
|
91
|
+
projectConfig: config.projectConfig,
|
|
92
|
+
secretsFile: path.join(path.dirname(globalConfigPath()), "mcp.secrets.json"),
|
|
93
|
+
servers: config.servers.filter((server) => server.scope === "global").map((server) => {
|
|
94
|
+
const row = status.get(server.id);
|
|
95
|
+
return {
|
|
96
|
+
...publicServer(server),
|
|
97
|
+
connected: Boolean(row?.connected),
|
|
98
|
+
tools: row?.tools ?? 0,
|
|
99
|
+
error: row?.error ?? "",
|
|
100
|
+
serverInfo: row?.serverInfo ?? null,
|
|
101
|
+
projectTrusted: server.scope !== "project" || Boolean(server.trusted),
|
|
102
|
+
};
|
|
103
|
+
}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function restartGlobal(manager) {
|
|
108
|
+
await manager.close();
|
|
109
|
+
manager.reopen();
|
|
110
|
+
const config = loadConfig({ cwd: process.cwd() });
|
|
111
|
+
await manager.connectAll(config.servers.filter((server) => server.scope === "global" && server.enabled && server.trusted));
|
|
112
|
+
manager.registerTools();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function validateServer(payload) {
|
|
116
|
+
const id = String(payload.id ?? "").trim();
|
|
117
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,32}$/u.test(id)) throw new Error("id: строчные латинские буквы, цифры, _ и -");
|
|
118
|
+
const transport = payload.transport === "http" ? "http" : "stdio";
|
|
119
|
+
if (transport === "http") {
|
|
120
|
+
if (!/^https?:\/\//u.test(String(payload.url ?? ""))) throw new Error("URL должен начинаться с http:// или https://");
|
|
121
|
+
} else if (String(payload.command ?? "").trim().length === 0) {
|
|
122
|
+
throw new Error("command обязателен");
|
|
123
|
+
}
|
|
124
|
+
return { id, transport, trusted: payload.trusted === true };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function registerApi(ctx, manager) {
|
|
128
|
+
const operation = (name, handler) => {
|
|
129
|
+
const route = {
|
|
130
|
+
path: `${ROUTE_BASE}/${name}`,
|
|
131
|
+
methods: ["POST"],
|
|
132
|
+
requestBody: "buffered",
|
|
133
|
+
fetch: async (request) => {
|
|
134
|
+
let rpcId = "invalid-request";
|
|
135
|
+
try {
|
|
136
|
+
const envelope = await readEnvelope(request);
|
|
137
|
+
rpcId = envelope.rpcId;
|
|
138
|
+
if (envelope.method !== `${RPC_NAMESPACE}/${name}`) throw new Error("endpoint/method mismatch");
|
|
139
|
+
const value = await handler(envelope.payload);
|
|
140
|
+
return okEnvelope(rpcId, value);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
return errorEnvelope(rpcId, "mcp/bad-request", error instanceof Error ? error.message : String(error));
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
ctx.effect(() => {
|
|
147
|
+
const dispose = ctx.connection.fetch.register(route);
|
|
148
|
+
return () => { try { void Promise.resolve(dispose()).catch(() => {}); } catch { /* unloading */ } };
|
|
149
|
+
}, `mcp-manager: ${name}`);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
operation("status", async () => configSnapshot(manager));
|
|
153
|
+
operation("presets", async () => ({ presets: presets() }));
|
|
154
|
+
operation("trust-project", async () => {
|
|
155
|
+
const config = loadConfig({ cwd: process.cwd() });
|
|
156
|
+
if (!config.projectConfig) throw new Error("нет .dsh/mcp.json");
|
|
157
|
+
const marker = path.join(path.dirname(config.projectConfig), "mcp.trusted");
|
|
158
|
+
fs.writeFileSync(marker, `${new Date().toISOString()}\n`, "utf8");
|
|
159
|
+
await restartGlobal(manager);
|
|
160
|
+
return configSnapshot(manager);
|
|
161
|
+
});
|
|
162
|
+
operation("save", async (payload) => {
|
|
163
|
+
const { id, transport, trusted } = validateServer(payload);
|
|
164
|
+
const secrets = { ...loadSecrets() };
|
|
165
|
+
if (payload.tokenEnv && !payload.token && !secrets[`${id}.${String(payload.tokenEnv)}`]) {
|
|
166
|
+
throw new Error(`для ${payload.tokenEnv} нужен токен`);
|
|
167
|
+
}
|
|
168
|
+
if (payload.tokenHeader && payload.token && !payload.tokenHeader) throw new Error("tokenHeader обязателен для HTTP-токена");
|
|
169
|
+
const raw = { trusted, enabled: payload.enabled !== false };
|
|
170
|
+
if (transport === "http") {
|
|
171
|
+
raw.url = String(payload.url).trim();
|
|
172
|
+
const headers = {};
|
|
173
|
+
if (payload.token && payload.tokenHeader) {
|
|
174
|
+
headers[String(payload.tokenHeader)] = `Bearer \${secret:${id}.token}`;
|
|
175
|
+
secrets[`${id}.token`] = String(payload.token);
|
|
176
|
+
}
|
|
177
|
+
if (payload.headers && typeof payload.headers === "object") {
|
|
178
|
+
for (const [key, value] of Object.entries(payload.headers)) {
|
|
179
|
+
if (/^[A-Za-z0-9-]+$/u.test(key)) headers[key] = String(value);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (Object.keys(headers).length > 0) raw.headers = headers;
|
|
183
|
+
} else {
|
|
184
|
+
raw.command = String(payload.command).trim();
|
|
185
|
+
raw.args = Array.isArray(payload.args) ? payload.args.map(String) : [];
|
|
186
|
+
const env = {};
|
|
187
|
+
if (payload.token && payload.tokenEnv) {
|
|
188
|
+
env[String(payload.tokenEnv)] = `\${secret:${id}.${String(payload.tokenEnv)}}`;
|
|
189
|
+
secrets[`${id}.${String(payload.tokenEnv)}`] = String(payload.token);
|
|
190
|
+
}
|
|
191
|
+
if (payload.env && typeof payload.env === "object") {
|
|
192
|
+
for (const [key, value] of Object.entries(payload.env)) {
|
|
193
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) env[key] = String(value);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (Object.keys(env).length > 0) raw.env = env;
|
|
197
|
+
}
|
|
198
|
+
upsertGlobalServer(id, raw, Object.fromEntries(Object.entries(secrets).filter(([key]) => key.startsWith(`${id}.`))));
|
|
199
|
+
saveSecrets(secrets);
|
|
200
|
+
await restartGlobal(manager);
|
|
201
|
+
return configSnapshot(manager);
|
|
202
|
+
});
|
|
203
|
+
operation("remove", async (payload) => {
|
|
204
|
+
const id = String(payload.id ?? "");
|
|
205
|
+
removeGlobalServer(id);
|
|
206
|
+
await restartGlobal(manager);
|
|
207
|
+
return configSnapshot(manager);
|
|
208
|
+
});
|
|
209
|
+
operation("toggle", async (payload) => {
|
|
210
|
+
const id = String(payload.id ?? "");
|
|
211
|
+
setGlobalServerFlags(id, { enabled: payload.enabled, trusted: payload.trusted });
|
|
212
|
+
await restartGlobal(manager);
|
|
213
|
+
return configSnapshot(manager);
|
|
214
|
+
});
|
|
215
|
+
operation("test", async (payload) => {
|
|
216
|
+
const config = loadConfig({ cwd: process.cwd() });
|
|
217
|
+
const server = config.servers.find((item) => item.id === String(payload.id ?? ""));
|
|
218
|
+
if (!server) throw new Error("server not found");
|
|
219
|
+
const record = await manager.connect(server);
|
|
220
|
+
manager.registerTools();
|
|
221
|
+
return {
|
|
222
|
+
connected: Boolean(record.client?.connected),
|
|
223
|
+
tools: record.tools.length,
|
|
224
|
+
error: record.error,
|
|
225
|
+
serverInfo: record.client?.serverInfo ?? null,
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
operation("tools", async (payload) => {
|
|
229
|
+
const config = loadConfig({ cwd: process.cwd() });
|
|
230
|
+
const server = config.servers.find((item) => item.id === String(payload.id ?? ""));
|
|
231
|
+
if (!server) throw new Error("server not found");
|
|
232
|
+
const record = await manager.connect(server);
|
|
233
|
+
return {
|
|
234
|
+
tools: record.tools.map((tool) => ({
|
|
235
|
+
name: tool.name,
|
|
236
|
+
description: tool.description ?? "",
|
|
237
|
+
schema: tool.inputSchema ?? null,
|
|
238
|
+
})),
|
|
239
|
+
error: record.error,
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
operation("call", async (payload) => {
|
|
243
|
+
const config = loadConfig({ cwd: process.cwd() });
|
|
244
|
+
const server = config.servers.find((item) => item.id === String(payload.id ?? ""));
|
|
245
|
+
if (!server) throw new Error("server not found");
|
|
246
|
+
await manager.connect(server);
|
|
247
|
+
const text = await manager.call(server.id, String(payload.tool ?? ""), payload.arguments ?? {});
|
|
248
|
+
return { text };
|
|
249
|
+
});
|
|
250
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal MCP client: JSON-RPC 2.0 over stdio or streamable HTTP.
|
|
3
|
+
*
|
|
4
|
+
* No SDK dependency: the kit stays installable as one package. Supports the
|
|
5
|
+
* primitives the manager needs today: initialize, tools/list, tools/call,
|
|
6
|
+
* resources/list, resources/read, resources/templates/list.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { createInterface } from "node:readline";
|
|
10
|
+
|
|
11
|
+
const PROTOCOL_VERSION = "2025-06-18";
|
|
12
|
+
|
|
13
|
+
function makeId() {
|
|
14
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseJsonRpc(line) {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(line);
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class StdioTransport {
|
|
26
|
+
constructor(config) {
|
|
27
|
+
this.config = config;
|
|
28
|
+
this.pending = new Map();
|
|
29
|
+
this.child = null;
|
|
30
|
+
this.closed = false;
|
|
31
|
+
this.stderr = "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async start() {
|
|
35
|
+
this.child = spawn(this.config.command, this.config.args, {
|
|
36
|
+
cwd: this.config.cwd || process.cwd(),
|
|
37
|
+
env: { ...process.env, ...this.config.env },
|
|
38
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
39
|
+
windowsHide: true,
|
|
40
|
+
});
|
|
41
|
+
const reader = createInterface({ input: this.child.stdout });
|
|
42
|
+
reader.on("line", (line) => {
|
|
43
|
+
const message = parseJsonRpc(line);
|
|
44
|
+
if (!message) return;
|
|
45
|
+
if (message.id !== undefined && this.pending.has(String(message.id))) {
|
|
46
|
+
const waiter = this.pending.get(String(message.id));
|
|
47
|
+
this.pending.delete(String(message.id));
|
|
48
|
+
clearTimeout(waiter.timer);
|
|
49
|
+
if (message.error) waiter.reject(new Error(message.error.message || "MCP error"));
|
|
50
|
+
else waiter.resolve(message.result);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
this.child.stderr.on("data", (chunk) => {
|
|
54
|
+
this.stderr = `${this.stderr}${chunk.toString("utf8")}`.slice(-4_000);
|
|
55
|
+
});
|
|
56
|
+
this.child.on("exit", (code) => {
|
|
57
|
+
for (const waiter of this.pending.values()) {
|
|
58
|
+
clearTimeout(waiter.timer);
|
|
59
|
+
waiter.reject(new Error(`MCP server exited (${code ?? "?"}): ${this.stderr.trim().slice(-500)}`));
|
|
60
|
+
}
|
|
61
|
+
this.pending.clear();
|
|
62
|
+
this.closed = true;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async request(method, params, timeoutMs) {
|
|
67
|
+
if (this.closed || !this.child?.stdin?.writable) throw new Error("MCP server is not running");
|
|
68
|
+
const id = makeId();
|
|
69
|
+
const payload = { jsonrpc: "2.0", id, method, params: params ?? {} };
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
const timer = setTimeout(() => {
|
|
72
|
+
this.pending.delete(id);
|
|
73
|
+
reject(new Error(`MCP ${method} timed out after ${timeoutMs}ms`));
|
|
74
|
+
}, timeoutMs);
|
|
75
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
76
|
+
this.child.stdin.write(`${JSON.stringify(payload)}\n`, (error) => {
|
|
77
|
+
if (!error) return;
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
this.pending.delete(id);
|
|
80
|
+
reject(error);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
notify(method, params) {
|
|
86
|
+
if (this.closed || !this.child?.stdin?.writable) return;
|
|
87
|
+
this.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params: params ?? {} })}\n`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async close() {
|
|
91
|
+
this.closed = true;
|
|
92
|
+
for (const waiter of this.pending.values()) {
|
|
93
|
+
clearTimeout(waiter.timer);
|
|
94
|
+
waiter.reject(new Error("MCP server closed"));
|
|
95
|
+
}
|
|
96
|
+
this.pending.clear();
|
|
97
|
+
try { this.child?.stdin?.end(); } catch { /* best effort */ }
|
|
98
|
+
try { this.child?.kill(); } catch { /* best effort */ }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
class HttpTransport {
|
|
103
|
+
constructor(config) {
|
|
104
|
+
this.config = config;
|
|
105
|
+
this.sessionId = "";
|
|
106
|
+
this.pending = new Map();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async request(method, params, timeoutMs) {
|
|
110
|
+
const controller = new AbortController();
|
|
111
|
+
const timer = setTimeout(() => controller.abort(new Error("timeout")), timeoutMs);
|
|
112
|
+
try {
|
|
113
|
+
const response = await fetch(this.config.url, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: {
|
|
116
|
+
"content-type": "application/json",
|
|
117
|
+
accept: "application/json, text/event-stream",
|
|
118
|
+
...(this.sessionId ? { "mcp-session-id": this.sessionId } : {}),
|
|
119
|
+
...this.config.headers,
|
|
120
|
+
},
|
|
121
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: makeId(), method, params: params ?? {} }),
|
|
122
|
+
signal: controller.signal,
|
|
123
|
+
});
|
|
124
|
+
const sessionId = response.headers.get("mcp-session-id");
|
|
125
|
+
if (sessionId) this.sessionId = sessionId;
|
|
126
|
+
if (!response.ok) throw new Error(`MCP HTTP ${response.status}: ${(await response.text()).slice(0, 500)}`);
|
|
127
|
+
const text = await response.text();
|
|
128
|
+
if (text.trim() === "") return undefined;
|
|
129
|
+
const contentType = response.headers.get("content-type") || "";
|
|
130
|
+
if (contentType.includes("text/event-stream")) {
|
|
131
|
+
for (const line of text.split(/\r?\n/u)) {
|
|
132
|
+
if (!line.startsWith("data:")) continue;
|
|
133
|
+
const message = parseJsonRpc(line.slice(5).trim());
|
|
134
|
+
if (!message || message.id === undefined) continue;
|
|
135
|
+
if (message.error) throw new Error(message.error.message || "MCP error");
|
|
136
|
+
if (message.result !== undefined) return message.result;
|
|
137
|
+
}
|
|
138
|
+
throw new Error("MCP HTTP: no JSON-RPC response in event stream");
|
|
139
|
+
}
|
|
140
|
+
const message = parseJsonRpc(text);
|
|
141
|
+
if (!message) throw new Error("MCP HTTP: invalid JSON response");
|
|
142
|
+
if (message.error) throw new Error(message.error.message || "MCP error");
|
|
143
|
+
return message.result;
|
|
144
|
+
} finally {
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
notify() {
|
|
150
|
+
/* MCP streamable HTTP notifications are optional for our read-only tools. */
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async close() {
|
|
154
|
+
/* stateless: nothing to close */
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export class McpClient {
|
|
159
|
+
constructor(config, logger) {
|
|
160
|
+
this.config = config;
|
|
161
|
+
this.logger = logger;
|
|
162
|
+
this.transport = config.url ? new HttpTransport(config) : new StdioTransport(config);
|
|
163
|
+
this.connected = false;
|
|
164
|
+
this.serverInfo = null;
|
|
165
|
+
this.capabilities = {};
|
|
166
|
+
this.tools = [];
|
|
167
|
+
this.resources = [];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async connect() {
|
|
171
|
+
await this.transport.start?.();
|
|
172
|
+
const result = await this.transport.request(
|
|
173
|
+
"initialize",
|
|
174
|
+
{
|
|
175
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
176
|
+
capabilities: {},
|
|
177
|
+
clientInfo: { name: "dsh-mcp-manager", version: "1.0.0" },
|
|
178
|
+
},
|
|
179
|
+
this.config.timeoutMs,
|
|
180
|
+
);
|
|
181
|
+
this.serverInfo = result?.serverInfo ?? null;
|
|
182
|
+
this.capabilities = result?.capabilities ?? {};
|
|
183
|
+
this.transport.notify("notifications/initialized");
|
|
184
|
+
this.connected = true;
|
|
185
|
+
return this;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async listTools() {
|
|
189
|
+
const tools = [];
|
|
190
|
+
let cursor;
|
|
191
|
+
do {
|
|
192
|
+
const result = await this.transport.request("tools/list", cursor ? { cursor } : {}, this.config.timeoutMs);
|
|
193
|
+
if (Array.isArray(result?.tools)) tools.push(...result.tools);
|
|
194
|
+
cursor = result?.nextCursor;
|
|
195
|
+
} while (cursor);
|
|
196
|
+
this.tools = tools;
|
|
197
|
+
return tools;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async callTool(name, args = {}) {
|
|
201
|
+
return this.transport.request("tools/call", { name, arguments: args }, this.config.timeoutMs);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async listResources() {
|
|
205
|
+
if (!this.capabilities.resources) return [];
|
|
206
|
+
const result = await this.transport.request("resources/list", {}, this.config.timeoutMs);
|
|
207
|
+
this.resources = Array.isArray(result?.resources) ? result.resources : [];
|
|
208
|
+
return this.resources;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async readResource(uri) {
|
|
212
|
+
return this.transport.request("resources/read", { uri }, this.config.timeoutMs);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async close() {
|
|
216
|
+
await this.transport.close?.();
|
|
217
|
+
this.connected = false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server configuration.
|
|
3
|
+
*
|
|
4
|
+
* Global: `~/.dsh/mcp.json`
|
|
5
|
+
* Project: `<project>/.dsh/mcp.json` (loaded by `/mcp`, not auto-started)
|
|
6
|
+
*
|
|
7
|
+
* Servers are never started unless `trusted: true`; this is deliberate because
|
|
8
|
+
* a stdio server is arbitrary process execution.
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
export function mcpHome() {
|
|
15
|
+
return process.env.DSH_HOME || path.join(os.homedir(), ".dsh");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function globalConfigPath() {
|
|
19
|
+
return path.join(mcpHome(), "mcp.json");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function secretsPath() {
|
|
23
|
+
return path.join(mcpHome(), "mcp.secrets.json");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function loadSecrets(home = mcpHome()) {
|
|
27
|
+
const file = path.join(home, "mcp.secrets.json");
|
|
28
|
+
if (!fs.existsSync(file)) return {};
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
31
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
32
|
+
} catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function saveSecrets(secrets, home = mcpHome()) {
|
|
38
|
+
const file = path.join(home, "mcp.secrets.json");
|
|
39
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
40
|
+
fs.writeFileSync(file, `${JSON.stringify(secrets, null, 2)}\n`, { mode: 0o600 });
|
|
41
|
+
return file;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function readGlobalConfig() {
|
|
45
|
+
try {
|
|
46
|
+
return readJson(globalConfigPath()) ?? { mcpServers: {} };
|
|
47
|
+
} catch {
|
|
48
|
+
return { mcpServers: {} };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function writeGlobalConfig(config) {
|
|
53
|
+
const file = globalConfigPath();
|
|
54
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
55
|
+
fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
56
|
+
return file;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function upsertGlobalServer(id, server, secrets = {}) {
|
|
60
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,32}$/u.test(id)) throw new Error("server id must match [a-z0-9_-]{1,33}");
|
|
61
|
+
const config = readGlobalConfig();
|
|
62
|
+
config.mcpServers = config.mcpServers && typeof config.mcpServers === "object" ? config.mcpServers : {};
|
|
63
|
+
config.mcpServers[id] = server;
|
|
64
|
+
writeGlobalConfig(config);
|
|
65
|
+
const nextSecrets = { ...loadSecrets(), ...secrets };
|
|
66
|
+
if (Object.keys(secrets).length > 0) saveSecrets(nextSecrets);
|
|
67
|
+
return config;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function removeGlobalServer(id) {
|
|
71
|
+
const config = readGlobalConfig();
|
|
72
|
+
if (config.mcpServers && typeof config.mcpServers === "object") delete config.mcpServers[id];
|
|
73
|
+
writeGlobalConfig(config);
|
|
74
|
+
const secrets = loadSecrets();
|
|
75
|
+
for (const key of Object.keys(secrets)) {
|
|
76
|
+
if (key.startsWith(`${id}.`)) delete secrets[key];
|
|
77
|
+
}
|
|
78
|
+
saveSecrets(secrets);
|
|
79
|
+
return config;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function setGlobalServerFlags(id, flags) {
|
|
83
|
+
const config = readGlobalConfig();
|
|
84
|
+
const server = config.mcpServers?.[id];
|
|
85
|
+
if (!server) throw new Error(`server not found: ${id}`);
|
|
86
|
+
if (typeof flags.enabled === "boolean") server.enabled = flags.enabled;
|
|
87
|
+
if (typeof flags.trusted === "boolean") server.trusted = flags.trusted;
|
|
88
|
+
writeGlobalConfig(config);
|
|
89
|
+
return config;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function findProject(start) {
|
|
93
|
+
let current = path.resolve(start || process.cwd());
|
|
94
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
95
|
+
const dsh = path.join(current, ".dsh");
|
|
96
|
+
if (fs.existsSync(dsh)) return { root: current, config: path.join(dsh, "mcp.json") };
|
|
97
|
+
const parent = path.dirname(current);
|
|
98
|
+
if (parent === current) break;
|
|
99
|
+
current = parent;
|
|
100
|
+
}
|
|
101
|
+
return { root: path.resolve(start || process.cwd()), config: null };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function readJson(file) {
|
|
105
|
+
if (!file || !fs.existsSync(file)) return null;
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
108
|
+
} catch (error) {
|
|
109
|
+
throw new Error(`${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function resolveValue(value, env, secrets) {
|
|
114
|
+
if (typeof value !== "string") return value;
|
|
115
|
+
return value
|
|
116
|
+
.replace(/\$\{secret:([A-Za-z0-9_.-]+)\}/gu, (_, key) => {
|
|
117
|
+
const resolved = secrets?.[key];
|
|
118
|
+
if (resolved === undefined) throw new Error(`нет секрета ${key}`);
|
|
119
|
+
return resolved;
|
|
120
|
+
})
|
|
121
|
+
.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/gu, (_, key) => {
|
|
122
|
+
const resolved = env[key];
|
|
123
|
+
if (resolved === undefined) throw new Error(`нет переменной окружения ${key}`);
|
|
124
|
+
return resolved;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function normalizeServer(id, raw, env, secrets) {
|
|
129
|
+
if (!raw || typeof raw !== "object") throw new Error(`server ${id}: must be an object`);
|
|
130
|
+
const command = typeof raw.command === "string" ? raw.command.trim() : "";
|
|
131
|
+
const url = typeof raw.url === "string" ? raw.url.trim() : "";
|
|
132
|
+
if (!command && !url) throw new Error(`server ${id}: command or url is required`);
|
|
133
|
+
if (command && url) throw new Error(`server ${id}: choose command or url, not both`);
|
|
134
|
+
const args = Array.isArray(raw.args) ? raw.args.map((arg) => String(arg)) : [];
|
|
135
|
+
const rawEnv = raw.env && typeof raw.env === "object" ? raw.env : {};
|
|
136
|
+
const resolvedEnv = {};
|
|
137
|
+
for (const [key, value] of Object.entries(rawEnv)) {
|
|
138
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) continue;
|
|
139
|
+
resolvedEnv[key] = resolveValue(String(value), env, secrets);
|
|
140
|
+
}
|
|
141
|
+
const headers = {};
|
|
142
|
+
if (raw.headers && typeof raw.headers === "object") {
|
|
143
|
+
for (const [key, value] of Object.entries(raw.headers)) {
|
|
144
|
+
if (!/^[A-Za-z0-9-]+$/u.test(key)) continue;
|
|
145
|
+
headers[key] = resolveValue(String(value), env, secrets);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
id,
|
|
150
|
+
command,
|
|
151
|
+
args,
|
|
152
|
+
url,
|
|
153
|
+
env: resolvedEnv,
|
|
154
|
+
headers,
|
|
155
|
+
cwd: typeof raw.cwd === "string" ? raw.cwd : undefined,
|
|
156
|
+
timeoutMs: Math.max(1_000, Math.min(120_000, Number(raw.timeoutMs) || 30_000)),
|
|
157
|
+
trusted: raw.trusted === true,
|
|
158
|
+
enabled: raw.enabled !== false,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function loadConfig({ cwd = process.cwd(), env = process.env } = {}) {
|
|
163
|
+
const globalFile = globalConfigPath();
|
|
164
|
+
const project = findProject(cwd);
|
|
165
|
+
const projectTrusted = Boolean(project.config && fs.existsSync(path.join(path.dirname(project.config), "mcp.trusted")));
|
|
166
|
+
const secrets = loadSecrets();
|
|
167
|
+
const sources = [];
|
|
168
|
+
const servers = new Map();
|
|
169
|
+
for (const [file, scope] of [[globalFile, "global"], [project.config, "project"]]) {
|
|
170
|
+
if (!file) continue;
|
|
171
|
+
let parsed;
|
|
172
|
+
try {
|
|
173
|
+
parsed = readJson(file);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
sources.push({ file, scope, error: error instanceof Error ? error.message : String(error) });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (!parsed) continue;
|
|
179
|
+
sources.push({ file, scope });
|
|
180
|
+
const map = parsed.mcpServers && typeof parsed.mcpServers === "object" ? parsed.mcpServers : {};
|
|
181
|
+
for (const [id, raw] of Object.entries(map)) {
|
|
182
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,32}$/u.test(id)) continue;
|
|
183
|
+
try {
|
|
184
|
+
const normalized = normalizeServer(id, raw, env, secrets);
|
|
185
|
+
if (scope === "project" && !projectTrusted) normalized.trusted = false;
|
|
186
|
+
servers.set(id, { ...normalized, scope });
|
|
187
|
+
} catch (error) {
|
|
188
|
+
sources.push({ file, scope, error: error instanceof Error ? error.message : String(error) });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return { servers: [...servers.values()], sources, projectRoot: project.root, projectConfig: project.config };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function writeTemplate(file = globalConfigPath()) {
|
|
196
|
+
if (fs.existsSync(file)) return { created: false, file };
|
|
197
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
198
|
+
fs.writeFileSync(
|
|
199
|
+
file,
|
|
200
|
+
`${JSON.stringify(
|
|
201
|
+
{
|
|
202
|
+
mcpServers: {
|
|
203
|
+
example: {
|
|
204
|
+
command: "npx",
|
|
205
|
+
args: ["-y", "@modelcontextprotocol/server-everything"],
|
|
206
|
+
trusted: false,
|
|
207
|
+
enabled: false,
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
null,
|
|
212
|
+
2,
|
|
213
|
+
)}\n`,
|
|
214
|
+
"utf8",
|
|
215
|
+
);
|
|
216
|
+
return { created: true, file };
|
|
217
|
+
}
|