Railyard MCP Server
by railyard-sh
README.md
# Railyard MCP server
A [Model Context Protocol](https://modelcontextprotocol.io) server that gives an MCP client
(Claude Desktop, Claude Code, or any other) **read and write** access to your Railyard
projects and organisations. It talks to a running Railyard backend over its REST API and
authenticates with a **personal access token** (PAT).
It speaks MCP over **stdio** and is written in TypeScript against the official
`@modelcontextprotocol/sdk`.
> **Just want to install it?** Jump to [Install](#install) below, or follow the
> standalone [INSTALL.md](./INSTALL.md) — get a token, paste one config block, verify
> with `whoami`.
---
## What it can do
**Projects**
| Tool | Kind | Description |
| --- | --- | --- |
| `list_projects` | read | Projects in an org (id, name, slug, updated-at). |
| `get_project` | read | A project's full JSON document, by id **or** slug. |
| `check_project_name` | read | Whether a name is free in an org (and the slug it would get). |
| `create_project` | write | Create a new, empty project and save it. |
| `update_project` | write · **destructive** | Save a project via full-document PUT. Merges partial fields by default; can replace the whole document. |
| `rename_project` | write | Change a project's name + URL slug. |
| `delete_project` | write · **destructive** | Permanently delete a project. No undo. |
| `move_project` | write | Move a project into another org you can write to, optionally renaming it in the same step. |
**Validation & export** — these operate on a document, so each takes either a saved project
(`ref`) or an inline `project` you have not saved yet. Neither changes anything stored.
| Tool | Kind | Description |
| --- | --- | --- |
| `validate_project` | read | Design problems: out-of-bounds placements, overlaps on a face, racks with no data centre. Same check the app shows. |
| `list_export_formats` | read | The export targets this build supports (`nautobot-csv`, `netbox-csv`, `designbuilder-yaml`, `json`). |
| `export_project` | read | Render a project into a format and return the files' content, unresolved placements and warnings. |
**Organisations, members & billing**
| Tool | Kind | Description |
| --- | --- | --- |
| `whoami` | read | The user your token authenticates as (id, email, name). |
| `server_info` | read | Server health: version, schema version, and whether persistence, auth and billing are configured. |
| `list_orgs` | read | Organisations you belong to — id, slug, role, plan, billing status. |
| `create_org` | write | Create a shared org; you become its owner. |
| `rename_org` | write · owner | Change an org's display name. |
| `delete_org` | write · **destructive** · owner | Delete a shared org **and every project in it**. No undo. |
| `get_org_catalog` | read | The org's shared device-type library. |
| `set_org_catalog` | write · **destructive** | Replace that library wholesale (not a merge). |
| `list_members` | read | Roster: user id, email, role, joined-at. |
| `set_member_role` | write · owner | Change a member's role. |
| `remove_member` | write · **destructive** · owner | Remove a member and drop their live sessions. |
| `list_invites` | read · owner | An org's pending invitations. |
| `invite_member` | write · owner | Invite an email at a role (needs a current Team/Enterprise plan). |
| `revoke_invite` | write · owner | Withdraw a pending invitation. |
| `list_my_invites` | read | Invitations addressed to *your* email. |
| `accept_invite` | write | Accept one, joining that org. |
| `get_billing` | read | Plan, status, seats, trial/period end, and whether the org is currently entitled to edit. |
| `billing_manage_url` | write · owner | Mint a Stripe Checkout or Customer Portal URL to open in a browser. Creates a link only — it charges nothing. |
The destructive tools (`update_project`, `delete_project`, `delete_org`, `set_org_catalog`,
`remove_member`) are annotated with the MCP `destructiveHint`, so clients that surface tool
safety hints will flag them.
**Not exposed, deliberately.** Personal-access-token management, account deletion and the
starter-example claim are gated to an interactive browser session server-side — a token cannot
drive them (see [Auth model](#auth-model-why-a-pat)). The OAuth/magic-link routes and the Stripe
webhook are not client-callable. Live collaboration is a WebSocket protocol rather than
request/response, so it has no tool; see the caveat on concurrent edits below.
**Org selection.** Every org-scoped tool accepts an optional `org` argument (an org **id**,
**slug**, or **name**). When omitted it falls back to the `RAILYARD_ORG` environment variable,
and if that too is unset, to your first (personal) organisation. Slugs/names are resolved to
the org id the API needs (via `GET /api/orgs`) automatically.
---
## Setup
### 1. Requirements
- Node.js 18 or newer.
- A running Railyard backend **with persistence + auth enabled** (i.e. started with
`DATABASE_URL` set). The project/org API only exists in that mode. Note the base URL,
e.g. `http://localhost:8080`.
### 2. Mint a personal access token
1. Sign in to Railyard in your browser.
2. Go to **User settings → Personal access tokens**.
3. Create a token, give it a name, and **copy the `ry_…` secret** — it is shown **once**,
at creation. The server only stores its hash; you cannot retrieve it again.
Treat this secret like a password (see [Auth model](#auth-model-why-a-pat) below).
### 3. Install and build
Only needed to **run from source** (or to develop). If you install the published package
with `npx -y railyard-mcp`, skip this — npm fetches and builds it for you.
```bash
cd mcp
npm install
npm run build
```
This compiles `src/` to `dist/`. The entry point is `dist/index.js`.
### 4. Configure the environment
| Variable | Required | Meaning |
| --- | --- | --- |
| `RAILYARD_TOKEN` | **yes** | Your `ry_…` personal access token. |
| `RAILYARD_BASE_URL` | no | Railyard base URL. Defaults to `http://localhost:8080`. |
| `RAILYARD_ORG` | no | Default org (id or slug) for org-scoped tools. |
You can smoke-test it from a shell:
```bash
RAILYARD_TOKEN=ry_xxx RAILYARD_BASE_URL=http://localhost:8080 npm start
# (it waits on stdio for an MCP client; Ctrl-C to exit)
```
---
## Install
Pick your client and paste one config block. For a friendly step-by-step walkthrough
see the standalone [INSTALL.md](./INSTALL.md); the essentials are below.
**Two ways to run it:**
- **Published (recommended):** `npx -y railyard-mcp` downloads and runs the package on
demand — no clone, no build. Requires the package to be on npm (see
[For operators](#for-operators-publishing) if it isn't yet).
- **From source (works today):** run the built entry point directly with
`node /absolute/path/to/railyard-mcp/dist/index.js` after `npm install && npm run build`
in this repo (see [Setup](#setup)). Substitute that `command`/`args` in any snippet below.
All snippets set the **hosted** URL `https://railyard.sh`. For a self-hosted or local
backend, set `RAILYARD_BASE_URL` to your own URL (e.g. `http://localhost:8080`).
`RAILYARD_ORG` is optional — add it to pin a default organisation.
### Claude Desktop — one-click bundle (`.mcpb`)
The easiest path, no JSON. Open **Claude Desktop → Settings → Extensions**, then drag in
(or **Install extension**) the packaged `railyard-mcp.mcpb` bundle and fill in the token +
base URL fields it prompts for. The bundle is built from [`manifest.json`](./manifest.json)
— see [For operators](#for-operators-publishing).
### Claude Desktop — manual config
Add the server under `mcpServers` in `claude_desktop_config.json`:
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
```json
{
"mcpServers": {
"railyard": {
"command": "npx",
"args": ["-y", "railyard-mcp"],
"env": {
"RAILYARD_TOKEN": "ry_your_token_here",
"RAILYARD_BASE_URL": "https://railyard.sh",
"RAILYARD_ORG": "my-team-slug"
}
}
}
}
```
Restart Claude Desktop after editing. The Railyard tools then appear in the tools menu.
_From source:_ replace the two command lines with
`"command": "node", "args": ["/absolute/path/to/railyard-mcp/dist/index.js"]`.
### Claude Code
Register it in one command:
```bash
claude mcp add railyard \
--env RAILYARD_TOKEN=ry_your_token_here \
--env RAILYARD_BASE_URL=https://railyard.sh \
-- npx -y railyard-mcp
```
Check it with `claude mcp list`. Add `--scope project` to write a shared `.mcp.json`
instead of your user config (keep real tokens out of committed files). From source, swap
the trailing `-- npx -y railyard-mcp` for `-- node /absolute/path/to/railyard-mcp/dist/index.js`.
A project-level `.mcp.json` takes the same shape as the Claude Desktop block above.
### Cursor
Edit `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project), then enable
**railyard** under **Settings → MCP**:
```json
{
"mcpServers": {
"railyard": {
"command": "npx",
"args": ["-y", "railyard-mcp"],
"env": {
"RAILYARD_TOKEN": "ry_your_token_here",
"RAILYARD_BASE_URL": "https://railyard.sh"
}
}
}
}
```
### Any other stdio MCP client
Launch this command with the environment set; the client speaks MCP to it over stdio:
```
command: npx
args: ["-y", "railyard-mcp"]
env: RAILYARD_TOKEN=ry_your_token_here
RAILYARD_BASE_URL=https://railyard.sh
RAILYARD_ORG=my-team-slug # optional
```
> Prefer not to commit real tokens. Keep `RAILYARD_TOKEN` in a private/user-scoped config,
> or inject it from your environment rather than checking it into a shared config file.
### Verify
Run the **`whoami`** tool (or ask "who am I on Railyard?"). It returns your Railyard user
id, email and name — confirming the token and URL work. Then try **`list_projects`**.
---
## Auth model — why a PAT
**A personal access token is the right credential for an MCP server; a session cookie is not.**
- **Non-interactive.** An MCP server runs headless. It cannot complete an interactive
SSO/OAuth or magic-link sign-in to obtain a session cookie, and a copied cookie is a
short-lived, browser-bound artefact that expires and can't be rotated cleanly. A PAT is a
long-lived credential minted *for* programmatic use — exactly this case.
- **It's the backend's intended programmatic credential.** Railyard's API accepts
`Authorization: Bearer ry_…` on every org-scoped route as a first-class alternative to the
browser session cookie. This server sends that header on every request.
- **Safer blast radius by design.** Railyard deliberately gates *token management* itself
(creating or revoking PATs) behind an interactive **browser session only** — a PAT cannot
mint or revoke tokens. So even if this server's token leaked, an attacker could not use it
to create more tokens or lock you out of revoking it; you revoke it from the browser.
**What the token carries.** A PAT authenticates as **you**, across **all** your organisations,
with your full role in each. There are **no per-token scopes or expiry yet** — so:
- **Treat the token like a password.** Don't commit it, log it, or paste it into shared
configs. This server never writes the token to its logs.
- **Scope it operationally.** Only point this server at orgs you intend it to touch (set
`RAILYARD_ORG`, and be deliberate with write tools). Remember the token can still reach any
org you belong to if a tool call names one.
- **Rotate on suspicion.** If a token may be exposed, revoke it in **User settings →
Personal access tokens** and mint a new one. Revocation is immediate.
**Future hardening (not built yet):** per-token scopes (e.g. read-only, or org-restricted)
and configurable expiry would let you hand this server a narrower credential. Today a PAT is
all-or-nothing, which is why the guidance above matters.
---
## How org access & errors map
- **`X-Org-Id` header.** Project-scoped calls send the resolved org **id** in `X-Org-Id`; the
org-management routes carry it in the path instead. Either way the backend membership-checks
it and returns **403** if the token's user isn't a member.
- **Roles.** Reads need any membership. Project writes need **editor** or **owner** — a
**viewer** gets a 403. Managing the org itself (rename/delete, members, invitations, billing)
is **owner**-only.
- **Billing.** If an org's plan has lapsed it becomes read-only and writes return **402**.
Inviting members additionally needs a current **Team or Enterprise** plan (402 otherwise).
- **Errors are readable.** HTTP failures are surfaced as `isError` tool results with a plain
message, e.g. *"Forbidden (403): not a member of this organisation"*, *"Conflict (409): a
project with that name already exists"*, *"Authentication failed (401): …"*.
---
## Notes & caveats
- **`update_project` is a whole-document save.** The API's save endpoint is a `PUT` of the
entire project JSON. To make partial edits safe, `update_project` defaults to
`merge=true`: it fetches the current document and shallow-merges the top-level keys you
supply (so `{racks:[…]}` replaces only the racks). Pass `merge=false` to replace the whole
document, in which case you must provide a complete, valid project.
- **`set_org_catalog` is a whole-library write too.** It replaces the org's shared device-type
library; anything absent from the array you send is removed. Read it with `get_org_catalog`
first and send that back with your additions.
- **Live collaboration.** If a project is open in a live collaboration session in the app,
that session owns saving. A `PUT` from this server and the room's autosave can overwrite
each other. Prefer writing when no one has the project open in the browser.
- **Export output is truncated.** A large artefact is cut off in the tool reply with an
explicit marker (the byte count is always reported in full). Use the app's download for the
complete file.
- **`export_project` never silently drops data.** A placement whose `deviceTypeRef` matches no
catalogue entry comes back under `unresolved` rather than vanishing; pass
`placeholders: true` to emit it as a placeholder device type so the row still imports.
- **Schema.** Documents use `schemaVersion: "1"` and the backend rejects unknown top-level
fields, so stick to the shape returned by `get_project`.
## For operators (publishing)
Two distribution channels, both from this `mcp/` directory. Neither is done automatically —
these are the manual operator steps.
**npm** (enables `npx -y railyard-mcp` and the config blocks above):
```bash
npm publish # runs the build first via prepublishOnly; add --access public if you scope the name
```
`package.json` ships only `dist/`, `manifest.json`, `README.md` and `LICENSE` (see its
`files`), and the `prepare`/`prepublishOnly` scripts rebuild `dist/` so it is always fresh
on publish. Confirm the package **name** (currently the unscoped `railyard-mcp`) is the one
you want and is available, or pick a scope such as `@your-org/mcp` before publishing.
**Claude Desktop bundle** (`.mcpb`, the one-click install):
```bash
npm run build # produce dist/
npx @anthropic-ai/mcpb pack # bundles manifest.json + dist/ + deps into railyard-mcp.mcpb
```
The bundle is described by [`manifest.json`](./manifest.json): it declares the Node entry
point and a `user_config` that prompts the user for the token (stored securely) and base
URL, mapping them to `RAILYARD_TOKEN` / `RAILYARD_BASE_URL` at launch. Distribute the
resulting `.mcpb` file for drag-and-drop install.
## Development
```bash
npm run build # compile once
npm run dev # compile on change (tsc --watch)
npm run typecheck # type-check without emitting
```
Source layout:
- `src/client.ts` — the typed HTTP client. All auth (`Authorization: Bearer`), org
resolution (`X-Org-Id`), and error mapping live here, in one place.
- `src/index.ts` — the MCP server: tool definitions (zod schemas + annotations) and stdio wiring.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues