@amalgm/tools 0.1.7-main.34746904064 → 0.1.7-main.35132311462

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.
Files changed (60) hide show
  1. package/PURPOSE.md +33 -1
  2. package/README.md +35 -5
  3. package/dist/api-driver.d.ts +2 -1
  4. package/dist/api-driver.js +67 -48
  5. package/dist/cli-driver.d.ts +4 -3
  6. package/dist/cli-driver.js +43 -40
  7. package/dist/cli.d.ts +7 -2
  8. package/dist/cli.js +51 -49
  9. package/dist/commands.d.ts +27 -0
  10. package/dist/commands.js +16 -0
  11. package/dist/connection-auth.d.ts +11 -0
  12. package/dist/connection-auth.js +98 -0
  13. package/dist/connection-source.d.ts +23 -0
  14. package/dist/connection-source.js +62 -0
  15. package/dist/connection-types.d.ts +34 -0
  16. package/dist/connection-types.js +1 -0
  17. package/dist/connections.d.ts +14 -0
  18. package/dist/connections.js +98 -0
  19. package/dist/credential-operations.d.ts +7 -0
  20. package/dist/credential-operations.js +37 -0
  21. package/dist/definition.js +11 -3
  22. package/dist/host-mcp-tool.d.ts +18 -1
  23. package/dist/http-mutations.d.ts +63 -0
  24. package/dist/http-mutations.js +41 -0
  25. package/dist/http.d.ts +2 -3
  26. package/dist/http.js +9 -3
  27. package/dist/ids.d.ts +3 -1
  28. package/dist/ids.js +7 -2
  29. package/dist/index.d.ts +8 -0
  30. package/dist/index.js +4 -0
  31. package/dist/input.js +7 -32
  32. package/dist/loadout.d.ts +3 -0
  33. package/dist/loadout.js +23 -0
  34. package/dist/mcp-driver.d.ts +16 -0
  35. package/dist/mcp-driver.js +44 -0
  36. package/dist/mcp-search.d.ts +8 -0
  37. package/dist/mcp-search.js +51 -0
  38. package/dist/mcp-server.d.ts +1 -1
  39. package/dist/mcp-server.js +3 -2
  40. package/dist/mcp-types.d.ts +13 -0
  41. package/dist/mcp-types.js +1 -0
  42. package/dist/mcp.d.ts +1 -1
  43. package/dist/mcp.js +49 -26
  44. package/dist/oauth.d.ts +29 -0
  45. package/dist/oauth.js +131 -0
  46. package/dist/prepared-catalog.d.ts +3 -1
  47. package/dist/prepared-catalog.js +20 -1
  48. package/dist/results.d.ts +4 -1
  49. package/dist/results.js +21 -16
  50. package/dist/selection.js +3 -0
  51. package/dist/toolbox-view.d.ts +4 -1
  52. package/dist/toolbox-view.js +8 -2
  53. package/dist/toolbox.d.ts +12 -2
  54. package/dist/toolbox.js +32 -16
  55. package/dist/types.d.ts +33 -37
  56. package/dist/updates.d.ts +7 -2
  57. package/dist/updates.js +15 -1
  58. package/docs/SHELL_INTEGRATION.md +8 -3
  59. package/package.json +5 -4
  60. package/skills/tools/SKILL.md +6 -0
package/PURPOSE.md CHANGED
@@ -5,7 +5,7 @@
5
5
  Amalgm Tools lets a user define, keep, select, and run agent capabilities as a
6
6
  standalone product or inside Amalgm. It serves people building agents,
7
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
8
+ CLI, HTTP API, GraphQL, and MCP tools. When the product is healthy, every callable
9
9
  action has one stable identity and one owning tool, a selected loadout exposes
10
10
  only its granted capabilities, and the SDK, CLI, and MCP surfaces all observe
11
11
  and execute the same catalog.
@@ -30,6 +30,10 @@ and parity evidence only.
30
30
  - A **loadout** is a set of tool or action ids used to derive a read-only view
31
31
  of the Toolbox.
32
32
  - A **driver** executes actions for one tool type.
33
+ - A **connection** is a tool's current admission to execution, with credential
34
+ references resolved by the host's encrypted vault; it is not another catalog.
35
+ - **Exposure** is an agent loadout's presentation of a granted action: direct,
36
+ searchable, or hidden. An action override is more specific than its tool default.
33
37
  - An **MCP server** is a separately identified runtime endpoint that may expose
34
38
  one or more tools; selecting a tool never changes that server's identity.
35
39
  - A **system tool** is an embedder-owned definition projected into the catalog;
@@ -39,6 +43,10 @@ and parity evidence only.
39
43
 
40
44
  ## Axioms
41
45
 
46
+ MCP execution context belongs to each host-admitted call. It passes unchanged
47
+ to the driver, never comes from model-supplied arguments or metadata, and never
48
+ leaks between concurrent calls sharing a prepared Toolbox view.
49
+
42
50
  Consumer assembly derives exact same-branch dependency pins from the declared
43
51
  package manifests and registry candidate records. One pending composition per
44
52
  branch preserves human edits; complete consumer checks for the exact merge tree govern its
@@ -137,6 +145,30 @@ locked dependency graph on the supported Node toolchain.
137
145
  definitions and scope cannot change. Later catalog edits affect future
138
146
  preparation; already prepared calls use their captured definitions and the
139
147
  host's current credential and execution effects.
148
+ 28. Disconnect disables the parent tool and removes its saved authentication;
149
+ it retains the definition and action schemas for reconnection. Current parent
150
+ admission gates every invocation, including an already prepared execution.
151
+ A loadout edit never connects, disconnects, or changes the parent Toolbox.
152
+ 29. A loadout grants only its selected tool/action ids. Omitted exposure means
153
+ direct; an explicit action exposure overrides its selected tool's exposure.
154
+ Hidden actions appear in neither listing, search, prompt descriptions, nor
155
+ invocation. Search exposes only searchable descriptors and its scoped call
156
+ action. An exposure entry cannot grant an unselected capability.
157
+ 30. Every tool type crosses the same scoped MCP action boundary. ACP describes
158
+ the server supplied to an agent; MCP describes action schemas and results.
159
+ Native adapters do not discover tools or decide exposure. CLI descriptions
160
+ may inform system instructions, but never substitute for execution admission.
161
+ 31. Connecting an MCP source discovers and stores its complete official action
162
+ schemas before activation. Invocation uses those captured schemas and exact
163
+ target names; a later remote catalog cannot expand an agent's grant.
164
+ 32. OAuth access, refresh, and client secrets remain in the host vault.
165
+ Header, environment, and query authentication use source-bound vault references. Refresh
166
+ is serialized per credential, preserves rotated tokens, and cannot restore a
167
+ disconnected credential. Credential references are bound to the registered
168
+ source, so editing an endpoint cannot send an earlier secret to a new authority.
169
+ 33. GraphQL actions store one operation document and send validated input as its
170
+ variables to the registered endpoint. API and GraphQL redirects never forward
171
+ credentials to another endpoint; callers cannot override registered targets.
140
172
 
141
173
  ## Predictable behavior
142
174
 
package/README.md CHANGED
@@ -66,9 +66,15 @@ amalgm-tools apply ./tool.mjs
66
66
  amalgm-tools list
67
67
  amalgm-tools run hello.say --input '{"name":"Ada"}'
68
68
  amalgm-tools disable hello.say
69
+ amalgm-tools link canvas app-3f1c
69
70
  amalgm-tools connections
70
71
  ```
71
72
 
73
+ A tool may belong to an app: `appId` on the tool is the reverse edge of the
74
+ app manifest's `toolIds`, and sharing either side carries the other. The host
75
+ that composes Tools and Apps may pass `environment(tool)` so a linked tool
76
+ runs knowing where its app listens.
77
+
72
78
  `amalgm-tools-mcp` serves Toolbox management and enabled action tools over
73
79
  newline-delimited MCP stdio.
74
80
 
@@ -83,10 +89,14 @@ const server = createToolboxHttpServer({ toolbox });
83
89
  await server.listen(8083);
84
90
  ```
85
91
 
86
- The read adapter serves the legacy-compatible `GET /toolbox`,
92
+ The adapter serves `GET /toolbox`,
87
93
  `GET /toolbox/tools?id=...`, and `GET /toolbox/actions?id=...` routes over
88
- the same live Toolbox used by CLI and MCP consumers. Mutation routes return
89
- 405 until their legacy record fields can be represented without data loss.
94
+ the same live Toolbox used by CLI and MCP consumers. `POST /toolbox/tools`
95
+ connects a definition with optional write-only credentials. `PATCH /toolbox/tools`
96
+ with `{ id, status: "disabled" }` disconnects it. `DELETE /toolbox/tools` removes
97
+ it. `POST /toolbox/actions/call` invokes `{ id, input }`.
98
+ The authenticated host selects the owner. OAuth authorization and callback
99
+ use `POST /toolbox/oauth/authorize` and `POST /toolbox/oauth/callback`.
90
100
 
91
101
  ## Notifications
92
102
 
@@ -102,11 +112,31 @@ and must not be presented as a second Channels implementation.
102
112
 
103
113
  - `cli`: executed directly with `spawn`; a shell is never involved.
104
114
  - `api`: same-origin HTTP actions with redirects refused.
115
+ - `graphql`: a saved operation document with validated input as its variables.
105
116
  - `mcp`: a connection plus action projection. The composing MCP host supplies
106
117
  the one MCP driver that owns discovery, sessions, and calls.
107
118
 
108
- Secret values are never part of definitions. `secretEnv` and `secretHeaders`
109
- contain opaque references resolved through `CallOptions.resolveSecret`.
119
+ Secret values are never part of definitions. `secretEnv`, `secretHeaders`,
120
+ and `secretQuery` contain opaque references resolved at invocation. A host
121
+ supplies `credentials: { get, set, delete }` backed by its encrypted vault.
122
+ `toolbox.connect({ definition, credentials })` stores authentication separately
123
+ and discovers MCP actions before activation. `toolbox.disconnect(id)` removes
124
+ authentication and disables execution while retaining the definition and actions.
125
+
126
+ A loadout contains `toolIds` (whole-tool or action ids) and optional `exposure`:
127
+
128
+ ```js
129
+ const selected = toolbox.prepare(toolbox.catalog().revisionId, {
130
+ toolIds: ['service'],
131
+ exposure: { service: 'search', 'service.read': 'direct', 'service.erase': 'hidden' },
132
+ });
133
+ ```
134
+
135
+ Direct actions are listed normally. Searchable actions are reachable only through
136
+ `toolbox_search` and its scoped `toolbox_call`. Hidden actions cannot be listed,
137
+ searched, or invoked. Every type uses this same MCP surface; ACP supplies the
138
+ server to each agent adapter. A prepared view keeps its captured definitions
139
+ but still honors a subsequent parent disconnect.
110
140
  See [PURPOSE.md](./PURPOSE.md) for the complete contract and
111
141
  [SHELL_INTEGRATION.md](./docs/SHELL_INTEGRATION.md) for active composition.
112
142
  The [Engine integration](./docs/ENGINE_INTEGRATION.md) page is historical only.
@@ -1,3 +1,4 @@
1
1
  import type { ToolDriver } from './types.js';
2
2
  declare const apiDriver: ToolDriver;
3
- export { apiDriver };
3
+ declare const graphqlDriver: ToolDriver;
4
+ export { apiDriver, graphqlDriver };
@@ -26,13 +26,20 @@ async function readBody(response, maximum) {
26
26
  chunks.push(value.subarray(0, room));
27
27
  size += Math.min(room, value.length);
28
28
  }
29
+ if (truncated) {
30
+ await reader.cancel();
31
+ break;
32
+ }
29
33
  }
30
34
  return { text: Buffer.concat(chunks).toString('utf8'), truncated };
31
35
  }
32
36
  function timeoutSignal(timeoutMs, parent) {
33
37
  const controller = new AbortController();
34
38
  const abort = () => controller.abort(parent?.reason);
35
- parent?.addEventListener('abort', abort, { once: true });
39
+ if (parent?.aborted)
40
+ abort();
41
+ else
42
+ parent?.addEventListener('abort', abort, { once: true });
36
43
  const timer = setTimeout(() => controller.abort(new Error('timeout')), timeoutMs);
37
44
  timer.unref();
38
45
  return {
@@ -40,53 +47,65 @@ function timeoutSignal(timeoutMs, parent) {
40
47
  close() { clearTimeout(timer); parent?.removeEventListener('abort', abort); },
41
48
  };
42
49
  }
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);
50
+ async function callHttp({ tool, action, input, options }) {
51
+ const source = tool.source;
52
+ if (source.type !== 'api' && source.type !== 'graphql')
53
+ throw new Error('HTTP tool source required');
54
+ const target = action.target;
55
+ const graphql = action.target;
56
+ const base = new URL(source.type === 'api' ? source.baseUrl : source.url);
57
+ const url = source.type === 'api' ? new URL(fillPath(target.path, input), base) : base;
58
+ if (url.origin !== base.origin)
59
+ throw new Error(`API action ${action.id} leaves its registered origin`);
60
+ const query = source.type === 'api' ? input.query : undefined;
61
+ if (query && (typeof query !== 'object' || Array.isArray(query)))
62
+ throw new Error('input.query must be an object');
63
+ for (const [key, value] of Object.entries((query || {}))) {
64
+ if (value !== undefined && value !== null)
65
+ url.searchParams.append(key, String(value));
66
+ }
67
+ const secretQuery = await resolveReferences(source.secretQuery, options.resolveSecret);
68
+ for (const [key, value] of Object.entries(secretQuery))
69
+ url.searchParams.set(key, value);
70
+ const headers = await resolveReferences(source.secretHeaders, options.resolveSecret);
71
+ const method = source.type === 'api' ? target.method.toUpperCase() : 'POST';
72
+ const requestBody = source.type === 'api' ? (input.body ?? input) : {
73
+ query: graphql.document, variables: input,
74
+ ...(graphql.operationName ? { operationName: graphql.operationName } : {}),
75
+ };
76
+ const timed = timeoutSignal(source.timeoutMs, options.signal);
77
+ try {
78
+ const response = await fetch(url, {
79
+ method, redirect: 'manual', signal: timed.signal,
80
+ headers: { 'content-type': 'application/json', ...headers },
81
+ body: ['GET', 'HEAD'].includes(method) ? undefined : JSON.stringify(requestBody),
82
+ });
83
+ if (response.status >= 300 && response.status < 400)
84
+ return errorResult(`API tool ${action.id} refused redirect`);
85
+ const body = await readBody(response, source.maxOutputBytes);
86
+ if (!response.ok)
87
+ return errorResult(`HTTP ${response.status} ${response.statusText}\n\n${body.text}`);
88
+ let result;
62
89
  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 } : {}) };
90
+ const value = JSON.parse(body.text);
91
+ result = structuredResult(value);
92
+ if (source.type === 'graphql' && Array.isArray(value?.errors) && value.errors.length)
93
+ result.isError = true;
81
94
  }
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;
95
+ catch {
96
+ result = textResult(body.text);
86
97
  }
87
- finally {
88
- timed.close();
89
- }
90
- },
91
- };
92
- export { apiDriver };
98
+ return { ...boundedResult(result, source.maxOutputBytes), ...(body.truncated ? { truncated: true } : {}) };
99
+ }
100
+ catch (error) {
101
+ if (timed.signal.aborted)
102
+ return errorResult(options.signal?.aborted ? `Tool ${action.id} was cancelled` : `Tool ${action.id} timed out`);
103
+ throw error;
104
+ }
105
+ finally {
106
+ timed.close();
107
+ }
108
+ }
109
+ const apiDriver = { type: 'api', call: callHttp };
110
+ const graphqlDriver = { type: 'graphql', call: callHttp };
111
+ export { apiDriver, graphqlDriver };
@@ -1,3 +1,4 @@
1
- import type { ToolDriver } from './types.js';
2
- declare const cliDriver: ToolDriver;
3
- export { cliDriver };
1
+ import type { ToolDriver, ToolRecord } from './types.js';
2
+ type Environment = (tool: ToolRecord) => Record<string, string> | Promise<Record<string, string>>;
3
+ declare function createCliDriver(environment?: Environment): ToolDriver;
4
+ export { createCliDriver };
@@ -1,4 +1,5 @@
1
1
  import { runCommand } from './process.js';
2
+ import { getDefaultEnvironment } from '@modelcontextprotocol/sdk/client/stdio.js';
2
3
  import { errorResult, structuredResult, textResult } from './results.js';
3
4
  import { resolveReferences } from './secrets.js';
4
5
  function inputArguments(input) {
@@ -8,45 +9,47 @@ function inputArguments(input) {
8
9
  }
9
10
  return args;
10
11
  }
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));
12
+ function createCliDriver(environment = () => ({})) {
13
+ return {
14
+ type: 'cli',
15
+ async call({ tool, action, input, options }) {
16
+ const source = tool.source;
17
+ const target = action.target;
18
+ const secrets = await resolveReferences(source.secretEnv, options.resolveSecret);
19
+ const args = [...(source.args || []), ...(target.args || [])];
20
+ if (source.inputMode === 'argv')
21
+ args.push(...inputArguments(input));
22
+ const output = await runCommand({
23
+ command: source.command, args, cwd: source.cwd,
24
+ env: { ...getDefaultEnvironment(), ...(await environment(tool)), ...secrets },
25
+ stdin: source.inputMode === 'json-stdin' ? `${JSON.stringify(input)}\n` : undefined,
26
+ timeoutMs: source.timeoutMs, maximumBytes: source.maxOutputBytes, signal: options.signal,
27
+ });
28
+ const stdout = output.stdout.trim();
29
+ const stderr = output.stderr.trim();
30
+ if (output.timedOut)
31
+ return errorResult(`Tool ${action.id} timed out after ${source.timeoutMs}ms`);
32
+ if (output.aborted)
33
+ return errorResult(`Tool ${action.id} was cancelled`);
34
+ if (output.code !== 0) {
35
+ return errorResult([
36
+ `Tool ${action.id} exited with ${output.code ?? output.signal ?? 'unknown'}.`,
37
+ stdout && `stdout:\n${stdout}`, stderr && `stderr:\n${stderr}`,
38
+ ].filter(Boolean).join('\n\n'));
42
39
  }
43
- catch {
44
- return errorResult(`Tool ${action.id} returned invalid JSON`);
40
+ let result;
41
+ if (source.outputMode === 'json' && stdout) {
42
+ try {
43
+ result = structuredResult(JSON.parse(stdout));
44
+ }
45
+ catch {
46
+ return errorResult(`Tool ${action.id} returned invalid JSON`);
47
+ }
45
48
  }
46
- }
47
- else
48
- result = textResult(stdout || `Tool ${action.id} completed.`);
49
- return { ...result, ...(output.truncated ? { truncated: true } : {}) };
50
- },
51
- };
52
- export { cliDriver };
49
+ else
50
+ result = textResult(stdout || `Tool ${action.id} completed.`);
51
+ return { ...result, ...(output.truncated ? { truncated: true } : {}) };
52
+ },
53
+ };
54
+ }
55
+ export { createCliDriver };
package/dist/cli.d.ts CHANGED
@@ -1,5 +1,5 @@
1
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";
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 link <tool-id> <app-id> Link one tool to the app it belongs to\n unlink <tool-id> Detach one tool from its app\n run <action-id> [--input JSON]\n Run an action\n connections List MCP connections for a composing host";
3
3
  interface Parsed {
4
4
  command?: string;
5
5
  args: string[];
@@ -7,7 +7,12 @@ interface Parsed {
7
7
  loadout?: LoadoutInput;
8
8
  input: Record<string, unknown>;
9
9
  }
10
+ /** One call on the command surface: an MCP tool name and its arguments. */
11
+ interface Call {
12
+ name: string;
13
+ args: Record<string, unknown>;
14
+ }
10
15
  declare function parse(argv: string[]): Parsed;
11
16
  declare function runCli(argv: string[], io?: CliIo): Promise<number>;
12
17
  export { HELP, parse, runCli };
13
- export type { Parsed };
18
+ export type { Call, Parsed };
package/dist/cli.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { createToolboxCommands } from './commands.js';
2
+ import { id, mcpName } from './ids.js';
1
3
  import { loadDefinitions } from './module.js';
2
4
  import { Toolbox } from './toolbox.js';
3
5
  const HELP = `Usage: amalgm-tools [--state-dir DIR] [--loadout IDS] <command>
@@ -10,6 +12,8 @@ Commands:
10
12
  enable <tool-or-action-id> Enable one record
11
13
  disable <tool-or-action-id> Disable one record
12
14
  remove <tool-or-action-id> Remove one record
15
+ link <tool-id> <app-id> Link one tool to the app it belongs to
16
+ unlink <tool-id> Detach one tool from its app
13
17
  run <action-id> [--input JSON]
14
18
  Run an action
15
19
  connections List MCP connections for a composing host`;
@@ -48,9 +52,44 @@ function required(value, label) {
48
52
  throw new Error(`${label} is required`);
49
53
  return value;
50
54
  }
55
+ /** Map one parsed command line to its calls; `apply` makes one call per definition. */
56
+ async function calls(parsed) {
57
+ const [first, second] = parsed.args;
58
+ const record = (name, label) => ({ name, args: { id: required(first, label) } });
59
+ switch (parsed.command) {
60
+ case 'apply': {
61
+ const definitions = await loadDefinitions(required(first, 'definition file'));
62
+ return definitions.map((tool) => ({ name: 'toolbox_tool_apply', args: { tool } }));
63
+ }
64
+ case 'list': return [{ name: 'toolbox_tools_selected', args: {} }];
65
+ case 'catalog': return [{ name: 'toolbox_tools_list', args: {} }];
66
+ case 'show': return [record('toolbox_tool_get', 'tool or action id')];
67
+ case 'enable': return [record('toolbox_record_enable', 'record id')];
68
+ case 'disable': return [record('toolbox_record_disable', 'record id')];
69
+ case 'remove': return [record('toolbox_record_remove', 'record id')];
70
+ case 'link': return [{
71
+ name: 'toolbox_tool_link',
72
+ args: { id: required(first, 'tool id'), app_id: required(second, 'app id') },
73
+ }];
74
+ case 'unlink': return [record('toolbox_tool_unlink', 'tool id')];
75
+ case 'run': return [{ name: mcpName({ id: id(first, 'action id') }), args: parsed.input }];
76
+ case 'connections': return [{ name: 'toolbox_mcp_connections', args: {} }];
77
+ default: throw new Error(`Unknown command: ${parsed.command}`);
78
+ }
79
+ }
80
+ function open(io, parsed) {
81
+ if (io.commands)
82
+ return { commands: io.commands, close() { } };
83
+ const toolbox = new Toolbox({ stateDir: parsed.stateDir });
84
+ return { commands: createToolboxCommands(toolbox, parsed.loadout), close: () => toolbox.close() };
85
+ }
51
86
  function write(output, value) {
52
87
  output.write(`${JSON.stringify(value, null, 2)}\n`);
53
88
  }
89
+ function fail(output, error) {
90
+ output.write(`${error instanceof Error ? error.message : String(error)}\n`);
91
+ return 1;
92
+ }
54
93
  async function runCli(argv, io = {}) {
55
94
  const stdout = io.stdout || process.stdout;
56
95
  const stderr = io.stderr || process.stderr;
@@ -59,65 +98,28 @@ async function runCli(argv, io = {}) {
59
98
  parsed = parse(argv);
60
99
  }
61
100
  catch (error) {
62
- stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
63
- return 1;
101
+ return fail(stderr, error);
64
102
  }
65
103
  if (!parsed.command || ['help', '-h', '--help'].includes(parsed.command)) {
66
104
  stdout.write(`${HELP}\n`);
67
105
  return 0;
68
106
  }
69
- const toolbox = io.toolbox || new Toolbox({ stateDir: parsed.stateDir });
70
107
  try {
71
- const recordId = parsed.args[0];
72
- switch (parsed.command) {
73
- case 'apply': {
74
- const definitions = await loadDefinitions(required(recordId, 'definition file'));
75
- const results = [];
76
- for (const definition of definitions)
77
- results.push(await toolbox.apply(definition));
78
- write(stdout, results.length === 1 ? results[0] : results);
79
- break;
80
- }
81
- case 'list':
82
- write(stdout, toolbox.list(parsed.loadout));
83
- break;
84
- case 'catalog':
85
- write(stdout, toolbox.catalog());
86
- break;
87
- case 'show': {
88
- const value = required(recordId, 'tool or action id');
89
- const result = toolbox.get(value) || toolbox.action(value);
90
- if (!result)
91
- throw new Error(`Unknown tool or action: ${value}`);
92
- write(stdout, result);
93
- break;
94
- }
95
- case 'enable':
96
- write(stdout, await toolbox.setStatus(required(recordId, 'record id'), 'enabled'));
97
- break;
98
- case 'disable':
99
- write(stdout, await toolbox.setStatus(required(recordId, 'record id'), 'disabled'));
100
- break;
101
- case 'remove':
102
- write(stdout, await toolbox.remove(required(recordId, 'record id')));
103
- break;
104
- case 'run':
105
- write(stdout, await toolbox.call(required(recordId, 'action id'), parsed.input, { loadout: parsed.loadout }));
106
- break;
107
- case 'connections':
108
- write(stdout, toolbox.connections(parsed.loadout));
109
- break;
110
- default: throw new Error(`Unknown command: ${parsed.command}`);
108
+ const planned = await calls(parsed);
109
+ const session = open(io, parsed);
110
+ try {
111
+ const results = [];
112
+ for (const call of planned)
113
+ results.push(await session.commands.execute(call.name, call.args));
114
+ write(stdout, results.length === 1 ? results[0] : results);
115
+ }
116
+ finally {
117
+ session.close();
111
118
  }
112
119
  return 0;
113
120
  }
114
121
  catch (error) {
115
- stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
116
- return 1;
117
- }
118
- finally {
119
- if (!io.toolbox)
120
- toolbox.close();
122
+ return fail(stderr, error);
121
123
  }
122
124
  }
123
125
  export { HELP, parse, runCli };
@@ -0,0 +1,27 @@
1
+ import type { Toolbox } from './toolbox.js';
2
+ import type { LoadoutInput } from './types.js';
3
+ /**
4
+ * The one command surface. The CLI speaks only this; a host forwards it to a
5
+ * running runtime's MCP over HTTP, and `createToolboxCommands` runs it against
6
+ * a local Toolbox through that Toolbox's own MCP tools.
7
+ *
8
+ * `name` is an MCP tool name from this package's MCP surface: a management
9
+ * tool (`toolbox_*`, single underscore) or an action tool (`toolbox__<id>`,
10
+ * from `mcpName`). `args` is the MCP `arguments` object for that tool.
11
+ *
12
+ * `execute` resolves to the tool's value, unwrapped from the MCP call result:
13
+ * - any result with `isError: true` rejects with an Error whose message is
14
+ * the result's text content (every `content[].text`, joined by newlines);
15
+ * - a management tool's value is `result.structuredContent` (its text
16
+ * content is the same value as JSON and is never consulted);
17
+ * - an action tool's value is the whole MCP result (`content`,
18
+ * `structuredContent`, `truncated`) exactly as the driver returned it.
19
+ * A remote implementation sends `name`/`args` as an MCP `tools/call` and
20
+ * applies these three rules to the response.
21
+ */
22
+ interface ToolCommands {
23
+ execute(name: string, args: Record<string, unknown>): Promise<unknown>;
24
+ }
25
+ declare function createToolboxCommands(toolbox: Toolbox, loadout?: LoadoutInput): ToolCommands;
26
+ export { createToolboxCommands };
27
+ export type { ToolCommands };
@@ -0,0 +1,16 @@
1
+ import { isActionMcpName } from './ids.js';
2
+ import { callMcpTool } from './mcp.js';
3
+ import { structuredValue } from './results.js';
4
+ function unwrap(name, result) {
5
+ if (result.isError)
6
+ throw new Error(result.content.filter((item) => item.type === 'text').map((item) => item.text).join('\n'));
7
+ return isActionMcpName(name) ? result : structuredValue(result.structuredContent);
8
+ }
9
+ function createToolboxCommands(toolbox, loadout) {
10
+ return {
11
+ async execute(name, args) {
12
+ return unwrap(name, await callMcpTool(toolbox, name, args, { loadout }));
13
+ },
14
+ };
15
+ }
16
+ export { createToolboxCommands };
@@ -0,0 +1,11 @@
1
+ import type { ToolCredential, ToolCredentialStore } from './connection-types.js';
2
+ import type { SecretResolver } from './types.js';
3
+ export declare function validateCredential(value: ToolCredential): ToolCredential;
4
+ /** Token refresh belongs to the connection; encryption belongs to the host. */
5
+ export declare class ToolAuthentication {
6
+ #private;
7
+ readonly store?: ToolCredentialStore | undefined;
8
+ private readonly fallback?;
9
+ constructor(store?: ToolCredentialStore | undefined, fallback?: SecretResolver | undefined);
10
+ resolve: (reference: string) => Promise<string | undefined>;
11
+ }