@behavioralstate/best-mcp 2.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/dist/index.js ADDED
@@ -0,0 +1,854 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * best-mcp — MCP server for any BEST-compliant endpoint
4
+ *
5
+ * Exposes the BEST command and query surface as MCP tools so any LLM client
6
+ * can discover, read, and send commands to a BEST-compliant service.
7
+ *
8
+ * ── Single connection (backwards-compatible) ─────────────────────────────────
9
+ * BEST_ENDPOINT — Base URL of the BEST HTTP surface (required)
10
+ * e.g. https://api.example.com/best or https://api.example.com/best/tenants/<id>
11
+ * BEST_API_KEY — Credential value (required unless BEST_AUTH_TYPE=none)
12
+ * BEST_AUTH_TYPE — bearer (default) | apikey | none
13
+ * BEST_AUTH_HEADER — Header name when BEST_AUTH_TYPE=apikey, BEST_AUTH_IN=header (default: X-Api-Key)
14
+ * BEST_AUTH_IN — header (default) | query
15
+ * BEST_AUTH_PARAM — Query param name when BEST_AUTH_IN=query (default: apikey)
16
+ *
17
+ * ── Multiple named connections ────────────────────────────────────────────────
18
+ * BEST_CONNECTIONS — JSON array of connection objects. Takes precedence over
19
+ * the individual BEST_* variables above.
20
+ * Each object:
21
+ * name (string, required) — identifier used in the 'connection' tool param
22
+ * endpoint (string, required) — base URL of the BEST HTTP surface
23
+ * apiKey (string, optional) — required unless authType is "none"
24
+ * authType (string, optional, default "bearer") — bearer | apikey | none
25
+ * authHeader (string, optional, default "X-Api-Key")
26
+ * authIn (string, optional, default "header") — header | query
27
+ * authParam (string, optional, default "apikey")
28
+ * allowBearerPassthrough (boolean, optional, default false) — allow a
29
+ * per-request Authorization Bearer token to replace the
30
+ * configured credential (see below)
31
+ * description (string, optional) — human-readable description surfaced to the LLM
32
+ *
33
+ * Example:
34
+ * [
35
+ * { "name": "trading", "endpoint": "https://api.example.com/best/tenants/<id>", "apiKey": "...", "authType": "apikey", "description": "Tenant trading commands and queries" },
36
+ * { "name": "platform", "endpoint": "https://api.example.com/best", "apiKey": "...", "authType": "apikey", "description": "Cross-tenant platform queries" }
37
+ * ]
38
+ *
39
+ * ── Transport ─────────────────────────────────────────────────────────────────
40
+ * MCP_TRANSPORT — stdio (default) | http
41
+ * MCP_HTTP_PORT — HTTP port when MCP_TRANSPORT=http (default: 3000)
42
+ *
43
+ * Transports:
44
+ * stdio — for VS Code Copilot, Cursor, Claude Desktop, and other local clients
45
+ * http — for ChatGPT Desktop (Settings → Apps & Connectors → /mcp), or a
46
+ * multi-user backend that calls best-mcp on behalf of many different
47
+ * end users (see "Per-request credential overrides" below).
48
+ * Use ngrok or Cloudflare Tunnel to expose locally over HTTPS.
49
+ *
50
+ * ── Per-request credential overrides (HTTP transport only) ──────────────────
51
+ * A multi-user backend (e.g. a chat service acting on behalf of whichever
52
+ * user is currently logged in) typically cannot bake one fixed API key into
53
+ * this server's environment — each incoming request needs its OWN caller's
54
+ * credentials. When MCP_TRANSPORT=http, two request headers optionally
55
+ * override the resolved connection for that single call only:
56
+ *
57
+ * X-Api-Key — replaces the connection's configured apiKey for this request.
58
+ * X-Tenant-Id — replaces the tenant segment of the connection's endpoint for
59
+ * this request. Only takes effect on a Mode 1 "<app>/tenant"
60
+ * connection (the one auto-generated from BEST_<APP>_TENANT_ID);
61
+ * ignored otherwise, since other connections have no tenant
62
+ * template to substitute into. Must match ^[A-Za-z0-9_.-]+$ —
63
+ * an invalid value is ignored (logged to stderr) rather than
64
+ * spliced into the URL.
65
+ * Authorization: Bearer <token>
66
+ * — forwarded verbatim to the BEST endpoint as the caller's own
67
+ * credential, but ONLY when the connection was explicitly
68
+ * configured with BEST_<APP>_ALLOW_BEARER_PASSTHROUGH=true
69
+ * (BEST_CONNECTIONS: allowBearerPassthrough, legacy:
70
+ * BEST_ALLOW_BEARER_PASSTHROUGH). Off by default for a reason:
71
+ * the MCP transport's Authorization header may carry a
72
+ * credential meant for THIS server (e.g. MCP OAuth), which
73
+ * must never leak upstream unless the operator states both
74
+ * hops share one trust domain. A per-request X-Api-Key takes
75
+ * precedence — the Bearer token is only used when no explicit
76
+ * key override is present (mirrors BEST dual-auth gates, where
77
+ * a present API key is authoritative). Bearer scheme only;
78
+ * the token is never logged.
79
+ *
80
+ * No override header is required — omit them all and a request behaves
81
+ * exactly as configured via environment variables, same as before this
82
+ * feature existed. stdio is unaffected; there is no per-request boundary to
83
+ * attach headers to.
84
+ */
85
+ import { createServer as createHttpServer } from 'http';
86
+ import { randomUUID } from 'crypto';
87
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
88
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
89
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
90
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
91
+ // ── Config parsing ────────────────────────────────────────────────────────────
92
+ // Legacy compatibility: the protocol short name changed BSP → BEST in spec 0.9.0.
93
+ // Accept pre-0.9.0 BSP_* env vars as a fallback for any BEST_* var not explicitly
94
+ // set, so existing deployments keep working across the rename.
95
+ {
96
+ let legacyVarsUsed = false;
97
+ for (const [key, value] of Object.entries(process.env)) {
98
+ if (key.startsWith('BSP_') && value !== undefined) {
99
+ const bestKey = 'BEST_' + key.slice(4);
100
+ if (process.env[bestKey] === undefined) {
101
+ process.env[bestKey] = value;
102
+ legacyVarsUsed = true;
103
+ }
104
+ }
105
+ }
106
+ if (legacyVarsUsed) {
107
+ process.stderr.write('[best-mcp] WARNING: BSP_* environment variables are deprecated — rename them to BEST_*.\n');
108
+ }
109
+ }
110
+ const TRANSPORT = process.env.MCP_TRANSPORT ?? 'stdio';
111
+ const HTTP_PORT = parseInt(process.env.MCP_HTTP_PORT ?? '3000', 10);
112
+ function parseConnections() {
113
+ // ── Mode 1: per-app env vars ─────────────────────────────────────────────
114
+ // Detected by the presence of one or more BEST_<APP>_BASE_URL variables.
115
+ // APP must be a single uppercase word (letters and digits only, e.g. TRADING, HR).
116
+ //
117
+ // Required per app:
118
+ // BEST_<APP>_BASE_URL — root URL of the BEST HTTP surface
119
+ // BEST_<APP>_API_KEY — credential (not required when AUTH_TYPE=none)
120
+ //
121
+ // Optional per app:
122
+ // BEST_<APP>_TENANT_ID — when set, auto-generates two connections:
123
+ // <app>/tenant → BASE_URL/tenants/TENANT_ID
124
+ // <app>/platform → BASE_URL
125
+ // when omitted, generates one connection: <app>
126
+ // BEST_<APP>_AUTH_TYPE — bearer (default) | apikey | none
127
+ // BEST_<APP>_AUTH_HEADER — header name when AUTH_TYPE=apikey, AUTH_IN=header (default: X-Api-Key)
128
+ // BEST_<APP>_AUTH_IN — header (default) | query
129
+ // BEST_<APP>_AUTH_PARAM — query param name when AUTH_IN=query (default: apikey)
130
+ const appNames = Object.keys(process.env)
131
+ .map(key => key.match(/^BEST_([A-Z][A-Z0-9]*)_BASE_URL$/)?.[1])
132
+ .filter((name) => name !== undefined);
133
+ if (appNames.length > 0) {
134
+ const connections = [];
135
+ for (const appName of appNames) {
136
+ const p = `BEST_${appName}`;
137
+ const baseUrl = (process.env[`${p}_BASE_URL`] ?? '').replace(/\/$/, '');
138
+ const apiKey = process.env[`${p}_API_KEY`] ?? '';
139
+ const tenantId = process.env[`${p}_TENANT_ID`];
140
+ const authType = process.env[`${p}_AUTH_TYPE`] ?? 'apikey'; // Mode 1 default: apikey (X-Api-Key header)
141
+ const authHeader = process.env[`${p}_AUTH_HEADER`] ?? 'X-Api-Key';
142
+ const authIn = process.env[`${p}_AUTH_IN`] ?? 'header';
143
+ const authParam = process.env[`${p}_AUTH_PARAM`] ?? 'apikey';
144
+ const allowBearerPassthrough = (process.env[`${p}_ALLOW_BEARER_PASSTHROUGH`] ?? '').toLowerCase() === 'true';
145
+ const app = appName.toLowerCase();
146
+ if (!apiKey && authType !== 'none') {
147
+ process.stderr.write(`[best-mcp] ERROR: BEST_${appName}_API_KEY is required (or set BEST_${appName}_AUTH_TYPE=none)\n`);
148
+ process.exit(1);
149
+ }
150
+ const shared = { apiKey, authType, authHeader, authIn, authParam, allowBearerPassthrough };
151
+ if (tenantId) {
152
+ // Auto-generate two connections from one set of vars
153
+ connections.push({
154
+ ...shared,
155
+ name: `${app}/tenant`,
156
+ endpoint: `${baseUrl}/tenants/${tenantId}`,
157
+ tenantTemplateBaseUrl: baseUrl,
158
+ description: `${app} — tenant-scoped commands and queries`,
159
+ });
160
+ connections.push({
161
+ ...shared,
162
+ name: `${app}/platform`,
163
+ endpoint: baseUrl,
164
+ description: `${app} — platform root (manifest discovery and cross-tenant operations). Does not expose commands or queries directly.`,
165
+ });
166
+ }
167
+ else {
168
+ connections.push({
169
+ ...shared,
170
+ name: app,
171
+ endpoint: baseUrl,
172
+ description: `${app} — BEST service`,
173
+ });
174
+ }
175
+ }
176
+ return connections;
177
+ }
178
+ // ── Mode 2: BEST_CONNECTIONS JSON array ───────────────────────────────────
179
+ const raw = process.env.BEST_CONNECTIONS;
180
+ if (raw) {
181
+ let parsed;
182
+ try {
183
+ parsed = JSON.parse(raw);
184
+ }
185
+ catch (e) {
186
+ process.stderr.write(`[best-mcp] ERROR: BEST_CONNECTIONS is not valid JSON: ${e}\n`);
187
+ process.exit(1);
188
+ }
189
+ if (!Array.isArray(parsed) || parsed.length === 0) {
190
+ process.stderr.write('[best-mcp] ERROR: BEST_CONNECTIONS must be a non-empty JSON array\n');
191
+ process.exit(1);
192
+ }
193
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
194
+ return parsed.map((c, i) => {
195
+ if (!c.name) {
196
+ process.stderr.write(`[best-mcp] ERROR: BEST_CONNECTIONS[${i}] missing required field 'name'\n`);
197
+ process.exit(1);
198
+ }
199
+ if (!c.endpoint) {
200
+ process.stderr.write(`[best-mcp] ERROR: BEST_CONNECTIONS[${i}] missing required field 'endpoint'\n`);
201
+ process.exit(1);
202
+ }
203
+ const authType = c.authType ?? 'bearer';
204
+ if (!c.apiKey && authType !== 'none') {
205
+ process.stderr.write(`[best-mcp] ERROR: BEST_CONNECTIONS[${i}] ('${c.name}') missing required field 'apiKey' (or set authType: "none")\n`);
206
+ process.exit(1);
207
+ }
208
+ return {
209
+ name: String(c.name),
210
+ endpoint: String(c.endpoint).replace(/\/$/, ''),
211
+ apiKey: c.apiKey ? String(c.apiKey) : '',
212
+ authType,
213
+ authHeader: c.authHeader ? String(c.authHeader) : 'X-Api-Key',
214
+ authIn: c.authIn ? String(c.authIn) : 'header',
215
+ authParam: c.authParam ? String(c.authParam) : 'apikey',
216
+ allowBearerPassthrough: c.allowBearerPassthrough === true,
217
+ description: c.description ? String(c.description) : undefined,
218
+ };
219
+ });
220
+ }
221
+ // ── Mode 3: legacy single-connection — BEST_ENDPOINT / BEST_API_KEY ────────
222
+ const endpoint = (process.env.BEST_ENDPOINT ?? '').replace(/\/$/, '');
223
+ const apiKey = process.env.BEST_API_KEY ?? '';
224
+ const authType = process.env.BEST_AUTH_TYPE ?? 'bearer';
225
+ const missing = [];
226
+ if (!endpoint)
227
+ missing.push('BEST_ENDPOINT');
228
+ if (!apiKey && authType !== 'none')
229
+ missing.push('BEST_API_KEY');
230
+ if (missing.length) {
231
+ process.stderr.write(`[best-mcp] ERROR: missing required environment variables: ${missing.join(', ')}\n`);
232
+ process.stderr.write(`[best-mcp] See README for configuration options.\n`);
233
+ process.exit(1);
234
+ }
235
+ return [{
236
+ name: 'default',
237
+ endpoint,
238
+ apiKey,
239
+ authType,
240
+ authHeader: process.env.BEST_AUTH_HEADER ?? 'X-Api-Key',
241
+ authIn: process.env.BEST_AUTH_IN ?? 'header',
242
+ authParam: process.env.BEST_AUTH_PARAM ?? 'apikey',
243
+ allowBearerPassthrough: (process.env.BEST_ALLOW_BEARER_PASSTHROUGH ?? '').toLowerCase() === 'true',
244
+ }];
245
+ }
246
+ const CONNECTIONS = parseConnections();
247
+ const MULTI = CONNECTIONS.length > 1;
248
+ function resolveConnection(name) {
249
+ if (!MULTI)
250
+ return CONNECTIONS[0];
251
+ if (!name)
252
+ throw new Error(`Multiple BEST connections are configured — you must specify a 'connection' parameter. ` +
253
+ `Available connections: ${CONNECTIONS.map(c => c.name).join(', ')}. ` +
254
+ `Call list_connections to see full details, then confirm the correct connection with the user before proceeding.`);
255
+ const conn = CONNECTIONS.find(c => c.name === name);
256
+ if (!conn)
257
+ throw new Error(`Unknown connection '${name}'. Available: ${CONNECTIONS.map(c => c.name).join(', ')}.`);
258
+ return conn;
259
+ }
260
+ // Tenant IDs are spliced directly into a URL path segment — restrict to a safe
261
+ // charset rather than trusting an arbitrary caller-supplied header value.
262
+ const SAFE_TENANT_ID = /^[A-Za-z0-9_.-]+$/;
263
+ function firstHeaderValue(value) {
264
+ const v = Array.isArray(value) ? value[0] : value;
265
+ const trimmed = v?.trim();
266
+ return trimmed ? trimmed : undefined;
267
+ }
268
+ // Strict RFC 6750 shape: scheme "Bearer" (case-insensitive) + one non-empty token.
269
+ // Anything else (Basic, multiple tokens, empty) is not a passthrough candidate.
270
+ const BEARER_TOKEN = /^Bearer\s+(\S+)$/i;
271
+ // Connections we've already warned about ignoring an Authorization header for, so a
272
+ // misconfigured deployment (caller sends a JWT but passthrough is off) is diagnosable
273
+ // from the logs without emitting one line per request. Never logs the token itself.
274
+ const warnedBearerIgnored = new Set();
275
+ /**
276
+ * Applies optional per-request X-Api-Key / X-Tenant-Id / Authorization header
277
+ * overrides (HTTP transport only) to an already-resolved connection. Returns
278
+ * the connection unchanged when no override header is present, so a request
279
+ * that doesn't opt in behaves exactly as if this feature didn't exist.
280
+ *
281
+ * Credential precedence (mirrors typical BEST dual-auth gates, where a present
282
+ * API key is authoritative): an explicit per-request X-Api-Key always wins; the
283
+ * Authorization Bearer token is used only when there is no X-Api-Key AND the
284
+ * connection was explicitly configured with allowBearerPassthrough. The token
285
+ * is forwarded verbatim as `Authorization: Bearer <token>` to the connection's
286
+ * configured endpoint only, and is never logged or persisted.
287
+ */
288
+ function applyRequestOverrides(conn, headers) {
289
+ const requestApiKey = firstHeaderValue(headers['x-api-key']);
290
+ const requestTenantId = firstHeaderValue(headers['x-tenant-id']);
291
+ const requestBearer = firstHeaderValue(headers['authorization'])?.match(BEARER_TOKEN)?.[1];
292
+ if (!requestApiKey && !requestTenantId && !requestBearer)
293
+ return conn;
294
+ let endpoint = conn.endpoint;
295
+ if (requestTenantId && conn.tenantTemplateBaseUrl) {
296
+ if (SAFE_TENANT_ID.test(requestTenantId)) {
297
+ endpoint = `${conn.tenantTemplateBaseUrl}/tenants/${requestTenantId}`;
298
+ }
299
+ else {
300
+ process.stderr.write(`[best-mcp] WARNING: ignoring X-Tenant-Id header with unexpected characters for connection '${conn.name}'\n`);
301
+ }
302
+ }
303
+ // Bearer passthrough: only when the caller sent no explicit X-Api-Key override
304
+ // and the operator opted this connection in. Forwarding switches the effective
305
+ // auth to `Authorization: Bearer <token>` regardless of the configured authType,
306
+ // so the token can never end up in a query string or a custom header.
307
+ if (!requestApiKey && requestBearer) {
308
+ if (conn.allowBearerPassthrough) {
309
+ return { ...conn, endpoint, authType: 'bearer', apiKey: requestBearer };
310
+ }
311
+ if (!warnedBearerIgnored.has(conn.name)) {
312
+ warnedBearerIgnored.add(conn.name);
313
+ process.stderr.write(`[best-mcp] WARNING: request carried an Authorization Bearer token but connection '${conn.name}' ` +
314
+ `has bearer passthrough disabled — falling back to the configured credential. ` +
315
+ `Set allowBearerPassthrough (BEST_<APP>_ALLOW_BEARER_PASSTHROUGH=true) if this connection ` +
316
+ `should authenticate upstream with the caller's own token. (Logged once per connection.)\n`);
317
+ }
318
+ }
319
+ return {
320
+ ...conn,
321
+ apiKey: requestApiKey ?? conn.apiKey,
322
+ endpoint,
323
+ };
324
+ }
325
+ // Disable TLS verification for localhost dev endpoints
326
+ for (const conn of CONNECTIONS) {
327
+ if (/^https:\/\/localhost(:\d+)?/.test(conn.endpoint)) {
328
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
329
+ process.stderr.write(`[best-mcp] WARNING: TLS verification disabled for localhost (connection: ${conn.name})\n`);
330
+ break;
331
+ }
332
+ }
333
+ // ── HTTP helpers ──────────────────────────────────────────────────────────────
334
+ function authHeaders(conn) {
335
+ if (conn.authType === 'none')
336
+ return {};
337
+ if (conn.authType === 'bearer')
338
+ return { Authorization: `Bearer ${conn.apiKey}` };
339
+ if (conn.authType === 'apikey' && conn.authIn === 'header')
340
+ return { [conn.authHeader]: conn.apiKey };
341
+ return {}; // apikey in query — credentials go in the URL, not headers
342
+ }
343
+ function withAuthQuery(path, conn) {
344
+ if (conn.authType === 'apikey' && conn.authIn === 'query') {
345
+ const sep = path.includes('?') ? '&' : '?';
346
+ return `${path}${sep}${encodeURIComponent(conn.authParam)}=${encodeURIComponent(conn.apiKey)}`;
347
+ }
348
+ return path;
349
+ }
350
+ async function parseErrorMessage(response) {
351
+ const text = await response.text();
352
+ try {
353
+ const json = JSON.parse(text);
354
+ const err = json.error;
355
+ if (typeof err === 'string')
356
+ return err;
357
+ if (err && typeof err === 'object')
358
+ return err.message ?? JSON.stringify(err);
359
+ return json.title ?? json.detail ?? text;
360
+ }
361
+ catch {
362
+ return text;
363
+ }
364
+ }
365
+ async function bestGet(path, conn) {
366
+ const response = await fetch(`${conn.endpoint}${withAuthQuery(path, conn)}`, {
367
+ headers: { ...authHeaders(conn), Accept: 'application/json' }
368
+ });
369
+ if (!response.ok) {
370
+ const message = await parseErrorMessage(response);
371
+ throw new Error(message);
372
+ }
373
+ return response.json();
374
+ }
375
+ async function bestPost(path, body, conn) {
376
+ const response = await fetch(`${conn.endpoint}${withAuthQuery(path, conn)}`, {
377
+ method: 'POST',
378
+ headers: {
379
+ ...authHeaders(conn),
380
+ 'Content-Type': 'application/json',
381
+ Accept: 'application/json'
382
+ },
383
+ body: JSON.stringify(body)
384
+ });
385
+ if (!response.ok) {
386
+ const message = await parseErrorMessage(response);
387
+ throw new Error(message);
388
+ }
389
+ return response.json();
390
+ }
391
+ // ── Tool definitions ──────────────────────────────────────────────────────────
392
+ // When multiple connections are configured, every operation tool gains an optional
393
+ // 'connection' parameter. The LLM must specify it; if context makes the choice
394
+ // ambiguous, it should call list_connections first and confirm with the user.
395
+ const CONNECTION_PROP = MULTI ? {
396
+ connection: {
397
+ type: 'string',
398
+ description: `Name of the BEST connection to target. Available: ${CONNECTIONS.map(c => c.name).join(', ')}. ` +
399
+ 'Call list_connections to see full details (endpoint, description) for each. ' +
400
+ 'If you are not certain which connection the user intends, call list_connections ' +
401
+ 'and ask the user to confirm before proceeding — a wrong connection may silently ' +
402
+ 'reach the wrong service.'
403
+ }
404
+ } : {};
405
+ const TOOLS = [
406
+ // list_connections is only meaningful (and only shown) when MULTI is true
407
+ ...(MULTI ? [{
408
+ name: 'list_connections',
409
+ description: 'List all configured BEST connections with their names, endpoints, and descriptions. ' +
410
+ 'Call this when you are unsure which connection to use for a given request, ' +
411
+ 'then confirm the correct connection with the user before calling any operation tool.',
412
+ inputSchema: { type: 'object', properties: {}, required: [] }
413
+ }] : []),
414
+ {
415
+ name: 'get_command_catalogue',
416
+ description: 'List all commands this BEST endpoint accepts. ' +
417
+ 'Returns the command catalogue: every command type with its schema name, version, dataschema URI, and description. ' +
418
+ 'Call this first to discover what you can send. ' +
419
+ 'Examples: configure-broker, configure-indicator-alert, submit-signal, archive-broker.',
420
+ inputSchema: { type: 'object', properties: { ...CONNECTION_PROP }, required: [] }
421
+ },
422
+ {
423
+ name: 'get_command_schema',
424
+ description: 'Fetch the full JSON Schema for a specific command type and version. ' +
425
+ 'Use this to discover the exact fields required before calling send_command. ' +
426
+ 'Get the schema name and version from get_command_catalogue.',
427
+ inputSchema: {
428
+ type: 'object',
429
+ properties: {
430
+ ...CONNECTION_PROP,
431
+ schema: {
432
+ type: 'string',
433
+ description: 'Command schema name in kebab-case, from get_command_catalogue (e.g. configure-broker)'
434
+ },
435
+ version: {
436
+ type: 'string',
437
+ description: 'Schema version, from get_command_catalogue (e.g. 1.0)'
438
+ }
439
+ },
440
+ required: ['schema', 'version']
441
+ }
442
+ },
443
+ {
444
+ name: 'send_command',
445
+ description: 'Send a command to the BEST endpoint. ' +
446
+ 'Use get_command_catalogue to discover available commands, ' +
447
+ 'then get_command_schema to learn the required payload fields and the required source value, ' +
448
+ 'then call this with the schema name, version, source, and data payload. ' +
449
+ 'IMPORTANT: the source field is used by the backend to route the CloudEvent — ' +
450
+ 'an incorrect value may cause the command to be silently dropped. ' +
451
+ 'Always read the required source value from the schema description returned by get_command_schema before calling this tool. ' +
452
+ 'If the schema description does not specify a source value, ask the user before proceeding. ' +
453
+ 'Returns the accepted command ID on success.',
454
+ inputSchema: {
455
+ type: 'object',
456
+ properties: {
457
+ ...CONNECTION_PROP,
458
+ schema: {
459
+ type: 'string',
460
+ description: 'Command schema name in kebab-case, from get_command_catalogue (e.g. configure-broker)'
461
+ },
462
+ version: {
463
+ type: 'string',
464
+ description: 'Schema version, from get_command_catalogue (e.g. 1.0)'
465
+ },
466
+ source: {
467
+ type: 'string',
468
+ description: 'CloudEvent source — identifies the origin of this command. The required value is specified in the schema description returned by get_command_schema; always read it from there and do not invent it.'
469
+ },
470
+ data: {
471
+ type: 'object',
472
+ description: 'Command payload matching the JSON Schema from get_command_schema.',
473
+ additionalProperties: true
474
+ }
475
+ },
476
+ required: ['schema', 'version', 'source', 'data']
477
+ }
478
+ },
479
+ {
480
+ name: 'send_command_and_wait',
481
+ description: 'Send a command to the BEST endpoint and wait for it to be processed by polling a query. ' +
482
+ 'Use this instead of send_command when you need to confirm the command was processed before proceeding, ' +
483
+ 'for example subscribing a price feed and then verifying it appears in the list. ' +
484
+ 'Provide poll_query (query schema name from get_query_catalogue) and poll_until_contains ' +
485
+ '(a string that must appear in the query result, e.g. a ticker symbol). ' +
486
+ 'Returns the command ID plus the query result once the condition is met, ' +
487
+ 'or a timeout warning if the condition is not met within timeout_seconds.',
488
+ inputSchema: {
489
+ type: 'object',
490
+ properties: {
491
+ ...CONNECTION_PROP,
492
+ schema: {
493
+ type: 'string',
494
+ description: 'Command schema name in kebab-case (e.g. subscribe-price-feed)'
495
+ },
496
+ version: {
497
+ type: 'string',
498
+ description: 'Schema version (e.g. 1.0)'
499
+ },
500
+ source: {
501
+ type: 'string',
502
+ description: 'CloudEvent source — read the required value from get_command_schema before calling this tool'
503
+ },
504
+ data: {
505
+ type: 'object',
506
+ description: 'Command payload matching the JSON Schema from get_command_schema.',
507
+ additionalProperties: true
508
+ },
509
+ poll_query: {
510
+ type: 'string',
511
+ description: 'Query schema name to poll after sending the command (from get_query_catalogue). If omitted the tool behaves like send_command.'
512
+ },
513
+ poll_until_contains: {
514
+ type: 'string',
515
+ description: 'String that must appear in the query result for the wait to succeed. If omitted, the first successful query response is returned.'
516
+ },
517
+ poll_params: {
518
+ type: 'object',
519
+ description: 'Optional query parameters for the poll query.',
520
+ additionalProperties: true
521
+ },
522
+ timeout_seconds: {
523
+ type: 'number',
524
+ description: 'Maximum seconds to wait for the query to satisfy the condition (default: 30).'
525
+ }
526
+ },
527
+ required: ['schema', 'version', 'source', 'data']
528
+ }
529
+ },
530
+ {
531
+ name: 'get_query_catalogue',
532
+ description: 'List all read queries available at this BEST endpoint. ' +
533
+ 'Returns the query catalogue: every query type with its schema name, version, dataschema URI, and description. ' +
534
+ 'Call this to discover what current-state data you can read. ' +
535
+ 'Examples: list-brokers (get configured broker accounts), list-alerts (get configured alerts), list-price-feeds (get configured price feeds).',
536
+ inputSchema: { type: 'object', properties: { ...CONNECTION_PROP }, required: [] }
537
+ },
538
+ {
539
+ name: 'get_query_schema',
540
+ description: 'Fetch the JSON Schema for a specific query type and version. ' +
541
+ 'Returns the accepted parameters and the exact response shape. ' +
542
+ 'Get the schema name and version from get_query_catalogue.',
543
+ inputSchema: {
544
+ type: 'object',
545
+ properties: {
546
+ ...CONNECTION_PROP,
547
+ schema: {
548
+ type: 'string',
549
+ description: 'Query schema name in kebab-case, from get_query_catalogue (e.g. list-brokers)'
550
+ },
551
+ version: {
552
+ type: 'string',
553
+ description: 'Schema version, from get_query_catalogue (e.g. 1.0)'
554
+ }
555
+ },
556
+ required: ['schema', 'version']
557
+ }
558
+ },
559
+ {
560
+ name: 'execute_query',
561
+ description: 'Execute a read query against the BEST endpoint and return current state data synchronously. ' +
562
+ 'Use get_query_catalogue to discover available queries, ' +
563
+ 'then get_query_schema to learn the accepted parameters and response shape, ' +
564
+ 'then call this with the schema name and any parameters. ' +
565
+ 'Example: execute list-brokers to get IDs needed for a subsequent send_command call.',
566
+ inputSchema: {
567
+ type: 'object',
568
+ properties: {
569
+ ...CONNECTION_PROP,
570
+ schema: {
571
+ type: 'string',
572
+ description: 'Query schema name in kebab-case, from get_query_catalogue (e.g. list-brokers)'
573
+ },
574
+ params: {
575
+ type: 'object',
576
+ description: 'Optional query parameters as key-value pairs matching the parameters schema from get_query_schema. Omit or pass {} if no parameters are needed.',
577
+ additionalProperties: true
578
+ },
579
+ parameters: {
580
+ type: 'object',
581
+ description: "Alias of 'params' — both are accepted; if both are present, 'params' wins.",
582
+ additionalProperties: true
583
+ }
584
+ },
585
+ required: ['schema']
586
+ }
587
+ },
588
+ {
589
+ name: 'get_workflows',
590
+ description: "List the service's published workflows — optional, read-only \"descriptive sequence\" recipes: " +
591
+ 'a named, ordered list of command schemas with per-step guidance for a multi-step process. ' +
592
+ 'Workflows are a vendor extension (BEST does not require them) — many services publish none, ' +
593
+ 'in which case this returns a short note. Each step\'s "schema" is a command from ' +
594
+ 'get_command_catalogue: follow the steps in order, sending each command (and waiting for its ' +
595
+ 'result) before the next. The service does not execute the steps for you — it only describes them.',
596
+ inputSchema: { type: 'object', properties: { ...CONNECTION_PROP }, required: [] }
597
+ }
598
+ ];
599
+ // ── Tool handlers ─────────────────────────────────────────────────────────────
600
+ function handleListConnections() {
601
+ return JSON.stringify(CONNECTIONS.map(c => ({
602
+ name: c.name,
603
+ endpoint: c.endpoint,
604
+ description: c.description ?? '(no description)',
605
+ })), null, 2);
606
+ }
607
+ async function handleGetCommandCatalogue(conn) {
608
+ const data = await bestGet('/commands', conn);
609
+ if (!data.commands.length)
610
+ return 'No commands available at this endpoint.';
611
+ return JSON.stringify(data.commands, null, 2);
612
+ }
613
+ async function handleGetCommandSchema(args, conn) {
614
+ const schema = args.schema;
615
+ const version = args.version;
616
+ const doc = await bestGet(`/commands/${schema}/${version}`, conn);
617
+ return JSON.stringify(doc, null, 2);
618
+ }
619
+ async function handleSendCommand(args, conn) {
620
+ const schema = args.schema;
621
+ const version = args.version;
622
+ const source = args.source;
623
+ const data = args.data;
624
+ // CloudEvent type is PascalCase: configure-broker → ConfigureBroker
625
+ const type = schema
626
+ .split('-')
627
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
628
+ .join('');
629
+ const cloudEvent = {
630
+ specversion: '1.0',
631
+ id: randomUUID(),
632
+ source,
633
+ type,
634
+ datacontenttype: 'application/json',
635
+ // Absolute catalogue URI — BEST is a conformant CloudEvents 1.0 profile
636
+ dataschema: `${conn.endpoint.replace(/\/+$/, '')}/commands/${schema}/${version}`,
637
+ time: new Date().toISOString(),
638
+ data
639
+ };
640
+ const result = await bestPost('/commands', cloudEvent, conn);
641
+ return `Command accepted. ID: ${result.id}`;
642
+ }
643
+ async function handleSendCommandAndWait(args, conn) {
644
+ const commandResult = await handleSendCommand(args, conn);
645
+ const pollQuery = args.poll_query;
646
+ if (!pollQuery)
647
+ return commandResult;
648
+ const pollParams = (args.poll_params ?? args.poll_parameters ?? {});
649
+ const pollUntilContains = args.poll_until_contains;
650
+ const timeoutSeconds = args.timeout_seconds ?? 30;
651
+ const intervalMs = 2000;
652
+ const maxAttempts = Math.ceil((timeoutSeconds * 1000) / intervalMs);
653
+ for (let i = 0; i < maxAttempts; i++) {
654
+ await new Promise(resolve => setTimeout(resolve, intervalMs));
655
+ try {
656
+ const result = await handleExecuteQuery({ schema: pollQuery, params: pollParams }, conn);
657
+ const normalize = (s) => s.replace(/\s+/g, '');
658
+ if (!pollUntilContains || normalize(result).includes(normalize(pollUntilContains))) {
659
+ return `${commandResult}\n\nQuery result after processing:\n${result}`;
660
+ }
661
+ }
662
+ catch {
663
+ // transient query failure — keep polling
664
+ }
665
+ }
666
+ return `${commandResult}\n\nWarning: timed out after ${timeoutSeconds}s waiting for '${pollUntilContains ?? 'any result'}' in ${pollQuery}.`;
667
+ }
668
+ async function handleGetQueryCatalogue(conn) {
669
+ const data = await bestGet('/queries', conn);
670
+ if (!data.queries.length)
671
+ return 'No queries available at this endpoint.';
672
+ return JSON.stringify(data.queries, null, 2);
673
+ }
674
+ async function handleGetQuerySchema(args, conn) {
675
+ const schema = args.schema;
676
+ const version = args.version;
677
+ const doc = await bestGet(`/queries/${schema}/${version}`, conn);
678
+ return JSON.stringify(doc, null, 2);
679
+ }
680
+ async function handleExecuteQuery(args, conn) {
681
+ const schema = args.schema;
682
+ // Models routinely name this argument 'parameters' (the tool description itself speaks of
683
+ // "parameters"), and unknown keys are not rejected — before the alias, such calls silently
684
+ // ran the query UNFILTERED, which servers can surface as misleading authorisation errors.
685
+ const params = (args.params ?? args.parameters ?? {});
686
+ const queryString = Object.entries(params)
687
+ .filter(([, v]) => v !== undefined && v !== null)
688
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
689
+ .join('&');
690
+ const path = queryString ? `/queries/${schema}?${queryString}` : `/queries/${schema}`;
691
+ const result = await bestGet(path, conn);
692
+ return JSON.stringify(result, null, 2);
693
+ }
694
+ async function handleGetWorkflows(conn) {
695
+ // Workflows are an optional vendor extension (see the BEST "Composing Commands into Processes"
696
+ // guide). A service that doesn't publish them simply has no /workflows endpoint — treat that as
697
+ // "none offered" rather than an error, so the agent can move on.
698
+ try {
699
+ const data = await bestGet('/workflows', conn);
700
+ const workflows = data.workflows ?? [];
701
+ if (!workflows.length)
702
+ return 'This endpoint publishes no workflows.';
703
+ return JSON.stringify(workflows, null, 2);
704
+ }
705
+ catch (error) {
706
+ return `This endpoint does not publish workflows (an optional vendor extension). Details: ${String(error)}`;
707
+ }
708
+ }
709
+ // ── Server factory ────────────────────────────────────────────────────────────
710
+ const connectionSummary = MULTI
711
+ ? `\n\n## Connections\n\nMultiple BEST connections are configured:\n` +
712
+ CONNECTIONS.map(c => `- **${c.name}**: ${c.endpoint}${c.description ? ` — ${c.description}` : ''}`).join('\n') +
713
+ `\n\nAlways specify the \`connection\` parameter on every tool call. ` +
714
+ `If the user's request does not make it obvious which connection to use, ` +
715
+ `call \`list_connections\` first and ask the user to confirm before proceeding.`
716
+ : `\n\nConnected to: ${CONNECTIONS[0].endpoint}`;
717
+ const SERVER_INSTRUCTIONS = (`
718
+ You are connected to one or more BEST-compliant service endpoints.
719
+ ${connectionSummary}
720
+
721
+ ## Discovering running services
722
+
723
+ Service management is a domain like any other — BEST has no dedicated registry endpoint. If an endpoint exposes a directory of its services, it does so as queries (e.g. a 'list-services' query) and commands (e.g. 'RegisterService'). Use the query tools below to discover and read it.
724
+
725
+ ## Reading current state (queries)
726
+
727
+ Use the query tools to read domain state before issuing commands that require existing IDs:
728
+
729
+ 1. Call get_query_catalogue to discover available queries.
730
+ 2. Call get_query_schema for the chosen query to understand accepted parameters and response shape.
731
+ 3. Call execute_query to get the data synchronously.
732
+
733
+ ## Sending commands
734
+
735
+ 1. Call get_command_catalogue to discover available commands.
736
+ 2. Call get_command_schema for the chosen command to learn: required fields, field types, and the required 'source' routing value (stated in the schema top-level description).
737
+ 3. Gather any missing field values from the user.
738
+ 4. Call send_command with schema, version, source, and data payload.
739
+
740
+ CloudEvent envelope rules (enforced by send_command):
741
+ - 'type': PascalCase of the schema name (configure-broker → ConfigureBroker). Converted automatically.
742
+ - 'source': read from the schema description. NEVER invent or default this value.
743
+ - 'dataschema': the absolute catalogue URI '{endpoint}/commands/{schema}/{version}'. Built automatically from the connection endpoint.
744
+
745
+ ## Following a published workflow (optional)
746
+
747
+ Some services publish read-only "recipes" for common multi-step processes. Call get_workflows to
748
+ list them. Each workflow is an ordered list of steps; each step names a 'schema' that is a command
749
+ (or query) you already have. Follow the steps in order — send each command and wait for its result
750
+ before the next, threading ids from earlier results into later steps. The service does not run the
751
+ sequence for you; it only describes it. Workflows are optional — if get_workflows reports none, fall
752
+ back to discovering commands/queries directly.
753
+
754
+ ## Error handling
755
+
756
+ If a command fails, relay the error message verbatim to the user — it is actionable.
757
+ `).trim();
758
+ function createMcpServer(requestHeaders) {
759
+ const server = new Server({ name: 'best-mcp', version: '1.0.0' }, { capabilities: { tools: {} } });
760
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
761
+ tools: TOOLS,
762
+ _meta: { instructions: SERVER_INSTRUCTIONS }
763
+ }));
764
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
765
+ const { name, arguments: args } = request.params;
766
+ const safeArgs = (args ?? {});
767
+ try {
768
+ // list_connections needs no connection resolution
769
+ if (name === 'list_connections') {
770
+ return { content: [{ type: 'text', text: handleListConnections() }] };
771
+ }
772
+ // All other tools resolve their target connection from the optional 'connection' arg,
773
+ // then apply any per-request X-Api-Key / X-Tenant-Id header overrides (HTTP only).
774
+ const baseConn = resolveConnection(safeArgs.connection);
775
+ const conn = requestHeaders ? applyRequestOverrides(baseConn, requestHeaders) : baseConn;
776
+ let text;
777
+ switch (name) {
778
+ case 'get_command_catalogue':
779
+ text = await handleGetCommandCatalogue(conn);
780
+ break;
781
+ case 'get_command_schema':
782
+ text = await handleGetCommandSchema(safeArgs, conn);
783
+ break;
784
+ case 'send_command':
785
+ text = await handleSendCommand(safeArgs, conn);
786
+ break;
787
+ case 'send_command_and_wait':
788
+ text = await handleSendCommandAndWait(safeArgs, conn);
789
+ break;
790
+ case 'get_query_catalogue':
791
+ text = await handleGetQueryCatalogue(conn);
792
+ break;
793
+ case 'get_query_schema':
794
+ text = await handleGetQuerySchema(safeArgs, conn);
795
+ break;
796
+ case 'execute_query':
797
+ text = await handleExecuteQuery(safeArgs, conn);
798
+ break;
799
+ case 'get_workflows':
800
+ text = await handleGetWorkflows(conn);
801
+ break;
802
+ default:
803
+ return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
804
+ }
805
+ return { content: [{ type: 'text', text }] };
806
+ }
807
+ catch (error) {
808
+ return { content: [{ type: 'text', text: `Error: ${String(error)}` }], isError: true };
809
+ }
810
+ });
811
+ return server;
812
+ }
813
+ // ── Start ─────────────────────────────────────────────────────────────────────
814
+ if (TRANSPORT === 'http') {
815
+ const httpServer = createHttpServer(async (req, res) => {
816
+ if (req.url === '/mcp' && req.method === 'POST') {
817
+ const chunks = [];
818
+ for await (const chunk of req)
819
+ chunks.push(chunk);
820
+ let body;
821
+ try {
822
+ body = JSON.parse(Buffer.concat(chunks).toString());
823
+ }
824
+ catch {
825
+ body = undefined;
826
+ }
827
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
828
+ const server = createMcpServer(req.headers);
829
+ res.on('close', () => { server.close(); transport.close(); });
830
+ await server.connect(transport);
831
+ await transport.handleRequest(req, res, body);
832
+ }
833
+ else if (req.url === '/health' && req.method === 'GET') {
834
+ res.writeHead(200, { 'Content-Type': 'application/json' });
835
+ res.end(JSON.stringify({ status: 'ok', transport: 'http' }));
836
+ }
837
+ else {
838
+ res.writeHead(404);
839
+ res.end();
840
+ }
841
+ });
842
+ httpServer.listen(HTTP_PORT, () => {
843
+ process.stderr.write(`[best-mcp] HTTP server listening on port ${HTTP_PORT}\n`);
844
+ process.stderr.write(`[best-mcp] MCP endpoint: http://localhost:${HTTP_PORT}/mcp\n`);
845
+ for (const c of CONNECTIONS) {
846
+ process.stderr.write(`[best-mcp] Connection '${c.name}': ${c.endpoint}\n`);
847
+ }
848
+ });
849
+ }
850
+ else {
851
+ const transport = new StdioServerTransport();
852
+ const server = createMcpServer();
853
+ await server.connect(transport);
854
+ }