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 ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ All notable changes to the `botmancers` npm package. It uses
4
+ [Semantic Versioning](https://semver.org): while the package is `0.x`, a
5
+ minor version may change the CLI or configuration. The MCP tool contract is
6
+ versioned separately, as `botmancers.mcp.v1`.
7
+
8
+ ## [0.1.0] - 2026-10-15
9
+
10
+ ### Added
11
+
12
+ - `botmancers` CLI (`npx botmancers`), a standalone Streamable HTTP MCP bridge
13
+ that forwards the `botmancers.mcp.v1` tools to a Botmancers app's
14
+ `/api/v0/mcp` endpoint.
15
+ - Configuration through `BOTMANCERS_URL`, `BOTMANCERS_SERVICE_TOKEN`,
16
+ `BOTMANCERS_MCP_TOKEN`, `BOTMANCERS_MCP_HOST`, and `BOTMANCERS_MCP_PORT`,
17
+ with `--botmancers-url`, `--host`, and `--port` flags. The bridge binds
18
+ loopback by default.
19
+ - A startup preflight against Botmancers. Configuration, listen,
20
+ reachability, and credential failures each have their own exit code, and
21
+ their messages are redacted.
22
+ - `GET /healthz` liveness endpoint.
23
+ - The versioned tool schema at `schemas/botmancers.mcp.v1.tools.json`, and a
24
+ library export (`createBridge`, `parseCli`, and related functions).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yusuke Muraoka
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,184 @@
1
- # Temporary Holding Version
1
+ # botmancers
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The Botmancers MCP bridge. It is a standalone [MCP](https://modelcontextprotocol.io)
4
+ server, using Streamable HTTP, that lets an external agent hand a task to a
5
+ Botmancers coordinator Agent and poll the result. Examples of such agents are
6
+ Cursor's Grok Bot or any other MCP client.
7
+
8
+ The bridge runs on your machine next to the Botmancers app. MCP clients
9
+ authenticate to the bridge with their own token. The bridge forwards their
10
+ tool calls to Botmancers with a service token, and clients never see that
11
+ token. The tool contract is `botmancers.mcp.v1`, and its JSON schema ships in
12
+ this package at `schemas/botmancers.mcp.v1.tools.json`.
13
+
14
+ | Tool | What it does |
15
+ | --- | --- |
16
+ | `botmancers_start_task` | Sends one objective to an allowlisted coordinator. Returns `request_id` and `conversation_id`. |
17
+ | `botmancers_get_request` | Polls a request until `terminal`. Returns its `result` or `failure`. |
18
+ | `botmancers_get_conversation` | Reads the conversation the task opened. |
19
+ | `botmancers_health` | Shows the caller's identity and the available coordinators. |
20
+
21
+ Requirements: Node.js 20 or newer and a running Botmancers app.
22
+
23
+ ## Quick start
24
+
25
+ 1. **Create two tokens.** The service token authenticates the bridge to
26
+ Botmancers. The MCP token authenticates MCP clients to the bridge.
27
+
28
+ ```sh
29
+ export BOTMANCERS_SERVICE_TOKEN=$(openssl rand -hex 32)
30
+ export BOTMANCERS_MCP_TOKEN=$(openssl rand -hex 32)
31
+ printf %s "$BOTMANCERS_SERVICE_TOKEN" | shasum -a 256 # -> <hex>, for the next step
32
+ ```
33
+
34
+ 2. **Configure the Botmancers app**, in the environment of its `npm start`.
35
+ Store only the token's digest there, and allowlist the coordinators the
36
+ bridge may use:
37
+
38
+ ```sh
39
+ BOTMANCERS_SERVICE_TOKENS="mcp-bridge=sha256:<hex>"
40
+ BOTMANCERS_MCP_COORDINATORS="Tank"
41
+ ```
42
+
43
+ 3. **Start the bridge:**
44
+
45
+ ```sh
46
+ BOTMANCERS_URL=http://127.0.0.1:43123 npx botmancers
47
+ ```
48
+
49
+ It prints
50
+ `botmancers <version>: MCP bridge listening on http://127.0.0.1:43124/mcp ...`
51
+ and keeps running until you press Ctrl-C.
52
+
53
+ 4. **Connect an MCP client** to `http://127.0.0.1:43124/mcp` with the header
54
+ `Authorization: Bearer <BOTMANCERS_MCP_TOKEN>`. Then call
55
+ `botmancers_health`. It should return `status: "ok"` and each coordinator
56
+ with `available: true`.
57
+
58
+ To pin an exact release, which is recommended for anything long-running:
59
+
60
+ ```sh
61
+ BOTMANCERS_URL=http://127.0.0.1:43123 npx botmancers@0.1.0
62
+ ```
63
+
64
+ ## Configuration
65
+
66
+ | Variable | Flag | Required | Default | Meaning |
67
+ | --- | --- | --- | --- | --- |
68
+ | `BOTMANCERS_URL` | `--botmancers-url` | yes | none | Base URL of the Botmancers app, for example `http://127.0.0.1:43123`. Must be `http://` or `https://`, with no credentials, query, or fragment. The bridge calls `<url>/api/v0/mcp`. |
69
+ | `BOTMANCERS_SERVICE_TOKEN` | none (environment only) | yes | none | Bearer token the bridge sends to Botmancers. Its SHA-256 digest must be listed in the app's `BOTMANCERS_SERVICE_TOKENS`. |
70
+ | `BOTMANCERS_MCP_TOKEN` | none (environment only) | yes | none | Bearer token MCP clients must send to the bridge. At least 16 characters, and must differ from the service token. |
71
+ | `BOTMANCERS_MCP_HOST` | `--host` | no | `127.0.0.1` | Address to bind. |
72
+ | `BOTMANCERS_MCP_PORT` | `--port` | no | `43124` | Port to bind. `0` picks a free port. |
73
+
74
+ Flags override environment variables. Secrets are accepted only from the
75
+ environment, because command-line flags are visible to every local user in
76
+ the process list. `--service-token` and `--mcp-token` are refused.
77
+
78
+ `botmancers --help` lists the options. `botmancers --version` prints the
79
+ installed version.
80
+
81
+ ### Endpoints
82
+
83
+ - `POST /mcp` is MCP Streamable HTTP. It is stateless and answers with
84
+ `application/json`, using protocol versions `2025-11-25`, `2025-06-18`, and
85
+ `2025-03-26`. Every request needs `Authorization: Bearer <BOTMANCERS_MCP_TOKEN>`.
86
+ `GET` and `DELETE` return 405 because the bridge opens no SSE stream and
87
+ issues no session. Bodies over 256 KiB are refused with 413.
88
+ - `GET /healthz` returns `{"status":"ok","contract":"botmancers.mcp.v1","version":"<version>"}`.
89
+ It needs no authentication and does not call Botmancers. Use the
90
+ `botmancers_health` tool to check the end-to-end path.
91
+
92
+ ### Startup checks and exit codes
93
+
94
+ Before listening, the bridge validates its configuration. It then sends
95
+ Botmancers an MCP `initialize` with the service token. That call writes
96
+ nothing and does not count against the rate limit. No error message contains
97
+ a token.
98
+
99
+ | Exit | Meaning | What to do |
100
+ | --- | --- | --- |
101
+ | `0` | Stopped by SIGINT or SIGTERM. | Nothing. |
102
+ | `2` | Configuration error: a missing or invalid variable or flag. Every problem is listed. | Fix the listed settings. |
103
+ | `3` | Cannot listen, for example because the port is already in use. | Stop the other process, or choose another `--port`. |
104
+ | `4` | Botmancers is unreachable, or `BOTMANCERS_URL` is not a Botmancers MCP endpoint (the server is not named `botmancers` or lacks a `botmancers.mcp.v1` tool). | Start the app, or fix the URL. |
105
+ | `5` | Botmancers rejected the service token, or the app has no service tokens configured. | Check `BOTMANCERS_SERVICE_TOKEN` against the app's `BOTMANCERS_SERVICE_TOKENS`. |
106
+
107
+ If Botmancers becomes unavailable after startup, tool calls return a JSON-RPC
108
+ error (`-32603`) whose `data.code` is `botmancers_unreachable`,
109
+ `botmancers_unauthorized`, or a similar code. The bridge keeps running. When
110
+ Botmancers rate-limits a tool call, the bridge answers 429 and passes on
111
+ `Retry-After`.
112
+
113
+ ## Network exposure
114
+
115
+ - **Loopback by default.** The bridge binds `127.0.0.1`. While it is bound to
116
+ loopback, it refuses any request whose `Host` is not a loopback name, and
117
+ any `Origin` other than its own. This guards against DNS rebinding. The
118
+ bridge does not support CORS, so browser pages cannot call it.
119
+ - **Remote MCP consumers.** A hosted agent such as Grok Bot cannot reach your
120
+ loopback address. Put the bridge behind an HTTPS endpoint that you control,
121
+ such as a TLS-terminating reverse proxy or tunnel, or reach it over a
122
+ private network such as a VPN or tailnet. Do not expose it as plain HTTP on
123
+ the public internet: the MCP token would travel in cleartext. If you bind a
124
+ non-loopback address (`--host 0.0.0.0`), the bridge prints a warning. Keep
125
+ the proxy or network in front of it.
126
+ - **Botmancers itself** should stay on loopback, with the bridge on the same
127
+ machine. If `BOTMANCERS_URL` points to another host over plain `http://`,
128
+ the bridge warns that the service token is sent unencrypted. Use `https://`
129
+ in that case.
130
+ - Rotate a token by changing it and restarting the bridge. For the service
131
+ token, also update its digest in the app's `BOTMANCERS_SERVICE_TOKENS`.
132
+
133
+ ## Upgrade
134
+
135
+ `npx botmancers` resolves the newest release on the `latest` tag the first
136
+ time. After that, npx may reuse its cached copy. To move deliberately, name
137
+ the version:
138
+
139
+ ```sh
140
+ npx botmancers@0.1.0 --version
141
+ ```
142
+
143
+ To upgrade a project-local or global install:
144
+
145
+ ```sh
146
+ npm install botmancers@latest # project-local
147
+ npm install -g botmancers@latest # global
148
+ ```
149
+
150
+ Check the [changelog](CHANGELOG.md) before upgrading across a minor version
151
+ while the package is `0.x`. The tool contract version, `botmancers.mcp.v1`,
152
+ changes only with a major contract revision.
153
+
154
+ ## Uninstall
155
+
156
+ Stop the bridge with Ctrl-C or SIGTERM, then remove the package:
157
+
158
+ ```sh
159
+ npm uninstall botmancers # project-local
160
+ npm uninstall -g botmancers # global
161
+ npm cache clean --force # optional: drops npx's cached copy
162
+ ```
163
+
164
+ Finally, remove the bridge's entry from the app's `BOTMANCERS_SERVICE_TOKENS`
165
+ so its token stops working. The bridge stores no files of its own.
166
+
167
+ ## Library use
168
+
169
+ The package also exports the bridge for embedding:
170
+
171
+ ```js
172
+ import { createBridge, parseCli } from 'botmancers';
173
+
174
+ const command = parseCli([], process.env);
175
+ if (command.kind === 'serve') {
176
+ const bridge = createBridge({ config: command.config });
177
+ await bridge.upstream.preflight();
178
+ const address = await bridge.listen();
179
+ }
180
+ ```
181
+
182
+ ## License
183
+
184
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ export declare const EXIT: {
3
+ readonly ok: 0;
4
+ readonly config: 2;
5
+ readonly listen: 3;
6
+ readonly unavailable: 4;
7
+ readonly credentials: 5;
8
+ };
9
+ export interface CliIo {
10
+ stdout: (text: string) => void;
11
+ stderr: (text: string) => void;
12
+ }
13
+ export declare function main(argv: string[], env: NodeJS.ProcessEnv, io?: CliIo): Promise<number | 'serving'>;
package/dist/cli.js ADDED
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ // `npx botmancers`: validate configuration, check that Botmancers is reachable
3
+ // and accepts the service token, then serve the MCP bridge until a signal.
4
+ import { pathToFileURL } from 'node:url';
5
+ import { realpathSync } from 'node:fs';
6
+ import { ConfigError, ENV, HELP, isLoopbackHost, parseCli } from './config.js';
7
+ import { packageVersion } from './contract.js';
8
+ import { createRedactor } from './redact.js';
9
+ import { HEALTH_PATH, MCP_PATH, createBridge } from './server.js';
10
+ import { UpstreamError } from './upstream.js';
11
+ export const EXIT = { ok: 0, config: 2, listen: 3, unavailable: 4, credentials: 5 };
12
+ const processIo = {
13
+ stdout: text => process.stdout.write(text),
14
+ stderr: text => process.stderr.write(text),
15
+ };
16
+ function urlHost(url) {
17
+ return new URL(url).hostname;
18
+ }
19
+ export async function main(argv, env, io = processIo) {
20
+ // Redact whatever secrets the environment holds before anything is printed.
21
+ const redact = createRedactor([env[ENV.serviceToken]?.trim(), env[ENV.mcpToken]?.trim()]);
22
+ const err = (line) => io.stderr(`${redact(line)}\n`);
23
+ let command;
24
+ try {
25
+ command = parseCli(argv, env);
26
+ }
27
+ catch (error) {
28
+ if (!(error instanceof ConfigError))
29
+ throw error;
30
+ err('botmancers: configuration error:');
31
+ for (const problem of error.problems)
32
+ err(` - ${problem}`);
33
+ err('Run botmancers --help for all options.');
34
+ return EXIT.config;
35
+ }
36
+ if (command.kind === 'help') {
37
+ io.stdout(HELP);
38
+ return EXIT.ok;
39
+ }
40
+ if (command.kind === 'version') {
41
+ io.stdout(`${packageVersion()}\n`);
42
+ return EXIT.ok;
43
+ }
44
+ const { config } = command;
45
+ const bridge = createBridge({ config, logger: { info: err, error: err } });
46
+ if (!isLoopbackHost(config.host)) {
47
+ err(`botmancers: warning: binding ${config.host}, not loopback. Expose the bridge only on a private network or behind an HTTPS (TLS-terminating) proxy.`);
48
+ }
49
+ if (config.botmancersUrl.startsWith('http://') && !isLoopbackHost(urlHost(config.botmancersUrl))) {
50
+ err(`botmancers: warning: ${ENV.url} uses plain http:// to a non-loopback host; the service token is sent unencrypted. Use https://.`);
51
+ }
52
+ try {
53
+ await bridge.upstream.preflight();
54
+ }
55
+ catch (error) {
56
+ if (!(error instanceof UpstreamError))
57
+ throw error;
58
+ err(`botmancers: ${error.message}`);
59
+ return error.kind === 'unauthorized' || error.kind === 'unconfigured' ? EXIT.credentials : EXIT.unavailable;
60
+ }
61
+ let address;
62
+ try {
63
+ address = await bridge.listen();
64
+ }
65
+ catch (error) {
66
+ const code = error.code;
67
+ if (code === 'EADDRINUSE') {
68
+ err(`botmancers: port ${config.port} on ${config.host} is already in use. Stop the other process or choose another port with --port or ${ENV.port}.`);
69
+ }
70
+ else if (code === 'EADDRNOTAVAIL') {
71
+ err(`botmancers: cannot bind ${config.host}: no such local address. Check --host or ${ENV.host}.`);
72
+ }
73
+ else if (code === 'EACCES') {
74
+ err(`botmancers: permission denied binding ${config.host}:${config.port}. Use a port above 1023.`);
75
+ }
76
+ else {
77
+ err(`botmancers: cannot listen on ${config.host}:${config.port} (${code ?? error.message}).`);
78
+ }
79
+ return EXIT.listen;
80
+ }
81
+ const shown = address.family === 'IPv6' ? `[${address.address}]` : address.address;
82
+ err(`botmancers ${packageVersion()}: MCP bridge listening on http://${shown}:${address.port}${MCP_PATH} (health: ${HEALTH_PATH}) -> ${config.botmancersUrl}`);
83
+ const stop = (signal) => {
84
+ err(`botmancers: ${signal} received, shutting down.`);
85
+ bridge.close().then(() => process.exit(EXIT.ok));
86
+ };
87
+ process.once('SIGINT', () => stop('SIGINT'));
88
+ process.once('SIGTERM', () => stop('SIGTERM'));
89
+ return 'serving';
90
+ }
91
+ function invokedDirectly() {
92
+ if (!process.argv[1])
93
+ return false;
94
+ try {
95
+ return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
96
+ }
97
+ catch {
98
+ return false;
99
+ }
100
+ }
101
+ if (invokedDirectly()) {
102
+ main(process.argv.slice(2), process.env).then(outcome => {
103
+ if (outcome !== 'serving')
104
+ process.exitCode = outcome;
105
+ }, error => {
106
+ const redact = createRedactor([process.env[ENV.serviceToken]?.trim(), process.env[ENV.mcpToken]?.trim()]);
107
+ process.stderr.write(`botmancers: unexpected error: ${redact(error?.message ?? String(error))}\n`);
108
+ process.exitCode = 1;
109
+ });
110
+ }
@@ -0,0 +1,36 @@
1
+ export declare const ENV: {
2
+ readonly url: "BOTMANCERS_URL";
3
+ readonly serviceToken: "BOTMANCERS_SERVICE_TOKEN";
4
+ readonly mcpToken: "BOTMANCERS_MCP_TOKEN";
5
+ readonly host: "BOTMANCERS_MCP_HOST";
6
+ readonly port: "BOTMANCERS_MCP_PORT";
7
+ };
8
+ export declare const DEFAULT_HOST = "127.0.0.1";
9
+ export declare const DEFAULT_PORT = 43124;
10
+ export declare const MIN_MCP_TOKEN_LENGTH = 16;
11
+ export interface BridgeConfig {
12
+ botmancersUrl: string;
13
+ serviceToken: string;
14
+ mcpToken: string;
15
+ host: string;
16
+ port: number;
17
+ }
18
+ export type CliCommand = {
19
+ kind: 'help';
20
+ } | {
21
+ kind: 'version';
22
+ } | {
23
+ kind: 'serve';
24
+ config: BridgeConfig;
25
+ };
26
+ export declare class ConfigError extends Error {
27
+ readonly problems: string[];
28
+ constructor(problems: string[]);
29
+ }
30
+ export declare function isLoopbackHost(host: string): boolean;
31
+ export declare function parseBotmancersUrl(raw: string): {
32
+ url?: string;
33
+ problem?: string;
34
+ };
35
+ export declare function parseCli(argv: string[], env: NodeJS.ProcessEnv): CliCommand;
36
+ export declare const HELP: string;
package/dist/config.js ADDED
@@ -0,0 +1,188 @@
1
+ // Bridge configuration: environment variables, with command-line flags for
2
+ // the non-secret settings. Secrets are accepted only from the environment
3
+ // (flags are visible to every local user through the process list).
4
+ import { isIP } from 'node:net';
5
+ export const ENV = {
6
+ url: 'BOTMANCERS_URL',
7
+ serviceToken: 'BOTMANCERS_SERVICE_TOKEN',
8
+ mcpToken: 'BOTMANCERS_MCP_TOKEN',
9
+ host: 'BOTMANCERS_MCP_HOST',
10
+ port: 'BOTMANCERS_MCP_PORT',
11
+ };
12
+ export const DEFAULT_HOST = '127.0.0.1';
13
+ export const DEFAULT_PORT = 43124;
14
+ export const MIN_MCP_TOKEN_LENGTH = 16;
15
+ export class ConfigError extends Error {
16
+ problems;
17
+ constructor(problems) {
18
+ super(problems.join('\n'));
19
+ this.name = 'ConfigError';
20
+ this.problems = problems;
21
+ }
22
+ }
23
+ const FLAGS = {
24
+ '--botmancers-url': 'url',
25
+ '--host': 'host',
26
+ '--port': 'port',
27
+ };
28
+ const SECRET_FLAGS = ['--service-token', '--mcp-token', '--token'];
29
+ export function isLoopbackHost(host) {
30
+ const bare = host.replace(/^\[|\]$/g, '').toLowerCase();
31
+ return bare === 'localhost' || bare === '::1' || /^127(?:\.\d{1,3}){3}$/.test(bare);
32
+ }
33
+ function validToken(value) {
34
+ return /^[\x21-\x7e]+$/.test(value) && value.length <= 4096;
35
+ }
36
+ export function parseBotmancersUrl(raw) {
37
+ let parsed;
38
+ try {
39
+ parsed = new URL(raw);
40
+ }
41
+ catch {
42
+ // Never echo the value: an operator may have pasted a token into it.
43
+ return { problem: `${ENV.url} is not a valid absolute URL. Expected something like http://127.0.0.1:43123.` };
44
+ }
45
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
46
+ return { problem: `${ENV.url} must use http:// or https:// (got ${parsed.protocol}//).` };
47
+ }
48
+ if (parsed.username || parsed.password) {
49
+ return { problem: `${ENV.url} must not contain credentials; put the service token in ${ENV.serviceToken} instead.` };
50
+ }
51
+ if (parsed.search || parsed.hash) {
52
+ return { problem: `${ENV.url} must not contain a query string or fragment.` };
53
+ }
54
+ return { url: `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}` };
55
+ }
56
+ function parsePort(raw, source) {
57
+ const trimmed = raw.trim();
58
+ const port = /^\d+$/.test(trimmed) ? Number(trimmed) : NaN;
59
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
60
+ // Never echo the value: a token pasted into the wrong setting must not be printed.
61
+ return { problem: `${source} must be an integer port between 0 and 65535 (default ${DEFAULT_PORT}, 0 for any free port).` };
62
+ }
63
+ return { port };
64
+ }
65
+ function parseHost(raw, source) {
66
+ const host = raw.trim();
67
+ if (isIP(host.replace(/^\[|\]$/g, '')) || /^[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$/.test(host)) {
68
+ return { host: host.replace(/^\[|\]$/g, '') };
69
+ }
70
+ return { problem: `${source} must be an IP address or hostname to bind (default ${DEFAULT_HOST}).` };
71
+ }
72
+ // Parses argv (without node and script) and env into a command. Throws
73
+ // ConfigError listing every problem, none of which contains a secret.
74
+ export function parseCli(argv, env) {
75
+ const fromFlags = {};
76
+ const problems = [];
77
+ for (let i = 0; i < argv.length; i++) {
78
+ const arg = argv[i];
79
+ if (arg === '-h' || arg === '--help')
80
+ return { kind: 'help' };
81
+ if (arg === '-v' || arg === '--version')
82
+ return { kind: 'version' };
83
+ const eq = arg.indexOf('=');
84
+ const name = eq > 0 ? arg.slice(0, eq) : arg;
85
+ if (SECRET_FLAGS.includes(name)) {
86
+ problems.push(`${name} is not accepted: pass secrets only through ${ENV.serviceToken} and ${ENV.mcpToken} environment variables.`);
87
+ if (eq < 0)
88
+ i++;
89
+ continue;
90
+ }
91
+ const key = FLAGS[name];
92
+ if (!key) {
93
+ // Echo only flag-shaped names; a stray positional value may be a pasted token.
94
+ problems.push(/^--?[a-z][a-z0-9-]{0,38}$/.test(name)
95
+ ? `Unknown argument ${JSON.stringify(name)}. Run botmancers --help for usage.`
96
+ : 'Unexpected positional argument (value not shown). Run botmancers --help for usage.');
97
+ continue;
98
+ }
99
+ const value = eq > 0 ? arg.slice(eq + 1) : argv[++i];
100
+ if (value === undefined || value === '') {
101
+ problems.push(`${name} needs a value.`);
102
+ continue;
103
+ }
104
+ fromFlags[key] = value;
105
+ }
106
+ const pick = (key) => {
107
+ const value = fromFlags[key] ?? env[ENV[key]];
108
+ return value === undefined || value.trim() === '' ? undefined : value.trim();
109
+ };
110
+ const rawUrl = pick('url');
111
+ let botmancersUrl = '';
112
+ if (!rawUrl) {
113
+ problems.push(`${ENV.url} is required: the Botmancers base URL, for example http://127.0.0.1:43123 (or pass --botmancers-url).`);
114
+ }
115
+ else {
116
+ const { url, problem } = parseBotmancersUrl(rawUrl);
117
+ if (problem)
118
+ problems.push(problem);
119
+ else
120
+ botmancersUrl = url;
121
+ }
122
+ const serviceToken = env[ENV.serviceToken]?.trim() ?? '';
123
+ if (!serviceToken) {
124
+ problems.push(`${ENV.serviceToken} is required: the bearer token whose SHA-256 digest Botmancers lists in BOTMANCERS_SERVICE_TOKENS.`);
125
+ }
126
+ else if (!validToken(serviceToken)) {
127
+ problems.push(`${ENV.serviceToken} must be a single printable token without spaces.`);
128
+ }
129
+ const mcpToken = env[ENV.mcpToken]?.trim() ?? '';
130
+ if (!mcpToken) {
131
+ problems.push(`${ENV.mcpToken} is required: the bearer token MCP clients must send to this bridge (generate one with: openssl rand -hex 32).`);
132
+ }
133
+ else if (!validToken(mcpToken) || mcpToken.length < MIN_MCP_TOKEN_LENGTH) {
134
+ problems.push(`${ENV.mcpToken} must be a single printable token of at least ${MIN_MCP_TOKEN_LENGTH} characters without spaces.`);
135
+ }
136
+ else if (mcpToken === serviceToken) {
137
+ problems.push(`${ENV.mcpToken} must differ from ${ENV.serviceToken}: MCP clients must not hold the Botmancers service token.`);
138
+ }
139
+ let host = DEFAULT_HOST;
140
+ const rawHost = pick('host');
141
+ if (rawHost) {
142
+ const parsed = parseHost(rawHost, fromFlags.host ? '--host' : ENV.host);
143
+ if (parsed.problem)
144
+ problems.push(parsed.problem);
145
+ else
146
+ host = parsed.host;
147
+ }
148
+ let port = DEFAULT_PORT;
149
+ const rawPort = pick('port');
150
+ if (rawPort) {
151
+ const parsed = parsePort(rawPort, fromFlags.port ? '--port' : ENV.port);
152
+ if (parsed.problem)
153
+ problems.push(parsed.problem);
154
+ else
155
+ port = parsed.port;
156
+ }
157
+ if (problems.length)
158
+ throw new ConfigError(problems);
159
+ return { kind: 'serve', config: { botmancersUrl, serviceToken, mcpToken, host, port } };
160
+ }
161
+ export const HELP = `Usage: botmancers [options]
162
+
163
+ Run the Botmancers MCP bridge: a Streamable HTTP MCP server (contract
164
+ botmancers.mcp.v1) that forwards tool calls to a running Botmancers app.
165
+
166
+ Options:
167
+ --botmancers-url <url> Botmancers base URL (env ${ENV.url}, required)
168
+ --host <host> Address to bind (env ${ENV.host}, default ${DEFAULT_HOST})
169
+ --port <port> Port to bind, 0 for any free (env ${ENV.port}, default ${DEFAULT_PORT})
170
+ -h, --help Show this help
171
+ -v, --version Show the package version
172
+
173
+ Secrets (environment only, never flags):
174
+ ${ENV.serviceToken} Bearer token this bridge sends to Botmancers (required)
175
+ ${ENV.mcpToken} Bearer token MCP clients must send to this bridge (required, >= ${MIN_MCP_TOKEN_LENGTH} chars)
176
+
177
+ Endpoints:
178
+ POST /mcp MCP Streamable HTTP (Authorization: Bearer $${ENV.mcpToken})
179
+ GET /healthz Liveness (no authentication, no upstream call)
180
+
181
+ Example:
182
+ ${ENV.url}=http://127.0.0.1:43123 \\
183
+ ${ENV.serviceToken}=... ${ENV.mcpToken}=$(openssl rand -hex 32) \\
184
+ npx botmancers
185
+
186
+ Exit codes: 0 stopped by signal, 2 configuration, 3 cannot listen,
187
+ 4 Botmancers unreachable or incompatible, 5 credentials rejected.
188
+ `;
@@ -0,0 +1,21 @@
1
+ export declare const MCP_CONTRACT = "botmancers.mcp.v1";
2
+ export declare const MCP_PROTOCOL_VERSIONS: readonly ["2025-11-25", "2025-06-18", "2025-03-26"];
3
+ export declare const MCP_INSTRUCTIONS: string;
4
+ export interface McpTool {
5
+ name: string;
6
+ title?: string;
7
+ description?: string;
8
+ inputSchema: Record<string, unknown>;
9
+ outputSchema?: Record<string, unknown>;
10
+ annotations?: Record<string, unknown>;
11
+ _meta?: Record<string, unknown>;
12
+ }
13
+ export interface ToolContract {
14
+ contract: string;
15
+ tools: McpTool[];
16
+ }
17
+ export declare const TOOL_SCHEMA_URL: import("url").URL;
18
+ export declare function loadToolContract(): ToolContract;
19
+ export declare function negotiateProtocolVersion(requested: unknown): string;
20
+ export declare const PACKAGE_JSON_URL: import("url").URL;
21
+ export declare function packageVersion(): string;
@@ -0,0 +1,28 @@
1
+ // The botmancers.mcp.v1 tool contract, as captured by the Botmancers app in
2
+ // docs/contracts/mcp-bridge-v1.tools.json and shipped here as a versioned
3
+ // schema file. The bridge serves tools/list from it and forwards only these
4
+ // tool names to Botmancers; it never alters the contract.
5
+ import { readFileSync } from 'node:fs';
6
+ export const MCP_CONTRACT = 'botmancers.mcp.v1';
7
+ // Newest first; an unknown client version is answered with the newest.
8
+ // Same list as the Botmancers app's /api/v0/mcp endpoint.
9
+ export const MCP_PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26'];
10
+ export const MCP_INSTRUCTIONS = 'Botmancers bridge (botmancers.mcp.v1). Start a task with botmancers_start_task, then poll botmancers_get_request '
11
+ + 'until terminal and read botmancers_get_conversation; it stays active while the coordinator\'s follow-up work is open.';
12
+ export const TOOL_SCHEMA_URL = new URL('../schemas/botmancers.mcp.v1.tools.json', import.meta.url);
13
+ export function loadToolContract() {
14
+ const parsed = JSON.parse(readFileSync(TOOL_SCHEMA_URL, 'utf8'));
15
+ if (parsed.contract !== MCP_CONTRACT || !Array.isArray(parsed.tools)) {
16
+ throw new Error(`The bundled tool schema is not ${MCP_CONTRACT}; reinstall the botmancers package.`);
17
+ }
18
+ return parsed;
19
+ }
20
+ export function negotiateProtocolVersion(requested) {
21
+ return typeof requested === 'string' && MCP_PROTOCOL_VERSIONS.includes(requested)
22
+ ? requested
23
+ : MCP_PROTOCOL_VERSIONS[0];
24
+ }
25
+ export const PACKAGE_JSON_URL = new URL('../package.json', import.meta.url);
26
+ export function packageVersion() {
27
+ return JSON.parse(readFileSync(PACKAGE_JSON_URL, 'utf8')).version;
28
+ }
@@ -0,0 +1,6 @@
1
+ export { MCP_CONTRACT, MCP_PROTOCOL_VERSIONS, TOOL_SCHEMA_URL, loadToolContract, negotiateProtocolVersion, packageVersion, type McpTool, type ToolContract, } from './contract.js';
2
+ export { DEFAULT_HOST, DEFAULT_PORT, ENV, ConfigError, isLoopbackHost, parseBotmancersUrl, parseCli, type BridgeConfig, type CliCommand, } from './config.js';
3
+ export { createRedactor, REDACTED, type Redactor } from './redact.js';
4
+ export { UPSTREAM_PATH, UpstreamError, createUpstream, type Upstream, type UpstreamFailureKind } from './upstream.js';
5
+ export { HEALTH_PATH, MAX_BODY_BYTES, MCP_PATH, createBridge, type Bridge, type BridgeOptions, type Logger } from './server.js';
6
+ export { EXIT, main } from './cli.js';
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ // Library surface of the `botmancers` package: the botmancers.mcp.v1 MCP
2
+ // bridge. The `botmancers` binary (./cli) is the supported entry point; these
3
+ // exports let a Node program embed the same bridge.
4
+ export { MCP_CONTRACT, MCP_PROTOCOL_VERSIONS, TOOL_SCHEMA_URL, loadToolContract, negotiateProtocolVersion, packageVersion, } from './contract.js';
5
+ export { DEFAULT_HOST, DEFAULT_PORT, ENV, ConfigError, isLoopbackHost, parseBotmancersUrl, parseCli, } from './config.js';
6
+ export { createRedactor, REDACTED } from './redact.js';
7
+ export { UPSTREAM_PATH, UpstreamError, createUpstream } from './upstream.js';
8
+ export { HEALTH_PATH, MAX_BODY_BYTES, MCP_PATH, createBridge } from './server.js';
9
+ export { EXIT, main } from './cli.js';
@@ -0,0 +1,3 @@
1
+ export declare const REDACTED = "[REDACTED]";
2
+ export type Redactor = (text: string) => string;
3
+ export declare function createRedactor(secrets: Array<string | undefined>): Redactor;