raidcom-mcp 0.0.0-stage → 0.1.2
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/CHANGELOG.md +57 -0
- package/INSTALL.md +169 -0
- package/LICENSE +21 -0
- package/README.docker.md +50 -0
- package/README.md +117 -2
- package/README.plugin.md +56 -0
- package/dist/builder.js +409 -0
- package/dist/catalog.js +79 -0
- package/dist/cmdline.js +145 -0
- package/dist/config.js +118 -0
- package/dist/http.js +198 -0
- package/dist/index.js +307 -0
- package/dist/recipes.js +307 -0
- package/dist/search.js +157 -0
- package/dist/tools.js +179 -0
- package/package.json +54 -4
- package/spec/examples.json +390 -0
- package/spec/limits.json +236 -0
- package/spec/notes/conventions.md +56 -0
- package/spec/notes/horcm-setup.md +27 -0
- package/spec/raidcom-catalog.json +1 -0
- package/spec/recipes.json +1106 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Server settings, read once from the environment, and helpers for the bundled data files.
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { createSecureContext } from "node:tls";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
7
|
+
/** Path of a file bundled with the server (spec/, package.json). */
|
|
8
|
+
export const bundled = (...parts) => join(root, ...parts);
|
|
9
|
+
/** Reads a JSON file, naming the file in the error if it cannot be parsed. */
|
|
10
|
+
export function loadJson(path, what) {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
13
|
+
}
|
|
14
|
+
catch (e) {
|
|
15
|
+
throw new Error(`[raidcom-mcp] cannot read ${what} at ${path}: ${e.message}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Like loadJson, but returns `fallback` when the path is empty or the file does not exist. */
|
|
19
|
+
export function loadOptionalJson(path, what, fallback) {
|
|
20
|
+
return path && existsSync(path) ? loadJson(path, what) : fallback;
|
|
21
|
+
}
|
|
22
|
+
export const VERSION = loadJson(bundled("package.json"), "package.json").version;
|
|
23
|
+
export function loadServerConfig(env = process.env) {
|
|
24
|
+
return {
|
|
25
|
+
catalogPath: env.RAIDCOM_CATALOG || bundled("spec", "raidcom-catalog.json"),
|
|
26
|
+
limitsPath: env.RAIDCOM_LIMITS || bundled("spec", "limits.json"),
|
|
27
|
+
recipesPath: env.RAIDCOM_RECIPES || bundled("spec", "recipes.json"),
|
|
28
|
+
// An empty RAIDCOM_EXAMPLES turns the example questions off.
|
|
29
|
+
examplesPath: env.RAIDCOM_EXAMPLES ?? bundled("spec", "examples.json"),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const MIN_TOKEN_LENGTH = 32;
|
|
33
|
+
/** RAIDCOM_HTTP_TRUST_PROXY: unset or false → 0, true → 1, or a number of proxies from 1 to 10. */
|
|
34
|
+
function proxyHops(v) {
|
|
35
|
+
if (v === undefined || v === "" || v === "false" || v === "0")
|
|
36
|
+
return 0;
|
|
37
|
+
if (v === "true")
|
|
38
|
+
return 1;
|
|
39
|
+
if (/^\d+$/.test(v) && Number(v) >= 1 && Number(v) <= 10)
|
|
40
|
+
return Number(v);
|
|
41
|
+
throw new Error(`[raidcom-mcp] RAIDCOM_HTTP_TRUST_PROXY must be true, false or the number of proxies in front of the server (1 to 10), not "${v}"`);
|
|
42
|
+
}
|
|
43
|
+
const list = (v) => (v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
44
|
+
/** True for addresses that only this machine can reach. */
|
|
45
|
+
export function isLoopback(host) {
|
|
46
|
+
const h = host.replace(/^\[|\]$/g, "").toLowerCase();
|
|
47
|
+
return h === "localhost" || h === "::1" || /^127\.\d+\.\d+\.\d+$/.test(h);
|
|
48
|
+
}
|
|
49
|
+
/** Reads the TLS certificate and key, checking that they are valid PEM and belong together. */
|
|
50
|
+
function loadTls(env) {
|
|
51
|
+
const certPath = env.RAIDCOM_HTTP_TLS_CERT;
|
|
52
|
+
const keyPath = env.RAIDCOM_HTTP_TLS_KEY;
|
|
53
|
+
if (!certPath && !keyPath)
|
|
54
|
+
return undefined;
|
|
55
|
+
if (!certPath || !keyPath)
|
|
56
|
+
throw new Error("[raidcom-mcp] Set both RAIDCOM_HTTP_TLS_CERT and RAIDCOM_HTTP_TLS_KEY to serve HTTPS, or neither");
|
|
57
|
+
const read = (path, what) => {
|
|
58
|
+
try {
|
|
59
|
+
return readFileSync(path);
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
throw new Error(`[raidcom-mcp] cannot read the TLS ${what} at ${path}: ${e.message}`);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const tls = { cert: read(certPath, "certificate"), key: read(keyPath, "key") };
|
|
66
|
+
try {
|
|
67
|
+
createSecureContext(tls);
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
throw new Error(`[raidcom-mcp] RAIDCOM_HTTP_TLS_CERT and RAIDCOM_HTTP_TLS_KEY are not a usable certificate and key: ${e.message}`);
|
|
71
|
+
}
|
|
72
|
+
return tls;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Reads the transport settings. The server only builds text, so it holds no secrets, but a public endpoint is
|
|
76
|
+
* still guarded: a bearer token (or an explicit RAIDCOM_HTTP_OPEN=true), TLS for any address other machines can
|
|
77
|
+
* reach, and a per-client rate limit.
|
|
78
|
+
*/
|
|
79
|
+
export function loadHttpConfig(env = process.env) {
|
|
80
|
+
const transport = (env.RAIDCOM_TRANSPORT || "stdio").toLowerCase();
|
|
81
|
+
if (transport === "stdio")
|
|
82
|
+
return undefined;
|
|
83
|
+
if (transport !== "http")
|
|
84
|
+
throw new Error(`[raidcom-mcp] RAIDCOM_TRANSPORT must be stdio or http, not "${env.RAIDCOM_TRANSPORT}"`);
|
|
85
|
+
const host = env.RAIDCOM_HTTP_HOST || "127.0.0.1";
|
|
86
|
+
const port = Number(env.RAIDCOM_HTTP_PORT ?? 8361);
|
|
87
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
88
|
+
throw new Error(`[raidcom-mcp] RAIDCOM_HTTP_PORT must be a port number, not "${env.RAIDCOM_HTTP_PORT}"`);
|
|
89
|
+
const token = env.RAIDCOM_HTTP_TOKEN ?? "";
|
|
90
|
+
const open = env.RAIDCOM_HTTP_OPEN === "true";
|
|
91
|
+
if (token && token.length < MIN_TOKEN_LENGTH) {
|
|
92
|
+
throw new Error(`[raidcom-mcp] RAIDCOM_HTTP_TOKEN must be at least ${MIN_TOKEN_LENGTH} characters. Generate one with: openssl rand -hex 32`);
|
|
93
|
+
}
|
|
94
|
+
if (!token && !open) {
|
|
95
|
+
throw new Error(`[raidcom-mcp] RAIDCOM_TRANSPORT=http needs RAIDCOM_HTTP_TOKEN (clients send it as "Authorization: Bearer <token>"; generate one with: openssl rand -hex 32), ` +
|
|
96
|
+
`or RAIDCOM_HTTP_OPEN=true to serve anyone who can reach the port. The server only builds command text, so an open endpoint is a choice, not a risk to your arrays.`);
|
|
97
|
+
}
|
|
98
|
+
const tls = loadTls(env);
|
|
99
|
+
const behindTlsProxy = env.RAIDCOM_HTTP_BEHIND_TLS_PROXY === "true";
|
|
100
|
+
if (!isLoopback(host) && !tls && !behindTlsProxy) {
|
|
101
|
+
throw new Error(`[raidcom-mcp] RAIDCOM_HTTP_HOST=${host} would accept connections from other machines over plain HTTP. ` +
|
|
102
|
+
`Set RAIDCOM_HTTP_TLS_CERT and RAIDCOM_HTTP_TLS_KEY to serve HTTPS, or RAIDCOM_HTTP_BEHIND_TLS_PROXY=true if a proxy or load balancer ends TLS in front of this server.`);
|
|
103
|
+
}
|
|
104
|
+
const rate = Number(env.RAIDCOM_HTTP_RATE_LIMIT ?? 120);
|
|
105
|
+
if (!Number.isFinite(rate) || rate < 0)
|
|
106
|
+
throw new Error(`[raidcom-mcp] RAIDCOM_HTTP_RATE_LIMIT must be a number of requests per minute (0 for none), not "${env.RAIDCOM_HTTP_RATE_LIMIT}"`);
|
|
107
|
+
return {
|
|
108
|
+
host,
|
|
109
|
+
port,
|
|
110
|
+
token,
|
|
111
|
+
allowedHosts: list(env.RAIDCOM_HTTP_ALLOWED_HOSTS),
|
|
112
|
+
allowedOrigins: list(env.RAIDCOM_HTTP_ALLOWED_ORIGINS),
|
|
113
|
+
tls,
|
|
114
|
+
behindTlsProxy,
|
|
115
|
+
rateLimitPerMinute: Math.floor(rate),
|
|
116
|
+
trustProxy: proxyHops(env.RAIDCOM_HTTP_TRUST_PROXY),
|
|
117
|
+
};
|
|
118
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Streamable HTTP transport (RAIDCOM_TRANSPORT=http), stateless: every POST to /mcp gets its own MCP server and
|
|
2
|
+
// transport, which answer over an SSE stream and close with the response. The tools keep no per-client state, so
|
|
3
|
+
// there are no sessions to track or expire, and any number of instances can sit behind a load balancer.
|
|
4
|
+
//
|
|
5
|
+
// The checks run before the MCP layer sees a request, in this order: path (and /healthz), Host and Origin (against
|
|
6
|
+
// DNS rebinding and browser pages driving a local server), the rate limit, the bearer token, method, then body size.
|
|
7
|
+
import { createServer as createHttpServer } from "node:http";
|
|
8
|
+
import { createServer as createHttpsServer } from "node:https";
|
|
9
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
10
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
11
|
+
import { VERSION } from "./config.js";
|
|
12
|
+
export const MCP_PATH = "/mcp";
|
|
13
|
+
export const HEALTH_PATH = "/healthz";
|
|
14
|
+
/** Largest request body accepted: tool arguments are small. */
|
|
15
|
+
export const MAX_BODY_BYTES = 256 * 1024;
|
|
16
|
+
/** Requests handled at once; more get 503. */
|
|
17
|
+
export const MAX_CONCURRENT = 64;
|
|
18
|
+
/** Requests one client address may have in progress at once, so a single client can't take every slot. */
|
|
19
|
+
export const MAX_PER_CLIENT = 8;
|
|
20
|
+
/** A client has this long to send its headers, and this long to send the whole request (ms). */
|
|
21
|
+
export const HEADERS_TIMEOUT_MS = 10_000;
|
|
22
|
+
export const REQUEST_TIMEOUT_MS = 15_000;
|
|
23
|
+
/** Client addresses the rate limiter remembers at most. */
|
|
24
|
+
const MAX_TRACKED = 10_000;
|
|
25
|
+
const digest = (s) => createHash("sha256").update(s).digest();
|
|
26
|
+
/** Compares the Authorization header with the token in constant time (hashed first, so length doesn't leak). */
|
|
27
|
+
function authorized(header, token) {
|
|
28
|
+
const m = /^Bearer[ \t]+(\S+)\s*$/i.exec(header ?? "");
|
|
29
|
+
return !!m && timingSafeEqual(digest(m[1]), token);
|
|
30
|
+
}
|
|
31
|
+
/** The Host header values that name this server: loopback names for the port in use, plus any configured. */
|
|
32
|
+
function hostNames(cfg, port) {
|
|
33
|
+
const names = ["localhost", "127.0.0.1", "[::1]"];
|
|
34
|
+
if (!names.includes(cfg.host) && cfg.host !== "0.0.0.0" && cfg.host !== "::")
|
|
35
|
+
names.push(cfg.host.includes(":") ? `[${cfg.host}]` : cfg.host);
|
|
36
|
+
return new Set([...names.map((n) => `${n}:${port}`), ...cfg.allowedHosts].map((h) => h.toLowerCase()));
|
|
37
|
+
}
|
|
38
|
+
function reject(res, status, message, headers = {}) {
|
|
39
|
+
res.writeHead(status, { "content-type": "application/json", ...headers });
|
|
40
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message }, id: null }));
|
|
41
|
+
}
|
|
42
|
+
class TooLarge extends Error {
|
|
43
|
+
}
|
|
44
|
+
async function readJson(req) {
|
|
45
|
+
if (Number(req.headers["content-length"]) > MAX_BODY_BYTES)
|
|
46
|
+
throw new TooLarge();
|
|
47
|
+
const chunks = [];
|
|
48
|
+
let size = 0;
|
|
49
|
+
for await (const chunk of req) {
|
|
50
|
+
size += chunk.length;
|
|
51
|
+
if (size > MAX_BODY_BYTES)
|
|
52
|
+
throw new TooLarge();
|
|
53
|
+
chunks.push(chunk);
|
|
54
|
+
}
|
|
55
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
56
|
+
}
|
|
57
|
+
/** Fixed-window request counter per client address. */
|
|
58
|
+
export class RateLimiter {
|
|
59
|
+
perMinute;
|
|
60
|
+
now;
|
|
61
|
+
hits = new Map();
|
|
62
|
+
constructor(perMinute, now = Date.now) {
|
|
63
|
+
this.perMinute = perMinute;
|
|
64
|
+
this.now = now;
|
|
65
|
+
}
|
|
66
|
+
/** Number of client addresses currently remembered (for tests). */
|
|
67
|
+
get size() {
|
|
68
|
+
return this.hits.size;
|
|
69
|
+
}
|
|
70
|
+
/** True when the request is allowed. */
|
|
71
|
+
allow(key) {
|
|
72
|
+
if (this.perMinute <= 0)
|
|
73
|
+
return true;
|
|
74
|
+
const t = this.now();
|
|
75
|
+
if (this.hits.size >= MAX_TRACKED) {
|
|
76
|
+
for (const [k, v] of this.hits)
|
|
77
|
+
if (t - v.start >= 60_000)
|
|
78
|
+
this.hits.delete(k);
|
|
79
|
+
// still full: forget the oldest windows (a Map iterates in insertion order), so memory stays bounded and the
|
|
80
|
+
// sweep above runs rarely
|
|
81
|
+
if (this.hits.size >= MAX_TRACKED)
|
|
82
|
+
for (const k of [...this.hits.keys()].slice(0, MAX_TRACKED / 2))
|
|
83
|
+
this.hits.delete(k);
|
|
84
|
+
}
|
|
85
|
+
const h = this.hits.get(key);
|
|
86
|
+
if (!h || t - h.start >= 60_000) {
|
|
87
|
+
this.hits.set(key, { start: t, count: 1 });
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
return ++h.count <= this.perMinute;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The client's address. Each proxy appends the address it received the request from, so behind `hops` trusted
|
|
95
|
+
* proxies the client is the entry `hops` places from the right of X-Forwarded-For: the one the outermost trusted
|
|
96
|
+
* proxy added. Entries further left come from the client and can say anything. (Same rule as Express's
|
|
97
|
+
* `trust proxy` set to a number.)
|
|
98
|
+
*/
|
|
99
|
+
export function clientAddress(req, hops) {
|
|
100
|
+
if (hops > 0) {
|
|
101
|
+
const xff = req.headers["x-forwarded-for"];
|
|
102
|
+
const entries = (Array.isArray(xff) ? xff.join(",") : (xff ?? "")).split(",").map((s) => s.trim()).filter(Boolean);
|
|
103
|
+
if (entries.length)
|
|
104
|
+
return entries[Math.max(0, entries.length - hops)];
|
|
105
|
+
}
|
|
106
|
+
return req.socket.remoteAddress ?? "unknown";
|
|
107
|
+
}
|
|
108
|
+
/** Starts listening, over HTTPS when a certificate is set; resolves once the port is open, with the URL clients should use. */
|
|
109
|
+
export async function startHttp(cfg, createServer) {
|
|
110
|
+
const token = cfg.token ? digest(cfg.token) : undefined;
|
|
111
|
+
const origins = new Set(cfg.allowedOrigins.map((o) => o.toLowerCase()));
|
|
112
|
+
const anyOrigin = origins.has("*");
|
|
113
|
+
let hosts = new Set();
|
|
114
|
+
const anyHost = cfg.allowedHosts.includes("*");
|
|
115
|
+
const limiter = new RateLimiter(cfg.rateLimitPerMinute);
|
|
116
|
+
let active = 0;
|
|
117
|
+
const perClient = new Map();
|
|
118
|
+
const refuse = (req, res, status, message, headers) => {
|
|
119
|
+
console.error(`[raidcom-mcp] refused ${req.method} ${req.url} from ${clientAddress(req, cfg.trustProxy)}: ${status} ${message}`);
|
|
120
|
+
reject(res, status, message, headers);
|
|
121
|
+
};
|
|
122
|
+
const handler = async (req, res) => {
|
|
123
|
+
const path = (req.url ?? "").split("?")[0];
|
|
124
|
+
if (path === HEALTH_PATH && (req.method === "GET" || req.method === "HEAD")) {
|
|
125
|
+
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
|
126
|
+
// Behind proxies, the health check also shows the address the server takes as the caller's, so the number of
|
|
127
|
+
// proxies (RAIDCOM_HTTP_TRUST_PROXY) can be checked: it should be your own public address.
|
|
128
|
+
const body = { status: "ok", version: VERSION, ...(cfg.trustProxy ? { client: clientAddress(req, cfg.trustProxy) } : {}) };
|
|
129
|
+
return res.end(req.method === "HEAD" ? undefined : JSON.stringify(body));
|
|
130
|
+
}
|
|
131
|
+
if (path !== MCP_PATH)
|
|
132
|
+
return reject(res, 404, `Not found. The MCP endpoint is ${MCP_PATH}.`);
|
|
133
|
+
if (!anyHost && !hosts.has((req.headers.host ?? "").toLowerCase()))
|
|
134
|
+
return refuse(req, res, 403, "Host not allowed. Add it to RAIDCOM_HTTP_ALLOWED_HOSTS if this name should reach the server.");
|
|
135
|
+
const origin = req.headers.origin;
|
|
136
|
+
if (origin !== undefined && !anyOrigin && !origins.has(origin.toLowerCase()))
|
|
137
|
+
return refuse(req, res, 403, "Origin not allowed. Add it to RAIDCOM_HTTP_ALLOWED_ORIGINS if this page should reach the server.");
|
|
138
|
+
const client = clientAddress(req, cfg.trustProxy);
|
|
139
|
+
if (!limiter.allow(client))
|
|
140
|
+
return refuse(req, res, 429, "Too many requests. Try again in a minute.", { "retry-after": "60" });
|
|
141
|
+
if (token && !authorized(req.headers.authorization, token))
|
|
142
|
+
return refuse(req, res, 401, "Missing or wrong bearer token.", { "www-authenticate": 'Bearer realm="raidcom-mcp"' });
|
|
143
|
+
// Stateless: no standalone SSE stream (GET) and no session to end (DELETE).
|
|
144
|
+
if (req.method !== "POST")
|
|
145
|
+
return reject(res, 405, "Method not allowed. Send MCP requests with POST.", { allow: "POST" });
|
|
146
|
+
if (active >= MAX_CONCURRENT)
|
|
147
|
+
return reject(res, 503, "Too many requests in progress. Try again shortly.", { "retry-after": "1" });
|
|
148
|
+
const mine = perClient.get(client) ?? 0;
|
|
149
|
+
if (mine >= MAX_PER_CLIENT)
|
|
150
|
+
return refuse(req, res, 429, "Too many requests in progress from this address. Try again shortly.", { "retry-after": "1" });
|
|
151
|
+
active++;
|
|
152
|
+
perClient.set(client, mine + 1);
|
|
153
|
+
res.on("close", () => {
|
|
154
|
+
active--;
|
|
155
|
+
const n = (perClient.get(client) ?? 1) - 1;
|
|
156
|
+
if (n > 0)
|
|
157
|
+
perClient.set(client, n);
|
|
158
|
+
else
|
|
159
|
+
perClient.delete(client);
|
|
160
|
+
});
|
|
161
|
+
let body;
|
|
162
|
+
try {
|
|
163
|
+
body = await readJson(req);
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
return e instanceof TooLarge
|
|
167
|
+
? reject(res, 413, `Request body larger than ${MAX_BODY_BYTES} bytes.`, { connection: "close" })
|
|
168
|
+
: reject(res, 400, "Request body is not valid JSON.");
|
|
169
|
+
}
|
|
170
|
+
const server = createServer();
|
|
171
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
172
|
+
res.on("close", () => {
|
|
173
|
+
transport.close().catch(() => { });
|
|
174
|
+
server.close().catch(() => { });
|
|
175
|
+
});
|
|
176
|
+
try {
|
|
177
|
+
await server.connect(transport);
|
|
178
|
+
await transport.handleRequest(req, res, body);
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
console.error(`[raidcom-mcp] error handling request: ${e.message}`);
|
|
182
|
+
if (!res.headersSent)
|
|
183
|
+
reject(res, 500, "Internal server error.");
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
// A client that sends its request slowly would otherwise hold a slot for Node's default of five minutes. The
|
|
187
|
+
// timeouts are checked every second rather than Node's default of every 30 seconds.
|
|
188
|
+
const timeouts = { headersTimeout: HEADERS_TIMEOUT_MS, requestTimeout: REQUEST_TIMEOUT_MS, connectionsCheckingInterval: 1_000 };
|
|
189
|
+
const http = cfg.tls ? createHttpsServer({ ...timeouts, cert: cfg.tls.cert, key: cfg.tls.key }, handler) : createHttpServer(timeouts, handler);
|
|
190
|
+
await new Promise((resolve, rej) => {
|
|
191
|
+
http.once("error", rej);
|
|
192
|
+
http.listen(cfg.port, cfg.host, () => resolve());
|
|
193
|
+
});
|
|
194
|
+
const port = http.address().port;
|
|
195
|
+
hosts = hostNames(cfg, port);
|
|
196
|
+
const shown = cfg.host.includes(":") ? `[${cfg.host}]` : cfg.host;
|
|
197
|
+
return { url: `${cfg.tls ? "https" : "http"}://${shown}:${port}${MCP_PATH}`, server: http };
|
|
198
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// MCP server that builds and checks Hitachi raidcom commands.
|
|
3
|
+
//
|
|
4
|
+
// Build-only by design: it validates structured requests against a catalogue generated from the Command Control
|
|
5
|
+
// Interface Command Reference and returns command text. It never connects to a storage system and has no tool that
|
|
6
|
+
// runs anything, so it can be hosted anywhere.
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
9
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
10
|
+
import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
11
|
+
import { Catalog, commandName, isDestructive, isReadOnly } from "./catalog.js";
|
|
12
|
+
import { build } from "./builder.js";
|
|
13
|
+
import { parseCommandLine } from "./cmdline.js";
|
|
14
|
+
import { CommandIndex } from "./search.js";
|
|
15
|
+
import { buildRecipe, buildScript } from "./recipes.js";
|
|
16
|
+
import { VERSION, bundled, loadHttpConfig, loadJson, loadOptionalJson, loadServerConfig } from "./config.js";
|
|
17
|
+
import { tools } from "./tools.js";
|
|
18
|
+
/** Startup steps that read files: a bad file stops the server with one clear line instead of a stack trace. */
|
|
19
|
+
function orExit(load) {
|
|
20
|
+
try {
|
|
21
|
+
return load();
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
const msg = e.message;
|
|
25
|
+
console.error(msg.startsWith("[raidcom-mcp]") ? msg : `[raidcom-mcp] cannot start: ${msg}`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const cfg = loadServerConfig();
|
|
30
|
+
const httpCfg = orExit(() => loadHttpConfig());
|
|
31
|
+
const catalog = orExit(() => new Catalog(cfg.catalogPath, cfg.limitsPath));
|
|
32
|
+
const questions = orExit(() => loadOptionalJson(cfg.examplesPath, "example questions", {}));
|
|
33
|
+
const index = new CommandIndex(catalog.commands.values(), questions);
|
|
34
|
+
const recipes = orExit(() => loadJson(cfg.recipesPath, "recipes").recipes);
|
|
35
|
+
const notes = {
|
|
36
|
+
conventions: orExit(() => readFileSync(bundled("spec", "notes", "conventions.md"), "utf8")),
|
|
37
|
+
horcm: orExit(() => readFileSync(bundled("spec", "notes", "horcm-setup.md"), "utf8")),
|
|
38
|
+
};
|
|
39
|
+
// ---------- helpers ----------
|
|
40
|
+
function text(t, isError = false) {
|
|
41
|
+
return { content: [{ type: "text", text: t }], ...(isError ? { isError: true } : {}) };
|
|
42
|
+
}
|
|
43
|
+
const json = (v, isError = false) => text(JSON.stringify(v), isError);
|
|
44
|
+
const str = (v) => (typeof v === "string" ? v : typeof v === "number" ? String(v) : "");
|
|
45
|
+
const int = (v, fallback) => (typeof v === "number" || (typeof v === "string" && v.trim() !== "")) && Number.isFinite(Number(v)) ? Math.trunc(Number(v)) : fallback;
|
|
46
|
+
const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {});
|
|
47
|
+
const access = (c) => (isReadOnly(c) ? "read-only" : isDestructive(c) ? "changes the array (destructive)" : "changes the array");
|
|
48
|
+
function section(title, items) {
|
|
49
|
+
return items.length ? `\n${title}:\n${items.map((i) => `- ${i}`).join("\n")}` : "";
|
|
50
|
+
}
|
|
51
|
+
function formatBuild(r) {
|
|
52
|
+
if (!r.ok)
|
|
53
|
+
return `Not built.${section("Errors", r.errors)}${section("Warnings", r.warnings)}`;
|
|
54
|
+
const kind = r.readOnly ? "read-only" : r.destructive ? "changes the array, destructive" : "changes the array";
|
|
55
|
+
return `${r.command}\n\n(${kind}${r.async ? ", asynchronous" : ""}; form: ${r.form?.title ? r.form.title.replace(/:$/, "") : r.form?.usage ?? "n/a"})${section("Warnings", r.warnings)}${section("Notes", r.notes)}`;
|
|
56
|
+
}
|
|
57
|
+
// ---------- tool handlers ----------
|
|
58
|
+
function listCommands(args) {
|
|
59
|
+
const query = str(args.query).trim();
|
|
60
|
+
const action = str(args.action).trim().toLowerCase();
|
|
61
|
+
const object = str(args.object).trim().toLowerCase();
|
|
62
|
+
const row = (c) => `${commandName(c)} [${access(c)}${c.async ? ", async" : ""}]: ${c.summary}`;
|
|
63
|
+
if (!query && !action && !object) {
|
|
64
|
+
const byObject = new Map();
|
|
65
|
+
for (const c of catalog.commands.values())
|
|
66
|
+
byObject.set(c.object, [...(byObject.get(c.object) ?? []), c.action]);
|
|
67
|
+
const lines = [...byObject.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([o, a]) => `- ${o}: ${a.sort().join(", ")}`);
|
|
68
|
+
return text(`${catalog.commands.size} raidcom commands (action object). Pass query, action or object to list them.\n\n${lines.join("\n")}`);
|
|
69
|
+
}
|
|
70
|
+
if (!query) {
|
|
71
|
+
const found = [...catalog.commands.values()].filter((c) => (!action || c.action === action) && (!object || c.object === object));
|
|
72
|
+
if (!found.length)
|
|
73
|
+
return text("No commands match. Call with no arguments to see the objects and their actions.");
|
|
74
|
+
return text(`${found.length} command(s):\n${found.map((c) => `- ${row(c)}`).join("\n")}`);
|
|
75
|
+
}
|
|
76
|
+
const limit = Math.min(Math.max(int(args.limit, 10) || 10, 1), 40);
|
|
77
|
+
const r = index.search(query, { limit, action: action || undefined, object: object || undefined });
|
|
78
|
+
if (!r.hits.length)
|
|
79
|
+
return text("No commands matched. Try other words, or call with no arguments to see the objects.");
|
|
80
|
+
const advice = {
|
|
81
|
+
high: "Use the top result. Describe it if you need its options.",
|
|
82
|
+
medium: "The top result is likely right. Check its description before building.",
|
|
83
|
+
low: "Results are ambiguous. Describe the top 2 or 3 and choose by their descriptions, or search again with the object name (ldev, lun, host_grp, pool, ...).",
|
|
84
|
+
}[r.confidence];
|
|
85
|
+
return text(`Confidence: ${r.confidence.toUpperCase()}: ${r.reason}.\n${advice}\n\n${r.hits.map((h, i) => `${i + 1}. ${row(h.command)}`).join("\n")}`);
|
|
86
|
+
}
|
|
87
|
+
function describeCommand(args) {
|
|
88
|
+
const cmd = catalog.find(str(args.command));
|
|
89
|
+
if (!cmd)
|
|
90
|
+
return text(`Unknown raidcom command "${str(args.command)}". Closest: ${catalog.suggest(str(args.command)).join(", ")}. Use raidcom_list_commands to search.`, true);
|
|
91
|
+
const only = str(args.option);
|
|
92
|
+
const options = Object.entries(cmd.flags)
|
|
93
|
+
.filter(([f]) => !only || f === (only.startsWith("-") ? only : `-${only}`))
|
|
94
|
+
.map(([flag, shape]) => ({
|
|
95
|
+
option: flag,
|
|
96
|
+
usage: cmd.options[flag]?.usage,
|
|
97
|
+
values: shape.max === 0 ? "none (a switch)" : `${shape.min === shape.max ? shape.min : `${shape.min}${shape.max === null ? " or more" : ` to ${shape.max}`}`}${shape.placeholders?.length ? `: ${shape.placeholders.map((p) => `<${p}>`).join(" ")}` : ""}`,
|
|
98
|
+
...(shape.enum ? { only: shape.enum } : shape.literals?.length ? { alsoAccepts: shape.literals } : {}),
|
|
99
|
+
...(cmd.options[flag]?.hints ? { values_listed_in_manual: cmd.options[flag].hints } : {}),
|
|
100
|
+
description: cmd.options[flag]?.summary,
|
|
101
|
+
...(cmd.flags[flag] && cmd.flags[flag].derived ? { note: "described in the text of another option, not in the syntax line" } : {}),
|
|
102
|
+
}));
|
|
103
|
+
if (only && !options.length)
|
|
104
|
+
return text(`raidcom ${commandName(cmd)} has no option "${only}". Options: ${Object.keys(cmd.flags).join(", ") || "none"}.`, true);
|
|
105
|
+
return json({
|
|
106
|
+
command: `raidcom ${commandName(cmd)}`,
|
|
107
|
+
summary: cmd.summary,
|
|
108
|
+
access: access(cmd),
|
|
109
|
+
asynchronous: cmd.async,
|
|
110
|
+
supported_storage_systems: cmd.supported.length ? cmd.supported : "see the Command Reference",
|
|
111
|
+
syntax_forms: cmd.forms.map((f) => ({ ...(f.title ? { when: f.title } : {}), usage: f.usage })),
|
|
112
|
+
options,
|
|
113
|
+
examples: cmd.examples.slice(0, 8).map((e) => ({ ...(e.text ? { what: e.text } : {}), command: e.command })),
|
|
114
|
+
reading_the_syntax: "[x] optional, {a | b} choose one, <x> a value you supply, ... one or more values.",
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function buildCommand(args) {
|
|
118
|
+
const r = build(catalog, {
|
|
119
|
+
command: str(args.command),
|
|
120
|
+
options: obj(args.options),
|
|
121
|
+
model: str(args.model) || undefined,
|
|
122
|
+
global: obj(args.global),
|
|
123
|
+
strict: args.strict === false ? false : undefined,
|
|
124
|
+
});
|
|
125
|
+
return text(formatBuild(r), !r.ok);
|
|
126
|
+
}
|
|
127
|
+
function checkCommand(args) {
|
|
128
|
+
const parsed = parseCommandLine(catalog, str(args.command_line));
|
|
129
|
+
if (!parsed.request)
|
|
130
|
+
return text(`Not checked.${section("Errors", parsed.errors)}${section("Warnings", parsed.warnings)}`, true);
|
|
131
|
+
const r = build(catalog, { ...parsed.request, model: str(args.model) || undefined, strict: args.strict === false ? false : undefined });
|
|
132
|
+
const cmd = catalog.find(parsed.request.command);
|
|
133
|
+
const explained = Object.keys(parsed.request.options ?? {}).map((raw) => {
|
|
134
|
+
const flag = Object.keys(cmd.flags).find((f) => f.toLowerCase() === raw.toLowerCase());
|
|
135
|
+
return flag ? `${flag}: ${cmd.options[flag]?.summary || "(no description in the manual)"}` : `${raw}: not an option of raidcom ${commandName(cmd)}`;
|
|
136
|
+
});
|
|
137
|
+
const errors = [...parsed.errors, ...r.errors];
|
|
138
|
+
const warnings = [...parsed.warnings, ...r.warnings];
|
|
139
|
+
const head = r.ok && !parsed.errors.length ? `${r.command}\n\nValid. raidcom ${commandName(cmd)}: ${cmd.summary} (${access(cmd)}${cmd.async ? ", asynchronous" : ""})` : `Problems found with raidcom ${commandName(cmd)}.`;
|
|
140
|
+
return text(`${head}${section("Options", explained)}${section("Errors", errors)}${section("Warnings", warnings)}${r.ok ? section("Notes", r.notes) : ""}`, errors.length > 0);
|
|
141
|
+
}
|
|
142
|
+
function formatScript(r, what) {
|
|
143
|
+
if (!r.ok)
|
|
144
|
+
return text(`No script built.${section("Errors", r.errors)}${section("Warnings", r.warnings)}`, true);
|
|
145
|
+
const changing = r.commands.filter((c) => !c.readOnly).length;
|
|
146
|
+
const destructive = r.commands.filter((c) => c.destructive).length;
|
|
147
|
+
return text(`\`\`\`bash\n${r.script}\`\`\`\n\n${what}: ${r.commands.length} command(s), ${r.commands.length - changing} read-only, ${changing} changing${destructive ? `, ${destructive} destructive` : ""}.${section("Warnings", r.warnings)}${section("Notes", r.notes)}`);
|
|
148
|
+
}
|
|
149
|
+
function buildScriptTool(args) {
|
|
150
|
+
const steps = Array.isArray(args.steps) ? args.steps : [];
|
|
151
|
+
if (!steps.length)
|
|
152
|
+
return text("steps must be a non-empty array of {command, options, foreach?, zip?}.", true);
|
|
153
|
+
const r = buildScript(catalog, {
|
|
154
|
+
steps: steps.map((s) => ({ ...obj(s), command: str(obj(s).command), options: obj(obj(s).options), title: obj(s).title ? str(obj(s).title) : undefined })),
|
|
155
|
+
model: str(args.model) || undefined,
|
|
156
|
+
global: obj(args.global),
|
|
157
|
+
strict: args.strict === false ? false : undefined,
|
|
158
|
+
login: args.login === true,
|
|
159
|
+
lock_resource: args.lock_resource === true,
|
|
160
|
+
description: args.description ? str(args.description) : undefined,
|
|
161
|
+
});
|
|
162
|
+
return formatScript(r, "Script");
|
|
163
|
+
}
|
|
164
|
+
function listRecipes(args) {
|
|
165
|
+
const id = str(args.recipe).trim();
|
|
166
|
+
if (id) {
|
|
167
|
+
const r = Object.hasOwn(recipes, id) ? recipes[id] : undefined;
|
|
168
|
+
if (!r)
|
|
169
|
+
return text(`Unknown recipe "${id}". Recipes: ${Object.keys(recipes).join(", ")}.`, true);
|
|
170
|
+
return json({ recipe: id, title: r.title, summary: r.summary, params: r.params, steps: r.steps.map((s) => ({ title: s.title, command: s.command, options: s.options, ...(s.foreach ? { foreach: s.foreach } : {}), ...(s.zip ? { zip: s.zip } : {}), ...(s.when ? { only_when_given: s.when } : {}) })), notes: r.notes });
|
|
171
|
+
}
|
|
172
|
+
const query = str(args.query).toLowerCase().trim();
|
|
173
|
+
let entries = Object.entries(recipes);
|
|
174
|
+
if (query) {
|
|
175
|
+
const w = query.split(/[^a-z0-9]+/).filter((x) => x.length > 1);
|
|
176
|
+
entries = entries
|
|
177
|
+
.map(([k, r]) => ({ k, r, s: w.filter((x) => `${k} ${r.title} ${r.summary} ${r.keywords.join(" ")}`.toLowerCase().includes(x)).length }))
|
|
178
|
+
.filter((x) => x.s > 0)
|
|
179
|
+
.sort((a, b) => b.s - a.s)
|
|
180
|
+
.map((x) => [x.k, x.r]);
|
|
181
|
+
if (!entries.length)
|
|
182
|
+
return text(`No recipe matched. Recipes: ${Object.keys(recipes).join(", ")}.`);
|
|
183
|
+
}
|
|
184
|
+
return text(entries.map(([k, r]) => `- ${k}: ${r.title}. ${r.summary}`).join("\n"));
|
|
185
|
+
}
|
|
186
|
+
function buildRecipeTool(args) {
|
|
187
|
+
const id = str(args.recipe).trim();
|
|
188
|
+
const recipe = Object.hasOwn(recipes, id) ? recipes[id] : undefined;
|
|
189
|
+
if (!recipe)
|
|
190
|
+
return text(`Unknown recipe "${id}". Recipes: ${Object.keys(recipes).join(", ")}.`, true);
|
|
191
|
+
const r = buildRecipe(catalog, id, recipe, obj(args.params), {
|
|
192
|
+
model: str(args.model) || undefined,
|
|
193
|
+
global: obj(args.global),
|
|
194
|
+
strict: args.strict === false ? false : undefined,
|
|
195
|
+
login: args.login === true,
|
|
196
|
+
lock_resource: args.lock_resource === true,
|
|
197
|
+
});
|
|
198
|
+
return formatScript(r, recipe.title);
|
|
199
|
+
}
|
|
200
|
+
function modelLimits(args) {
|
|
201
|
+
const want = str(args.model).trim();
|
|
202
|
+
if (!want)
|
|
203
|
+
return json({ models: [...catalog.models.entries()].map(([id, m]) => ({ id, label: m.label, aliases: m.aliases })) });
|
|
204
|
+
const m = catalog.model(want);
|
|
205
|
+
if (!m)
|
|
206
|
+
return text(`Unknown model "${want}". Models: ${[...catalog.models.keys()].join(", ")}.`, true);
|
|
207
|
+
const l = m.limits;
|
|
208
|
+
return json({
|
|
209
|
+
model: m.id,
|
|
210
|
+
label: l.label,
|
|
211
|
+
serial_number: `${l.serial[0]} to ${l.serial[1]}`,
|
|
212
|
+
ldev_id: `${l.ldev[0]} to ${l.ldev[1]}`,
|
|
213
|
+
pool_id: `${l.pool[0]} to ${l.pool[1]}`,
|
|
214
|
+
lun: `${l.lun[0]} to ${l.lun[1]}`,
|
|
215
|
+
port_columns: l.portColumns.split("").join(", "),
|
|
216
|
+
port_letters: l.portLetters.split("").join(", "),
|
|
217
|
+
...(l.portColumnsLimited ? { port_note: `columns ${l.portColumnsLimited.cols.split("").join(", ")} only have ports ${l.portColumnsLimited.letters.split("").join(", ")}` } : {}),
|
|
218
|
+
host_group_id: "0 to 254",
|
|
219
|
+
source: "Valid parameter ranges in the appendices of the CCI Command Reference",
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
async function callTool(req) {
|
|
223
|
+
const { name } = req.params;
|
|
224
|
+
const args = obj(req.params.arguments);
|
|
225
|
+
try {
|
|
226
|
+
switch (name) {
|
|
227
|
+
case "raidcom_list_commands": return listCommands(args);
|
|
228
|
+
case "raidcom_describe_command": return describeCommand(args);
|
|
229
|
+
case "raidcom_build_command": return buildCommand(args);
|
|
230
|
+
case "raidcom_check_command": return checkCommand(args);
|
|
231
|
+
case "raidcom_build_script": return buildScriptTool(args);
|
|
232
|
+
case "raidcom_list_recipes": return listRecipes(args);
|
|
233
|
+
case "raidcom_build_recipe": return buildRecipeTool(args);
|
|
234
|
+
case "raidcom_model_limits": return modelLimits(args);
|
|
235
|
+
default: return text(`Unknown tool ${name}`, true);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch (e) {
|
|
239
|
+
return text(`Error: ${e.message}`, true);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// ---------- resources and prompts ----------
|
|
243
|
+
const resources = [
|
|
244
|
+
{ uri: "raidcom://notes/conventions", name: "raidcom conventions", description: "Login, instances, serial numbers, asynchronous commands, resource locking.", mimeType: "text/markdown" },
|
|
245
|
+
{ uri: "raidcom://notes/horcm-setup", name: "Minimal HORCM configuration", description: "What the host that runs raidcom needs.", mimeType: "text/markdown" },
|
|
246
|
+
{ uri: "raidcom://catalog/commands", name: "Command list", description: "Every raidcom command in the catalogue with its one-line summary.", mimeType: "text/markdown" },
|
|
247
|
+
];
|
|
248
|
+
function readResource(uri) {
|
|
249
|
+
if (uri === "raidcom://notes/conventions")
|
|
250
|
+
return notes.conventions;
|
|
251
|
+
if (uri === "raidcom://notes/horcm-setup")
|
|
252
|
+
return notes.horcm;
|
|
253
|
+
if (uri === "raidcom://catalog/commands") {
|
|
254
|
+
return `# raidcom commands\n\nFrom ${catalog.source} (generated ${catalog.generated}).\n\n` + [...catalog.commands.values()].map((c) => `- \`raidcom ${commandName(c)}\` [${access(c)}]: ${c.summary}`).join("\n") + "\n";
|
|
255
|
+
}
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
const GUIDANCE = "You build raidcom commands for Hitachi VSP storage with the raidcom_* tools. The server only builds text; it never runs anything.\n" +
|
|
259
|
+
"1. If you do not know the command, raidcom_list_commands with the user's words. 2. If unsure of its options, raidcom_describe_command. " +
|
|
260
|
+
"3. Build with raidcom_build_command (one command) or raidcom_build_script / raidcom_build_recipe (several; use foreach or ranges instead of repeating calls). " +
|
|
261
|
+
"4. Pass `model` when the user names one so IDs and ports are range-checked. 5. If a result has errors, fix the arguments and build again. " +
|
|
262
|
+
"6. Show the user the command or script, say which commands change the array, and remind them to review it and to run changing commands under raidcom lock resource, ideally first with -checkmode precheck. " +
|
|
263
|
+
"Never invent options: only use ones raidcom_describe_command lists. Ask the user for values you do not have (pool, LDEV IDs, ports, WWNs) instead of guessing them.";
|
|
264
|
+
const prompts = [
|
|
265
|
+
{ name: "build-raidcom-commands", description: "Turn a storage task into validated raidcom commands.", arguments: [{ name: "task", description: "What you want to do, e.g. \"present 4 x 100 GB volumes from pool 0 to host esx01 on CL1-A and CL2-A\"", required: true }, { name: "model", description: "Storage model, e.g. vsp-one-b28", required: false }] },
|
|
266
|
+
{ name: "explain-raidcom-command", description: "Explain and check an existing raidcom command line.", arguments: [{ name: "command_line", description: "The raidcom command", required: true }] },
|
|
267
|
+
];
|
|
268
|
+
function getPrompt(name, args = {}) {
|
|
269
|
+
if (name === "build-raidcom-commands") {
|
|
270
|
+
return { description: prompts[0].description, messages: [{ role: "user", content: { type: "text", text: `${GUIDANCE}\n\nTask: ${args.task ?? ""}${args.model ? `\nStorage model: ${args.model}` : ""}\n\nBuild the commands for this task with the raidcom tools. List anything you still need from me before building.` } }] };
|
|
271
|
+
}
|
|
272
|
+
if (name === "explain-raidcom-command") {
|
|
273
|
+
return { description: prompts[1].description, messages: [{ role: "user", content: { type: "text", text: `Check this raidcom command with raidcom_check_command, then explain in plain words what it does, what it changes, and anything risky about it:\n\n${args.command_line ?? ""}` } }] };
|
|
274
|
+
}
|
|
275
|
+
throw new Error(`Unknown prompt ${name}`);
|
|
276
|
+
}
|
|
277
|
+
/** An MCP server with the tools. stdio uses one for the whole process; HTTP makes one per request. */
|
|
278
|
+
function createServer() {
|
|
279
|
+
const server = new Server({ name: "raidcom-mcp", version: VERSION }, { capabilities: { tools: {}, resources: {}, prompts: {} }, instructions: GUIDANCE });
|
|
280
|
+
const toolList = tools();
|
|
281
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolList }));
|
|
282
|
+
server.setRequestHandler(CallToolRequestSchema, callTool);
|
|
283
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources }));
|
|
284
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
|
|
285
|
+
const body = readResource(req.params.uri);
|
|
286
|
+
if (body === undefined)
|
|
287
|
+
throw new Error(`Unknown resource ${req.params.uri}`);
|
|
288
|
+
return { contents: [{ uri: req.params.uri, mimeType: "text/markdown", text: body }] };
|
|
289
|
+
});
|
|
290
|
+
server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts }));
|
|
291
|
+
server.setRequestHandler(GetPromptRequestSchema, async (req) => getPrompt(req.params.name, req.params.arguments));
|
|
292
|
+
return server;
|
|
293
|
+
}
|
|
294
|
+
let listening = "";
|
|
295
|
+
if (httpCfg) {
|
|
296
|
+
// Imported only here, so the stdio server never loads the HTTP stack.
|
|
297
|
+
const { startHttp } = await import("./http.js");
|
|
298
|
+
const { url } = await startHttp(httpCfg, createServer).catch((e) => {
|
|
299
|
+
console.error(`[raidcom-mcp] cannot listen on ${httpCfg.host}:${httpCfg.port}: ${e.message}`);
|
|
300
|
+
process.exit(1);
|
|
301
|
+
});
|
|
302
|
+
listening = `, listening on ${url}${httpCfg.token ? "" : " (no token: open endpoint)"}`;
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
await createServer().connect(new StdioServerTransport());
|
|
306
|
+
}
|
|
307
|
+
console.error(`[raidcom-mcp] ready: ${catalog.commands.size} commands, ${Object.keys(recipes).length} recipes, build-only${listening}`);
|