@pixlcore/xycli 1.1.1 → 1.2.1

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 (47) hide show
  1. package/.dockerignore +13 -0
  2. package/.github/workflows/docker.yml +64 -0
  3. package/.github/workflows/offline-tests.yml +2 -2
  4. package/Dockerfile +45 -0
  5. package/LICENSE.md +28 -0
  6. package/README.md +22 -1
  7. package/docs/help.md +23 -0
  8. package/docs/mcp.md +224 -0
  9. package/index.js +11 -1
  10. package/lib/config.js +7 -2
  11. package/lib/jobs.js +21 -3
  12. package/lib/mcp/context.js +52 -0
  13. package/lib/mcp/registry.js +27 -0
  14. package/lib/mcp/results.js +14 -0
  15. package/lib/mcp/schemas.js +67 -0
  16. package/lib/mcp/server.js +28 -0
  17. package/lib/mcp/tools/comment-ticket.js +11 -0
  18. package/lib/mcp/tools/create-event.js +17 -0
  19. package/lib/mcp/tools/create-ticket.js +11 -0
  20. package/lib/mcp/tools/delete-event.js +8 -0
  21. package/lib/mcp/tools/delete-ticket.js +8 -0
  22. package/lib/mcp/tools/get-event-options.js +19 -0
  23. package/lib/mcp/tools/get-event.js +8 -0
  24. package/lib/mcp/tools/get-job.js +8 -0
  25. package/lib/mcp/tools/list-events.js +17 -0
  26. package/lib/mcp/tools/list-servers.js +17 -0
  27. package/lib/mcp/tools/run-event.js +10 -0
  28. package/lib/mcp/tools/search-alerts.js +12 -0
  29. package/lib/mcp/tools/search-jobs.js +12 -0
  30. package/lib/mcp/tools/search-snapshots.js +12 -0
  31. package/lib/mcp/tools/take-snapshot.js +15 -0
  32. package/lib/mcp/tools/update-event.js +12 -0
  33. package/lib/mcp/tools/update-ticket.js +11 -0
  34. package/lib/mcp/transports/http.js +76 -0
  35. package/lib/mcp/transports/stdio.js +7 -0
  36. package/lib/mcp.js +120 -0
  37. package/package.json +6 -4
  38. package/test/README.md +3 -0
  39. package/test/config.test.js +22 -0
  40. package/test/fixtures/mcp/localhost.crt +19 -0
  41. package/test/fixtures/mcp/localhost.key +28 -0
  42. package/test/helpers/mcp.js +78 -0
  43. package/test/jobs-follow-unit.test.js +107 -0
  44. package/test/mcp-http-unit.test.js +188 -0
  45. package/test/mcp-stdio-unit.test.js +194 -0
  46. package/test/mcp-tools-unit.test.js +212 -0
  47. package/test/mcp.test.js +212 -0
@@ -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
+ });
@@ -0,0 +1,212 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const Path = require('node:path');
6
+ const { createContext } = require('../lib/mcp/context.js');
7
+ const { configuration } = require('../lib/mcp.js');
8
+ const { result } = require('../lib/mcp/results.js');
9
+ const tools = require('../lib/mcp/registry.js')();
10
+
11
+ test('tool schemas reject unsafe or ambiguous mutation inputs', async () => {
12
+ const validate = (name, args) => tools.get(name).inputSchema['~standard'].validate(args);
13
+ assert.equal(tools.size, 17);
14
+ assert.ok((await validate('create_event', { event: { title: 'Test', category: 'general', enabled: false } })).issues);
15
+ assert.ok((await validate('update_event', { id: 'test', changes: { title: 'New' } })).issues);
16
+ assert.ok((await validate('update_event', { id: 'test', revision: 0, changes: {} })).issues);
17
+ assert.ok((await validate('run_event', { id: 'test', test: true })).issues);
18
+ assert.ok((await validate('delete_event', { id: 'test', delete_jobs: true })).issues);
19
+ assert.ok((await validate('search_jobs', { limit: 101 })).issues);
20
+ assert.ok(!(await validate('create_event', { event: { title: 'Flow', category: 'general', enabled: false, type: 'workflow', workflow: { nodes: [], connections: [] } } })).issues);
21
+ });
22
+
23
+ test('update submits only supplied changes and the expected revision', async () => {
24
+ let request;
25
+ const context = createContext({ update_event: async params => { request = params; return { data: { code: 0, event: { id: params.id, revision: 3 } } }; } });
26
+ const response = await tools.get('update_event').run({ id: 'event1', revision: 2, changes: { params: { message: 'new' }, triggers: [] } }, context);
27
+ assert.deepEqual(request, { id: 'event1', revision: 2, params: { message: 'new' }, triggers: [] });
28
+ assert.equal(response.event.revision, 3);
29
+ });
30
+
31
+ test('context enforces read-only, cancellation and safe property names before SDK calls', async () => {
32
+ let calls = 0;
33
+ const api = { run_event: async () => { calls++; return { data: { code: 0 } }; } };
34
+ await assert.rejects(createContext(api, { read_only: true }).callAPI('run_event', {}), /read-only/);
35
+ const context = createContext(api);
36
+ await assert.rejects(context.callAPI('run_event', {}, AbortSignal.abort()), /cancelled/);
37
+ await assert.rejects(context.callAPI('run_event', JSON.parse('{"params":{"__proto__":{}}}')), /Unsupported property/);
38
+ await assert.rejects(context.callAPI('admin_update', {}), /Unsupported API/);
39
+ assert.equal(calls, 0);
40
+ });
41
+
42
+ test('transport failure reports uncertain mutations, redacts the key, and never retries', async () => {
43
+ let calls = 0;
44
+ const context = createContext({ run_event: async (params, options) => {
45
+ calls++; assert.equal(options.retries, 0); return { err: 'lost connection secret-key' };
46
+ } }, { api_key: 'secret-key' });
47
+ await assert.rejects(context.callAPI('run_event', {}), error => /uncertain/.test(error.message) && !error.message.includes('secret-key'));
48
+ assert.equal(calls, 1);
49
+ const denied = createContext({ run_event: async () => ({ err: 'denied', data: { code: 'access', description: 'Denied' } }) });
50
+ await assert.rejects(denied.callAPI('run_event'), error => error.message === 'Denied');
51
+ });
52
+
53
+ test('read tools paginate and omit API envelopes and download tokens', async () => {
54
+ const context = createContext({
55
+ get_events: async () => ({ data: { code: 0, rows: [{ id: 'b', title: 'Second', enabled: false }, { id: 'a', title: 'First', enabled: true }] } }),
56
+ get_job: async () => ({ data: { code: 0, token: 'private', job: { id: 'j1', state: 'complete', data: { answer: 42 } } } })
57
+ });
58
+ const events = await tools.get('list_events').run({ limit: 1 }, context);
59
+ assert.equal(events.total, 2);
60
+ assert.equal(events.rows[0].id, 'a');
61
+ const job = await tools.get('get_job').run({ id: 'j1' }, context);
62
+ assert.equal(job.job.data.answer, 42);
63
+ assert.ok(!JSON.stringify(job).includes('private'));
64
+ assert.throws(() => result({ long: 'x'.repeat(100) }, 50), /output limit/);
65
+ });
66
+
67
+ test('configuration validates named options and limits', () => {
68
+ assert.equal(configuration({}, { port: '9000' }).port, 9000);
69
+ assert.equal(configuration({ mcp: { read_only: true }, mcp_port: 9001 }, {}).port, 9001);
70
+ assert.throws(() => configuration({}, { port: 'oops' }), /port/);
71
+ assert.throws(() => configuration({ mcp: { max_page_size: 1000 } }, {}), /max_page_size/);
72
+ assert.throws(() => configuration({ mcp: { tls_cert: 'cert' } }, {}), /both/);
73
+ });
74
+
75
+ test('registry fails on duplicate names, invalid exports and invalid schemas', t => {
76
+ const directory = fs.mkdtempSync(Path.join(os.tmpdir(), 'xycli-mcp-registry-'));
77
+ t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
78
+ const load = require('../lib/mcp/registry.js');
79
+ fs.writeFileSync(Path.join(directory, 'bad.js'), 'module.exports = {};');
80
+ assert.throws(() => load(directory), /Invalid MCP tool exports/);
81
+ fs.unlinkSync(Path.join(directory, 'bad.js'));
82
+ const moduleText = 'exports.run = async () => ({}); exports.describe = () => (' + JSON.stringify(tools.get('get_event').description) + ');';
83
+ fs.writeFileSync(Path.join(directory, 'one.js'), moduleText);
84
+ fs.writeFileSync(Path.join(directory, 'two.js'), moduleText);
85
+ assert.throws(() => load(directory), /Duplicate/);
86
+ fs.unlinkSync(Path.join(directory, 'one.js'));
87
+ fs.unlinkSync(Path.join(directory, 'two.js'));
88
+ const invalid = structuredClone(tools.get('get_event').description);
89
+ invalid.inputSchema.properties.id.type = 'not-a-json-type';
90
+ fs.writeFileSync(Path.join(directory, 'schema.js'), 'exports.run = async () => ({}); exports.describe = () => (' + JSON.stringify(invalid) + ');');
91
+ assert.throws(() => load(directory));
92
+ });
93
+
94
+ test('ticket and monitoring schemas reject ambiguous targets and protected metadata', async () => {
95
+ const validate = (name, args) => tools.get(name).inputSchema['~standard'].validate(args);
96
+ const invalid = [
97
+ ['create_ticket', { ticket: { subject: ' ' } }],
98
+ ['create_ticket', { ticket: { subject: 'Test', username: 'admin' } }],
99
+ ['create_ticket', { ticket: { subject: 'Test', changes: [] } }],
100
+ ['create_ticket', { ticket: { subject: 'Test', template: 'job', job: 'j1' } }],
101
+ ['update_ticket', { id: 't1', changes: {} }],
102
+ ['update_ticket', { id: 't1', changes: { id: 't2' } }],
103
+ ['update_ticket', { id: 't1', changes: { due: 3600 } }],
104
+ ['update_ticket', { id: 't1', changes: { assignees: 'admin' } }],
105
+ ['comment_ticket', { id: 't1', body: ' ' }],
106
+ ['comment_ticket', { id: 't1', body: 'Hello', username: 'admin' }],
107
+ ['delete_ticket', { id: 'bad/id' }],
108
+ ['take_snapshot', {}],
109
+ ['take_snapshot', { server: 's1', group: 'main' }],
110
+ ['take_snapshot', { server: '' }],
111
+ ['search_alerts', { limit: 101 }],
112
+ ['search_snapshots', { verbose: 'true' }],
113
+ ['list_servers', { offset: -1 }]
114
+ ];
115
+ for (const [name, args] of invalid) assert.ok((await validate(name, args)).issues, name + ': ' + JSON.stringify(args));
116
+ for (const [name, args] of [
117
+ ['create_ticket', { ticket: { subject: 'Test', type: 'custom', status: 'custom' } }],
118
+ ['update_ticket', { id: 't1', changes: { due: 0, server: '', category: '', assignees: [], tags: [] } }],
119
+ ['update_ticket', { id: 't1', changes: { due: 1800000000 } }],
120
+ ['take_snapshot', { server: 's1' }], ['take_snapshot', { group: 'main' }]
121
+ ]) assert.ok(!(await validate(name, args)).issues, name + ': ' + JSON.stringify(args));
122
+ });
123
+
124
+ test('ticket mutations preserve partial updates and keep history out of summaries', async () => {
125
+ const requests = [];
126
+ const api = {};
127
+ for (const name of ['create_ticket', 'update_ticket', 'delete_ticket', 'add_ticket_change']) {
128
+ api[name] = async params => {
129
+ requests.push({ name, params });
130
+ return { data: { code: 0, ticket: { id: 't1', num: 1, subject: 'Test', body: 'Large body', changes: [{ body: 'Old history' }, { id: 'c1', type: 'comment', body: 'New comment', username: 'key1', date: 123 }] } } };
131
+ };
132
+ }
133
+ const context = createContext(api);
134
+ const created = await tools.get('create_ticket').run({ ticket: { subject: 'Test', body: 'Markdown' } }, context);
135
+ assert.equal(created.ticket.id, 't1');
136
+ assert.ok(!('body' in created.ticket) && !('changes' in created.ticket));
137
+ await tools.get('update_ticket').run({ id: 't1', changes: { tags: [], server: '', due: 0 } }, context);
138
+ assert.deepEqual(requests[1], { name: 'update_ticket', params: { id: 't1', tags: [], server: '', due: 0 } });
139
+ const comment = await tools.get('comment_ticket').run({ id: 't1', body: 'New comment' }, context);
140
+ assert.deepEqual(requests[2].params, { id: 't1', change: { type: 'comment', body: 'New comment' } });
141
+ assert.equal(comment.comment.id, 'c1');
142
+ assert.equal(comment.comment.username, 'key1');
143
+ assert.ok(!JSON.stringify(comment).includes('Old history'));
144
+ assert.deepEqual(await tools.get('delete_ticket').run({ id: 't1' }, context), { id: 't1', deleted: true });
145
+ });
146
+
147
+ test('monitoring reads preserve queries, bound pages and filter authorized server summaries', async () => {
148
+ const requests = [];
149
+ const api = {
150
+ get_active_servers: async () => ({ data: { code: 0, rows: [
151
+ { id: 's3', hostname: 'WEB-3', groups: ['main'], enabled: false, socket_id: 'internal' },
152
+ { id: 's2', hostname: 'web-2', groups: ['other'], enabled: false },
153
+ { id: 's1', hostname: 'web-1', groups: ['main'], enabled: false },
154
+ { id: 's4', hostname: 'web-4', groups: ['main'], enabled: true }
155
+ ] } })
156
+ };
157
+ for (const name of ['search_alerts', 'search_snapshots']) api[name] = async params => {
158
+ requests.push({ name, params });
159
+ return { data: { code: 0, rows: [{ id: 'record1', data: { cpu: 42 } }], list: { length: 50 } } };
160
+ };
161
+ const context = createContext(api, { max_page_size: 2 });
162
+ const alerts = await tools.get('search_alerts').run({ query: 'active:true server:s1', offset: 4, limit: 10, sort_by: 'start', sort_dir: 1 }, context);
163
+ assert.deepEqual(requests[0].params, { query: 'active:true server:s1', offset: 4, limit: 2, sort_by: 'start', sort_dir: 1 });
164
+ assert.equal(alerts.total, 50);
165
+ assert.equal(alerts.limit, 2);
166
+ const snapshots = await tools.get('search_snapshots').run({}, context);
167
+ assert.deepEqual(requests[1].params, { query: '*', offset: 0, limit: 2, sort_by: '_id', sort_dir: -1, verbose: false });
168
+ assert.equal(snapshots.rows[0].data.cpu, 42);
169
+ await tools.get('search_snapshots').run({ verbose: true, limit: 1 }, context);
170
+ assert.equal(requests[2].params.verbose, true);
171
+ const servers = await tools.get('list_servers').run({ hostname: 'WeB', group: 'main', enabled: false, offset: 1, limit: 1 }, context);
172
+ assert.equal(servers.total, 2);
173
+ assert.equal(servers.rows[0].id, 's3');
174
+ assert.ok(!('socket_id' in servers.rows[0]));
175
+ });
176
+
177
+ test('all new write APIs enforce read-only, key scoping and uncertain outcomes', async () => {
178
+ const mutations = [
179
+ ['create_ticket', { ticket: { subject: 'Test' } }, 'create_ticket'],
180
+ ['update_ticket', { id: 't1', changes: { status: 'closed' } }, 'update_ticket'],
181
+ ['delete_ticket', { id: 't1' }, 'delete_ticket'],
182
+ ['comment_ticket', { id: 't1', body: 'Test' }, 'add_ticket_change'],
183
+ ['take_snapshot', { server: 's1' }, 'create_snapshot'],
184
+ ['take_snapshot', { group: 'main' }, 'create_group_snapshot']
185
+ ];
186
+ for (const [name, args, method] of mutations) {
187
+ let calls = 0;
188
+ const api = { [method]: async (params, options) => {
189
+ calls++;
190
+ assert.equal(options.retries, 0);
191
+ assert.equal(options.headers['X-API-Key'], 'client-key');
192
+ return { err: 'lost connection client-key' };
193
+ } };
194
+ assert.equal(tools.get(name).description.annotations.readOnlyHint, false);
195
+ await assert.rejects(tools.get(name).run(args, createContext(api, { read_only: true })), /read-only/);
196
+ assert.equal(calls, 0);
197
+ const scoped = createContext(api, { api_key: 'server-key' }).withApiKey('client-key');
198
+ await assert.rejects(tools.get(name).run(args, scoped), error => /uncertain/.test(error.message) && !error.message.includes('client-key'));
199
+ assert.equal(calls, 1);
200
+ api[method] = async () => ({ data: { code: 'access', description: 'Permission denied' } });
201
+ await assert.rejects(tools.get(name).run(args, scoped), error => error.message === 'Permission denied');
202
+ }
203
+ });
204
+
205
+ test('take_snapshot dispatches server and group requests without starting watches', async () => {
206
+ const context = createContext({
207
+ create_snapshot: async params => { assert.deepEqual(params, { server: 's1' }); return { data: { code: 0, id: 'sn1' } }; },
208
+ create_group_snapshot: async params => { assert.deepEqual(params, { group: 'main' }); return { data: { code: 0, id: 'sn2' } }; }
209
+ });
210
+ assert.deepEqual(await tools.get('take_snapshot').run({ server: 's1' }, context), { id: 'sn1', type: 'server', server: 's1' });
211
+ assert.deepEqual(await tools.get('take_snapshot').run({ group: 'main' }, context), { id: 'sn2', type: 'group', group: 'main' });
212
+ });
@@ -0,0 +1,212 @@
1
+ // Explicit live suite. Only this test's disposable records may be modified.
2
+ const { test } = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const { loadTestConfig, call, cleanupFixtures } = require('./helpers/common.js');
5
+ const { stdioClient, httpRPC } = require('./helpers/mcp.js');
6
+ const { configuration } = require('../lib/mcp.js');
7
+
8
+ test('MCP live event/job lifecycle and API-key permissions', async t => {
9
+ const config = loadTestConfig();
10
+ const stamp = Date.now();
11
+ const eventID = 'cli_mcp_' + stamp;
12
+ const keyID = 'cli_mcp_key_' + stamp;
13
+ const sdk = require('@pixlcore/xyops-sdk');
14
+ const jobs = new Set();
15
+ const client = stdioClient(t);
16
+ await client.initialize();
17
+
18
+ async function invoke(name, args) {
19
+ const reply = await client.call(name, args);
20
+ assert.ok(reply.result && !reply.result.isError, reply.result?.content?.[0]?.text || 'Tool failed: ' + name);
21
+ return reply.result.structuredContent;
22
+ }
23
+
24
+ async function finished(id) {
25
+ const deadline = Date.now() + 30000;
26
+ while (Date.now() < deadline) {
27
+ const { job } = await invoke('get_job', { id });
28
+ if (job.final) return job;
29
+ await new Promise(resolve => setTimeout(resolve, 250));
30
+ }
31
+ throw new Error('Disposable MCP job did not finish');
32
+ }
33
+
34
+ // Check connectivity before creating fixtures, so an unavailable server
35
+ // reports the original failure rather than a secondary cleanup failure.
36
+ const data = await call('getMultiple', { lists: 'categories,plugins', servers: 1 });
37
+ try {
38
+ const options = await invoke('get_event_options', { plugin_id: 'shellplug' });
39
+ assert.equal(options.plugin.id, 'shellplug');
40
+ const category = data.categories.find(item => item.enabled);
41
+ const target = Object.keys(data.servers)[0];
42
+ assert.ok(category && target, 'Live test requires an enabled category and connected server');
43
+ const created = await invoke('create_event', { event: {
44
+ id: eventID, title: 'Disposable MCP Test ' + stamp, enabled: true,
45
+ category: category.id, plugin: 'shellplug', targets: [target], algo: 'random',
46
+ params: { script: 'echo XYCLI_MCP_TEST_' + stamp },
47
+ triggers: [{ type: 'manual', enabled: true }], actions: [], limits: []
48
+ } });
49
+ assert.equal(created.event.id, eventID);
50
+ const read = await invoke('get_event', { id: eventID });
51
+ const updated = await invoke('update_event', { id: eventID, revision: read.event.revision, changes: { notes: 'Updated through MCP' } });
52
+ assert.ok(updated.event.revision > read.event.revision);
53
+ const stale = await client.call('update_event', { id: eventID, revision: read.event.revision, changes: { notes: 'Must not apply' } });
54
+ assert.equal(stale.result.isError, true);
55
+ assert.match(stale.result.content[0].text, /Revision mismatch/);
56
+ const preserved = await invoke('get_event', { id: eventID });
57
+ assert.equal(preserved.event.params.script, read.event.params.script);
58
+ assert.equal(preserved.event.notes, 'Updated through MCP');
59
+ assert.equal((await invoke('list_events', { title: 'Disposable MCP Test ' + stamp })).total, 1);
60
+
61
+ // A genuinely restricted xyOps key can inspect and run, but cannot edit.
62
+ const key = await call('createApiKey', { id: keyID, title: keyID, active: true, privileges: { run_jobs: true } });
63
+ const restricted = stdioClient(t, { XYOPS_API_KEY: key.plain_key });
64
+ await restricted.initialize();
65
+ assert.ok(!(await restricted.call('get_event', { id: eventID })).result.isError);
66
+ const denied = await restricted.call('update_event', { id: eventID, revision: updated.event.revision, changes: { notes: 'Forbidden' } });
67
+ assert.equal(denied.result.isError, true);
68
+ const launched = await restricted.call('run_event', { id: eventID });
69
+ assert.ok(!launched.result.isError, 'Restricted key should be able to run the fixture');
70
+ const jobID = launched.result.structuredContent.id;
71
+ jobs.add(jobID);
72
+ const job = await finished(jobID);
73
+ assert.equal(job.code, 0);
74
+ const results = await invoke('search_jobs', { query: 'event:' + eventID });
75
+ assert.ok(results.rows.some(row => row.id === jobID));
76
+
77
+ // Use the HTTP transport against the same live API and compare results.
78
+ const context = require('../lib/mcp/context.js').createContext(sdk.api, { api_key: config.api_key });
79
+ const factory = require('../lib/mcp/server.js')(require('../lib/mcp/registry.js')(), context);
80
+ const service = await require('../lib/mcp/transports/http.js')(factory, { ...configuration({}, {}), port: 0, log() {} });
81
+ try {
82
+ const response = await httpRPC('http://127.0.0.1:' + service.address.port + '/mcp', 'tools/call', { name: 'get_event', arguments: { id: eventID } }, { modern: true, headers: { Authorization: 'Bearer ' + key.plain_key } });
83
+ assert.deepEqual(response.result.structuredContent.event, preserved.event);
84
+ const url = 'http://127.0.0.1:' + service.address.port + '/mcp';
85
+ // The process has admin credentials, but each HTTP caller must use
86
+ // only its supplied key, even while requests overlap.
87
+ const updates = await Promise.all([
88
+ { key: config.api_key, notes: 'HTTP admin update' },
89
+ { key: key.plain_key, notes: 'Forbidden HTTP update' },
90
+ { key: 'invalid-fixture-key', notes: 'Invalid key update' }
91
+ ].map(item => httpRPC(url, 'tools/call', { name: 'update_event', arguments: {
92
+ id: eventID, revision: updated.event.revision, changes: { notes: item.notes }
93
+ } }, { modern: true, headers: { Authorization: 'Bearer ' + item.key } })));
94
+ assert.ok(!updates[0].result.isError);
95
+ assert.equal(updates[1].result.isError, true);
96
+ assert.equal(updates[2].result.isError, true);
97
+ // These must be authentication/permission failures, not revision
98
+ // conflicts caused by the concurrent administrator update.
99
+ assert.match(updates[1].result.content[0].text, /does not have the .* privilege/);
100
+ assert.match(updates[2].result.content[0].text, /Invalid API Key/);
101
+ assert.equal((await fetch(url)).status, 401);
102
+ assert.equal((await invoke('get_event', { id: eventID })).event.notes, 'HTTP admin update');
103
+ }
104
+ finally { await service.close(); }
105
+
106
+ await invoke('delete_event', { id: eventID });
107
+ assert.equal((await invoke('get_job', { id: jobID })).job.id, jobID, 'Event deletion retains historical jobs');
108
+ assert.equal((await client.call('get_event', { id: eventID })).result.isError, true);
109
+ }
110
+ finally {
111
+ // Cleanup is allowed to delete only IDs owned by this test invocation.
112
+ const errors = [];
113
+ for (const id of jobs) {
114
+ try {
115
+ const { job } = await call('getJob', { id });
116
+ if (!job.final) { await call('abortJob', { id }); await finished(id); }
117
+ await call('deleteJob', { id });
118
+ }
119
+ catch (err) { errors.push(err); }
120
+ }
121
+ try { await cleanupFixtures([
122
+ { list: 'events', method: 'deleteEvent', match: item => item.id === eventID },
123
+ { list: 'api_keys', method: 'deleteApiKey', match: item => item.id === keyID }
124
+ ]); }
125
+ catch (err) { errors.push(err); }
126
+ if (errors.length) throw new AggregateError(errors, 'MCP fixture cleanup failed');
127
+ }
128
+ });
129
+
130
+ test('MCP live ticket and monitoring lifecycle', async t => {
131
+ loadTestConfig();
132
+ const subject = 'Disposable MCP Ticket ' + Date.now();
133
+ const snapshots = new Set();
134
+ let ticketID;
135
+ const client = stdioClient(t);
136
+ await client.initialize();
137
+ async function invoke(name, args) {
138
+ const reply = await client.call(name, args);
139
+ assert.ok(reply.result && !reply.result.isError, reply.result?.content?.[0]?.text || 'Tool failed: ' + name);
140
+ return reply.result.structuredContent;
141
+ }
142
+
143
+ // Exact-ID searches return a not-found API error after deletion, rather
144
+ // than an empty search page. Keep that distinction in cleanup assertions.
145
+ async function verifyMissing(collection, id) {
146
+ const response = await require('@pixlcore/xyops-sdk').api['search_' + collection]({ query: '#id:' + id, limit: 1 });
147
+ assert.ok(response.err && response.data?.description?.includes(id) && /Not found/i.test(response.data.description), 'Disposable ' + collection + ' record was removed');
148
+ }
149
+
150
+ // Preflight is read-only. The server needs one saved monitoring sample.
151
+ const servers = await invoke('list_servers', { limit: 1 });
152
+ const server = servers.rows[0];
153
+ assert.ok(server?.groups?.length, 'Live MCP monitoring test requires a connected worker in a group');
154
+ try {
155
+ // Keep the ticket in draft for its entire lifetime to suppress email.
156
+ const created = await invoke('create_ticket', { ticket: { subject, status: 'draft', body: 'Initial body', assignees: [], cc: [], notify: [] } });
157
+ ticketID = created.ticket.id;
158
+ assert.ok(ticketID);
159
+ assert.equal(created.ticket.status, 'draft');
160
+ await invoke('update_ticket', { id: ticketID, changes: { body: 'Updated body', server: server.id } });
161
+ const saved = (await call('get_ticket', { id: ticketID })).ticket;
162
+ assert.equal(saved.subject, subject);
163
+ assert.equal(saved.status, 'draft');
164
+ assert.equal(saved.body, 'Updated body');
165
+ assert.equal(saved.server, server.id);
166
+ const commented = await invoke('comment_ticket', { id: ticketID, body: 'MCP fixture comment' });
167
+ assert.equal(commented.comment.body, 'MCP fixture comment');
168
+ assert.ok(commented.comment.id && commented.comment.username);
169
+ assert.equal(commented.ticket.status, 'draft');
170
+ const history = (await call('get_ticket', { id: ticketID })).ticket.changes;
171
+ assert.equal(history.at(-1).id, commented.comment.id);
172
+ const removed = await invoke('delete_ticket', { id: ticketID });
173
+ assert.equal(removed.deleted, true);
174
+ const deletedID = ticketID;
175
+ ticketID = null;
176
+ await verifyMissing('tickets', deletedID);
177
+
178
+ // Search existing alert history without triggering an alert or action.
179
+ const alerts = await invoke('search_alerts', { query: 'server:' + server.id, limit: 1 });
180
+ assert.ok(Array.isArray(alerts.rows) && alerts.rows.length <= 1);
181
+ assert.equal(typeof alerts.total, 'number');
182
+ for (const target of [{ server: server.id }, { group: server.groups[0] }]) {
183
+ const snapshot = await invoke('take_snapshot', target);
184
+ snapshots.add(snapshot.id);
185
+ assert.ok(snapshot.id);
186
+ const found = await invoke('search_snapshots', { query: '#id:' + snapshot.id, limit: 1 });
187
+ assert.equal(found.rows[0].id, snapshot.id);
188
+ assert.equal(found.rows[0].type, target.server ? 'server' : 'group');
189
+ assert.ok(!('snapshots' in found.rows[0]));
190
+ assert.ok(!('processes' in (found.rows[0].data || {})));
191
+ }
192
+ }
193
+ finally {
194
+ // Delete only returned IDs from this test and verify each removal.
195
+ const errors = [];
196
+ if (ticketID) {
197
+ try {
198
+ await call('delete_ticket', { id: ticketID });
199
+ await verifyMissing('tickets', ticketID);
200
+ }
201
+ catch (err) { errors.push(err); }
202
+ }
203
+ for (const id of snapshots) {
204
+ try {
205
+ await call('delete_snapshot', { id });
206
+ await verifyMissing('snapshots', id);
207
+ }
208
+ catch (err) { errors.push(err); }
209
+ }
210
+ if (errors.length) throw new AggregateError(errors, 'MCP ticket/snapshot fixture cleanup failed');
211
+ }
212
+ });