droid-mode 0.2.0 → 0.2.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/README.md +10 -0
- package/dist/cli.js +5 -3
- package/package.json +1 -1
- package/templates/skills/droid-mode/README.md +24 -14
- package/templates/skills/droid-mode/SKILL.md +16 -16
- package/templates/skills/droid-mode/bin/dm +2 -2
- package/templates/skills/droid-mode/lib/__tests__/run.test.mjs +248 -0
- package/templates/skills/droid-mode/lib/config.mjs +35 -1
- package/templates/skills/droid-mode/lib/daemon.mjs +13 -7
- package/templates/skills/droid-mode/lib/run.mjs +238 -5
- package/templates/skills/droid-mode/lib/tool_index.mjs +28 -4
package/README.md
CHANGED
|
@@ -17,6 +17,16 @@ Access MCP tools on-demand without loading schemas into your context window.
|
|
|
17
17
|
|
|
18
18
|
---
|
|
19
19
|
|
|
20
|
+
## A Personal Note
|
|
21
|
+
|
|
22
|
+
> I spend most of my time in Factory.ai's Droid CLI, and I'm a big believer in MCP - it's the right abstraction for giving AI agents access to external tools. But I kept bumping into an interesting constraint. Every MCP server adds its tool schemas to the context window, which means adding servers costs tokens whether you use those tools or not. I wanted ten servers available; I didn't want to pay for ten servers in every prompt. This seemed like a solvable inefficiency, and I was curious enough to dig in.
|
|
23
|
+
>
|
|
24
|
+
> Droid Mode is what emerged: a Skill that provides daemon-backed MCP access with progressive disclosure. Built on 28 years of Linux system administration experience, it treats MCP servers as infrastructure - persistent connections, lazy schema loading, code-mode execution outside the context window. The result is zero token overhead and 13% better latency than native MCP. Per-project daemon isolation handles governance, and everything is traced for accountability. It's experimental research software, but I've tested it thoroughly and use it daily. I'm sharing it as an early AI explorer who believes we're just scratching the surface of what performant agent architectures can look like.
|
|
25
|
+
>
|
|
26
|
+
> \- [GitMaxd](https://github.com/Gitmaxd)
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
20
30
|
## The Problem
|
|
21
31
|
|
|
22
32
|
When you configure MCP servers in Factory.ai Droid, **all tool schemas get injected into every prompt** ([source](https://www.anthropic.com/engineering/code-execution-with-mcp)). This creates a compounding cost:
|
package/dist/cli.js
CHANGED
|
@@ -83,7 +83,7 @@ var init = defineCommand({
|
|
|
83
83
|
console.error("\u274C Templates directory not found. Package may be corrupted.");
|
|
84
84
|
process.exit(1);
|
|
85
85
|
}
|
|
86
|
-
if (await pathExists(skillDir)) {
|
|
86
|
+
if (await pathExists(skillDir) && !args.force) {
|
|
87
87
|
console.log("\u26A0\uFE0F .factory/skills/droid-mode already exists");
|
|
88
88
|
console.log(" Scaffolding new files only (existing files will NOT be overwritten)\n");
|
|
89
89
|
}
|
|
@@ -100,8 +100,10 @@ var init = defineCommand({
|
|
|
100
100
|
return true;
|
|
101
101
|
}
|
|
102
102
|
if (await pathExists(dest)) {
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
if (!args.force) {
|
|
104
|
+
const relativePath2 = dest.replace(targetDir, ".");
|
|
105
|
+
console.log(`\u23ED\uFE0F Skipped: ${relativePath2} (already exists)`);
|
|
106
|
+
}
|
|
105
107
|
skipped++;
|
|
106
108
|
return false;
|
|
107
109
|
}
|
package/package.json
CHANGED
|
@@ -2,9 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
Progressive Code-Mode MCP integration for Factory Droid. Access MCP tools without loading them into context.
|
|
4
4
|
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
> I spend most of my time in Factory.ai's Droid CLI, and I'm a big believer in MCP - it's the right abstraction for giving AI agents access to external tools. But I kept bumping into an interesting constraint. Every MCP server adds its tool schemas to the context window, which means adding servers costs tokens whether you use those tools or not. I wanted ten servers available; I didn't want to pay for ten servers in every prompt. This seemed like a solvable inefficiency, and I was curious enough to dig in.
|
|
8
|
+
>
|
|
9
|
+
> Droid Mode is what emerged: a Skill that provides daemon-backed MCP access with progressive disclosure. Built on 28 years of Linux system administration experience, it treats MCP servers as infrastructure - persistent connections, lazy schema loading, code-mode execution outside the context window. The result is zero token overhead and 13% better latency than native MCP. Per-project daemon isolation handles governance, and everything is traced for accountability. It's experimental research software, but I've tested it thoroughly and use it daily. I'm sharing it as an early AI explorer who believes we're just scratching the surface of what performant agent architectures can look like.
|
|
10
|
+
>
|
|
11
|
+
> \- [GitMaxd](https://github.com/Gitmaxd)
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
5
15
|
## Installation
|
|
6
16
|
|
|
7
|
-
### Option A
|
|
17
|
+
### Option A - Project skill (recommended for teams)
|
|
8
18
|
|
|
9
19
|
Copy this directory to `<repo>/.factory/skills/droid-mode/`
|
|
10
20
|
|
|
@@ -12,7 +22,7 @@ Copy this directory to `<repo>/.factory/skills/droid-mode/`
|
|
|
12
22
|
./.factory/skills/droid-mode/bin/dm --help
|
|
13
23
|
```
|
|
14
24
|
|
|
15
|
-
### Option B
|
|
25
|
+
### Option B - User skill (personal)
|
|
16
26
|
|
|
17
27
|
Copy to `~/.factory/skills/droid-mode/`
|
|
18
28
|
|
|
@@ -20,7 +30,7 @@ Copy to `~/.factory/skills/droid-mode/`
|
|
|
20
30
|
~/.factory/skills/droid-mode/bin/dm --help
|
|
21
31
|
```
|
|
22
32
|
|
|
23
|
-
### Option C
|
|
33
|
+
### Option C - npx (scaffolding)
|
|
24
34
|
|
|
25
35
|
```bash
|
|
26
36
|
npx droid-mode init
|
|
@@ -76,7 +86,7 @@ For CI or quick experiments:
|
|
|
76
86
|
|
|
77
87
|
## Commands
|
|
78
88
|
|
|
79
|
-
### `dm servers`
|
|
89
|
+
### `dm servers` - List available MCP servers
|
|
80
90
|
|
|
81
91
|
```bash
|
|
82
92
|
dm servers
|
|
@@ -84,7 +94,7 @@ dm servers
|
|
|
84
94
|
|
|
85
95
|
Shows all servers from `mcp.json`. Servers with `disabled: true` show as "disabled (good!)".
|
|
86
96
|
|
|
87
|
-
### `dm doctor`
|
|
97
|
+
### `dm doctor` - Sanity check
|
|
88
98
|
|
|
89
99
|
```bash
|
|
90
100
|
dm doctor --server contextrepo
|
|
@@ -92,7 +102,7 @@ dm doctor --server contextrepo
|
|
|
92
102
|
|
|
93
103
|
Checks: finds `mcp.json`, resolves server, attempts `initialize` + `tools/list`.
|
|
94
104
|
|
|
95
|
-
### `dm index`
|
|
105
|
+
### `dm index` - List tools (compact)
|
|
96
106
|
|
|
97
107
|
```bash
|
|
98
108
|
dm index --server contextrepo
|
|
@@ -101,7 +111,7 @@ dm index --server contextrepo --json
|
|
|
101
111
|
|
|
102
112
|
Outputs a compact table (name + description + required params).
|
|
103
113
|
|
|
104
|
-
### `dm search`
|
|
114
|
+
### `dm search` - Find tools by keyword
|
|
105
115
|
|
|
106
116
|
```bash
|
|
107
117
|
dm search "semantic search over docs" --limit 8 --server contextrepo
|
|
@@ -109,18 +119,18 @@ dm search "semantic search over docs" --limit 8 --server contextrepo
|
|
|
109
119
|
|
|
110
120
|
Searches the cached index.
|
|
111
121
|
|
|
112
|
-
### `dm hydrate`
|
|
122
|
+
### `dm hydrate` - Get full schemas
|
|
113
123
|
|
|
114
124
|
```bash
|
|
115
125
|
dm hydrate search_documents get_document --server contextrepo
|
|
116
126
|
```
|
|
117
127
|
|
|
118
128
|
Writes to `.factory/droid-mode/hydrated/<server>/<timestamp>/`:
|
|
119
|
-
- `tools.json`
|
|
120
|
-
- `types.d.ts`
|
|
121
|
-
- `toolmap.json`
|
|
129
|
+
- `tools.json` - full tool definitions
|
|
130
|
+
- `types.d.ts` - TypeScript types
|
|
131
|
+
- `toolmap.json` - safe JS identifiers → tool names
|
|
122
132
|
|
|
123
|
-
### `dm run`
|
|
133
|
+
### `dm run` - Execute workflow
|
|
124
134
|
|
|
125
135
|
```bash
|
|
126
136
|
dm run --server contextrepo --tools search_documents,get_document --workflow workflow.js
|
|
@@ -196,7 +206,7 @@ dm daemon list # Alias for status --all
|
|
|
196
206
|
dm daemon warm [server] # Pre-warm connections
|
|
197
207
|
```
|
|
198
208
|
|
|
199
|
-
The daemon is optional
|
|
209
|
+
The daemon is optional-without it, everything works as before.
|
|
200
210
|
|
|
201
211
|
---
|
|
202
212
|
|
|
@@ -213,4 +223,4 @@ Optional: Add a **PreToolUse hook** to block direct `mcp__*` calls. See `example
|
|
|
213
223
|
|
|
214
224
|
## Design Philosophy
|
|
215
225
|
|
|
216
|
-
Treat MCP as infrastructure. Treat Skills as capability boundaries. Treat code as a reasoning amplifier
|
|
226
|
+
Treat MCP as infrastructure. Treat Skills as capability boundaries. Treat code as a reasoning amplifier - not as authority.
|
|
@@ -56,9 +56,9 @@ This is the entire point of the skill.
|
|
|
56
56
|
## Idempotency
|
|
57
57
|
|
|
58
58
|
All droid-mode commands are safe to rerun:
|
|
59
|
-
- `dm servers` / `dm index
|
|
60
|
-
- `dm hydrate
|
|
61
|
-
- `dm run
|
|
59
|
+
- `dm servers` / `dm index`: read-only discovery
|
|
60
|
+
- `dm hydrate`: overwrites previous hydration (timestamped)
|
|
61
|
+
- `dm run`: each run creates a new timestamped trace
|
|
62
62
|
|
|
63
63
|
No cleanup required between invocations.
|
|
64
64
|
|
|
@@ -192,19 +192,19 @@ Command should exit 0.
|
|
|
192
192
|
|
|
193
193
|
All outputs written to `.factory/droid-mode/`:
|
|
194
194
|
|
|
195
|
-
- `cache/<server>/tools.json
|
|
196
|
-
- `hydrated/<server>/<ts
|
|
197
|
-
- `runs/<server>/<ts>/run.json
|
|
195
|
+
- `cache/<server>/tools.json`: tool inventory
|
|
196
|
+
- `hydrated/<server>/<ts>/`: schemas + types
|
|
197
|
+
- `runs/<server>/<ts>/run.json`: execution trace
|
|
198
198
|
|
|
199
199
|
All JSON artifacts are machine-parseable for downstream skill chaining. Workflows can read `tools.json` or `run.json` to inform subsequent steps.
|
|
200
200
|
|
|
201
201
|
## Supporting Files
|
|
202
202
|
|
|
203
|
-
- `bin/dm
|
|
204
|
-
- `lib
|
|
205
|
-
- `examples/workflows
|
|
206
|
-
- `examples/hooks
|
|
207
|
-
- `README.md
|
|
203
|
+
- `bin/dm`: CLI entry point
|
|
204
|
+
- `lib/`: implementation modules
|
|
205
|
+
- `examples/workflows/`: sample workflow files
|
|
206
|
+
- `examples/hooks/`: PreToolUse hook examples
|
|
207
|
+
- `README.md`: full documentation
|
|
208
208
|
|
|
209
209
|
## Quick Reference
|
|
210
210
|
|
|
@@ -230,10 +230,10 @@ For personal skill, replace `./.factory/` with `~/.factory/`.
|
|
|
230
230
|
## References
|
|
231
231
|
|
|
232
232
|
For project-specific conventions, see:
|
|
233
|
-
- `AGENTS.md
|
|
234
|
-
- `mcp.json
|
|
235
|
-
- `.factory/skills/*/SKILL.md
|
|
233
|
+
- `AGENTS.md`: project-wide agent guidance (if present)
|
|
234
|
+
- `mcp.json`: MCP server configuration
|
|
235
|
+
- `.factory/skills/*/SKILL.md`: related skills that may chain with droid-mode
|
|
236
236
|
|
|
237
237
|
For droid-mode internals:
|
|
238
|
-
- `README.md
|
|
239
|
-
- `examples
|
|
238
|
+
- `README.md`: full CLI documentation
|
|
239
|
+
- `examples/`: sample workflows and hooks
|
|
@@ -13,7 +13,7 @@ import { setServerAutoWarm, getServerState } from "../lib/state.mjs";
|
|
|
13
13
|
|
|
14
14
|
function usage() {
|
|
15
15
|
return `
|
|
16
|
-
Droid Mode (dm)
|
|
16
|
+
Droid Mode (dm) - Progressive Code-Mode MCP workflows for Factory Droid
|
|
17
17
|
|
|
18
18
|
Usage:
|
|
19
19
|
dm servers [--json]
|
|
@@ -21,7 +21,7 @@ Usage:
|
|
|
21
21
|
dm index --server <name> [--refresh] [--json]
|
|
22
22
|
dm search "<query>" --server <name> [--limit 8] [--refresh] [--json]
|
|
23
23
|
dm hydrate <tool1> <tool2> ... --server <name> [--out <dir>] [--refresh] [--json]
|
|
24
|
-
dm run --workflow <file.js> --tools <a,b,...> --server <name> [--retries 3] [--timeout-ms 300000]
|
|
24
|
+
dm run --workflow <file.js> --tools <a,b,...> --server <name> [--retries 3] [--timeout-ms 300000]
|
|
25
25
|
dm call <tool> --server <name> [--args '{...}'] [--args-file path.json] [--json] [--no-daemon]
|
|
26
26
|
|
|
27
27
|
Daemon (persistent connections for faster calls):
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { stripCommentsAndStrings, validateWorkflowSource } from "../run.mjs";
|
|
2
|
+
|
|
3
|
+
let passed = 0;
|
|
4
|
+
let failed = 0;
|
|
5
|
+
|
|
6
|
+
function test(name, fn) {
|
|
7
|
+
try {
|
|
8
|
+
fn();
|
|
9
|
+
passed++;
|
|
10
|
+
console.log(` ✓ ${name}`);
|
|
11
|
+
} catch (err) {
|
|
12
|
+
failed++;
|
|
13
|
+
console.log(` ✗ ${name}`);
|
|
14
|
+
console.log(` ${err.message}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function assertEqual(actual, expected, msg = "") {
|
|
19
|
+
if (actual !== expected) {
|
|
20
|
+
throw new Error(`${msg}\n Expected: ${JSON.stringify(expected)}\n Got: ${JSON.stringify(actual)}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function assertNoThrow(fn, msg = "") {
|
|
25
|
+
try {
|
|
26
|
+
fn();
|
|
27
|
+
} catch (err) {
|
|
28
|
+
throw new Error(`${msg} - Unexpected error: ${err.message}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function assertThrows(fn, msg = "") {
|
|
33
|
+
try {
|
|
34
|
+
fn();
|
|
35
|
+
throw new Error(`${msg} - Expected to throw but did not`);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
if (err.message.includes("Expected to throw")) throw err;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
console.log("\n=== stripCommentsAndStrings ===\n");
|
|
42
|
+
|
|
43
|
+
test("strips single-line comments", () => {
|
|
44
|
+
const input = 'const x = 1; // process is here';
|
|
45
|
+
const result = stripCommentsAndStrings(input);
|
|
46
|
+
assertEqual(result.includes("process"), false, "Should strip comment");
|
|
47
|
+
assertEqual(result.includes("const x = 1;"), true, "Should keep code");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("strips multi-line comments", () => {
|
|
51
|
+
const input = 'const x = 1; /* process\n is here */ const y = 2;';
|
|
52
|
+
const result = stripCommentsAndStrings(input);
|
|
53
|
+
assertEqual(result.includes("process"), false, "Should strip comment");
|
|
54
|
+
assertEqual(result.includes("const x = 1;"), true, "Should keep code before");
|
|
55
|
+
assertEqual(result.includes("const y = 2;"), true, "Should keep code after");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("strips double-quoted strings", () => {
|
|
59
|
+
const input = 'log("process completed");';
|
|
60
|
+
const result = stripCommentsAndStrings(input);
|
|
61
|
+
assertEqual(result.includes("process"), false, "Should strip string");
|
|
62
|
+
assertEqual(result.includes("log"), true, "Should keep function name");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("strips single-quoted strings", () => {
|
|
66
|
+
const input = "log('process completed');";
|
|
67
|
+
const result = stripCommentsAndStrings(input);
|
|
68
|
+
assertEqual(result.includes("process"), false, "Should strip string");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("strips template literals but keeps expressions", () => {
|
|
72
|
+
const input = 'const x = `prefix ${process.env} suffix`;';
|
|
73
|
+
const result = stripCommentsAndStrings(input);
|
|
74
|
+
assertEqual(result.includes("prefix"), false, "Should strip literal text");
|
|
75
|
+
assertEqual(result.includes("suffix"), false, "Should strip literal text");
|
|
76
|
+
assertEqual(result.includes("process"), true, "Should keep expression code");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("handles escaped quotes", () => {
|
|
80
|
+
const input = 'const x = "hello \\"process\\" world";';
|
|
81
|
+
const result = stripCommentsAndStrings(input);
|
|
82
|
+
assertEqual(result.includes("process"), false, "Should strip escaped content");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("handles regex literals", () => {
|
|
86
|
+
const input = 'const re = /process/g;';
|
|
87
|
+
const result = stripCommentsAndStrings(input);
|
|
88
|
+
assertEqual(result.includes("process"), false, "Should strip regex content");
|
|
89
|
+
assertEqual(result.includes("const re ="), true, "Should keep assignment");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("preserves actual code", () => {
|
|
93
|
+
const input = 'const process = {}; process.start();';
|
|
94
|
+
const result = stripCommentsAndStrings(input);
|
|
95
|
+
assertEqual(result.includes("process"), true, "Should keep actual code");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
console.log("\n=== validateWorkflowSource ===\n");
|
|
99
|
+
|
|
100
|
+
// Cases that should PASS (false positives we're fixing)
|
|
101
|
+
const passCases = [
|
|
102
|
+
['log("process completed")', "string with process"],
|
|
103
|
+
['// process this data', "comment with process"],
|
|
104
|
+
["/* process */", "block comment with process"],
|
|
105
|
+
['const x = "require this"', "string with require"],
|
|
106
|
+
['`processing ${data}`', "template literal with process text"],
|
|
107
|
+
['const re = /process/', "regex with process"],
|
|
108
|
+
['obj.process.start()', "property named process"],
|
|
109
|
+
['log("use fetch API")', "string with fetch"],
|
|
110
|
+
['// import something', "comment with import"],
|
|
111
|
+
['const msg = "eval this"', "string with eval"],
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
for (const [code, desc] of passCases) {
|
|
115
|
+
test(`passes: ${desc}`, () => {
|
|
116
|
+
assertNoThrow(() => validateWorkflowSource(code), desc);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Cases that should FAIL (real violations)
|
|
121
|
+
const failCases = [
|
|
122
|
+
['process.exit()', "actual process access"],
|
|
123
|
+
['require("fs")', "actual require call"],
|
|
124
|
+
['import fs from "fs"', "static import"],
|
|
125
|
+
['import("fs")', "dynamic import"],
|
|
126
|
+
['eval("code")', "eval call"],
|
|
127
|
+
['new Function("code")', "Function constructor"],
|
|
128
|
+
['fetch("url")', "fetch call"],
|
|
129
|
+
['const { exit } = process', "process destructuring"],
|
|
130
|
+
['child_process.spawn()', "child_process access"],
|
|
131
|
+
['WebAssembly.compile()', "WebAssembly access"],
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
for (const [code, desc] of failCases) {
|
|
135
|
+
test(`blocks: ${desc}`, () => {
|
|
136
|
+
assertThrows(() => validateWorkflowSource(code), desc);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Edge cases
|
|
141
|
+
console.log("\n=== Edge Cases ===\n");
|
|
142
|
+
|
|
143
|
+
test("handles nested template expressions", () => {
|
|
144
|
+
const input = 'const x = `outer ${inner ? `nested ${process}` : "no"} end`;';
|
|
145
|
+
const result = stripCommentsAndStrings(input);
|
|
146
|
+
assertEqual(result.includes("process"), true, "Should keep nested expression");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("handles empty strings", () => {
|
|
150
|
+
assertNoThrow(() => validateWorkflowSource('const x = "";'));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("handles complex workflow", () => {
|
|
154
|
+
const workflow = `
|
|
155
|
+
// This workflow processes data
|
|
156
|
+
workflow = async () => {
|
|
157
|
+
const msg = "Processing started";
|
|
158
|
+
log(msg);
|
|
159
|
+
const result = await t.someApiCall({ query: "process" });
|
|
160
|
+
/* Multi-line
|
|
161
|
+
comment about process */
|
|
162
|
+
return { ok: true, data: "process complete" };
|
|
163
|
+
};
|
|
164
|
+
`;
|
|
165
|
+
assertNoThrow(() => validateWorkflowSource(workflow), "Complex workflow should pass");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("blocks process even with tricky formatting", () => {
|
|
169
|
+
assertThrows(() => validateWorkflowSource('process\n.exit()'), "newline between process and method");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("allowUnsafe option bypasses validation", () => {
|
|
173
|
+
assertNoThrow(() => validateWorkflowSource('process.exit()', { allowUnsafe: true }));
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Review-identified edge cases
|
|
177
|
+
console.log("\n=== Review Edge Cases (Critical/Major fixes) ===\n");
|
|
178
|
+
|
|
179
|
+
test("CRITICAL: brace in string inside template expression doesn't break parsing", () => {
|
|
180
|
+
// This was bypassing the scanner because "}" inside the string closed braceDepth
|
|
181
|
+
const code = 'const x = `${"}"}` + process.exit()';
|
|
182
|
+
assertThrows(() => validateWorkflowSource(code), "Should still catch process.exit()");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("CRITICAL: brace bypass attempt with actual dangerous code", () => {
|
|
186
|
+
const code = '`${"}" + process.exit()}`';
|
|
187
|
+
assertThrows(() => validateWorkflowSource(code), "Should catch process in expression");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("MAJOR: strings inside template expressions are stripped", () => {
|
|
191
|
+
// String "process" inside ${} should not cause false positive
|
|
192
|
+
const code = '`${log("process finished")}`';
|
|
193
|
+
assertNoThrow(() => validateWorkflowSource(code), "String in template expr should be stripped");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("MAJOR: comments inside template expressions are stripped", () => {
|
|
197
|
+
const code = '`${/*process*/ 42}`';
|
|
198
|
+
assertNoThrow(() => validateWorkflowSource(code), "Comment in template expr should be stripped");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("MAJOR: regex after return is detected", () => {
|
|
202
|
+
const code = 'return /process/';
|
|
203
|
+
const result = stripCommentsAndStrings(code);
|
|
204
|
+
assertEqual(result.includes("process"), false, "Regex after return should be stripped");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("MAJOR: regex after case is detected", () => {
|
|
208
|
+
const code = 'case /process/: break;';
|
|
209
|
+
const result = stripCommentsAndStrings(code);
|
|
210
|
+
assertEqual(result.includes("process"), false, "Regex after case should be stripped");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("MAJOR: regex after throw is detected", () => {
|
|
214
|
+
const code = 'throw /process/';
|
|
215
|
+
const result = stripCommentsAndStrings(code);
|
|
216
|
+
assertEqual(result.includes("process"), false, "Regex after throw should be stripped");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("MAJOR: regex after typeof is detected", () => {
|
|
220
|
+
const code = 'typeof /process/';
|
|
221
|
+
const result = stripCommentsAndStrings(code);
|
|
222
|
+
assertEqual(result.includes("process"), false, "Regex after typeof should be stripped");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("handles regex with character class containing slash", () => {
|
|
226
|
+
const code = 'const re = /[/]/';
|
|
227
|
+
const result = stripCommentsAndStrings(code);
|
|
228
|
+
assertEqual(result.includes("const re ="), true, "Should parse regex with char class");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("deeply nested template with dangerous code is caught", () => {
|
|
232
|
+
const code = '`outer ${`inner ${process.exit()}`}`';
|
|
233
|
+
assertThrows(() => validateWorkflowSource(code), "Nested template with process should fail");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("safe deeply nested template passes", () => {
|
|
237
|
+
const code = '`outer ${`inner ${"process"}`}`';
|
|
238
|
+
assertNoThrow(() => validateWorkflowSource(code), "Nested template with string should pass");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Summary
|
|
242
|
+
console.log(`\n${"=".repeat(40)}`);
|
|
243
|
+
console.log(`Results: ${passed} passed, ${failed} failed`);
|
|
244
|
+
console.log("=".repeat(40) + "\n");
|
|
245
|
+
|
|
246
|
+
if (failed > 0) {
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
@@ -3,9 +3,29 @@ import path from "node:path";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import { findProjectRoot, readJsonFileIfExists, redactEnvForDisplay } from "./util.mjs";
|
|
5
5
|
|
|
6
|
+
// Module-level cache for mcp.json configs (invalidated by mtime changes)
|
|
7
|
+
let _configCache = null;
|
|
8
|
+
|
|
9
|
+
function getMtime(filePath) {
|
|
10
|
+
if (!filePath) return null;
|
|
11
|
+
try {
|
|
12
|
+
return fs.statSync(filePath).mtimeMs;
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isCacheValid() {
|
|
19
|
+
if (!_configCache) return false;
|
|
20
|
+
const projectMtime = getMtime(_configCache.projectPath);
|
|
21
|
+
const userMtime = getMtime(_configCache.userPath);
|
|
22
|
+
return projectMtime === _configCache.projectMtime && userMtime === _configCache.userMtime;
|
|
23
|
+
}
|
|
24
|
+
|
|
6
25
|
/**
|
|
7
26
|
* Read project and user mcp.json, merge according to Factory's layering rules:
|
|
8
27
|
* - user servers override project servers with the same name
|
|
28
|
+
* - Results are cached and invalidated when file mtimes change
|
|
9
29
|
* @returns {{
|
|
10
30
|
* projectPath: string|null,
|
|
11
31
|
* userPath: string,
|
|
@@ -15,6 +35,10 @@ import { findProjectRoot, readJsonFileIfExists, redactEnvForDisplay } from "./ut
|
|
|
15
35
|
* }}
|
|
16
36
|
*/
|
|
17
37
|
export function loadMcpConfigs() {
|
|
38
|
+
if (isCacheValid()) {
|
|
39
|
+
return _configCache.result;
|
|
40
|
+
}
|
|
41
|
+
|
|
18
42
|
const projectRoot = findProjectRoot();
|
|
19
43
|
const projectPath = projectRoot ? path.join(projectRoot, ".factory", "mcp.json") : null;
|
|
20
44
|
const userPath = path.join(os.homedir(), ".factory", "mcp.json");
|
|
@@ -26,7 +50,17 @@ export function loadMcpConfigs() {
|
|
|
26
50
|
const userServers = (user && user.mcpServers) || {};
|
|
27
51
|
|
|
28
52
|
const merged = { ...projectServers, ...userServers };
|
|
29
|
-
|
|
53
|
+
const result = { projectPath, userPath, project, user, mergedServers: merged };
|
|
54
|
+
|
|
55
|
+
_configCache = {
|
|
56
|
+
projectPath,
|
|
57
|
+
userPath,
|
|
58
|
+
projectMtime: getMtime(projectPath),
|
|
59
|
+
userMtime: getMtime(userPath),
|
|
60
|
+
result,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return result;
|
|
30
64
|
}
|
|
31
65
|
|
|
32
66
|
/**
|
|
@@ -9,6 +9,7 @@ import { getDaemonPaths, findProjectRoot, getRunDir } from "./util.mjs";
|
|
|
9
9
|
|
|
10
10
|
const IDLE_TIMEOUT_MS = parseInt(process.env.DM_DAEMON_IDLE_MS || "600000", 10); // 10 minutes default
|
|
11
11
|
const MAX_CONNECTIONS = 50;
|
|
12
|
+
const WARM_CONCURRENCY = 4; // Max parallel server warm operations
|
|
12
13
|
|
|
13
14
|
// Daemon paths are set via env vars from client, or computed from cwd
|
|
14
15
|
function resolveDaemonConfig() {
|
|
@@ -114,13 +115,18 @@ class ConnectionPool {
|
|
|
114
115
|
const warmed = [];
|
|
115
116
|
const failed = [];
|
|
116
117
|
|
|
117
|
-
// Warm in
|
|
118
|
-
const results =
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
118
|
+
// Warm in batches with concurrency limit
|
|
119
|
+
const results = [];
|
|
120
|
+
for (let i = 0; i < toWarm.length; i += WARM_CONCURRENCY) {
|
|
121
|
+
const batch = toWarm.slice(i, i + WARM_CONCURRENCY);
|
|
122
|
+
const batchResults = await Promise.allSettled(
|
|
123
|
+
batch.map(async (name) => {
|
|
124
|
+
const success = await this.warmServer(name);
|
|
125
|
+
return { name, success };
|
|
126
|
+
})
|
|
127
|
+
);
|
|
128
|
+
results.push(...batchResults);
|
|
129
|
+
}
|
|
124
130
|
|
|
125
131
|
for (const result of results) {
|
|
126
132
|
if (result.status === "fulfilled" && result.value.success) {
|
|
@@ -6,28 +6,261 @@ import { nowIsoCompact, sha256Hex, uniqueSafeToolMap, ensureDir, getDroidModeDat
|
|
|
6
6
|
/**
|
|
7
7
|
* Static, best-effort disallow list for sandboxed workflows.
|
|
8
8
|
* This is not meant to be perfect security; it is intended to prevent accidental escalation.
|
|
9
|
+
* The vm sandbox is the real security boundary - these checks provide clearer error messages.
|
|
9
10
|
*/
|
|
10
11
|
const DEFAULT_BANNED_PATTERNS = [
|
|
11
12
|
/\brequire\s*\(/,
|
|
12
13
|
/\bimport\s+/,
|
|
13
|
-
/\bimport\s*\(/,
|
|
14
|
-
|
|
14
|
+
/\bimport\s*\(/,
|
|
15
|
+
/(?<![.])process\b/, // process but not .process (property access is ok)
|
|
15
16
|
/\bchild_process\b/,
|
|
16
|
-
/node:(?:fs|http|https|net)\b/,
|
|
17
|
-
|
|
17
|
+
/node:(?:fs|http|https|net)\b/,
|
|
18
|
+
/(?<![.])fetch\b/, // fetch but not .fetch (property access is ok)
|
|
18
19
|
/\beval\s*\(/,
|
|
19
20
|
/\bFunction\s*\(/,
|
|
20
21
|
/\bWebAssembly\b/,
|
|
21
22
|
];
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Check if we're in a regex-allowed context by looking at the last meaningful token.
|
|
26
|
+
* Returns true if a `/` at this position would start a regex literal, not division.
|
|
27
|
+
* @param {string} codeSoFar - Code processed so far (with literals stripped)
|
|
28
|
+
* @returns {boolean}
|
|
29
|
+
*/
|
|
30
|
+
function isRegexContext(codeSoFar) {
|
|
31
|
+
const trimmed = codeSoFar.trimEnd();
|
|
32
|
+
if (!trimmed) return true;
|
|
33
|
+
|
|
34
|
+
// Check for keywords that precede regex (return /x/, case /x/:, throw /x/, etc.)
|
|
35
|
+
const keywordMatch = trimmed.match(/\b(return|case|throw|typeof|void|delete|in|instanceof|new|else|do)\s*$/);
|
|
36
|
+
if (keywordMatch) return true;
|
|
37
|
+
|
|
38
|
+
// Check for operators and punctuation that precede regex
|
|
39
|
+
const lastChar = trimmed.slice(-1);
|
|
40
|
+
return ["=", "(", ",", "[", "!", "&", "|", ":", ";", "{", "}", "?", "\n", "+", "-", "*", "%", "<", ">", "~", "^"].includes(lastChar);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Extract a template expression ${...} handling nested braces, strings, and templates correctly.
|
|
45
|
+
* @param {string} source - Full source
|
|
46
|
+
* @param {number} start - Position of the `$` in `${`
|
|
47
|
+
* @returns {{ content: string, endIndex: number }} - Expression content and position after closing `}`
|
|
48
|
+
*/
|
|
49
|
+
function extractTemplateExpression(source, start) {
|
|
50
|
+
let i = start + 2; // Skip `${`
|
|
51
|
+
let content = "";
|
|
52
|
+
let braceDepth = 1;
|
|
53
|
+
const len = source.length;
|
|
54
|
+
|
|
55
|
+
while (i < len && braceDepth > 0) {
|
|
56
|
+
// Handle nested template literals
|
|
57
|
+
if (source[i] === "`") {
|
|
58
|
+
content += "`";
|
|
59
|
+
i++;
|
|
60
|
+
while (i < len && source[i] !== "`") {
|
|
61
|
+
if (source[i] === "$" && source[i + 1] === "{") {
|
|
62
|
+
const nested = extractTemplateExpression(source, i);
|
|
63
|
+
content += "${" + nested.content + "}";
|
|
64
|
+
i = nested.endIndex;
|
|
65
|
+
} else if (source[i] === "\\") {
|
|
66
|
+
content += source.slice(i, i + 2);
|
|
67
|
+
i += 2;
|
|
68
|
+
} else {
|
|
69
|
+
content += source[i];
|
|
70
|
+
i++;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (i < len) {
|
|
74
|
+
content += "`";
|
|
75
|
+
i++;
|
|
76
|
+
}
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Handle strings inside expression (so } inside strings don't count)
|
|
81
|
+
if (source[i] === '"' || source[i] === "'") {
|
|
82
|
+
const quote = source[i];
|
|
83
|
+
content += quote;
|
|
84
|
+
i++;
|
|
85
|
+
while (i < len && source[i] !== quote) {
|
|
86
|
+
if (source[i] === "\\") {
|
|
87
|
+
content += source.slice(i, i + 2);
|
|
88
|
+
i += 2;
|
|
89
|
+
} else {
|
|
90
|
+
content += source[i];
|
|
91
|
+
i++;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (i < len) {
|
|
95
|
+
content += quote;
|
|
96
|
+
i++;
|
|
97
|
+
}
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Handle comments
|
|
102
|
+
if (source[i] === "/" && source[i + 1] === "/") {
|
|
103
|
+
while (i < len && source[i] !== "\n") {
|
|
104
|
+
content += source[i];
|
|
105
|
+
i++;
|
|
106
|
+
}
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (source[i] === "/" && source[i + 1] === "*") {
|
|
110
|
+
content += "/*";
|
|
111
|
+
i += 2;
|
|
112
|
+
while (i < len && !(source[i] === "*" && source[i + 1] === "/")) {
|
|
113
|
+
content += source[i];
|
|
114
|
+
i++;
|
|
115
|
+
}
|
|
116
|
+
if (i < len) {
|
|
117
|
+
content += "*/";
|
|
118
|
+
i += 2;
|
|
119
|
+
}
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Track braces
|
|
124
|
+
if (source[i] === "{") braceDepth++;
|
|
125
|
+
else if (source[i] === "}") braceDepth--;
|
|
126
|
+
|
|
127
|
+
if (braceDepth > 0) {
|
|
128
|
+
content += source[i];
|
|
129
|
+
i++;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { content, endIndex: i + 1 }; // +1 to skip the closing `}`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Strip comments and string literals from source code to avoid false positives
|
|
138
|
+
* when scanning for banned patterns. Replaces content with whitespace to preserve positions.
|
|
139
|
+
* Template expressions ${...} are recursively processed to strip their literals too.
|
|
140
|
+
* @param {string} source
|
|
141
|
+
* @returns {string}
|
|
142
|
+
*/
|
|
143
|
+
export function stripCommentsAndStrings(source) {
|
|
144
|
+
let result = "";
|
|
145
|
+
let i = 0;
|
|
146
|
+
const len = source.length;
|
|
147
|
+
|
|
148
|
+
while (i < len) {
|
|
149
|
+
// Single-line comment
|
|
150
|
+
if (source[i] === "/" && source[i + 1] === "/") {
|
|
151
|
+
const start = i;
|
|
152
|
+
i += 2;
|
|
153
|
+
while (i < len && source[i] !== "\n") i++;
|
|
154
|
+
result += " ".repeat(i - start);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Multi-line comment
|
|
159
|
+
if (source[i] === "/" && source[i + 1] === "*") {
|
|
160
|
+
const start = i;
|
|
161
|
+
i += 2;
|
|
162
|
+
while (i < len && !(source[i] === "*" && source[i + 1] === "/")) i++;
|
|
163
|
+
i += 2;
|
|
164
|
+
result += " ".repeat(i - start);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Template literal (backtick) - preserve ${...} expressions but strip their literals
|
|
169
|
+
if (source[i] === "`") {
|
|
170
|
+
result += " ";
|
|
171
|
+
i++;
|
|
172
|
+
while (i < len && source[i] !== "`") {
|
|
173
|
+
if (source[i] === "$" && source[i + 1] === "{") {
|
|
174
|
+
const expr = extractTemplateExpression(source, i);
|
|
175
|
+
// Recursively strip literals from the expression content
|
|
176
|
+
const strippedExpr = stripCommentsAndStrings(expr.content);
|
|
177
|
+
result += "${" + strippedExpr + "}";
|
|
178
|
+
i = expr.endIndex;
|
|
179
|
+
} else if (source[i] === "\\") {
|
|
180
|
+
result += " ";
|
|
181
|
+
i += 2;
|
|
182
|
+
} else {
|
|
183
|
+
result += " ";
|
|
184
|
+
i++;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (i < len) {
|
|
188
|
+
result += " ";
|
|
189
|
+
i++;
|
|
190
|
+
}
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Double-quoted string
|
|
195
|
+
if (source[i] === '"') {
|
|
196
|
+
const start = i;
|
|
197
|
+
i++;
|
|
198
|
+
while (i < len && source[i] !== '"') {
|
|
199
|
+
if (source[i] === "\\") i++;
|
|
200
|
+
i++;
|
|
201
|
+
}
|
|
202
|
+
i++;
|
|
203
|
+
result += " ".repeat(i - start);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Single-quoted string
|
|
208
|
+
if (source[i] === "'") {
|
|
209
|
+
const start = i;
|
|
210
|
+
i++;
|
|
211
|
+
while (i < len && source[i] !== "'") {
|
|
212
|
+
if (source[i] === "\\") i++;
|
|
213
|
+
i++;
|
|
214
|
+
}
|
|
215
|
+
i++;
|
|
216
|
+
result += " ".repeat(i - start);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Regex literal - detect using context analysis
|
|
221
|
+
if (source[i] === "/" && i > 0 && source[i + 1] !== "/" && source[i + 1] !== "*") {
|
|
222
|
+
if (isRegexContext(result)) {
|
|
223
|
+
const start = i;
|
|
224
|
+
i++;
|
|
225
|
+
while (i < len && source[i] !== "/") {
|
|
226
|
+
if (source[i] === "\\") i++;
|
|
227
|
+
if (source[i] === "[") {
|
|
228
|
+
// Character class - / doesn't end regex here
|
|
229
|
+
i++;
|
|
230
|
+
while (i < len && source[i] !== "]") {
|
|
231
|
+
if (source[i] === "\\") i++;
|
|
232
|
+
i++;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
i++;
|
|
236
|
+
}
|
|
237
|
+
i++;
|
|
238
|
+
// Skip flags
|
|
239
|
+
while (i < len && /[gimsuy]/.test(source[i])) i++;
|
|
240
|
+
result += " ".repeat(i - start);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
result += source[i];
|
|
246
|
+
i++;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
251
|
+
|
|
23
252
|
/**
|
|
24
253
|
* @param {string} source
|
|
25
254
|
* @param {{ allowUnsafe?: boolean }} opts
|
|
26
255
|
*/
|
|
27
256
|
export function validateWorkflowSource(source, opts = {}) {
|
|
28
257
|
if (opts.allowUnsafe) return;
|
|
258
|
+
|
|
259
|
+
// Strip comments and strings to avoid false positives like log("process completed")
|
|
260
|
+
const codeOnly = stripCommentsAndStrings(source);
|
|
261
|
+
|
|
29
262
|
for (const re of DEFAULT_BANNED_PATTERNS) {
|
|
30
|
-
if (re.test(
|
|
263
|
+
if (re.test(codeOnly)) {
|
|
31
264
|
throw new Error(
|
|
32
265
|
`Workflow source contains a disallowed pattern (${re}).\n` +
|
|
33
266
|
`This runner executes workflows in a restricted sandbox; ` +
|
|
@@ -1,19 +1,40 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { getDroidModeDataDir, readJsonFileIfExists, writeJson, serverNameToDirName } from "./util.mjs";
|
|
3
3
|
|
|
4
|
+
const DEFAULT_MAX_PAGES = 100;
|
|
5
|
+
const DEFAULT_MAX_TOOLS = 1000;
|
|
6
|
+
|
|
4
7
|
/**
|
|
5
8
|
* @param {import("./mcp_client.mjs").McpClient} client
|
|
9
|
+
* @param {{ maxPages?: number, maxTools?: number }} opts
|
|
10
|
+
* @returns {Promise<{ tools: any[], truncated: boolean, truncationReason?: string }>}
|
|
6
11
|
*/
|
|
7
|
-
export async function fetchAllTools(client) {
|
|
12
|
+
export async function fetchAllTools(client, opts = {}) {
|
|
13
|
+
const maxPages = opts.maxPages ?? DEFAULT_MAX_PAGES;
|
|
14
|
+
const maxTools = opts.maxTools ?? DEFAULT_MAX_TOOLS;
|
|
8
15
|
const tools = [];
|
|
9
16
|
let cursor = undefined;
|
|
10
|
-
|
|
17
|
+
let truncated = false;
|
|
18
|
+
let truncationReason;
|
|
19
|
+
|
|
20
|
+
for (let i = 0; i < maxPages; i++) {
|
|
11
21
|
const res = await client.listTools({ cursor, timeoutMs: 60_000 });
|
|
12
22
|
if (Array.isArray(res?.tools)) tools.push(...res.tools);
|
|
13
23
|
cursor = res?.nextCursor;
|
|
24
|
+
|
|
25
|
+
if (tools.length >= maxTools) {
|
|
26
|
+
truncated = true;
|
|
27
|
+
truncationReason = `max_tools (${maxTools})`;
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
14
30
|
if (!cursor) break;
|
|
31
|
+
if (i === maxPages - 1 && cursor) {
|
|
32
|
+
truncated = true;
|
|
33
|
+
truncationReason = `max_pages (${maxPages})`;
|
|
34
|
+
}
|
|
15
35
|
}
|
|
16
|
-
|
|
36
|
+
|
|
37
|
+
return { tools, truncated, truncationReason };
|
|
17
38
|
}
|
|
18
39
|
|
|
19
40
|
/**
|
|
@@ -43,7 +64,7 @@ export async function getToolsCached(opts) {
|
|
|
43
64
|
|
|
44
65
|
// Refresh
|
|
45
66
|
await opts.client.init();
|
|
46
|
-
const tools = await fetchAllTools(opts.client);
|
|
67
|
+
const { tools, truncated, truncationReason } = await fetchAllTools(opts.client);
|
|
47
68
|
const payload = {
|
|
48
69
|
fetchedAt: new Date().toISOString(),
|
|
49
70
|
serverName: opts.serverName,
|
|
@@ -51,6 +72,9 @@ export async function getToolsCached(opts) {
|
|
|
51
72
|
protocolVersion: opts.client.negotiatedProtocolVersion,
|
|
52
73
|
serverInfo: opts.client.serverInfo,
|
|
53
74
|
capabilities: opts.client.serverCapabilities,
|
|
75
|
+
truncated,
|
|
76
|
+
truncationReason,
|
|
77
|
+
toolCount: tools.length,
|
|
54
78
|
tools,
|
|
55
79
|
};
|
|
56
80
|
writeJson(cacheFile, payload);
|