@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/README.md +296 -0
- package/dist/index.js +854 -0
- package/package.json +22 -0
- package/src/index.ts +921 -0
- package/tsconfig.json +13 -0
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
|
+
}
|