@pixlcore/xyext-mcp 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/README.md +93 -2
- package/index.js +200 -0
- package/package.json +36 -5
- package/xyops.json +15 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PixlCore LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,94 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center"><img src="https://raw.githubusercontent.com/pixlcore/xyext-mcp/refs/heads/main/logo.png" height="160" alt="MCP Server for xyOps"/></p>
|
|
2
|
+
<h1 align="center">MCP Server for xyOps</h1>
|
|
2
3
|
|
|
3
|
-
This
|
|
4
|
+
Connect your AI assistant directly to xyOps. This extension adds a Model Context Protocol (MCP) server at `/mcp` on your conductor's existing URL, with tools for events, jobs, tickets, alert invocations, monitoring snapshots, and servers.
|
|
5
|
+
|
|
6
|
+
The extension includes [xyCLI](https://github.com/pixlcore/xycli) as a dependency and runs its MCP HTTP server locally. It works with Docker and manual xyOps installations. You do not need a global CLI installation, a separate container, or an additional published port.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
Requires **xyOps 1.1.11 or later**.
|
|
11
|
+
|
|
12
|
+
Install **MCP Server** from the xyOps Marketplace. Installing the extension enables it automatically after the conductor restarts. No configuration is required for the default setup.
|
|
13
|
+
|
|
14
|
+
For unpublished development versions, use the [local extension development instructions](https://github.com/pixlcore/xyops/blob/main/docs/ext.md#local-development). The package name is `@pixlcore/xyext-mcp` and its runtime ID is `pixlcore-xyext-mcp`.
|
|
15
|
+
|
|
16
|
+
## Connect Your AI Assistant
|
|
17
|
+
|
|
18
|
+
Choose **Streamable HTTP** in your MCP client and use your conductor's URL with `/mcp` appended:
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
https://xyops.example.com/mcp
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Configure this request header in your client:
|
|
25
|
+
|
|
26
|
+
```text
|
|
27
|
+
Authorization: Bearer YOUR_XYOPS_API_KEY
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Each client supplies its own xyOps API key. The extension forwards it to xyCLI, and xyOps validates the key and enforces its permissions and resource restrictions when tools call the API. There is no shared server API key to configure. Choose the permissions you want to grant the assistant when creating its key in xyOps.
|
|
31
|
+
|
|
32
|
+
To explore the tool list and make calls manually, launch [MCP Inspector](https://github.com/modelcontextprotocol/inspector):
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
npx @modelcontextprotocol/inspector
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Select Streamable HTTP, enter your conductor's `/mcp` URL, and configure the Bearer authorization header. See the [xyCLI MCP guide](https://github.com/pixlcore/xycli/blob/main/docs/mcp.md) for tool descriptions and more client examples.
|
|
39
|
+
|
|
40
|
+
## Configuration
|
|
41
|
+
|
|
42
|
+
The only setting is `port`, which defaults to `5524`. This is the private port used by the internal service on `127.0.0.1`. Clients always connect through the xyOps conductor's `/mcp` endpoint.
|
|
43
|
+
|
|
44
|
+
To choose another port, merge this into your xyOps configuration, preserving any existing extension settings:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"extensions": {
|
|
49
|
+
"pixlcore-xyext-mcp": {
|
|
50
|
+
"port": 5525
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Restart the conductor to apply the change. Choose an unused port between `1` and `65535`, different from `WebServer.port`. In Docker, the equivalent environment option is:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
-e XYOPS_extensions__pixlcore-xyext-mcp__port=5525
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Remove the extension through the Marketplace when you no longer want the MCP endpoint.
|
|
63
|
+
|
|
64
|
+
## Deployment Notes
|
|
65
|
+
|
|
66
|
+
- The child connects back to xyOps at `http://127.0.0.1:PORT`. The conductor's HTTP listener must be accessible on loopback.
|
|
67
|
+
- External clients use the conductor's existing HTTPS listener or reverse proxy. Do not publish the internal MCP child port.
|
|
68
|
+
- Browser requests with an `Origin` header must match the origin of xyOps's `base_app_url`. Desktop agents that omit this header can connect normally.
|
|
69
|
+
- Extensions run on the primary conductor. In a multi-conductor cluster with a load balancer, route `/mcp` to the current primary.
|
|
70
|
+
- If you use an authentication gateway, allow `/mcp` to use Bearer API-key authentication. Browser login redirects cannot authenticate an MCP client.
|
|
71
|
+
|
|
72
|
+
The extension launches its dependency using the conductor's Node.js executable. Node's module resolution handles both ordinary NPM dependency layouts and development symlinks. Child diagnostics are written to `logs/Extension.log` with the category column set to `pixlcore-xyext-mcp`.
|
|
73
|
+
|
|
74
|
+
The endpoint returns `503` while the child is unavailable. An unexpected child exit is logged and requires a conductor restart; requests are not automatically replayed. During shutdown, the extension removes its URI handler, closes its proxy connections, and sends SIGTERM to the child without waiting for it.
|
|
75
|
+
|
|
76
|
+
## Development Tests
|
|
77
|
+
|
|
78
|
+
Install the package dependencies and run the tests against a development xyOps checkout:
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
XYOPS_ROOT=/path/to/xyops npm test
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Without `XYOPS_ROOT`, the tests look for a sibling `xyops` checkout beside the parent `xyplugs` directory. They use that checkout's web-server dependencies, the extension's actual xyCLI dependency, and an isolated API fixture. They do not require a running conductor or real API keys.
|
|
85
|
+
|
|
86
|
+
The tests cover startup and shutdown, custom ports, port conflicts, concurrent client credentials, JSON and SSE forwarding, missing or invalid credentials, rejected origins, and disconnect cleanup.
|
|
87
|
+
|
|
88
|
+
## Privacy
|
|
89
|
+
|
|
90
|
+
This extension does not collect telemetry or send data to an external service. Tool requests and results travel between your MCP client and xyOps through a local child process. Your AI application may send results to its model provider according to that application's configuration and policies.
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT. See [LICENSE.md](LICENSE.md). xyCLI and its dependencies retain their own licenses.
|
package/index.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// MCP Extension for xyOps
|
|
2
|
+
// Copyright (c) 2026 PixlCore LLC
|
|
3
|
+
// MIT License
|
|
4
|
+
|
|
5
|
+
const cp = require('child_process');
|
|
6
|
+
const http = require('http');
|
|
7
|
+
|
|
8
|
+
module.exports = {
|
|
9
|
+
|
|
10
|
+
init() {
|
|
11
|
+
// Extensions are loaded on the primary. Installing this package enables
|
|
12
|
+
// MCP without any additional service or global CLI installation.
|
|
13
|
+
this.shut = false;
|
|
14
|
+
this.xy.web.addURIHandler('/mcp', '@pixlcore/xyext-mcp', this.handleMCP.bind(this));
|
|
15
|
+
this.startLocalMCP();
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
startLocalMCP() {
|
|
19
|
+
// One long-lived HTTP server, with credentials scoped to each request.
|
|
20
|
+
if (this.mcpChild || this.shut) return;
|
|
21
|
+
const webPort = Number(this.server.config.getPath('WebServer.port'));
|
|
22
|
+
|
|
23
|
+
const port = Number(this.config.port ?? 5524);
|
|
24
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535 || port === webPort) {
|
|
25
|
+
return this.logDebug(1, 'Cannot launch local MCP: Invalid MCP port (must differ from WebServer.port).');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Strip conductor settings BEFORE adding CLI settings. Do not supply a
|
|
29
|
+
// service API key: every HTTP caller must provide its own Bearer token.
|
|
30
|
+
const env = this.xy.cleanEnv();
|
|
31
|
+
env.XYOPS_BASE_URL = 'http://127.0.0.1:' + webPort;
|
|
32
|
+
env.XYOPS_API_KEY = '';
|
|
33
|
+
|
|
34
|
+
// The SDK must reach the conductor directly, even on hosts configured
|
|
35
|
+
// with a corporate outbound proxy. Set both spellings for compatibility.
|
|
36
|
+
env.NO_PROXY = env.no_proxy = '127.0.0.1,localhost,::1';
|
|
37
|
+
env.XYOPS_MCP_TLS_CERT = '';
|
|
38
|
+
env.XYOPS_MCP_TLS_KEY = '';
|
|
39
|
+
|
|
40
|
+
let origin;
|
|
41
|
+
try { origin = new URL(this.server.config.get('base_app_url')).origin; }
|
|
42
|
+
catch (err) { return this.logDebug(1, 'Cannot launch local MCP: Invalid base_app_url.'); }
|
|
43
|
+
env.XYOPS_MCP = JSON.stringify({ allowed_hosts: ['127.0.0.1'], allowed_origins: [origin] });
|
|
44
|
+
|
|
45
|
+
this.mcpPort = port;
|
|
46
|
+
this.mcpReady = false;
|
|
47
|
+
this.mcpRequests = new Set();
|
|
48
|
+
let child;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
// Resolve from this package so NPM hoisting and development symlinks
|
|
52
|
+
// both work. Use the conductor's Node executable, never a global xy.
|
|
53
|
+
const entry = require.resolve('@pixlcore/xycli');
|
|
54
|
+
child = cp.spawn(process.execPath, [entry, 'mcp', 'serve', '--host', '127.0.0.1', '--port', String(port)], {
|
|
55
|
+
env, stdio: ['ignore', 'pipe', 'pipe']
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
this.logDebug(1, 'Cannot launch local MCP: ' + err.message);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.mcpChild = child;
|
|
64
|
+
this.logDebug(2, 'Starting local MCP HTTP server on loopback port ' + port);
|
|
65
|
+
|
|
66
|
+
// Only mark ready after this child announces its successful bind. A
|
|
67
|
+
// service already occupying the port must never receive client keys.
|
|
68
|
+
const readyLine = 'xy mcp: Listening at http://127.0.0.1:' + port + '/mcp';
|
|
69
|
+
|
|
70
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
71
|
+
stream.setEncoding('utf8');
|
|
72
|
+
let pending = '';
|
|
73
|
+
|
|
74
|
+
stream.on('data', chunk => {
|
|
75
|
+
pending += chunk;
|
|
76
|
+
let index;
|
|
77
|
+
|
|
78
|
+
while ((index = pending.indexOf('\n')) >= 0) {
|
|
79
|
+
const line = pending.slice(0, index).trim();
|
|
80
|
+
pending = pending.slice(index + 1);
|
|
81
|
+
|
|
82
|
+
if (line === readyLine && !this.shut && child.exitCode === null && child.signalCode === null) this.mcpReady = true;
|
|
83
|
+
if (line) this.logDebug(5, 'MCP: ' + line);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Bound buffering even if a diagnostic never includes a newline.
|
|
87
|
+
if (pending.length > 8192) { this.logDebug(5, 'MCP: ' + pending.slice(0, 8192)); pending = ''; }
|
|
88
|
+
});
|
|
89
|
+
stream.on('end', () => { if (pending) this.logDebug(5, 'MCP: ' + pending); });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
child.on('error', err => {
|
|
93
|
+
this.mcpReady = false;
|
|
94
|
+
this.logDebug(1, 'Local MCP process error: ' + err.message);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
child.on('exit', (code, signal) => {
|
|
98
|
+
this.mcpReady = false;
|
|
99
|
+
for (const request of this.mcpRequests) request.destroy();
|
|
100
|
+
this.logDebug(this.shut ? 3 : 1, 'Local MCP process exited', { code, signal });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
child.on('close', () => { if (this.mcpChild === child) this.mcpChild = null; });
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
handleMCP(args, callback) {
|
|
107
|
+
// The web server has already consumed the request body. Reuse its bytes
|
|
108
|
+
// and calculate a fresh Content-Length instead of piping an empty input.
|
|
109
|
+
const { request, response } = args;
|
|
110
|
+
const fail = (status, message) => callback(status, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' }, message);
|
|
111
|
+
|
|
112
|
+
if (!this.xy.master || this.xy.shut || this.shut || !this.mcpReady) return fail('503 Service Unavailable', 'Local MCP server is unavailable.');
|
|
113
|
+
|
|
114
|
+
if (!['POST', 'GET', 'DELETE'].includes(request.method)) {
|
|
115
|
+
return callback('405 Method Not Allowed', { Allow: 'POST, GET, DELETE' }, 'Method not allowed.');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (request.method === 'POST' && !/^application\/json(?:\s*;|$)/i.test(request.headers['content-type'] || '')) {
|
|
119
|
+
return fail('415 Unsupported Media Type', 'MCP requests must use application/json.');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Preserve MCP and authentication headers only. Cookies, forwarded
|
|
123
|
+
// identity headers, and hop-by-hop headers do not belong on this hop.
|
|
124
|
+
const headers = {};
|
|
125
|
+
for (const name of ['authorization', 'accept', 'content-type', 'content-encoding', 'origin', 'mcp-protocol-version', 'mcp-session-id', 'last-event-id']) {
|
|
126
|
+
if (request.headers[name] !== undefined) headers[name] = request.headers[name];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const body = request.method === 'POST' ? (request.body || Buffer.from(JSON.stringify(args.params))) : null;
|
|
130
|
+
if (body) headers['content-length'] = body.length;
|
|
131
|
+
|
|
132
|
+
let completed = false;
|
|
133
|
+
let upstream;
|
|
134
|
+
|
|
135
|
+
const finish = () => {
|
|
136
|
+
if (completed) return;
|
|
137
|
+
completed = true;
|
|
138
|
+
this.mcpRequests.delete(upstream);
|
|
139
|
+
response.removeListener('finish', finish);
|
|
140
|
+
response.removeListener('close', disconnect);
|
|
141
|
+
args.perf.end('process');
|
|
142
|
+
callback(true);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const disconnect = () => { upstream.destroy(); finish(); };
|
|
146
|
+
|
|
147
|
+
upstream = http.request({
|
|
148
|
+
hostname: '127.0.0.1', port: this.mcpPort, path: '/mcp',
|
|
149
|
+
method: request.method, headers, agent: false
|
|
150
|
+
}, incoming => {
|
|
151
|
+
// Stream JSON or SSE verbatim. Strip connection-specific headers,
|
|
152
|
+
// including any additional names nominated by Connection itself.
|
|
153
|
+
const excluded = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade']);
|
|
154
|
+
for (const name of String(incoming.headers.connection || '').split(',')) excluded.add(name.trim().toLowerCase());
|
|
155
|
+
|
|
156
|
+
const output = {};
|
|
157
|
+
for (const name in incoming.headers) { if (!excluded.has(name)) output[name] = incoming.headers[name]; }
|
|
158
|
+
|
|
159
|
+
response.writeHead(incoming.statusCode, output);
|
|
160
|
+
response.flushHeaders();
|
|
161
|
+
|
|
162
|
+
incoming.on('data', chunk => args.perf.count('bytes_out', chunk.length));
|
|
163
|
+
incoming.on('error', () => response.destroy());
|
|
164
|
+
incoming.pipe(response);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
this.mcpRequests.add(upstream);
|
|
168
|
+
|
|
169
|
+
response.once('finish', finish);
|
|
170
|
+
response.once('close', disconnect);
|
|
171
|
+
|
|
172
|
+
upstream.setTimeout(120000, () => upstream.destroy(new Error('MCP upstream timeout')));
|
|
173
|
+
|
|
174
|
+
upstream.on('error', err => {
|
|
175
|
+
if (completed) return;
|
|
176
|
+
this.logDebug(3, 'MCP upstream request failed: ' + err.message);
|
|
177
|
+
if (response.headersSent) response.destroy();
|
|
178
|
+
else {
|
|
179
|
+
response.writeHead(502, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
|
|
180
|
+
response.end('Local MCP server request failed.');
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
upstream.end(body);
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
shutdown() {
|
|
188
|
+
// Extension shutdown is fire-and-forget. Stop admitting requests, remove
|
|
189
|
+
// our route and signal the attached child without blocking the conductor.
|
|
190
|
+
this.shut = true;
|
|
191
|
+
this.mcpReady = false;
|
|
192
|
+
this.xy.web.removeURIHandler('@pixlcore/xyext-mcp');
|
|
193
|
+
|
|
194
|
+
for (const request of this.mcpRequests || []) request.destroy();
|
|
195
|
+
|
|
196
|
+
const child = this.mcpChild;
|
|
197
|
+
if (child && child.exitCode === null && child.signalCode === null) child.kill('SIGTERM');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@pixlcore/xyext-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Connect AI assistants to xyOps with an HTTP MCP server.",
|
|
5
|
+
"author": "Joseph Huckaby <jhuckaby@pixlcore.com>",
|
|
6
|
+
"homepage": "https://github.com/pixlcore/xyext-mcp",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"main": "index.js",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/pixlcore/xyext-mcp"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/pixlcore/xyext-mcp/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"xyops",
|
|
18
|
+
"extension",
|
|
19
|
+
"mcp"
|
|
20
|
+
],
|
|
21
|
+
"overrides": {
|
|
22
|
+
"basic-ftp": "6.2.1"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@pixlcore/xycli": "^1.2.1"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"index.js",
|
|
32
|
+
"xyops.json"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "node --test test/mcp-proxy.test.js"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/xyops.json
ADDED