droid-mode 0.0.1
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/LICENSE +21 -0
- package/README.md +135 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +103 -0
- package/package.json +51 -0
- package/templates/skills/droid-mode/SKILL.md +237 -0
- package/templates/skills/droid-mode/bin/dm +424 -0
- package/templates/skills/droid-mode/examples/hooks/README.md +13 -0
- package/templates/skills/droid-mode/examples/workflows/workflow.example.js +21 -0
- package/templates/skills/droid-mode/lib/config.mjs +135 -0
- package/templates/skills/droid-mode/lib/hydrate.mjs +164 -0
- package/templates/skills/droid-mode/lib/mcp_client.mjs +175 -0
- package/templates/skills/droid-mode/lib/mcp_http.mjs +132 -0
- package/templates/skills/droid-mode/lib/mcp_stdio.mjs +152 -0
- package/templates/skills/droid-mode/lib/run.mjs +213 -0
- package/templates/skills/droid-mode/lib/search.mjs +67 -0
- package/templates/skills/droid-mode/lib/tool_index.mjs +89 -0
- package/templates/skills/droid-mode/lib/util.mjs +160 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { ensureDir, nowIsoCompact, safeIdentifier, writeJson, getDroidModeDataDir } from "./util.mjs";
|
|
4
|
+
|
|
5
|
+
/** @param {string} s */
|
|
6
|
+
function toPascalCase(s) {
|
|
7
|
+
if (!s) return "Tool";
|
|
8
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Best-effort JSON Schema → TypeScript type string.
|
|
13
|
+
* This is intentionally conservative: unknown/complex schemas become `any`.
|
|
14
|
+
* @param {any} schema
|
|
15
|
+
* @param {number} depth
|
|
16
|
+
*/
|
|
17
|
+
function schemaToTs(schema, depth = 0) {
|
|
18
|
+
if (!schema || typeof schema !== "object") return "any";
|
|
19
|
+
if (depth > 8) return "any";
|
|
20
|
+
if (schema.$ref) return "any";
|
|
21
|
+
|
|
22
|
+
// enum
|
|
23
|
+
if (Array.isArray(schema.enum) && schema.enum.length) {
|
|
24
|
+
const vals = schema.enum;
|
|
25
|
+
if (vals.length > 20) return typeof vals[0] === "number" ? "number" : "string";
|
|
26
|
+
return vals
|
|
27
|
+
.map((v) => (typeof v === "string" ? JSON.stringify(v) : typeof v === "number" ? String(v) : "any"))
|
|
28
|
+
.join(" | ");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// anyOf/oneOf/allOf
|
|
32
|
+
for (const key of ["oneOf", "anyOf"]) {
|
|
33
|
+
if (Array.isArray(schema[key]) && schema[key].length) {
|
|
34
|
+
return schema[key].map((s) => schemaToTs(s, depth + 1)).join(" | ");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (Array.isArray(schema.allOf) && schema.allOf.length) {
|
|
38
|
+
return schema.allOf.map((s) => schemaToTs(s, depth + 1)).join(" & ");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const t = schema.type;
|
|
42
|
+
|
|
43
|
+
if (t === "string") return "string";
|
|
44
|
+
if (t === "number" || t === "integer") return "number";
|
|
45
|
+
if (t === "boolean") return "boolean";
|
|
46
|
+
if (t === "null") return "null";
|
|
47
|
+
|
|
48
|
+
if (t === "array") {
|
|
49
|
+
const item = schema.items ? schemaToTs(schema.items, depth + 1) : "any";
|
|
50
|
+
return `Array<${item}>`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (t === "object" || schema.properties || schema.additionalProperties) {
|
|
54
|
+
const props = schema.properties || {};
|
|
55
|
+
const req = new Set(Array.isArray(schema.required) ? schema.required : []);
|
|
56
|
+
const lines = ["{"];
|
|
57
|
+
|
|
58
|
+
for (const [k, v] of Object.entries(props)) {
|
|
59
|
+
const optional = req.has(k) ? "" : "?";
|
|
60
|
+
lines.push(` ${JSON.stringify(k)}${optional}: ${schemaToTs(v, depth + 1)};`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (schema.additionalProperties) {
|
|
64
|
+
if (schema.additionalProperties === true) {
|
|
65
|
+
lines.push(` [key: string]: any;`);
|
|
66
|
+
} else if (typeof schema.additionalProperties === "object") {
|
|
67
|
+
lines.push(` [key: string]: ${schemaToTs(schema.additionalProperties, depth + 1)};`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
lines.push("}");
|
|
72
|
+
return lines.join("\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// If type omitted but properties exist
|
|
76
|
+
if (schema.properties) return schemaToTs({ ...schema, type: "object" }, depth + 1);
|
|
77
|
+
|
|
78
|
+
return "any";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Generate a hydration bundle (schemas + TS types + safe tool map).
|
|
83
|
+
* @param {{
|
|
84
|
+
* serverName: string,
|
|
85
|
+
* tools: any[],
|
|
86
|
+
* toolNames: string[],
|
|
87
|
+
* outDir?: string,
|
|
88
|
+
* }} opts
|
|
89
|
+
*/
|
|
90
|
+
export function hydrateTools(opts) {
|
|
91
|
+
const ts = nowIsoCompact();
|
|
92
|
+
const baseOut =
|
|
93
|
+
opts.outDir ||
|
|
94
|
+
path.join(getDroidModeDataDir(), "hydrated", opts.serverName, ts);
|
|
95
|
+
|
|
96
|
+
ensureDir(baseOut);
|
|
97
|
+
|
|
98
|
+
// Select tools by name
|
|
99
|
+
const byName = new Map(opts.tools.map((t) => [t?.name, t]));
|
|
100
|
+
const selected = [];
|
|
101
|
+
for (const n of opts.toolNames) {
|
|
102
|
+
const t = byName.get(n);
|
|
103
|
+
if (t) selected.push(t);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const toolmap = {};
|
|
107
|
+
for (const t of selected) {
|
|
108
|
+
const safe = safeIdentifier(t?.name || "tool");
|
|
109
|
+
toolmap[safe] = t?.name || safe;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
writeJson(path.join(baseOut, "tools.json"), selected);
|
|
113
|
+
writeJson(path.join(baseOut, "toolmap.json"), toolmap);
|
|
114
|
+
|
|
115
|
+
// Also provide an ESM-friendly toolmap module (avoids JSON import assertions).
|
|
116
|
+
const toolmapModule = `// Auto-generated by droid-mode.\nexport default ${JSON.stringify(toolmap, null, 2)};\n`;
|
|
117
|
+
fs.writeFileSync(path.join(baseOut, "toolmap.mjs"), toolmapModule, "utf-8");
|
|
118
|
+
|
|
119
|
+
// types.d.ts
|
|
120
|
+
const typeLines = [
|
|
121
|
+
`// Auto-generated by droid-mode (best-effort).`,
|
|
122
|
+
`// This file exists to improve IDE autocomplete; it is NOT a contract.`,
|
|
123
|
+
``,
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
for (const t of selected) {
|
|
127
|
+
const safe = safeIdentifier(t?.name || "tool");
|
|
128
|
+
const pascal = toPascalCase(safe);
|
|
129
|
+
const inSchema = t?.inputSchema || t?.parameters || null;
|
|
130
|
+
const outSchema = t?.outputSchema || null;
|
|
131
|
+
|
|
132
|
+
const argsTs = schemaToTs(inSchema, 0);
|
|
133
|
+
const resTs = outSchema ? schemaToTs(outSchema, 0) : "any";
|
|
134
|
+
|
|
135
|
+
typeLines.push(`export type ${pascal}Args = ${argsTs};`);
|
|
136
|
+
typeLines.push(`export type ${pascal}Result = ${resTs};`);
|
|
137
|
+
typeLines.push("");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
fs.writeFileSync(path.join(baseOut, "types.d.ts"), typeLines.join("\n"), "utf-8");
|
|
141
|
+
|
|
142
|
+
// api.mjs: convenience builder (requires you to provide callTool)
|
|
143
|
+
const apiLines = [
|
|
144
|
+
`// Auto-generated by droid-mode.`,
|
|
145
|
+
`// Usage:`,
|
|
146
|
+
`// import { buildApi } from "./api.mjs";`,
|
|
147
|
+
`// import toolmap from "./toolmap.mjs";`,
|
|
148
|
+
`// const api = buildApi((name, args) => client.callTool({ name, arguments: args }));`,
|
|
149
|
+
`import toolmap from "./toolmap.mjs";`,
|
|
150
|
+
``,
|
|
151
|
+
`export function buildApi(callTool) {`,
|
|
152
|
+
` if (typeof callTool !== "function") throw new Error("buildApi requires a callTool(name,args) function");`,
|
|
153
|
+
` const api = { call: callTool };`,
|
|
154
|
+
` for (const [safeName, toolName] of Object.entries(toolmap)) {`,
|
|
155
|
+
` api[safeName] = (args) => callTool(toolName, args || {});`,
|
|
156
|
+
` }`,
|
|
157
|
+
` return api;`,
|
|
158
|
+
`}`,
|
|
159
|
+
``,
|
|
160
|
+
];
|
|
161
|
+
fs.writeFileSync(path.join(baseOut, "api.mjs"), apiLines.join("\n"), "utf-8");
|
|
162
|
+
|
|
163
|
+
return { outDir: baseOut, selectedCount: selected.length, toolmap };
|
|
164
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { McpStdioTransport } from "./mcp_stdio.mjs";
|
|
2
|
+
import { McpHttpTransport } from "./mcp_http.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {{
|
|
6
|
+
* type: "stdio",
|
|
7
|
+
* command: string,
|
|
8
|
+
* args?: string[],
|
|
9
|
+
* env?: Record<string,string>,
|
|
10
|
+
* }} StdioServer
|
|
11
|
+
*
|
|
12
|
+
* @typedef {{
|
|
13
|
+
* type: "http",
|
|
14
|
+
* url: string,
|
|
15
|
+
* headers?: Record<string,string>,
|
|
16
|
+
* }} HttpServer
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Minimal MCP client with lifecycle handling (initialize + notifications/initialized).
|
|
21
|
+
*/
|
|
22
|
+
export class McpClient {
|
|
23
|
+
/**
|
|
24
|
+
* @param {{ serverName?: string, entry: any }} cfg
|
|
25
|
+
*/
|
|
26
|
+
constructor(cfg) {
|
|
27
|
+
this.cfg = cfg;
|
|
28
|
+
this.serverName = cfg.serverName || "server";
|
|
29
|
+
this.entry = cfg.entry;
|
|
30
|
+
this.transport = null;
|
|
31
|
+
this.initialized = false;
|
|
32
|
+
this.negotiatedProtocolVersion = null;
|
|
33
|
+
this.serverInfo = null;
|
|
34
|
+
this.serverCapabilities = null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async connect() {
|
|
38
|
+
if (this.transport) return;
|
|
39
|
+
const t = this.entry?.type;
|
|
40
|
+
if (t === "stdio") {
|
|
41
|
+
this.transport = new McpStdioTransport({
|
|
42
|
+
command: this.entry.command,
|
|
43
|
+
args: this.entry.args || [],
|
|
44
|
+
env: this.entry.env || {},
|
|
45
|
+
});
|
|
46
|
+
await this.transport.connect();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (t === "http") {
|
|
50
|
+
this.transport = new McpHttpTransport({
|
|
51
|
+
url: this.entry.url,
|
|
52
|
+
headers: this.entry.headers || {},
|
|
53
|
+
});
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
throw new Error(`Unsupported MCP server type: ${t}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Perform MCP lifecycle initialization. Safe to call multiple times.
|
|
61
|
+
* @param {{ protocolVersion?: string, timeoutMs?: number }} opts
|
|
62
|
+
*/
|
|
63
|
+
async init(opts = {}) {
|
|
64
|
+
if (this.initialized) return;
|
|
65
|
+
await this.connect();
|
|
66
|
+
|
|
67
|
+
const requested = opts.protocolVersion || process.env.DM_MCP_PROTOCOL_VERSION || "2025-06-18";
|
|
68
|
+
const timeoutMs = typeof opts.timeoutMs === "number" ? opts.timeoutMs : 60_000;
|
|
69
|
+
|
|
70
|
+
const initializeParams = {
|
|
71
|
+
protocolVersion: requested,
|
|
72
|
+
capabilities: {
|
|
73
|
+
// Keep minimal: we don't need special client features for this skill.
|
|
74
|
+
roots: { listChanged: false },
|
|
75
|
+
sampling: {},
|
|
76
|
+
elicitation: {},
|
|
77
|
+
},
|
|
78
|
+
clientInfo: {
|
|
79
|
+
name: "droid-mode",
|
|
80
|
+
title: "Droid Mode Skill",
|
|
81
|
+
version: "0.1.0",
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const resp = await this.transport.request("initialize", initializeParams, { timeoutMs });
|
|
86
|
+
if (resp?.error) {
|
|
87
|
+
throw new Error(`MCP initialize error: ${resp.error.message || JSON.stringify(resp.error)}`);
|
|
88
|
+
}
|
|
89
|
+
const result = resp?.result;
|
|
90
|
+
this.negotiatedProtocolVersion = result?.protocolVersion || requested;
|
|
91
|
+
this.serverInfo = result?.serverInfo || null;
|
|
92
|
+
this.serverCapabilities = result?.capabilities || null;
|
|
93
|
+
|
|
94
|
+
// For HTTP transport, spec requires MCP-Protocol-Version header for subsequent requests.
|
|
95
|
+
if (typeof this.transport.setProtocolVersion === "function") {
|
|
96
|
+
this.transport.setProtocolVersion(this.negotiatedProtocolVersion);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Required: send notifications/initialized
|
|
100
|
+
await this.transport.notify("notifications/initialized", undefined);
|
|
101
|
+
|
|
102
|
+
this.initialized = true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* List tools (supports pagination via cursor).
|
|
107
|
+
* @param {{ cursor?: string, timeoutMs?: number }} opts
|
|
108
|
+
*/
|
|
109
|
+
async listTools(opts = {}) {
|
|
110
|
+
await this.init({ timeoutMs: opts.timeoutMs });
|
|
111
|
+
const resp = await this.transport.request("tools/list", opts.cursor ? { cursor: opts.cursor } : undefined, {
|
|
112
|
+
timeoutMs: opts.timeoutMs,
|
|
113
|
+
});
|
|
114
|
+
if (resp?.error) throw new Error(`tools/list error: ${resp.error.message || JSON.stringify(resp.error)}`);
|
|
115
|
+
return resp?.result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Call tool by name with args.
|
|
120
|
+
* Returns normalized result with convenience fields.
|
|
121
|
+
* @param {{ name: string, arguments?: any, timeoutMs?: number }} req
|
|
122
|
+
*/
|
|
123
|
+
async callTool(req) {
|
|
124
|
+
await this.init({ timeoutMs: req.timeoutMs });
|
|
125
|
+
const resp = await this.transport.request(
|
|
126
|
+
"tools/call",
|
|
127
|
+
{ name: req.name, arguments: req.arguments ?? {} },
|
|
128
|
+
{ timeoutMs: req.timeoutMs }
|
|
129
|
+
);
|
|
130
|
+
if (resp?.error) throw new Error(`tools/call error: ${resp.error.message || JSON.stringify(resp.error)}`);
|
|
131
|
+
const result = resp?.result;
|
|
132
|
+
return normalizeToolCallResult(result);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async close() {
|
|
136
|
+
if (!this.transport) return;
|
|
137
|
+
if (typeof this.transport.close === "function") await this.transport.close();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Normalize an MCP tool call result into a stable shape.
|
|
143
|
+
* MCP results generally contain `content` items; some servers also provide `structuredContent`.
|
|
144
|
+
* @param {any} result
|
|
145
|
+
*/
|
|
146
|
+
export function normalizeToolCallResult(result) {
|
|
147
|
+
const out = {
|
|
148
|
+
raw: result,
|
|
149
|
+
isError: !!result?.isError,
|
|
150
|
+
structured: result?.structuredContent ?? null,
|
|
151
|
+
text: "",
|
|
152
|
+
content: result?.content ?? [],
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
if (Array.isArray(result?.content)) {
|
|
156
|
+
const texts = [];
|
|
157
|
+
for (const c of result.content) {
|
|
158
|
+
if (!c) continue;
|
|
159
|
+
if (c.type === "text" && typeof c.text === "string") texts.push(c.text);
|
|
160
|
+
// Some servers include objects or embedded resources; leave in raw.
|
|
161
|
+
}
|
|
162
|
+
out.text = texts.join("\n").trim();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// If structured is missing but text looks like JSON, try to parse.
|
|
166
|
+
if (!out.structured && out.text) {
|
|
167
|
+
const t = out.text.trim();
|
|
168
|
+
if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) {
|
|
169
|
+
try {
|
|
170
|
+
out.structured = JSON.parse(t);
|
|
171
|
+
} catch {}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal HTTP transport for MCP servers.
|
|
5
|
+
* This implementation prefers plain JSON POST responses, but can also parse simple SSE responses.
|
|
6
|
+
*/
|
|
7
|
+
export class McpHttpTransport {
|
|
8
|
+
/**
|
|
9
|
+
* @param {{ url: string, headers?: Record<string,string> }} cfg
|
|
10
|
+
*/
|
|
11
|
+
constructor(cfg) {
|
|
12
|
+
this.cfg = cfg;
|
|
13
|
+
this.nextId = 1;
|
|
14
|
+
this.protocolVersion = null;
|
|
15
|
+
this.sessionId = null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** @param {string|null} v */
|
|
19
|
+
setProtocolVersion(v) {
|
|
20
|
+
this.protocolVersion = v;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async request(method, params, opts = {}) {
|
|
24
|
+
const id = this.nextId++;
|
|
25
|
+
const msg = { jsonrpc: "2.0", id, method, params };
|
|
26
|
+
const timeoutMs = typeof opts.timeoutMs === "number" ? opts.timeoutMs : 60_000;
|
|
27
|
+
|
|
28
|
+
const controller = new AbortController();
|
|
29
|
+
const t = setTimeout(() => controller.abort(), timeoutMs);
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const headers = {
|
|
33
|
+
Accept: "application/json, text/event-stream",
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
...(this.cfg.headers || {}),
|
|
36
|
+
};
|
|
37
|
+
if (this.protocolVersion) headers["MCP-Protocol-Version"] = this.protocolVersion;
|
|
38
|
+
if (this.sessionId) headers["Mcp-Session-Id"] = this.sessionId;
|
|
39
|
+
|
|
40
|
+
const res = await fetch(this.cfg.url, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers,
|
|
43
|
+
body: JSON.stringify(msg),
|
|
44
|
+
signal: controller.signal,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Capture session id if provided
|
|
48
|
+
const sid =
|
|
49
|
+
res.headers.get("mcp-session-id") ||
|
|
50
|
+
res.headers.get("Mcp-Session-Id") ||
|
|
51
|
+
res.headers.get("MCP-Session-Id");
|
|
52
|
+
if (sid) this.sessionId = sid;
|
|
53
|
+
|
|
54
|
+
const ct = (res.headers.get("content-type") || "").toLowerCase();
|
|
55
|
+
if (ct.includes("application/json")) {
|
|
56
|
+
const json = await res.json();
|
|
57
|
+
return json;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (ct.includes("text/event-stream")) {
|
|
61
|
+
const msg = await this._readSseForId(res, id, timeoutMs);
|
|
62
|
+
return msg;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Fallback: try text → json
|
|
66
|
+
const txt = await res.text();
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(txt);
|
|
69
|
+
} catch {
|
|
70
|
+
throw new Error(`Unexpected MCP HTTP response content-type=${ct}. Body:\n${txt.slice(0, 5000)}`);
|
|
71
|
+
}
|
|
72
|
+
} finally {
|
|
73
|
+
clearTimeout(t);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async notify(method, params) {
|
|
78
|
+
const msg = { jsonrpc: "2.0", method, params };
|
|
79
|
+
const headers = {
|
|
80
|
+
Accept: "application/json, text/event-stream",
|
|
81
|
+
"Content-Type": "application/json",
|
|
82
|
+
...(this.cfg.headers || {}),
|
|
83
|
+
};
|
|
84
|
+
if (this.protocolVersion) headers["MCP-Protocol-Version"] = this.protocolVersion;
|
|
85
|
+
if (this.sessionId) headers["Mcp-Session-Id"] = this.sessionId;
|
|
86
|
+
|
|
87
|
+
await fetch(this.cfg.url, { method: "POST", headers, body: JSON.stringify(msg) });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async _readSseForId(res, id, timeoutMs) {
|
|
91
|
+
const reader = res.body.getReader();
|
|
92
|
+
const decoder = new TextDecoder("utf-8");
|
|
93
|
+
let buf = "";
|
|
94
|
+
const start = Date.now();
|
|
95
|
+
|
|
96
|
+
const tryParseEvent = (eventText) => {
|
|
97
|
+
// eventText is lines separated by \n
|
|
98
|
+
const lines = eventText.split("\n");
|
|
99
|
+
const dataLines = lines.filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim());
|
|
100
|
+
if (!dataLines.length) return null;
|
|
101
|
+
const dataStr = dataLines.join("\n");
|
|
102
|
+
try {
|
|
103
|
+
const obj = JSON.parse(dataStr);
|
|
104
|
+
return obj;
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
while (true) {
|
|
111
|
+
if (Date.now() - start > timeoutMs) {
|
|
112
|
+
throw new Error(`Timeout waiting for SSE response id=${id}`);
|
|
113
|
+
}
|
|
114
|
+
const { value, done } = await reader.read();
|
|
115
|
+
if (done) break;
|
|
116
|
+
buf += decoder.decode(value, { stream: true });
|
|
117
|
+
|
|
118
|
+
// SSE events end with a blank line
|
|
119
|
+
let idx;
|
|
120
|
+
while ((idx = buf.indexOf("\n\n")) !== -1) {
|
|
121
|
+
const rawEvent = buf.slice(0, idx);
|
|
122
|
+
buf = buf.slice(idx + 2);
|
|
123
|
+
const parsed = tryParseEvent(rawEvent);
|
|
124
|
+
if (parsed && Object.prototype.hasOwnProperty.call(parsed, "id") && parsed.id === id) {
|
|
125
|
+
return parsed;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
throw new Error(`SSE stream ended before receiving response id=${id}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import readline from "node:readline";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minimal JSON-RPC stdio transport for MCP servers.
|
|
6
|
+
* Assumes line-delimited JSON messages on stdout.
|
|
7
|
+
*/
|
|
8
|
+
export class McpStdioTransport {
|
|
9
|
+
/**
|
|
10
|
+
* @param {{ command: string, args?: string[], env?: Record<string,string>, cwd?: string }} cfg
|
|
11
|
+
*/
|
|
12
|
+
constructor(cfg) {
|
|
13
|
+
this.cfg = cfg;
|
|
14
|
+
this.proc = null;
|
|
15
|
+
this.rl = null;
|
|
16
|
+
this.nextId = 1;
|
|
17
|
+
/** @type {Map<string|number, {resolve: Function, reject: Function, timeout: any}>} */
|
|
18
|
+
this.pending = new Map();
|
|
19
|
+
/** @type {any[]} */
|
|
20
|
+
this.notifications = [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async connect() {
|
|
24
|
+
if (this.proc) return;
|
|
25
|
+
const { command, args = [], env = {}, cwd } = this.cfg;
|
|
26
|
+
const mergedEnv = { ...process.env };
|
|
27
|
+
for (const [k, v] of Object.entries(env || {})) mergedEnv[k] = String(v);
|
|
28
|
+
|
|
29
|
+
this.proc = spawn(command, args, {
|
|
30
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
31
|
+
env: mergedEnv,
|
|
32
|
+
cwd: cwd || process.cwd(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
this.proc.on("exit", (code, signal) => {
|
|
36
|
+
// Reject any pending requests
|
|
37
|
+
for (const [id, p] of this.pending.entries()) {
|
|
38
|
+
clearTimeout(p.timeout);
|
|
39
|
+
p.reject(new Error(`MCP stdio server exited (${signal || code}) while waiting for response id=${id}`));
|
|
40
|
+
}
|
|
41
|
+
this.pending.clear();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
this.proc.stderr.on("data", (buf) => {
|
|
45
|
+
// Avoid noisy output; keep it available for debugging.
|
|
46
|
+
// You can enable by setting DM_DEBUG=1.
|
|
47
|
+
if (process.env.DM_DEBUG === "1") {
|
|
48
|
+
process.stderr.write(String(buf));
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
this.rl = readline.createInterface({ input: this.proc.stdout });
|
|
53
|
+
|
|
54
|
+
this.rl.on("line", (line) => {
|
|
55
|
+
if (!line) return;
|
|
56
|
+
let msg;
|
|
57
|
+
try {
|
|
58
|
+
msg = JSON.parse(line);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (process.env.DM_DEBUG === "1") {
|
|
61
|
+
process.stderr.write(`[dm] Failed to parse JSON from server: ${line}\n`);
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (Object.prototype.hasOwnProperty.call(msg, "id")) {
|
|
66
|
+
const id = msg.id;
|
|
67
|
+
const pending = this.pending.get(id);
|
|
68
|
+
if (!pending) return;
|
|
69
|
+
clearTimeout(pending.timeout);
|
|
70
|
+
this.pending.delete(id);
|
|
71
|
+
pending.resolve(msg);
|
|
72
|
+
} else {
|
|
73
|
+
this.notifications.push(msg);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Send a JSON-RPC request and await its response.
|
|
80
|
+
* @param {string} method
|
|
81
|
+
* @param {any} params
|
|
82
|
+
* @param {{ timeoutMs?: number }} opts
|
|
83
|
+
*/
|
|
84
|
+
async request(method, params, opts = {}) {
|
|
85
|
+
await this.connect();
|
|
86
|
+
const id = this.nextId++;
|
|
87
|
+
const msg = { jsonrpc: "2.0", id, method, params };
|
|
88
|
+
const timeoutMs = typeof opts.timeoutMs === "number" ? opts.timeoutMs : 60_000;
|
|
89
|
+
|
|
90
|
+
const p = new Promise((resolve, reject) => {
|
|
91
|
+
const timeout = setTimeout(() => {
|
|
92
|
+
this.pending.delete(id);
|
|
93
|
+
reject(new Error(`Timeout waiting for MCP stdio response (method=${method}, id=${id})`));
|
|
94
|
+
}, timeoutMs);
|
|
95
|
+
this.pending.set(id, { resolve, reject, timeout });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
this.proc.stdin.write(JSON.stringify(msg) + "\n");
|
|
99
|
+
const resp = await p;
|
|
100
|
+
return resp;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Send a JSON-RPC notification (no response expected).
|
|
105
|
+
* @param {string} method
|
|
106
|
+
* @param {any} params
|
|
107
|
+
*/
|
|
108
|
+
async notify(method, params) {
|
|
109
|
+
await this.connect();
|
|
110
|
+
const msg = { jsonrpc: "2.0", method, params };
|
|
111
|
+
this.proc.stdin.write(JSON.stringify(msg) + "\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async close() {
|
|
115
|
+
if (!this.proc) return;
|
|
116
|
+
|
|
117
|
+
// Close readline interface first
|
|
118
|
+
if (this.rl) {
|
|
119
|
+
try {
|
|
120
|
+
this.rl.close();
|
|
121
|
+
} catch {}
|
|
122
|
+
this.rl = null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Close stdin
|
|
126
|
+
try {
|
|
127
|
+
this.proc.stdin.end();
|
|
128
|
+
} catch {}
|
|
129
|
+
|
|
130
|
+
// Give server time to exit gracefully
|
|
131
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
132
|
+
|
|
133
|
+
// If still running, terminate
|
|
134
|
+
if (this.proc && !this.proc.killed) {
|
|
135
|
+
try {
|
|
136
|
+
this.proc.kill("SIGTERM");
|
|
137
|
+
} catch {}
|
|
138
|
+
|
|
139
|
+
// Wait briefly for SIGTERM
|
|
140
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
141
|
+
|
|
142
|
+
// Force kill if still running
|
|
143
|
+
if (this.proc && !this.proc.killed) {
|
|
144
|
+
try {
|
|
145
|
+
this.proc.kill("SIGKILL");
|
|
146
|
+
} catch {}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
this.proc = null;
|
|
151
|
+
}
|
|
152
|
+
}
|