@pixlcore/xycli 1.1.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.dockerignore +13 -0
  2. package/.github/workflows/docker.yml +64 -0
  3. package/.github/workflows/offline-tests.yml +2 -2
  4. package/Dockerfile +45 -0
  5. package/LICENSE.md +28 -0
  6. package/README.md +22 -1
  7. package/docs/help.md +23 -0
  8. package/docs/mcp.md +224 -0
  9. package/index.js +11 -1
  10. package/lib/config.js +7 -2
  11. package/lib/jobs.js +21 -3
  12. package/lib/mcp/context.js +52 -0
  13. package/lib/mcp/registry.js +27 -0
  14. package/lib/mcp/results.js +14 -0
  15. package/lib/mcp/schemas.js +67 -0
  16. package/lib/mcp/server.js +28 -0
  17. package/lib/mcp/tools/comment-ticket.js +11 -0
  18. package/lib/mcp/tools/create-event.js +17 -0
  19. package/lib/mcp/tools/create-ticket.js +11 -0
  20. package/lib/mcp/tools/delete-event.js +8 -0
  21. package/lib/mcp/tools/delete-ticket.js +8 -0
  22. package/lib/mcp/tools/get-event-options.js +19 -0
  23. package/lib/mcp/tools/get-event.js +8 -0
  24. package/lib/mcp/tools/get-job.js +8 -0
  25. package/lib/mcp/tools/list-events.js +17 -0
  26. package/lib/mcp/tools/list-servers.js +17 -0
  27. package/lib/mcp/tools/run-event.js +10 -0
  28. package/lib/mcp/tools/search-alerts.js +12 -0
  29. package/lib/mcp/tools/search-jobs.js +12 -0
  30. package/lib/mcp/tools/search-snapshots.js +12 -0
  31. package/lib/mcp/tools/take-snapshot.js +15 -0
  32. package/lib/mcp/tools/update-event.js +12 -0
  33. package/lib/mcp/tools/update-ticket.js +11 -0
  34. package/lib/mcp/transports/http.js +76 -0
  35. package/lib/mcp/transports/stdio.js +7 -0
  36. package/lib/mcp.js +120 -0
  37. package/package.json +6 -4
  38. package/test/README.md +3 -0
  39. package/test/config.test.js +22 -0
  40. package/test/fixtures/mcp/localhost.crt +19 -0
  41. package/test/fixtures/mcp/localhost.key +28 -0
  42. package/test/helpers/mcp.js +78 -0
  43. package/test/jobs-follow-unit.test.js +107 -0
  44. package/test/mcp-http-unit.test.js +188 -0
  45. package/test/mcp-stdio-unit.test.js +194 -0
  46. package/test/mcp-tools-unit.test.js +212 -0
  47. package/test/mcp.test.js +212 -0
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 |
@@ -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
+ });