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.
Files changed (64) hide show
  1. package/package.json +1 -2
  2. package/runtime/scripts/amalgm-mcp/agents/rest.js +29 -22
  3. package/runtime/scripts/amalgm-mcp/agents/store.js +90 -24
  4. package/runtime/scripts/amalgm-mcp/agents/talk.js +66 -37
  5. package/runtime/scripts/amalgm-mcp/agents/tools.js +0 -4
  6. package/runtime/scripts/amalgm-mcp/automations/runner.js +4 -5
  7. package/runtime/scripts/amalgm-mcp/automations/tool-actions.js +54 -57
  8. package/runtime/scripts/amalgm-mcp/automations/tools.js +4 -4
  9. package/runtime/scripts/amalgm-mcp/email/inbound.js +21 -0
  10. package/runtime/scripts/amalgm-mcp/events/internal-workflows.js +3 -3
  11. package/runtime/scripts/amalgm-mcp/index.js +3 -1
  12. package/runtime/scripts/amalgm-mcp/lib/prefs.js +15 -0
  13. package/runtime/scripts/amalgm-mcp/mcp-connections/rest.js +5 -3
  14. package/runtime/scripts/amalgm-mcp/runtime-worker.js +47 -0
  15. package/runtime/scripts/amalgm-mcp/server/combined-service.js +27 -0
  16. package/runtime/scripts/amalgm-mcp/server/control-plane-service.js +24 -0
  17. package/runtime/scripts/amalgm-mcp/server/core-tools.js +52 -7
  18. package/runtime/scripts/amalgm-mcp/server/http-context.js +44 -0
  19. package/runtime/scripts/amalgm-mcp/server/http-service.js +34 -0
  20. package/runtime/scripts/amalgm-mcp/server/http.js +31 -465
  21. package/runtime/scripts/amalgm-mcp/server/local-service-router.js +57 -0
  22. package/runtime/scripts/amalgm-mcp/server/mcp-adapter.js +22 -0
  23. package/runtime/scripts/amalgm-mcp/server/mcp-http-service.js +24 -0
  24. package/runtime/scripts/amalgm-mcp/server/mcp-request-context.js +32 -0
  25. package/runtime/scripts/amalgm-mcp/server/mcp.js +14 -73
  26. package/runtime/scripts/amalgm-mcp/server/routes/agents.js +29 -0
  27. package/runtime/scripts/amalgm-mcp/server/routes/apps.js +59 -0
  28. package/runtime/scripts/amalgm-mcp/server/routes/automations.js +45 -0
  29. package/runtime/scripts/amalgm-mcp/server/routes/browser.js +49 -0
  30. package/runtime/scripts/amalgm-mcp/server/routes/credentials.js +30 -0
  31. package/runtime/scripts/amalgm-mcp/server/routes/events.js +18 -0
  32. package/runtime/scripts/amalgm-mcp/server/routes/files.js +33 -0
  33. package/runtime/scripts/amalgm-mcp/server/routes/health.js +24 -0
  34. package/runtime/scripts/amalgm-mcp/server/routes/inbound.js +18 -0
  35. package/runtime/scripts/amalgm-mcp/server/routes/local.js +21 -0
  36. package/runtime/scripts/amalgm-mcp/server/routes/preferences.js +18 -0
  37. package/runtime/scripts/amalgm-mcp/server/routes/state.js +23 -0
  38. package/runtime/scripts/amalgm-mcp/server/routes/toolbox.js +59 -0
  39. package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +57 -0
  40. package/runtime/scripts/amalgm-mcp/server/service-lifecycle.js +54 -0
  41. package/runtime/scripts/amalgm-mcp/services/control-plane.js +19 -0
  42. package/runtime/scripts/amalgm-mcp/services/mcp-adapter.js +12 -0
  43. package/runtime/scripts/amalgm-mcp/services/runtime-worker.js +18 -0
  44. package/runtime/scripts/amalgm-mcp/slack/inbound.js +21 -0
  45. package/runtime/scripts/amalgm-mcp/state/snapshot.js +3 -3
  46. package/runtime/scripts/amalgm-mcp/tests/agents-source-of-truth.test.js +82 -0
  47. package/runtime/scripts/amalgm-mcp/tests/automations-store-runner.test.js +5 -5
  48. package/runtime/scripts/amalgm-mcp/tests/core-tools.test.js +37 -0
  49. package/runtime/scripts/amalgm-mcp/tests/mcp-surface.test.js +158 -0
  50. package/runtime/scripts/amalgm-mcp/tests/server-routing.test.js +81 -0
  51. package/runtime/scripts/amalgm-mcp/tests/server-services.test.js +156 -0
  52. package/runtime/scripts/amalgm-mcp/tests/system-catalog.test.js +81 -0
  53. package/runtime/scripts/amalgm-mcp/tests/toolbox-service.test.js +250 -0
  54. package/runtime/scripts/amalgm-mcp/toolbox/mcp-surface.js +106 -0
  55. package/runtime/scripts/amalgm-mcp/toolbox/names.js +41 -0
  56. package/runtime/scripts/amalgm-mcp/toolbox/rest.js +107 -53
  57. package/runtime/scripts/amalgm-mcp/toolbox/runner.js +9 -75
  58. package/runtime/scripts/amalgm-mcp/toolbox/selection.js +22 -0
  59. package/runtime/scripts/amalgm-mcp/toolbox/service.js +227 -0
  60. package/runtime/scripts/amalgm-mcp/toolbox/store.js +9 -78
  61. package/runtime/scripts/amalgm-mcp/toolbox/system-catalog.js +112 -0
  62. package/runtime/scripts/amalgm-mcp/toolbox/tools.js +11 -74
  63. package/runtime/scripts/amalgm-mcp/workflows/compiler.js +1 -1
  64. 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/store').readToolbox();
41
+ cache.toolbox ||= require('../toolbox/service').defaultToolboxService.readCatalog();
42
42
  return cache.toolbox;
43
43
  case 'tools':
44
- cache.toolbox ||= require('../toolbox/store').readToolbox();
44
+ cache.toolbox ||= require('../toolbox/service').defaultToolboxService.readCatalog();
45
45
  return cache.toolbox.tools;
46
46
  case 'tool_actions':
47
- cache.toolbox ||= require('../toolbox/store').readToolbox();
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
+ });