sap-ai-dev-toolkit 0.5.5 → 0.5.6
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/.github/agents/abap-developer.agent.md +3 -3
- package/.github/agents/abap-runtime-debugger.agent.md +3 -3
- package/.github/agents/hana-cloud-hdi-specialist.agent.md +1 -1
- package/.github/agents/rap-service-developer.agent.md +1 -1
- package/.github/agents/sap-solution-architect.agent.md +1 -1
- package/.github/skills/abap-debugging/SKILL.md +3 -3
- package/.github/skills/abap-development/SKILL.md +1 -1
- package/.github/skills/abap-runtime-analysis/SKILL.md +3 -3
- package/.github/skills/abap-testing-quality/SKILL.md +1 -1
- package/.github/skills/cds-development/SKILL.md +1 -1
- package/.github/skills/clean-core-extensibility/SKILL.md +1 -1
- package/.github/skills/hana-cloud-inspection/SKILL.md +1 -1
- package/.github/skills/hana-cloud-native-development/SKILL.md +2 -2
- package/.github/skills/hana-cloud-validation/SKILL.md +1 -1
- package/.github/skills/rap-development/SKILL.md +1 -1
- package/.github/skills/rap-service-delivery/SKILL.md +1 -1
- package/.github/skills/sap-sdlc-orchestration/SKILL.md +1 -1
- package/.github/skills/sap-standard-api-analysis/SKILL.md +1 -1
- package/.github/skills/sap-transport-release/SKILL.md +1 -1
- package/README.md +6 -6
- package/inventory.md +179 -0
- package/package.json +2 -2
- package/scripts/build-vsp.mjs +2 -1
- package/scripts/postinstall.mjs +14 -16
- package/src/bas-destination-relay.mjs +25 -3
- package/src/bas-discovery.mjs +9 -3
- package/src/binary.mjs +101 -17
- package/src/cf-destination.mjs +55 -1
- package/src/credentials-store.mjs +0 -10
- package/src/engineering-tools.mjs +32 -10
- package/src/hana-tools.mjs +9 -2
- package/src/launcher.mjs +8 -11
- package/src/mcp-config.mjs +19 -1
- package/src/mcp-proxy.mjs +230 -76
- package/src/redact.mjs +28 -0
- package/src/setup.mjs +30 -1
- package/tools.md +1 -1
- package/test/bas-destination-relay.test.mjs +0 -415
- package/test/cf-connectivity.test.mjs +0 -287
- package/test/cf-destination.test.mjs +0 -307
- package/test/cf-runtime.test.mjs +0 -527
- package/test/copilot-assets.test.mjs +0 -63
- package/test/copilot-content.test.mjs +0 -249
- package/test/credentials-store.test.mjs +0 -187
- package/test/discovery.test.mjs +0 -282
- package/test/fixtures/fake-vsp.mjs +0 -266
- package/test/hana-config.test.mjs +0 -168
- package/test/hana-inspector-stdio.test.mjs +0 -44
- package/test/hana-tools.test.mjs +0 -206
- package/test/launcher.test.mjs +0 -401
- package/test/live-s4h.test.mjs +0 -204
- package/test/mcp-config-cf.test.mjs +0 -475
- package/test/mcp-proxy.test.mjs +0 -604
- package/test/pty.mjs +0 -8
- package/test/setup-cf.test.mjs +0 -489
- package/test/setup.test.mjs +0 -417
- package/test/terminal-ui.test.mjs +0 -47
package/test/mcp-proxy.test.mjs
DELETED
|
@@ -1,604 +0,0 @@
|
|
|
1
|
-
import test from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
5
|
-
import { join } from 'node:path';
|
|
6
|
-
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import { once } from 'node:events';
|
|
8
|
-
import { childArguments, MCPProxy, PUBLIC_VSP_TOOLS, snakeCaseName, TOOL_NAME_PREFIX } from '../src/mcp-proxy.mjs';
|
|
9
|
-
const MCP_LOG_LEVELS = ['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency'];
|
|
10
|
-
import { PassThrough } from 'node:stream';
|
|
11
|
-
|
|
12
|
-
const publicName = (destination, name) => `${TOOL_NAME_PREFIX}${destination}_${snakeCaseName(name)}`;
|
|
13
|
-
|
|
14
|
-
const fixture = fileURLToPath(new URL('./fixtures/fake-vsp.mjs', import.meta.url));
|
|
15
|
-
const HIDDEN_VSP_TOOLS = [
|
|
16
|
-
'AnalyzeABAPCode', 'DebuggerListen', 'DeleteBreakpoint', 'DeleteObject', 'ExecuteABAP', 'ReleaseTransport',
|
|
17
|
-
'DeleteTransport', 'SAP', 'ListSQLTraces', 'PublishServiceBinding', 'UnpublishServiceBinding'
|
|
18
|
-
];
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
async function fixtureProxy(logger = () => {}) {
|
|
22
|
-
const directory = await mkdtemp(join(tmpdir(), 'bas-vsp-test-'));
|
|
23
|
-
const log = join(directory, 'children.log');
|
|
24
|
-
const destinations = ['alpha', 'beta'].map(name => ({ name, url: `http://${name}.dest`, client: '001' }));
|
|
25
|
-
const proxy = new MCPProxy({ binary: fixture, destinations, env: { ...process.env, SAP_AI_DEV_TOOLKIT_DESTINATION: 'alpha', SAP_AI_DEV_TOOLKIT_MODE: 'expert', FAKE_LOG: log, SAP_ALLOW_TRANSPORTABLE_EDITS: 'true', SAP_USER: 'must-not-pass', SAP_PASSWORD: 'must-not-pass', Authorization: 'Bearer must-not-pass', Cookie: 'secret', SAP_READ_ONLY: 'true' }, log: logger });
|
|
26
|
-
proxy.start();
|
|
27
|
-
return { directory, log, proxy };
|
|
28
|
-
}
|
|
29
|
-
test('uses authentication-specific arguments for Cloud Foundry children', () => {
|
|
30
|
-
const bas = { name: 'bas', url: 'http://bas.dest', client: '001' };
|
|
31
|
-
assert.deepEqual(childArguments(bas, {}), [
|
|
32
|
-
'--url', 'http://bas.dest', '--client', '001', '--mode', 'expert', '--proxy-auth', '--enable-transports'
|
|
33
|
-
]);
|
|
34
|
-
const noAuth = { source: 'cloud-foundry', name: 'internet', url: 'http://sap.example', client: '100', authentication: 'NoAuthentication' };
|
|
35
|
-
const basic = { ...noAuth, authentication: 'BasicAuthentication' };
|
|
36
|
-
const principal = { ...noAuth, proxyType: 'OnPremise', authentication: 'PrincipalPropagation' };
|
|
37
|
-
assert.equal(childArguments(noAuth, {}).includes('--proxy-auth'), false);
|
|
38
|
-
assert.equal(childArguments(basic, {}).includes('--proxy-auth'), false);
|
|
39
|
-
assert.equal(childArguments(principal, {}).includes('--proxy-auth'), true);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test('self-heals BAS destinations through a local destination relay without credentials', async t => {
|
|
43
|
-
const destination = { name: 'S4H', url: 'http://S4H.dest', client: '100', authentication: 'BasicAuthentication', proxyType: 'Internet' };
|
|
44
|
-
const directory = await mkdtemp(join(tmpdir(), 'bas-relay-'));
|
|
45
|
-
const log = join(directory, 'children.log');
|
|
46
|
-
const logs = [];
|
|
47
|
-
const proxy = new MCPProxy({ binary: fixture, destinations: [destination], env: { ...process.env, FAKE_LOG: log }, log: message => logs.push(message) });
|
|
48
|
-
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
49
|
-
proxy.start();
|
|
50
|
-
await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
|
51
|
-
const event = (await readFile(log, 'utf8')).trim().split('\n').map(line => JSON.parse(line)).find(row => row.event === 'initialize');
|
|
52
|
-
assert.equal(event.argv.includes('--proxy-auth'), true);
|
|
53
|
-
assert.match(event.argv[event.argv.indexOf('--url') + 1], /^http:\/\/127\.0\.0\.1:\d+$/);
|
|
54
|
-
assert.equal(event.env.user, undefined);
|
|
55
|
-
assert.equal(event.env.password, undefined);
|
|
56
|
-
assert.ok(logs.some(message => message.includes('BAS destination relay enabled')));
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test('self-heals BAS relay children with loopback NO_PROXY and keeps --proxy-auth', async t => {
|
|
60
|
-
const destination = { name: 'S4H', url: 'http://S4H.dest', client: '100', authentication: 'BasicAuthentication', proxyType: 'Internet' };
|
|
61
|
-
const directory = await mkdtemp(join(tmpdir(), 'bas-relay-env-'));
|
|
62
|
-
const log = join(directory, 'children.log');
|
|
63
|
-
const proxy = new MCPProxy({ binary: fixture, destinations: [destination], env: { ...process.env, FAKE_LOG: log }, log: () => {} });
|
|
64
|
-
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
65
|
-
proxy.start();
|
|
66
|
-
await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
|
67
|
-
const event = (await readFile(log, 'utf8')).trim().split('\n').map(line => JSON.parse(line)).find(row => row.event === 'initialize');
|
|
68
|
-
assert.equal(event.argv.includes('--proxy-auth'), true);
|
|
69
|
-
const noProxy = String(event.env.noProxy || '').split(',').map(value => value.trim().toLowerCase());
|
|
70
|
-
assert.ok(noProxy.includes('127.0.0.1'), `NO_PROXY must exempt the relay loopback: ${event.env.noProxy}`);
|
|
71
|
-
assert.ok(noProxy.includes('localhost'), `NO_PROXY must exempt relay localhost: ${event.env.noProxy}`);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test('restarts a crashed VSP child and retries the tool call once', async t => {
|
|
75
|
-
const { directory, log, proxy } = await fixtureProxy();
|
|
76
|
-
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
77
|
-
await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
|
|
78
|
-
await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
79
|
-
const entry = proxy.children[0];
|
|
80
|
-
const originalChild = entry.child;
|
|
81
|
-
// Kill the child behind the proxy's back to simulate a crash.
|
|
82
|
-
const exit = once(originalChild.process, 'exit');
|
|
83
|
-
originalChild.process.kill('SIGKILL');
|
|
84
|
-
await exit;
|
|
85
|
-
const response = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'sap_system_alpha_get_system_info', arguments: {} } });
|
|
86
|
-
assert.equal(response?.error, undefined, `self-healing must recover the call: ${JSON.stringify(response?.error)}`);
|
|
87
|
-
assert.notEqual(proxy.children[0].child, originalChild, 'a replacement child must have been spawned');
|
|
88
|
-
const events = (await readFile(log, 'utf8')).trim().split('\n').map(line => JSON.parse(line));
|
|
89
|
-
assert.ok(events.some(row => row.event === 'initialize' && row.destination === 'alpha'), 'replacement child must re-initialize');
|
|
90
|
-
assert.ok(events.some(row => row.event === 'call' && row.name === 'GetSystemInfo'), 'the retried call must reach the child');
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test('closes each Cloud Foundry route after child exit and on startup failure', async t => {
|
|
94
|
-
const directory = await mkdtemp(join(tmpdir(), 'bas-cf-child-lifecycle-'));
|
|
95
|
-
const log = join(directory, 'children.log');
|
|
96
|
-
let closeCount = 0;
|
|
97
|
-
const destination = {
|
|
98
|
-
source: 'cloud-foundry',
|
|
99
|
-
name: 'runtime',
|
|
100
|
-
url: 'http://runtime.dest',
|
|
101
|
-
client: '001',
|
|
102
|
-
authentication: 'BasicAuthentication',
|
|
103
|
-
childEnv: { SAP_USER: 'destination-user', SAP_PASSWORD: 'destination-password', SAP_VERBOSE: 'false' },
|
|
104
|
-
async close() { closeCount += 1; }
|
|
105
|
-
};
|
|
106
|
-
const proxy = new MCPProxy({
|
|
107
|
-
binary: fixture,
|
|
108
|
-
destinations: [destination],
|
|
109
|
-
env: { ...process.env, FAKE_LOG: log, SAP_USER: 'parent-user', SAP_PASSWORD: 'parent-password' },
|
|
110
|
-
log: () => {}
|
|
111
|
-
});
|
|
112
|
-
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
113
|
-
proxy.start();
|
|
114
|
-
const initialized = await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
|
115
|
-
assert.ok(initialized.result);
|
|
116
|
-
const child = proxy.children[0].child;
|
|
117
|
-
const exit = once(child.process, 'exit');
|
|
118
|
-
child.process.kill('SIGTERM');
|
|
119
|
-
await exit;
|
|
120
|
-
await new Promise(resolve => setImmediate(resolve));
|
|
121
|
-
assert.equal(closeCount, 1);
|
|
122
|
-
const event = (await readFile(log, 'utf8')).trim().split('\n').map(line => JSON.parse(line)).find(row => row.event === 'initialize');
|
|
123
|
-
assert.equal(event.argv.includes('--proxy-auth'), false);
|
|
124
|
-
assert.deepEqual([event.env.user, event.env.password, event.env.verbose], ['destination-user', 'destination-password', 'false']);
|
|
125
|
-
await proxy.close();
|
|
126
|
-
assert.equal(closeCount, 1);
|
|
127
|
-
|
|
128
|
-
let failedStartCloseCount = 0;
|
|
129
|
-
const failedProxy = new MCPProxy({
|
|
130
|
-
binary: fixture,
|
|
131
|
-
destinations: [{ ...destination, async close() { failedStartCloseCount += 1; } }],
|
|
132
|
-
spawn: () => { throw new Error('spawn denied'); },
|
|
133
|
-
log: () => {}
|
|
134
|
-
});
|
|
135
|
-
failedProxy.start();
|
|
136
|
-
await assert.rejects(() => failedProxy.starting, /No destination child could be started/);
|
|
137
|
-
await new Promise(resolve => setImmediate(resolve));
|
|
138
|
-
assert.equal(failedStartCloseCount, 1);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
test('merges paged tools and routes calls to the selected child', async t => {
|
|
142
|
-
const { directory, log, proxy } = await fixtureProxy();
|
|
143
|
-
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
144
|
-
|
|
145
|
-
const initialized = await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
|
|
146
|
-
assert.equal(initialized.result.serverInfo.name, 'alpha');
|
|
147
|
-
assert.equal(initialized.result.capabilities.tools.listChanged, true);
|
|
148
|
-
const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
149
|
-
const names = new Set(listed.result.tools.map(tool => tool.name));
|
|
150
|
-
const queryTool = listed.result.tools.find(tool => tool.name === 'sap_system_beta_run_query');
|
|
151
|
-
assert.deepEqual(queryTool.inputSchema, {
|
|
152
|
-
type: 'object',
|
|
153
|
-
properties: { sql_query: { type: 'string' }, max_rows: { type: 'number' }, all_rows: { type: 'boolean' } },
|
|
154
|
-
required: ['sql_query']
|
|
155
|
-
});
|
|
156
|
-
assert.equal(queryTool.description, 'RunQuery [destination: beta]');
|
|
157
|
-
const lintTool = listed.result.tools.find(tool => tool.name === 'sap_system_alpha_lint_abap');
|
|
158
|
-
assert.deepEqual(Object.keys(lintTool.inputSchema.properties).sort(), ['config', 'files']);
|
|
159
|
-
assert.deepEqual(lintTool.inputSchema.required, ['files']);
|
|
160
|
-
assert.deepEqual(lintTool.inputSchema.properties.files.items.required, ['filename', 'source']);
|
|
161
|
-
|
|
162
|
-
const localWorkflowTools = [
|
|
163
|
-
'lint_abap', 'get_application_log', 'prepare_abap_change_set', 'apply_abap_change_set',
|
|
164
|
-
'check_transport_readiness', 'plan_abap_cloud_migration', 'generate_rap_regression_suite', 'run_rap_regression_suite'
|
|
165
|
-
];
|
|
166
|
-
for (const destination of ['alpha', 'beta']) {
|
|
167
|
-
for (const toolName of PUBLIC_VSP_TOOLS) {
|
|
168
|
-
assert.ok(names.has(`${TOOL_NAME_PREFIX}${destination}_${snakeCaseName(toolName)}`), `${destination} exposes curated ${toolName}`);
|
|
169
|
-
}
|
|
170
|
-
for (const toolName of localWorkflowTools) {
|
|
171
|
-
assert.ok(names.has(`${TOOL_NAME_PREFIX}${destination}_${toolName}`), `${destination} exposes local ${toolName}`);
|
|
172
|
-
}
|
|
173
|
-
for (const hiddenTool of HIDDEN_VSP_TOOLS) {
|
|
174
|
-
assert.equal(names.has(`${TOOL_NAME_PREFIX}${destination}_${snakeCaseName(hiddenTool)}`), false, `${destination} hides ${hiddenTool}`);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
assert.equal(listed.result.tools.filter(tool => tool.name.startsWith('sap_system_alpha_')).length, PUBLIC_VSP_TOOLS.size + localWorkflowTools.length);
|
|
178
|
-
assert.equal(PUBLIC_VSP_TOOLS.size + localWorkflowTools.length, 59);
|
|
179
|
-
for (const tool of listed.result.tools) {
|
|
180
|
-
assert.match(tool.name, /^[a-z0-9_-]+$/, `public tool names must be lowercase: ${tool.name}`);
|
|
181
|
-
assert.ok(tool.name.length <= 128, `public tool names must stay within the MCP limit: ${tool.name}`);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const requiredArguments = {
|
|
185
|
-
GetFeatures: [],
|
|
186
|
-
GetAPIReleaseState: ['object_uri'],
|
|
187
|
-
PrettyPrint: ['source'],
|
|
188
|
-
ListTransports: [],
|
|
189
|
-
GetTransport: ['transport'],
|
|
190
|
-
GetUserTransports: [],
|
|
191
|
-
GetTransportInfo: ['object_url', 'dev_class'],
|
|
192
|
-
ActivateMultiple: ['objects'],
|
|
193
|
-
GetApplicationLog: []
|
|
194
|
-
};
|
|
195
|
-
for (const [toolName, required] of Object.entries(requiredArguments)) {
|
|
196
|
-
const tool = listed.result.tools.find(entry => entry.name === publicName('alpha', toolName));
|
|
197
|
-
assert.deepEqual(tool.inputSchema.required, required);
|
|
198
|
-
}
|
|
199
|
-
const applicationLog = listed.result.tools.find(entry => entry.name === 'sap_system_alpha_get_application_log');
|
|
200
|
-
assert.deepEqual(Object.keys(applicationLog.inputSchema.properties).sort(), [
|
|
201
|
-
'from', 'max_results', 'messages', 'object', 'program', 'subobject', 'to', 'user'
|
|
202
|
-
]);
|
|
203
|
-
|
|
204
|
-
const read = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'sap_system_beta_get_source', arguments: { object: 'ZREAD' } } });
|
|
205
|
-
const write = await proxy.handle({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'sap_system_alpha_write_source', arguments: { source: 'WRITE' } } });
|
|
206
|
-
const query = await proxy.handle({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'sap_system_beta_run_query', arguments: { sql_query: 'SELECT * FROM T000' } } });
|
|
207
|
-
const system = await proxy.handle({ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'sap_system_alpha_get_system_info', arguments: {} } });
|
|
208
|
-
assert.equal(read.result.content[0].text, 'beta:GetSource');
|
|
209
|
-
assert.equal(write.result.content[0].text, 'alpha:WriteSource');
|
|
210
|
-
assert.equal(query.result.content[0].text, 'beta:RunQuery');
|
|
211
|
-
assert.equal(system.result.content[0].text, 'alpha:GetSystemInfo');
|
|
212
|
-
|
|
213
|
-
const additionalCalls = [
|
|
214
|
-
['GetFeatures', {}],
|
|
215
|
-
['GetAPIReleaseState', { object_uri: '/sap/bc/adt/oo/classes/cl_abap_typedescr' }],
|
|
216
|
-
['PrettyPrint', { source: 'WRITE / 1.' }],
|
|
217
|
-
['ListTransports', {}],
|
|
218
|
-
['GetTransport', { transport: 'A4HK900094' }],
|
|
219
|
-
['GetUserTransports', { user_name: 'DEVUSER' }],
|
|
220
|
-
['GetTransportInfo', { object_url: '/sap/bc/adt/oo/classes/zcl_demo', dev_class: 'ZPKG' }],
|
|
221
|
-
['GetApplicationLog', {
|
|
222
|
-
program: 'ZDEMO_POST',
|
|
223
|
-
user: 'TESTUSER',
|
|
224
|
-
object: 'ZDEMO_LOG',
|
|
225
|
-
subobject: 'POST',
|
|
226
|
-
from: '2026-08-01',
|
|
227
|
-
to: '2026-08-31',
|
|
228
|
-
max_results: 20,
|
|
229
|
-
messages: true,
|
|
230
|
-
action: 'delete',
|
|
231
|
-
type: 'delete',
|
|
232
|
-
params: { type: 'delete' }
|
|
233
|
-
}],
|
|
234
|
-
['ActivateMultiple', { objects: ['PROG ZDEMO'] }],
|
|
235
|
-
['CreateTransport', {}]
|
|
236
|
-
];
|
|
237
|
-
for (const [index, [name, arguments_]] of additionalCalls.entries()) {
|
|
238
|
-
const response = await proxy.handle({
|
|
239
|
-
jsonrpc: '2.0',
|
|
240
|
-
id: 7 + index,
|
|
241
|
-
method: 'tools/call',
|
|
242
|
-
params: { name: publicName('alpha', name), arguments: arguments_ }
|
|
243
|
-
});
|
|
244
|
-
assert.equal(response.result.content[0].text, name === 'GetApplicationLog' ? 'alpha:SAP' : `alpha:${name}`);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
const entries = (await readFile(log, 'utf8')).trim().split('\n').map(line => JSON.parse(line));
|
|
248
|
-
const calls = entries.filter(entry => entry.event === 'call');
|
|
249
|
-
assert.deepEqual(calls.map(call => [call.destination, call.name]), [
|
|
250
|
-
['beta', 'GetSource'], ['alpha', 'WriteSource'], ['beta', 'RunQuery'], ['alpha', 'GetSystemInfo'],
|
|
251
|
-
...additionalCalls.map(([name]) => ['alpha', name === 'GetApplicationLog' ? 'SAP' : name])
|
|
252
|
-
]);
|
|
253
|
-
assert.deepEqual(calls.map(call => call.arguments), [
|
|
254
|
-
{ object: 'ZREAD' }, { source: 'WRITE' }, { sql_query: 'SELECT * FROM T000' }, {},
|
|
255
|
-
...additionalCalls.map(([name, arguments_]) => name === 'GetApplicationLog'
|
|
256
|
-
? {
|
|
257
|
-
action: 'analyze',
|
|
258
|
-
params: {
|
|
259
|
-
type: 'application_log',
|
|
260
|
-
program: arguments_.program,
|
|
261
|
-
user: arguments_.user,
|
|
262
|
-
object: arguments_.object,
|
|
263
|
-
subobject: arguments_.subobject,
|
|
264
|
-
from: arguments_.from,
|
|
265
|
-
to: arguments_.to,
|
|
266
|
-
max_results: arguments_.max_results,
|
|
267
|
-
messages: arguments_.messages
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
: arguments_)
|
|
271
|
-
]);
|
|
272
|
-
|
|
273
|
-
for (const init of entries.filter(entry => entry.event === 'initialize')) {
|
|
274
|
-
assert.equal(init.env.guard, 'true');
|
|
275
|
-
assert.equal(init.env.authorization, undefined);
|
|
276
|
-
assert.equal(init.env.cookie, undefined);
|
|
277
|
-
assert.equal(init.env.user, undefined);
|
|
278
|
-
assert.equal(init.env.password, undefined);
|
|
279
|
-
assert.equal(init.env.allowTransportableEdits, 'true');
|
|
280
|
-
}
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
test('routes tools/call using the exact name advertised for a BAS destination', async t => {
|
|
284
|
-
const directory = await mkdtemp(join(tmpdir(), 'bas-namespaced-tool-roundtrip-'));
|
|
285
|
-
const log = join(directory, 'children.log');
|
|
286
|
-
const logs = [];
|
|
287
|
-
const proxy = new MCPProxy({
|
|
288
|
-
binary: fixture,
|
|
289
|
-
destinations: [{ name: 'ActionS4D_100', url: 'http://ActionS4D_100.dest', client: '100' }],
|
|
290
|
-
env: { ...process.env, SAP_AI_DEV_TOOLKIT_MODE: 'expert', FAKE_LOG: log },
|
|
291
|
-
log: message => logs.push(message)
|
|
292
|
-
});
|
|
293
|
-
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
294
|
-
proxy.start();
|
|
295
|
-
const initialized = await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
|
|
296
|
-
assert.equal(initialized.error, undefined, `${JSON.stringify(initialized.error)} ${logs.join(' | ')}`);
|
|
297
|
-
const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
298
|
-
assert.equal(listed.error, undefined, JSON.stringify(listed.error));
|
|
299
|
-
const tool = listed.result.tools.find(candidate => candidate.name === 'sap_system_actions4d-100_write_source');
|
|
300
|
-
assert.ok(tool, 'tools/list must publish the normalized destination namespace');
|
|
301
|
-
const called = await proxy.handle({
|
|
302
|
-
jsonrpc: '2.0',
|
|
303
|
-
id: 3,
|
|
304
|
-
method: 'tools/call',
|
|
305
|
-
params: { name: tool.name, arguments: { source: 'REPORT zself_heal.' } }
|
|
306
|
-
});
|
|
307
|
-
assert.equal(called.error, undefined);
|
|
308
|
-
assert.equal(called.result.content[0].text, 'ActionS4D_100:WriteSource');
|
|
309
|
-
const calls = (await readFile(log, 'utf8')).trim().split('\n').map(line => JSON.parse(line)).filter(entry => entry.event === 'call');
|
|
310
|
-
assert.deepEqual(calls.map(entry => entry.name), ['WriteSource']);
|
|
311
|
-
});
|
|
312
|
-
test('runs LintABAP locally and returns parser findings', async t => {
|
|
313
|
-
const { directory, log, proxy } = await fixtureProxy();
|
|
314
|
-
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
315
|
-
|
|
316
|
-
await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
|
|
317
|
-
const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
318
|
-
assert.ok(listed.result.tools.some(tool => tool.name === 'sap_system_alpha_lint_abap'));
|
|
319
|
-
const called = await proxy.handle({
|
|
320
|
-
jsonrpc: '2.0',
|
|
321
|
-
id: 3,
|
|
322
|
-
method: 'tools/call',
|
|
323
|
-
params: {
|
|
324
|
-
name: 'sap_system_alpha_lint_abap',
|
|
325
|
-
arguments: { files: [{ filename: 'zparser_error.prog.abap', source: 'blah blah.' }] }
|
|
326
|
-
}
|
|
327
|
-
});
|
|
328
|
-
assert.equal(called.result.isError, false);
|
|
329
|
-
const report = JSON.parse(called.result.content[0].text);
|
|
330
|
-
assert.equal(report.status, 'issues');
|
|
331
|
-
assert.ok(report.issues.some(issue =>
|
|
332
|
-
issue.rule === 'parser_error' &&
|
|
333
|
-
issue.filename === 'zparser_error.prog.abap' &&
|
|
334
|
-
issue.start.line === 1
|
|
335
|
-
));
|
|
336
|
-
|
|
337
|
-
const entries = (await readFile(log, 'utf8')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
|
|
338
|
-
assert.equal(entries.some(entry => entry.event === 'call' && entry.name === 'LintABAP'), false);
|
|
339
|
-
});
|
|
340
|
-
test('exposes local lint when a destination tool listing fails', async t => {
|
|
341
|
-
const { directory, proxy } = await fixtureProxy();
|
|
342
|
-
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
343
|
-
|
|
344
|
-
// Patch before initialize so the post-initialize cache warm sees the failure.
|
|
345
|
-
await proxy.starting;
|
|
346
|
-
const alpha = proxy.children.find(entry => entry.destination.name === 'alpha');
|
|
347
|
-
alpha.child.listTools = async () => { throw new Error('fixture tools/list failure'); };
|
|
348
|
-
await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
|
349
|
-
|
|
350
|
-
const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
351
|
-
assert.ok(listed.result.tools.some(tool => tool.name === 'sap_system_alpha_lint_abap'));
|
|
352
|
-
assert.equal(listed.result.tools.some(tool => tool.name === 'sap_system_alpha_get_source'), false);
|
|
353
|
-
const called = await proxy.handle({
|
|
354
|
-
jsonrpc: '2.0',
|
|
355
|
-
id: 3,
|
|
356
|
-
method: 'tools/call',
|
|
357
|
-
params: { name: 'sap_system_alpha_lint_abap', arguments: { files: [{ filename: 'zparser_error.prog.abap', source: 'blah blah.' }] } }
|
|
358
|
-
});
|
|
359
|
-
assert.equal(called.result.isError, false);
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
test('returns JSON-RPC errors for unknown namespaces and logs child failures', async t => {
|
|
363
|
-
const logs = [];
|
|
364
|
-
const { directory, proxy } = await fixtureProxy(message => logs.push(message));
|
|
365
|
-
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
366
|
-
await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
|
367
|
-
await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
368
|
-
const unknown = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'sap_system_missing_get_source' } });
|
|
369
|
-
assert.equal(unknown.error.code, -32602);
|
|
370
|
-
const remoteError = await proxy.handle({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'sap_system_alpha_get_source', arguments: { object: 'RPC_ERROR' } } });
|
|
371
|
-
assert.equal(remoteError.error.code, -32042);
|
|
372
|
-
assert.ok(logs.some(message => message.includes('backend connection refused') && message.includes('password=[redacted]') && !message.includes('must-not-log')));
|
|
373
|
-
const toolError = await proxy.handle({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'sap_system_beta_get_source', arguments: { object: 'TOOL_ERROR' } } });
|
|
374
|
-
assert.equal(toolError.result.isError, true);
|
|
375
|
-
assert.ok(logs.some(message => message.includes('backend timeout') && message.includes('token=[redacted]') && !message.includes('must-not-log')));
|
|
376
|
-
const success = await proxy.handle({ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'sap_system_alpha_write_source', arguments: { source: 'must-not-log' } } });
|
|
377
|
-
assert.equal(success.result.content[0].text, 'alpha:WriteSource');
|
|
378
|
-
assert.ok(logs.some(message => /\[alpha\] tools\/call WriteSource completed in \d+ms/.test(message)));
|
|
379
|
-
assert.equal(logs.some(message => message.includes('must-not-log')), false);
|
|
380
|
-
const child = proxy.children.find(entry => entry.destination.name === 'alpha').child;
|
|
381
|
-
child.process.kill('SIGKILL');
|
|
382
|
-
await new Promise(resolve => setTimeout(resolve, 25));
|
|
383
|
-
// A crashed child no longer fails the call: self-healing restarts it and
|
|
384
|
-
// retries once, so the request goes through to a fresh child.
|
|
385
|
-
const healed = await proxy.handle({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'sap_system_alpha_get_source' } });
|
|
386
|
-
assert.equal(healed?.error, undefined, `call must heal after child crash: ${JSON.stringify(healed?.error)}`);
|
|
387
|
-
assert.equal(healed.result.content[0].text, 'alpha:GetSource');
|
|
388
|
-
assert.ok(logs.some(message => message.includes('alpha') && message.includes('tools/call GetSource failed')));
|
|
389
|
-
assert.ok(logs.some(message => message.includes('self-healing restart complete')), 'restart must be logged');
|
|
390
|
-
});
|
|
391
|
-
test('stdin EOF shuts down every child process and forwards child logs', async t => {
|
|
392
|
-
const { directory, log, proxy } = await fixtureProxy();
|
|
393
|
-
t.after(async () => { await rm(directory, { recursive: true, force: true }); });
|
|
394
|
-
const input = new PassThrough();
|
|
395
|
-
const output = [];
|
|
396
|
-
const serving = proxy.serve(input, line => output.push(JSON.parse(line)));
|
|
397
|
-
input.end([
|
|
398
|
-
{ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
|
|
399
|
-
{ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} },
|
|
400
|
-
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'sap_system_alpha_get_source', arguments: { emitNotification: true } } }
|
|
401
|
-
].map(message => JSON.stringify(message)).join('\n') + '\n');
|
|
402
|
-
await serving;
|
|
403
|
-
assert.ok(output.some(message => message.method === 'notifications/message' && message.params?.data === 'fixture log notification'));
|
|
404
|
-
const entries = (await readFile(log, 'utf8')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
|
|
405
|
-
assert.equal(entries.filter(entry => entry.event === 'initialize').length, 2);
|
|
406
|
-
assert.deepEqual([...new Set(entries.filter(entry => entry.event === 'term').map(entry => entry.destination))].sort(), ['alpha', 'beta']);
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
async function driveUntil(output, predicate, timeoutMs = 5000) {
|
|
410
|
-
const deadline = Date.now() + timeoutMs;
|
|
411
|
-
while (!output.some(predicate) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 10));
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
test('streams lifecycle and tool-call events to the client as MCP log notifications', async t => {
|
|
415
|
-
const { directory, proxy } = await fixtureProxy();
|
|
416
|
-
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
417
|
-
const input = new PassThrough();
|
|
418
|
-
const output = [];
|
|
419
|
-
const serving = proxy.serve(input, line => output.push(JSON.parse(line)));
|
|
420
|
-
input.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }) + '\n');
|
|
421
|
-
await driveUntil(output, message => message.id === 1);
|
|
422
|
-
// A real client lists tools before calling one; this also guarantees the
|
|
423
|
-
// merged namespace is built before the call.
|
|
424
|
-
input.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }) + '\n');
|
|
425
|
-
await driveUntil(output, message => message.id === 2);
|
|
426
|
-
input.write(JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: publicName('alpha', 'GetSource'), arguments: {} } }) + '\n');
|
|
427
|
-
await driveUntil(output, message => message.id === 3);
|
|
428
|
-
input.end();
|
|
429
|
-
await serving;
|
|
430
|
-
const logs = output.filter(message => message.method === 'notifications/message');
|
|
431
|
-
assert.ok(logs.length > 0, 'proxy events must reach the client as notifications/message');
|
|
432
|
-
assert.ok(logs.some(message => message.params.level === 'info' && String(message.params.data).includes('VSP MCP session initialized')), 'startup events buffered before initialize must flush');
|
|
433
|
-
assert.ok(logs.some(message => String(message.params.data).includes('tools/call GetSource started')), 'tool-call start must be logged');
|
|
434
|
-
assert.ok(logs.some(message => String(message.params.data).includes('tools/call GetSource completed')), 'tool-call completion must be logged');
|
|
435
|
-
assert.ok(logs.every(message => typeof message.params.logger === 'string' && MCP_LOG_LEVELS.includes(message.params.level)), 'every log notification carries a logger and a valid level');
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
test('honors logging/setLevel and keeps writing stderr regardless of level', async t => {
|
|
439
|
-
const stderr = [];
|
|
440
|
-
const { directory, proxy } = await fixtureProxy(message => stderr.push(message));
|
|
441
|
-
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
442
|
-
const input = new PassThrough();
|
|
443
|
-
const output = [];
|
|
444
|
-
const serving = proxy.serve(input, line => output.push(JSON.parse(line)));
|
|
445
|
-
input.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }) + '\n');
|
|
446
|
-
await driveUntil(output, message => message.id === 1);
|
|
447
|
-
input.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }) + '\n');
|
|
448
|
-
await driveUntil(output, message => message.id === 2);
|
|
449
|
-
input.write(JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'logging/setLevel', params: { level: 'error' } }) + '\n');
|
|
450
|
-
await driveUntil(output, message => message.id === 3);
|
|
451
|
-
output.length = 0;
|
|
452
|
-
input.write(JSON.stringify({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: publicName('alpha', 'GetSource'), arguments: {} } }) + '\n');
|
|
453
|
-
await driveUntil(output, message => message.id === 4);
|
|
454
|
-
input.end();
|
|
455
|
-
await serving;
|
|
456
|
-
assert.equal(output.some(message => message.method === 'notifications/message'), false, 'info events must be suppressed at logging/setLevel=error');
|
|
457
|
-
assert.ok(stderr.some(message => message.includes('tools/call GetSource completed')), 'stderr keeps receiving every event');
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
test('keeps healthy children when one destination fails initialization', async t => {
|
|
462
|
-
const directory = await mkdtemp(join(tmpdir(), 'bas-vsp-test-'));
|
|
463
|
-
const log = join(directory, 'children.log');
|
|
464
|
-
const proxy = new MCPProxy({ binary: fixture, destinations: ['broken', 'healthy'].map(name => ({ name, url: `http://${name}.dest`, client: '001' })), env: { ...process.env, FAKE_LOG: log }, log: () => {} });
|
|
465
|
-
proxy.start();
|
|
466
|
-
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
467
|
-
await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
|
468
|
-
const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
469
|
-
assert.ok(listed.result.tools.every(tool => tool.name.startsWith('sap_system_healthy_')));
|
|
470
|
-
});
|
|
471
|
-
|
|
472
|
-
test('converts upstream tool names to lowercase snake_case for chat binding', () => {
|
|
473
|
-
const cases = new Map([
|
|
474
|
-
['GetTableContents', 'get_table_contents'],
|
|
475
|
-
['RunATCCheck', 'run_atc_check'],
|
|
476
|
-
['GetCDSDependencies', 'get_cds_dependencies'],
|
|
477
|
-
['GetAPIReleaseState', 'get_api_release_state'],
|
|
478
|
-
['LintABAP', 'lint_abap'],
|
|
479
|
-
['GetApplicationLog', 'get_application_log'],
|
|
480
|
-
['PrepareABAPChangeSet', 'prepare_abap_change_set'],
|
|
481
|
-
['RunRAPRegressionSuite', 'run_rap_regression_suite'],
|
|
482
|
-
['ActivateMultiple', 'activate_multiple'],
|
|
483
|
-
['already_snake', 'already_snake'],
|
|
484
|
-
['lowercase', 'lowercase']
|
|
485
|
-
]);
|
|
486
|
-
for (const [input, expected] of cases) {
|
|
487
|
-
assert.equal(snakeCaseName(input), expected, `${input} must convert to ${expected}`);
|
|
488
|
-
assert.equal(snakeCaseName(snakeCaseName(input)), expected, `${input} conversion must be idempotent`);
|
|
489
|
-
}
|
|
490
|
-
});
|
|
491
|
-
|
|
492
|
-
test('warms the tool cache once and serves repeat tools/list without child round trips', async t => {
|
|
493
|
-
const { directory, proxy } = await fixtureProxy();
|
|
494
|
-
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
495
|
-
await proxy.starting;
|
|
496
|
-
const alpha = proxy.children.find(entry => entry.destination.name === 'alpha');
|
|
497
|
-
let calls = 0;
|
|
498
|
-
const original = alpha.child.listTools.bind(alpha.child);
|
|
499
|
-
alpha.child.listTools = async () => {
|
|
500
|
-
calls += 1;
|
|
501
|
-
await new Promise(resolve => setTimeout(resolve, 40));
|
|
502
|
-
return original();
|
|
503
|
-
};
|
|
504
|
-
|
|
505
|
-
await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
|
506
|
-
await new Promise(resolve => setTimeout(resolve, 150));
|
|
507
|
-
assert.equal(calls, 1, 'the post-initialize warm performs exactly one child fetch');
|
|
508
|
-
|
|
509
|
-
const first = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
510
|
-
assert.equal(calls, 1, 'a cached tools/list must not fetch from the child');
|
|
511
|
-
assert.ok(first.result.tools.some(tool => tool.name === 'sap_system_alpha_get_source'));
|
|
512
|
-
|
|
513
|
-
// Single-flight: two concurrent lists after a cache drop share one rebuild.
|
|
514
|
-
proxy.toolsCache = null;
|
|
515
|
-
alpha.child.toolsPromise = undefined;
|
|
516
|
-
const [second, third] = await Promise.all([
|
|
517
|
-
proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} }),
|
|
518
|
-
proxy.handle({ jsonrpc: '2.0', id: 4, method: 'tools/list', params: {} })
|
|
519
|
-
]);
|
|
520
|
-
assert.equal(calls, 2, 'concurrent tools/list must share a single child fetch');
|
|
521
|
-
assert.equal(second.result.tools.length, third.result.tools.length);
|
|
522
|
-
});
|
|
523
|
-
|
|
524
|
-
test('refetches tool listings after a self-healing restart', async t => {
|
|
525
|
-
const { directory, proxy } = await fixtureProxy();
|
|
526
|
-
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
527
|
-
await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
|
528
|
-
await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
529
|
-
|
|
530
|
-
const alpha = proxy.children.find(entry => entry.destination.name === 'alpha');
|
|
531
|
-
const exit = once(alpha.child.process, 'exit');
|
|
532
|
-
alpha.child.process.kill('SIGKILL');
|
|
533
|
-
await exit;
|
|
534
|
-
const healed = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'sap_system_alpha_get_source', arguments: {} } });
|
|
535
|
-
assert.equal(healed?.error, undefined);
|
|
536
|
-
assert.equal(proxy.toolsCache, null, 'a replacement child must invalidate the merged cache');
|
|
537
|
-
const listed = await proxy.handle({ jsonrpc: '2.0', id: 4, method: 'tools/list', params: {} });
|
|
538
|
-
assert.ok(listed.result.tools.some(tool => tool.name === 'sap_system_alpha_get_source'));
|
|
539
|
-
assert.ok(proxy.toolsCache, 'the rebuilt listing must be cached again');
|
|
540
|
-
});
|
|
541
|
-
|
|
542
|
-
test('upstream tools/list_changed notifications invalidate the cache', async t => {
|
|
543
|
-
const { directory, proxy } = await fixtureProxy();
|
|
544
|
-
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
545
|
-
await proxy.starting;
|
|
546
|
-
const alpha = proxy.children.find(entry => entry.destination.name === 'alpha');
|
|
547
|
-
let calls = 0;
|
|
548
|
-
const original = alpha.child.listTools.bind(alpha.child);
|
|
549
|
-
alpha.child.listTools = async () => { calls += 1; return original(); };
|
|
550
|
-
|
|
551
|
-
await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
|
552
|
-
await new Promise(resolve => setTimeout(resolve, 150));
|
|
553
|
-
assert.equal(calls, 1);
|
|
554
|
-
|
|
555
|
-
// The fixture emits notifications/tools/list_changed before replying, so
|
|
556
|
-
// both cache layers are cleared by the time the call resolves.
|
|
557
|
-
await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'sap_system_alpha_get_source', arguments: { emitListChanged: true } } });
|
|
558
|
-
const listed = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} });
|
|
559
|
-
assert.equal(calls, 2, 'a list_changed notification must force a refetch');
|
|
560
|
-
assert.ok(listed.result.tools.some(tool => tool.name === 'sap_system_alpha_get_source'));
|
|
561
|
-
});
|
|
562
|
-
|
|
563
|
-
test('emits tools/list_changed to the client after a self-healing restart', async t => {
|
|
564
|
-
const { directory, proxy } = await fixtureProxy();
|
|
565
|
-
t.after(async () => { await rm(directory, { recursive: true, force: true }); });
|
|
566
|
-
const input = new PassThrough();
|
|
567
|
-
const output = [];
|
|
568
|
-
const serving = proxy.serve(input, line => output.push(JSON.parse(line)));
|
|
569
|
-
input.write(`${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })}\n`);
|
|
570
|
-
input.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} })}\n`);
|
|
571
|
-
await new Promise(resolve => setTimeout(resolve, 150));
|
|
572
|
-
|
|
573
|
-
const alpha = proxy.children.find(entry => entry.destination.name === 'alpha');
|
|
574
|
-
const exit = once(alpha.child.process, 'exit');
|
|
575
|
-
alpha.child.process.kill('SIGKILL');
|
|
576
|
-
await exit;
|
|
577
|
-
input.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'sap_system_alpha_get_source', arguments: {} } })}\n`);
|
|
578
|
-
const deadline = Date.now() + 10000;
|
|
579
|
-
while (!output.some(message => message.id === 3) && Date.now() < deadline) {
|
|
580
|
-
await new Promise(resolve => setTimeout(resolve, 50));
|
|
581
|
-
}
|
|
582
|
-
input.end();
|
|
583
|
-
await serving;
|
|
584
|
-
assert.ok(output.some(message => message.id === 3 && !message.error), 'the healed call must succeed');
|
|
585
|
-
assert.ok(output.some(message => message.method === 'notifications/tools/list_changed'), 'the client must be told the tool surface changed');
|
|
586
|
-
});
|
|
587
|
-
|
|
588
|
-
test('assembles children in destination order regardless of relay timing', async t => {
|
|
589
|
-
const directory = await mkdtemp(join(tmpdir(), 'bas-vsp-start-order-'));
|
|
590
|
-
const log = join(directory, 'children.log');
|
|
591
|
-
const destinations = ['zz-relay', 'aa-relay'].map(name => ({
|
|
592
|
-
name, url: `http://${name}.dest`, client: '001', authentication: 'BasicAuthentication', proxyType: 'Internet'
|
|
593
|
-
}));
|
|
594
|
-
const proxy = new MCPProxy({ binary: fixture, destinations, env: { ...process.env, FAKE_LOG: log }, log: () => {} });
|
|
595
|
-
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
596
|
-
proxy.start();
|
|
597
|
-
await proxy.starting;
|
|
598
|
-
assert.deepEqual(proxy.children.map(entry => entry.destination.name), ['zz-relay', 'aa-relay'], 'children keep destination order');
|
|
599
|
-
const initialized = await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
|
600
|
-
assert.equal(initialized.result.serverInfo.name, 'zz-relay');
|
|
601
|
-
const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
602
|
-
assert.ok(listed.result.tools.some(tool => tool.name.startsWith('sap_system_zz-relay_')));
|
|
603
|
-
assert.ok(listed.result.tools.some(tool => tool.name.startsWith('sap_system_aa-relay_')));
|
|
604
|
-
});
|
package/test/pty.mjs
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
|
|
3
|
-
export function spawnWithPty(command, options) {
|
|
4
|
-
if (process.platform === 'darwin') {
|
|
5
|
-
return spawn('python3', ['-c', 'import os,pty,sys; status=pty.spawn(["/bin/sh","-c",sys.argv[1]]); code=os.waitstatus_to_exitcode(status); sys.exit(code if code >= 0 else 128-code)', command], options);
|
|
6
|
-
}
|
|
7
|
-
return spawn('script', ['-qec', command, '/dev/null'], options);
|
|
8
|
-
}
|