From 6ecdab2d095745932de3a895ee2eef85ac7e9e6a Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Fri, 18 Sep 2026 13:48:01 +0100 Subject: [PATCH] Add an MCPB bundle for installing in Claude Desktop Claude Desktop's remote connector UI cannot send an API token header, so it could not talk to the server at all. The bundle wraps a dependency-free Node script that speaks stdio to Desktop and forwards each message verbatim over HTTPS with the token attached, so it gains no capabilities of its own. Packs to a 2.9 kB, three-file bundle. CI runs the proxy tests and attaches a build as an artifact. Prompt: Can you help me build a MCPB to allow our staff to access the admin MCP server? Also add details to the readme, including how to build and how to install Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 25 +++++ .gitignore | 1 + README.md | 78 ++++++++++++++ mcpb/.mcpbignore | 3 + mcpb/manifest.json | 64 ++++++++++++ mcpb/package.json | 14 +++ mcpb/server/index.js | 115 +++++++++++++++++++++ mcpb/test/proxy.test.js | 214 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 514 insertions(+) create mode 100644 mcpb/.mcpbignore create mode 100644 mcpb/manifest.json create mode 100644 mcpb/package.json create mode 100644 mcpb/server/index.js create mode 100644 mcpb/test/proxy.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a582846..b7bd51e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,3 +20,28 @@ jobs: - name: Run specs run: bundle exec rspec + + mcpb: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Run proxy tests + run: npm test + working-directory: mcpb + + - name: Pack the bundle + run: npx --yes @anthropic-ai/mcpb pack . ../activeadmin-mcp.mcpb + working-directory: mcpb + + - name: Upload the bundle + uses: actions/upload-artifact@v4 + with: + name: activeadmin-mcp-mcpb + path: activeadmin-mcp.mcpb diff --git a/.gitignore b/.gitignore index c4368ca..596f30e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ /tmp/ *.gem Gemfile.lock +*.mcpb diff --git a/README.md b/README.md index b0912b7..46b1301 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,84 @@ end } ``` +## Claude Desktop (MCPB bundle) + +Claude Code talks to the server over HTTP directly, but **Claude Desktop** cannot: +its remote connector UI has no way to send an API token header. The `mcpb/` +directory solves this with an +[MCP bundle](https://claude.com/docs/connectors/building/mcpb) — a `.mcpb` file +your colleagues install with a double-click. + +Inside the bundle is a small Node script that speaks stdio to Claude Desktop and +forwards every message, unchanged, to your server over HTTPS with the token +attached. It has no dependencies and adds no capabilities of its own, so the +tools it exposes are exactly the ones your server exposes. + +``` +Claude Desktop ──stdio──▶ mcpb proxy ──HTTPS + token──▶ your Rails app +``` + +### Building the bundle + +Requires Node 18 or newer. From the repository root: + +```bash +cd mcpb +npm test # no dependencies to install +npx @anthropic-ai/mcpb pack . ../activeadmin-mcp.mcpb +``` + +That writes `activeadmin-mcp.mcpb` (a zip of `manifest.json`, `package.json` and +`server/index.js`) to the repository root, ready to distribute. Bump `version` +in **both** `mcpb/manifest.json` and `mcpb/package.json` before packing a +release — Claude Desktop uses the manifest version to detect upgrades. + +CI packs the bundle on every push and attaches it as a build artifact, so you +can also download a build from the Actions tab rather than packing it yourself. + +Distribute the file however suits you: an internal file share, a GitHub release +asset, or an S3 bucket. Anyone with the file can install it, but it is inert +without a token. + +### Installing + +1. **Generate a token.** Sign in to your admin panel, go to **MCP Tokens**, name + the token after the machine you are installing on (e.g. "Claude Desktop — + work laptop") and copy it. It is shown only once. +2. **Install the bundle.** Double-click the `.mcpb` file, or drag it onto the + Claude Desktop window, or use **Settings → Extensions → Advanced settings → + Install Extension…**. +3. **Fill in the three settings** Claude Desktop prompts for: + + | Setting | Value | + |---------|-------| + | Server URL | The full MCP endpoint, e.g. `https://admin.example.com/admin/mcp` | + | API token | The token from step 1 (stored in the OS keychain, never shown again) | + | Authentication header | `Authorization`, unless your app sets a custom [`auth_header_name`](#custom-auth-header) | + +4. **Check it works.** Start a new chat and ask Claude to list the admin + resources it can see. You should get back the resources your account can read. + +Installation is per-person: each colleague installs the bundle and generates +their own token, so every MCP call is attributed to them and constrained by +their own admin permissions. + +### Revoking access + +A token is a long-lived credential granting everything that user can do in +admin. Revoke one from the same **MCP Tokens** page — the `Last used` column +shows which tokens are still live. Revoking takes effect immediately; the +installed bundle simply starts reporting an authentication failure. + +### Troubleshooting + +| Symptom | Cause | +|---------|-------| +| "The server rejected the API token (HTTP 401)" | The token is wrong, revoked, or being sent in the wrong header. Check the **Authentication header** setting matches your `auth_header_name`. | +| "Could not reach …" | The URL is wrong or unreachable from this machine — check VPN, and that the URL includes the full mount path. | +| The extension shows no tools | Claude Desktop only refreshes tools on connect. Toggle the extension off and on in Settings → Extensions. | +| Anything else | Claude Desktop's extension logs carry the proxy's stderr output, each line prefixed `[activeadmin_mcp]`. | + ## Configuration The generator writes an initializer to diff --git a/mcpb/.mcpbignore b/mcpb/.mcpbignore new file mode 100644 index 0000000..5e48052 --- /dev/null +++ b/mcpb/.mcpbignore @@ -0,0 +1,3 @@ +test/ +.mcpbignore +*.mcpb diff --git a/mcpb/manifest.json b/mcpb/manifest.json new file mode 100644 index 0000000..097d291 --- /dev/null +++ b/mcpb/manifest.json @@ -0,0 +1,64 @@ +{ + "manifest_version": "0.3", + "name": "activeadmin-mcp", + "display_name": "ActiveAdmin MCP", + "version": "0.0.3", + "description": "Query and update your ActiveAdmin data from Claude Desktop.", + "long_description": "Connects Claude Desktop to an application running the activeadmin_mcp Rails engine.\n\nThe tools available mirror the ActiveAdmin resources your account can already see: listing resources, querying them with Ransack syntax, and updating records through the same permitted parameters and authorisation rules as the admin UI. Nothing is exposed that you could not do yourself while signed in to the admin panel.\n\nAuthentication uses an API token you generate from the MCP Tokens page in your admin panel, and can be revoked there at any time.", + "author": { + "name": "OLIO", + "url": "https://olioex.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/OLIOEX/activeadmin_mcp.git" + }, + "homepage": "https://github.com/OLIOEX/activeadmin_mcp", + "documentation": "https://github.com/OLIOEX/activeadmin_mcp#claude-desktop-mcpb-bundle", + "support": "https://github.com/OLIOEX/activeadmin_mcp/issues", + "license": "MIT", + "keywords": ["activeadmin", "rails", "admin", "mcp"], + "server": { + "type": "node", + "entry_point": "server/index.js", + "mcp_config": { + "command": "node", + "args": ["${__dirname}/server/index.js"], + "env": { + "MCP_SERVER_URL": "${user_config.server_url}", + "MCP_API_TOKEN": "${user_config.api_token}", + "MCP_AUTH_HEADER": "${user_config.auth_header}" + } + } + }, + "tools_generated": true, + "compatibility": { + "claude_desktop": ">=0.10.0", + "platforms": ["darwin", "win32", "linux"], + "runtimes": { + "node": ">=18.0.0" + } + }, + "user_config": { + "server_url": { + "type": "string", + "title": "Server URL", + "description": "Full URL of the MCP endpoint, e.g. https://admin.example.com/admin/mcp", + "required": true + }, + "api_token": { + "type": "string", + "title": "API token", + "description": "Generate one from the MCP Tokens page in your admin panel. It is shown only once.", + "required": true, + "sensitive": true + }, + "auth_header": { + "type": "string", + "title": "Authentication header", + "description": "The header the server reads the token from. Leave as Authorization unless your application sets a custom auth_header_name.", + "required": true, + "default": "Authorization" + } + } +} diff --git a/mcpb/package.json b/mcpb/package.json new file mode 100644 index 0000000..799d7b4 --- /dev/null +++ b/mcpb/package.json @@ -0,0 +1,14 @@ +{ + "name": "activeadmin-mcp-mcpb", + "version": "0.0.3", + "private": true, + "description": "Claude Desktop bundle bridging stdio to an activeadmin_mcp HTTP server", + "type": "module", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "scripts": { + "test": "node --test" + } +} diff --git a/mcpb/server/index.js b/mcpb/server/index.js new file mode 100644 index 0000000..0407e98 --- /dev/null +++ b/mcpb/server/index.js @@ -0,0 +1,115 @@ +#!/usr/bin/env node + +// Bridges Claude Desktop's stdio transport to an activeadmin_mcp server's HTTP +// endpoint, injecting the API token on the way through. +// +// Every message is forwarded verbatim, so this proxy needs no knowledge of the +// MCP protocol and gains no new capabilities when the server does. +// +// stdout carries the protocol. Diagnostics go to stderr, never stdout. + +const SERVER_URL = process.env.MCP_SERVER_URL +const API_TOKEN = process.env.MCP_API_TOKEN +const AUTH_HEADER = process.env.MCP_AUTH_HEADER || "Authorization" + +const REQUEST_TIMEOUT_MS = 120_000 + +const PARSE_ERROR = -32_700 +const INTERNAL_ERROR = -32_603 + +function fail(message) { + log(message) + process.exit(1) +} + +function log(message) { + process.stderr.write(`[activeadmin_mcp] ${message}\n`) +} + +if (!SERVER_URL) fail("MCP_SERVER_URL is not set — check the extension's Server URL setting.") +if (!API_TOKEN) fail("MCP_API_TOKEN is not set — check the extension's API token setting.") + +function write(message) { + process.stdout.write(`${JSON.stringify(message)}\n`) +} + +// JSON-RPC forbids replying to a notification, which has no id to reply to. +function writeError(id, code, message) { + if (id === undefined || id === null) { + log(message) + return + } + write({ jsonrpc: "2.0", id, error: { code, message } }) +} + +function describeFailure(status, body) { + if (status === 401 || status === 403) { + return `The server rejected the API token (HTTP ${status}). Generate a new token in the admin panel and update the extension's settings.` + } + return `The server returned HTTP ${status}: ${body.slice(0, 500) || "(empty response)"}` +} + +async function forward(line) { + let id + try { + id = JSON.parse(line).id + } catch (error) { + write({ jsonrpc: "2.0", id: null, error: { code: PARSE_ERROR, message: error.message } }) + return + } + + let response + try { + response = await fetch(SERVER_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": "activeadmin_mcp-mcpb", + [AUTH_HEADER]: `Bearer ${API_TOKEN}`, + }, + body: line, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + } catch (error) { + writeError(id, INTERNAL_ERROR, `Could not reach ${SERVER_URL}: ${error.message}`) + return + } + + const body = await response.text() + + if (!response.ok) { + writeError(id, INTERNAL_ERROR, describeFailure(response.status, body)) + return + } + + // The server answers notifications with 204 No Content; there is nothing to relay. + if (!body.trim()) return + + process.stdout.write(body.endsWith("\n") ? body : `${body}\n`) +} + +let buffer = "" + +process.stdin.setEncoding("utf8") + +process.stdin.on("data", (chunk) => { + buffer += chunk + + let newline + while ((newline = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, newline).trim() + buffer = buffer.slice(newline + 1) + if (line) forward(line) + } +}) + +process.stdin.on("end", () => { + const line = buffer.trim() + if (line) forward(line) +}) + +process.stdout.on("error", (error) => { + if (error.code === "EPIPE") process.exit(0) + throw error +}) diff --git a/mcpb/test/proxy.test.js b/mcpb/test/proxy.test.js new file mode 100644 index 0000000..03392fb --- /dev/null +++ b/mcpb/test/proxy.test.js @@ -0,0 +1,214 @@ +import { after, before, beforeEach, describe, it } from "node:test" +import assert from "node:assert/strict" +import { spawn } from "node:child_process" +import http from "node:http" +import { once } from "node:events" +import { fileURLToPath } from "node:url" + +const SERVER = fileURLToPath(new URL("../server/index.js", import.meta.url)) + +let upstream +let upstreamUrl +let handler + +before(async () => { + upstream = http.createServer((req, res) => { + let body = "" + req.on("data", (chunk) => { body += chunk }) + req.on("end", () => handler(req, res, body)) + }) + upstream.listen(0, "127.0.0.1") + await once(upstream, "listening") + upstreamUrl = `http://127.0.0.1:${upstream.address().port}/admin/mcp` +}) + +after(() => upstream.close()) + +beforeEach(() => { + handler = (_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ jsonrpc: "2.0", id: 1, result: {} })) + } +}) + +// Drives the proxy the way Claude Desktop does: writes `chunks` to its stdin and +// resolves with the lines it wrote to stdout once it exits. +function run(chunks, env = {}) { + const child = spawn(process.execPath, [SERVER], { + env: { + ...process.env, + MCP_SERVER_URL: upstreamUrl, + MCP_API_TOKEN: "test-token", + ...env, + }, + stdio: ["pipe", "pipe", "pipe"], + }) + + let stdout = "" + let stderr = "" + child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk }) + child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk }) + + for (const chunk of chunks) child.stdin.write(chunk) + child.stdin.end() + + return once(child, "close").then(([code]) => ({ + code, + stderr, + lines: stdout.split("\n").filter(Boolean).map(JSON.parse), + })) +} + +describe("stdio to HTTP proxy", () => { + it("forwards the message and returns the upstream response", async () => { + let received + handler = (req, res, body) => { + received = { headers: req.headers, method: req.method, url: req.url, body } + res.writeHead(200, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ jsonrpc: "2.0", id: 7, result: { ok: true } })) + } + + const { lines } = await run(['{"jsonrpc":"2.0","id":7,"method":"tools/list"}\n']) + + assert.equal(received.method, "POST") + assert.equal(received.url, "/admin/mcp") + assert.equal(received.body, '{"jsonrpc":"2.0","id":7,"method":"tools/list"}') + assert.deepEqual(lines, [{ jsonrpc: "2.0", id: 7, result: { ok: true } }]) + }) + + it("injects the token into the standard Authorization header by default", async () => { + let headers + handler = (req, res, _body) => { + headers = req.headers + res.writeHead(200, { "Content-Type": "application/json" }) + res.end('{"jsonrpc":"2.0","id":1,"result":{}}') + } + + await run(['{"jsonrpc":"2.0","id":1,"method":"ping"}\n']) + + assert.equal(headers.authorization, "Bearer test-token") + }) + + it("injects the token into a custom header when one is configured", async () => { + let headers + handler = (req, res, _body) => { + headers = req.headers + res.writeHead(200, { "Content-Type": "application/json" }) + res.end('{"jsonrpc":"2.0","id":1,"result":{}}') + } + + await run( + ['{"jsonrpc":"2.0","id":1,"method":"ping"}\n'], + { MCP_AUTH_HEADER: "X-MCP-Authorization" }, + ) + + assert.equal(headers["x-mcp-authorization"], "Bearer test-token") + assert.equal(headers.authorization, undefined) + }) + + it("reassembles a message split across stdin chunks", async () => { + handler = (req, res, body) => { + res.writeHead(200, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ jsonrpc: "2.0", id: JSON.parse(body).id, result: {} })) + } + + const { lines } = await run(['{"jsonrpc":"2.0","id":', '7,"method":"ping"}', "\n"]) + + assert.deepEqual(lines, [{ jsonrpc: "2.0", id: 7, result: {} }]) + }) + + it("handles several messages arriving in a single chunk", async () => { + handler = (req, res, body) => { + res.writeHead(200, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ jsonrpc: "2.0", id: JSON.parse(body).id, result: {} })) + } + + const { lines } = await run([ + '{"jsonrpc":"2.0","id":1,"method":"ping"}\n{"jsonrpc":"2.0","id":2,"method":"ping"}\n', + ]) + + assert.deepEqual(lines.map((line) => line.id).sort(), [1, 2]) + }) + + it("writes nothing when the server answers a notification with 204 No Content", async () => { + handler = (_req, res) => res.writeHead(204).end() + + const { lines } = await run(['{"jsonrpc":"2.0","method":"notifications/initialized"}\n']) + + assert.deepEqual(lines, []) + }) + + it("returns a JSON-RPC error carrying the original id when the server rejects the token", async () => { + handler = (_req, res) => { + res.writeHead(401, { "Content-Type": "application/json" }) + res.end('{"jsonrpc":"2.0","id":null,"error":{"code":-32000,"message":"Unauthorized"}}') + } + + const { lines } = await run(['{"jsonrpc":"2.0","id":9,"method":"tools/list"}\n']) + + assert.equal(lines.length, 1) + assert.equal(lines[0].id, 9) + assert.match(lines[0].error.message, /token/i) + }) + + it("returns a JSON-RPC error when the server is unreachable", async () => { + const { lines } = await run( + ['{"jsonrpc":"2.0","id":3,"method":"tools/list"}\n'], + { MCP_SERVER_URL: "http://127.0.0.1:1/admin/mcp" }, + ) + + assert.equal(lines.length, 1) + assert.equal(lines[0].id, 3) + assert.equal(lines[0].error.code, -32603) + }) + + it("returns a JSON-RPC error when the server returns a non-JSON body", async () => { + handler = (_req, res) => { + res.writeHead(502, { "Content-Type": "text/html" }) + res.end("502 Bad Gateway") + } + + const { lines } = await run(['{"jsonrpc":"2.0","id":4,"method":"tools/list"}\n']) + + assert.equal(lines[0].id, 4) + assert.match(lines[0].error.message, /502/) + }) + + it("logs to stderr rather than answering a failed notification, which has no id to answer", async () => { + const { lines, stderr } = await run( + ['{"jsonrpc":"2.0","method":"notifications/initialized"}\n'], + { MCP_SERVER_URL: "http://127.0.0.1:1/admin/mcp" }, + ) + + assert.deepEqual(lines, []) + assert.match(stderr, /\[activeadmin_mcp\] Could not reach/) + }) + + it("reports a parse error without contacting the server", async () => { + let called = false + handler = (_req, res) => { + called = true + res.writeHead(204).end() + } + + const { lines } = await run(["not json\n"]) + + assert.equal(called, false) + assert.equal(lines[0].error.code, -32700) + assert.equal(lines[0].id, null) + }) + + it("exits with an explanation when the server URL is missing", async () => { + const { code, stderr } = await run([], { MCP_SERVER_URL: "" }) + + assert.equal(code, 1) + assert.match(stderr, /MCP_SERVER_URL/) + }) + + it("exits with an explanation when the token is missing", async () => { + const { code, stderr } = await run([], { MCP_API_TOKEN: "" }) + + assert.equal(code, 1) + assert.match(stderr, /MCP_API_TOKEN/) + }) +})