botmancers 0.0.0-stage → 0.1.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/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +183 -2
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +110 -0
- package/dist/config.d.ts +36 -0
- package/dist/config.js +188 -0
- package/dist/contract.d.ts +21 -0
- package/dist/contract.js +28 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +9 -0
- package/dist/redact.d.ts +3 -0
- package/dist/redact.js +16 -0
- package/dist/server.d.ts +33 -0
- package/dist/server.js +240 -0
- package/dist/upstream.d.ts +24 -0
- package/dist/upstream.js +106 -0
- package/package.json +63 -4
- package/schemas/botmancers.mcp.v1.tools.json +542 -0
package/dist/redact.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Secrets never reach logs or error messages: every line the bridge writes
|
|
2
|
+
// passes through a redactor that knows the configured tokens.
|
|
3
|
+
export const REDACTED = '[REDACTED]';
|
|
4
|
+
export function createRedactor(secrets) {
|
|
5
|
+
const known = [...new Set(secrets.filter((secret) => !!secret && secret.length >= 4))]
|
|
6
|
+
.sort((a, b) => b.length - a.length);
|
|
7
|
+
return text => {
|
|
8
|
+
let out = text;
|
|
9
|
+
for (const secret of known)
|
|
10
|
+
out = out.split(secret).join(REDACTED);
|
|
11
|
+
// Belt and braces: any bearer credential or URL userinfo that slipped in.
|
|
12
|
+
return out
|
|
13
|
+
.replace(/(Bearer[ \t]+)[A-Za-z0-9._~+/=-]{12,}/gi, `$1${REDACTED}`)
|
|
14
|
+
.replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi, `$1${REDACTED}@`);
|
|
15
|
+
};
|
|
16
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type Server } from 'node:http';
|
|
2
|
+
import type { AddressInfo } from 'node:net';
|
|
3
|
+
import type { BridgeConfig } from './config.js';
|
|
4
|
+
import { type Redactor } from './redact.js';
|
|
5
|
+
import { type Upstream } from './upstream.js';
|
|
6
|
+
export declare const MCP_PATH = "/mcp";
|
|
7
|
+
export declare const HEALTH_PATH = "/healthz";
|
|
8
|
+
export declare const MAX_BODY_BYTES: number;
|
|
9
|
+
export declare const JSON_RPC: {
|
|
10
|
+
parseError: number;
|
|
11
|
+
invalidRequest: number;
|
|
12
|
+
methodNotFound: number;
|
|
13
|
+
invalidParams: number;
|
|
14
|
+
internalError: number;
|
|
15
|
+
};
|
|
16
|
+
export interface Logger {
|
|
17
|
+
info(message: string): void;
|
|
18
|
+
error(message: string): void;
|
|
19
|
+
}
|
|
20
|
+
export interface BridgeOptions {
|
|
21
|
+
config: BridgeConfig;
|
|
22
|
+
logger?: Logger;
|
|
23
|
+
upstream?: Upstream;
|
|
24
|
+
}
|
|
25
|
+
export interface Bridge {
|
|
26
|
+
server: Server;
|
|
27
|
+
redact: Redactor;
|
|
28
|
+
upstream: Upstream;
|
|
29
|
+
listen(): Promise<AddressInfo>;
|
|
30
|
+
close(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
export declare const defaultLogger: Logger;
|
|
33
|
+
export declare function createBridge(options: BridgeOptions): Bridge;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// The standalone bridge: MCP Streamable HTTP at POST /mcp, stateless, with
|
|
2
|
+
// application/json responses (no session id, no server-initiated SSE
|
|
3
|
+
// stream, so GET and DELETE are 405), and an unauthenticated GET /healthz.
|
|
4
|
+
//
|
|
5
|
+
// initialize, ping, and tools/list are answered locally from the bundled
|
|
6
|
+
// botmancers.mcp.v1 schema; tools/call for a contract tool is forwarded
|
|
7
|
+
// unchanged to Botmancers' /api/v0/mcp with the service token. MCP clients
|
|
8
|
+
// authenticate to the bridge with their own bearer token and never see the
|
|
9
|
+
// service token.
|
|
10
|
+
//
|
|
11
|
+
// DNS-rebinding guard: when bound to a loopback address, Host must be a
|
|
12
|
+
// loopback host; an Origin header, when present, must be exactly
|
|
13
|
+
// http://<loopback Host>. The endpoint has no CORS support.
|
|
14
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
15
|
+
import { createServer } from 'node:http';
|
|
16
|
+
import { isLoopbackHost } from './config.js';
|
|
17
|
+
import { MCP_CONTRACT, MCP_INSTRUCTIONS, MCP_PROTOCOL_VERSIONS, loadToolContract, negotiateProtocolVersion, packageVersion } from './contract.js';
|
|
18
|
+
import { createRedactor } from './redact.js';
|
|
19
|
+
import { UpstreamError, createUpstream } from './upstream.js';
|
|
20
|
+
export const MCP_PATH = '/mcp';
|
|
21
|
+
export const HEALTH_PATH = '/healthz';
|
|
22
|
+
export const MAX_BODY_BYTES = 256 * 1024;
|
|
23
|
+
export const JSON_RPC = { parseError: -32700, invalidRequest: -32600, methodNotFound: -32601, invalidParams: -32602, internalError: -32603 };
|
|
24
|
+
function rpcError(id, code, message, data) {
|
|
25
|
+
return { jsonrpc: '2.0', id, error: { code, message, ...(data === undefined ? {} : { data }) } };
|
|
26
|
+
}
|
|
27
|
+
function sendJson(res, status, body, headers = {}) {
|
|
28
|
+
const text = JSON.stringify(body);
|
|
29
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', ...headers });
|
|
30
|
+
res.end(text);
|
|
31
|
+
}
|
|
32
|
+
function envelope(res, status, code, message, headers = {}) {
|
|
33
|
+
sendJson(res, status, { error: { code, message } }, headers);
|
|
34
|
+
}
|
|
35
|
+
class BodyTooLarge extends Error {
|
|
36
|
+
}
|
|
37
|
+
function readBody(req) {
|
|
38
|
+
const declared = req.headers['content-length'];
|
|
39
|
+
if (declared !== undefined && !(/^\d+$/.test(declared.trim()) && Number(declared) <= MAX_BODY_BYTES)) {
|
|
40
|
+
return Promise.reject(new BodyTooLarge());
|
|
41
|
+
}
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const chunks = [];
|
|
44
|
+
let size = 0;
|
|
45
|
+
req.on('data', (chunk) => {
|
|
46
|
+
size += chunk.byteLength;
|
|
47
|
+
if (size > MAX_BODY_BYTES) {
|
|
48
|
+
req.pause();
|
|
49
|
+
reject(new BodyTooLarge());
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
chunks.push(chunk);
|
|
53
|
+
});
|
|
54
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
55
|
+
req.on('error', reject);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function digest(value) {
|
|
59
|
+
return createHash('sha256').update(value, 'utf8').digest();
|
|
60
|
+
}
|
|
61
|
+
export const defaultLogger = {
|
|
62
|
+
info: message => process.stderr.write(`${message}\n`),
|
|
63
|
+
error: message => process.stderr.write(`${message}\n`),
|
|
64
|
+
};
|
|
65
|
+
export function createBridge(options) {
|
|
66
|
+
const { config } = options;
|
|
67
|
+
const redact = createRedactor([config.serviceToken, config.mcpToken]);
|
|
68
|
+
const base = options.logger ?? defaultLogger;
|
|
69
|
+
const logger = { info: m => base.info(redact(m)), error: m => base.error(redact(m)) };
|
|
70
|
+
const upstream = options.upstream ?? createUpstream({ botmancersUrl: config.botmancersUrl, serviceToken: config.serviceToken });
|
|
71
|
+
const contract = loadToolContract();
|
|
72
|
+
const toolNames = new Set(contract.tools.map(tool => tool.name));
|
|
73
|
+
const version = packageVersion();
|
|
74
|
+
const expectedToken = digest(config.mcpToken);
|
|
75
|
+
const loopbackOnly = isLoopbackHost(config.host);
|
|
76
|
+
function authorized(req) {
|
|
77
|
+
const match = /^Bearer[ \t]+(\S+)[ \t]*$/i.exec(req.headers.authorization ?? '');
|
|
78
|
+
if (!match || match[1].length > 4096)
|
|
79
|
+
return false;
|
|
80
|
+
return timingSafeEqual(digest(match[1]), expectedToken);
|
|
81
|
+
}
|
|
82
|
+
// Null when Host and Origin are trusted, else [code, message].
|
|
83
|
+
function rebindingRefusal(req) {
|
|
84
|
+
const host = (req.headers.host ?? '').trim().toLowerCase();
|
|
85
|
+
if (loopbackOnly && !isLoopbackHost(host.replace(/:\d+$/, ''))) {
|
|
86
|
+
return ['host_not_allowed', 'The request did not arrive on a loopback host.'];
|
|
87
|
+
}
|
|
88
|
+
const origin = req.headers.origin;
|
|
89
|
+
if (origin === undefined)
|
|
90
|
+
return null;
|
|
91
|
+
if (isLoopbackHost(host.replace(/:\d+$/, '')) && origin.trim().toLowerCase() === `http://${host}`)
|
|
92
|
+
return null;
|
|
93
|
+
return ['origin_not_allowed', 'Cross-origin requests are not accepted.'];
|
|
94
|
+
}
|
|
95
|
+
async function handleMessage(message) {
|
|
96
|
+
if (!message || typeof message !== 'object' || Array.isArray(message)) {
|
|
97
|
+
return { status: 400, body: rpcError(null, JSON_RPC.invalidRequest, 'Invalid Request') };
|
|
98
|
+
}
|
|
99
|
+
const { jsonrpc, id, method, params } = message;
|
|
100
|
+
const hasId = typeof id === 'string' || (typeof id === 'number' && Number.isFinite(id));
|
|
101
|
+
if (jsonrpc !== '2.0' || (id !== undefined && !hasId))
|
|
102
|
+
return { status: 400, body: rpcError(null, JSON_RPC.invalidRequest, 'Invalid Request') };
|
|
103
|
+
if (typeof method !== 'string') {
|
|
104
|
+
// A response to a server request; this server sends none.
|
|
105
|
+
return hasId ? { status: 202 } : { status: 400, body: rpcError(null, JSON_RPC.invalidRequest, 'Invalid Request') };
|
|
106
|
+
}
|
|
107
|
+
if (!hasId)
|
|
108
|
+
return { status: 202 };
|
|
109
|
+
const requestId = id;
|
|
110
|
+
const p = (params && typeof params === 'object' && !Array.isArray(params) ? params : {});
|
|
111
|
+
switch (method) {
|
|
112
|
+
case 'initialize':
|
|
113
|
+
return {
|
|
114
|
+
status: 200,
|
|
115
|
+
body: {
|
|
116
|
+
jsonrpc: '2.0',
|
|
117
|
+
id: requestId,
|
|
118
|
+
result: {
|
|
119
|
+
protocolVersion: negotiateProtocolVersion(p.protocolVersion),
|
|
120
|
+
capabilities: { tools: { listChanged: false } },
|
|
121
|
+
serverInfo: { name: 'botmancers', title: 'Botmancers', version },
|
|
122
|
+
instructions: MCP_INSTRUCTIONS,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
case 'ping':
|
|
127
|
+
return { status: 200, body: { jsonrpc: '2.0', id: requestId, result: {} } };
|
|
128
|
+
case 'tools/list':
|
|
129
|
+
return { status: 200, body: { jsonrpc: '2.0', id: requestId, result: { tools: contract.tools } } };
|
|
130
|
+
case 'tools/call': {
|
|
131
|
+
const name = p.name;
|
|
132
|
+
if (typeof name !== 'string' || !toolNames.has(name)) {
|
|
133
|
+
return { status: 200, body: rpcError(requestId, JSON_RPC.invalidParams, `Unknown tool: ${typeof name === 'string' ? name.slice(0, 100) : String(name)}`) };
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const answer = await upstream.send({ jsonrpc: '2.0', id: requestId, method, params: p });
|
|
137
|
+
return { status: 200, body: answer };
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (!(error instanceof UpstreamError))
|
|
141
|
+
throw error;
|
|
142
|
+
logger.error(`botmancers: tools/call ${name} failed: ${error.message}`);
|
|
143
|
+
if (error.kind === 'rate_limited') {
|
|
144
|
+
return {
|
|
145
|
+
status: 429,
|
|
146
|
+
body: { error: { code: 'rate_limited', message: 'Too many tool calls; retry later.' } },
|
|
147
|
+
headers: error.retryAfter ? { 'retry-after': error.retryAfter } : {},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return { status: 200, body: rpcError(requestId, JSON_RPC.internalError, redact(error.message), { code: `botmancers_${error.kind}` }) };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
default:
|
|
154
|
+
return { status: 200, body: rpcError(requestId, JSON_RPC.methodNotFound, 'Method not found') };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async function handleMcpPost(req, res) {
|
|
158
|
+
const refusal = rebindingRefusal(req);
|
|
159
|
+
if (refusal)
|
|
160
|
+
return envelope(res, 403, refusal[0], refusal[1]);
|
|
161
|
+
if (!authorized(req)) {
|
|
162
|
+
return envelope(res, 401, 'unauthenticated', 'Send Authorization: Bearer <BOTMANCERS_MCP_TOKEN>.', { 'www-authenticate': 'Bearer realm="botmancers"' });
|
|
163
|
+
}
|
|
164
|
+
const protocol = req.headers['mcp-protocol-version'];
|
|
165
|
+
if (typeof protocol === 'string' && !MCP_PROTOCOL_VERSIONS.includes(protocol)) {
|
|
166
|
+
return envelope(res, 400, 'unsupported_protocol_version', `Supported MCP protocol versions: ${MCP_PROTOCOL_VERSIONS.join(', ')}.`);
|
|
167
|
+
}
|
|
168
|
+
const accept = req.headers.accept;
|
|
169
|
+
if (accept && !/application\/json|application\/\*|\*\/\*/i.test(accept)) {
|
|
170
|
+
return envelope(res, 406, 'not_acceptable', 'This server answers with application/json.');
|
|
171
|
+
}
|
|
172
|
+
let text;
|
|
173
|
+
try {
|
|
174
|
+
text = await readBody(req);
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (error instanceof BodyTooLarge) {
|
|
178
|
+
res.setHeader('connection', 'close');
|
|
179
|
+
return envelope(res, 413, 'payload_too_large', 'The message is too large.');
|
|
180
|
+
}
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
let message;
|
|
184
|
+
try {
|
|
185
|
+
message = JSON.parse(text);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return sendJson(res, 400, rpcError(null, JSON_RPC.parseError, 'Parse error'));
|
|
189
|
+
}
|
|
190
|
+
if (Array.isArray(message))
|
|
191
|
+
return sendJson(res, 400, rpcError(null, JSON_RPC.invalidRequest, 'Batched messages are not supported.'));
|
|
192
|
+
const answer = await handleMessage(message);
|
|
193
|
+
if (answer.body === undefined) {
|
|
194
|
+
res.writeHead(answer.status);
|
|
195
|
+
res.end();
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
sendJson(res, answer.status, answer.body, answer.headers);
|
|
199
|
+
}
|
|
200
|
+
const server = createServer((req, res) => {
|
|
201
|
+
const path = (req.url ?? '/').split('?')[0];
|
|
202
|
+
const route = async () => {
|
|
203
|
+
if (path === HEALTH_PATH) {
|
|
204
|
+
if (req.method !== 'GET' && req.method !== 'HEAD')
|
|
205
|
+
return envelope(res, 405, 'method_not_allowed', 'Use GET.', { allow: 'GET, HEAD' });
|
|
206
|
+
return sendJson(res, 200, { status: 'ok', contract: MCP_CONTRACT, version });
|
|
207
|
+
}
|
|
208
|
+
if (path === MCP_PATH) {
|
|
209
|
+
if (req.method === 'POST')
|
|
210
|
+
return handleMcpPost(req, res);
|
|
211
|
+
return envelope(res, 405, 'method_not_allowed', 'This MCP endpoint accepts POST only (no server-initiated stream or session).', { allow: 'POST' });
|
|
212
|
+
}
|
|
213
|
+
return envelope(res, 404, 'not_found', `Not found. The MCP endpoint is POST ${MCP_PATH}.`);
|
|
214
|
+
};
|
|
215
|
+
route().catch(error => {
|
|
216
|
+
logger.error(`botmancers: internal error: ${error.message}`);
|
|
217
|
+
if (!res.headersSent)
|
|
218
|
+
envelope(res, 500, 'internal_error', 'The request could not be processed.');
|
|
219
|
+
else
|
|
220
|
+
res.end();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
return {
|
|
224
|
+
server,
|
|
225
|
+
redact,
|
|
226
|
+
upstream,
|
|
227
|
+
listen: () => new Promise((resolve, reject) => {
|
|
228
|
+
const onError = (error) => reject(error);
|
|
229
|
+
server.once('error', onError);
|
|
230
|
+
server.listen(config.port, config.host, () => {
|
|
231
|
+
server.off('error', onError);
|
|
232
|
+
resolve(server.address());
|
|
233
|
+
});
|
|
234
|
+
}),
|
|
235
|
+
close: () => new Promise(resolve => {
|
|
236
|
+
server.close(() => resolve());
|
|
237
|
+
server.closeAllConnections();
|
|
238
|
+
}),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const MCP_SERVER_NAME = "botmancers";
|
|
2
|
+
export declare const UPSTREAM_PATH = "/api/v0/mcp";
|
|
3
|
+
export declare const UPSTREAM_TIMEOUT_MS = 30000;
|
|
4
|
+
export type UpstreamFailureKind = 'unreachable' | 'unauthorized' | 'unconfigured' | 'incompatible' | 'rate_limited' | 'error';
|
|
5
|
+
export declare class UpstreamError extends Error {
|
|
6
|
+
readonly kind: UpstreamFailureKind;
|
|
7
|
+
readonly status: number | null;
|
|
8
|
+
readonly retryAfter: string | null;
|
|
9
|
+
constructor(kind: UpstreamFailureKind, message: string, status?: number | null, retryAfter?: string | null);
|
|
10
|
+
}
|
|
11
|
+
export interface UpstreamOptions {
|
|
12
|
+
botmancersUrl: string;
|
|
13
|
+
serviceToken: string;
|
|
14
|
+
timeoutMs?: number;
|
|
15
|
+
fetchImpl?: typeof fetch;
|
|
16
|
+
}
|
|
17
|
+
export declare function createUpstream(options: UpstreamOptions): {
|
|
18
|
+
endpoint: string;
|
|
19
|
+
send: (message: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
20
|
+
preflight: () => Promise<{
|
|
21
|
+
serverVersion: string | null;
|
|
22
|
+
}>;
|
|
23
|
+
};
|
|
24
|
+
export type Upstream = ReturnType<typeof createUpstream>;
|
package/dist/upstream.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// The Botmancers side of the bridge: JSON-RPC messages POSTed to the app's
|
|
2
|
+
// /api/v0/mcp endpoint with the service token. Failures are classified so
|
|
3
|
+
// the CLI and the MCP surface can say what to fix, without echoing secrets.
|
|
4
|
+
import { MCP_CONTRACT, MCP_PROTOCOL_VERSIONS, loadToolContract } from './contract.js';
|
|
5
|
+
// The serverInfo.name the Botmancers app reports from initialize.
|
|
6
|
+
export const MCP_SERVER_NAME = 'botmancers';
|
|
7
|
+
export const UPSTREAM_PATH = '/api/v0/mcp';
|
|
8
|
+
export const UPSTREAM_TIMEOUT_MS = 30_000;
|
|
9
|
+
export class UpstreamError extends Error {
|
|
10
|
+
kind;
|
|
11
|
+
status;
|
|
12
|
+
retryAfter;
|
|
13
|
+
constructor(kind, message, status = null, retryAfter = null) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'UpstreamError';
|
|
16
|
+
this.kind = kind;
|
|
17
|
+
this.status = status;
|
|
18
|
+
this.retryAfter = retryAfter;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function errorCodeOf(body) {
|
|
22
|
+
const code = body?.error?.code;
|
|
23
|
+
return typeof code === 'string' ? code.slice(0, 60) : null;
|
|
24
|
+
}
|
|
25
|
+
export function createUpstream(options) {
|
|
26
|
+
const endpoint = `${options.botmancersUrl}${UPSTREAM_PATH}`;
|
|
27
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
28
|
+
const timeoutMs = options.timeoutMs ?? UPSTREAM_TIMEOUT_MS;
|
|
29
|
+
async function send(message) {
|
|
30
|
+
let response;
|
|
31
|
+
try {
|
|
32
|
+
response = await fetchImpl(endpoint, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: {
|
|
35
|
+
'content-type': 'application/json',
|
|
36
|
+
accept: 'application/json',
|
|
37
|
+
authorization: `Bearer ${options.serviceToken}`,
|
|
38
|
+
'mcp-protocol-version': MCP_PROTOCOL_VERSIONS[0],
|
|
39
|
+
},
|
|
40
|
+
body: JSON.stringify(message),
|
|
41
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
42
|
+
redirect: 'error',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
const cause = error.cause?.code ?? error.name;
|
|
47
|
+
throw new UpstreamError('unreachable', `Cannot reach Botmancers at ${options.botmancersUrl} (${cause}). Is the Botmancers app running, and is BOTMANCERS_URL correct?`);
|
|
48
|
+
}
|
|
49
|
+
const text = await response.text().catch(() => '');
|
|
50
|
+
let body = null;
|
|
51
|
+
try {
|
|
52
|
+
body = text ? JSON.parse(text) : null;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
body = null;
|
|
56
|
+
}
|
|
57
|
+
const code = errorCodeOf(body);
|
|
58
|
+
if (response.status === 401 || response.status === 403) {
|
|
59
|
+
throw new UpstreamError('unauthorized', `Botmancers rejected the service token (HTTP ${response.status}${code ? ` ${code}` : ''}). Check that BOTMANCERS_SERVICE_TOKEN matches an entry of BOTMANCERS_SERVICE_TOKENS on the Botmancers server.`, response.status);
|
|
60
|
+
}
|
|
61
|
+
if (response.status === 503 && code === 'authentication_unavailable') {
|
|
62
|
+
throw new UpstreamError('unconfigured', 'Botmancers has no valid service tokens configured. Set BOTMANCERS_SERVICE_TOKENS on the Botmancers server (see the README).', response.status);
|
|
63
|
+
}
|
|
64
|
+
if (response.status === 429) {
|
|
65
|
+
throw new UpstreamError('rate_limited', 'Botmancers rate-limited this bridge; retry later.', 429, response.headers.get('retry-after'));
|
|
66
|
+
}
|
|
67
|
+
if (response.status === 404 || response.status === 405 || (response.status === 400 && code === 'unsupported_protocol_version')) {
|
|
68
|
+
throw new UpstreamError('incompatible', `Botmancers at ${options.botmancersUrl} does not serve a compatible MCP endpoint at ${UPSTREAM_PATH} (HTTP ${response.status}). Check BOTMANCERS_URL and upgrade Botmancers.`, response.status);
|
|
69
|
+
}
|
|
70
|
+
const isRpc = body && typeof body === 'object' && body.jsonrpc === '2.0';
|
|
71
|
+
if (!isRpc) {
|
|
72
|
+
throw new UpstreamError('error', `Botmancers answered HTTP ${response.status}${code ? ` ${code}` : ''} without a JSON-RPC response.`, response.status);
|
|
73
|
+
}
|
|
74
|
+
return body;
|
|
75
|
+
}
|
|
76
|
+
// initialize and tools/list round trips: authenticate, then confirm the
|
|
77
|
+
// endpoint is Botmancers and offers every botmancers.mcp.v1 tool. No tool
|
|
78
|
+
// is called, so preflight writes nothing and is not rate-limited.
|
|
79
|
+
async function preflight() {
|
|
80
|
+
const incompatible = (why) => new UpstreamError('incompatible', `${options.botmancersUrl}${UPSTREAM_PATH} is an MCP endpoint but not a compatible Botmancers (${why}). Check BOTMANCERS_URL and upgrade Botmancers.`);
|
|
81
|
+
const init = await send({
|
|
82
|
+
jsonrpc: '2.0',
|
|
83
|
+
id: 'botmancers-bridge-preflight',
|
|
84
|
+
method: 'initialize',
|
|
85
|
+
params: { protocolVersion: MCP_PROTOCOL_VERSIONS[0], capabilities: {}, clientInfo: { name: 'botmancers-bridge', version: '0' } },
|
|
86
|
+
});
|
|
87
|
+
const result = init.result;
|
|
88
|
+
if (!result)
|
|
89
|
+
throw new UpstreamError('incompatible', `Botmancers at ${options.botmancersUrl} refused MCP initialize.`);
|
|
90
|
+
if (result.serverInfo?.name !== MCP_SERVER_NAME)
|
|
91
|
+
throw incompatible(`serverInfo.name is not "${MCP_SERVER_NAME}"`);
|
|
92
|
+
if (!result.capabilities?.tools || typeof result.capabilities.tools !== 'object')
|
|
93
|
+
throw incompatible('no tools capability');
|
|
94
|
+
const list = await send({ jsonrpc: '2.0', id: 'botmancers-bridge-preflight-tools', method: 'tools/list', params: {} });
|
|
95
|
+
const offered = list.result?.tools;
|
|
96
|
+
if (!Array.isArray(offered))
|
|
97
|
+
throw incompatible('tools/list returned no tools');
|
|
98
|
+
const names = new Set(offered.map(tool => tool?.name));
|
|
99
|
+
const missing = loadToolContract().tools.map(tool => tool.name).filter(name => !names.has(name));
|
|
100
|
+
if (missing.length)
|
|
101
|
+
throw incompatible(`missing ${MCP_CONTRACT} tools: ${missing.join(', ')}`);
|
|
102
|
+
const version = result.serverInfo.version;
|
|
103
|
+
return { serverVersion: typeof version === 'string' ? version : null };
|
|
104
|
+
}
|
|
105
|
+
return { endpoint, send, preflight };
|
|
106
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,65 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "botmancers",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Botmancers MCP bridge: a Streamable HTTP MCP server that hands tasks to a Botmancers coordinator (contract botmancers.mcp.v1).",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"botmancers",
|
|
7
|
+
"mcp",
|
|
8
|
+
"model-context-protocol",
|
|
9
|
+
"streamable-http",
|
|
10
|
+
"agents"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": "Yusuke Muraoka",
|
|
14
|
+
"homepage": "https://github.com/not-so-fat/botmancers/tree/main/packages/mcp-server#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/not-so-fat/botmancers/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/not-so-fat/botmancers.git",
|
|
21
|
+
"directory": "packages/mcp-server"
|
|
22
|
+
},
|
|
23
|
+
"type": "module",
|
|
24
|
+
"main": "./dist/index.js",
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"default": "./dist/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./schemas/botmancers.mcp.v1.tools.json": "./schemas/botmancers.mcp.v1.tools.json",
|
|
32
|
+
"./package.json": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
"bin": {
|
|
35
|
+
"botmancers": "dist/cli.js"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist/**/*.js",
|
|
39
|
+
"dist/**/*.d.ts",
|
|
40
|
+
"schemas/*.json",
|
|
41
|
+
"CHANGELOG.md"
|
|
42
|
+
],
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=20"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public",
|
|
48
|
+
"provenance": true,
|
|
49
|
+
"tag": "canary"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
53
|
+
"build": "npm run clean && tsc -p tsconfig.json",
|
|
54
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
55
|
+
"test": "npm run build && node --test test/",
|
|
56
|
+
"check:pack": "node scripts/check-pack.mjs",
|
|
57
|
+
"smoke:install": "node scripts/smoke-install.mjs",
|
|
58
|
+
"release:check": "node scripts/release.mjs",
|
|
59
|
+
"prepack": "npm run build"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@types/node": "20.19.43",
|
|
63
|
+
"typescript": "5.9.3"
|
|
64
|
+
}
|
|
65
|
+
}
|