Skip to content
Open
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
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,45 @@ All calculators are purely local — no network calls, no API key needed.
| `MANIFEST_TTL_SECONDS` | `3600` | How often to rebuild the manifest (seconds). |
| `CACHE_DIR` | `~/.cache/5eMCP` | Disk cache location (local stdio mode). |
| `REDIS_URL` | — | Redis connection URL (e.g. `redis://localhost:6379`). When set and reachable, Redis is used instead of disk cache. Falls back to disk on connection failure. |
| `LOCAL_BASE_URL` | — | Base URL of a self-hosted 5etools static mirror (e.g. `https://5e.example.com`). When set, spell/monster/item/etc. content for the `2024` and `2014` ruleset repos is fetched from this mirror instead of `raw.githubusercontent.com` — faster, no GitHub rate limit for content fetches. Ignored if `LOCAL_DATA_DIR` is also set. Manifest indexing (file listing) still uses the GitHub Contents API, since a static mirror has no equivalent listing endpoint. Homebrew content is never redirected. |
| `LOCAL_DATA_DIR` | — | Filesystem path to a local 5etools `data/` directory (e.g. `/opt/5etools/data`) — typically used when the MCP server runs colocated with a self-hosted mirror. When set, **both** manifest indexing and content fetching read directly from disk, bypassing GitHub entirely for core ruleset content (no rate limit, no network round-trip at all). Homebrew still goes through the GitHub Contents API regardless, since self-hosted mirrors don't bundle it — that call degrades gracefully (logged, non-fatal) if it hits a rate limit. Assumes the directory matches the ruleset(s) you query; a single local mirror generally only reflects one ruleset. |
| `PORT` | `3000` | Port for the HTTP transport (`npm start` / `dist/http.js`). |
| `MCP_HTTP_TOKEN` | — | Bearer token required on the HTTP transport's `/mcp` endpoint. If unset, the endpoint is unauthenticated — fine on a private network, not recommended for public exposure. |

## HTTP Transport

In addition to stdio (used by Claude Desktop/Code/Cursor above), the server supports the [MCP Streamable HTTP transport](https://modelcontextprotocol.io/), useful for running the server remotely (e.g. colocated with a self-hosted 5etools mirror) and connecting to it from clients that can't spawn a local process.

Colocated with a self-hosted mirror (reads the mirror's `data/` directory straight off disk — fastest, no GitHub calls for core content):

```
LOCAL_DATA_DIR=/opt/5etools/data MCP_HTTP_TOKEN=your-secret npm start
```

Or pointing at a mirror over HTTP (e.g. the MCP server runs elsewhere than the mirror):

```
LOCAL_BASE_URL=https://5e.example.com MCP_HTTP_TOKEN=your-secret npm start
```

`npm start` runs the TypeScript source directly via `tsx` — no separate build step needed. If you do want a compiled build (e.g. for `npm run build:mcpb`), note that `tsc` is memory-hungry; on RAM-constrained hosts it can OOM, in which case `npm start` is the way to go anyway.

This starts a stateless HTTP server:

- `POST /mcp` — MCP JSON-RPC endpoint (Streamable HTTP transport, one server instance per request)
- `GET /health` — health check, returns `{"status":"ok","service":"5eMCP"}`

If `MCP_HTTP_TOKEN` is set, requests to `/mcp` must include `Authorization: Bearer <token>`; `/health` is always open.

### Connecting Claude Desktop / claude.ai to a Remote Instance

Once the HTTP server is deployed and reachable, connect to it as a **Custom Connector** rather than editing `claude_desktop_config.json` — that file is for stdio servers that Claude spawns as a local process, which doesn't apply to a server running elsewhere:

1. Claude Desktop (or claude.ai) → **Settings → Connectors → Add custom connector**
2. Enter your server's URL, e.g. `https://5emcp.example.com/mcp`
3. Click **Add**

If `MCP_HTTP_TOKEN` is unset, that's all — the connector works immediately. Note that the Custom Connector UI's "Advanced settings" are built for OAuth (Client ID/Secret), not a raw static bearer token, so `MCP_HTTP_TOKEN` isn't directly pluggable there. If you need auth on a Custom Connector, put a reverse proxy in front (e.g. Cloudflare Access, Caddy with `basicauth`) rather than relying on `MCP_HTTP_TOKEN` alone.

## Ruleset Support

Expand Down
32 changes: 32 additions & 0 deletions src/github.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import type { GitHubContentsItem } from "./types.js";

const GITHUB_API = "https://api.github.com";
const GITHUB_RAW = "https://raw.githubusercontent.com";

/** Repos whose data files can be served from a local 5etools mirror instead of
* GitHub, when LOCAL_BASE_URL is set. Homebrew is intentionally excluded —
* self-hosted 5etools mirrors don't bundle it. */
const LOCAL_MIRROR_REPOS = new Set(["5etools-src", "5etools-2014-src"]);

/** Returns true only for a token that looks like a real PAT, not an empty string
* or an unresolved mcpb template variable like "${user_config.github_token}". */
function isValidToken(token: string | undefined): boolean {
Expand All @@ -18,6 +25,18 @@ function githubHeaders(): HeadersInit {
};
}

/** Base URL of a self-hosted 5etools static mirror (e.g. "https://5e.example.com").
* When set, raw content for the core ruleset repos is fetched from here instead
* of raw.githubusercontent.com — faster, no GitHub rate limit, no token needed
* for this part. Manifest indexing (directory listing) still goes through the
* GitHub Contents API, since a static mirror has no equivalent listing endpoint
* (unless LOCAL_DATA_DIR is also set — see manifest/local-builder.ts). */
function localBaseUrl(): string | undefined {
const value = process.env.LOCAL_BASE_URL;
if (!value || value.trim() === "") return undefined;
return value.replace(/\/+$/, "");
}

export async function fetchContents(
owner: string,
repo: string,
Expand All @@ -33,7 +52,16 @@ export async function fetchContents(
return Array.isArray(data) ? (data as GitHubContentsItem[]) : [data as GitHubContentsItem];
}

/** Fetches and parses a JSON file, whether it lives on GitHub, a local HTTP
* mirror, or (when LOCAL_DATA_DIR indexing produced a file:// URL) directly
* on disk. */
export async function fetchRaw(url: string): Promise<unknown> {
if (url.startsWith("file://")) {
const filePath = fileURLToPath(url);
const raw = await readFile(filePath, "utf8");
return JSON.parse(raw);
}

const res = await fetch(url, {
headers: { "User-Agent": "5eMCP/1.0.0" },
});
Expand All @@ -44,5 +72,9 @@ export async function fetchRaw(url: string): Promise<unknown> {
}

export function rawUrl(owner: string, repo: string, branch: string, path: string): string {
const base = localBaseUrl();
if (base && LOCAL_MIRROR_REPOS.has(repo)) {
return `${base}/${path}`;
}
return `${GITHUB_RAW}/${owner}/${repo}/${branch}/${path}`;
}
Loading