@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.
- package/.dockerignore +13 -0
- package/.github/workflows/docker.yml +64 -0
- package/.github/workflows/offline-tests.yml +2 -2
- package/Dockerfile +45 -0
- package/LICENSE.md +28 -0
- package/README.md +22 -1
- 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/jobs.js +21 -3
- 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 +120 -0
- package/package.json +6 -4
- package/test/README.md +3 -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/jobs-follow-unit.test.js +107 -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
package/test/README.md
CHANGED
|
@@ -12,6 +12,8 @@ Use an xyOps version that supports restoring API key IDs, hashes, and masks thro
|
|
|
12
12
|
|
|
13
13
|
## Running Tests
|
|
14
14
|
|
|
15
|
+
For MCP-only coverage, run `node test.js mcp` against the configured local server. It creates disposable events, a restricted API key and a draft ticket, runs an echo job, checks both transports, exercises ticket mutations, searches alert invocations, takes server/group snapshots, and cleans up. Monitoring coverage requires a connected worker in a group with a saved monitoring sample. Ticket fixtures remain drafts to suppress notifications; only snapshots created by the MCP suite are deleted, with removal verified. The offline MCP suites are included in `npm run test:offline` and use a fake upstream API plus local transport connections.
|
|
16
|
+
|
|
15
17
|
Run every suite, one at a time:
|
|
16
18
|
|
|
17
19
|
```sh
|
|
@@ -57,6 +59,7 @@ node --test test/config.test.js test/sync-buckets-unit.test.js test/sync-unit.te
|
|
|
57
59
|
| `config` | Offline user-file updates, inherited-setting isolation, missing-file creation, dotted edits, permissions, and read-only display |
|
|
58
60
|
| `events` | CRUD, dependency filters, nested edits, triggers, actions, limits, fields, validation, and help |
|
|
59
61
|
| `jobs` | Event launch, parameter and input overrides, completed reports, output logs, search, rerun, deletion, validation, and help |
|
|
62
|
+
| `jobs-follow-unit` | Offline live-follow retry chains, queued and already-completed retries, completed-job lookups, and stopping without a retry |
|
|
60
63
|
| `log` | Log searches, latest rows, matching, column selection, native output |
|
|
61
64
|
| `markdown` | Terminal rendering, inline formatting inside list items, typographic bullets, and output margins |
|
|
62
65
|
| `monitors` | CRUD, groups, evaluator requests, type conversions, validation, help |
|
package/test/config.test.js
CHANGED
|
@@ -134,3 +134,25 @@ test('config display shows effective environment overrides without writing the u
|
|
|
134
134
|
assert.doesNotMatch(output, /fixture-key/);
|
|
135
135
|
assert.equal(fs.readFileSync(fixture.file, 'utf8'), original);
|
|
136
136
|
});
|
|
137
|
+
|
|
138
|
+
test('config parses and exports JSON objects and arrays for any setting', t => {
|
|
139
|
+
const fixture = createConfigFixture(t, { sync: { up: ['events'] }, targets: ['server1'] });
|
|
140
|
+
const output = fixture.run([]);
|
|
141
|
+
assert.match(output, /"sync":/);
|
|
142
|
+
assert.match(output, /"targets":/);
|
|
143
|
+
|
|
144
|
+
// Two loads verify the loader's config-to-environment round trip for any
|
|
145
|
+
// nested setting, while retaining the existing scalar conversions.
|
|
146
|
+
const script = "const loader=require('./lib/config.js'); const one={},two={}; loader.loadConfig.call(one); loader.loadConfig.call(two); process.stdout.write(JSON.stringify({ object:two.config.fixture_object,array:two.config.fixture_array,flag:two.config.fixture_flag,number:two.config.fixture_number,text:two.config.fixture_text }));";
|
|
147
|
+
const response = spawnSync(process.execPath, ['-e', script], {
|
|
148
|
+
cwd: Path.resolve(__dirname, '..'), encoding: 'utf8',
|
|
149
|
+
env: { ...process.env, XYOPS_FIXTURE_OBJECT: ' {"nested":{"enabled":true}} ', XYOPS_FIXTURE_ARRAY: '[1,"two",false]', XYOPS_FIXTURE_FLAG: 'false', XYOPS_FIXTURE_NUMBER: '2.5', XYOPS_FIXTURE_TEXT: 'ordinary string' }
|
|
150
|
+
});
|
|
151
|
+
assert.equal(response.status, 0);
|
|
152
|
+
assert.deepEqual(JSON.parse(response.stdout), { object: { nested: { enabled: true } }, array: [1, 'two', false], flag: false, number: 2.5, text: 'ordinary string' });
|
|
153
|
+
const malformed = spawnSync(process.execPath, ['-e', script], {
|
|
154
|
+
cwd: Path.resolve(__dirname, '..'), encoding: 'utf8', env: { ...process.env, XYOPS_FIXTURE_OBJECT: '{broken}' }
|
|
155
|
+
});
|
|
156
|
+
assert.notEqual(malformed.status, 0);
|
|
157
|
+
assert.match(malformed.stderr, /Failed to parse JSON from environment variable: XYOPS_FIXTURE_OBJECT/);
|
|
158
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-----BEGIN CERTIFICATE-----
|
|
2
|
+
MIIDJTCCAg2gAwIBAgIUE+q8i5Huab9cIusPdyyM1jSo1D0wDQYJKoZIhvcNAQEL
|
|
3
|
+
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MTAyNDIxMTQ1NloXDTM2MTAy
|
|
4
|
+
MTIxMTQ1NlowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
|
|
5
|
+
AAOCAQ8AMIIBCgKCAQEAp/9tYH7BYj0HsppRCLpWjIGIboi3tuoPhC57QcPu2TxD
|
|
6
|
+
YSoHb6rS7PgFccFdGr8bOhfm8f5IKxZLS78hfaNDx499DTX/NaU7pR47vR/Ryxbm
|
|
7
|
+
P1wojuo53N3GoMXrsAM6AK/2KbK1R1IWlMD6zQ5rsArtpDTEhQdxbkK2Qgjp3qnz
|
|
8
|
+
suXgmxlsJ0m/8OACavuKdieNynl6CdJ75+tJn4AV0jqaHV7CDk7QyW2Yg6SDCZVD
|
|
9
|
+
lk4Ftayxc05CPAlh8WOPnvUTIXk2SEpA3p59D/hH9yA2izvEMTG45pOItoJwtFvG
|
|
10
|
+
FZc1DPlng3ie1A/lUKEtoA+5lk2A91Jg/sIJUxirkQIDAQABo28wbTAdBgNVHQ4E
|
|
11
|
+
FgQUlfqOYOxeA5j18RO6+hQZySE7TvQwHwYDVR0jBBgwFoAUlfqOYOxeA5j18RO6
|
|
12
|
+
+hQZySE7TvQwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SH
|
|
13
|
+
BH8AAAEwDQYJKoZIhvcNAQELBQADggEBAIhp1ckadusVEbdyiWRF+GyX3rUs0bDq
|
|
14
|
+
9dgMfY1b5cYpdDEv/oMCQvAMWpy70mQb8hq4ffpNLLuzolF4mzt5S+PiR8LYBacY
|
|
15
|
+
E7iIFQtJ4WpI+50xdxJT2xx1Bk1KIyt3au9T99oVljUJGejYC4MYQR5pCRY83I/h
|
|
16
|
+
apUEb6NPxgFwfaD+BGnCG1INQvPc6LRVuat9M72KGziBUM5OtERuJpgA/t8EbZXA
|
|
17
|
+
rnGA9XF9q8c56BpwYlrpvM3pjeIcH3sRJ3Wx6wtp6hGPa51EnoqrpT5tmE994miG
|
|
18
|
+
2vnHalGtQISt9Ru6F9sxlR7mqh441TpAxYDa42MmDsXxhKTRcY7xOww=
|
|
19
|
+
-----END CERTIFICATE-----
|
|
@@ -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,107 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const cli = require('pixl-cli');
|
|
4
|
+
const jobs = require('../lib/jobs.js');
|
|
5
|
+
|
|
6
|
+
cli.global();
|
|
7
|
+
|
|
8
|
+
function makeFollower(t, records) {
|
|
9
|
+
// Exercise the real command transitions with an offline SDK stub. Streaming
|
|
10
|
+
// finishes an attempt, but only getJob exposes its additional-job links.
|
|
11
|
+
const streamed = [];
|
|
12
|
+
const results = [];
|
|
13
|
+
const context = {
|
|
14
|
+
...jobs,
|
|
15
|
+
args: { id: records[0].id },
|
|
16
|
+
format: 'json',
|
|
17
|
+
async getMultiple() {},
|
|
18
|
+
api: {
|
|
19
|
+
async getJob({ id }) {
|
|
20
|
+
const job = records.find( item => item.id === id );
|
|
21
|
+
assert.ok(job, 'Unexpected job lookup: ' + id);
|
|
22
|
+
return { data: { job: { ...job } } };
|
|
23
|
+
},
|
|
24
|
+
async streamJob({ id }, callback) {
|
|
25
|
+
const job = records.find( item => item.id === id );
|
|
26
|
+
streamed.push(id);
|
|
27
|
+
job.final = true;
|
|
28
|
+
callback({ id, final: true, code: job.code });
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
jsonOutput(job) { results.push(job.id); },
|
|
33
|
+
die(message) { throw new Error(message); },
|
|
34
|
+
color() { return cli.chalk.white; },
|
|
35
|
+
printBoxList() {}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Keep terminal rendering out of these tests, including progress timers.
|
|
39
|
+
for (const name of ['start', 'update', 'end']) t.mock.method(cli.progress, name, () => {});
|
|
40
|
+
for (const name of ['print', 'println', 'verboseln']) t.mock.method(global, name, () => {});
|
|
41
|
+
for (const name of ['getNiceJob', 'getNiceJobEvent', 'getNiceCategory', 'getNicePlugin',
|
|
42
|
+
'getNiceTargets', 'getNiceServer', 'getNiceAlgo', 'getNiceJobSource',
|
|
43
|
+
'getRelativeDateTime', 'getNiceJobState']) {
|
|
44
|
+
context[name] = () => '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { context, streamed, results };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test('live follow crosses completed and queued retries, ignoring unrelated jobs', async t => {
|
|
51
|
+
const { context, streamed, results } = makeFollower(t, [
|
|
52
|
+
{ id: 'initial', code: 1, jobs: [{ id: 'other', reason: 'action' }, { id: 'retry1', reason: 'retry' }] },
|
|
53
|
+
{ id: 'retry1', final: true, code: 'warning', jobs: [{ id: 'retry2', reason: 'retry' }] },
|
|
54
|
+
{ id: 'retry2', state: 'queued', code: 0 }
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
await context.cmd_stream_job();
|
|
58
|
+
assert.deepEqual(streamed, ['initial', 'retry2']);
|
|
59
|
+
assert.deepEqual(results, ['retry2']);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('directly opening an active job also follows its retry', async t => {
|
|
63
|
+
const { context, streamed, results } = makeFollower(t, [
|
|
64
|
+
{ id: 'initial', code: 1, jobs: [{ id: 'retry', reason: 'retry' }] },
|
|
65
|
+
{ id: 'retry', code: 2 }
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
await context.cmd_get_job();
|
|
69
|
+
assert.deepEqual(streamed, ['initial', 'retry']);
|
|
70
|
+
assert.deepEqual(results, ['retry']);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('follow catches retries when the initial job finishes before streaming starts', async t => {
|
|
74
|
+
const { context, streamed, results } = makeFollower(t, [
|
|
75
|
+
{ id: 'initial', final: true, code: 1, jobs: [{ id: 'retry', reason: 'retry' }] },
|
|
76
|
+
{ id: 'retry', code: 0 }
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
await context.cmd_stream_job();
|
|
80
|
+
assert.deepEqual(streamed, ['retry']);
|
|
81
|
+
assert.deepEqual(results, ['retry']);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('opening a completed job preserves the requested attempt', async t => {
|
|
85
|
+
const { context, streamed, results } = makeFollower(t, [
|
|
86
|
+
{ id: 'initial', final: true, code: 1, jobs: [{ id: 'retry', reason: 'retry' }] }
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
await context.cmd_get_job();
|
|
90
|
+
assert.deepEqual(streamed, []);
|
|
91
|
+
assert.deepEqual(results, ['initial']);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('follow stops on success or failure without a valid retry link', async t => {
|
|
95
|
+
for (const record of [
|
|
96
|
+
{ code: 0, jobs: [{ id: 'other', reason: 'retry' }] },
|
|
97
|
+
{ code: 1 },
|
|
98
|
+
{ code: 1, jobs: [] },
|
|
99
|
+
{ code: 1, jobs: {} },
|
|
100
|
+
{ code: 1, jobs: [null, { reason: 'retry' }, { id: 'other', reason: 'action' }] }
|
|
101
|
+
]) {
|
|
102
|
+
const { context, streamed, results } = makeFollower(t, [{ id: 'initial', ...record }]);
|
|
103
|
+
await context.cmd_stream_job();
|
|
104
|
+
assert.deepEqual(streamed, ['initial']);
|
|
105
|
+
assert.deepEqual(results, ['initial']);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
@@ -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
|
+
});
|