amalgm 0.1.72 → 0.1.73
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/package.json +1 -2
- package/runtime/scripts/amalgm-mcp/agents/rest.js +29 -22
- package/runtime/scripts/amalgm-mcp/agents/store.js +90 -24
- package/runtime/scripts/amalgm-mcp/agents/talk.js +66 -37
- package/runtime/scripts/amalgm-mcp/agents/tools.js +0 -4
- package/runtime/scripts/amalgm-mcp/automations/runner.js +4 -5
- package/runtime/scripts/amalgm-mcp/automations/tool-actions.js +54 -57
- package/runtime/scripts/amalgm-mcp/automations/tools.js +4 -4
- package/runtime/scripts/amalgm-mcp/email/inbound.js +21 -0
- package/runtime/scripts/amalgm-mcp/events/internal-workflows.js +3 -3
- package/runtime/scripts/amalgm-mcp/index.js +3 -1
- package/runtime/scripts/amalgm-mcp/lib/prefs.js +15 -0
- package/runtime/scripts/amalgm-mcp/mcp-connections/rest.js +5 -3
- package/runtime/scripts/amalgm-mcp/runtime-worker.js +47 -0
- package/runtime/scripts/amalgm-mcp/server/combined-service.js +27 -0
- package/runtime/scripts/amalgm-mcp/server/control-plane-service.js +24 -0
- package/runtime/scripts/amalgm-mcp/server/core-tools.js +52 -7
- package/runtime/scripts/amalgm-mcp/server/http-context.js +44 -0
- package/runtime/scripts/amalgm-mcp/server/http-service.js +34 -0
- package/runtime/scripts/amalgm-mcp/server/http.js +31 -465
- package/runtime/scripts/amalgm-mcp/server/local-service-router.js +57 -0
- package/runtime/scripts/amalgm-mcp/server/mcp-adapter.js +22 -0
- package/runtime/scripts/amalgm-mcp/server/mcp-http-service.js +24 -0
- package/runtime/scripts/amalgm-mcp/server/mcp-request-context.js +32 -0
- package/runtime/scripts/amalgm-mcp/server/mcp.js +14 -73
- package/runtime/scripts/amalgm-mcp/server/routes/agents.js +29 -0
- package/runtime/scripts/amalgm-mcp/server/routes/apps.js +59 -0
- package/runtime/scripts/amalgm-mcp/server/routes/automations.js +45 -0
- package/runtime/scripts/amalgm-mcp/server/routes/browser.js +49 -0
- package/runtime/scripts/amalgm-mcp/server/routes/credentials.js +30 -0
- package/runtime/scripts/amalgm-mcp/server/routes/events.js +18 -0
- package/runtime/scripts/amalgm-mcp/server/routes/files.js +33 -0
- package/runtime/scripts/amalgm-mcp/server/routes/health.js +24 -0
- package/runtime/scripts/amalgm-mcp/server/routes/inbound.js +18 -0
- package/runtime/scripts/amalgm-mcp/server/routes/local.js +21 -0
- package/runtime/scripts/amalgm-mcp/server/routes/preferences.js +18 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +23 -0
- package/runtime/scripts/amalgm-mcp/server/routes/toolbox.js +59 -0
- package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +57 -0
- package/runtime/scripts/amalgm-mcp/server/service-lifecycle.js +54 -0
- package/runtime/scripts/amalgm-mcp/services/control-plane.js +19 -0
- package/runtime/scripts/amalgm-mcp/services/mcp-adapter.js +12 -0
- package/runtime/scripts/amalgm-mcp/services/runtime-worker.js +18 -0
- package/runtime/scripts/amalgm-mcp/slack/inbound.js +21 -0
- package/runtime/scripts/amalgm-mcp/state/snapshot.js +3 -3
- package/runtime/scripts/amalgm-mcp/tests/agents-source-of-truth.test.js +82 -0
- package/runtime/scripts/amalgm-mcp/tests/automations-store-runner.test.js +5 -5
- package/runtime/scripts/amalgm-mcp/tests/core-tools.test.js +37 -0
- package/runtime/scripts/amalgm-mcp/tests/mcp-surface.test.js +158 -0
- package/runtime/scripts/amalgm-mcp/tests/server-routing.test.js +81 -0
- package/runtime/scripts/amalgm-mcp/tests/server-services.test.js +156 -0
- package/runtime/scripts/amalgm-mcp/tests/system-catalog.test.js +81 -0
- package/runtime/scripts/amalgm-mcp/tests/toolbox-service.test.js +250 -0
- package/runtime/scripts/amalgm-mcp/toolbox/mcp-surface.js +106 -0
- package/runtime/scripts/amalgm-mcp/toolbox/names.js +41 -0
- package/runtime/scripts/amalgm-mcp/toolbox/rest.js +107 -53
- package/runtime/scripts/amalgm-mcp/toolbox/runner.js +9 -75
- package/runtime/scripts/amalgm-mcp/toolbox/selection.js +22 -0
- package/runtime/scripts/amalgm-mcp/toolbox/service.js +227 -0
- package/runtime/scripts/amalgm-mcp/toolbox/store.js +9 -78
- package/runtime/scripts/amalgm-mcp/toolbox/system-catalog.js +112 -0
- package/runtime/scripts/amalgm-mcp/toolbox/tools.js +11 -74
- package/runtime/scripts/amalgm-mcp/workflows/compiler.js +1 -1
- package/runtime/scripts/amalgm-mcp/workflows/runner.js +4 -5
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { PORT, STORAGE_DIR } = require('../config');
|
|
4
|
+
const { hasSupabase } = require('../lib/supabase');
|
|
5
|
+
const { createControlPlaneServer } = require('../server/control-plane-service');
|
|
6
|
+
const { startHttpServer } = require('../server/service-lifecycle');
|
|
7
|
+
|
|
8
|
+
const port = parseInt(process.env.AMALGM_CONTROL_PLANE_PORT || String(PORT), 10);
|
|
9
|
+
|
|
10
|
+
startHttpServer(createControlPlaneServer(), {
|
|
11
|
+
port,
|
|
12
|
+
serviceName: 'AmalgmControlPlane',
|
|
13
|
+
onListening() {
|
|
14
|
+
console.log(`[AmalgmControlPlane] Storage: ${STORAGE_DIR}`);
|
|
15
|
+
console.log(
|
|
16
|
+
`[AmalgmControlPlane] Supabase: ${hasSupabase() ? 'connected' : 'not configured (runs are local-only)'}`,
|
|
17
|
+
);
|
|
18
|
+
},
|
|
19
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { PORT } = require('../config');
|
|
4
|
+
const { createMcpHttpServer } = require('../server/mcp-http-service');
|
|
5
|
+
const { startHttpServer } = require('../server/service-lifecycle');
|
|
6
|
+
|
|
7
|
+
const port = parseInt(process.env.AMALGM_MCP_ADAPTER_PORT || String(PORT), 10);
|
|
8
|
+
|
|
9
|
+
startHttpServer(createMcpHttpServer(), {
|
|
10
|
+
port,
|
|
11
|
+
serviceName: 'AmalgmMCPAdapter',
|
|
12
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
startRuntimeWorker,
|
|
5
|
+
stopRuntimeWorker,
|
|
6
|
+
} = require('../runtime-worker');
|
|
7
|
+
|
|
8
|
+
console.log('[AmalgmRuntimeWorker] Starting...');
|
|
9
|
+
startRuntimeWorker();
|
|
10
|
+
|
|
11
|
+
function shutdown() {
|
|
12
|
+
console.log('[AmalgmRuntimeWorker] Shutting down...');
|
|
13
|
+
stopRuntimeWorker();
|
|
14
|
+
process.exit(0);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
process.on('SIGTERM', shutdown);
|
|
18
|
+
process.on('SIGINT', shutdown);
|
|
@@ -39,6 +39,24 @@ function asString(value) {
|
|
|
39
39
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
function normalizeStringList(values) {
|
|
43
|
+
if (!Array.isArray(values)) return [];
|
|
44
|
+
return [...new Set(values
|
|
45
|
+
.filter((value) => typeof value === 'string')
|
|
46
|
+
.map((value) => value.trim())
|
|
47
|
+
.filter(Boolean))];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sessionToolConfig(metadata) {
|
|
51
|
+
const tools = metadata?.tools && typeof metadata.tools === 'object' && !Array.isArray(metadata.tools)
|
|
52
|
+
? metadata.tools
|
|
53
|
+
: {};
|
|
54
|
+
return {
|
|
55
|
+
mode: tools.mode === 'selected' ? 'selected' : 'all',
|
|
56
|
+
toolIds: normalizeStringList(tools.selectedToolIds),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
42
60
|
function isDefaultSessionTitle(title) {
|
|
43
61
|
return ['', 'New Chat', 'New session', 'New code session'].includes(String(title || '').trim());
|
|
44
62
|
}
|
|
@@ -113,6 +131,7 @@ async function handleSlackInbound(body, sendJson) {
|
|
|
113
131
|
const defaultAuthMethod = asString(metadata.authMethod) || resolveDefaultAuthMethod(baseHarnessId);
|
|
114
132
|
const cwd = asString(metadata.cwd) || DEFAULT_CWD;
|
|
115
133
|
const computerId = asString(metadata.computerId);
|
|
134
|
+
const toolConfig = sessionToolConfig(metadata);
|
|
116
135
|
const slackSystemPrompt = [
|
|
117
136
|
asString(metadata.systemPrompt),
|
|
118
137
|
'You are replying in Slack. Keep responses clear, direct, and Slack-friendly. Use concise Markdown/mrkdwn when helpful.',
|
|
@@ -129,6 +148,8 @@ async function handleSlackInbound(body, sendJson) {
|
|
|
129
148
|
},
|
|
130
149
|
tools: {
|
|
131
150
|
mcpAppIds: Array.isArray(metadata.mcpAppIds) ? metadata.mcpAppIds : [],
|
|
151
|
+
mode: toolConfig.mode,
|
|
152
|
+
toolIds: toolConfig.toolIds,
|
|
132
153
|
},
|
|
133
154
|
execution: {
|
|
134
155
|
cwd,
|
|
@@ -38,13 +38,13 @@ function readResource(resource, cache) {
|
|
|
38
38
|
case 'apps':
|
|
39
39
|
return require('../apps/store').loadApps().apps;
|
|
40
40
|
case 'toolbox':
|
|
41
|
-
cache.toolbox ||= require('../toolbox/
|
|
41
|
+
cache.toolbox ||= require('../toolbox/service').defaultToolboxService.readCatalog();
|
|
42
42
|
return cache.toolbox;
|
|
43
43
|
case 'tools':
|
|
44
|
-
cache.toolbox ||= require('../toolbox/
|
|
44
|
+
cache.toolbox ||= require('../toolbox/service').defaultToolboxService.readCatalog();
|
|
45
45
|
return cache.toolbox.tools;
|
|
46
46
|
case 'tool_actions':
|
|
47
|
-
cache.toolbox ||= require('../toolbox/
|
|
47
|
+
cache.toolbox ||= require('../toolbox/service').defaultToolboxService.readCatalog();
|
|
48
48
|
return cache.toolbox.toolActions;
|
|
49
49
|
case 'hooks':
|
|
50
50
|
return require('../agents/hooks').collectNativeHooks();
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-agents-source-test-'));
|
|
10
|
+
process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
11
|
+
|
|
12
|
+
const { closeLocalDb } = require('../state/db');
|
|
13
|
+
const { writeUserPreferences } = require('../lib/prefs');
|
|
14
|
+
const {
|
|
15
|
+
createAgent,
|
|
16
|
+
getAllAgentsWithBuiltins,
|
|
17
|
+
resolveAgent,
|
|
18
|
+
updateAgent,
|
|
19
|
+
} = require('../agents/store');
|
|
20
|
+
|
|
21
|
+
test.after(() => {
|
|
22
|
+
closeLocalDb();
|
|
23
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('default agent rows backfill prefs once then remain authoritative', () => {
|
|
27
|
+
writeUserPreferences({
|
|
28
|
+
usage: { codex: 'provider_auth' },
|
|
29
|
+
recent_prefs: { codex: 'openai/gpt-5.5' },
|
|
30
|
+
model_settings: {
|
|
31
|
+
'openai/gpt-5.5': { effort: 'low', fastMode: true },
|
|
32
|
+
},
|
|
33
|
+
last_used: {
|
|
34
|
+
harness: 'codex',
|
|
35
|
+
model: 'openai/gpt-5.5',
|
|
36
|
+
cwd: tempRoot,
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const seededCodex = getAllAgentsWithBuiltins().find((agent) => agent.id === 'codex');
|
|
41
|
+
assert.equal(seededCodex.authMethod, 'provider_auth');
|
|
42
|
+
assert.deepEqual(seededCodex.modelSettings, { effort: 'low', fastMode: true });
|
|
43
|
+
|
|
44
|
+
updateAgent('codex', {
|
|
45
|
+
authMethod: 'amalgm',
|
|
46
|
+
modelSettings: { effort: 'high' },
|
|
47
|
+
});
|
|
48
|
+
writeUserPreferences({
|
|
49
|
+
usage: { codex: 'provider_auth' },
|
|
50
|
+
recent_prefs: { codex: 'openai/gpt-5.5' },
|
|
51
|
+
model_settings: {
|
|
52
|
+
'openai/gpt-5.5': { effort: 'low', fastMode: true },
|
|
53
|
+
},
|
|
54
|
+
last_used: {
|
|
55
|
+
harness: 'codex',
|
|
56
|
+
model: 'openai/gpt-5.5',
|
|
57
|
+
cwd: tempRoot,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const preservedCodex = resolveAgent('codex');
|
|
62
|
+
assert.equal(preservedCodex.authMethod, 'amalgm');
|
|
63
|
+
assert.deepEqual(preservedCodex.modelSettings, { effort: 'high' });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('custom agent auth and model settings survive normalization', () => {
|
|
67
|
+
const agent = createAgent({
|
|
68
|
+
name: 'Provider Codex',
|
|
69
|
+
baseHarnessId: 'codex',
|
|
70
|
+
baseModelId: 'openai/gpt-5.5',
|
|
71
|
+
authMethod: 'provider_auth',
|
|
72
|
+
modelSettings: { effort: 'medium', fastMode: true },
|
|
73
|
+
tools: { mode: 'selected', selectedToolIds: ['tool.search'] },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
assert.equal(agent.authMethod, 'provider_auth');
|
|
77
|
+
assert.deepEqual(agent.modelSettings, { effort: 'medium', fastMode: true });
|
|
78
|
+
assert.deepEqual(agent.tools, {
|
|
79
|
+
mode: 'selected',
|
|
80
|
+
selectedToolIds: ['tool.search'],
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -158,14 +158,14 @@ test('workflow compiler accepts compact tool and agent helpers', () => {
|
|
|
158
158
|
trigger: event("github.push"),
|
|
159
159
|
cells: [
|
|
160
160
|
code("greet", async () => ({ message: "hello" })),
|
|
161
|
-
tool("notify", "amalgm.notify_user", ({ cells }) => ({ message: cells.greet.output.message })),
|
|
161
|
+
tool("notify", "amalgm.notifications.notify_user", ({ cells }) => ({ message: cells.greet.output.message })),
|
|
162
162
|
agent("ask_codex", "Codex", { prompt: "Review this", run_in_background: true })
|
|
163
163
|
]
|
|
164
164
|
})`);
|
|
165
165
|
|
|
166
|
-
assert.equal(ir.cells[1].toolId, 'amalgm');
|
|
166
|
+
assert.equal(ir.cells[1].toolId, 'amalgm.notifications');
|
|
167
167
|
assert.equal(ir.cells[1].actionName, 'notify_user');
|
|
168
|
-
assert.equal(ir.cells[2].toolId, 'amalgm');
|
|
168
|
+
assert.equal(ir.cells[2].toolId, 'amalgm.agents');
|
|
169
169
|
assert.equal(ir.cells[2].actionName, 'talk_to_agent');
|
|
170
170
|
assert.equal(ir.cells[2].args.agent, 'Codex');
|
|
171
171
|
});
|
|
@@ -187,7 +187,7 @@ test('automation validator catches dry-run cell reference failures without persi
|
|
|
187
187
|
trigger: event("test.validate"),
|
|
188
188
|
cells: [
|
|
189
189
|
code("greet", async () => ({ message: "hello" })),
|
|
190
|
-
tool("notify", "amalgm.notify_user", ({ cells }) => ({ message: cells.greet.output.message }))
|
|
190
|
+
tool("notify", "amalgm.notifications.notify_user", ({ cells }) => ({ message: cells.greet.output.message }))
|
|
191
191
|
]
|
|
192
192
|
})`);
|
|
193
193
|
|
|
@@ -198,7 +198,7 @@ test('automation validator catches dry-run cell reference failures without persi
|
|
|
198
198
|
trigger: event("test.validate"),
|
|
199
199
|
cells: [
|
|
200
200
|
code("greet", async () => ({ message: "hello" })),
|
|
201
|
-
tool("notify", "amalgm.notify_user", ({ cells }) => ({ message: cells.missing.output.message }))
|
|
201
|
+
tool("notify", "amalgm.notifications.notify_user", ({ cells }) => ({ message: cells.missing.output.message }))
|
|
202
202
|
]
|
|
203
203
|
})`);
|
|
204
204
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const test = require('node:test');
|
|
5
|
+
|
|
6
|
+
const { CORE_TOOL_GROUPS, CORE_TOOLS } = require('../server/core-tools');
|
|
7
|
+
|
|
8
|
+
test('core tools are grouped into first-party toolbox-style defaults', () => {
|
|
9
|
+
assert.deepEqual(CORE_TOOL_GROUPS.map((group) => group.id), [
|
|
10
|
+
'amalgm.automations',
|
|
11
|
+
'amalgm.notifications',
|
|
12
|
+
'amalgm.agents',
|
|
13
|
+
'amalgm.apps',
|
|
14
|
+
'amalgm.browser',
|
|
15
|
+
]);
|
|
16
|
+
assert.equal(CORE_TOOL_GROUPS.every((group) => group.origin === 'system'), true);
|
|
17
|
+
assert.equal(CORE_TOOL_GROUPS.every((group) => group.owner === 'amalgm'), true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('core tool grouping preserves handler definitions for MCP surface wrapping', () => {
|
|
21
|
+
const groupedTools = CORE_TOOL_GROUPS.flatMap((group) => group.tools);
|
|
22
|
+
|
|
23
|
+
assert.equal(CORE_TOOLS.length, groupedTools.length);
|
|
24
|
+
assert.deepEqual(CORE_TOOLS.map((tool) => tool.name), groupedTools.map((tool) => tool.name));
|
|
25
|
+
assert.equal(CORE_TOOLS.some((tool) => tool.name === 'browser_navigate'), true);
|
|
26
|
+
assert.equal(CORE_TOOLS.some((tool) => tool.name === 'notify_user'), true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('core tool actions carry stable toolbox ids', () => {
|
|
30
|
+
const browserNavigate = CORE_TOOLS.find((tool) => tool.name === 'browser_navigate');
|
|
31
|
+
const notifyUser = CORE_TOOLS.find((tool) => tool.name === 'notify_user');
|
|
32
|
+
|
|
33
|
+
assert.equal(browserNavigate.toolboxToolId, 'amalgm.browser');
|
|
34
|
+
assert.equal(browserNavigate.toolboxActionId, 'amalgm.browser.browser_navigate');
|
|
35
|
+
assert.equal(notifyUser.toolboxToolId, 'amalgm.notifications');
|
|
36
|
+
assert.equal(notifyUser.toolboxActionId, 'amalgm.notifications.notify_user');
|
|
37
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const test = require('node:test');
|
|
5
|
+
|
|
6
|
+
const { textResult } = require('../lib/tool-result');
|
|
7
|
+
const {
|
|
8
|
+
createToolboxMcpSurface,
|
|
9
|
+
mcpNameForStaticTool,
|
|
10
|
+
toolDescriptor,
|
|
11
|
+
} = require('../toolbox/mcp-surface');
|
|
12
|
+
|
|
13
|
+
function staticTools() {
|
|
14
|
+
return [
|
|
15
|
+
{
|
|
16
|
+
name: 'static_echo',
|
|
17
|
+
description: 'Echo through a static tool.',
|
|
18
|
+
inputSchema: { type: 'object', properties: { value: { type: 'string' } } },
|
|
19
|
+
toolboxToolId: 'amalgm.test',
|
|
20
|
+
toolboxActionId: 'amalgm.test.static_echo',
|
|
21
|
+
async handler(args, context) {
|
|
22
|
+
return textResult(JSON.stringify({ args, callerSessionId: context.callerSessionId }));
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: 'static_fail',
|
|
27
|
+
description: 'Throw from a static tool.',
|
|
28
|
+
inputSchema: { type: 'object', properties: {} },
|
|
29
|
+
toolboxToolId: 'amalgm.failures',
|
|
30
|
+
toolboxActionId: 'amalgm.failures.static_fail',
|
|
31
|
+
async handler() {
|
|
32
|
+
throw new Error('planned failure');
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test('toolDescriptor exposes only MCP descriptor fields', () => {
|
|
39
|
+
assert.deepEqual(toolDescriptor({
|
|
40
|
+
name: 'x',
|
|
41
|
+
description: 'X',
|
|
42
|
+
inputSchema: { type: 'object', properties: {} },
|
|
43
|
+
handler: async () => textResult('ignored'),
|
|
44
|
+
privateField: true,
|
|
45
|
+
}), {
|
|
46
|
+
name: 'x',
|
|
47
|
+
description: 'X',
|
|
48
|
+
inputSchema: { type: 'object', properties: {} },
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('mcpNameForStaticTool uses Toolbox action ids for first-party tools', () => {
|
|
53
|
+
assert.equal(mcpNameForStaticTool(staticTools()[0]), 'toolbox__amalgm_test_static_echo');
|
|
54
|
+
assert.equal(mcpNameForStaticTool({ name: 'toolbox_list' }), 'toolbox_list');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('MCP surface lists static tools plus dynamic toolbox tools', async () => {
|
|
58
|
+
const calls = [];
|
|
59
|
+
const surface = createToolboxMcpSurface({
|
|
60
|
+
staticTools: staticTools(),
|
|
61
|
+
async listDynamicTools(context) {
|
|
62
|
+
calls.push(context);
|
|
63
|
+
return [{
|
|
64
|
+
name: 'toolbox__dynamic_run',
|
|
65
|
+
description: 'Run dynamic action.',
|
|
66
|
+
inputSchema: { type: 'object', properties: { ok: { type: 'boolean' } } },
|
|
67
|
+
}];
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const tools = await surface.listTools({ callerSessionId: 'session-1' });
|
|
72
|
+
|
|
73
|
+
assert.deepEqual(calls, [{ callerSessionId: 'session-1' }]);
|
|
74
|
+
assert.deepEqual(tools.map((tool) => tool.name), [
|
|
75
|
+
'toolbox__amalgm_test_static_echo',
|
|
76
|
+
'toolbox__amalgm_failures_static_fail',
|
|
77
|
+
'toolbox__dynamic_run',
|
|
78
|
+
]);
|
|
79
|
+
assert.equal(tools[0].handler, undefined);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('MCP surface calls static and dynamic tools', async () => {
|
|
83
|
+
const dynamicCalls = [];
|
|
84
|
+
const surface = createToolboxMcpSurface({
|
|
85
|
+
staticTools: staticTools(),
|
|
86
|
+
async callDynamicTool(toolName, args, context) {
|
|
87
|
+
dynamicCalls.push({ toolName, args, context });
|
|
88
|
+
if (toolName === 'toolbox__dynamic_run') {
|
|
89
|
+
return textResult(JSON.stringify({ dynamic: true, args }));
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const staticResult = await surface.callTool('toolbox__amalgm_test_static_echo', { value: 'hi' }, { callerSessionId: 'session-2' });
|
|
96
|
+
const dynamicResult = await surface.callTool('toolbox__dynamic_run', { ok: true }, { callerSessionId: 'session-3' });
|
|
97
|
+
const missingResult = await surface.callTool('missing_tool', {}, { callerSessionId: 'session-4' });
|
|
98
|
+
|
|
99
|
+
assert.deepEqual(JSON.parse(staticResult.content[0].text), {
|
|
100
|
+
args: { value: 'hi' },
|
|
101
|
+
callerSessionId: 'session-2',
|
|
102
|
+
});
|
|
103
|
+
assert.deepEqual(JSON.parse(dynamicResult.content[0].text), {
|
|
104
|
+
dynamic: true,
|
|
105
|
+
args: { ok: true },
|
|
106
|
+
});
|
|
107
|
+
assert.deepEqual(dynamicCalls, [
|
|
108
|
+
{
|
|
109
|
+
toolName: 'toolbox__dynamic_run',
|
|
110
|
+
args: { ok: true },
|
|
111
|
+
context: { callerSessionId: 'session-3' },
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
toolName: 'missing_tool',
|
|
115
|
+
args: {},
|
|
116
|
+
context: { callerSessionId: 'session-4' },
|
|
117
|
+
},
|
|
118
|
+
]);
|
|
119
|
+
assert.equal(missingResult.isError, true);
|
|
120
|
+
assert.equal(missingResult.content[0].text, 'Unknown tool: missing_tool');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('MCP surface filters static tools in selected-tool mode', async () => {
|
|
124
|
+
const surface = createToolboxMcpSurface({
|
|
125
|
+
staticTools: staticTools(),
|
|
126
|
+
async listDynamicTools() {
|
|
127
|
+
return [];
|
|
128
|
+
},
|
|
129
|
+
async callDynamicTool() {
|
|
130
|
+
return null;
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
const context = {
|
|
134
|
+
sessionMetadata: {
|
|
135
|
+
tools: {
|
|
136
|
+
mode: 'selected',
|
|
137
|
+
selectedToolIds: ['amalgm.test'],
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const listed = await surface.listTools(context);
|
|
143
|
+
const selectedResult = await surface.callTool('toolbox__amalgm_test_static_echo', { value: 'ok' }, context);
|
|
144
|
+
const unselectedResult = await surface.callTool('toolbox__amalgm_failures_static_fail', {}, context);
|
|
145
|
+
|
|
146
|
+
assert.deepEqual(listed.map((tool) => tool.name), ['toolbox__amalgm_test_static_echo']);
|
|
147
|
+
assert.equal(JSON.parse(selectedResult.content[0].text).args.value, 'ok');
|
|
148
|
+
assert.equal(unselectedResult.isError, true);
|
|
149
|
+
assert.equal(unselectedResult.content[0].text, 'Unknown tool: toolbox__amalgm_failures_static_fail');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('MCP surface converts static handler errors into MCP errors', async () => {
|
|
153
|
+
const surface = createToolboxMcpSurface({ staticTools: staticTools() });
|
|
154
|
+
const result = await surface.callTool('toolbox__amalgm_failures_static_fail', {}, {});
|
|
155
|
+
|
|
156
|
+
assert.equal(result.isError, true);
|
|
157
|
+
assert.equal(result.content[0].text, 'Error: planned failure');
|
|
158
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
process.env.AMALGM_RUNTIME_AUTH = 'disabled';
|
|
4
|
+
process.env.AMALGM_DIR = require('node:fs').mkdtempSync(
|
|
5
|
+
require('node:path').join(require('node:os').tmpdir(), 'amalgm-server-routing-'),
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
const assert = require('node:assert/strict');
|
|
9
|
+
const test = require('node:test');
|
|
10
|
+
|
|
11
|
+
const { createServer } = require('../server/http');
|
|
12
|
+
const { createLocalServiceRouter } = require('../server/local-service-router');
|
|
13
|
+
const { requestPathname, requestQuery } = require('../server/http-context');
|
|
14
|
+
const { isMcpRequest } = require('../server/mcp-adapter');
|
|
15
|
+
|
|
16
|
+
function listen(server) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
server.once('error', reject);
|
|
19
|
+
server.listen(0, '127.0.0.1', () => {
|
|
20
|
+
server.off('error', reject);
|
|
21
|
+
resolve(server.address().port);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function close(server) {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function fakeReq(url, method = 'GET') {
|
|
33
|
+
return { url, method, headers: {} };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('HTTP context parses path and query independently', () => {
|
|
37
|
+
assert.equal(requestPathname(fakeReq('/toolbox/tools?id=amalgm')), '/toolbox/tools');
|
|
38
|
+
assert.deepEqual(requestQuery(fakeReq('/toolbox/tools?id=amalgm')), { id: 'amalgm' });
|
|
39
|
+
assert.equal(isMcpRequest('/mcp'), true);
|
|
40
|
+
assert.equal(isMcpRequest('/mcp/'), true);
|
|
41
|
+
assert.equal(isMcpRequest('/toolbox'), false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('local service router stops at the first handled route group', async () => {
|
|
45
|
+
const calls = [];
|
|
46
|
+
const router = createLocalServiceRouter([
|
|
47
|
+
async () => {
|
|
48
|
+
calls.push('first');
|
|
49
|
+
return false;
|
|
50
|
+
},
|
|
51
|
+
async () => {
|
|
52
|
+
calls.push('second');
|
|
53
|
+
return true;
|
|
54
|
+
},
|
|
55
|
+
async () => {
|
|
56
|
+
calls.push('third');
|
|
57
|
+
return true;
|
|
58
|
+
},
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
assert.equal(await router.handle({}), true);
|
|
62
|
+
assert.deepEqual(calls, ['first', 'second']);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('server dispatches non-DB local routes and 404 routes', async () => {
|
|
66
|
+
const server = createServer();
|
|
67
|
+
const port = await listen(server);
|
|
68
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const events = await fetch(`${baseUrl}/events`);
|
|
72
|
+
assert.equal(events.status, 200);
|
|
73
|
+
assert.deepEqual(await events.json(), { events: [] });
|
|
74
|
+
|
|
75
|
+
const missing = await fetch(`${baseUrl}/definitely-missing`);
|
|
76
|
+
assert.equal(missing.status, 404);
|
|
77
|
+
assert.equal(await missing.text(), 'Not found');
|
|
78
|
+
} finally {
|
|
79
|
+
await close(server);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
process.env.AMALGM_RUNTIME_AUTH = 'disabled';
|
|
4
|
+
process.env.AMALGM_DIR = require('node:fs').mkdtempSync(
|
|
5
|
+
require('node:path').join(require('node:os').tmpdir(), 'amalgm-server-services-'),
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
const assert = require('node:assert/strict');
|
|
9
|
+
const test = require('node:test');
|
|
10
|
+
|
|
11
|
+
const { createRuntimeWorker } = require('../runtime-worker');
|
|
12
|
+
const { createCombinedServer } = require('../server/combined-service');
|
|
13
|
+
const { createControlPlaneServer } = require('../server/control-plane-service');
|
|
14
|
+
const { createMcpHttpServer } = require('../server/mcp-http-service');
|
|
15
|
+
const { startHttpServer } = require('../server/service-lifecycle');
|
|
16
|
+
|
|
17
|
+
function listen(server) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
server.once('error', reject);
|
|
20
|
+
server.listen(0, '127.0.0.1', () => {
|
|
21
|
+
server.off('error', reject);
|
|
22
|
+
resolve(server.address().port);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function close(server) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function controlRoute(calls = []) {
|
|
34
|
+
return async (ctx) => {
|
|
35
|
+
calls.push(`control:${ctx.pathname}`);
|
|
36
|
+
if (ctx.pathname !== '/control') return false;
|
|
37
|
+
ctx.sendJson(200, { service: 'control-plane' });
|
|
38
|
+
return true;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function mcpRoute(calls = []) {
|
|
43
|
+
return async (ctx) => {
|
|
44
|
+
calls.push(`mcp:${ctx.pathname}`);
|
|
45
|
+
if (ctx.pathname !== '/mcp') return false;
|
|
46
|
+
ctx.sendJson(200, { service: 'mcp-adapter' });
|
|
47
|
+
return true;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('runtime worker lifecycle is idempotent', () => {
|
|
52
|
+
const calls = [];
|
|
53
|
+
const worker = createRuntimeWorker({
|
|
54
|
+
startAutomationScheduler() {
|
|
55
|
+
calls.push('start');
|
|
56
|
+
},
|
|
57
|
+
stopAutomationScheduler() {
|
|
58
|
+
calls.push('stop');
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
assert.equal(worker.running, false);
|
|
63
|
+
assert.equal(worker.start(), true);
|
|
64
|
+
assert.equal(worker.running, true);
|
|
65
|
+
assert.equal(worker.start(), false);
|
|
66
|
+
assert.equal(worker.stop(), true);
|
|
67
|
+
assert.equal(worker.running, false);
|
|
68
|
+
assert.equal(worker.stop(), false);
|
|
69
|
+
assert.deepEqual(calls, ['start', 'stop']);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('service lifecycle starts HTTP servers without installing shutdown handlers', async () => {
|
|
73
|
+
const server = createControlPlaneServer({ routeGroups: [controlRoute()] });
|
|
74
|
+
startHttpServer(server, {
|
|
75
|
+
port: 0,
|
|
76
|
+
serviceName: 'TestControlPlane',
|
|
77
|
+
installShutdown: false,
|
|
78
|
+
});
|
|
79
|
+
await new Promise((resolve) => server.once('listening', resolve));
|
|
80
|
+
const port = server.address().port;
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const control = await fetch(`http://127.0.0.1:${port}/control`);
|
|
84
|
+
assert.equal(control.status, 200);
|
|
85
|
+
assert.deepEqual(await control.json(), { service: 'control-plane' });
|
|
86
|
+
} finally {
|
|
87
|
+
await close(server);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('control-plane server handles only local API routes', async () => {
|
|
92
|
+
const server = createControlPlaneServer({ routeGroups: [controlRoute()] });
|
|
93
|
+
const port = await listen(server);
|
|
94
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const control = await fetch(`${baseUrl}/control`);
|
|
98
|
+
assert.equal(control.status, 200);
|
|
99
|
+
assert.deepEqual(await control.json(), { service: 'control-plane' });
|
|
100
|
+
|
|
101
|
+
const mcp = await fetch(`${baseUrl}/mcp`);
|
|
102
|
+
assert.equal(mcp.status, 404);
|
|
103
|
+
} finally {
|
|
104
|
+
await close(server);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('MCP HTTP server handles only MCP routes', async () => {
|
|
109
|
+
const server = createMcpHttpServer({ mcpRequestHandler: mcpRoute() });
|
|
110
|
+
const port = await listen(server);
|
|
111
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const mcp = await fetch(`${baseUrl}/mcp`);
|
|
115
|
+
assert.equal(mcp.status, 200);
|
|
116
|
+
assert.deepEqual(await mcp.json(), { service: 'mcp-adapter' });
|
|
117
|
+
|
|
118
|
+
const control = await fetch(`${baseUrl}/control`);
|
|
119
|
+
assert.equal(control.status, 404);
|
|
120
|
+
} finally {
|
|
121
|
+
await close(server);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('combined server tries MCP before control-plane routes', async () => {
|
|
126
|
+
const calls = [];
|
|
127
|
+
const server = createCombinedServer({
|
|
128
|
+
mcpRequestHandler: mcpRoute(calls),
|
|
129
|
+
routeGroups: [controlRoute(calls)],
|
|
130
|
+
});
|
|
131
|
+
const port = await listen(server);
|
|
132
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const mcp = await fetch(`${baseUrl}/mcp`);
|
|
136
|
+
assert.equal(mcp.status, 200);
|
|
137
|
+
assert.deepEqual(await mcp.json(), { service: 'mcp-adapter' });
|
|
138
|
+
|
|
139
|
+
const control = await fetch(`${baseUrl}/control`);
|
|
140
|
+
assert.equal(control.status, 200);
|
|
141
|
+
assert.deepEqual(await control.json(), { service: 'control-plane' });
|
|
142
|
+
|
|
143
|
+
const missing = await fetch(`${baseUrl}/missing`);
|
|
144
|
+
assert.equal(missing.status, 404);
|
|
145
|
+
} finally {
|
|
146
|
+
await close(server);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
assert.deepEqual(calls, [
|
|
150
|
+
'mcp:/mcp',
|
|
151
|
+
'mcp:/control',
|
|
152
|
+
'control:/control',
|
|
153
|
+
'mcp:/missing',
|
|
154
|
+
'control:/missing',
|
|
155
|
+
]);
|
|
156
|
+
});
|