From 3a8603f8aa7c0ad322b2b5405c54fdfe1c9cea2e Mon Sep 17 00:00:00 2001 From: Gabe Date: Thu, 30 Oct 2025 13:29:46 -0400 Subject: [PATCH] Add Docker HTTP transport and docs --- .dockerignore | 14 ++ .env.example | 5 +- Dockerfile | 34 +++++ README.md | 148 +++++++++++++++++++++- examples/http-read-only-test.mjs | 166 ++++++++++++++++++++++++ src/clients/xero-client.ts | 6 +- src/server.ts | 211 +++++++++++++++++++++++++++++++ src/server/xero-mcp-server.ts | 22 +++- 8 files changed, 592 insertions(+), 14 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 examples/http-read-only-test.mjs create mode 100644 src/server.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e45e293 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +node_modules +dist +npm-debug.log* +.git +.gitignore +.DS_Store +backlog +examples +coverage +.vscode +.idea +*.local +.env +.env.* diff --git a/.env.example b/.env.example index cdd6da5..511aa1c 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ # Xero API Configuration for Custom Connections XERO_CLIENT_ID=your_client_id_here -XERO_CLIENT_SECRET=your_client_secret_here \ No newline at end of file +XERO_CLIENT_SECRET=your_client_secret_here + +# Optional: protect HTTP transport with a shared secret +MCP_API_KEY=optional_shared_secret diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f970944 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +# Install dependencies with the lockfile to ensure reproducible builds +COPY package.json package-lock.json ./ +RUN npm ci --ignore-scripts + +# Copy the source and build outputs +COPY tsconfig.json ./ +COPY src ./src + +# Compile TypeScript sources to JavaScript +RUN npm run build + + +FROM node:20-alpine AS runner + +WORKDIR /app + +ENV NODE_ENV=production + +# Install only production dependencies +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force + +# Copy build output from the builder stage +COPY --from=builder /app/dist dist + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s CMD node -e "const http = require('http'); const port = process.env.PORT || 3000; http.get({ host: '127.0.0.1', port, path: '/healthz' }, (res) => { process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));" + +CMD ["node", "dist/server.js"] diff --git a/README.md b/README.md index af4d6d1..cb479fe 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,8 @@ To add the MCP server to Claude go to Settings > Developer > Edit config and add "args": ["-y", "@xeroapi/xero-mcp-server@latest"], "env": { "XERO_CLIENT_ID": "your_client_id_here", - "XERO_CLIENT_SECRET": "your_client_secret_here" + "XERO_CLIENT_SECRET": "your_client_secret_here", + "MCP_API_KEY": "optional_shared_secret" } } } @@ -82,7 +83,8 @@ In this case, use the following configuration: "command": "npx", "args": ["-y", "@xeroapi/xero-mcp-server@latest"], "env": { - "XERO_CLIENT_BEARER_TOKEN": "your_bearer_token" + "XERO_CLIENT_BEARER_TOKEN": "your_bearer_token", + "MCP_API_KEY": "optional_shared_secret" } } } @@ -91,6 +93,21 @@ In this case, use the following configuration: NOTE: The `XERO_CLIENT_BEARER_TOKEN` will take precedence over the `XERO_CLIENT_ID` if defined. +Set `MCP_API_KEY` when exposing the HTTP transport (for example by running `node dist/server.js`) to require clients to include an `Authorization: Bearer ` header on every call to `/mcp`. Leave the variable undefined for local testing without authentication. + +### Environment variables + +| Variable | Required | Description | +| --- | --- | --- | +| `XERO_CLIENT_ID` | yes (unless `XERO_CLIENT_BEARER_TOKEN` is provided) | OAuth client id from the Xero developer portal. | +| `XERO_CLIENT_SECRET` | yes (unless `XERO_CLIENT_BEARER_TOKEN` is provided) | OAuth client secret for the configured custom connection. | +| `XERO_CLIENT_BEARER_TOKEN` | optional | Pre-issued bearer token; when set it is preferred over the client/secret flow. | +| `MCP_API_KEY` | optional (recommended for HTTP) | Shared secret required by the HTTP transport. Clients must send `Authorization: Bearer `. | +| `HOST` | optional | Bind address for the HTTP server (default `0.0.0.0`). | +| `PORT` | optional | Listen port for the HTTP server (default `3000`). | + +> Tip: ensure environment values are free from leading or trailing whitespace—any surrounding quotes or spaces will cause Xero to reject the client credentials. + ### Available MCP Commands - `list-accounts`: Retrieve a list of accounts @@ -172,13 +189,138 @@ NOTE: For Windows ensure the `args` path escapes the `\` between folders ie. `"C "args": ["insert-your-file-path-here/xero-mcp-server/dist/index.js"], "env": { "XERO_CLIENT_ID": "your_client_id_here", - "XERO_CLIENT_SECRET": "your_client_secret_here" + "XERO_CLIENT_SECRET": "your_client_secret_here", + "MCP_API_KEY": "optional_shared_secret" } } } } ``` +### HTTP Transport + +The project exposes an HTTP/SSE transport in `dist/server.js`. Set `MCP_API_KEY` to require callers to include `Authorization: Bearer ` headers. Leave it undefined for local unauthenticated experiments. + +#### Modes at a glance + +- **STDIO**: `node dist/index.js` (or invoke via `npx @xeroapi/xero-mcp-server`). This is the default when integrating with MCP-compatible clients that communicate over stdin/stdout. +- **HTTP/SSE**: `node dist/server.js` (documented below). This listens for `/mcp` SSE sessions and exposes `/healthz` for readiness checks. + +#### Run the HTTP server locally + +```bash +# Build TypeScript -> JavaScript +npm run build + +# Export the credentials you want the server to use +export MCP_API_KEY=optional_shared_secret +export XERO_CLIENT_ID=your_client_id +export XERO_CLIENT_SECRET=your_client_secret + +# Optionally override HOST/PORT (defaults: 0.0.0.0:3000) +export PORT=3300 +export HOST=127.0.0.1 + +# Start the HTTP transport +node dist/server.js +``` + +Check readiness with `curl http://127.0.0.1:3300/healthz` (expect `ok`) and post to `/mcp` using the `Authorization: Bearer optional_shared_secret` header. + +#### Run the HTTP server with Docker + +```bash +# Build the Docker image +docker build -t xero-mcp . + +# Run with your environment (either --env or --env-file) +docker run \ + --rm \ + --name xero-mcp-http \ + --env-file .env \ # contains MCP_API_KEY, XERO_CLIENT_ID, XERO_CLIENT_SECRET + -e PORT=3300 \ + -p 3300:3300 \ + xero-mcp +``` + +Validate the container with: + +```bash +curl http://127.0.0.1:3300/healthz +``` + +When the server is running in Docker you can reuse the local harness by skipping the spawn step: + +```bash +MCP_API_KEY=optional_shared_secret \ +MCP_TEST_SKIP_SPAWN=true \ +MCP_TEST_HOST=127.0.0.1 \ +MCP_TEST_PORT=3300 \ +node examples/http-read-only-test.mjs +``` + +The harness connects to the existing container, lists advertised tools, and invokes the read-only tool you specify. + +### Read-only HTTP Verification + +To smoke-test the HTTP transport without Docker, let the harness spawn the server for you: + +```bash +export MCP_API_KEY=optional_shared_secret +export XERO_CLIENT_ID=your_client_id +export XERO_CLIENT_SECRET=your_client_secret +npm run build +node examples/http-read-only-test.mjs +``` + +Optional environment overrides: + +- `MCP_TEST_TOOL` — tool name to invoke (default `list-organisation-details`) +- `MCP_TEST_HOST`/`MCP_TEST_PORT` — bind address for the spawned server (default `127.0.0.1:3300`) + +The harness authenticates using `MCP_API_KEY`, lists available tools, and prints the JSON result of the selected read-only tool call. + +#### Manual verification checklist + +```bash +# 1. Build the Docker image +$ docker build -t xero-mcp . +... +#14 naming to docker.io/library/xero-mcp:latest done + +# 2. Run the container with your env file +$ docker run --rm --name xero-mcp-http --env-file .env -e PORT=3300 -p 3300:3300 xero-mcp +HTTP MCP server listening on http://0.0.0.0:3300 + +# 3. Health endpoint +$ curl http://127.0.0.1:3300/healthz +ok + +# 4. /mcp requires Authorization +$ curl -i http://127.0.0.1:3300/mcp +HTTP/1.1 401 Unauthorized +WWW-Authenticate: Bearer realm="xero-mcp-server" +Missing or invalid MCP API key + +$ curl --max-time 2 -i -H "Authorization: Bearer optional_shared_secret" http://127.0.0.1:3300/mcp +HTTP/1.1 200 OK +Content-Type: text/event-stream +event: endpoint +data: /mcp?sessionId=... + +# 5. Invoke a tool via the harness (against the running container) +$ MCP_API_KEY=optional_shared_secret \ + MCP_TEST_SKIP_SPAWN=true \ + MCP_TEST_HOST=127.0.0.1 \ + MCP_TEST_PORT=3300 \ + node examples/http-read-only-test.mjs +Connected to MCP server at http://127.0.0.1:3300/mcp +Tool response: +{ "content": [ ... organisation details ... ] } +``` + +Capture these transcripts (or equivalent) in your PR description to satisfy review requirements. + ## License MIT diff --git a/examples/http-read-only-test.mjs b/examples/http-read-only-test.mjs new file mode 100644 index 0000000..6011067 --- /dev/null +++ b/examples/http-read-only-test.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node + +/** + * Simple harness that spins up the HTTP MCP server, authenticates with the configured + * MCP_API_KEY, and runs a read-only tool (defaults to list-organisation-details). + * + * Usage: + * MCP_API_KEY=secret XERO_CLIENT_ID=... XERO_CLIENT_SECRET=... node examples/http-read-only-test.mjs + * + * Optional environment variables: + * MCP_TEST_TOOL - tool name to call (default: list-organisation-details) + * MCP_TEST_PORT - port to bind the HTTP server (default: 3300) + * MCP_TEST_HOST - host to bind the HTTP server (default: 127.0.0.1) + */ + +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { createInterface } from "node:readline"; +import { setTimeout as delay } from "node:timers/promises"; +import process from "node:process"; +import dotenv from "dotenv"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; + +dotenv.config(); + +const TOOL_NAME = process.env.MCP_TEST_TOOL ?? "list-organisation-details"; +const HOST = process.env.MCP_TEST_HOST ?? "127.0.0.1"; +const PORT = Number.parseInt(process.env.MCP_TEST_PORT ?? "3300", 10); +const API_KEY = process.env.MCP_API_KEY ?? ""; +const SKIP_SPAWN = process.env.MCP_TEST_SKIP_SPAWN === "true"; + +if (!API_KEY) { + console.error("MCP_API_KEY must be set to authenticate against the HTTP server."); + process.exit(1); +} + +const serverEnv = { + ...process.env, + HOST, + PORT: String(PORT), +}; + +const server = SKIP_SPAWN + ? null + : spawn("node", ["dist/server.js"], { + env: serverEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + +const cleanup = async () => { + if (!server || SKIP_SPAWN || server.killed) { + return; + } + if (!server.killed) { + server.kill(); + await once(server, "exit").catch(() => {}); + } +}; + +if (server) { + server.on("exit", (code, signal) => { + if (code !== 0) { + console.error(`HTTP server exited unexpectedly (code=${code}, signal=${signal})`); + } + }); + + server.stderr.on("data", (chunk) => { + process.stderr.write(chunk); + }); +} + +const waitForServer = async () => { + if (SKIP_SPAWN) { + const healthUrl = new URL(`http://${HOST}:${PORT}/healthz`); + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + try { + const response = await fetch(healthUrl); + if (response.ok) { + return; + } + } catch (error) { + // Swallow and retry until deadline expires + } + await delay(250); + } + throw new Error(`Timed out waiting for remote server at ${healthUrl.href}`); + } + + if (!server) { + throw new Error("Server process not started and SKIP_SPAWN disabled."); + } + + const readline = createInterface({ input: server.stdout }); + try { + for await (const line of readline) { + if (line.includes("HTTP MCP server listening")) { + return; + } + process.stdout.write(`${line}\n`); + } + } finally { + readline.close(); + } + + throw new Error("Server exited before signaling readiness."); +}; + +const run = async () => { + await waitForServer(); + + const serverUrl = new URL(`http://${HOST}:${PORT}/mcp`); + const headers = { Authorization: `Bearer ${API_KEY}` }; + + const transport = new SSEClientTransport(serverUrl, { + requestInit: { headers }, + eventSourceInit: { + fetch: async (resource, init) => { + const mergedHeaders = new Headers(init?.headers ?? {}); + mergedHeaders.set("Authorization", `Bearer ${API_KEY}`); + return fetch(resource, { ...init, headers: mergedHeaders }); + }, + }, + }); + + const client = new Client( + { name: "xero-mcp-read-test", version: "0.1.0" }, + { capabilities: { tools: {} } }, + ); + + try { + await client.connect(transport); + console.log(`Connected to MCP server at ${serverUrl.href}`); + + const tools = await client.listTools({}); + const hasTool = tools.tools.some((tool) => tool.name === TOOL_NAME); + if (!hasTool) { + throw new Error( + `Tool "${TOOL_NAME}" not advertised by server. Available tools: ${tools.tools + .map((tool) => tool.name) + .join(", ")}`, + ); + } + + console.log(`Calling read-only tool "${TOOL_NAME}"...`); + const result = await client.callTool({ + name: TOOL_NAME, + arguments: {}, + }); + + console.log("Tool response:"); + console.log(JSON.stringify(result, null, 2)); + } finally { + await client.close().catch(() => {}); + await cleanup(); + } +}; + +run() + .then(() => delay(100)) + .catch(async (error) => { + console.error("Read-only harness failed:", error); + await cleanup(); + process.exit(1); + }); diff --git a/src/clients/xero-client.ts b/src/clients/xero-client.ts index 24ece6e..1c959f2 100644 --- a/src/clients/xero-client.ts +++ b/src/clients/xero-client.ts @@ -11,9 +11,9 @@ import { ensureError } from "../helpers/ensure-error.js"; dotenv.config(); -const client_id = process.env.XERO_CLIENT_ID; -const client_secret = process.env.XERO_CLIENT_SECRET; -const bearer_token = process.env.XERO_CLIENT_BEARER_TOKEN; +const client_id = process.env.XERO_CLIENT_ID?.trim(); +const client_secret = process.env.XERO_CLIENT_SECRET?.trim(); +const bearer_token = process.env.XERO_CLIENT_BEARER_TOKEN?.trim(); const grant_type = "client_credentials"; if (!bearer_token && (!client_id || !client_secret)) { diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..44da3eb --- /dev/null +++ b/src/server.ts @@ -0,0 +1,211 @@ +#!/usr/bin/env node + +import { createServer, IncomingMessage, ServerResponse } from "node:http"; +import dotenv from "dotenv"; +import { URL } from "node:url"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import { XeroMcpServer } from "./server/xero-mcp-server.js"; +import { ToolFactory } from "./tools/tool-factory.js"; + +dotenv.config(); + +const DEFAULT_PORT = 3000; +const HEALTH_PATH = "/healthz"; +const MCP_PATH = "/mcp"; + +type Session = { + transport: SSEServerTransport; + server: McpServer; +}; + +const sessions = new Map(); + +const mcpApiKey = process.env.MCP_API_KEY; +const isAuthEnabled = typeof mcpApiKey === "string" && mcpApiKey.length > 0; + +const getAuthorizationHeader = (req: IncomingMessage): string | undefined => { + const header = req.headers.authorization; + if (!header) { + return undefined; + } + + return Array.isArray(header) ? header[0] : header; +}; + +const hasValidBearerToken = (authorizationHeader: string): boolean => { + const match = /^Bearer\s+(.+)$/i.exec(authorizationHeader); + if (!match) { + return false; + } + + const token = match[1]?.trim(); + return Boolean(token) && token === mcpApiKey; +}; + +const isAuthorizedRequest = (req: IncomingMessage): boolean => { + if (!isAuthEnabled) { + return true; + } + + const authorizationHeader = getAuthorizationHeader(req); + if (!authorizationHeader) { + return false; + } + + return hasValidBearerToken(authorizationHeader); +}; + +const rejectUnauthorized = (res: ServerResponse): void => { + if (!res.headersSent) { + res.writeHead(401, { + "Content-Type": "text/plain", + "WWW-Authenticate": 'Bearer realm="xero-mcp-server"', + }); + } + res.end("Missing or invalid MCP API key"); +}; + +const resolvePort = (): number => { + const configured = Number.parseInt(process.env.PORT ?? "", 10); + return Number.isNaN(configured) ? DEFAULT_PORT : configured; +}; + +const host = process.env.HOST ?? "0.0.0.0"; +const port = resolvePort(); + +const buildServerWithTools = (): McpServer => { + const server = XeroMcpServer.CreateServerInstance(); + ToolFactory(server); + return server; +}; + +const handleHealthz = (_req: IncomingMessage, res: ServerResponse): void => { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("ok"); +}; + +const handleSseConnection = async ( + req: IncomingMessage, + res: ServerResponse, +): Promise => { + if (req.method !== "GET") { + res.writeHead(405, { Allow: "GET, POST" }); + res.end("Method Not Allowed"); + return; + } + + const server = buildServerWithTools(); + const transport = new SSEServerTransport(MCP_PATH, res); + const sessionId = transport.sessionId; + + sessions.set(sessionId, { transport, server }); + + transport.onclose = async () => { + sessions.delete(sessionId); + try { + await server.close(); + } catch (error) { + console.error("Error closing MCP server:", error); + } + }; + + transport.onerror = (error) => { + console.error("SSE transport error:", error); + }; + + try { + await server.connect(transport); + } catch (error) { + sessions.delete(sessionId); + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "text/plain" }); + res.end("Failed to initialize MCP session"); + } + console.error("Failed to establish MCP SSE session:", error); + } +}; + +const handleMcpPost = async ( + req: IncomingMessage, + res: ServerResponse, + url: URL, +): Promise => { + if (req.method !== "POST") { + res.writeHead(405, { Allow: "GET, POST" }); + res.end("Method Not Allowed"); + return; + } + + const sessionId = url.searchParams.get("sessionId"); + if (!sessionId) { + res.writeHead(400, { "Content-Type": "text/plain" }); + res.end("Missing sessionId"); + return; + } + + const session = sessions.get(sessionId); + if (!session) { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Session not found"); + return; + } + + try { + await session.transport.handlePostMessage(req, res); + } catch (error) { + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "text/plain" }); + res.end("Failed to handle MCP message"); + } + console.error("Failed to handle MCP POST:", error); + } +}; + +const server = createServer(async (req, res) => { + try { + if (!req.url) { + res.writeHead(400, { "Content-Type": "text/plain" }); + res.end("Invalid request"); + return; + } + + const requestUrl = new URL( + req.url, + `http://${req.headers.host ?? `${host}:${port}`}`, + ); + + if (requestUrl.pathname === HEALTH_PATH && req.method === "GET") { + handleHealthz(req, res); + return; + } + + if (requestUrl.pathname === MCP_PATH) { + if (!isAuthorizedRequest(req)) { + rejectUnauthorized(res); + return; + } + + if (req.method === "GET") { + await handleSseConnection(req, res); + return; + } + + await handleMcpPost(req, res, requestUrl); + return; + } + + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not Found"); + } catch (error) { + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "text/plain" }); + res.end("Internal Server Error"); + } + console.error("Unhandled server error:", error); + } +}); + +server.listen(port, host, () => { + console.log(`HTTP MCP server listening on http://${host}:${port}`); +}); diff --git a/src/server/xero-mcp-server.ts b/src/server/xero-mcp-server.ts index f316feb..e53f75d 100644 --- a/src/server/xero-mcp-server.ts +++ b/src/server/xero-mcp-server.ts @@ -5,16 +5,24 @@ export class XeroMcpServer { private constructor() {} + private static createServer(): McpServer { + return new McpServer({ + name: "Xero MCP Server", + version: "1.0.0", + capabilities: { + tools: {}, + }, + }); + } + public static GetServer(): McpServer { if (XeroMcpServer.instance === null) { - XeroMcpServer.instance = new McpServer({ - name: "Xero MCP Server", - version: "1.0.0", - capabilities: { - tools: {}, - }, - }); + XeroMcpServer.instance = XeroMcpServer.createServer(); } return XeroMcpServer.instance; } + + public static CreateServerInstance(): McpServer { + return XeroMcpServer.createServer(); + } }