@pixlcore/xycli 1.1.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.dockerignore +13 -0
  2. package/.github/workflows/docker.yml +64 -0
  3. package/.github/workflows/offline-tests.yml +2 -2
  4. package/Dockerfile +45 -0
  5. package/LICENSE.md +28 -0
  6. package/README.md +22 -1
  7. package/docs/help.md +23 -0
  8. package/docs/mcp.md +224 -0
  9. package/index.js +11 -1
  10. package/lib/config.js +7 -2
  11. package/lib/jobs.js +21 -3
  12. package/lib/mcp/context.js +52 -0
  13. package/lib/mcp/registry.js +27 -0
  14. package/lib/mcp/results.js +14 -0
  15. package/lib/mcp/schemas.js +67 -0
  16. package/lib/mcp/server.js +28 -0
  17. package/lib/mcp/tools/comment-ticket.js +11 -0
  18. package/lib/mcp/tools/create-event.js +17 -0
  19. package/lib/mcp/tools/create-ticket.js +11 -0
  20. package/lib/mcp/tools/delete-event.js +8 -0
  21. package/lib/mcp/tools/delete-ticket.js +8 -0
  22. package/lib/mcp/tools/get-event-options.js +19 -0
  23. package/lib/mcp/tools/get-event.js +8 -0
  24. package/lib/mcp/tools/get-job.js +8 -0
  25. package/lib/mcp/tools/list-events.js +17 -0
  26. package/lib/mcp/tools/list-servers.js +17 -0
  27. package/lib/mcp/tools/run-event.js +10 -0
  28. package/lib/mcp/tools/search-alerts.js +12 -0
  29. package/lib/mcp/tools/search-jobs.js +12 -0
  30. package/lib/mcp/tools/search-snapshots.js +12 -0
  31. package/lib/mcp/tools/take-snapshot.js +15 -0
  32. package/lib/mcp/tools/update-event.js +12 -0
  33. package/lib/mcp/tools/update-ticket.js +11 -0
  34. package/lib/mcp/transports/http.js +76 -0
  35. package/lib/mcp/transports/stdio.js +7 -0
  36. package/lib/mcp.js +120 -0
  37. package/package.json +6 -4
  38. package/test/README.md +3 -0
  39. package/test/config.test.js +22 -0
  40. package/test/fixtures/mcp/localhost.crt +19 -0
  41. package/test/fixtures/mcp/localhost.key +28 -0
  42. package/test/helpers/mcp.js +78 -0
  43. package/test/jobs-follow-unit.test.js +107 -0
  44. package/test/mcp-http-unit.test.js +188 -0
  45. package/test/mcp-stdio-unit.test.js +194 -0
  46. package/test/mcp-tools-unit.test.js +212 -0
  47. package/test/mcp.test.js +212 -0
@@ -0,0 +1,27 @@
1
+ // Load only the bundled tools. Helpers belong outside the tools directory.
2
+ const fs = require('node:fs');
3
+ const Path = require('node:path');
4
+ const { fromJsonSchema } = require('@modelcontextprotocol/server');
5
+
6
+ module.exports = function loadTools(directory = Path.join(__dirname, 'tools')) {
7
+ const tools = new Map();
8
+
9
+ for (const file of fs.readdirSync(directory).filter(file => file.endsWith('.js')).sort()) {
10
+ const tool = require(Path.join(directory, file));
11
+ if (typeof tool.describe !== 'function' || typeof tool.run !== 'function') {
12
+ throw new Error('Invalid MCP tool exports: ' + file);
13
+ }
14
+ const description = tool.describe();
15
+ if (!description || !/^[a-z][a-z0-9_]*$/.test(description.name) || !description.description ||
16
+ !description.inputSchema || description.inputSchema.type !== 'object' ||
17
+ typeof description.annotations?.readOnlyHint !== 'boolean') {
18
+ throw new Error('Invalid MCP tool description: ' + file);
19
+ }
20
+ if (tools.has(description.name)) throw new Error('Duplicate MCP tool: ' + description.name);
21
+
22
+ // Compile once, rather than rebuilding validators for each HTTP request.
23
+ const inputSchema = fromJsonSchema(description.inputSchema);
24
+ tools.set(description.name, { ...tool, description, inputSchema });
25
+ }
26
+ return tools;
27
+ };
@@ -0,0 +1,14 @@
1
+ // Tools return ordinary objects. Only this layer knows the MCP result envelope.
2
+ function result(data, limit) {
3
+ const text = JSON.stringify(data);
4
+ if (Buffer.byteLength(text) > limit) {
5
+ throw new Error('Result exceeds the MCP output limit. Request fewer rows or selected fields. The operation may already have completed.');
6
+ }
7
+ return { structuredContent: data, content: [{ type: 'text', text }] };
8
+ }
9
+
10
+ function failure(error, redact) {
11
+ return { isError: true, content: [{ type: 'text', text: redact(error.message || error) }] };
12
+ }
13
+
14
+ module.exports = { result, failure };
@@ -0,0 +1,67 @@
1
+ // Handwritten public contracts, independent of the underlying API inventory.
2
+ const id = { type: 'string', pattern: '^[a-z0-9_]+$', description: 'Exact xyOps ID; use a list tool to discover IDs.' };
3
+ const text = { type: 'string' };
4
+ const object = { type: 'object', additionalProperties: true };
5
+ const objects = { type: 'array', items: object };
6
+ const strings = { type: 'array', items: text };
7
+ const pagination = {
8
+ offset: { type: 'integer', minimum: 0, default: 0 },
9
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 25 }
10
+ };
11
+
12
+ // Ticket types and statuses are configurable in xyOps, so do not freeze them
13
+ // into enums here. Empty category/server strings explicitly clear a link.
14
+ const ticketProperties = {
15
+ subject: { type: 'string', pattern: '\\S' },
16
+ body: { ...text, description: 'Ticket body in Markdown.' },
17
+ type: { ...text, minLength: 1, description: 'Configured ticket type, such as issue or question.' },
18
+ status: { ...text, minLength: 1, description: 'Configured ticket status, such as open, closed or draft.' },
19
+ category: { type: 'string', pattern: '^[a-z0-9_]*$' },
20
+ server: { type: 'string', pattern: '^[a-z0-9_]*$' },
21
+ assignees: { ...strings, description: 'Assignee usernames. Replaces the entire list.' },
22
+ cc: { ...strings, description: 'Usernames to receive ticket updates. Replaces the entire list.' },
23
+ notify: { ...strings, description: 'Email addresses to receive ticket updates. Replaces the entire list.' },
24
+ tags: { ...strings, description: 'Existing tag IDs. Replaces the entire list.' },
25
+ // xyOps interprets small positive values as relative offsets. Accept only
26
+ // absolute dates here, plus zero to remove the due date.
27
+ due: { type: 'integer', anyOf: [{ const: 0 }, { minimum: 315532800 }], description: 'Absolute Unix timestamp in seconds (1980 or later), or 0 to clear.' }
28
+ };
29
+ const ticketSummary = ['id', 'num', 'subject', 'type', 'status', 'category', 'server', 'assignees', 'cc', 'notify', 'tags', 'due', 'created', 'modified'];
30
+
31
+ const workflow = {
32
+ type: 'object', required: ['nodes', 'connections'], additionalProperties: false,
33
+ properties: {
34
+ nodes: { ...objects, description: 'Workflow nodes, each with id, type and type-specific settings. Inspect an existing workflow for examples.' },
35
+ connections: { ...objects, description: 'Directed workflow connections between node IDs.' },
36
+ start: text
37
+ }
38
+ };
39
+ const eventProperties = {
40
+ title: { type: 'string', pattern: '\\S' }, enabled: { type: 'boolean' }, category: id,
41
+ plugin: id, targets: strings, algo: { ...text, description: 'Server selection algorithm, such as random, round_robin or least_cpu.' },
42
+ params: { ...object, description: 'Plugin parameters. A supplied object replaces the saved parameter object on update.' },
43
+ fields: objects, triggers: { ...objects, description: 'Trigger definitions. For manual execution use {type: "manual", enabled: true}.' },
44
+ actions: objects, limits: objects, tags: strings, notes: text, icon: text,
45
+ expression: text, workflow
46
+ };
47
+
48
+ function input(properties, required = []) {
49
+ return { type: 'object', properties, required, additionalProperties: false };
50
+ }
51
+
52
+ function describe(name, description, properties, required = [], readOnly = true) {
53
+ return {
54
+ name, description, inputSchema: input(properties, required),
55
+ annotations: { readOnlyHint: readOnly, destructiveHint: !readOnly, idempotentHint: readOnly, openWorldHint: true }
56
+ };
57
+ }
58
+
59
+ function page(args, context) {
60
+ return { offset: args.offset ?? 0, limit: Math.min(args.limit ?? 25, context.limits.max_page_size) };
61
+ }
62
+
63
+ function pick(value, fields) {
64
+ return Object.fromEntries(fields.filter(key => Object.hasOwn(value, key)).map(key => [key, value[key]]));
65
+ }
66
+
67
+ module.exports = { id, text, object, objects, strings, pagination, eventProperties, ticketProperties, ticketSummary, input, describe, page, pick };
@@ -0,0 +1,28 @@
1
+ // Both transports register the same descriptions and invoke the same modules.
2
+ const { McpServer } = require('@modelcontextprotocol/server');
3
+ const { result, failure } = require('./results.js');
4
+ const { checkKeys } = require('./context.js');
5
+ const pkg = require('../../package.json');
6
+
7
+ module.exports = function serverFactory(tools, context) {
8
+ return function buildServer({ apiKey } = {}) {
9
+ // STDIO uses the configured context. HTTP supplies a key for each
10
+ // exchange, shared by all API calls made by that exchange's tools.
11
+ const scoped = apiKey === undefined ? context : context.withApiKey(apiKey);
12
+ const server = new McpServer({ name: 'xyops', version: pkg.version });
13
+ for (const tool of tools.values()) {
14
+ const { name, ...description } = tool.description;
15
+ if (scoped.readOnly && !description.annotations.readOnlyHint) continue;
16
+ server.registerTool(name, { ...description, inputSchema: tool.inputSchema }, async function(args, request) {
17
+ try {
18
+ checkKeys(args);
19
+ const invocation = { ...scoped, signal: request.mcpReq.signal };
20
+ invocation.callAPI = (name, params) => scoped.callAPI(name, params, invocation.signal);
21
+ return result(await tool.run(args, invocation), scoped.limits.max_result_bytes);
22
+ }
23
+ catch (err) { return failure(err, scoped.redact); }
24
+ });
25
+ }
26
+ return server;
27
+ };
28
+ };
@@ -0,0 +1,11 @@
1
+ const S = require('../schemas.js');
2
+
3
+ exports.describe = () => S.describe('comment_ticket', 'Append a Markdown comment to a ticket by exact ID. xyOps assigns the author and timestamp. May send configured ticket notifications. Returns the ticket summary and new comment.', {
4
+ id: S.id, body: { type: 'string', pattern: '\\S', description: 'Comment body in Markdown.' }
5
+ }, ['id', 'body'], false);
6
+
7
+ exports.run = async function(args, context) {
8
+ // Expose comments only, not arbitrary change-history records or authors.
9
+ const { ticket } = await context.callAPI('add_ticket_change', { id: args.id, change: { type: 'comment', body: args.body } });
10
+ return { ticket: S.pick(ticket, S.ticketSummary), comment: S.pick(ticket.changes.at(-1), ['id', 'type', 'body', 'username', 'date']) };
11
+ };
@@ -0,0 +1,17 @@
1
+ const S = require('../schemas.js');
2
+
3
+ exports.describe = function() {
4
+ const definition = S.input({ ...S.eventProperties, id: S.id, type: { type: 'string', enum: ['event', 'workflow'] } }, ['title', 'enabled', 'category']);
5
+ // A workflow owns its graph; an ordinary event selects a plugin and worker.
6
+ definition.allOf = [{
7
+ if: { properties: { type: { const: 'workflow' } }, required: ['type'] },
8
+ then: { required: ['workflow'] },
9
+ else: { required: ['plugin', 'targets', 'algo'], properties: { targets: { ...S.strings, minItems: 1 } } }
10
+ }];
11
+ return S.describe('create_event', 'Create an event or workflow. Use get_event_options for valid IDs and plugin parameters. Specify enabled explicitly and supply triggers explicitly when needed. Returns the created ID and revision.', { event: definition }, ['event'], false);
12
+ };
13
+
14
+ exports.run = async function(args, context) {
15
+ const { event } = await context.callAPI('create_event', args.event);
16
+ return { event: S.pick(event, ['id', 'title', 'enabled', 'revision']) };
17
+ };
@@ -0,0 +1,11 @@
1
+ const S = require('../schemas.js');
2
+
3
+ exports.describe = () => S.describe('create_ticket', 'Create a ticket with a subject and optional Markdown body, assignment and classification. Status defaults to open in xyOps. May send configured ticket notifications. Returns a summary; file attachments and automatic templates are not supported.', {
4
+ ticket: S.input(S.ticketProperties, ['subject'])
5
+ }, ['ticket'], false);
6
+
7
+ exports.run = async function(args, context) {
8
+ // Let xyOps assign the ID, number, author and initial change history.
9
+ const { ticket } = await context.callAPI('create_ticket', { ...args.ticket });
10
+ return { ticket: S.pick(ticket, S.ticketSummary) };
11
+ };
@@ -0,0 +1,8 @@
1
+ const S = require('../schemas.js');
2
+
3
+ exports.describe = () => S.describe('delete_event', 'Delete an event or workflow by exact ID. Historical jobs are retained. xyOps rejects deletion while jobs for this event are active.', { id: S.id }, ['id'], false);
4
+
5
+ exports.run = async function(args, context) {
6
+ await context.callAPI('delete_event', { id: args.id });
7
+ return { id: args.id, deleted: true, historical_jobs_retained: true };
8
+ };
@@ -0,0 +1,8 @@
1
+ const S = require('../schemas.js');
2
+
3
+ exports.describe = () => S.describe('delete_ticket', 'Permanently delete a ticket by exact ID. References from jobs and alerts are cleaned up by xyOps background maintenance.', { id: S.id }, ['id'], false);
4
+
5
+ exports.run = async function(args, context) {
6
+ await context.callAPI('delete_ticket', { id: args.id });
7
+ return { id: args.id, deleted: true };
8
+ };
@@ -0,0 +1,19 @@
1
+ const S = require('../schemas.js');
2
+
3
+ exports.describe = () => S.describe('get_event_options', 'Look up categories, target groups and event plugins for creating events. Each collection is paginated. Supply plugin_id to also inspect its parameter definitions.', { ...S.pagination, plugin_id: S.id });
4
+
5
+ exports.run = async function(args, context) {
6
+ const { offset, limit } = S.page(args, context);
7
+ const output = {};
8
+ // Each API independently enforces the configured key permissions.
9
+ for (const name of ['categories', 'groups', 'plugins']) {
10
+ const data = await context.callAPI('get_' + name);
11
+ const rows = data.rows.filter(item => name !== 'plugins' || item.type === 'event').sort((a, b) => a.id.localeCompare(b.id));
12
+ output[name] = { rows: rows.slice(offset, offset + limit).map(item => S.pick(item, ['id', 'title', 'enabled'])), total: rows.length, offset, limit };
13
+ }
14
+ if (args.plugin_id) {
15
+ const { plugin } = await context.callAPI('get_plugin', { id: args.plugin_id });
16
+ output.plugin = S.pick(plugin, ['id', 'title', 'type', 'params']);
17
+ }
18
+ return output;
19
+ };
@@ -0,0 +1,8 @@
1
+ const S = require('../schemas.js');
2
+
3
+ exports.describe = () => S.describe('get_event', 'Read an event or workflow definition before editing it. Pass its revision to update_event. Optional fields limits the returned definition.', { id: S.id, fields: S.strings }, ['id']);
4
+
5
+ exports.run = async function(args, context) {
6
+ const { event } = await context.callAPI('get_event', { id: args.id });
7
+ return { event: args.fields ? S.pick(event, [...new Set(['id', 'revision', ...args.fields])]) : event };
8
+ };
@@ -0,0 +1,8 @@
1
+ const S = require('../schemas.js');
2
+
3
+ exports.describe = () => S.describe('get_job', 'Inspect a running or completed job. Returns status, completion details and output data. Optional fields selects additional job properties. Download tokens are not returned.', { id: S.id, fields: S.strings }, ['id']);
4
+
5
+ exports.run = async function(args, context) {
6
+ const { job } = await context.callAPI('get_job', { id: args.id });
7
+ return { job: S.pick(job, args.fields ? [...new Set(['id', 'state', 'final', ...args.fields])] : ['id', 'event', 'title', 'state', 'final', 'code', 'description', 'started', 'completed', 'elapsed', 'progress', 'data', 'output']) };
8
+ };
@@ -0,0 +1,17 @@
1
+ const S = require('../schemas.js');
2
+
3
+ exports.describe = () => S.describe('list_events', 'Find events and workflows. Returns summaries, not full definitions. Title is a case-insensitive substring. Pagination is applied locally after fetching authorized events.', {
4
+ ...S.pagination, title: S.text, category: S.id, type: { type: 'string', enum: ['event', 'workflow'] }, enabled: { type: 'boolean' }
5
+ });
6
+
7
+ exports.run = async function(args, context) {
8
+ const data = await context.callAPI('get_events');
9
+ const rows = data.rows.filter(event =>
10
+ (!args.title || event.title.toLowerCase().includes(args.title.toLowerCase())) &&
11
+ (!args.category || event.category === args.category) &&
12
+ (!args.type || (event.type || 'event') === args.type) &&
13
+ (args.enabled === undefined || !!event.enabled === args.enabled)
14
+ ).sort((a, b) => a.id.localeCompare(b.id));
15
+ const { offset, limit } = S.page(args, context);
16
+ return { rows: rows.slice(offset, offset + limit).map(event => S.pick(event, ['id', 'title', 'type', 'category', 'plugin', 'enabled', 'revision'])), total: rows.length, offset, limit };
17
+ };
@@ -0,0 +1,17 @@
1
+ const S = require('../schemas.js');
2
+
3
+ exports.describe = () => S.describe('list_servers', 'List connected worker servers visible to the API key, excluding offline servers and conductor peers. Returns compact summaries. Hostname is a case-insensitive substring; group is an exact group ID. Filtering and pagination are local.', {
4
+ ...S.pagination, hostname: S.text, group: S.id, enabled: { type: 'boolean' }
5
+ });
6
+
7
+ exports.run = async function(args, context) {
8
+ // Use the authorized worker collection, avoiding internal conductor data.
9
+ const data = await context.callAPI('get_active_servers');
10
+ const rows = data.rows.filter(server =>
11
+ (!args.hostname || (server.hostname || '').toLowerCase().includes(args.hostname.toLowerCase())) &&
12
+ (!args.group || (server.groups || []).includes(args.group)) &&
13
+ (args.enabled === undefined || !!server.enabled === args.enabled)
14
+ ).sort((a, b) => a.id.localeCompare(b.id));
15
+ const { offset, limit } = S.page(args, context);
16
+ return { rows: rows.slice(offset, offset + limit).map(server => S.pick(server, ['id', 'hostname', 'title', 'ip', 'enabled', 'groups', 'created', 'modified', 'info'])), total: rows.length, offset, limit };
17
+ };
@@ -0,0 +1,10 @@
1
+ const S = require('../schemas.js');
2
+
3
+ exports.describe = () => S.describe('run_event', 'Launch an enabled event or workflow with a manual trigger. Optional params override declared event parameters; input.data passes input data. Returns immediately with a job ID. Use get_job to follow completion. Repeating this call starts another job.', {
4
+ id: S.id, params: S.object, input: S.input({ data: {} })
5
+ }, ['id'], false);
6
+
7
+ exports.run = async function(args, context) {
8
+ const data = await context.callAPI('run_event', args);
9
+ return { id: data.id, started: true };
10
+ };
@@ -0,0 +1,12 @@
1
+ const S = require('../schemas.js');
2
+
3
+ exports.describe = () => S.describe('search_alerts', 'Search active or historical alert invocations, not alert definitions. Query examples: active:true, alert:ALERT_ID, server:SERVER_ID, groups:GROUP_ID, or start:>=today. Combine terms with spaces. Newest first by default; xyOps applies API-key restrictions.', {
4
+ ...S.pagination, query: S.text, sort_by: S.text, sort_dir: { type: 'integer', enum: [1, -1] }
5
+ });
6
+
7
+ exports.run = async function(args, context) {
8
+ // Keep the query intact so xyOps owns parsing and permission filtering.
9
+ const { offset, limit } = S.page(args, context);
10
+ const data = await context.callAPI('search_alerts', { query: args.query || '*', offset, limit, sort_by: args.sort_by || '_id', sort_dir: args.sort_dir ?? -1 });
11
+ return { rows: data.rows, total: data.list.length, offset, limit };
12
+ };
@@ -0,0 +1,12 @@
1
+ const S = require('../schemas.js');
2
+
3
+ exports.describe = () => S.describe('search_jobs', 'Search completed jobs, newest first by default. Query examples: tags:_error, event:EVENT_ID, or date:>=2026-01-01 date:<2027-01-01. Combine terms with spaces. Running jobs are available through get_job. Results respect API-key category and group restrictions.', {
4
+ ...S.pagination, query: S.text, sort_by: { type: 'string', enum: ['_id', 'completed', 'elapsed'] }, sort_dir: { type: 'integer', enum: [1, -1] }
5
+ });
6
+
7
+ exports.run = async function(args, context) {
8
+ const { offset, limit } = S.page(args, context);
9
+ const fields = ['id', 'event', 'title', 'category', 'plugin', 'code', 'description', 'completed', 'elapsed'];
10
+ const data = await context.callAPI('search_jobs', { query: args.query || '*', offset, limit, sort_by: args.sort_by || '_id', sort_dir: args.sort_dir ?? -1, select: fields.join(',') });
11
+ return { rows: data.rows.map(job => S.pick(job, fields)), total: data.list.length, offset, limit };
12
+ };
@@ -0,0 +1,12 @@
1
+ const S = require('../schemas.js');
2
+
3
+ exports.describe = () => S.describe('search_snapshots', 'Search server or group snapshots. Query examples: type:server, type:group, server:SERVER_ID, groups:GROUP_ID, source:alert, or date:>=today. Newest first by default. Heavy process, connection and group details are omitted unless verbose is true; use small pages for verbose results.', {
4
+ ...S.pagination, query: S.text, sort_by: S.text, sort_dir: { type: 'integer', enum: [1, -1] }, verbose: { type: 'boolean', default: false }
5
+ });
6
+
7
+ exports.run = async function(args, context) {
8
+ // The API prunes heavy nested fields by default while retaining metrics.
9
+ const { offset, limit } = S.page(args, context);
10
+ const data = await context.callAPI('search_snapshots', { query: args.query || '*', offset, limit, sort_by: args.sort_by || '_id', sort_dir: args.sort_dir ?? -1, verbose: args.verbose ?? false });
11
+ return { rows: data.rows, total: data.list.length, offset, limit };
12
+ };
@@ -0,0 +1,15 @@
1
+ const S = require('../schemas.js');
2
+
3
+ exports.describe = function() {
4
+ const tool = S.describe('take_snapshot', 'Capture the most recent monitoring data for exactly one server or group. This creates a stored snapshot; it does not start a watch or request a fresh sample. Returns the snapshot ID.', {
5
+ server: S.id, group: S.id
6
+ }, [], false);
7
+ tool.inputSchema.oneOf = [{ required: ['server'] }, { required: ['group'] }];
8
+ return tool;
9
+ };
10
+
11
+ exports.run = async function(args, context) {
12
+ // One public operation dispatches to the appropriate backend API.
13
+ const data = args.server ? await context.callAPI('create_snapshot', { server: args.server }) : await context.callAPI('create_group_snapshot', { group: args.group });
14
+ return { id: data.id, type: args.server ? 'server' : 'group', ...(args.server ? { server: args.server } : { group: args.group }) };
15
+ };
@@ -0,0 +1,12 @@
1
+ const S = require('../schemas.js');
2
+
3
+ exports.describe = () => S.describe('update_event', 'Update an event using the revision returned by get_event. Omitted fields are preserved. Supplied nested objects and arrays replace existing values, so include all values you want to keep. Event type cannot be changed. A stale revision is rejected.', {
4
+ id: S.id, revision: { type: 'integer', minimum: 1 }, changes: { ...S.input(S.eventProperties), minProperties: 1 }
5
+ }, ['id', 'revision', 'changes'], false);
6
+
7
+ exports.run = async function(args, context) {
8
+ // Submit a partial update with the caller's revision. Do not fetch and merge
9
+ // the entire record, which could overwrite changes made by another caller.
10
+ const { event } = await context.callAPI('update_event', { ...args.changes, id: args.id, revision: args.revision });
11
+ return { event: S.pick(event, ['id', 'title', 'enabled', 'revision']) };
12
+ };
@@ -0,0 +1,11 @@
1
+ const S = require('../schemas.js');
2
+
3
+ exports.describe = () => S.describe('update_ticket', 'Update a ticket by exact ID. Omitted fields are preserved; supplied arrays replace the entire saved list. Tickets have no revision precondition. May send configured ticket notifications. Returns a summary.', {
4
+ id: S.id, changes: { ...S.input(S.ticketProperties), minProperties: 1 }
5
+ }, ['id', 'changes'], false);
6
+
7
+ exports.run = async function(args, context) {
8
+ // Preserve the API partial-update contract without fetching stale fields.
9
+ const { ticket } = await context.callAPI('update_ticket', { ...args.changes, id: args.id });
10
+ return { ticket: S.pick(ticket, S.ticketSummary) };
11
+ };
@@ -0,0 +1,76 @@
1
+ // Thin Node listener: do not read or parse the body before the MCP adapter.
2
+ const http = require('node:http');
3
+ const https = require('node:https');
4
+ const fs = require('node:fs');
5
+ const { createMcpHandler } = require('@modelcontextprotocol/server');
6
+ const { toNodeHandler, hostHeaderValidation } = require('@modelcontextprotocol/node');
7
+
8
+ module.exports = async function startHTTP(factory, options) {
9
+ // Check only the header shape here. xyOps validates the key and enforces
10
+ // privileges when the tool calls its API; there is no separate MCP secret.
11
+ function bearerKey(authorization) {
12
+ return /^Bearer +([A-Za-z0-9._~+\/-]+=*)$/i.exec(authorization || '')?.[1];
13
+ }
14
+ const handler = createMcpHandler(request => {
15
+ const apiKey = bearerKey(request.requestInfo.headers.get('authorization'));
16
+ if (!apiKey) throw new Error('Missing xyOps API key.');
17
+ return factory({ apiKey });
18
+ }, {
19
+ // Auto returns JSON for ordinary calls and preserves streamed messages
20
+ // if a future tool needs progress notifications during its response.
21
+ responseMode: 'auto', maxRequestBodySize: options.max_request_bytes,
22
+ maxSubscriptions: 0, onerror: () => options.log('MCP protocol error on HTTP request.')
23
+ });
24
+ const nodeHandler = toNodeHandler(handler, { maxRequestBodySize: options.max_request_bytes, onerror: () => options.log('MCP HTTP adapter error.') });
25
+ const validateHost = hostHeaderValidation(options.allowed_hosts);
26
+
27
+ function respond(response, status, text) {
28
+ response.writeHead(status, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
29
+ response.end(text);
30
+ }
31
+
32
+ function request(request, response) {
33
+ // Route before dispatch. The SDK owns protocol method/status semantics.
34
+ if (request.url.split('?')[0] !== '/mcp') return respond(response, 404, 'Not found');
35
+ if (!validateHost(request, response)) return;
36
+ const origin = request.headers.origin;
37
+ if (origin && !options.allowed_origins.includes(origin)) return respond(response, 403, 'Origin not allowed');
38
+ if (!bearerKey(request.headers.authorization)) {
39
+ response.setHeader('WWW-Authenticate', 'Bearer');
40
+ return respond(response, 401, 'Supply a xyOps API key as a Bearer token');
41
+ }
42
+ response.setHeader('Cache-Control', 'no-store');
43
+ nodeHandler(request, response).catch(function() {
44
+ options.log('MCP HTTP request failed.');
45
+ if (!response.headersSent) respond(response, 500, 'Internal server error');
46
+ else response.destroy();
47
+ });
48
+ }
49
+
50
+ const server = options.tls_cert ? https.createServer({ cert: fs.readFileSync(options.tls_cert), key: fs.readFileSync(options.tls_key) }, request) : http.createServer(request);
51
+ server.requestTimeout = 30000;
52
+ server.headersTimeout = 15000;
53
+ server.maxConnections = 100;
54
+ await new Promise((resolve, reject) => {
55
+ server.once('error', reject);
56
+ server.listen(options.port, options.host, function() {
57
+ server.removeListener('error', reject);
58
+ resolve();
59
+ });
60
+ });
61
+
62
+ let closing;
63
+ return {
64
+ address: server.address(),
65
+ close() {
66
+ // Stop new requests first, then cancel MCP exchanges and close sockets.
67
+ if (!closing) closing = (async function() {
68
+ const closed = new Promise(resolve => server.close(resolve));
69
+ await handler.close();
70
+ server.closeAllConnections();
71
+ await closed;
72
+ })();
73
+ return closing;
74
+ }
75
+ };
76
+ };
@@ -0,0 +1,7 @@
1
+ // The SDK exclusively owns stdin/stdout. Diagnostics always use stderr.
2
+ const { serveStdio, StdioServerTransport } = require('@modelcontextprotocol/server/stdio');
3
+
4
+ module.exports = function startStdio(factory, options) {
5
+ const transport = new StdioServerTransport(process.stdin, process.stdout, { maxBufferSize: options.max_request_bytes });
6
+ return serveStdio(factory, { transport, maxSubscriptions: 0, onerror: () => options.log('MCP protocol error on stdin.') });
7
+ };
package/lib/mcp.js ADDED
@@ -0,0 +1,120 @@
1
+ // Dedicated MCP startup avoids terminal behavior in the interactive CLI.
2
+ const { parseArgs } = require('node:util');
3
+ const os = require('node:os');
4
+ const pkg = require('../package.json');
5
+
6
+ const HELP = `Usage:
7
+ xy mcp stdio [--read-only]
8
+ xy mcp serve [--port 8080] [--host 127.0.0.1] [--read-only]
9
+
10
+ Uses the existing CLI configuration and XYOPS_BASE_URL. STDIO uses XYOPS_API_KEY.
11
+ HTTP endpoint: /mcp. Clients supply their xyOps API key as a Bearer token.
12
+ Remote binds also require allowed_hosts in the mcp configuration section.
13
+ TLS options: --tls-cert FILE --tls-key FILE. See xy help mcp for details.
14
+ `;
15
+
16
+ function configuration(config, args) {
17
+ if (config.mcp !== undefined && (!config.mcp || typeof config.mcp !== 'object' || Array.isArray(config.mcp))) throw new Error('MCP configuration must be an object.');
18
+ const values = { ...config.mcp };
19
+ // Nested config stays readable in cli.json. Environment overrides use flat
20
+ // names, matching the CLI's existing XYOPS_* loader.
21
+ for (const key of ['host', 'port', 'read_only', 'max_page_size', 'max_result_bytes', 'max_request_bytes', 'tls_cert', 'tls_key']) {
22
+ if (config['mcp_' + key] !== undefined) values[key] = config['mcp_' + key];
23
+ }
24
+ for (const key of ['host', 'port', 'read-only', 'tls-cert', 'tls-key']) {
25
+ if (args[key] !== undefined) values[key.replaceAll('-', '_')] = args[key];
26
+ }
27
+ const options = {
28
+ host: '127.0.0.1', port: 8080, read_only: false,
29
+ max_page_size: 100, max_result_bytes: 1048576, max_request_bytes: 1048576,
30
+ allowed_hosts: ['localhost', '127.0.0.1', '[::1]'], allowed_origins: [], ...values
31
+ };
32
+ for (const key of ['port', 'max_page_size', 'max_result_bytes', 'max_request_bytes']) {
33
+ options[key] = Number(options[key]);
34
+ const max = key === 'port' ? 65535 : (key === 'max_page_size' ? 100 : 16 * 1024 * 1024);
35
+ if (!Number.isInteger(options[key]) || options[key] < 1 || options[key] > max) throw new Error('Invalid MCP ' + key);
36
+ }
37
+ if (typeof options.host !== 'string' || !options.host) throw new Error('Invalid MCP host');
38
+ if (typeof options.read_only !== 'boolean') throw new Error('MCP read_only must be a boolean');
39
+ for (const key of ['allowed_hosts', 'allowed_origins']) {
40
+ if (!Array.isArray(options[key]) || options[key].some(value => typeof value !== 'string' || !value)) throw new Error('MCP ' + key + ' must be an array of strings');
41
+ }
42
+ if (!!options.tls_cert !== !!options.tls_key) throw new Error('Supply both tls_cert and tls_key');
43
+ return options;
44
+ }
45
+
46
+ async function main(argv) {
47
+ // Give the long-running MCP server a recognizable name in process lists.
48
+ process.title = 'xyOps MCP Server';
49
+
50
+ const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: {
51
+ port: { type: 'string' }, host: { type: 'string' }, 'read-only': { type: 'boolean' },
52
+ 'tls-cert': { type: 'string' }, 'tls-key': { type: 'string' },
53
+ help: { type: 'boolean', short: 'h' }, verbose: { type: 'boolean', short: 'v' },
54
+ debug: { type: 'boolean', short: 'd' }, quiet: { type: 'boolean', short: 'q' }
55
+ } });
56
+ if (values.help || positionals[0] === 'help') { process.stdout.write(HELP); return; }
57
+ const mode = positionals[0];
58
+ if (!['stdio', 'serve'].includes(mode) || positionals.length !== 1) throw new Error('Use xy mcp stdio or xy mcp serve --port 8080. Run xy mcp --help for details.');
59
+ if (Number(process.versions.node.split('.')[0]) < 20) throw new Error('MCP requires Node.js 20 or newer.');
60
+
61
+ const app = {};
62
+ require('./config.js').loadConfig.call(app);
63
+ const config = app.config;
64
+ if (!config.base_url) throw new Error('Configure XYOPS_BASE_URL or base_url in cli.json.');
65
+ if (mode === 'stdio' && !config.api_key) throw new Error('Configure XYOPS_API_KEY or api_key in cli.json for STDIO.');
66
+ const options = configuration(config, values);
67
+ if (mode === 'serve' && !['127.0.0.1', '::1', 'localhost'].includes(options.host)) {
68
+ if (!config.mcp?.allowed_hosts?.length) throw new Error('Remote HTTP binds require explicit mcp.allowed_hosts.');
69
+ }
70
+
71
+ // Identify MCP traffic in xyOps logs, preserving an explicit override.
72
+ if (!process.env.XYOPS_USER_AGENT) {
73
+ process.env.XYOPS_USER_AGENT = `xyOps MCP v${pkg.version} (Node.js); ${os.hostname()} (${os.platform()}/${os.arch()})`;
74
+ }
75
+
76
+ // Initialize the SDK only after config is loaded so timeout settings apply.
77
+ // Never inherit a retry policy which could duplicate a create/run request.
78
+ process.env.XYOPS_RETRIES = '0';
79
+ const { api } = require('@pixlcore/xyops-sdk');
80
+ api.request.setRetries(0);
81
+ const context = require('./mcp/context.js').createContext(api, { ...options, api_key: mode === 'stdio' ? config.api_key : undefined });
82
+ const tools = require('./mcp/registry.js')();
83
+ const factory = require('./mcp/server.js')(tools, context);
84
+ const start = require('./mcp/transports/' + (mode === 'stdio' ? 'stdio' : 'http') + '.js');
85
+ const service = await start(factory, { ...options, log: context.log });
86
+ if (mode === 'serve') {
87
+ // IPv6 bind addresses are unbracketed, but URLs require brackets to
88
+ // separate the address from the port.
89
+ const hostname = options.host.includes(':') ? '[' + options.host + ']' : options.host;
90
+ context.log('Listening at ' + (options.tls_cert ? 'https' : 'http') + '://' + hostname + ':' + service.address.port + '/mcp');
91
+ }
92
+
93
+ await new Promise(resolve => {
94
+ let closing = false;
95
+ const stop = async function() {
96
+ if (closing) return;
97
+ closing = true;
98
+ // A request already accepted upstream may still complete. Shutdown does
99
+ // not abort xyOps jobs or claim that a cancelled mutation was rolled back.
100
+ const timer = setTimeout(() => process.exit(1), 5000);
101
+ timer.unref();
102
+ try { await service.close(); }
103
+ catch (err) { context.log(err.message); process.exitCode = 1; }
104
+ finally {
105
+ clearTimeout(timer);
106
+ api.request.setKeepAlive(false);
107
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.removeListener(signal, stop);
108
+ process.stdin.removeListener('end', stop);
109
+ resolve();
110
+ }
111
+ };
112
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.once(signal, stop);
113
+ if (mode === 'stdio') {
114
+ process.stdin.once('end', stop);
115
+ if (process.stdin.readableEnded) stop();
116
+ }
117
+ });
118
+ }
119
+
120
+ module.exports = { main, configuration };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixlcore/xycli",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "description": "A complete command-line interface for the xyOps workflow automation system.",
5
5
  "author": "Joseph Huckaby <jhuckaby@pixlcore.com>",
6
6
  "homepage": "https://github.com/pixlcore/xycli",
@@ -21,21 +21,23 @@
21
21
  ],
22
22
  "scripts": {
23
23
  "test": "node test.js",
24
- "test:offline": "node --test test/config.test.js test/help.test.js test/markdown.test.js test/sync-buckets-unit.test.js test/sync-syshook-unit.test.js test/sync-unit.test.js test/toast.test.js test/upgrade.test.js test/utils-unit.test.js",
24
+ "test:offline": "node --test test/config.test.js test/help.test.js test/jobs-follow-unit.test.js test/markdown.test.js test/sync-buckets-unit.test.js test/sync-syshook-unit.test.js test/sync-unit.test.js test/toast.test.js test/upgrade.test.js test/utils-unit.test.js test/mcp-tools-unit.test.js test/mcp-stdio-unit.test.js test/mcp-http-unit.test.js",
25
25
  "changelog": "node tools/changelog.js"
26
26
  },
27
27
  "overrides": {
28
28
  "basic-ftp": "6.2.1"
29
29
  },
30
30
  "dependencies": {
31
- "@pixlcore/xyops-sdk": "^1.0.7",
31
+ "@pixlcore/xyops-sdk": "^1.0.8",
32
32
  "cli-highlight": "2.1.11",
33
33
  "diff": "9.0.0",
34
34
  "pixl-cli": "^1.1.6",
35
35
  "html-to-text": "10.0.1",
36
36
  "marked": "15.0.12",
37
37
  "marked-terminal": "7.3.0",
38
- "uncatch": "^1.0.4"
38
+ "uncatch": "^1.0.4",
39
+ "@modelcontextprotocol/server": "^2.4.0",
40
+ "@modelcontextprotocol/node": "^2.1.2"
39
41
  },
40
42
  "publishConfig": {
41
43
  "access": "public"