Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
/tmp/
*.gem
Gemfile.lock
*.mcpb
78 changes: 78 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions mcpb/.mcpbignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
test/
.mcpbignore
*.mcpb
64 changes: 64 additions & 0 deletions mcpb/manifest.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
14 changes: 14 additions & 0 deletions mcpb/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
115 changes: 115 additions & 0 deletions mcpb/server/index.js
Original file line number Diff line number Diff line change
@@ -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
})
Loading
Loading