@dougbots/avenor-mcp 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/README.md ADDED
@@ -0,0 +1,185 @@
1
+ # @dougbots/avenor-mcp
2
+
3
+ MCP server that makes [avenor](https://github.com/dougbots/avenor) agent runs available as tools inside any MCP-compatible client (Claude Code, Claude Desktop, Cursor, etc.).
4
+
5
+ ## When you need this
6
+
7
+ You have `avenor` installed and you want Claude (or another MCP host) to spawn and manage agent runs — kicking off a Codex or Gemini session, checking its status, answering permission prompts, tailing event logs, and resuming completed runs with a follow-up.
8
+
9
+ This package is the MCP layer. It depends on `@dougbots/avenor-core` for all the actual wiring (supervisor lifecycle, binary discovery, socket protocol). You don't need to know the internals to use it.
10
+
11
+ ## Prerequisites
12
+
13
+ - **Node 20+** (or Bun — the server runs on Bun but is shipped as ESM)
14
+ - **`avenor` binary** on your `PATH`, or set `AVENOR_BIN=/path/to/avenor`
15
+
16
+ If the binary isn't found at startup, the MCP server will fail to connect.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npm install @dougbots/avenor-mcp
22
+ ```
23
+
24
+ Or run it on-demand with `npx` (see configuration below).
25
+
26
+ ## Configuration
27
+
28
+ ### stdio transport (default)
29
+
30
+ Add to your MCP host config. For **Claude Code**, edit `~/.claude/mcp.json`:
31
+
32
+ ```json
33
+ {
34
+ "mcpServers": {
35
+ "avenor": {
36
+ "command": "npx",
37
+ "args": ["@dougbots/avenor-mcp"],
38
+ "env": {
39
+ "AVENOR_BIN": "/opt/homebrew/bin/avenor"
40
+ }
41
+ }
42
+ }
43
+ }
44
+ ```
45
+
46
+ For **Claude Desktop**, use `~/.config/Claude/claude_desktop_config.json` with the same shape.
47
+
48
+ Skip `AVENOR_BIN` if `avenor` is already on your `PATH`. On a fresh session, the supervisor auto-starts the `avenor` daemon the first time you call a tool.
49
+
50
+ ### SSE transport
51
+
52
+ When you need the MCP server running as a long-lived HTTP service (multiple clients, remote access via tunneling, etc.), use SSE mode. Set `MCP_TRANSPORT=sse` and provide an auth token:
53
+
54
+ ```bash
55
+ MCP_TRANSPORT=sse \
56
+ MCP_AUTH_TOKEN="my-secret" \
57
+ MCP_PORT=3748 \
58
+ npx @dougbots/avenor-mcp
59
+ ```
60
+
61
+ Then in your MCP host config:
62
+
63
+ ```json
64
+ {
65
+ "mcpServers": {
66
+ "avenor": {
67
+ "url": "http://127.0.0.1:3748/mcp",
68
+ "headers": {
69
+ "Authorization": "Bearer my-secret"
70
+ }
71
+ }
72
+ }
73
+ }
74
+ ```
75
+
76
+ SSE mode restricts incoming requests to `localhost` / `127.0.0.1` / `[::1]` origins. The auth token is required — the server returns `401` without it.
77
+
78
+ | Env variable | Default | Notes |
79
+ |---|---|---|
80
+ | `MCP_TRANSPORT` | (unset → stdio) | Set to `sse` for HTTP |
81
+ | `MCP_PORT` | `3748` | Only used in SSE mode |
82
+ | `MCP_AUTH_TOKEN` | — | **Required** in SSE mode |
83
+ | `AVENOR_BIN` | — | Path to the `avenor` binary (falls back to `PATH`) |
84
+
85
+ ## Tools
86
+
87
+ All six tools register under the `avenor_` prefix to avoid collisions with other MCP servers.
88
+
89
+ ### `avenor_spawn`
90
+
91
+ Start a new agent run.
92
+
93
+ | Parameter | Required | Description |
94
+ |---|---|---|
95
+ | `agent` | yes | Agent to invoke: `codex`, `claude`, `gemini`, `butler` |
96
+ | `repo_dir` | yes | Working directory for the agent |
97
+ | `prompt` | no | Prompt text (mutually exclusive with `prompt_file`) |
98
+ | `prompt_file` | no | Path to a prompt file |
99
+ | `label` | no | Human-readable label for this run |
100
+ | `timeout` | no | Duration string (`5m`, `1h`, `90s`) |
101
+ | `model` | no | Model override for the agent |
102
+ | `supervisor_id` | no | Supervisor ID for multi-supervisor setups |
103
+
104
+ Returns `{ run_id, label, supervisor_id }`. The `run_id` is what you pass to the other tools.
105
+
106
+ ### `avenor_status`
107
+
108
+ Get the status of one run, or list all runs.
109
+
110
+ | Parameter | Required | Description |
111
+ |---|---|---|
112
+ | `run_id` | no | Run ID or label to query. Omit to list all runs. |
113
+ | `supervisor_id` | no | Supervisor ID for multi-supervisor setups |
114
+
115
+ Returns a status object (or array) with `status` being one of `running`, `done`, `failed`, `timeout`, or `killed`. When a run is blocked on a permission request, the response includes a `pending_permission` field with the available options.
116
+
117
+ ### `avenor_answer_permission`
118
+
119
+ Respond to a permission prompt that paused a run.
120
+
121
+ | Parameter | Required | Description |
122
+ |---|---|---|
123
+ | `run_id` | yes | Run ID or label |
124
+ | `option_id` | yes | The option to select (from the `pending_permission` shown by `avenor_status`) |
125
+ | `request_id` | no | Specific permission request ID. Auto-detected from the pending request if omitted. |
126
+ | `supervisor_id` | no | Supervisor ID for multi-supervisor setups |
127
+
128
+ Returns `{ ok: true }` on success. Throws if no permission request is pending for the given run.
129
+
130
+ ### `avenor_follow_up`
131
+
132
+ Resume a completed run with a new message. The agent picks up from the same session, so it retains context from the original run.
133
+
134
+ | Parameter | Required | Description |
135
+ |---|---|---|
136
+ | `run_id` | yes | Run ID or label of the *completed* run to resume |
137
+ | `message` | yes | Follow-up prompt |
138
+ | `label` | no | Label for the follow-up run (defaults to `<original-label>-followup`) |
139
+ | `supervisor_id` | no | Supervisor ID for multi-supervisor setups |
140
+
141
+ Returns `{ run_id, label }` for the new follow-up run. Throws if the original run has no persisted session.
142
+
143
+ ### `avenor_events`
144
+
145
+ Fetch the event log for a run. Useful for inspecting what an agent did.
146
+
147
+ | Parameter | Required | Description |
148
+ |---|---|---|
149
+ | `run_id` | yes | Run ID or label |
150
+ | `types` | no | Filter by event types (array of strings) |
151
+ | `limit` | no | Max events to return (default `50`, max `1000`) |
152
+ | `supervisor_id` | no | Supervisor ID for multi-supervisor setups |
153
+
154
+ Returns an array of event objects (`{ type, ts, ... }`) in chronological order.
155
+
156
+ ### `avenor_shutdown`
157
+
158
+ Stop the avenor supervisor and clean up sentinel files.
159
+
160
+ | Parameter | Required | Description |
161
+ |---|---|---|
162
+ | `supervisor_id` | no | Supervisor ID for multi-supervisor setups |
163
+ | `force` | no | Force shutdown instead of graceful (`false` by default) |
164
+
165
+ Returns `{ ok: true, cleaned_up: [...] }`. Use this when you're done with avenor in your session or when the supervisor crashes and needs to be restarted.
166
+
167
+ ## Typical workflow
168
+
169
+ 1. **Spawn a run:** `avenor_spawn(agent: "codex", repo_dir: "/path/to/project", prompt: "audit the API routes for auth gaps")`
170
+ 2. **Check status:** `avenor_status(run_id: "<id>")` — repeat until status is `done` or an action is needed.
171
+ 3. **If permission requested:** run `avenor_answer_permission` with the option you want.
172
+ 4. **After completion:** `avenor_events(run_id: "<id>")` to see what happened.
173
+ 5. **Optional follow-up:** `avenor_follow_up(run_id: "<id>", message: "now write tests for that")`
174
+ 6. **Clean up:** `avenor_shutdown()` when you're finished.
175
+
176
+ ## Package structure
177
+
178
+ ```
179
+ @dougbots/avenor-mcp
180
+ ├── @dougbots/avenor-core (workspace dep — supervisor, tool implementations)
181
+ ├── @modelcontextprotocol/sdk (MCP server + transports)
182
+ └── hono (HTTP framework for SSE transport)
183
+ ```
184
+
185
+ The server is a single file (`src/mcp.ts`), compiled to `dist/index.js` by tsdown. The `avenor-mcp` bin entry runs it directly.
@@ -0,0 +1,7 @@
1
+ //#region src/mcp.d.ts
2
+ declare function parseBearerToken(authorizationHeader: string | null | undefined): string | null;
3
+ declare function isAllowedOrigin(originHeader: string | null | undefined): boolean;
4
+ declare function isAllowedHost(hostHeader: string | null | undefined, port: number): boolean;
5
+ declare function getMcpAuthToken(): string;
6
+ //#endregion
7
+ export { getMcpAuthToken, isAllowedHost, isAllowedOrigin, parseBearerToken };
package/dist/index.js ADDED
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env bun
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
+ import { Hono } from "hono";
6
+ import { z } from "zod";
7
+ import { answerPermissionTool, eventsTool, followUpTool, shutdownTool, spawnTool, statusTool } from "@dougbots/avenor-core";
8
+ //#region src/mcp.ts
9
+ const LOCALHOST_ORIGINS = new Set([
10
+ "localhost",
11
+ "127.0.0.1",
12
+ "[::1]"
13
+ ]);
14
+ function parseBearerToken(authorizationHeader) {
15
+ if (!authorizationHeader) return null;
16
+ return authorizationHeader.match(/^Bearer\s+(.+)$/i)?.[1]?.trim() || null;
17
+ }
18
+ function isAllowedOrigin(originHeader) {
19
+ if (!originHeader) return true;
20
+ try {
21
+ const origin = new URL(originHeader);
22
+ return origin.protocol === "http:" && LOCALHOST_ORIGINS.has(origin.hostname);
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+ function isAllowedHost(hostHeader, port) {
28
+ if (!hostHeader) return false;
29
+ const [host, hostPort] = hostHeader.startsWith("[") ? (() => {
30
+ const end = hostHeader.indexOf("]");
31
+ if (end === -1) return [hostHeader, void 0];
32
+ const host = hostHeader.slice(0, end + 1);
33
+ const portPart = hostHeader.slice(end + 1);
34
+ return [host, portPart.startsWith(":") ? portPart.slice(1) : void 0];
35
+ })() : hostHeader.split(":");
36
+ if (!LOCALHOST_ORIGINS.has(host)) return false;
37
+ return hostPort === String(port);
38
+ }
39
+ function getMcpAuthToken() {
40
+ const token = process.env.MCP_AUTH_TOKEN?.trim();
41
+ if (!token) throw new Error("MCP_TRANSPORT=sse requires MCP_AUTH_TOKEN to be set");
42
+ return token;
43
+ }
44
+ const server = new McpServer({
45
+ name: "avenor",
46
+ version: "0.1.0"
47
+ });
48
+ server.registerTool("avenor_spawn", {
49
+ description: "Spawn a new agent run",
50
+ inputSchema: {
51
+ agent: z.string().describe("Agent name (codex, claude, gemini, butler)"),
52
+ repo_dir: z.string().describe("Working directory for the agent"),
53
+ prompt: z.string().optional().describe("Prompt text"),
54
+ prompt_file: z.string().optional().describe("Path to a prompt file"),
55
+ label: z.string().optional().describe("Human-readable label for this run"),
56
+ timeout: z.string().optional().describe("Timeout duration (e.g. 5m, 1h)"),
57
+ model: z.string().optional().describe("Model override"),
58
+ supervisor_id: z.string().optional().describe("Supervisor ID for multi-supervisor mode")
59
+ }
60
+ }, async ({ repo_dir, agent, prompt, prompt_file, label, timeout, model, supervisor_id }) => {
61
+ return spawnTool({
62
+ agent,
63
+ dir: repo_dir,
64
+ prompt,
65
+ promptFile: prompt_file,
66
+ label,
67
+ timeout,
68
+ model,
69
+ supervisorId: supervisor_id
70
+ });
71
+ });
72
+ server.registerTool("avenor_status", {
73
+ description: "Get status of one or all runs",
74
+ inputSchema: {
75
+ run_id: z.string().optional().describe("Run ID or label to query"),
76
+ supervisor_id: z.string().optional().describe("Supervisor ID for multi-supervisor mode")
77
+ }
78
+ }, async ({ run_id, supervisor_id }) => {
79
+ return statusTool({
80
+ runId: run_id,
81
+ supervisorId: supervisor_id
82
+ });
83
+ });
84
+ server.registerTool("avenor_answer_permission", {
85
+ description: "Answer a pending permission request",
86
+ inputSchema: {
87
+ run_id: z.string().describe("Run ID or label"),
88
+ option_id: z.string().describe("Option ID to select"),
89
+ request_id: z.string().optional().describe("Permission request ID (auto-detected if omitted)"),
90
+ supervisor_id: z.string().optional().describe("Supervisor ID for multi-supervisor mode")
91
+ }
92
+ }, async ({ run_id, option_id, request_id, supervisor_id }) => {
93
+ return answerPermissionTool({
94
+ runId: run_id,
95
+ optionId: option_id,
96
+ requestId: request_id,
97
+ supervisorId: supervisor_id
98
+ });
99
+ });
100
+ server.registerTool("avenor_follow_up", {
101
+ description: "Resume a completed run with a follow-up message",
102
+ inputSchema: {
103
+ run_id: z.string().describe("Run ID or label to follow up on"),
104
+ message: z.string().describe("Follow-up message"),
105
+ label: z.string().optional().describe("Label for the follow-up run"),
106
+ supervisor_id: z.string().optional().describe("Supervisor ID for multi-supervisor mode")
107
+ }
108
+ }, async ({ run_id, message, label, supervisor_id }) => {
109
+ return followUpTool({
110
+ runId: run_id,
111
+ message,
112
+ label,
113
+ supervisorId: supervisor_id
114
+ });
115
+ });
116
+ server.registerTool("avenor_events", {
117
+ description: "Get event log for a run",
118
+ inputSchema: {
119
+ run_id: z.string().describe("Run ID or label"),
120
+ types: z.array(z.string()).optional().describe("Filter by event types"),
121
+ limit: z.number().optional().describe("Max events to return (default 50)"),
122
+ supervisor_id: z.string().optional().describe("Supervisor ID for multi-supervisor mode")
123
+ }
124
+ }, async ({ run_id, types, limit, supervisor_id }) => {
125
+ return eventsTool({
126
+ runId: run_id,
127
+ types,
128
+ limit,
129
+ supervisorId: supervisor_id
130
+ });
131
+ });
132
+ server.registerTool("avenor_shutdown", {
133
+ description: "Shut down the supervisor",
134
+ inputSchema: {
135
+ supervisor_id: z.string().optional().describe("Supervisor ID for multi-supervisor mode"),
136
+ force: z.boolean().optional().describe("Force shutdown instead of graceful")
137
+ }
138
+ }, async ({ supervisor_id, force }) => {
139
+ return shutdownTool({
140
+ supervisorId: supervisor_id,
141
+ force
142
+ });
143
+ });
144
+ if (import.meta.main) if (process.env.MCP_TRANSPORT !== "sse") {
145
+ const transport = new StdioServerTransport();
146
+ await server.connect(transport);
147
+ } else {
148
+ const port = Number(process.env.MCP_PORT ?? 3748);
149
+ const authToken = getMcpAuthToken();
150
+ const app = new Hono();
151
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID() });
152
+ app.all("/mcp", (c) => {
153
+ const request = c.req.raw;
154
+ const originHeader = request.headers.get("origin");
155
+ const hostHeader = request.headers.get("host");
156
+ const token = parseBearerToken(request.headers.get("authorization"));
157
+ if (!isAllowedOrigin(originHeader)) return c.text("Forbidden origin", 403);
158
+ if (!isAllowedHost(hostHeader, port)) return c.text("Forbidden host", 403);
159
+ if (token !== authToken) return c.text("Unauthorized", 401, { "WWW-Authenticate": "Bearer" });
160
+ return transport.handleRequest(c.req.raw, c.res, c.req.raw.body);
161
+ });
162
+ await server.connect(transport);
163
+ Bun.serve({
164
+ fetch: app.fetch,
165
+ port,
166
+ hostname: "127.0.0.1"
167
+ });
168
+ console.error(`avenor MCP server listening on :${port}`);
169
+ }
170
+ //#endregion
171
+ export { getMcpAuthToken, isAllowedHost, isAllowedOrigin, parseBearerToken };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@dougbots/avenor-mcp",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "files": [
6
+ "dist"
7
+ ],
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ }
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "bin": {
20
+ "avenor-mcp": "./dist/index.js"
21
+ },
22
+ "scripts": {
23
+ "build": "tsdown",
24
+ "test": "bun test",
25
+ "dev": "tsdown --watch"
26
+ },
27
+ "dependencies": {
28
+ "@dougbots/avenor-core": "workspace:*",
29
+ "@modelcontextprotocol/sdk": "^1.29.0",
30
+ "hono": "^4.12.19",
31
+ "zod": "^4.4.3"
32
+ },
33
+ "devDependencies": {
34
+ "@types/bun": "^1.3.12",
35
+ "@types/node": "^25.8.0",
36
+ "tsdown": "^0.22.0",
37
+ "typescript": "^6.0.3",
38
+ "unrun": "^0.3.0"
39
+ }
40
+ }