@amalgm/tools 0.1.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/PURPOSE.md +87 -0
- package/README.md +62 -0
- package/dist/api-driver.d.ts +3 -0
- package/dist/api-driver.js +92 -0
- package/dist/artifact-files.d.ts +28 -0
- package/dist/artifact-files.js +115 -0
- package/dist/artifacts.d.ts +66 -0
- package/dist/artifacts.js +112 -0
- package/dist/bin/mcp.d.ts +2 -0
- package/dist/bin/mcp.js +13 -0
- package/dist/bin/tools.d.ts +2 -0
- package/dist/bin/tools.js +3 -0
- package/dist/cli-driver.d.ts +3 -0
- package/dist/cli-driver.js +52 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +123 -0
- package/dist/definition.d.ts +7 -0
- package/dist/definition.js +170 -0
- package/dist/ids.d.ts +7 -0
- package/dist/ids.js +38 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +7 -0
- package/dist/input.d.ts +4 -0
- package/dist/input.js +43 -0
- package/dist/mcp-server.d.ts +18 -0
- package/dist/mcp-server.js +61 -0
- package/dist/mcp.d.ts +9 -0
- package/dist/mcp.js +95 -0
- package/dist/module.d.ts +3 -0
- package/dist/module.js +22 -0
- package/dist/process.d.ts +22 -0
- package/dist/process.js +46 -0
- package/dist/query.d.ts +23 -0
- package/dist/query.js +36 -0
- package/dist/results.d.ts +6 -0
- package/dist/results.js +27 -0
- package/dist/schema.d.ts +3 -0
- package/dist/schema.js +26 -0
- package/dist/secrets.d.ts +3 -0
- package/dist/secrets.js +13 -0
- package/dist/selection.d.ts +10 -0
- package/dist/selection.js +18 -0
- package/dist/store.d.ts +17 -0
- package/dist/store.js +96 -0
- package/dist/toolbox.d.ts +46 -0
- package/dist/toolbox.js +204 -0
- package/dist/types.d.ts +172 -0
- package/dist/types.js +1 -0
- package/dist/updates.d.ts +7 -0
- package/dist/updates.js +57 -0
- package/docs/ENGINE_INTEGRATION.md +57 -0
- package/package.json +57 -0
package/PURPOSE.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Amalgm Tools
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Amalgm Tools lets a user define, keep, select, and run agent capabilities
|
|
6
|
+
without installing Amalgm Engine. It serves people building agents,
|
|
7
|
+
automations, and applications that need one small local source of truth for
|
|
8
|
+
CLI, HTTP API, and MCP tools. When the product is healthy, every callable
|
|
9
|
+
action has one stable identity and one owning tool, a selected loadout exposes
|
|
10
|
+
only its granted capabilities, and the SDK, CLI, and MCP surfaces all observe
|
|
11
|
+
and execute the same catalog.
|
|
12
|
+
|
|
13
|
+
Amalgm Engine is the composition root for the complete Amalgm runtime. This
|
|
14
|
+
repository owns the Tools product and its Toolbox. Engine may provide
|
|
15
|
+
first-party actions, credentials, agent loadouts, state events, and UI
|
|
16
|
+
composition through public adapters, but it does not maintain a second tool
|
|
17
|
+
registry or execution implementation.
|
|
18
|
+
|
|
19
|
+
## Primitives
|
|
20
|
+
|
|
21
|
+
- A **tool** is a stable provider or connector identity and its execution
|
|
22
|
+
source.
|
|
23
|
+
- An **action** is one model-callable capability owned by exactly one tool.
|
|
24
|
+
- A **Toolbox** is the sole catalog and mutation authority for one local state
|
|
25
|
+
directory.
|
|
26
|
+
- A **loadout** is a set of tool or action ids used to derive a read-only view
|
|
27
|
+
of the Toolbox.
|
|
28
|
+
- A **driver** executes actions for one tool type.
|
|
29
|
+
- A **system tool** is an embedder-owned definition projected into the catalog;
|
|
30
|
+
it is not user state.
|
|
31
|
+
|
|
32
|
+
## Axioms
|
|
33
|
+
|
|
34
|
+
1. Every action belongs to exactly one tool and has the canonical id
|
|
35
|
+
`<tool-id>.<action-name>`.
|
|
36
|
+
2. Tool ids are stable, globally unique within one Toolbox, and never change
|
|
37
|
+
as a side effect of renaming display text.
|
|
38
|
+
3. A tool's portable definition is its artifact file: one file per tool,
|
|
39
|
+
the tool and all of its actions together, never a second tool in the
|
|
40
|
+
same file. The Toolbox database is a derived index maintained for
|
|
41
|
+
runtime and realtime sync; SDK, CLI, and MCP adapters never keep
|
|
42
|
+
parallel registries.
|
|
43
|
+
4. Applying a tool definition is atomic and authoritative for that tool: its
|
|
44
|
+
supplied action set replaces the previous set completely.
|
|
45
|
+
5. Deleting a tool deletes all of its actions in the same transaction; an
|
|
46
|
+
orphan action can never be observed.
|
|
47
|
+
6. Disabled tools expose and execute no actions. Disabled actions are likewise
|
|
48
|
+
absent from every runnable surface.
|
|
49
|
+
7. A missing loadout means all enabled actions; an explicit empty loadout
|
|
50
|
+
means none. Selection never grants a capability not present in the catalog.
|
|
51
|
+
8. Tool and action selection use ids only. Display names, ordering, and MCP
|
|
52
|
+
names never confer authority.
|
|
53
|
+
9. MCP action names are a deterministic, collision-checked projection of
|
|
54
|
+
canonical action ids.
|
|
55
|
+
10. Drivers declare which normalized tools they can execute, receive validated
|
|
56
|
+
object input, and never read or mutate Toolbox state.
|
|
57
|
+
11. CLI execution never invokes a shell. API execution never changes origin
|
|
58
|
+
from the registered base URL, including through redirects.
|
|
59
|
+
12. Credentials are supplied at execution time by the embedder. Secret values
|
|
60
|
+
are never stored in a tool or action record.
|
|
61
|
+
13. Output is bounded before it enters an agent context, and cancellation or a
|
|
62
|
+
timeout terminates the underlying execution.
|
|
63
|
+
14. MCP tools are connection definitions. Their actions are advertised only
|
|
64
|
+
when the composing MCP host supplies a driver that supports that exact
|
|
65
|
+
connection; Toolbox does not invent a second MCP session owner.
|
|
66
|
+
15. System tools are immutable projections supplied by the embedder. Removing
|
|
67
|
+
an embedder projection removes it from the catalog without mutating user
|
|
68
|
+
state.
|
|
69
|
+
16. The SDK owns behavior. CLI, MCP, HTTP, UI, and Engine integrations only
|
|
70
|
+
adapt the public Toolbox service.
|
|
71
|
+
17. TypeScript under `src/` is the product source of truth. JavaScript and
|
|
72
|
+
declarations under `dist/` are generated together and are never edited by
|
|
73
|
+
hand.
|
|
74
|
+
18. Tools may store opaque references owned by another product, but neither
|
|
75
|
+
Tools nor a Core adapter silently rewrites that product. A cross-product
|
|
76
|
+
workflow has one explicit product owner.
|
|
77
|
+
|
|
78
|
+
## Predictable behavior
|
|
79
|
+
|
|
80
|
+
Applying the same definition twice produces the same catalog. Applying a
|
|
81
|
+
changed action set replaces the old set atomically. Selecting a whole tool
|
|
82
|
+
grants all of its enabled actions; selecting one action grants only that
|
|
83
|
+
action. Disabling or deleting a tool immediately removes all of its actions
|
|
84
|
+
from list and call surfaces. CLI arguments are passed directly to a process,
|
|
85
|
+
API paths stay under their registered origin, and MCP calls cross only the
|
|
86
|
+
driver boundary supplied by the composing host. Engine and standalone users
|
|
87
|
+
therefore get the same identities, validation, selection, and execution rules.
|
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Amalgm Tools
|
|
2
|
+
|
|
3
|
+
`@amalgm/tools` is the standalone Tools product from Amalgm Engine. It owns
|
|
4
|
+
tool definitions, the local Toolbox catalog, loadout selection, execution,
|
|
5
|
+
and MCP projection. It can be used without the rest of Amalgm.
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import { defineTool, Toolbox } from '@amalgm/tools';
|
|
9
|
+
|
|
10
|
+
const toolbox = new Toolbox({ stateDir: './.tools' });
|
|
11
|
+
await toolbox.apply(defineTool({
|
|
12
|
+
id: 'hello',
|
|
13
|
+
name: 'Hello',
|
|
14
|
+
source: { type: 'cli', command: 'node', inputMode: 'json-stdin', outputMode: 'json' },
|
|
15
|
+
actions: [{
|
|
16
|
+
name: 'say',
|
|
17
|
+
target: { args: ['./hello.mjs'] },
|
|
18
|
+
inputSchema: {
|
|
19
|
+
type: 'object',
|
|
20
|
+
required: ['name'],
|
|
21
|
+
properties: { name: { type: 'string' } },
|
|
22
|
+
additionalProperties: false,
|
|
23
|
+
},
|
|
24
|
+
}],
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const result = await toolbox.call('hello.say', { name: 'Ada' });
|
|
28
|
+
toolbox.close();
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## State directory
|
|
32
|
+
|
|
33
|
+
Given no options, the Toolbox resolves its state directory through the
|
|
34
|
+
one product state-dir law in `@amalgm/core`: `AMALGM_TOOLS_DIR` is used
|
|
35
|
+
verbatim when set, then `AMALGM_DIR/toolbox`, then the scoped layout
|
|
36
|
+
`~/.amalgm/users/<scope>/toolbox`. Passing `stateDir` or `databaseFile`
|
|
37
|
+
bypasses resolution entirely.
|
|
38
|
+
|
|
39
|
+
## CLI
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
amalgm-tools apply ./tool.mjs
|
|
43
|
+
amalgm-tools list
|
|
44
|
+
amalgm-tools run hello.say --input '{"name":"Ada"}'
|
|
45
|
+
amalgm-tools disable hello.say
|
|
46
|
+
amalgm-tools connections
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`amalgm-tools-mcp` serves Toolbox management and enabled action tools over
|
|
50
|
+
newline-delimited MCP stdio.
|
|
51
|
+
|
|
52
|
+
## Tool types
|
|
53
|
+
|
|
54
|
+
- `cli`: executed directly with `spawn`; a shell is never involved.
|
|
55
|
+
- `api`: same-origin HTTP actions with redirects refused.
|
|
56
|
+
- `mcp`: a connection plus action projection. The composing MCP host supplies
|
|
57
|
+
the one MCP driver that owns discovery, sessions, and calls.
|
|
58
|
+
|
|
59
|
+
Secret values are never part of definitions. `secretEnv` and `secretHeaders`
|
|
60
|
+
contain opaque references resolved through `CallOptions.resolveSecret`.
|
|
61
|
+
See [PURPOSE.md](./PURPOSE.md) for the complete contract and
|
|
62
|
+
[ENGINE_INTEGRATION.md](./docs/ENGINE_INTEGRATION.md) for composition.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { boundedResult, errorResult, structuredResult, textResult } from './results.js';
|
|
2
|
+
import { resolveReferences } from './secrets.js';
|
|
3
|
+
function fillPath(path, input) {
|
|
4
|
+
const params = (input.pathParams || input);
|
|
5
|
+
return path.replace(/\{([^}]+)\}/g, (_, key) => {
|
|
6
|
+
if (params[key] === undefined)
|
|
7
|
+
throw new Error(`Missing API path parameter: ${key}`);
|
|
8
|
+
return encodeURIComponent(String(params[key]));
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
async function readBody(response, maximum) {
|
|
12
|
+
if (!response.body)
|
|
13
|
+
return { text: '', truncated: false };
|
|
14
|
+
const reader = response.body.getReader();
|
|
15
|
+
const chunks = [];
|
|
16
|
+
let size = 0;
|
|
17
|
+
let truncated = false;
|
|
18
|
+
while (true) {
|
|
19
|
+
const { done, value } = await reader.read();
|
|
20
|
+
if (done)
|
|
21
|
+
break;
|
|
22
|
+
const room = Math.max(0, maximum - size);
|
|
23
|
+
if (value.length > room)
|
|
24
|
+
truncated = true;
|
|
25
|
+
if (room > 0) {
|
|
26
|
+
chunks.push(value.subarray(0, room));
|
|
27
|
+
size += Math.min(room, value.length);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return { text: Buffer.concat(chunks).toString('utf8'), truncated };
|
|
31
|
+
}
|
|
32
|
+
function timeoutSignal(timeoutMs, parent) {
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
const abort = () => controller.abort(parent?.reason);
|
|
35
|
+
parent?.addEventListener('abort', abort, { once: true });
|
|
36
|
+
const timer = setTimeout(() => controller.abort(new Error('timeout')), timeoutMs);
|
|
37
|
+
timer.unref();
|
|
38
|
+
return {
|
|
39
|
+
signal: controller.signal,
|
|
40
|
+
close() { clearTimeout(timer); parent?.removeEventListener('abort', abort); },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const apiDriver = {
|
|
44
|
+
type: 'api',
|
|
45
|
+
async call({ tool, action, input, options }) {
|
|
46
|
+
const source = tool.source;
|
|
47
|
+
const target = action.target;
|
|
48
|
+
const base = new URL(source.baseUrl);
|
|
49
|
+
const url = new URL(fillPath(target.path, input), base);
|
|
50
|
+
if (url.origin !== base.origin)
|
|
51
|
+
throw new Error(`API action ${action.id} leaves its registered origin`);
|
|
52
|
+
const query = input.query;
|
|
53
|
+
if (query && (typeof query !== 'object' || Array.isArray(query)))
|
|
54
|
+
throw new Error('input.query must be an object');
|
|
55
|
+
for (const [key, value] of Object.entries((query || {}))) {
|
|
56
|
+
if (value !== undefined && value !== null)
|
|
57
|
+
url.searchParams.append(key, String(value));
|
|
58
|
+
}
|
|
59
|
+
const headers = await resolveReferences(source.secretHeaders, options.resolveSecret);
|
|
60
|
+
const method = target.method.toUpperCase();
|
|
61
|
+
const timed = timeoutSignal(source.timeoutMs, options.signal);
|
|
62
|
+
try {
|
|
63
|
+
const response = await fetch(url, {
|
|
64
|
+
method, redirect: 'manual', signal: timed.signal,
|
|
65
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
66
|
+
body: ['GET', 'HEAD'].includes(method) ? undefined : JSON.stringify(input.body ?? input),
|
|
67
|
+
});
|
|
68
|
+
if (response.status >= 300 && response.status < 400)
|
|
69
|
+
return errorResult(`API tool ${action.id} refused redirect`);
|
|
70
|
+
const body = await readBody(response, source.maxOutputBytes);
|
|
71
|
+
if (!response.ok)
|
|
72
|
+
return errorResult(`HTTP ${response.status} ${response.statusText}\n\n${body.text}`);
|
|
73
|
+
let result;
|
|
74
|
+
try {
|
|
75
|
+
result = structuredResult(JSON.parse(body.text));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
result = textResult(body.text);
|
|
79
|
+
}
|
|
80
|
+
return { ...boundedResult(result, source.maxOutputBytes), ...(body.truncated ? { truncated: true } : {}) };
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (timed.signal.aborted)
|
|
84
|
+
return errorResult(options.signal?.aborted ? `Tool ${action.id} was cancelled` : `Tool ${action.id} timed out`);
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
timed.close();
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
export { apiDriver };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ActionRecord, Catalog, ToolRecord } from './types.js';
|
|
2
|
+
declare class ArtifactFiles {
|
|
3
|
+
private readonly directory;
|
|
4
|
+
constructor(directory: string);
|
|
5
|
+
private file;
|
|
6
|
+
writeTool(entry: {
|
|
7
|
+
tool: ToolRecord;
|
|
8
|
+
actions: ActionRecord[];
|
|
9
|
+
}): string;
|
|
10
|
+
removeTool(toolId: string): void;
|
|
11
|
+
writeIndex(catalog: Catalog): void;
|
|
12
|
+
/** Give every persisted user tool its portable artifact and refresh the
|
|
13
|
+
* index. Additive only: a file for a tool this catalog does not know is
|
|
14
|
+
* never deleted here — removal is an explicit mutation. */
|
|
15
|
+
materialize(catalog: Catalog): void;
|
|
16
|
+
/** Execute the legacy `toolbox.json` migration plan: per-tool artifacts
|
|
17
|
+
* first, then the index, and only then the rename that retires the
|
|
18
|
+
* aggregate file. */
|
|
19
|
+
migrateLegacy(): boolean;
|
|
20
|
+
/** Read every valid per-tool artifact back. A document only counts when
|
|
21
|
+
* its file carries the exact name its tool id demands — a copied or
|
|
22
|
+
* renamed file never smuggles in a second identity. */
|
|
23
|
+
read(): {
|
|
24
|
+
tools: Record<string, ToolRecord>;
|
|
25
|
+
toolActions: Record<string, ActionRecord>;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export { ArtifactFiles };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { LEGACY_TOOLBOX_FILE_NAME, TOOLBOX_INDEX_FILE_NAME, artifactDocument, catalogIndexDocument, isToolArtifactDocument, legacyMigrationPlan, safeToolId, userArtifactFileName, } from './artifacts.js';
|
|
4
|
+
/**
|
|
5
|
+
* The host adapter for tool artifacts: it executes the pure documents and
|
|
6
|
+
* plans from `artifacts.ts` against one state directory. Writes are atomic
|
|
7
|
+
* (tmp file + rename) and skipped when the bytes are unchanged, matching
|
|
8
|
+
* Engine `lib/storage.js`.
|
|
9
|
+
*
|
|
10
|
+
* DELIBERATE DEVIATION: Engine keeps the index and the legacy backup
|
|
11
|
+
* beside — not inside — its cloud-backed toolbox folder. A standalone
|
|
12
|
+
* state directory already holds the local `tools.db` cache, so both files
|
|
13
|
+
* live inside it, next to the database.
|
|
14
|
+
*/
|
|
15
|
+
function readJson(file) {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function writeJsonIfChanged(file, value) {
|
|
24
|
+
if (JSON.stringify(readJson(file)) === JSON.stringify(value))
|
|
25
|
+
return;
|
|
26
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
27
|
+
const temporary = `${file}.tmp`;
|
|
28
|
+
fs.writeFileSync(temporary, JSON.stringify(value, null, 2));
|
|
29
|
+
fs.renameSync(temporary, file);
|
|
30
|
+
}
|
|
31
|
+
class ArtifactFiles {
|
|
32
|
+
directory;
|
|
33
|
+
constructor(directory) {
|
|
34
|
+
this.directory = directory;
|
|
35
|
+
}
|
|
36
|
+
file(name) { return path.join(this.directory, name); }
|
|
37
|
+
writeTool(entry) {
|
|
38
|
+
const target = this.file(userArtifactFileName(entry.tool.id));
|
|
39
|
+
writeJsonIfChanged(target, artifactDocument(entry));
|
|
40
|
+
return target;
|
|
41
|
+
}
|
|
42
|
+
removeTool(toolId) {
|
|
43
|
+
try {
|
|
44
|
+
fs.unlinkSync(this.file(userArtifactFileName(toolId)));
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (error.code !== 'ENOENT')
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
writeIndex(catalog) {
|
|
52
|
+
writeJsonIfChanged(this.file(TOOLBOX_INDEX_FILE_NAME), catalogIndexDocument(catalog));
|
|
53
|
+
}
|
|
54
|
+
/** Give every persisted user tool its portable artifact and refresh the
|
|
55
|
+
* index. Additive only: a file for a tool this catalog does not know is
|
|
56
|
+
* never deleted here — removal is an explicit mutation. */
|
|
57
|
+
materialize(catalog) {
|
|
58
|
+
for (const tool of catalog.tools) {
|
|
59
|
+
if (tool.origin === 'system')
|
|
60
|
+
continue;
|
|
61
|
+
this.writeTool({ tool, actions: catalog.actions.filter((action) => action.toolId === tool.id) });
|
|
62
|
+
}
|
|
63
|
+
this.writeIndex(catalog);
|
|
64
|
+
}
|
|
65
|
+
/** Execute the legacy `toolbox.json` migration plan: per-tool artifacts
|
|
66
|
+
* first, then the index, and only then the rename that retires the
|
|
67
|
+
* aggregate file. */
|
|
68
|
+
migrateLegacy() {
|
|
69
|
+
const legacy = this.file(LEGACY_TOOLBOX_FILE_NAME);
|
|
70
|
+
if (!fs.existsSync(legacy))
|
|
71
|
+
return false;
|
|
72
|
+
const plan = legacyMigrationPlan(readJson(legacy));
|
|
73
|
+
if (!plan.migrated || !plan.rename)
|
|
74
|
+
return false;
|
|
75
|
+
for (const write of plan.files)
|
|
76
|
+
writeJsonIfChanged(this.file(write.fileName), write.document);
|
|
77
|
+
if (plan.index)
|
|
78
|
+
writeJsonIfChanged(this.file(TOOLBOX_INDEX_FILE_NAME), plan.index);
|
|
79
|
+
let target = this.file(plan.rename.to);
|
|
80
|
+
if (fs.existsSync(target))
|
|
81
|
+
target = this.file(`toolbox.legacy-${Date.now()}.json`);
|
|
82
|
+
fs.renameSync(legacy, target);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
/** Read every valid per-tool artifact back. A document only counts when
|
|
86
|
+
* its file carries the exact name its tool id demands — a copied or
|
|
87
|
+
* renamed file never smuggles in a second identity. */
|
|
88
|
+
read() {
|
|
89
|
+
const tools = {};
|
|
90
|
+
const toolActions = {};
|
|
91
|
+
let entries = [];
|
|
92
|
+
try {
|
|
93
|
+
entries = fs.readdirSync(this.directory, { withFileTypes: true });
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return { tools, toolActions };
|
|
97
|
+
}
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
if (!entry.isFile() || !entry.name.endsWith('.json') || entry.name === LEGACY_TOOLBOX_FILE_NAME)
|
|
100
|
+
continue;
|
|
101
|
+
const document = readJson(path.join(this.directory, entry.name));
|
|
102
|
+
if (!isToolArtifactDocument(document))
|
|
103
|
+
continue;
|
|
104
|
+
const toolId = safeToolId(document.tool.id);
|
|
105
|
+
if (userArtifactFileName(toolId) !== entry.name)
|
|
106
|
+
continue;
|
|
107
|
+
tools[toolId] = { ...document.tool, id: toolId };
|
|
108
|
+
for (const action of document.actions) {
|
|
109
|
+
toolActions[String(action.id)] = { ...action, toolId };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { tools, toolActions };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export { ArtifactFiles };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ActionRecord, Catalog, ToolRecord } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Tool artifact format laws — the portable representation of a tool.
|
|
4
|
+
*
|
|
5
|
+
* One tool is one artifact file: the tool definition and every action it
|
|
6
|
+
* owns travel together, and a file never carries a second tool. Artifact
|
|
7
|
+
* files are the portable definition; the Toolbox database and
|
|
8
|
+
* `toolbox.index.json` are derived caches. The format is ported byte-exact
|
|
9
|
+
* from Engine `runtime/scripts/amalgm-mcp/toolbox/artifacts.js`.
|
|
10
|
+
*
|
|
11
|
+
* Pure laws only: nothing here touches the filesystem. `artifact-files.ts`
|
|
12
|
+
* is the host adapter that executes these documents and plans.
|
|
13
|
+
*
|
|
14
|
+
* DELIBERATE DEVIATION: Engine recognizes system tools by its managed
|
|
15
|
+
* artifact list; this product's system tools are embedder projections
|
|
16
|
+
* (Axiom 15) and are recognized by `origin: 'system'`. The portable-source
|
|
17
|
+
* law is unchanged: a system tool's `127.0.0.1:<port>` binding is a device
|
|
18
|
+
* fact, never tool identity, so its artifact carries
|
|
19
|
+
* `{ transport: 'amalgm-runtime', service: 'mcp' }` instead.
|
|
20
|
+
*/
|
|
21
|
+
declare const TOOL_ARTIFACT_SCHEMA_VERSION = 1;
|
|
22
|
+
declare const TOOL_ARTIFACT_KIND = "amalgm-tool";
|
|
23
|
+
declare const LEGACY_TOOLBOX_FILE_NAME = "toolbox.json";
|
|
24
|
+
declare const LEGACY_TOOLBOX_BACKUP_FILE_NAME = "toolbox.legacy.json";
|
|
25
|
+
declare const TOOLBOX_INDEX_FILE_NAME = "toolbox.index.json";
|
|
26
|
+
type ArtifactRecord = Record<string, unknown>;
|
|
27
|
+
interface ToolArtifactDocument {
|
|
28
|
+
schemaVersion: typeof TOOL_ARTIFACT_SCHEMA_VERSION;
|
|
29
|
+
kind: typeof TOOL_ARTIFACT_KIND;
|
|
30
|
+
tool: ArtifactRecord;
|
|
31
|
+
actions: ArtifactRecord[];
|
|
32
|
+
}
|
|
33
|
+
interface ToolboxIndexDocument {
|
|
34
|
+
version: 1;
|
|
35
|
+
tools: Record<string, unknown>;
|
|
36
|
+
toolActions: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
interface ArtifactFileWrite {
|
|
39
|
+
fileName: string;
|
|
40
|
+
document: ToolArtifactDocument;
|
|
41
|
+
}
|
|
42
|
+
interface LegacyMigrationPlan {
|
|
43
|
+
migrated: boolean;
|
|
44
|
+
files: ArtifactFileWrite[];
|
|
45
|
+
index: ToolboxIndexDocument | null;
|
|
46
|
+
rename: {
|
|
47
|
+
from: string;
|
|
48
|
+
to: string;
|
|
49
|
+
} | null;
|
|
50
|
+
}
|
|
51
|
+
declare function safeToolId(value: unknown): string;
|
|
52
|
+
declare function userArtifactFileName(toolId: unknown): string;
|
|
53
|
+
declare function artifactDocument(input: {
|
|
54
|
+
tool: ToolRecord;
|
|
55
|
+
actions?: readonly ActionRecord[];
|
|
56
|
+
}): ToolArtifactDocument;
|
|
57
|
+
declare function isToolArtifactDocument(value: unknown): value is ToolArtifactDocument;
|
|
58
|
+
declare function toolboxIndexDocument(tools: Record<string, unknown>, toolActions: Record<string, unknown>): ToolboxIndexDocument;
|
|
59
|
+
declare function catalogIndexDocument(catalog: Pick<Catalog, 'tools' | 'actions'>): ToolboxIndexDocument;
|
|
60
|
+
/** The migration of one legacy aggregate `toolbox.json` catalog, as a pure
|
|
61
|
+
* plan: every user tool becomes a per-tool artifact write, the whole legacy
|
|
62
|
+
* catalog becomes the index, and the aggregate file is renamed out of the
|
|
63
|
+
* way only after each record has a per-tool replacement. */
|
|
64
|
+
declare function legacyMigrationPlan(legacy: unknown): LegacyMigrationPlan;
|
|
65
|
+
export { LEGACY_TOOLBOX_BACKUP_FILE_NAME, LEGACY_TOOLBOX_FILE_NAME, TOOL_ARTIFACT_KIND, TOOL_ARTIFACT_SCHEMA_VERSION, TOOLBOX_INDEX_FILE_NAME, artifactDocument, catalogIndexDocument, isToolArtifactDocument, legacyMigrationPlan, safeToolId, toolboxIndexDocument, userArtifactFileName, };
|
|
66
|
+
export type { ArtifactFileWrite, LegacyMigrationPlan, ToolArtifactDocument, ToolboxIndexDocument };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool artifact format laws — the portable representation of a tool.
|
|
3
|
+
*
|
|
4
|
+
* One tool is one artifact file: the tool definition and every action it
|
|
5
|
+
* owns travel together, and a file never carries a second tool. Artifact
|
|
6
|
+
* files are the portable definition; the Toolbox database and
|
|
7
|
+
* `toolbox.index.json` are derived caches. The format is ported byte-exact
|
|
8
|
+
* from Engine `runtime/scripts/amalgm-mcp/toolbox/artifacts.js`.
|
|
9
|
+
*
|
|
10
|
+
* Pure laws only: nothing here touches the filesystem. `artifact-files.ts`
|
|
11
|
+
* is the host adapter that executes these documents and plans.
|
|
12
|
+
*
|
|
13
|
+
* DELIBERATE DEVIATION: Engine recognizes system tools by its managed
|
|
14
|
+
* artifact list; this product's system tools are embedder projections
|
|
15
|
+
* (Axiom 15) and are recognized by `origin: 'system'`. The portable-source
|
|
16
|
+
* law is unchanged: a system tool's `127.0.0.1:<port>` binding is a device
|
|
17
|
+
* fact, never tool identity, so its artifact carries
|
|
18
|
+
* `{ transport: 'amalgm-runtime', service: 'mcp' }` instead.
|
|
19
|
+
*/
|
|
20
|
+
const TOOL_ARTIFACT_SCHEMA_VERSION = 1;
|
|
21
|
+
const TOOL_ARTIFACT_KIND = 'amalgm-tool';
|
|
22
|
+
const LEGACY_TOOLBOX_FILE_NAME = 'toolbox.json';
|
|
23
|
+
const LEGACY_TOOLBOX_BACKUP_FILE_NAME = 'toolbox.legacy.json';
|
|
24
|
+
const TOOLBOX_INDEX_FILE_NAME = 'toolbox.index.json';
|
|
25
|
+
function safeToolId(value) {
|
|
26
|
+
return String(value || '')
|
|
27
|
+
.trim()
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/[^a-z0-9_.-]+/g, '.')
|
|
30
|
+
.replace(/\.+/g, '.')
|
|
31
|
+
.replace(/^[._-]+|[._-]+$/g, '');
|
|
32
|
+
}
|
|
33
|
+
function userArtifactFileName(toolId) {
|
|
34
|
+
const id = safeToolId(toolId);
|
|
35
|
+
if (!id)
|
|
36
|
+
throw new Error('tool artifact requires a stable tool id');
|
|
37
|
+
return `tool-${id}.json`;
|
|
38
|
+
}
|
|
39
|
+
function artifactDocument(input) {
|
|
40
|
+
const { tool, actions = [] } = input;
|
|
41
|
+
if (!tool || typeof tool !== 'object' || !safeToolId(tool.id)) {
|
|
42
|
+
throw new Error('tool artifact requires a tool with an id');
|
|
43
|
+
}
|
|
44
|
+
const toolId = safeToolId(tool.id);
|
|
45
|
+
const normalizedActions = Array.isArray(actions)
|
|
46
|
+
? actions.filter((action) => action && action.toolId === toolId)
|
|
47
|
+
: [];
|
|
48
|
+
const portableTool = tool.origin === 'system'
|
|
49
|
+
? {
|
|
50
|
+
...tool,
|
|
51
|
+
id: toolId,
|
|
52
|
+
// `127.0.0.1:<port>` is a device binding, not tool identity. The
|
|
53
|
+
// embedder resolves the current runtime endpoint when serving the
|
|
54
|
+
// local tool surface.
|
|
55
|
+
source: { transport: 'amalgm-runtime', service: 'mcp' },
|
|
56
|
+
}
|
|
57
|
+
: { ...tool, id: toolId };
|
|
58
|
+
return {
|
|
59
|
+
schemaVersion: TOOL_ARTIFACT_SCHEMA_VERSION,
|
|
60
|
+
kind: TOOL_ARTIFACT_KIND,
|
|
61
|
+
tool: portableTool,
|
|
62
|
+
actions: normalizedActions
|
|
63
|
+
.map((action) => ({ ...action, toolId }))
|
|
64
|
+
.sort((left, right) => String(left.id).localeCompare(String(right.id))),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function isToolArtifactDocument(value) {
|
|
68
|
+
const document = value;
|
|
69
|
+
return !!document
|
|
70
|
+
&& document.schemaVersion === TOOL_ARTIFACT_SCHEMA_VERSION
|
|
71
|
+
&& document.kind === TOOL_ARTIFACT_KIND
|
|
72
|
+
&& !!document.tool
|
|
73
|
+
&& typeof document.tool === 'object'
|
|
74
|
+
&& !!safeToolId(document.tool.id)
|
|
75
|
+
&& Array.isArray(document.actions)
|
|
76
|
+
&& document.actions.every((action) => action && action.toolId === safeToolId(document.tool.id));
|
|
77
|
+
}
|
|
78
|
+
function toolboxIndexDocument(tools, toolActions) {
|
|
79
|
+
return { version: 1, tools: tools || {}, toolActions: toolActions || {} };
|
|
80
|
+
}
|
|
81
|
+
function catalogIndexDocument(catalog) {
|
|
82
|
+
return toolboxIndexDocument(Object.fromEntries(catalog.tools.map((tool) => [tool.id, tool])), Object.fromEntries(catalog.actions.map((action) => [action.id, action])));
|
|
83
|
+
}
|
|
84
|
+
/** The migration of one legacy aggregate `toolbox.json` catalog, as a pure
|
|
85
|
+
* plan: every user tool becomes a per-tool artifact write, the whole legacy
|
|
86
|
+
* catalog becomes the index, and the aggregate file is renamed out of the
|
|
87
|
+
* way only after each record has a per-tool replacement. */
|
|
88
|
+
function legacyMigrationPlan(legacy) {
|
|
89
|
+
const parsed = legacy;
|
|
90
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.tools || typeof parsed.tools !== 'object') {
|
|
91
|
+
return { migrated: false, files: [], index: null, rename: null };
|
|
92
|
+
}
|
|
93
|
+
const files = [];
|
|
94
|
+
for (const [rawId, rawTool] of Object.entries(parsed.tools)) {
|
|
95
|
+
const toolId = safeToolId(rawTool?.id || rawId);
|
|
96
|
+
if (!toolId || !rawTool || typeof rawTool !== 'object' || rawTool.origin === 'system')
|
|
97
|
+
continue;
|
|
98
|
+
const actions = Object.values(parsed.toolActions || {})
|
|
99
|
+
.filter((action) => action && action.toolId === toolId);
|
|
100
|
+
files.push({
|
|
101
|
+
fileName: userArtifactFileName(toolId),
|
|
102
|
+
document: artifactDocument({ tool: { ...rawTool, id: toolId }, actions }),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
migrated: true,
|
|
107
|
+
files,
|
|
108
|
+
index: toolboxIndexDocument(parsed.tools || {}, parsed.toolActions || {}),
|
|
109
|
+
rename: { from: LEGACY_TOOLBOX_FILE_NAME, to: LEGACY_TOOLBOX_BACKUP_FILE_NAME },
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export { LEGACY_TOOLBOX_BACKUP_FILE_NAME, LEGACY_TOOLBOX_FILE_NAME, TOOL_ARTIFACT_KIND, TOOL_ARTIFACT_SCHEMA_VERSION, TOOLBOX_INDEX_FILE_NAME, artifactDocument, catalogIndexDocument, isToolArtifactDocument, legacyMigrationPlan, safeToolId, toolboxIndexDocument, userArtifactFileName, };
|
package/dist/bin/mcp.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createMcpServer } from '../mcp-server.js';
|
|
3
|
+
const args = process.argv.slice(2);
|
|
4
|
+
const stateIndex = args.indexOf('--state-dir');
|
|
5
|
+
const stateDir = stateIndex >= 0 ? args[stateIndex + 1] : undefined;
|
|
6
|
+
const server = createMcpServer({ stateDir });
|
|
7
|
+
server.start();
|
|
8
|
+
const close = () => {
|
|
9
|
+
server.toolbox.close();
|
|
10
|
+
process.exit(0);
|
|
11
|
+
};
|
|
12
|
+
process.once('SIGINT', close);
|
|
13
|
+
process.once('SIGTERM', close);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { runCommand } from './process.js';
|
|
2
|
+
import { errorResult, structuredResult, textResult } from './results.js';
|
|
3
|
+
import { resolveReferences } from './secrets.js';
|
|
4
|
+
function inputArguments(input) {
|
|
5
|
+
const args = input.args;
|
|
6
|
+
if (!Array.isArray(args) || args.some((value) => typeof value !== 'string')) {
|
|
7
|
+
throw new Error('CLI argv input requires a string array at input.args');
|
|
8
|
+
}
|
|
9
|
+
return args;
|
|
10
|
+
}
|
|
11
|
+
const cliDriver = {
|
|
12
|
+
type: 'cli',
|
|
13
|
+
async call({ tool, action, input, options }) {
|
|
14
|
+
const source = tool.source;
|
|
15
|
+
const target = action.target;
|
|
16
|
+
const environment = await resolveReferences(source.secretEnv, options.resolveSecret);
|
|
17
|
+
const args = [...(source.args || []), ...(target.args || [])];
|
|
18
|
+
if (source.inputMode === 'argv')
|
|
19
|
+
args.push(...inputArguments(input));
|
|
20
|
+
const output = await runCommand({
|
|
21
|
+
command: source.command, args, cwd: source.cwd,
|
|
22
|
+
env: { ...process.env, ...environment },
|
|
23
|
+
stdin: source.inputMode === 'json-stdin' ? `${JSON.stringify(input)}\n` : undefined,
|
|
24
|
+
timeoutMs: source.timeoutMs, maximumBytes: source.maxOutputBytes, signal: options.signal,
|
|
25
|
+
});
|
|
26
|
+
const stdout = output.stdout.trim();
|
|
27
|
+
const stderr = output.stderr.trim();
|
|
28
|
+
if (output.timedOut)
|
|
29
|
+
return errorResult(`Tool ${action.id} timed out after ${source.timeoutMs}ms`);
|
|
30
|
+
if (output.aborted)
|
|
31
|
+
return errorResult(`Tool ${action.id} was cancelled`);
|
|
32
|
+
if (output.code !== 0) {
|
|
33
|
+
return errorResult([
|
|
34
|
+
`Tool ${action.id} exited with ${output.code ?? output.signal ?? 'unknown'}.`,
|
|
35
|
+
stdout && `stdout:\n${stdout}`, stderr && `stderr:\n${stderr}`,
|
|
36
|
+
].filter(Boolean).join('\n\n'));
|
|
37
|
+
}
|
|
38
|
+
let result;
|
|
39
|
+
if (source.outputMode === 'json' && stdout) {
|
|
40
|
+
try {
|
|
41
|
+
result = structuredResult(JSON.parse(stdout));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return errorResult(`Tool ${action.id} returned invalid JSON`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else
|
|
48
|
+
result = textResult(stdout || `Tool ${action.id} completed.`);
|
|
49
|
+
return { ...result, ...(output.truncated ? { truncated: true } : {}) };
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
export { cliDriver };
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CliIo, LoadoutInput } from './types.js';
|
|
2
|
+
declare const HELP = "Usage: amalgm-tools [--state-dir DIR] [--loadout IDS] <command>\n\nCommands:\n apply <definition.js|json> Apply one tool or an array of tools\n list List tools and enabled actions\n catalog Print the complete canonical catalog\n show <tool-or-action-id> Show one record\n enable <tool-or-action-id> Enable one record\n disable <tool-or-action-id> Disable one record\n remove <tool-or-action-id> Remove one record\n run <action-id> [--input JSON]\n Run an action\n connections List MCP connections for a composing host";
|
|
3
|
+
interface Parsed {
|
|
4
|
+
command?: string;
|
|
5
|
+
args: string[];
|
|
6
|
+
stateDir?: string;
|
|
7
|
+
loadout?: LoadoutInput;
|
|
8
|
+
input: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
declare function parse(argv: string[]): Parsed;
|
|
11
|
+
declare function runCli(argv: string[], io?: CliIo): Promise<number>;
|
|
12
|
+
export { HELP, parse, runCli };
|
|
13
|
+
export type { Parsed };
|