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,424 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
|
|
4
|
+
import { parseArgs, printTable } from "../lib/util.mjs";
|
|
5
|
+
import { loadMcpConfigs, resolveServer, summarizeServerForDisplay, listAllServers } from "../lib/config.mjs";
|
|
6
|
+
import { McpClient } from "../lib/mcp_client.mjs";
|
|
7
|
+
import { getToolsCached, compactToolSummaries } from "../lib/tool_index.mjs";
|
|
8
|
+
import { searchTools } from "../lib/search.mjs";
|
|
9
|
+
import { hydrateTools } from "../lib/hydrate.mjs";
|
|
10
|
+
import { createToolApi, executeWorkflow, writeRunArtifact } from "../lib/run.mjs";
|
|
11
|
+
|
|
12
|
+
function usage() {
|
|
13
|
+
return `
|
|
14
|
+
Droid Mode (dm) — Progressive Code‑Mode MCP workflows for Factory Droid
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
dm servers [--json]
|
|
18
|
+
dm doctor --server <name> [--no-connect] [--json]
|
|
19
|
+
dm index --server <name> [--refresh] [--json]
|
|
20
|
+
dm search "<query>" --server <name> [--limit 8] [--refresh] [--json]
|
|
21
|
+
dm hydrate <tool1> <tool2> ... --server <name> [--out <dir>] [--refresh] [--json]
|
|
22
|
+
dm run --workflow <file.js> --tools <a,b,...> --server <name> [--retries 3] [--timeout-ms 300000] [--json]
|
|
23
|
+
dm call <tool> --server <name> [--args-json '{...}'] [--args-file path.json] [--json]
|
|
24
|
+
|
|
25
|
+
Progressive Disclosure Flow:
|
|
26
|
+
1. dm servers → Discover available MCP servers
|
|
27
|
+
2. dm index --server X → List tools on server X
|
|
28
|
+
3. dm search "..." --server X → Find relevant tools
|
|
29
|
+
4. dm hydrate tool1 --server X → Get full schemas
|
|
30
|
+
5. dm run --server X ... → Execute workflow
|
|
31
|
+
|
|
32
|
+
Manual connection overrides (optional):
|
|
33
|
+
--http-url <url> [--headers-json '{"Authorization":"Bearer ..."}']
|
|
34
|
+
--stdio-command <cmd> [--stdio-args "a,b,c"] [--env-json '{"KEY":"VALUE"}']
|
|
35
|
+
|
|
36
|
+
Notes:
|
|
37
|
+
- Servers with 'disabled: true' in mcp.json are fully available here.
|
|
38
|
+
That flag only prevents Droid from loading tools into context.
|
|
39
|
+
- Tool inventory is cached under .factory/droid-mode/cache/<server>/tools.json
|
|
40
|
+
`.trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fail(msg, code = 1) {
|
|
44
|
+
process.stderr.write(String(msg).trim() + "\n");
|
|
45
|
+
process.exit(code);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve server entry from explicit flags OR from mcp.json.
|
|
50
|
+
* @param {Record<string, any>} flags
|
|
51
|
+
*/
|
|
52
|
+
function resolveServerFromFlags(flags) {
|
|
53
|
+
// HTTP override
|
|
54
|
+
if (flags["http-url"]) {
|
|
55
|
+
const url = String(flags["http-url"]);
|
|
56
|
+
let headers = {};
|
|
57
|
+
if (flags["headers-json"]) {
|
|
58
|
+
try {
|
|
59
|
+
headers = JSON.parse(String(flags["headers-json"]));
|
|
60
|
+
} catch (err) {
|
|
61
|
+
throw new Error(`Invalid --headers-json: ${err.message || err}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { serverName: flags.server ? String(flags.server) : "http", entry: { type: "http", url, headers } };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// stdio override
|
|
68
|
+
if (flags["stdio-command"]) {
|
|
69
|
+
const command = String(flags["stdio-command"]);
|
|
70
|
+
const args = flags["stdio-args"]
|
|
71
|
+
? String(flags["stdio-args"])
|
|
72
|
+
.split(",")
|
|
73
|
+
.map((s) => s.trim())
|
|
74
|
+
.filter(Boolean)
|
|
75
|
+
: [];
|
|
76
|
+
let env = {};
|
|
77
|
+
if (flags["env-json"]) {
|
|
78
|
+
try {
|
|
79
|
+
env = JSON.parse(String(flags["env-json"]));
|
|
80
|
+
} catch (err) {
|
|
81
|
+
throw new Error(`Invalid --env-json: ${err.message || err}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { serverName: flags.server ? String(flags.server) : "stdio", entry: { type: "stdio", command, args, env } };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Default: mcp.json
|
|
88
|
+
const resolved = resolveServer({ server: flags.server });
|
|
89
|
+
return { serverName: resolved.serverName, entry: resolved.entry };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function withClient(serverName, entry, fn) {
|
|
93
|
+
const client = new McpClient({ serverName, entry });
|
|
94
|
+
try {
|
|
95
|
+
return await fn(client);
|
|
96
|
+
} finally {
|
|
97
|
+
try {
|
|
98
|
+
await client.close();
|
|
99
|
+
} catch {}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function cmdServers(args) {
|
|
104
|
+
const servers = listAllServers();
|
|
105
|
+
|
|
106
|
+
if (args.flags.json) {
|
|
107
|
+
process.stdout.write(JSON.stringify({ servers }, null, 2) + "\n");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!servers.length) {
|
|
112
|
+
process.stdout.write("No MCP servers configured.\n");
|
|
113
|
+
process.stdout.write("Add servers via `droid mcp add` or create ~/.factory/mcp.json\n");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
printTable(
|
|
118
|
+
servers.map((s) => ({
|
|
119
|
+
name: s.name,
|
|
120
|
+
type: s.type,
|
|
121
|
+
"droid-context": s.disabledInDroid ? "disabled (good!)" : "enabled",
|
|
122
|
+
})),
|
|
123
|
+
["name", "type", "droid-context"]
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
process.stdout.write(`\n${servers.length} MCP server(s) available to droid-mode.\n`);
|
|
127
|
+
process.stdout.write(`Tip: "disabled" servers won't bloat Droid's context - that's the point!\n`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function cmdDoctor(args) {
|
|
131
|
+
const { projectPath, userPath } = loadMcpConfigs();
|
|
132
|
+
|
|
133
|
+
let serverName, entry;
|
|
134
|
+
try {
|
|
135
|
+
({ serverName, entry } = resolveServerFromFlags(args.flags));
|
|
136
|
+
} catch (err) {
|
|
137
|
+
fail(err.message || err);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const summary = summarizeServerForDisplay(entry);
|
|
141
|
+
|
|
142
|
+
if (args.flags.json) {
|
|
143
|
+
const out = {
|
|
144
|
+
projectMcpPath: projectPath,
|
|
145
|
+
userMcpPath: userPath,
|
|
146
|
+
serverName,
|
|
147
|
+
server: summary,
|
|
148
|
+
};
|
|
149
|
+
if (args.flags["no-connect"]) {
|
|
150
|
+
process.stdout.write(JSON.stringify(out, null, 2) + "\n");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const res = await withClient(serverName, entry, async (client) => {
|
|
154
|
+
await client.init();
|
|
155
|
+
const tools = await client.listTools();
|
|
156
|
+
return {
|
|
157
|
+
toolCount: Array.isArray(tools?.tools) ? tools.tools.length : 0,
|
|
158
|
+
protocolVersion: client.negotiatedProtocolVersion,
|
|
159
|
+
serverInfo: client.serverInfo,
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
out.connection = res;
|
|
163
|
+
process.stdout.write(JSON.stringify(out, null, 2) + "\n");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
process.stdout.write(`Project mcp.json: ${projectPath || "(not found)"}\n`);
|
|
168
|
+
process.stdout.write(`User mcp.json: ${userPath}\n`);
|
|
169
|
+
process.stdout.write(`Server: ${serverName}\n`);
|
|
170
|
+
process.stdout.write(`Config: ${JSON.stringify(summary, null, 2)}\n`);
|
|
171
|
+
|
|
172
|
+
if (!args.flags["no-connect"]) {
|
|
173
|
+
process.stdout.write(`\nConnecting...\n`);
|
|
174
|
+
const res = await withClient(serverName, entry, async (client) => {
|
|
175
|
+
await client.init();
|
|
176
|
+
const list = await client.listTools();
|
|
177
|
+
return {
|
|
178
|
+
toolCount: Array.isArray(list?.tools) ? list.tools.length : 0,
|
|
179
|
+
protocolVersion: client.negotiatedProtocolVersion,
|
|
180
|
+
serverInfo: client.serverInfo,
|
|
181
|
+
};
|
|
182
|
+
});
|
|
183
|
+
process.stdout.write(`Connected. Tools: ${res.toolCount}\n`);
|
|
184
|
+
process.stdout.write(`Protocol: ${res.protocolVersion}\n`);
|
|
185
|
+
if (res.serverInfo) process.stdout.write(`ServerInfo: ${JSON.stringify(res.serverInfo)}\n`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function cmdIndex(args) {
|
|
190
|
+
let serverName, entry;
|
|
191
|
+
try {
|
|
192
|
+
({ serverName, entry } = resolveServerFromFlags(args.flags));
|
|
193
|
+
} catch (err) {
|
|
194
|
+
fail(err.message || err);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const refresh = !!args.flags.refresh;
|
|
198
|
+
|
|
199
|
+
const res = await withClient(serverName, entry, async (client) => {
|
|
200
|
+
const { tools, cacheFile, cached } = await getToolsCached({ serverName, client, refresh });
|
|
201
|
+
const summaries = compactToolSummaries(tools);
|
|
202
|
+
return { summaries, cacheFile, cached };
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
if (args.flags.json) {
|
|
206
|
+
process.stdout.write(
|
|
207
|
+
JSON.stringify({ serverName, cacheFile: res.cacheFile, cached: res.cached, tools: res.summaries }, null, 2) + "\n"
|
|
208
|
+
);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
printTable(
|
|
213
|
+
res.summaries.map((t) => ({
|
|
214
|
+
name: t.name,
|
|
215
|
+
requires: t.requires || "-",
|
|
216
|
+
description: t.description.slice(0, 80)
|
|
217
|
+
})),
|
|
218
|
+
["name", "requires", "description"]
|
|
219
|
+
);
|
|
220
|
+
process.stdout.write(`Cache: ${res.cacheFile} (${res.cached ? "hit" : "refreshed"})\n`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function cmdSearch(args) {
|
|
224
|
+
const query = args._[1];
|
|
225
|
+
if (!query) fail(`Missing query.\n\n${usage()}`);
|
|
226
|
+
|
|
227
|
+
let serverName, entry;
|
|
228
|
+
try {
|
|
229
|
+
({ serverName, entry } = resolveServerFromFlags(args.flags));
|
|
230
|
+
} catch (err) {
|
|
231
|
+
fail(err.message || err);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const refresh = !!args.flags.refresh;
|
|
235
|
+
const limit = args.flags.limit ? Number(args.flags.limit) : 8;
|
|
236
|
+
|
|
237
|
+
const res = await withClient(serverName, entry, async (client) => {
|
|
238
|
+
const { tools } = await getToolsCached({ serverName, client, refresh });
|
|
239
|
+
const matches = searchTools(tools, query, { limit });
|
|
240
|
+
return { matches };
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
if (args.flags.json) {
|
|
244
|
+
process.stdout.write(JSON.stringify({ serverName, query, matches: res.matches }, null, 2) + "\n");
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
printTable(
|
|
249
|
+
res.matches.map((m) => ({ name: m.name, score: m.score.toFixed(2), description: m.description.slice(0, 140) })),
|
|
250
|
+
["name", "score", "description"]
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function cmdHydrate(args) {
|
|
255
|
+
const toolNames = args._.slice(1);
|
|
256
|
+
if (!toolNames.length) fail(`Missing tool names.\n\n${usage()}`);
|
|
257
|
+
|
|
258
|
+
let serverName, entry;
|
|
259
|
+
try {
|
|
260
|
+
({ serverName, entry } = resolveServerFromFlags(args.flags));
|
|
261
|
+
} catch (err) {
|
|
262
|
+
fail(err.message || err);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const refresh = !!args.flags.refresh;
|
|
266
|
+
const outDir = args.flags.out ? String(args.flags.out) : undefined;
|
|
267
|
+
|
|
268
|
+
const res = await withClient(serverName, entry, async (client) => {
|
|
269
|
+
const { tools } = await getToolsCached({ serverName, client, refresh });
|
|
270
|
+
// const { outDir, selectedCount, toolmap } = hydrateTools({ serverName, tools, toolNames, outDir });
|
|
271
|
+
// return { outDir, selectedCount, toolmap };
|
|
272
|
+
return hydrateTools({ serverName, tools, toolNames, outDir });
|
|
273
|
+
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
if (args.flags.json) {
|
|
277
|
+
process.stdout.write(
|
|
278
|
+
JSON.stringify({ serverName, outDir: res.outDir, selectedCount: res.selectedCount, toolmap: res.toolmap }, null, 2) +
|
|
279
|
+
"\n"
|
|
280
|
+
);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
process.stdout.write(`Hydrated ${res.selectedCount} tools to:\n ${res.outDir}\n`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function cmdRun(args) {
|
|
288
|
+
const workflowPath = args.flags.workflow || args.flags.w;
|
|
289
|
+
const toolsCsv = args.flags.tools || args.flags.t;
|
|
290
|
+
if (!workflowPath || !toolsCsv) fail(`Missing --workflow and/or --tools.\n\n${usage()}`);
|
|
291
|
+
|
|
292
|
+
const toolNames = String(toolsCsv)
|
|
293
|
+
.split(",")
|
|
294
|
+
.map((s) => s.trim())
|
|
295
|
+
.filter(Boolean);
|
|
296
|
+
|
|
297
|
+
if (!fs.existsSync(workflowPath)) fail(`Workflow file not found: ${workflowPath}`);
|
|
298
|
+
|
|
299
|
+
let serverName, entry;
|
|
300
|
+
try {
|
|
301
|
+
({ serverName, entry } = resolveServerFromFlags(args.flags));
|
|
302
|
+
} catch (err) {
|
|
303
|
+
fail(err.message || err);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const refresh = !!args.flags.refresh;
|
|
307
|
+
const timeoutMs = args.flags["timeout-ms"] ? Number(args.flags["timeout-ms"]) : undefined;
|
|
308
|
+
const allowUnsafe = !!args.flags["allow-unsafe"];
|
|
309
|
+
const maxRetries = args.flags.retries ? Number(args.flags.retries) : 3;
|
|
310
|
+
|
|
311
|
+
const trace = [];
|
|
312
|
+
|
|
313
|
+
const output = await withClient(serverName, entry, async (client) => {
|
|
314
|
+
// Ensure tools exist (via cache)
|
|
315
|
+
const { tools } = await getToolsCached({ serverName, client, refresh });
|
|
316
|
+
const available = new Set((tools || []).map((t) => t?.name));
|
|
317
|
+
const missing = toolNames.filter((n) => !available.has(n));
|
|
318
|
+
if (missing.length) {
|
|
319
|
+
throw new Error(`Unknown tool(s) for server "${serverName}": ${missing.join(", ")}`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const toolApi = createToolApi({ client, toolNames, trace, maxRetries });
|
|
323
|
+
const { result, logs } = await executeWorkflow({
|
|
324
|
+
workflowPath,
|
|
325
|
+
toolApi,
|
|
326
|
+
timeoutMs,
|
|
327
|
+
allowUnsafe,
|
|
328
|
+
});
|
|
329
|
+
return { result, logs, toolmap: toolApi.toolmap };
|
|
330
|
+
}).catch((err) => {
|
|
331
|
+
// Persist a failure run artifact too.
|
|
332
|
+
const failOut = {
|
|
333
|
+
ok: false,
|
|
334
|
+
error: String(err?.message || err),
|
|
335
|
+
};
|
|
336
|
+
const artifact = writeRunArtifact({
|
|
337
|
+
serverName,
|
|
338
|
+
workflowPath,
|
|
339
|
+
tools: toolNames,
|
|
340
|
+
output: failOut,
|
|
341
|
+
trace,
|
|
342
|
+
});
|
|
343
|
+
if (args.flags.json) {
|
|
344
|
+
process.stdout.write(JSON.stringify({ ...failOut, artifact }, null, 2) + "\n");
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
fail(`${failOut.error}\nArtifact: ${artifact.file}`);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const okOut = { ok: true, result: output.result, logs: output.logs, toolmap: output.toolmap };
|
|
351
|
+
const artifact = writeRunArtifact({
|
|
352
|
+
serverName,
|
|
353
|
+
workflowPath,
|
|
354
|
+
tools: toolNames,
|
|
355
|
+
output: okOut,
|
|
356
|
+
trace,
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
process.stdout.write(JSON.stringify({ ...okOut, artifact }, null, 2) + "\n");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function cmdCall(args) {
|
|
363
|
+
const tool = args._[1];
|
|
364
|
+
if (!tool) fail(`Missing tool name.\n\n${usage()}`);
|
|
365
|
+
|
|
366
|
+
let serverName, entry;
|
|
367
|
+
try {
|
|
368
|
+
({ serverName, entry } = resolveServerFromFlags(args.flags));
|
|
369
|
+
} catch (err) {
|
|
370
|
+
fail(err.message || err);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
let argObj = {};
|
|
374
|
+
if (args.flags["args-json"]) {
|
|
375
|
+
try {
|
|
376
|
+
argObj = JSON.parse(String(args.flags["args-json"]));
|
|
377
|
+
} catch (err) {
|
|
378
|
+
fail(`Invalid --args-json: ${err.message || err}`);
|
|
379
|
+
}
|
|
380
|
+
} else if (args.flags["args-file"]) {
|
|
381
|
+
const p = String(args.flags["args-file"]);
|
|
382
|
+
try {
|
|
383
|
+
argObj = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
384
|
+
} catch (err) {
|
|
385
|
+
fail(`Invalid --args-file: ${err.message || err}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const out = await withClient(serverName, entry, async (client) => {
|
|
390
|
+
const res = await client.callTool({ name: tool, arguments: argObj, timeoutMs: 60_000 });
|
|
391
|
+
return res;
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
if (args.flags.json) {
|
|
395
|
+
process.stdout.write(JSON.stringify(out, null, 2) + "\n");
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
process.stdout.write(JSON.stringify(out.structured ?? out.text ?? out.raw, null, 2) + "\n");
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function main() {
|
|
402
|
+
const args = parseArgs(process.argv.slice(2));
|
|
403
|
+
const cmd = args._[0];
|
|
404
|
+
|
|
405
|
+
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
|
|
406
|
+
process.stdout.write(usage() + "\n");
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
try {
|
|
411
|
+
if (cmd === "servers") return await cmdServers(args);
|
|
412
|
+
if (cmd === "doctor") return await cmdDoctor(args);
|
|
413
|
+
if (cmd === "index") return await cmdIndex(args);
|
|
414
|
+
if (cmd === "search") return await cmdSearch(args);
|
|
415
|
+
if (cmd === "hydrate") return await cmdHydrate(args);
|
|
416
|
+
if (cmd === "run") return await cmdRun(args);
|
|
417
|
+
if (cmd === "call") return await cmdCall(args);
|
|
418
|
+
fail(`Unknown command: ${cmd}\n\n${usage()}`);
|
|
419
|
+
} catch (err) {
|
|
420
|
+
fail(err.message || err);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
main();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Hooks (optional)
|
|
2
|
+
|
|
3
|
+
Droid Mode is intentionally implemented as a **Skill-first** integration.
|
|
4
|
+
|
|
5
|
+
If you want to enforce a policy such as:
|
|
6
|
+
|
|
7
|
+
- “Do not call raw `mcp__*` tools directly; use `droid-mode` scripts instead”
|
|
8
|
+
|
|
9
|
+
…you can add a **PreToolUse** hook and gate decisions there.
|
|
10
|
+
|
|
11
|
+
Implementation details (hook payload format and the expected allow/deny response) can vary by Droid version, so treat any hook you write as **policy code** that should be validated in your environment.
|
|
12
|
+
|
|
13
|
+
Reference: Factory Hooks documentation.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Example Droid Mode workflow
|
|
2
|
+
// This file is executed inside the droid-mode sandbox.
|
|
3
|
+
// You have access to:
|
|
4
|
+
// - t.<safeToolName>(args): calls hydrated tools via MCP
|
|
5
|
+
// - tools.call(toolName, args): call by raw tool name
|
|
6
|
+
// - log(...args): writes to run logs
|
|
7
|
+
//
|
|
8
|
+
// Requirement: set `workflow = async () => { ... }`
|
|
9
|
+
|
|
10
|
+
workflow = async () => {
|
|
11
|
+
log("Starting example workflow...");
|
|
12
|
+
|
|
13
|
+
// Replace tool names below with tools that exist on your MCP server.
|
|
14
|
+
// Example pattern (for a server that has search + get tools):
|
|
15
|
+
//
|
|
16
|
+
// const hits = await t.searchDocuments({ query: "Droid Mode", limit: 3 });
|
|
17
|
+
// const docs = await Promise.all(hits.results.map(h => t.getDocument({ id: h.id })));
|
|
18
|
+
// return { hitCount: hits.results.length, ids: docs.map(d => d.id) };
|
|
19
|
+
|
|
20
|
+
return { ok: true, note: "Edit examples/workflows/workflow.example.js to match your MCP tools." };
|
|
21
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { findProjectRoot, readJsonFileIfExists, redactEnvForDisplay } from "./util.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Read project and user mcp.json, merge according to Factory's layering rules:
|
|
8
|
+
* - user servers override project servers with the same name
|
|
9
|
+
* @returns {{
|
|
10
|
+
* projectPath: string|null,
|
|
11
|
+
* userPath: string,
|
|
12
|
+
* project: any|null,
|
|
13
|
+
* user: any|null,
|
|
14
|
+
* mergedServers: Record<string, any>
|
|
15
|
+
* }}
|
|
16
|
+
*/
|
|
17
|
+
export function loadMcpConfigs() {
|
|
18
|
+
const projectRoot = findProjectRoot();
|
|
19
|
+
const projectPath = projectRoot ? path.join(projectRoot, ".factory", "mcp.json") : null;
|
|
20
|
+
const userPath = path.join(os.homedir(), ".factory", "mcp.json");
|
|
21
|
+
|
|
22
|
+
const project = projectPath ? readJsonFileIfExists(projectPath) : null;
|
|
23
|
+
const user = readJsonFileIfExists(userPath);
|
|
24
|
+
|
|
25
|
+
const projectServers = (project && project.mcpServers) || {};
|
|
26
|
+
const userServers = (user && user.mcpServers) || {};
|
|
27
|
+
|
|
28
|
+
const merged = { ...projectServers, ...userServers };
|
|
29
|
+
return { projectPath, userPath, project, user, mergedServers: merged };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* List all MCP servers available to droid-mode.
|
|
34
|
+
* Ignores 'disabled' flag - that's for Droid context injection, not skill access.
|
|
35
|
+
* @returns {Array<{name: string, type: string, disabledInDroid: boolean}>}
|
|
36
|
+
*/
|
|
37
|
+
export function listAllServers() {
|
|
38
|
+
const { mergedServers } = loadMcpConfigs();
|
|
39
|
+
return Object.entries(mergedServers || {}).map(([name, entry]) => ({
|
|
40
|
+
name,
|
|
41
|
+
type: entry?.type || "unknown",
|
|
42
|
+
disabledInDroid: !!entry?.disabled,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve an MCP server entry by name.
|
|
48
|
+
* Note: We ignore the 'disabled' flag - it controls Droid context injection, not skill access.
|
|
49
|
+
* @param {string} name
|
|
50
|
+
*/
|
|
51
|
+
export function getServerByName(name) {
|
|
52
|
+
const { mergedServers } = loadMcpConfigs();
|
|
53
|
+
const entry = mergedServers[name];
|
|
54
|
+
if (!entry) return null;
|
|
55
|
+
return entry;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Choose a default server name given mergedServers.
|
|
60
|
+
* Only auto-selects if there's exactly one server. Otherwise requires explicit --server.
|
|
61
|
+
* Note: We ignore 'disabled' flag - it controls Droid context injection, not skill access.
|
|
62
|
+
* @param {Record<string, any>} mergedServers
|
|
63
|
+
*/
|
|
64
|
+
export function chooseDefaultServerName(mergedServers) {
|
|
65
|
+
const names = Object.keys(mergedServers || {});
|
|
66
|
+
if (!names.length) return null;
|
|
67
|
+
if (names.length === 1) return names[0];
|
|
68
|
+
return null; // Multiple servers: require explicit --server
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve which server to use from CLI flags and mcp.json.
|
|
73
|
+
* Note: We ignore the 'disabled' flag - it controls Droid context injection, not skill access.
|
|
74
|
+
* @param {{ server?: string }} opts
|
|
75
|
+
* @returns {{ serverName: string, entry: any, allNames: string[] }}
|
|
76
|
+
*/
|
|
77
|
+
export function resolveServer(opts = {}) {
|
|
78
|
+
const { mergedServers } = loadMcpConfigs();
|
|
79
|
+
const allNames = Object.keys(mergedServers || {});
|
|
80
|
+
|
|
81
|
+
const name = opts.server || chooseDefaultServerName(mergedServers);
|
|
82
|
+
|
|
83
|
+
if (!name) {
|
|
84
|
+
if (!allNames.length) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`No MCP servers configured.\n` +
|
|
87
|
+
`Add servers via \`droid mcp add\` or create ~/.factory/mcp.json`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Multiple MCP servers available. Use --server <name> to select one.\n` +
|
|
92
|
+
`Run \`dm servers\` to see available servers.`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const entry = mergedServers[name];
|
|
97
|
+
if (!entry) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`MCP server "${name}" not found.\n` +
|
|
100
|
+
`Run \`dm servers\` to see available servers.`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Note: We intentionally ignore entry.disabled here.
|
|
105
|
+
// That flag controls Droid context injection, not droid-mode skill access.
|
|
106
|
+
|
|
107
|
+
return { serverName: name, entry, allNames };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Return a safe-for-display summary of a server entry (redacts env/header secrets).
|
|
112
|
+
* @param {any} entry
|
|
113
|
+
*/
|
|
114
|
+
export function summarizeServerForDisplay(entry) {
|
|
115
|
+
const type = entry?.type;
|
|
116
|
+
if (type === "http") {
|
|
117
|
+
const headers = entry?.headers || {};
|
|
118
|
+
const redacted = {};
|
|
119
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
120
|
+
const isSecret = /(authorization|token|key|secret|password)/i.test(k);
|
|
121
|
+
redacted[k] = isSecret ? "***" : String(v);
|
|
122
|
+
}
|
|
123
|
+
return { type, url: entry?.url, headers: redacted, disabled: !!entry?.disabled };
|
|
124
|
+
}
|
|
125
|
+
if (type === "stdio") {
|
|
126
|
+
return {
|
|
127
|
+
type,
|
|
128
|
+
command: entry?.command,
|
|
129
|
+
args: entry?.args || [],
|
|
130
|
+
env: redactEnvForDisplay(entry?.env || {}),
|
|
131
|
+
disabled: !!entry?.disabled,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return { type: type || "unknown", disabled: !!entry?.disabled };
|
|
135
|
+
}
|