@amalgm/tools 0.1.7-preview.34547452288 → 0.1.7-preview.34938534039
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 +33 -1
- package/README.md +29 -5
- package/dist/api-driver.d.ts +2 -1
- package/dist/api-driver.js +67 -48
- package/dist/cli-driver.js +2 -1
- package/dist/commands.js +3 -2
- package/dist/connection-auth.d.ts +11 -0
- package/dist/connection-auth.js +98 -0
- package/dist/connection-source.d.ts +23 -0
- package/dist/connection-source.js +62 -0
- package/dist/connection-types.d.ts +34 -0
- package/dist/connection-types.js +1 -0
- package/dist/connections.d.ts +14 -0
- package/dist/connections.js +98 -0
- package/dist/credential-operations.d.ts +7 -0
- package/dist/credential-operations.js +37 -0
- package/dist/definition.js +10 -3
- package/dist/http-mutations.d.ts +63 -0
- package/dist/http-mutations.js +41 -0
- package/dist/http.d.ts +2 -3
- package/dist/http.js +9 -3
- package/dist/index.d.ts +6 -0
- package/dist/index.js +2 -0
- package/dist/input.js +7 -32
- package/dist/loadout.d.ts +3 -0
- package/dist/loadout.js +23 -0
- package/dist/mcp-driver.d.ts +16 -0
- package/dist/mcp-driver.js +44 -0
- package/dist/mcp-search.d.ts +8 -0
- package/dist/mcp-search.js +51 -0
- package/dist/mcp-server.d.ts +4 -3
- package/dist/mcp-types.d.ts +13 -0
- package/dist/mcp-types.js +1 -0
- package/dist/mcp.d.ts +5 -4
- package/dist/mcp.js +26 -19
- package/dist/oauth.d.ts +29 -0
- package/dist/oauth.js +131 -0
- package/dist/prepared-catalog.d.ts +5 -0
- package/dist/prepared-catalog.js +44 -0
- package/dist/results.d.ts +4 -1
- package/dist/results.js +21 -16
- package/dist/selection.js +3 -0
- package/dist/toolbox-state-dir.d.ts +2 -0
- package/dist/toolbox-state-dir.js +18 -0
- package/dist/toolbox-view.d.ts +4 -1
- package/dist/toolbox-view.js +8 -2
- package/dist/toolbox.d.ts +10 -0
- package/dist/toolbox.js +29 -28
- package/dist/types.d.ts +27 -20
- package/docs/SHELL_INTEGRATION.md +8 -3
- package/package.json +5 -4
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;
|
|
@@ -133,6 +137,34 @@ locked dependency graph on the supported Node toolchain.
|
|
|
133
137
|
when a first-party release currently maps them one-to-one.
|
|
134
138
|
26. A scoped Toolbox MCP server advertises and calls only the selected loadout;
|
|
135
139
|
management actions are included only when the composing host asks for them.
|
|
140
|
+
27. Preparing an exact catalog revision captures a read-only loadout whose
|
|
141
|
+
definitions and scope cannot change. Later catalog edits affect future
|
|
142
|
+
preparation; already prepared calls use their captured definitions and the
|
|
143
|
+
host's current credential and execution effects.
|
|
144
|
+
28. Disconnect disables the parent tool and removes its saved authentication;
|
|
145
|
+
it retains the definition and action schemas for reconnection. Current parent
|
|
146
|
+
admission gates every invocation, including an already prepared execution.
|
|
147
|
+
A loadout edit never connects, disconnects, or changes the parent Toolbox.
|
|
148
|
+
29. A loadout grants only its selected tool/action ids. Omitted exposure means
|
|
149
|
+
direct; an explicit action exposure overrides its selected tool's exposure.
|
|
150
|
+
Hidden actions appear in neither listing, search, prompt descriptions, nor
|
|
151
|
+
invocation. Search exposes only searchable descriptors and its scoped call
|
|
152
|
+
action. An exposure entry cannot grant an unselected capability.
|
|
153
|
+
30. Every tool type crosses the same scoped MCP action boundary. ACP describes
|
|
154
|
+
the server supplied to an agent; MCP describes action schemas and results.
|
|
155
|
+
Native adapters do not discover tools or decide exposure. CLI descriptions
|
|
156
|
+
may inform system instructions, but never substitute for execution admission.
|
|
157
|
+
31. Connecting an MCP source discovers and stores its complete official action
|
|
158
|
+
schemas before activation. Invocation uses those captured schemas and exact
|
|
159
|
+
target names; a later remote catalog cannot expand an agent's grant.
|
|
160
|
+
32. OAuth access, refresh, and client secrets remain in the host vault.
|
|
161
|
+
Header, environment, and query authentication use source-bound vault references. Refresh
|
|
162
|
+
is serialized per credential, preserves rotated tokens, and cannot restore a
|
|
163
|
+
disconnected credential. Credential references are bound to the registered
|
|
164
|
+
source, so editing an endpoint cannot send an earlier secret to a new authority.
|
|
165
|
+
33. GraphQL actions store one operation document and send validated input as its
|
|
166
|
+
variables to the registered endpoint. API and GraphQL redirects never forward
|
|
167
|
+
credentials to another endpoint; callers cannot override registered targets.
|
|
136
168
|
|
|
137
169
|
## Predictable behavior
|
|
138
170
|
|
package/README.md
CHANGED
|
@@ -89,10 +89,14 @@ const server = createToolboxHttpServer({ toolbox });
|
|
|
89
89
|
await server.listen(8083);
|
|
90
90
|
```
|
|
91
91
|
|
|
92
|
-
The
|
|
92
|
+
The adapter serves `GET /toolbox`,
|
|
93
93
|
`GET /toolbox/tools?id=...`, and `GET /toolbox/actions?id=...` routes over
|
|
94
|
-
the same live Toolbox used by CLI and MCP consumers.
|
|
95
|
-
|
|
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`.
|
|
96
100
|
|
|
97
101
|
## Notifications
|
|
98
102
|
|
|
@@ -108,11 +112,31 @@ and must not be presented as a second Channels implementation.
|
|
|
108
112
|
|
|
109
113
|
- `cli`: executed directly with `spawn`; a shell is never involved.
|
|
110
114
|
- `api`: same-origin HTTP actions with redirects refused.
|
|
115
|
+
- `graphql`: a saved operation document with validated input as its variables.
|
|
111
116
|
- `mcp`: a connection plus action projection. The composing MCP host supplies
|
|
112
117
|
the one MCP driver that owns discovery, sessions, and calls.
|
|
113
118
|
|
|
114
|
-
Secret values are never part of definitions. `secretEnv
|
|
115
|
-
contain opaque references resolved
|
|
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.
|
|
116
140
|
See [PURPOSE.md](./PURPOSE.md) for the complete contract and
|
|
117
141
|
[SHELL_INTEGRATION.md](./docs/SHELL_INTEGRATION.md) for active composition.
|
|
118
142
|
The [Engine integration](./docs/ENGINE_INTEGRATION.md) page is historical only.
|
package/dist/api-driver.d.ts
CHANGED
package/dist/api-driver.js
CHANGED
|
@@ -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?.
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
83
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
};
|
|
92
|
-
|
|
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 };
|
package/dist/cli-driver.js
CHANGED
|
@@ -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) {
|
|
@@ -20,7 +21,7 @@ function createCliDriver(environment = () => ({})) {
|
|
|
20
21
|
args.push(...inputArguments(input));
|
|
21
22
|
const output = await runCommand({
|
|
22
23
|
command: source.command, args, cwd: source.cwd,
|
|
23
|
-
env: { ...
|
|
24
|
+
env: { ...getDefaultEnvironment(), ...(await environment(tool)), ...secrets },
|
|
24
25
|
stdin: source.inputMode === 'json-stdin' ? `${JSON.stringify(input)}\n` : undefined,
|
|
25
26
|
timeoutMs: source.timeoutMs, maximumBytes: source.maxOutputBytes, signal: options.signal,
|
|
26
27
|
});
|
package/dist/commands.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { isActionMcpName } from './ids.js';
|
|
2
2
|
import { callMcpTool } from './mcp.js';
|
|
3
|
+
import { structuredValue } from './results.js';
|
|
3
4
|
function unwrap(name, result) {
|
|
4
5
|
if (result.isError)
|
|
5
|
-
throw new Error(result.content.map((item) => item.text).join('\n'));
|
|
6
|
-
return isActionMcpName(name) ? result : result.structuredContent;
|
|
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);
|
|
7
8
|
}
|
|
8
9
|
function createToolboxCommands(toolbox, loadout) {
|
|
9
10
|
return {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { credentialOperations } from './credential-operations.js';
|
|
2
|
+
export function validateCredential(value) {
|
|
3
|
+
if (value.type === 'secret') {
|
|
4
|
+
if (typeof value.value !== 'string' || !value.value)
|
|
5
|
+
throw new Error('A non-empty tool secret is required');
|
|
6
|
+
return { type: 'secret', value: value.value };
|
|
7
|
+
}
|
|
8
|
+
if (value.type !== 'oauth' || !value.accessToken || !value.clientId || !value.tokenUrl) {
|
|
9
|
+
throw new Error('OAuth requires accessToken, clientId, and tokenUrl');
|
|
10
|
+
}
|
|
11
|
+
const url = new URL(value.tokenUrl);
|
|
12
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password)
|
|
13
|
+
throw new Error('Invalid OAuth token endpoint');
|
|
14
|
+
if (value.expiresAt && !Number.isFinite(Date.parse(value.expiresAt)))
|
|
15
|
+
throw new Error('Invalid OAuth token expiry');
|
|
16
|
+
if (value.tokenEndpointAuthMethod && !['none', 'client_secret_basic', 'client_secret_post'].includes(value.tokenEndpointAuthMethod)) {
|
|
17
|
+
throw new Error('Unsupported OAuth token endpoint authentication');
|
|
18
|
+
}
|
|
19
|
+
return structuredClone(value);
|
|
20
|
+
}
|
|
21
|
+
async function refresh(current) {
|
|
22
|
+
if (!current.refreshToken)
|
|
23
|
+
throw new Error('Tool authorization expired; reconnect the tool');
|
|
24
|
+
const body = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: current.refreshToken, client_id: current.clientId });
|
|
25
|
+
const headers = { 'content-type': 'application/x-www-form-urlencoded' };
|
|
26
|
+
if (current.clientSecret) {
|
|
27
|
+
if (current.tokenEndpointAuthMethod === 'client_secret_basic') {
|
|
28
|
+
headers.Authorization = `Basic ${Buffer.from(`${encodeURIComponent(current.clientId)}:${encodeURIComponent(current.clientSecret)}`).toString('base64')}`;
|
|
29
|
+
}
|
|
30
|
+
else
|
|
31
|
+
body.set('client_secret', current.clientSecret);
|
|
32
|
+
}
|
|
33
|
+
if (current.scope)
|
|
34
|
+
body.set('scope', current.scope);
|
|
35
|
+
if (current.resource)
|
|
36
|
+
body.set('resource', current.resource);
|
|
37
|
+
const response = await fetch(current.tokenUrl, {
|
|
38
|
+
method: 'POST', headers, body, redirect: 'manual', signal: AbortSignal.timeout(15_000),
|
|
39
|
+
});
|
|
40
|
+
if (!response.ok)
|
|
41
|
+
throw new Error(`Tool authorization refresh failed (${response.status}); reconnect the tool`);
|
|
42
|
+
const value = await response.json();
|
|
43
|
+
if (typeof value.access_token !== 'string' || !value.access_token)
|
|
44
|
+
throw new Error('OAuth refresh returned no access token');
|
|
45
|
+
if (value.token_type && String(value.token_type).toLowerCase() !== 'bearer')
|
|
46
|
+
throw new Error('Unsupported OAuth token type');
|
|
47
|
+
const { expiresAt: _previousExpiry, ...retained } = current;
|
|
48
|
+
return {
|
|
49
|
+
...retained, accessToken: value.access_token,
|
|
50
|
+
...(typeof value.refresh_token === 'string' && value.refresh_token ? { refreshToken: value.refresh_token } : {}),
|
|
51
|
+
...(typeof value.expires_in === 'number' && value.expires_in > 0 ? { expiresAt: new Date(Date.now() + value.expires_in * 1000).toISOString() } : {}),
|
|
52
|
+
...(typeof value.scope === 'string' ? { scope: value.scope } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Token refresh belongs to the connection; encryption belongs to the host. */
|
|
56
|
+
export class ToolAuthentication {
|
|
57
|
+
store;
|
|
58
|
+
fallback;
|
|
59
|
+
#refreshing = new Map();
|
|
60
|
+
constructor(store, fallback) {
|
|
61
|
+
this.store = store;
|
|
62
|
+
this.fallback = fallback;
|
|
63
|
+
this.store = store && credentialOperations(store);
|
|
64
|
+
}
|
|
65
|
+
resolve = async (reference) => {
|
|
66
|
+
if (!reference.startsWith('tools:'))
|
|
67
|
+
return this.fallback?.(reference);
|
|
68
|
+
const pending = this.#refreshing.get(reference);
|
|
69
|
+
if (pending)
|
|
70
|
+
return pending;
|
|
71
|
+
const work = this.#resolve(reference);
|
|
72
|
+
this.#refreshing.set(reference, work);
|
|
73
|
+
try {
|
|
74
|
+
return await work;
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
if (this.#refreshing.get(reference) === work)
|
|
78
|
+
this.#refreshing.delete(reference);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
async #resolve(reference) {
|
|
82
|
+
const encoded = await this.store?.get(reference);
|
|
83
|
+
if (encoded === undefined)
|
|
84
|
+
return undefined;
|
|
85
|
+
const credential = validateCredential(JSON.parse(encoded));
|
|
86
|
+
if (credential.type === 'secret')
|
|
87
|
+
return credential.value;
|
|
88
|
+
if (!credential.expiresAt || Date.parse(credential.expiresAt) > Date.now() + 30_000) {
|
|
89
|
+
return `Bearer ${credential.accessToken}`;
|
|
90
|
+
}
|
|
91
|
+
const renewed = await refresh(credential);
|
|
92
|
+
// A disconnect or a new authorization wins over an in-flight refresh.
|
|
93
|
+
if (!await credentialOperations(this.store).replace(reference, encoded, JSON.stringify(renewed))) {
|
|
94
|
+
throw new Error('Tool authorization changed during refresh');
|
|
95
|
+
}
|
|
96
|
+
return `Bearer ${renewed.accessToken}`;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ConnectToolInput } from './connection-types.js';
|
|
2
|
+
import type { ActionDefinition, ToolSource } from './types.js';
|
|
3
|
+
export declare function sourceReferences(source: ToolSource): string[];
|
|
4
|
+
/** Credentials are authority-bound references, never portable definition bytes. */
|
|
5
|
+
export declare function connectionSource(input: ConnectToolInput): {
|
|
6
|
+
definition: {
|
|
7
|
+
source: ToolSource;
|
|
8
|
+
status: "enabled";
|
|
9
|
+
actions: ActionDefinition[];
|
|
10
|
+
origin: import("./types.js").Origin;
|
|
11
|
+
owner: string;
|
|
12
|
+
createdAt: string;
|
|
13
|
+
updatedAt: string;
|
|
14
|
+
description?: string | undefined;
|
|
15
|
+
name: string;
|
|
16
|
+
id: string;
|
|
17
|
+
policy?: Record<string, unknown> | undefined;
|
|
18
|
+
metadata?: Record<string, unknown> | undefined;
|
|
19
|
+
appId?: string | undefined;
|
|
20
|
+
display?: Record<string, unknown> | undefined;
|
|
21
|
+
};
|
|
22
|
+
staged: Map<string, string>;
|
|
23
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { canonicalJson } from './deployments.js';
|
|
3
|
+
import { normalizeDefinition } from './definition.js';
|
|
4
|
+
import { validateCredential } from './connection-auth.js';
|
|
5
|
+
export function sourceReferences(source) {
|
|
6
|
+
return [...new Set([
|
|
7
|
+
...('secretHeaders' in source ? Object.values(source.secretHeaders || {}) : []),
|
|
8
|
+
...('secretQuery' in source ? Object.values(source.secretQuery || {}) : []),
|
|
9
|
+
...('secretEnv' in source ? Object.values(source.secretEnv || {}) : []),
|
|
10
|
+
])];
|
|
11
|
+
}
|
|
12
|
+
/** Credentials are authority-bound references, never portable definition bytes. */
|
|
13
|
+
export function connectionSource(input) {
|
|
14
|
+
const { tool, actions } = normalizeDefinition(input.definition);
|
|
15
|
+
if (tool.origin === 'system' || (tool.source.type === 'mcp' && tool.source.transport === 'host')) {
|
|
16
|
+
throw new Error('Host-owned tools cannot be connected by a user');
|
|
17
|
+
}
|
|
18
|
+
const source = structuredClone(tool.source);
|
|
19
|
+
const identity = { ...source };
|
|
20
|
+
delete identity.secretHeaders;
|
|
21
|
+
delete identity.secretEnv;
|
|
22
|
+
delete identity.secretQuery;
|
|
23
|
+
const authority = createHash('sha256').update(canonicalJson(identity)).digest('hex').slice(0, 24);
|
|
24
|
+
const prefix = `tools:${tool.id}:${authority}:`;
|
|
25
|
+
const staged = new Map();
|
|
26
|
+
const add = (surface, name, value) => {
|
|
27
|
+
if (!name || /[\r\n]/.test(name))
|
|
28
|
+
throw new Error('Invalid tool credential field');
|
|
29
|
+
const field = surface === 'headers' ? 'secretHeaders' : surface === 'query' ? 'secretQuery' : 'secretEnv';
|
|
30
|
+
if (surface !== 'env' && (source.type === 'cli' || (source.type === 'mcp' && source.transport === 'stdio'))) {
|
|
31
|
+
throw new Error('This tool uses environment credentials, not HTTP headers');
|
|
32
|
+
}
|
|
33
|
+
if (surface === 'env' && source.type !== 'cli' && !(source.type === 'mcp' && source.transport === 'stdio')) {
|
|
34
|
+
throw new Error('This tool uses HTTP header credentials, not environment variables');
|
|
35
|
+
}
|
|
36
|
+
const target = source;
|
|
37
|
+
const refs = { ...target[field] };
|
|
38
|
+
const canonicalName = surface === 'headers' ? name.toLowerCase() : name;
|
|
39
|
+
for (const existing of Object.keys(refs))
|
|
40
|
+
if ((surface === 'headers' ? existing.toLowerCase() : existing) === canonicalName)
|
|
41
|
+
delete refs[existing];
|
|
42
|
+
const reference = `${prefix}${surface}:${canonicalName}`;
|
|
43
|
+
refs[name] = reference;
|
|
44
|
+
target[field] = refs;
|
|
45
|
+
staged.set(reference, JSON.stringify(validateCredential(value)));
|
|
46
|
+
};
|
|
47
|
+
for (const [name, value] of Object.entries(input.credentials?.headers || {}))
|
|
48
|
+
add('headers', name, { type: 'secret', value });
|
|
49
|
+
for (const [name, value] of Object.entries(input.credentials?.query || {}))
|
|
50
|
+
add('query', name, { type: 'secret', value });
|
|
51
|
+
for (const [name, value] of Object.entries(input.credentials?.env || {}))
|
|
52
|
+
add('env', name, { type: 'secret', value });
|
|
53
|
+
if (input.credentials?.oauth) {
|
|
54
|
+
const resource = source.type === 'mcp' ? source.url : source.type === 'graphql' ? source.url : source.type === 'api' ? source.baseUrl : undefined;
|
|
55
|
+
add('headers', 'Authorization', { ...input.credentials.oauth, type: 'oauth', ...(resource ? { resource } : {}) });
|
|
56
|
+
}
|
|
57
|
+
for (const ref of sourceReferences(source)) {
|
|
58
|
+
if (!ref.startsWith(prefix))
|
|
59
|
+
throw new Error('Tool credential reference belongs to another source; reconnect with its credentials');
|
|
60
|
+
}
|
|
61
|
+
return { definition: { ...tool, source, status: 'enabled', actions: actions }, staged };
|
|
62
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ToolDefinition } from './types.js';
|
|
2
|
+
/** The host owns encryption and physical persistence; Tools owns these bytes. */
|
|
3
|
+
export interface ToolCredentialStore {
|
|
4
|
+
get(reference: string): string | undefined | Promise<string | undefined>;
|
|
5
|
+
set(reference: string, value: string): void | Promise<void>;
|
|
6
|
+
delete(reference: string): void | Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
export interface OAuthCredential {
|
|
9
|
+
type: 'oauth';
|
|
10
|
+
accessToken: string;
|
|
11
|
+
refreshToken?: string;
|
|
12
|
+
expiresAt?: string;
|
|
13
|
+
tokenUrl: string;
|
|
14
|
+
clientId: string;
|
|
15
|
+
clientSecret?: string;
|
|
16
|
+
tokenEndpointAuthMethod?: 'client_secret_basic' | 'client_secret_post' | 'none';
|
|
17
|
+
scope?: string;
|
|
18
|
+
resource?: string;
|
|
19
|
+
}
|
|
20
|
+
export type ToolCredential = {
|
|
21
|
+
type: 'secret';
|
|
22
|
+
value: string;
|
|
23
|
+
} | OAuthCredential;
|
|
24
|
+
export interface ConnectionCredentials {
|
|
25
|
+
headers?: Record<string, string>;
|
|
26
|
+
query?: Record<string, string>;
|
|
27
|
+
env?: Record<string, string>;
|
|
28
|
+
oauth?: OAuthCredential;
|
|
29
|
+
}
|
|
30
|
+
export interface ConnectToolInput {
|
|
31
|
+
definition: ToolDefinition;
|
|
32
|
+
/** Write-only. Values never enter the definition, response, or deployment. */
|
|
33
|
+
credentials?: ConnectionCredentials;
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ConnectToolInput, ToolCredentialStore } from './connection-types.js';
|
|
2
|
+
import type { Toolbox } from './toolbox.js';
|
|
3
|
+
import type { ToolDriver, ToolRecord } from './types.js';
|
|
4
|
+
/** Connection transitions mutate the one Toolbox and the host-owned vault. */
|
|
5
|
+
export declare class ToolConnections {
|
|
6
|
+
#private;
|
|
7
|
+
private readonly toolbox;
|
|
8
|
+
private readonly drivers;
|
|
9
|
+
private readonly store?;
|
|
10
|
+
constructor(toolbox: Toolbox, drivers: ReadonlyMap<string, ToolDriver>, store?: ToolCredentialStore | undefined);
|
|
11
|
+
connect(input: ConnectToolInput, guard?: () => Promise<void>): Promise<import("./types.js").ApplyResult>;
|
|
12
|
+
disconnect(toolId: string): Promise<import("./types.js").ApplyResult>;
|
|
13
|
+
forget(tool: ToolRecord, retained?: Set<string>): Promise<void>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { credentialOperations } from './credential-operations.js';
|
|
2
|
+
import { ToolAuthentication } from './connection-auth.js';
|
|
3
|
+
import { authorizationReference } from './oauth.js';
|
|
4
|
+
import { connectionSource, sourceReferences } from './connection-source.js';
|
|
5
|
+
/** Connection transitions mutate the one Toolbox and the host-owned vault. */
|
|
6
|
+
export class ToolConnections {
|
|
7
|
+
toolbox;
|
|
8
|
+
drivers;
|
|
9
|
+
store;
|
|
10
|
+
#pending = new Map();
|
|
11
|
+
constructor(toolbox, drivers, store) {
|
|
12
|
+
this.toolbox = toolbox;
|
|
13
|
+
this.drivers = drivers;
|
|
14
|
+
this.store = store;
|
|
15
|
+
this.store = store && credentialOperations(store);
|
|
16
|
+
}
|
|
17
|
+
connect(input, guard) {
|
|
18
|
+
const { definition, staged } = connectionSource(input);
|
|
19
|
+
return this.#ordered(definition.id, async () => {
|
|
20
|
+
await guard?.();
|
|
21
|
+
if (this.toolbox.isSystemTool(definition.id))
|
|
22
|
+
throw new Error('System tools are immutable');
|
|
23
|
+
if (staged.size && !this.store)
|
|
24
|
+
throw new Error('Tool credential vault is unavailable');
|
|
25
|
+
const previous = this.toolbox.get(definition.id)?.tool;
|
|
26
|
+
const original = new Map();
|
|
27
|
+
const overlay = {
|
|
28
|
+
get: (ref) => staged.has(ref) ? staged.get(ref) : this.store?.get(ref),
|
|
29
|
+
set: (ref, value) => { staged.set(ref, value); },
|
|
30
|
+
delete: (ref) => { staged.delete(ref); },
|
|
31
|
+
};
|
|
32
|
+
const auth = new ToolAuthentication(overlay);
|
|
33
|
+
for (const ref of sourceReferences(definition.source)) {
|
|
34
|
+
if (await auth.resolve(ref) === undefined)
|
|
35
|
+
throw new Error('Tool authorization is missing; reconnect with credentials');
|
|
36
|
+
}
|
|
37
|
+
const driver = this.drivers.get(definition.source.type);
|
|
38
|
+
if (!driver || !(driver.supports?.(definition) ?? true))
|
|
39
|
+
throw new Error(`No ${definition.source.type} driver is configured`);
|
|
40
|
+
if (definition.source.type === 'mcp') {
|
|
41
|
+
if (!driver.discover)
|
|
42
|
+
throw new Error('MCP action discovery is unavailable');
|
|
43
|
+
definition.actions = await driver.discover(definition, { resolveSecret: auth.resolve });
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
await guard?.();
|
|
47
|
+
for (const [ref, value] of staged) {
|
|
48
|
+
original.set(ref, await this.store.get(ref));
|
|
49
|
+
await this.store.set(ref, value);
|
|
50
|
+
}
|
|
51
|
+
const result = await this.toolbox.apply(definition);
|
|
52
|
+
await this.store?.delete(authorizationReference(definition.id));
|
|
53
|
+
if (previous) {
|
|
54
|
+
const retained = new Set(sourceReferences(result.tool.source));
|
|
55
|
+
await this.forget(previous, retained);
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
for (const [ref, value] of original) {
|
|
61
|
+
if (value === undefined)
|
|
62
|
+
await this.store.delete(ref);
|
|
63
|
+
else
|
|
64
|
+
await this.store.set(ref, value);
|
|
65
|
+
}
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
disconnect(toolId) {
|
|
71
|
+
return this.#ordered(toolId, async () => {
|
|
72
|
+
const current = this.toolbox.get(toolId);
|
|
73
|
+
if (!current)
|
|
74
|
+
throw new Error(`Unknown tool: ${toolId}`);
|
|
75
|
+
await this.toolbox.setStatus(toolId, 'disabled');
|
|
76
|
+
return this.toolbox.get(toolId);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
async forget(tool, retained = new Set()) {
|
|
80
|
+
await this.store?.delete(authorizationReference(tool.id));
|
|
81
|
+
for (const ref of sourceReferences(tool.source)) {
|
|
82
|
+
if (ref.startsWith(`tools:${tool.id}:`) && !retained.has(ref))
|
|
83
|
+
await this.store?.delete(ref);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async #ordered(key, operation) {
|
|
87
|
+
const previous = this.#pending.get(key) || Promise.resolve();
|
|
88
|
+
const current = previous.catch(() => undefined).then(operation);
|
|
89
|
+
this.#pending.set(key, current);
|
|
90
|
+
try {
|
|
91
|
+
return await current;
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
if (this.#pending.get(key) === current)
|
|
95
|
+
this.#pending.delete(key);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ToolCredentialStore } from './connection-types.js';
|
|
2
|
+
interface SerializedStore extends ToolCredentialStore {
|
|
3
|
+
replace(reference: string, expected: string | undefined, value: string | undefined): Promise<boolean>;
|
|
4
|
+
}
|
|
5
|
+
/** All credential writes share an order; a stale refresh never recreates a deletion. */
|
|
6
|
+
export declare function credentialOperations(store: ToolCredentialStore): SerializedStore;
|
|
7
|
+
export {};
|