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/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
|
-
#
|
|
1
|
+
# botmancers
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -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;
|
package/dist/contract.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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';
|
package/dist/redact.d.ts
ADDED