toolfunnel 0.7.1 → 0.8.0
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 +14 -4
- package/bin/toolfunnel.js +103 -24
- package/docs/KNOWN_BUGS.md +48 -28
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +1 -1
- package/docs/packaging.md +2 -1
- package/mcp/servers/mock-modern/server.js +1 -1
- package/mcp/servers/mock-upstream/server.js +3 -4
- package/package.json +1 -1
- package/src/auth/config.js +2 -4
- package/src/auth/resource-server.js +6 -1
- package/src/core/atomic-write.js +97 -0
- package/src/core/config-home.js +100 -8
- package/src/core/hook-engine.js +29 -54
- package/src/core/hook-loader.js +3 -61
- package/src/core/hook-runner.js +92 -43
- package/src/core/logger.js +5 -7
- package/src/core/metrics.js +17 -2
- package/src/core/output-cap.js +31 -0
- package/src/core/rename-migration.js +36 -21
- package/src/extend/add-hook.md +4 -1
- package/src/extend/create-tool.md +1 -1
- package/src/extend/package.md +1 -1
- package/src/mcp/aggregator.js +583 -54
- package/src/mcp/expose-store.js +246 -68
- package/src/mcp/http-transport.js +111 -17
- package/src/mcp/mcp-client.js +130 -26
- package/src/mcp/modern.js +9 -6
- package/src/mcp/protocol.js +12 -7
- package/src/mcp/server.js +268 -63
- package/src/mcp/tool-approvals.js +396 -0
- package/src/tools/registry.js +33 -87
- package/src/tools/tool-state.js +1 -1
- package/src/ui/public/app.js +60 -1
- package/src/ui/server.js +142 -16
- package/tools/scripts/tf-accept-change.js +67 -0
- package/tools/scripts/tf-mcp-add.js +28 -15
- package/tools/tools.register.json +16 -5
package/src/core/metrics.js
CHANGED
|
@@ -19,11 +19,19 @@
|
|
|
19
19
|
* CommonJS only.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
// Distinct byTool entries are CAPPED: the key is the tools/call name exactly as the client sent it,
|
|
23
|
+
// including names that never existed, so an unbounded map let a client spraying fresh names grow
|
|
24
|
+
// the gateway's memory for the life of the process. Names seen before the cap keep their own
|
|
25
|
+
// counters; everything after lands in one overflow bucket. Totals stay exact either way.
|
|
26
|
+
const MAX_DISTINCT_TOOLS = 1000;
|
|
27
|
+
const OVERFLOW_KEY = '(other)';
|
|
28
|
+
|
|
22
29
|
const state = {
|
|
23
30
|
startedAt: new Date().toISOString(),
|
|
24
31
|
calls: 0,
|
|
25
32
|
errors: 0,
|
|
26
33
|
byTool: Object.create(null), // name -> { calls, errors }
|
|
34
|
+
distinct: 0, // entries in byTool (the overflow bucket counts as one)
|
|
27
35
|
};
|
|
28
36
|
|
|
29
37
|
/**
|
|
@@ -38,9 +46,13 @@ function record(ev) {
|
|
|
38
46
|
state.calls += 1;
|
|
39
47
|
if (!ok) state.errors += 1;
|
|
40
48
|
let t = state.byTool[tool];
|
|
41
|
-
if (!t) {
|
|
49
|
+
if (!t && state.distinct >= MAX_DISTINCT_TOOLS && tool !== OVERFLOW_KEY) {
|
|
50
|
+
t = state.byTool[OVERFLOW_KEY]; // at the cap: an unseen name counts under the overflow bucket
|
|
51
|
+
if (!t) { t = { calls: 0, errors: 0 }; state.byTool[OVERFLOW_KEY] = t; state.distinct += 1; }
|
|
52
|
+
} else if (!t) {
|
|
42
53
|
t = { calls: 0, errors: 0 };
|
|
43
54
|
state.byTool[tool] = t;
|
|
55
|
+
state.distinct += 1;
|
|
44
56
|
}
|
|
45
57
|
t.calls += 1;
|
|
46
58
|
if (!ok) t.errors += 1;
|
|
@@ -55,7 +67,9 @@ function record(ev) {
|
|
|
55
67
|
*/
|
|
56
68
|
function snapshot() {
|
|
57
69
|
try {
|
|
58
|
-
|
|
70
|
+
// Null-prototype like the source: into a plain {} a key of "__proto__" sets the PROTOTYPE
|
|
71
|
+
// instead of a property, so that tool's counter silently vanished from /health and the UI.
|
|
72
|
+
const byTool = Object.create(null);
|
|
59
73
|
for (const k of Object.keys(state.byTool)) {
|
|
60
74
|
byTool[k] = { calls: state.byTool[k].calls, errors: state.byTool[k].errors };
|
|
61
75
|
}
|
|
@@ -70,6 +84,7 @@ function reset() {
|
|
|
70
84
|
state.calls = 0;
|
|
71
85
|
state.errors = 0;
|
|
72
86
|
state.byTool = Object.create(null);
|
|
87
|
+
state.distinct = 0;
|
|
73
88
|
}
|
|
74
89
|
|
|
75
90
|
module.exports = { record, snapshot, reset };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* output-cap.js - keep a captured stream within a byte cap.
|
|
4
|
+
*
|
|
5
|
+
* A child's output arrives in chunks (64 KiB pipe reads on Windows). Appending a whole chunk and checking the cap
|
|
6
|
+
* afterwards lets the captured text run past the cap by up to one chunk. takeWithinCap returns the part of a chunk
|
|
7
|
+
* that still fits, ending on a whole character so a multi-byte UTF-8 character is never split.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} text the decoded chunk
|
|
12
|
+
* @param {number} used bytes already captured
|
|
13
|
+
* @param {number} max the cap in bytes
|
|
14
|
+
* @returns {string} the leading part of text whose UTF-8 size fits in max - used
|
|
15
|
+
*/
|
|
16
|
+
function takeWithinCap(text, used, max) {
|
|
17
|
+
const room = max - used;
|
|
18
|
+
if (room <= 0) return '';
|
|
19
|
+
if (Buffer.byteLength(text, 'utf8') <= room) return text;
|
|
20
|
+
let bytes = 0;
|
|
21
|
+
let end = 0;
|
|
22
|
+
for (const ch of text) { // by code point, so a surrogate pair is never split either
|
|
23
|
+
const n = Buffer.byteLength(ch, 'utf8');
|
|
24
|
+
if (bytes + n > room) break;
|
|
25
|
+
bytes += n;
|
|
26
|
+
end += ch.length;
|
|
27
|
+
}
|
|
28
|
+
return text.slice(0, end);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { takeWithinCap };
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* rename-migration.js - keep gates and per-tool state attached across a SURFACED-NAME change
|
|
5
|
-
* (0.7.0; the rename-safety.test.js invariants are the acceptance).
|
|
4
|
+
* rename-migration.js - keep gates and per-tool state attached across a SURFACED-NAME change.
|
|
6
5
|
*
|
|
7
6
|
* A tool's SURFACED name is the string the PreToolUse gate matches and the tools.state.json
|
|
8
|
-
* key is written against.
|
|
9
|
-
* local display-name edit)
|
|
10
|
-
*
|
|
7
|
+
* key is written against. When that name changes (an expose `as` edit, an enabled flip, a
|
|
8
|
+
* local display-name edit) and the gate matcher and the state key keep the OLD string, a deny
|
|
9
|
+
* gate stops firing and a disabled tool comes back. The policy here,
|
|
11
10
|
* MATCHER-MIGRATE-ON-RENAME:
|
|
12
11
|
*
|
|
13
12
|
* - A WILDCARD matcher ('', '*', unset) fires for every tool name - it already covers the
|
|
@@ -36,21 +35,9 @@ function escapeRegex(str) {
|
|
|
36
35
|
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
37
36
|
}
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const base = path.basename(targetPath);
|
|
43
|
-
const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`);
|
|
44
|
-
const data = JSON.stringify(obj, null, 2) + '\n';
|
|
45
|
-
const fd = fs.openSync(tmp, 'w');
|
|
46
|
-
try {
|
|
47
|
-
fs.writeSync(fd, data, 0, 'utf8');
|
|
48
|
-
fs.fsyncSync(fd);
|
|
49
|
-
} finally {
|
|
50
|
-
fs.closeSync(fd);
|
|
51
|
-
}
|
|
52
|
-
fs.renameSync(tmp, targetPath);
|
|
53
|
-
}
|
|
38
|
+
// THE atomic writer (temp + fsync + rename, failure-path unlink, stale sweep) - shared by every store.
|
|
39
|
+
// This file's own copy had neither the unlink nor the sweep.
|
|
40
|
+
const { atomicWriteJson, atomicWriteText } = require('./atomic-write');
|
|
54
41
|
|
|
55
42
|
function readJsonIfPresent(p) {
|
|
56
43
|
try {
|
|
@@ -160,4 +147,32 @@ function migrateForStoreFile(storeFilePath, oldName, newName, opts) {
|
|
|
160
147
|
});
|
|
161
148
|
}
|
|
162
149
|
|
|
163
|
-
|
|
150
|
+
/**
|
|
151
|
+
* The sibling config files a migration may write, derived as migrateForStoreFile does (the store's
|
|
152
|
+
* grandparent is the config-home root). Exported so a caller can snapshot them BEFORE a migration and
|
|
153
|
+
* restore them if the write that follows fails - re-running a migration in reverse is not an unwind:
|
|
154
|
+
* it re-validates, and a pattern hook that matches the NEW name refuses it, leaving the manifest
|
|
155
|
+
* naming a surface the store does not (rename-rollback.test.js, case A2).
|
|
156
|
+
*/
|
|
157
|
+
function snapshotForStoreFile(storeFilePath) {
|
|
158
|
+
const root = path.dirname(path.dirname(storeFilePath));
|
|
159
|
+
const files = [path.join(root, 'hooks', 'hooks.manifest.json'), path.join(root, 'tools', 'tools.state.json')];
|
|
160
|
+
const snap = [];
|
|
161
|
+
for (const f of files) {
|
|
162
|
+
let bytes = null;
|
|
163
|
+
try { bytes = fs.readFileSync(f); } catch (_e) { bytes = null; } // absent stays absent
|
|
164
|
+
snap.push({ path: f, bytes });
|
|
165
|
+
}
|
|
166
|
+
return snap;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Put the snapshotted bytes back exactly (absent files are removed again). Best-effort, never throws. */
|
|
170
|
+
function restoreSnapshot(snap) {
|
|
171
|
+
for (const { path: f, bytes } of snap || []) {
|
|
172
|
+
try {
|
|
173
|
+
if (bytes == null) { if (fs.existsSync(f)) fs.unlinkSync(f); } else { atomicWriteText(f, bytes); }
|
|
174
|
+
} catch (_e) { /* best-effort - the caller's own error is the story */ }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = { migrateSurfacedName, migrateForStoreFile, escapeRegex, snapshotForStoreFile, restoreSnapshot };
|
package/src/extend/add-hook.md
CHANGED
|
@@ -88,7 +88,10 @@ Two protocols:
|
|
|
88
88
|
as the result text with `isError: true`.
|
|
89
89
|
- any other code → non-blocking error (stderr captured, loop continues; never throws).
|
|
90
90
|
|
|
91
|
-
**B) JSON (advanced)** - honored **only on exit 0**, when stdout
|
|
91
|
+
**B) JSON (advanced)** - honored **only on exit 0**, when stdout is a JSON object carrying protocol
|
|
92
|
+
keys - or ends with one on its own line after other output (a printed startup line is fine). A
|
|
93
|
+
decision that cannot be read (truncated, followed by stray text) **denies**; a printed object that
|
|
94
|
+
is not a decision (a diagnostic dump, a JSON log line) is ignored:
|
|
92
95
|
```json
|
|
93
96
|
{
|
|
94
97
|
"continue": true,
|
|
@@ -56,7 +56,7 @@ Field-by-field - these names are fixed (registry.js reads exactly these):
|
|
|
56
56
|
|---|---|---|
|
|
57
57
|
| `id` | string | Stable unique key, kebab-case. `toolfunnel_run_tool({ name })` and `registry.run(id, args)` look up by this. Never reuse an existing `id`. |
|
|
58
58
|
| `name` | string | Human/model-facing display name. Shown in the manager UI. |
|
|
59
|
-
| `summary` | string | One line. This is ALL that `toolfunnel_list_tools` returns for the tool - keep it tight and discriminating so the model can pick the right tool from the brief alone. |
|
|
59
|
+
| `summary` | string | One line. This is ALL that `toolfunnel_list_tools` returns for the tool - keep it tight and discriminating so the model can pick the right tool from the brief alone. Name every mode the tool has (`encode` / `decode`, `sha256` / `md5`): the listing's filter searches the name and summary only, so a mode that lives only in `instructions` or the schema is never found. |
|
|
60
60
|
| `category` | string | Grouping label (e.g. `demo`, `memory`, `messaging`, `media`). Used by `toolfunnel_list_tools({ category })` to filter. |
|
|
61
61
|
| `instructions` | string | Full usage doc returned by `toolfunnel_tool_instructions({ name })`. Describe the args object, what comes back, and any safety caveats. This is the long-tail payload - be complete here, terse in `summary`. |
|
|
62
62
|
| `inputSchema` | object (optional) | A JSON Schema for the tool's args. **This is what makes the tool a first-class MCP citizen when promoted hot:** the top-level `tools/list` advertises it VERBATIM, so a connected client knows the exact arg shape without reading `instructions`. Omit it and a hot-promoted tool falls back to a free-form `{ "type": "object" }` (callable, but the client gets no arg hints). Must be an object - the register rejects any other shape at authoring time. |
|
package/src/extend/package.md
CHANGED
|
@@ -9,7 +9,7 @@ self-documented, a package is just a folder that lists its units and carries the
|
|
|
9
9
|
|
|
10
10
|
## 0. The WHOLE-SETUP path - `tf_pack` (start here)
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
The fastest way to ship is not per-unit packaging but the **whole setup**: your entire
|
|
13
13
|
config home (tools + scripts, hot/hidden curation, upstream references, policy hooks, identity) is
|
|
14
14
|
already the product. One call packages it into a deployment-ready artifact under `<home>/dist/`:
|
|
15
15
|
|