@pixlcore/xycli 1.1.0 → 1.2.0

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 (45) hide show
  1. package/.dockerignore +13 -0
  2. package/.github/workflows/docker.yml +64 -0
  3. package/.github/workflows/offline-tests.yml +1 -1
  4. package/Dockerfile +45 -0
  5. package/README.md +21 -0
  6. package/docs/help.md +23 -0
  7. package/docs/mcp.md +224 -0
  8. package/docs/two-way-delete-tutorial.md +5 -2
  9. package/index.js +11 -1
  10. package/lib/config.js +7 -2
  11. package/lib/mcp/context.js +52 -0
  12. package/lib/mcp/registry.js +27 -0
  13. package/lib/mcp/results.js +14 -0
  14. package/lib/mcp/schemas.js +67 -0
  15. package/lib/mcp/server.js +28 -0
  16. package/lib/mcp/tools/comment-ticket.js +11 -0
  17. package/lib/mcp/tools/create-event.js +17 -0
  18. package/lib/mcp/tools/create-ticket.js +11 -0
  19. package/lib/mcp/tools/delete-event.js +8 -0
  20. package/lib/mcp/tools/delete-ticket.js +8 -0
  21. package/lib/mcp/tools/get-event-options.js +19 -0
  22. package/lib/mcp/tools/get-event.js +8 -0
  23. package/lib/mcp/tools/get-job.js +8 -0
  24. package/lib/mcp/tools/list-events.js +17 -0
  25. package/lib/mcp/tools/list-servers.js +17 -0
  26. package/lib/mcp/tools/run-event.js +10 -0
  27. package/lib/mcp/tools/search-alerts.js +12 -0
  28. package/lib/mcp/tools/search-jobs.js +12 -0
  29. package/lib/mcp/tools/search-snapshots.js +12 -0
  30. package/lib/mcp/tools/take-snapshot.js +15 -0
  31. package/lib/mcp/tools/update-event.js +12 -0
  32. package/lib/mcp/tools/update-ticket.js +11 -0
  33. package/lib/mcp/transports/http.js +76 -0
  34. package/lib/mcp/transports/stdio.js +7 -0
  35. package/lib/mcp.js +117 -0
  36. package/package.json +9 -4
  37. package/test/README.md +2 -0
  38. package/test/config.test.js +22 -0
  39. package/test/fixtures/mcp/localhost.crt +19 -0
  40. package/test/fixtures/mcp/localhost.key +28 -0
  41. package/test/helpers/mcp.js +78 -0
  42. package/test/mcp-http-unit.test.js +188 -0
  43. package/test/mcp-stdio-unit.test.js +194 -0
  44. package/test/mcp-tools-unit.test.js +212 -0
  45. package/test/mcp.test.js +212 -0
@@ -0,0 +1,28 @@
1
+ -----BEGIN PRIVATE KEY-----
2
+ MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCn/21gfsFiPQey
3
+ mlEIulaMgYhuiLe26g+ELntBw+7ZPENhKgdvqtLs+AVxwV0avxs6F+bx/kgrFktL
4
+ vyF9o0PHj30NNf81pTulHju9H9HLFuY/XCiO6jnc3cagxeuwAzoAr/YpsrVHUhaU
5
+ wPrNDmuwCu2kNMSFB3FuQrZCCOneqfOy5eCbGWwnSb/w4AJq+4p2J43KeXoJ0nvn
6
+ 60mfgBXSOpodXsIOTtDJbZiDpIMJlUOWTgW1rLFzTkI8CWHxY4+e9RMheTZISkDe
7
+ nn0P+Ef3IDaLO8QxMbjmk4i2gnC0W8YVlzUM+WeDeJ7UD+VQoS2gD7mWTYD3UmD+
8
+ wglTGKuRAgMBAAECggEACTWewLFyLHkoA4nOHhUI9ZQ27x2eOfAZgFIuqOudcQSK
9
+ tQ2MHCUDLGu+8KjRpSELfw3HDUZhR8C7HqCQV3DXIF4kqXq8+NXhWoCOdHmwEzAN
10
+ gzUBdtmEPoVchCfAGdqzbUziyQ+7kaNVtZCMBH22jY9WeQVOLfS7KnjhwOZNhnU3
11
+ vpstAKtdnc4tbxqT+z1b2clqvhO2fIFVPi4gPFM9oTvjSVyq51qoQB7nvnarJJSd
12
+ u7WFJXvvH9gY2yLdS1/efwxNG6mxkaNq6SbKKF9/WaqLNo4qctFAdGZ486LrSQZf
13
+ FiPOC7MCi9+UViy04cFebdibgmi9eAMiiIEdvDvJGQKBgQDnz81WrV2w9v0KGIeu
14
+ yByIoNv4apIM1fggM+0xu4oroD2Xakdh5VlGREJ7eHrPqQRTFtKH11LcYKqC7Zjs
15
+ fxmeNwb6fLZADX1cxKPjaUJXj4lK3HZC5VUM0YxC+iHOAZhmuHR3RbYj5ur/kXSh
16
+ 6UoRRQvIllXWo2Icf5X9xhZGCwKBgQC5hwPChGPsUc4F/QRzturaXt88e50vViny
17
+ Fqf8csujqk2isxg2NlbPcNlqeQ7jjW1iS3iUmVhpTThiri4cvTcLqG5xAQFLZ58a
18
+ +crcN3sBkNSpMYwYkxeU7MYYaLtZsDjv+kN1Xq2n7MdfSiIBPrTSx624QUSVUy7W
19
+ 1dg7DceiUwKBgQDZ5Y/rcMzHwnK6IbKwjTOdDi+Ww34DiMFSa90CyXkoZtheUq1E
20
+ jRQso48mhmpSdlo1TBebTsB5nHfK2FHNw+8ttCXgIIgjj0qOf9xZsCCHM8nmz3Ju
21
+ kSkwAmzorDxIa2F49NutHZ8HAlFU58INS7+umgtogj+voSZhCWDRCXj4XwKBgCOX
22
+ uGvv9C+ZPho+AUWl2QaN3uv+lxhGwoNUP2QaLeE3Qe+txj5YTG09rChWEzoqnsqV
23
+ ygtQI/sHuoKMAgK3dkEBTjbVx4bM2WRWecmnxyx0uae//q2UQpjql9KE/TRmr3Wy
24
+ oJB62nv13AX29fEhM5Wggz5vHxY6wCitG++wZBVLAoGAWB2yh8ICGTfcgfVFxTNv
25
+ fj54c/EnZXP6OxQ3Isz7tYy/aNDFubcQPhdjMS+bd5q0W7WMiP4NBv8+lkMh0sWx
26
+ Yawi8CYIFTcV8fYmXV3ord2D+NbcVfobcf9UE2W7zbv15nZ1+LrICimIlcPfX83R
27
+ 7PlB6dvbKNLI//9+yXExN7w=
28
+ -----END PRIVATE KEY-----
@@ -0,0 +1,78 @@
1
+ // Small wire-level MCP clients keep the tests independent of another SDK dep.
2
+ const { spawn } = require('node:child_process');
3
+ const { once } = require('node:events');
4
+ const readline = require('node:readline');
5
+ const Path = require('node:path');
6
+
7
+ async function decode(response) {
8
+ const body = await response.text();
9
+ if (response.headers.get('content-type')?.includes('text/event-stream')) {
10
+ const line = body.split('\n').find(line => line.startsWith('data: '));
11
+ return JSON.parse(line.slice(6));
12
+ }
13
+ return JSON.parse(body);
14
+ }
15
+
16
+ async function httpRPC(url, method, params = {}, options = {}) {
17
+ const modern = options.modern;
18
+ if (modern) params = { ...params, _meta: { 'io.modelcontextprotocol/protocolVersion': '2026-07-28', 'io.modelcontextprotocol/clientCapabilities': {} } };
19
+ const response = await fetch(url, {
20
+ method: 'POST', signal: AbortSignal.timeout(10000),
21
+ headers: {
22
+ 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream',
23
+ ...(modern ? { 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': method, ...(params.name ? { 'Mcp-Name': params.name } : {}) } : {}),
24
+ ...options.headers
25
+ }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params })
26
+ });
27
+ return decode(response);
28
+ }
29
+
30
+ function stdioClient(t, env = {}, args = ['mcp', 'stdio']) {
31
+ const child = spawn(process.execPath, [Path.resolve(__dirname, '../../index.js'), ...args], {
32
+ stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, ...env }
33
+ });
34
+ let stderr = '';
35
+ const lines = [];
36
+ const pending = new Map();
37
+ let id = 0;
38
+ child.stderr.on('data', data => { stderr += data; });
39
+ const exit = once(child, 'exit');
40
+ const reader = readline.createInterface({ input: child.stdout });
41
+ reader.on('line', line => {
42
+ lines.push(line);
43
+ let message;
44
+ try { message = JSON.parse(line); }
45
+ catch (err) {
46
+ for (const call of pending.values()) call.reject(new Error('Non-JSON stdout: ' + line));
47
+ return;
48
+ }
49
+ pending.get(message.id)?.resolve(message);
50
+ });
51
+ t.after(async () => {
52
+ if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM');
53
+ await exit;
54
+ });
55
+
56
+ return {
57
+ child, lines, exit, stderr: () => stderr,
58
+ request(method, params = {}) {
59
+ const requestId = ++id;
60
+ return new Promise((resolve, reject) => {
61
+ const timer = setTimeout(() => { pending.delete(requestId); reject(new Error('MCP response timeout: ' + method + '; ' + stderr)); }, 10000);
62
+ pending.set(requestId, {
63
+ resolve(value) { clearTimeout(timer); pending.delete(requestId); resolve(value); },
64
+ reject(error) { clearTimeout(timer); pending.delete(requestId); reject(error); }
65
+ });
66
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: requestId, method, params }) + '\n');
67
+ });
68
+ },
69
+ async initialize() {
70
+ return this.request('initialize', { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'xycli-test', version: '1' } });
71
+ },
72
+ async call(name, args) {
73
+ return this.request('tools/call', { name, arguments: args });
74
+ }
75
+ };
76
+ }
77
+
78
+ module.exports = { decode, httpRPC, stdioClient };
@@ -0,0 +1,188 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const http = require('node:http');
4
+ const https = require('node:https');
5
+ const fs = require('node:fs');
6
+ const Path = require('node:path');
7
+ const { configuration } = require('../lib/mcp.js');
8
+ const { createContext } = require('../lib/mcp/context.js');
9
+ const factory = require('../lib/mcp/server.js');
10
+ const start = require('../lib/mcp/transports/http.js');
11
+ const tools = require('../lib/mcp/registry.js')();
12
+ const { httpRPC } = require('./helpers/mcp.js');
13
+
14
+ test('HTTP serves both protocol generations, validates calls and rejects unauthorized access', async t => {
15
+ let calls = 0;
16
+ const context = createContext({ get_event: async (params, options) => { calls++; assert.equal(options.headers['X-API-Key'], 'test-token'); return { data: { code: 0, event: { id: params.id, revision: 1 } } }; } }, { read_only: true, api_key: 'server-key-must-not-be-used' });
17
+ const options = { ...configuration({}, {}), port: 0, log() {} };
18
+ const service = await start(factory(tools, context), options);
19
+ t.after(() => service.close());
20
+ const url = 'http://127.0.0.1:' + service.address.port + '/mcp';
21
+ const headers = { Authorization: 'Bearer test-token' };
22
+ for (const authorization of ['', 'Basic abc', 'Bearer', 'Bearer ', 'Bearer key extra']) {
23
+ assert.equal((await fetch(url, { headers: { Authorization: authorization } })).status, 401);
24
+ }
25
+ assert.equal(calls, 0);
26
+ assert.equal((await fetch(url, { headers: { ...headers, Origin: 'https://evil.example' } })).status, 403);
27
+ assert.equal((await fetch(url + '/other', { headers })).status, 404);
28
+
29
+ for (const modern of [false, true]) {
30
+ const listing = await httpRPC(url, 'tools/list', {}, { headers, modern });
31
+ assert.equal(listing.result.tools.length, 8);
32
+ assert.ok(listing.result.tools.every(tool => tool.annotations.readOnlyHint));
33
+ const response = await httpRPC(url, 'tools/call', { name: 'get_event', arguments: { id: 'e1' } }, { headers, modern });
34
+ assert.equal(response.result.structuredContent.event.id, 'e1');
35
+ }
36
+ assert.equal(calls, 2);
37
+ const invalid = await httpRPC(url, 'tools/call', { name: 'get_event', arguments: { id: 'bad/id' } }, { headers });
38
+ assert.ok(invalid.error || invalid.result.isError);
39
+ const forbidden = await httpRPC(url, 'tools/call', { name: 'delete_event', arguments: { id: 'e1' } }, { headers });
40
+ assert.ok(forbidden.error || forbidden.result.isError);
41
+ assert.equal(calls, 2);
42
+ const malformed = await fetch(url, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, body: '{broken' });
43
+ assert.equal(malformed.status, 400);
44
+ // Send the declared size first: the adapter can reject it before reading a
45
+ // body, without racing a megabyte upload against the closed connection.
46
+ const oversizedStatus = await new Promise((resolve, reject) => {
47
+ const req = http.request(url, { method: 'POST', headers: { ...headers, 'Content-Length': options.max_request_bytes + 1 } }, response => {
48
+ response.resume();
49
+ response.on('end', () => { resolve(response.statusCode); req.destroy(); });
50
+ });
51
+ req.on('error', reject);
52
+ req.flushHeaders();
53
+ });
54
+ assert.equal(oversizedStatus, 413);
55
+
56
+ // Exercise the real Host header instead of trusting URL-only validation.
57
+ const status = await new Promise((resolve, reject) => {
58
+ const req = http.get(url, { headers: { ...headers, Host: 'evil.example' } }, response => { response.resume(); resolve(response.statusCode); });
59
+ req.on('error', reject);
60
+ });
61
+ assert.equal(status, 403);
62
+ await service.close();
63
+ });
64
+
65
+ test('concurrent HTTP tools keep their keys across multiple API calls', async t => {
66
+ const calls = [];
67
+ const context = createContext({ get_event: async (params, options) => {
68
+ assert.equal(options.headers['X-API-Key'], params.id + '-key');
69
+ calls.push(options.headers['X-API-Key']);
70
+ await new Promise(resolve => setTimeout(resolve, params.id === 'slow' ? 25 : 1));
71
+ return { data: { code: 0, event: { id: params.id } } };
72
+ } }, { api_key: 'server-key-must-not-be-used' });
73
+ const custom = new Map(tools);
74
+ const original = tools.get('get_event');
75
+ custom.set('get_event', { ...original, async run(args, invocation) {
76
+ await original.run(args, invocation);
77
+ return original.run(args, invocation);
78
+ } });
79
+ const service = await start(factory(custom, context), { ...configuration({}, {}), port: 0, log() {} });
80
+ t.after(() => service.close());
81
+ const url = 'http://127.0.0.1:' + service.address.port + '/mcp';
82
+ const replies = await Promise.all(['slow', 'fast'].map(id => httpRPC(url, 'tools/call', { name: 'get_event', arguments: { id } }, { headers: { Authorization: 'Bearer ' + id + '-key' } })));
83
+ assert.deepEqual(replies.map(reply => reply.result.structuredContent.event.id), ['slow', 'fast']);
84
+ assert.deepEqual(calls.sort(), ['fast-key', 'fast-key', 'slow-key', 'slow-key']);
85
+ });
86
+
87
+ test('HTTP tool errors redact the requesting client key', async t => {
88
+ const context = createContext({ get_event: async (params, options) => ({
89
+ err: 'Rejected key: ' + options.headers['X-API-Key']
90
+ }) }, { api_key: 'server-default' });
91
+ const service = await start(factory(tools, context), { ...configuration({}, {}), port: 0, log() {} });
92
+ t.after(() => service.close());
93
+ const url = 'http://127.0.0.1:' + service.address.port + '/mcp';
94
+ const response = await httpRPC(url, 'tools/call', { name: 'get_event', arguments: { id: 'fixture' } }, {
95
+ headers: { Authorization: 'Bearer client-secret' }
96
+ });
97
+ assert.equal(response.result.isError, true);
98
+ assert.match(response.result.content[0].text, /\[redacted\]/);
99
+ assert.doesNotMatch(JSON.stringify(response), /client-secret|server-default/);
100
+ });
101
+
102
+ test('HTTPS uses the same handler and verifies the configured certificate', async t => {
103
+ const cert = Path.join(__dirname, 'fixtures/mcp/localhost.crt');
104
+ const key = Path.join(__dirname, 'fixtures/mcp/localhost.key');
105
+ const service = await start(factory(tools, createContext({})), {
106
+ ...configuration({}, {}), port: 0, tls_cert: cert, tls_key: key, log() {}
107
+ });
108
+ t.after(() => service.close());
109
+ const response = await new Promise((resolve, reject) => {
110
+ const request = https.request({
111
+ hostname: '127.0.0.1', port: service.address.port, path: '/mcp', method: 'POST',
112
+ ca: fs.readFileSync(cert), headers: { Authorization: 'Bearer fixture-key', 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }
113
+ }, response => {
114
+ let body = '';
115
+ response.on('data', chunk => { body += chunk; });
116
+ response.on('end', () => resolve({ status: response.statusCode, body }));
117
+ });
118
+ request.on('error', reject);
119
+ request.end(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }));
120
+ });
121
+ assert.equal(response.status, 200);
122
+ assert.match(response.body, /create_event/);
123
+ });
124
+
125
+ test('HTTP disconnect cancels the tool context before its next upstream call', async t => {
126
+ let entered;
127
+ const entering = new Promise(resolve => { entered = resolve; });
128
+ let cancelled;
129
+ const cancellation = new Promise(resolve => { cancelled = resolve; });
130
+ let calls = 0;
131
+ const context = createContext({ get_event: async () => { calls++; return { data: { code: 0 } }; } });
132
+ const custom = new Map(tools);
133
+ const original = tools.get('get_event');
134
+ custom.set('get_event', { ...original, async run(args, invocation) {
135
+ entered();
136
+ await new Promise(resolve => invocation.signal.addEventListener('abort', resolve, { once: true }));
137
+ await assert.rejects(invocation.callAPI('get_event', { id: args.id }), /cancelled/);
138
+ cancelled();
139
+ return {};
140
+ } });
141
+ const service = await start(factory(custom, context), { ...configuration({}, {}), port: 0, log() {} });
142
+ t.after(() => service.close());
143
+ const request = http.request({
144
+ hostname: '127.0.0.1', port: service.address.port, path: '/mcp', method: 'POST',
145
+ headers: { Authorization: 'Bearer fixture-key', 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }
146
+ });
147
+ request.on('error', () => {});
148
+ request.end(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'get_event', arguments: { id: 'e1' } } }));
149
+ await entering;
150
+ request.destroy();
151
+ await Promise.race([cancellation, new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('Cancellation did not reach the tool')), 2000); timer.unref(); })]);
152
+ assert.equal(calls, 0);
153
+ });
154
+
155
+ test('new HTTP mutations use each client key and read-only mode rejects every write tool', async t => {
156
+ const calls = [];
157
+ const context = createContext({ update_ticket: async (params, options) => {
158
+ const key = options.headers['X-API-Key'];
159
+ calls.push({ key, params });
160
+ if (key === 'restricted-key') return { data: { code: 'access', description: 'Permission denied' } };
161
+ return { data: { code: 0, ticket: { id: params.id, status: params.status } } };
162
+ } }, { api_key: 'server-key' });
163
+ const service = await start(factory(tools, context), { ...configuration({}, {}), port: 0, log() {} });
164
+ t.after(() => service.close());
165
+ const url = 'http://127.0.0.1:' + service.address.port + '/mcp';
166
+ const replies = await Promise.all(['allowed-key', 'restricted-key'].map(key => httpRPC(url, 'tools/call', {
167
+ name: 'update_ticket', arguments: { id: 't1', changes: { status: 'closed' } }
168
+ }, { modern: true, headers: { Authorization: 'Bearer ' + key } })));
169
+ assert.equal(replies[0].result.structuredContent.ticket.status, 'closed');
170
+ assert.equal(replies[1].result.isError, true);
171
+ assert.match(replies[1].result.content[0].text, /Permission denied/);
172
+ assert.deepEqual(calls.map(call => call.key).sort(), ['allowed-key', 'restricted-key']);
173
+
174
+ let forbiddenCalls = 0;
175
+ const readOnly = createContext(new Proxy({}, { get() { return async () => { forbiddenCalls++; throw new Error('Must not call the SDK'); }; } }), { read_only: true });
176
+ const readonlyService = await start(factory(tools, readOnly), { ...configuration({}, {}), port: 0, log() {} });
177
+ t.after(() => readonlyService.close());
178
+ const readonlyURL = 'http://127.0.0.1:' + readonlyService.address.port + '/mcp';
179
+ const headers = { Authorization: 'Bearer readonly-key' };
180
+ const listing = await httpRPC(readonlyURL, 'tools/list', {}, { headers });
181
+ assert.deepEqual(listing.result.tools.map(tool => tool.name).sort(), ['get_event', 'get_event_options', 'get_job', 'list_events', 'list_servers', 'search_alerts', 'search_jobs', 'search_snapshots']);
182
+ for (const tool of tools.values()) {
183
+ if (tool.description.annotations.readOnlyHint) continue;
184
+ const reply = await httpRPC(readonlyURL, 'tools/call', { name: tool.description.name, arguments: {} }, { headers });
185
+ assert.ok(reply.error || reply.result.isError, tool.description.name);
186
+ }
187
+ assert.equal(forbiddenCalls, 0);
188
+ });
@@ -0,0 +1,194 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const http = require('node:http');
4
+ const { spawnSync } = require('node:child_process');
5
+ const { httpRPC, stdioClient } = require('./helpers/mcp.js');
6
+
7
+ test('STDIO uses the real CLI and SDK with protocol-only stdout and clean EOF', async t => {
8
+ let calls = 0;
9
+ const backend = http.createServer((request, response) => {
10
+ calls++;
11
+ assert.equal(request.headers['x-api-key'], 'fixture-key');
12
+ assert.ok(request.url.startsWith('/api/app/get_event/v1'));
13
+ response.setHeader('Content-Type', 'application/json');
14
+ response.end(JSON.stringify({ code: 0, event: { id: 'fixture', revision: 2 } }));
15
+ });
16
+ await new Promise(resolve => backend.listen(0, '127.0.0.1', resolve));
17
+ t.after(() => new Promise(resolve => { backend.close(resolve); backend.closeAllConnections(); }));
18
+ const client = stdioClient(t, { XYOPS_BASE_URL: 'http://127.0.0.1:' + backend.address().port, XYOPS_API_KEY: 'fixture-key', XYOPS_RETRIES: '4' }, ['mcp', 'stdio', '--verbose', '--debug', '--read-only']);
19
+ assert.ok((await client.initialize()).result);
20
+ const listing = await client.request('tools/list');
21
+ assert.equal(listing.result.tools.length, 8);
22
+ assert.equal((await client.call('get_event', { id: 'fixture' })).result.structuredContent.event.revision, 2);
23
+ assert.equal(calls, 1);
24
+ const rejected = await client.call('run_event', { id: 'fixture' });
25
+ assert.ok(rejected.error || rejected.result.isError);
26
+ assert.equal(calls, 1);
27
+ client.child.stdin.end();
28
+ const [code] = await client.exit;
29
+ assert.equal(code, 0);
30
+ assert.ok(client.lines.every(line => JSON.parse(line).jsonrpc === '2.0'));
31
+ assert.ok(!client.stderr().includes('fixture-key'));
32
+ });
33
+
34
+ test('named HTTP options and local help do not contact xyOps', () => {
35
+ for (const args of [['mcp', 'serve', '--help'], ['mcp', '--help']]) {
36
+ const response = spawnSync(process.execPath, ['index.js', ...args], { encoding: 'utf8', env: { ...process.env, XYOPS_API_KEY: '', XYOPS_BASE_URL: '' } });
37
+ assert.equal(response.status, 0);
38
+ assert.match(response.stdout, /--port 8080/);
39
+ }
40
+ const response = spawnSync(process.execPath, ['index.js', 'mcp', 'serve', '8080'], { encoding: 'utf8' });
41
+ assert.equal(response.status, 1);
42
+ assert.equal(response.stdout, '');
43
+ assert.match(response.stderr, /--port/);
44
+ });
45
+
46
+ for (const { host, urlHost } of [{ host: '127.0.0.1', urlHost: '127.0.0.1' }, { host: '::1', urlHost: '[::1]' }]) {
47
+ test('HTTP CLI starts and shuts down on ' + host, { timeout: 15000 }, async t => {
48
+ // Reserve an available port before launching the actual CLI process. Tool
49
+ // calls use the real SDK with no process-wide API key configured.
50
+ const backend = http.createServer((request, response) => {
51
+ response.setHeader('Content-Type', 'application/json');
52
+ const id = new URL(request.url, 'http://fixture').searchParams.get('id');
53
+ assert.equal(request.headers['x-api-key'], id + '-key');
54
+ response.end(JSON.stringify({ code: 0, event: { id } }));
55
+ });
56
+ await new Promise(resolve => backend.listen(0, '127.0.0.1', resolve));
57
+ t.after(() => new Promise(resolve => { backend.close(resolve); backend.closeAllConnections(); }));
58
+ const reservation = http.createServer();
59
+ try {
60
+ await new Promise((resolve, reject) => {
61
+ reservation.once('error', reject);
62
+ reservation.listen(0, host, resolve);
63
+ });
64
+ }
65
+ catch (err) {
66
+ // Some test hosts have IPv6 disabled. Skip only that explicit case.
67
+ if (host === '::1' && ['EADDRNOTAVAIL', 'EAFNOSUPPORT'].includes(err.code)) return t.skip('IPv6 loopback unavailable');
68
+ throw err;
69
+ }
70
+ const port = reservation.address().port;
71
+ await new Promise(resolve => reservation.close(resolve));
72
+ const client = stdioClient(t, {
73
+ XYOPS_BASE_URL: 'http://127.0.0.1:' + backend.address().port, XYOPS_API_KEY: '',
74
+ XYOPS_MCP: JSON.stringify({ host: '127.0.0.1' })
75
+ }, ['mcp', 'serve', '--host', host, '--port', String(port), '--read-only']);
76
+ await new Promise((resolve, reject) => {
77
+ const timer = setTimeout(() => reject(new Error('HTTP startup timeout: ' + client.stderr())), 10000);
78
+ client.child.stderr.on('data', () => {
79
+ if (client.stderr().includes('Listening at ')) { clearTimeout(timer); resolve(); }
80
+ });
81
+ client.exit.then(() => { clearTimeout(timer); reject(new Error('CLI exited before startup: ' + client.stderr())); });
82
+ });
83
+ const url = 'http://' + urlHost + ':' + port + '/mcp';
84
+ assert.ok(client.stderr().includes('Listening at ' + url));
85
+ const listing = await httpRPC(url, 'tools/list', {}, {
86
+ modern: true, headers: { Authorization: 'Bearer fixture-token' }
87
+ });
88
+ assert.equal(listing.result.tools.length, 8);
89
+ assert.equal((await fetch(url)).status, 401);
90
+ // Binding IPv6 must not weaken the Host allowlist.
91
+ const rejectedHost = await new Promise((resolve, reject) => {
92
+ const req = http.get(url, { headers: { Host: 'evil.example', Authorization: 'Bearer fixture-key' } }, response => {
93
+ response.resume();
94
+ resolve(response.statusCode);
95
+ });
96
+ req.on('error', reject);
97
+ });
98
+ assert.equal(rejectedHost, 403);
99
+ const replies = await Promise.all(['first', 'second'].map((id, index) => httpRPC(url, 'tools/call', {
100
+ name: 'get_event', arguments: { id }
101
+ }, { modern: !!index, headers: { Authorization: 'Bearer ' + id + '-key' } })));
102
+ assert.deepEqual(replies.map(reply => reply.result.structuredContent.event.id), ['first', 'second']);
103
+ assert.equal(client.child.kill('SIGTERM'), true);
104
+ const [code, signal] = await client.exit;
105
+ if (process.platform === 'win32') {
106
+ // Windows force-terminates a child sent SIGTERM, without running its
107
+ // signal handler. Node reports signal termination, not exit code 0.
108
+ assert.equal(code, null);
109
+ assert.equal(signal, 'SIGTERM');
110
+ }
111
+ else {
112
+ // POSIX delivers SIGTERM to the CLI's graceful shutdown handler.
113
+ assert.equal(code, 0);
114
+ assert.equal(signal, null);
115
+ }
116
+ assert.deepEqual(client.lines, []);
117
+ });
118
+ }
119
+
120
+ test('ticket and monitoring tools use real SDK routes and validated STDIO inputs', async t => {
121
+ const requests = [];
122
+ const backend = http.createServer((request, response) => {
123
+ let body = '';
124
+ request.on('data', chunk => { body += chunk; });
125
+ request.on('end', () => {
126
+ const url = new URL(request.url, 'http://fixture');
127
+ const name = url.pathname.split('/')[3];
128
+ const params = body ? JSON.parse(body) : Object.fromEntries(url.searchParams);
129
+ requests.push({ name, method: request.method, params });
130
+ assert.equal(request.headers['x-api-key'], 'fixture-key');
131
+ assert.match(request.headers['user-agent'], /xyOps MCP/);
132
+ let result = { code: 0 };
133
+ switch (name) {
134
+ case 'create_ticket':
135
+ case 'update_ticket':
136
+ case 'add_ticket_change':
137
+ result.ticket = { id: 't1', num: 1, subject: 'Test', status: params.status || 'open', changes: params.change ? [{ ...params.change, id: 'c1', username: 'fixture-key', date: 123 }] : [] };
138
+ break;
139
+ case 'delete_ticket': break;
140
+ case 'create_snapshot':
141
+ case 'create_group_snapshot': result.id = 'sn1'; break;
142
+ case 'get_active_servers': result.rows = [{ id: 's1', hostname: 'worker', groups: ['main'], enabled: true }]; result.list = { length: 1 }; break;
143
+ case 'search_alerts':
144
+ case 'search_snapshots': result.rows = [{ id: 'record1' }]; result.list = { length: 1 }; break;
145
+ default: result = { code: 'test', description: 'Unexpected route ' + name };
146
+ }
147
+ response.setHeader('Content-Type', 'application/json');
148
+ response.end(JSON.stringify(result));
149
+ });
150
+ });
151
+ await new Promise(resolve => backend.listen(0, '127.0.0.1', resolve));
152
+ t.after(() => new Promise(resolve => { backend.close(resolve); backend.closeAllConnections(); }));
153
+ const client = stdioClient(t, { XYOPS_BASE_URL: 'http://127.0.0.1:' + backend.address().port, XYOPS_API_KEY: 'fixture-key', XYOPS_MCP: '{}' });
154
+ await client.initialize();
155
+ const listing = await client.request('tools/list');
156
+ assert.equal(listing.result.tools.length, 17);
157
+ const invoke = async (name, args) => {
158
+ const reply = await client.call(name, args);
159
+ assert.ok(reply.result && !reply.result.isError, JSON.stringify(reply));
160
+ return reply.result.structuredContent;
161
+ };
162
+ assert.equal((await invoke('create_ticket', { ticket: { subject: 'Test', body: '**Markdown**' } })).ticket.id, 't1');
163
+ assert.deepEqual(requests.at(-1), { name: 'create_ticket', method: 'POST', params: { subject: 'Test', body: '**Markdown**' } });
164
+ await invoke('update_ticket', { id: 't1', changes: { status: 'closed', assignees: [] } });
165
+ assert.deepEqual(requests.at(-1).params, { id: 't1', status: 'closed', assignees: [] });
166
+ assert.equal((await invoke('comment_ticket', { id: 't1', body: 'Comment' })).comment.username, 'fixture-key');
167
+ assert.deepEqual(requests.at(-1).params, { id: 't1', change: { type: 'comment', body: 'Comment' } });
168
+ assert.equal((await invoke('delete_ticket', { id: 't1' })).deleted, true);
169
+ await invoke('search_alerts', { query: 'active:true server:s1', limit: 2 });
170
+ assert.deepEqual(requests.at(-1), { name: 'search_alerts', method: 'GET', params: { query: 'active:true server:s1', offset: '0', limit: '2', sort_by: '_id', sort_dir: '-1' } });
171
+ await invoke('search_snapshots', {});
172
+ assert.ok(!('verbose' in requests.at(-1).params));
173
+ await invoke('search_snapshots', { verbose: true, limit: 1 });
174
+ assert.equal(requests.at(-1).params.verbose, '1');
175
+ assert.equal((await invoke('list_servers', { group: 'main' })).rows[0].id, 's1');
176
+ await invoke('take_snapshot', { server: 's1' });
177
+ assert.deepEqual(requests.at(-1), { name: 'create_snapshot', method: 'POST', params: { server: 's1' } });
178
+ await invoke('take_snapshot', { group: 'main' });
179
+ assert.deepEqual(requests.at(-1), { name: 'create_group_snapshot', method: 'POST', params: { group: 'main' } });
180
+
181
+ // Validate through the registered protocol schema as well as the registry.
182
+ const count = requests.length;
183
+ for (const [name, args] of [
184
+ ['take_snapshot', {}], ['take_snapshot', { server: 's1', group: 'main' }],
185
+ ['create_ticket', { ticket: { subject: 'Test', username: 'admin' } }],
186
+ ['comment_ticket', { id: 't1', body: ' ' }]
187
+ ]) {
188
+ const reply = await client.call(name, args);
189
+ assert.ok(reply.error || reply.result.isError, name);
190
+ }
191
+ assert.equal(requests.length, count);
192
+ client.child.stdin.end();
193
+ assert.equal((await client.exit)[0], 0);
194
+ });