diff --git a/README.md b/README.md index d192a8f..c795d26 100644 --- a/README.md +++ b/README.md @@ -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 `; `/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 diff --git a/src/github.ts b/src/github.ts index 66ac2ec..cfedb18 100644 --- a/src/github.ts +++ b/src/github.ts @@ -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 { @@ -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, @@ -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 { + 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" }, }); @@ -44,5 +72,9 @@ export async function fetchRaw(url: string): Promise { } 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}`; } diff --git a/src/http.ts b/src/http.ts index 98744ba..9dad854 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,180 +1,117 @@ -import { createServer } from "node:http"; - -const port = process.env.PORT ?? 3000; - -const html = ` - - - - - 5etools MCP - - - -
-
-

5etools MCP

-
D&D 5e Model Context Protocol Server
-
- -

- A complete D&D 5e reference server for AI assistants. Provides every content type - 5e.tools displays — spells, monsters, items, classes, sourcebooks, adventures, - and homebrew — backed by live 5etools GitHub data. No API key required. -

- -

Install

-
git clone https://github.com/jazzsequence/5eMCP.git
-cd 5eMCP
-npm install
-npm run build
- -

Configure your MCP client

-

Replace /path/to/5eMCP with the absolute path to your clone.

- -
-
Claude Desktop
-
Claude Code
-
Cursor
-
-
{
-  "mcpServers": {
-    "5etools": {
-      "command": "node",
-      "args": ["/path/to/5eMCP/dist/index.js"],
-      "env": {
-        "GITHUB_TOKEN": "ghp_your_token_here",
-        "DEFAULT_RULESET": "2024"
-      }
-    }
+import { createServer as createHttpServer, type IncomingMessage, type ServerResponse, type Server } from "node:http";
+import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
+import { createServer } from "./server.js";
+import { startRefreshLoop } from "./manifest/refresh.js";
+
+const port = Number(process.env.PORT ?? 3000);
+
+/** Optional shared-secret auth for the HTTP endpoint. If unset, the endpoint is
+ *  open to anyone who can reach it — fine behind a private network / VPN, not
+ *  recommended for a public-facing deployment. Read lazily (not at module load)
+ *  so tests can toggle it via process.env. */
+function authToken(): string | undefined {
+  const value = process.env.MCP_HTTP_TOKEN;
+  return value && value.trim() !== "" ? value : undefined;
+}
+
+export function jsonRpcError(res: ServerResponse, status: number, code: number, message: string): void {
+  res.writeHead(status, { "Content-Type": "application/json" });
+  res.end(JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id: null }));
+}
+
+export function isAuthorized(req: IncomingMessage): boolean {
+  const token = authToken();
+  if (!token) return true;
+  return req.headers.authorization === `Bearer ${token}`;
+}
+
+async function readJsonBody(req: IncomingMessage): Promise {
+  const chunks: Buffer[] = [];
+  for await (const chunk of req) {
+    chunks.push(chunk as Buffer);
+  }
+  const raw = Buffer.concat(chunks).toString("utf8");
+  if (!raw) return undefined;
+  return JSON.parse(raw);
+}
+
+async function handleMcpRequest(req: IncomingMessage, res: ServerResponse): Promise {
+  if (!isAuthorized(req)) {
+    jsonRpcError(res, 401, -32001, "Unauthorized");
+    return;
+  }
+
+  if (req.method !== "POST") {
+    // Stateless mode: no server-initiated streams, so GET/DELETE aren't supported.
+    jsonRpcError(res, 405, -32000, "Method not allowed. Use POST.");
+    return;
   }
-}
- -

- Config file locations:
- Claude Desktop~/Library/Application Support/Claude/claude_desktop_config.json (macOS)
- Claude Code~/.claude.json
- Cursor.cursor/mcp.json (project) or ~/.cursor/mcp.json (global) -

- -

Environment variables

-

- GITHUB_TOKEN — Read-only GitHub PAT. Optional but strongly recommended (5000 req/hr vs 60 unauthenticated).
- DEFAULT_RULESET"2024" (default) or "2014" for legacy rules. -

- -

Source

-

github.com/jazzsequence/5eMCP

-
HTTP transport coming in Phase 5
-
- -`; - -const server = createServer((req, res) => { - if (req.url === "/health") { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ status: "ok", service: "5eMCP" })); + + let parsedBody: unknown; + try { + parsedBody = await readJsonBody(req); + } catch { + jsonRpcError(res, 400, -32700, "Parse error"); return; } - res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); - res.end(html); -}); + try { + const mcpServer = createServer(); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + res.on("close", () => { + void transport.close(); + void mcpServer.close(); + }); + await mcpServer.connect(transport); + await transport.handleRequest(req, res, parsedBody); + } catch (err) { + console.error("Error handling MCP request:", err); + if (!res.headersSent) { + jsonRpcError(res, 500, -32603, "Internal server error"); + } + } +} + +/** Builds the HTTP server without starting it — used both by main() and by tests. */ +export function createHttpApp(): Server { + return createHttpServer((req, res) => { + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + + if (url.pathname === "/health") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "ok", service: "5eMCP" })); + return; + } + + if (url.pathname === "/mcp") { + void handleMcpRequest(req, res); + return; + } + + jsonRpcError(res, 404, -32000, "Not found"); + }); +} + +function isMainModule(): boolean { + return import.meta.url === `file://${process.argv[1]}`; +} + +if (isMainModule()) { + startRefreshLoop(); + + const httpServer = createHttpApp(); + httpServer.listen(port, () => { + console.log(`5eMCP running (Streamable HTTP) on port ${port}, endpoint: /mcp`); + if (!authToken()) { + console.warn( + "MCP_HTTP_TOKEN is not set — the /mcp endpoint is UNAUTHENTICATED. " + + "Anyone who can reach this port can use the server. " + + "Set MCP_HTTP_TOKEN to require a Bearer token, or restrict network access.", + ); + } + }); -server.listen(port, () => { - console.log(`5eMCP HTTP placeholder listening on port ${port}`); -}); + process.on("SIGINT", () => { + httpServer.close(() => process.exit(0)); + }); +} diff --git a/src/manifest/builder.ts b/src/manifest/builder.ts index 46b1612..ed327e5 100644 --- a/src/manifest/builder.ts +++ b/src/manifest/builder.ts @@ -1,8 +1,18 @@ import { fetchContents, rawUrl } from "../github.js"; +import { buildLocalContent } from "./local-builder.js"; import type { Manifest, ManifestFile } from "./schema.js"; import type { Ruleset, GitHubContentsItem } from "../types.js"; import { REPOS, HOMEBREW_REPO } from "../types.js"; +/** Filesystem path to a local 5etools data/ directory (e.g. "/opt/5etools/data"). + * When set, core ruleset content is indexed by scanning this directory instead + * of the GitHub Contents API — avoids GitHub rate limits entirely. Homebrew is + * unaffected (self-hosted mirrors don't bundle it) and still goes through GitHub. */ +function localDataDir(): string | undefined { + const value = process.env.LOCAL_DATA_DIR; + return value && value.trim() !== "" ? value : undefined; +} + function inferSource(filename: string): string | undefined { // e.g. "spells-phb.json" → "PHB", "bestiary-mm.json" → "MM" // e.g. "spells-xge.json" → "XGE" @@ -54,8 +64,11 @@ async function buildDirectoryContent( }); } -export async function buildManifest(ruleset: Ruleset): Promise { - const { owner, repo, branch } = REPOS[ruleset]; +async function buildGitHubContent( + owner: string, + repo: string, + branch: string, +): Promise> { const content: Record = {}; const dataItems = await fetchContents(owner, repo, "data"); @@ -110,7 +123,19 @@ export async function buildManifest(ruleset: Ruleset): Promise { content[contentType].push(file); } - // Build homebrew manifest + return content; +} + +export async function buildManifest(ruleset: Ruleset): Promise { + const { owner, repo, branch } = REPOS[ruleset]; + const dataDir = localDataDir(); + + const content = dataDir + ? await buildLocalContent(dataDir) + : await buildGitHubContent(owner, repo, branch); + + // Build homebrew manifest. Always via GitHub — self-hosted 5etools mirrors + // don't bundle third-party homebrew content, so there's no local source for it. const homebrew: Record = {}; try { await buildHomebrewManifest(homebrew); diff --git a/src/manifest/local-builder.ts b/src/manifest/local-builder.ts new file mode 100644 index 0000000..9ec59a4 --- /dev/null +++ b/src/manifest/local-builder.ts @@ -0,0 +1,140 @@ +import { readdir, stat } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import type { ManifestFile } from "./schema.js"; + +function inferSource(filename: string): string | undefined { + const base = filename.replace(/\.json$/i, ""); + const dashIdx = base.indexOf("-"); + if (dashIdx === -1) return undefined; + return base.slice(dashIdx + 1).toUpperCase(); +} + +function isFluffFile(name: string): boolean { + return name.startsWith("fluff-"); +} + +interface LocalEntry { + name: string; + fullPath: string; + /** Path relative to the data dir root, e.g. "spells/spells-phb.json" — + * matches the tail of a GitHub Contents API item's "path" (minus "data/"). */ + relPath: string; + isDir: boolean; +} + +async function listDir(dataDir: string, relDir: string): Promise { + const absDir = path.join(dataDir, relDir); + const dirents = await readdir(absDir, { withFileTypes: true }); + return dirents.map((d) => ({ + name: d.name, + fullPath: path.join(absDir, d.name), + relPath: relDir ? `${relDir}/${d.name}` : d.name, + isDir: d.isDirectory(), + })); +} + +/** Cheap change-indicator used in place of a GitHub blob SHA — good enough to + * invalidate the disk/Redis cache when a file is updated (e.g. by a `git pull` + * on the mirror host), without hashing file contents. */ +async function versionKey(fullPath: string): Promise { + const st = await stat(fullPath); + return `${st.mtimeMs}:${st.size}`; +} + +async function toManifestFile( + entry: LocalEntry, + fluff: LocalEntry | undefined, + withSource: boolean, +): Promise { + const file: ManifestFile = { + name: entry.name, + path: `data/${entry.relPath}`, + url: pathToFileURL(entry.fullPath).href, + sha: await versionKey(entry.fullPath), + ...(withSource ? { source: inferSource(entry.name) } : {}), + }; + if (fluff) { + file.fluff_url = pathToFileURL(fluff.fullPath).href; + file.fluff_sha = await versionKey(fluff.fullPath); + } + return file; +} + +async function buildLocalDirectoryContent( + dataDir: string, + relDir: string, +): Promise { + const entries = await listDir(dataDir, relDir); + + const fluffIndex = new Map(); + for (const e of entries) { + if (!e.isDir && e.name.endsWith(".json") && isFluffFile(e.name)) { + fluffIndex.set(e.name.replace(/^fluff-/, ""), e); + } + } + + const mechanical = entries.filter( + (e) => !e.isDir && e.name.endsWith(".json") && !isFluffFile(e.name), + ); + + return Promise.all( + mechanical.map((e) => toManifestFile(e, fluffIndex.get(e.name), true)), + ); +} + +/** + * Builds the content manifest by scanning a local 5etools data/ directory on + * disk instead of calling the GitHub Contents API. Used when LOCAL_DATA_DIR + * is set (typically because the MCP server runs colocated with a self-hosted + * 5etools mirror) — avoids GitHub rate limits entirely for core content. + * + * Mirrors buildManifest()'s GitHub-based walk: one level of subdirectories + * under data/, plus flat top-level *.json files, with fluff-*.json files + * paired to their mechanical counterpart. + */ +export async function buildLocalContent( + dataDir: string, +): Promise> { + const content: Record = {}; + const rootEntries = await listDir(dataDir, ""); + + const flatMechanical: LocalEntry[] = []; + const flatFluffIndex = new Map(); + const subdirPromises: Promise[] = []; + + for (const e of rootEntries) { + if (e.isDir) { + const dirName = e.name; + const promise = buildLocalDirectoryContent(dataDir, e.relPath) + .then((files) => { + if (files.length > 0) { + content[dirName] = files; + } + }) + .catch((err: unknown) => { + console.error(`Failed to index local directory ${e.relPath}:`, err); + }); + subdirPromises.push(promise); + } else if (e.name.endsWith(".json")) { + if (isFluffFile(e.name)) { + flatFluffIndex.set(e.name.replace(/^fluff-/, ""), e); + } else { + flatMechanical.push(e); + } + } + } + + await Promise.all(subdirPromises); + + for (const e of flatMechanical) { + const contentType = e.name.replace(/\.json$/i, ""); + const file = await toManifestFile(e, flatFluffIndex.get(e.name), false); + if (!content[contentType]) { + content[contentType] = []; + } + content[contentType].push(file); + } + + return content; +} diff --git a/tests/github.test.ts b/tests/github.test.ts new file mode 100644 index 0000000..8e298a9 --- /dev/null +++ b/tests/github.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { rawUrl } from "../src/github.js"; + +describe("rawUrl", () => { + const ORIGINAL_ENV = { ...process.env }; + + beforeEach(() => { + delete process.env.LOCAL_BASE_URL; + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it("builds a raw.githubusercontent.com URL when LOCAL_BASE_URL is unset", () => { + const url = rawUrl("5etools-mirror-3", "5etools-src", "main", "data/spells/spells-phb.json"); + expect(url).toBe( + "https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/spells/spells-phb.json", + ); + }); + + it("redirects to the local mirror for the 2024 ruleset repo when LOCAL_BASE_URL is set", () => { + process.env.LOCAL_BASE_URL = "https://5e.home.monk.cloud"; + const url = rawUrl("5etools-mirror-3", "5etools-src", "main", "data/spells/spells-phb.json"); + expect(url).toBe("https://5e.home.monk.cloud/data/spells/spells-phb.json"); + }); + + it("redirects to the local mirror for the 2014 ruleset repo when LOCAL_BASE_URL is set", () => { + process.env.LOCAL_BASE_URL = "https://5e.home.monk.cloud"; + const url = rawUrl( + "5etools-mirror-3", + "5etools-2014-src", + "main", + "data/spells/spells-phb.json", + ); + expect(url).toBe("https://5e.home.monk.cloud/data/spells/spells-phb.json"); + }); + + it("strips a trailing slash from LOCAL_BASE_URL", () => { + process.env.LOCAL_BASE_URL = "https://5e.home.monk.cloud/"; + const url = rawUrl("5etools-mirror-3", "5etools-src", "main", "data/spells/spells-phb.json"); + expect(url).toBe("https://5e.home.monk.cloud/data/spells/spells-phb.json"); + }); + + it("does NOT redirect homebrew content even when LOCAL_BASE_URL is set", () => { + process.env.LOCAL_BASE_URL = "https://5e.home.monk.cloud"; + const url = rawUrl("TheGiddyLimit", "homebrew", "master", "Spells/spells-homebrew.json"); + expect(url).toBe( + "https://raw.githubusercontent.com/TheGiddyLimit/homebrew/master/Spells/spells-homebrew.json", + ); + }); + + it("ignores an empty-string LOCAL_BASE_URL", () => { + process.env.LOCAL_BASE_URL = ""; + const url = rawUrl("5etools-mirror-3", "5etools-src", "main", "data/spells/spells-phb.json"); + expect(url).toBe( + "https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/spells/spells-phb.json", + ); + }); +}); + +describe("fetchRaw with file:// URLs", () => { + it("reads and parses a local JSON file instead of making a network request", async () => { + const { mkdtemp, writeFile, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const path = await import("node:path"); + const { pathToFileURL } = await import("node:url"); + const { fetchRaw } = await import("../src/github.js"); + + const dir = await mkdtemp(path.join(tmpdir(), "5emcp-fetchraw-")); + const filePath = path.join(dir, "spell.json"); + await writeFile(filePath, JSON.stringify({ name: "Fireball", level: 3 })); + + try { + const data = await fetchRaw(pathToFileURL(filePath).href); + expect(data).toEqual({ name: "Fireball", level: 3 }); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("rejects a file:// URL that doesn't exist", async () => { + const { pathToFileURL } = await import("node:url"); + const { fetchRaw } = await import("../src/github.js"); + + await expect(fetchRaw(pathToFileURL("/nonexistent/spell.json").href)).rejects.toThrow(); + }); +}); diff --git a/tests/http.test.ts b/tests/http.test.ts new file mode 100644 index 0000000..799f8b0 --- /dev/null +++ b/tests/http.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { createHttpApp } from "../src/http.js"; + +async function jsonRpcRequest( + baseUrl: string, + body: unknown, + headers: Record = {}, +): Promise { + return fetch(`${baseUrl}/mcp`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", ...headers }, + body: JSON.stringify(body), + }); +} + +function initializeRequest(): unknown { + return { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }, + }; +} + +describe("HTTP transport", () => { + let server: Server; + let baseUrl: string; + const ORIGINAL_ENV = { ...process.env }; + + beforeEach(async () => { + delete process.env.MCP_HTTP_TOKEN; + server = createHttpApp(); + await new Promise((resolve) => server.listen(0, resolve)); + const { port } = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${port}`; + }); + + afterEach(async () => { + process.env = { ...ORIGINAL_ENV }; + await new Promise((resolve) => server.close(() => resolve())); + }); + + it("responds to /health", async () => { + const res = await fetch(`${baseUrl}/health`); + expect(res.status).toBe(200); + const body = (await res.json()) as { status: string; service: string }; + expect(body).toEqual({ status: "ok", service: "5eMCP" }); + }); + + it("returns 404 for unknown paths", async () => { + const res = await fetch(`${baseUrl}/nope`); + expect(res.status).toBe(404); + }); + + it("rejects non-POST requests to /mcp with 405", async () => { + const res = await fetch(`${baseUrl}/mcp`, { method: "GET" }); + expect(res.status).toBe(405); + }); + + it("handles a valid initialize request over POST /mcp", async () => { + const res = await jsonRpcRequest(baseUrl, initializeRequest()); + expect(res.status).toBe(200); + const text = await res.text(); + expect(text).toContain('"5eMCP"'); + }); + + it("returns 400 for malformed JSON body", async () => { + const res = await fetch(`${baseUrl}/mcp`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" }, + body: "{not valid json", + }); + expect(res.status).toBe(400); + }); +}); + +describe("HTTP transport with MCP_HTTP_TOKEN set", () => { + let server: Server; + let baseUrl: string; + const ORIGINAL_ENV = { ...process.env }; + + beforeEach(async () => { + process.env.MCP_HTTP_TOKEN = "s3cr3t"; + server = createHttpApp(); + await new Promise((resolve) => server.listen(0, resolve)); + const { port } = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${port}`; + }); + + afterEach(async () => { + process.env = { ...ORIGINAL_ENV }; + await new Promise((resolve) => server.close(() => resolve())); + }); + + it("rejects requests without a Bearer token", async () => { + const res = await jsonRpcRequest(baseUrl, initializeRequest()); + expect(res.status).toBe(401); + }); + + it("rejects requests with the wrong token", async () => { + const res = await jsonRpcRequest(baseUrl, initializeRequest(), { Authorization: "Bearer wrong" }); + expect(res.status).toBe(401); + }); + + it("accepts requests with the correct token", async () => { + const res = await jsonRpcRequest(baseUrl, initializeRequest(), { Authorization: "Bearer s3cr3t" }); + expect(res.status).toBe(200); + }); + + it("still serves /health without a token", async () => { + const res = await fetch(`${baseUrl}/health`); + expect(res.status).toBe(200); + }); +}); diff --git a/tests/manifest/local-builder.test.ts b/tests/manifest/local-builder.test.ts new file mode 100644 index 0000000..7fbb18e --- /dev/null +++ b/tests/manifest/local-builder.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { buildLocalContent } from "../../src/manifest/local-builder.js"; + +describe("buildLocalContent", () => { + let dataDir: string; + + beforeAll(async () => { + dataDir = await mkdtemp(path.join(tmpdir(), "5emcp-local-")); + + // Subdirectory content type: data/spells/{spells-phb.json, fluff-spells-phb.json} + await mkdir(path.join(dataDir, "spells"), { recursive: true }); + await writeFile( + path.join(dataDir, "spells", "spells-phb.json"), + JSON.stringify({ spell: [{ name: "Fireball" }] }), + ); + await writeFile( + path.join(dataDir, "spells", "fluff-spells-phb.json"), + JSON.stringify({ spellFluff: [{ name: "Fireball", entries: ["lore"] }] }), + ); + + // Another subdirectory, no fluff pairing + await mkdir(path.join(dataDir, "bestiary"), { recursive: true }); + await writeFile( + path.join(dataDir, "bestiary", "bestiary-mm.json"), + JSON.stringify({ monster: [{ name: "Goblin" }] }), + ); + + // A flat top-level file (content type == file name minus .json) + await writeFile( + path.join(dataDir, "books.json"), + JSON.stringify({ book: [{ name: "Player's Handbook" }] }), + ); + + // Non-JSON entries should be ignored + await writeFile(path.join(dataDir, "README.txt"), "ignore me"); + }); + + afterAll(async () => { + await rm(dataDir, { recursive: true, force: true }); + }); + + it("indexes subdirectory content types with source inferred from filename", async () => { + const content = await buildLocalContent(dataDir); + expect(content.spells).toHaveLength(1); + expect(content.spells[0]).toMatchObject({ + name: "spells-phb.json", + path: "data/spells/spells-phb.json", + source: "PHB", + }); + expect(content.spells[0].url).toMatch(/^file:\/\/.*spells-phb\.json$/); + expect(content.spells[0].sha).toMatch(/^\d+(\.\d+)?:\d+$/); + }); + + it("pairs fluff-*.json files with their mechanical counterpart", async () => { + const content = await buildLocalContent(dataDir); + expect(content.spells[0].fluff_url).toMatch(/^file:\/\/.*fluff-spells-phb\.json$/); + expect(content.spells[0].fluff_sha).toBeDefined(); + }); + + it("indexes a subdirectory with no fluff file", async () => { + const content = await buildLocalContent(dataDir); + expect(content.bestiary).toHaveLength(1); + expect(content.bestiary[0].fluff_url).toBeUndefined(); + }); + + it("indexes flat top-level json files without inferring a source", async () => { + const content = await buildLocalContent(dataDir); + expect(content.books).toHaveLength(1); + expect(content.books[0]).toMatchObject({ name: "books.json", path: "data/books.json" }); + expect(content.books[0].source).toBeUndefined(); + }); + + it("ignores non-JSON files", async () => { + const content = await buildLocalContent(dataDir); + const allFiles = Object.values(content).flat(); + expect(allFiles.some((f) => f.name === "README.txt")).toBe(false); + }); +});