@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.
Files changed (51) hide show
  1. package/PURPOSE.md +33 -1
  2. package/README.md +29 -5
  3. package/dist/api-driver.d.ts +2 -1
  4. package/dist/api-driver.js +67 -48
  5. package/dist/cli-driver.js +2 -1
  6. package/dist/commands.js +3 -2
  7. package/dist/connection-auth.d.ts +11 -0
  8. package/dist/connection-auth.js +98 -0
  9. package/dist/connection-source.d.ts +23 -0
  10. package/dist/connection-source.js +62 -0
  11. package/dist/connection-types.d.ts +34 -0
  12. package/dist/connection-types.js +1 -0
  13. package/dist/connections.d.ts +14 -0
  14. package/dist/connections.js +98 -0
  15. package/dist/credential-operations.d.ts +7 -0
  16. package/dist/credential-operations.js +37 -0
  17. package/dist/definition.js +10 -3
  18. package/dist/http-mutations.d.ts +63 -0
  19. package/dist/http-mutations.js +41 -0
  20. package/dist/http.d.ts +2 -3
  21. package/dist/http.js +9 -3
  22. package/dist/index.d.ts +6 -0
  23. package/dist/index.js +2 -0
  24. package/dist/input.js +7 -32
  25. package/dist/loadout.d.ts +3 -0
  26. package/dist/loadout.js +23 -0
  27. package/dist/mcp-driver.d.ts +16 -0
  28. package/dist/mcp-driver.js +44 -0
  29. package/dist/mcp-search.d.ts +8 -0
  30. package/dist/mcp-search.js +51 -0
  31. package/dist/mcp-server.d.ts +4 -3
  32. package/dist/mcp-types.d.ts +13 -0
  33. package/dist/mcp-types.js +1 -0
  34. package/dist/mcp.d.ts +5 -4
  35. package/dist/mcp.js +26 -19
  36. package/dist/oauth.d.ts +29 -0
  37. package/dist/oauth.js +131 -0
  38. package/dist/prepared-catalog.d.ts +5 -0
  39. package/dist/prepared-catalog.js +44 -0
  40. package/dist/results.d.ts +4 -1
  41. package/dist/results.js +21 -16
  42. package/dist/selection.js +3 -0
  43. package/dist/toolbox-state-dir.d.ts +2 -0
  44. package/dist/toolbox-state-dir.js +18 -0
  45. package/dist/toolbox-view.d.ts +4 -1
  46. package/dist/toolbox-view.js +8 -2
  47. package/dist/toolbox.d.ts +10 -0
  48. package/dist/toolbox.js +29 -28
  49. package/dist/types.d.ts +27 -20
  50. package/docs/SHELL_INTEGRATION.md +8 -3
  51. 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 read adapter serves the legacy-compatible `GET /toolbox`,
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. Mutation routes return
95
- 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`.
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` and `secretHeaders`
115
- 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.
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.
@@ -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,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: { ...process.env, ...(await environment(tool)), ...secrets },
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 {};