@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.
- 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/docs/two-way-delete-tutorial.md +5 -2
- 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 +9 -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,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
|
+
});
|