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.
@@ -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
+ }