@pixlcore/xycli 1.1.1 → 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.
- package/.dockerignore +13 -0
- package/.github/workflows/docker.yml +64 -0
- package/.github/workflows/offline-tests.yml +1 -1
- package/Dockerfile +45 -0
- package/README.md +21 -0
- package/docs/help.md +23 -0
- package/docs/mcp.md +224 -0
- package/index.js +11 -1
- package/lib/config.js +7 -2
- package/lib/mcp/context.js +52 -0
- package/lib/mcp/registry.js +27 -0
- package/lib/mcp/results.js +14 -0
- package/lib/mcp/schemas.js +67 -0
- package/lib/mcp/server.js +28 -0
- package/lib/mcp/tools/comment-ticket.js +11 -0
- package/lib/mcp/tools/create-event.js +17 -0
- package/lib/mcp/tools/create-ticket.js +11 -0
- package/lib/mcp/tools/delete-event.js +8 -0
- package/lib/mcp/tools/delete-ticket.js +8 -0
- package/lib/mcp/tools/get-event-options.js +19 -0
- package/lib/mcp/tools/get-event.js +8 -0
- package/lib/mcp/tools/get-job.js +8 -0
- package/lib/mcp/tools/list-events.js +17 -0
- package/lib/mcp/tools/list-servers.js +17 -0
- package/lib/mcp/tools/run-event.js +10 -0
- package/lib/mcp/tools/search-alerts.js +12 -0
- package/lib/mcp/tools/search-jobs.js +12 -0
- package/lib/mcp/tools/search-snapshots.js +12 -0
- package/lib/mcp/tools/take-snapshot.js +15 -0
- package/lib/mcp/tools/update-event.js +12 -0
- package/lib/mcp/tools/update-ticket.js +11 -0
- package/lib/mcp/transports/http.js +76 -0
- package/lib/mcp/transports/stdio.js +7 -0
- package/lib/mcp.js +117 -0
- package/package.json +6 -4
- package/test/README.md +2 -0
- package/test/config.test.js +22 -0
- package/test/fixtures/mcp/localhost.crt +19 -0
- package/test/fixtures/mcp/localhost.key +28 -0
- package/test/helpers/mcp.js +78 -0
- package/test/mcp-http-unit.test.js +188 -0
- package/test/mcp-stdio-unit.test.js +194 -0
- package/test/mcp-tools-unit.test.js +212 -0
- package/test/mcp.test.js +212 -0
|
@@ -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
|
+
});
|
package/test/mcp.test.js
ADDED
|
@@ -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
|
+
});
|