diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..686f19e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +**/node_modules +**/.env +.git +.gitignore +*.zip +*.skill +**/_smoketest.mjs diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..829e5a8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +# Run the SharePoint Lists MCP server as a service (HTTP transport). +# docker compose up -d --build +# +# Requires mcp-server/.env (git-ignored) with: +# SP_TENANT_ID, SP_CLIENT_ID, SP_CLIENT_SECRET, MCP_AUTH_TOKEN +# See mcp-server/.env.example. + +services: + sharepoint-mcp: + build: + context: . + dockerfile: mcp-server/Dockerfile + image: sharepoint-lists-mcp:latest + container_name: sharepoint-lists-mcp + restart: unless-stopped + env_file: + - ./mcp-server/.env + environment: + PORT: "3838" + ports: + # Exposes on all host interfaces. To restrict to the LAN IP, use + # "192.168.101.12:3838:3838". The bearer token guards the endpoint either way. + - "3838:3838" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3838/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s diff --git a/mcp-server/.env.example b/mcp-server/.env.example index 6c62f91..80b174e 100644 --- a/mcp-server/.env.example +++ b/mcp-server/.env.example @@ -8,3 +8,11 @@ SP_CLIENT_SECRET=your-client-secret-value-here # Optional safety: set to true to expose ONLY read tools (no create/update/delete). # SP_READONLY=false + +# --- HTTP transport (only when running with --http, e.g. in Docker) --- +# Shared bearer token required on the /mcp endpoint. Generate a strong random +# value, e.g. openssl rand -hex 32 +# If left empty, the HTTP endpoint is UNAUTHENTICATED — don't do that off-LAN. +# MCP_AUTH_TOKEN= +# Port the HTTP server listens on (default 3838). +# PORT=3838 diff --git a/mcp-server/Dockerfile b/mcp-server/Dockerfile new file mode 100644 index 0000000..82a1f21 --- /dev/null +++ b/mcp-server/Dockerfile @@ -0,0 +1,26 @@ +# Build context must be the REPO ROOT (so the shared graph.mjs is available): +# docker build -f mcp-server/Dockerfile -t sharepoint-lists-mcp . +# or just use docker-compose.yml at the repo root. + +FROM node:22-alpine + +WORKDIR /app/mcp-server + +# Install deps first for better layer caching. +COPY mcp-server/package.json mcp-server/package-lock.json ./ +RUN npm ci --omit=dev + +# Server code … +COPY mcp-server/index.mjs ./ +# … and the shared core it imports, kept at the SAME relative path the import uses +# (../.claude/skills/sharepoint-lists/scripts/lib/graph.mjs). +COPY .claude/skills/sharepoint-lists/scripts/lib/graph.mjs \ + /app/.claude/skills/sharepoint-lists/scripts/lib/graph.mjs + +ENV NODE_ENV=production +EXPOSE 3838 + +# Run a non-root user (alpine node image ships one). +USER node + +CMD ["node", "index.mjs", "--http"] diff --git a/mcp-server/README.md b/mcp-server/README.md index 5771769..0365c2b 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -6,9 +6,16 @@ reuses the same core client as the skill (`../.claude/skills/sharepoint-lists/scripts/lib/graph.mjs`) — one source of truth for the Graph logic. -**Transport: stdio only.** This server is meant to run locally and be launched -by a local Claude app (Claude Desktop / Claude Code / Cowork). It has no network -listener by design, so it cannot be reached remotely or from web chat. +## Transports + +| Transport | Use | How the client connects | +|---|---|---| +| **stdio** (default) | local Claude apps on the same machine | the app launches `node index.mjs` | +| **Streamable HTTP** (`--http`) | hosted in a container, reached over the network | client → HTTP `POST /mcp` (bearer token) | + +stdio is for same-machine use. To run as a hosted service (e.g. Docker on +another box), use the HTTP transport — a stdio client can't attach to a process +on a different machine. ## Tools @@ -25,25 +32,75 @@ listener by design, so it cannot be reached remotely or from web chat. Set `SP_READONLY=true` to register only the read tools. -## Setup +## Configuration + +Credentials come from the environment or `mcp-server/.env` (git-ignored). See +`.env.example` and `../.claude/skills/sharepoint-lists/references/setup.md`. + +| Var | Required | Notes | +|---|---|---| +| `SP_TENANT_ID` / `SP_CLIENT_ID` / `SP_CLIENT_SECRET` | yes | app registration | +| `SP_SITE_URL` | no | default site so tools can omit `site` | +| `SP_READONLY` | no | `true` → read-only tool set | +| `MCP_AUTH_TOKEN` | HTTP only | shared bearer token required on `/mcp`; empty = unauthenticated | +| `PORT` | HTTP only | listen port (default 3838) | + +## Run locally ```bash cd mcp-server npm install -cp .env.example .env # then fill in SP_TENANT_ID / SP_CLIENT_ID / SP_CLIENT_SECRET +cp .env.example .env # fill in credentials +npm start # stdio +npm run start:http # Streamable HTTP on :3838 ``` -Credentials and the optional `SP_SITE_URL` default are documented in -`../.claude/skills/sharepoint-lists/references/setup.md`. The server loads -`mcp-server/.env` automatically (by its own path), so the client app does not -need to pass credentials. +## Run as a container (Docker Compose) -## Connect it +From the **repo root** (the build context needs the shared `graph.mjs`): -### Claude Desktop (primary) -Add an entry to `claude_desktop_config.json` -(`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS), -then fully quit and reopen Claude Desktop: +```bash +# 1. Create mcp-server/.env with SP_* creds + a token: +# echo "MCP_AUTH_TOKEN=$(openssl rand -hex 32)" >> mcp-server/.env +# 2. Build + start: +docker compose up -d --build +# 3. Check: +curl -s http://localhost:3838/health # {"ok":true,...} +``` + +The service restarts automatically and has a healthcheck. The port is published +on all interfaces; restrict it in `docker-compose.yml` (or via firewall) and rely +on `MCP_AUTH_TOKEN` for auth. + +## Connect a client + +### Claude Desktop → remote HTTP server (via `mcp-remote`) + +Desktop speaks stdio, so bridge to the remote HTTP server with `mcp-remote` in +`~/Library/Application Support/Claude/claude_desktop_config.json`, then restart +Desktop: + +```json +{ + "mcpServers": { + "sharepoint-lists": { + "command": "npx", + "args": [ + "-y", "mcp-remote", + "http://192.168.101.12:3838/mcp", + "--allow-http", + "--header", "Authorization: Bearer ${SP_MCP_TOKEN}" + ], + "env": { "SP_MCP_TOKEN": "" } + } + } +} +``` + +`--allow-http` permits the plain-HTTP LAN URL (no TLS). The token must match the +container's `MCP_AUTH_TOKEN`. + +### Claude Desktop → local stdio (no container) ```json { @@ -56,16 +113,15 @@ then fully quit and reopen Claude Desktop: } ``` -Use the absolute path to `node` (Claude Desktop doesn't inherit your shell PATH). - ### Claude Code -The project `.mcp.json` at the repo root already registers this server; from the -repo root, Claude Code will offer to start it. Ensure `mcp-server/.env` is filled in. -## Notes +The repo-root `.mcp.json` registers the local stdio server automatically. -- App-only auth means there is no per-user permission check — every caller acts - as the app. Keeping this stdio-only (no network listener) is the intended - containment. -- Field formats for writes (person/lookup/choice/date) are documented in +## Security notes + +- App-only auth = the server has full access to the granted SharePoint sites + with no per-user check. Anyone who can reach an unauthenticated `/mcp` has that + access — always set `MCP_AUTH_TOKEN` for the HTTP transport. +- `/health` is intentionally unauthenticated (liveness only; returns no data). +- Field formats for writes (person/lookup/choice/date) are in `../.claude/skills/sharepoint-lists/references/graph-api.md`. diff --git a/mcp-server/index.mjs b/mcp-server/index.mjs index 0590154..6f8b0d4 100644 --- a/mcp-server/index.mjs +++ b/mcp-server/index.mjs @@ -1,25 +1,33 @@ #!/usr/bin/env node -// SharePoint Lists MCP server (stdio). +// SharePoint Lists MCP server. // // Wraps the SAME core client the skill uses (graph.mjs) and exposes it as MCP // tools. App-only Microsoft Graph auth; credentials come from the environment // (or an mcp-server/.env file — git-ignored). // -// Transport: stdio only — for the local Claude apps (Claude Desktop, Claude -// Code, Cowork). There is intentionally no network/HTTP transport, so the -// server cannot be reached remotely or from web chat. +// Transports: +// stdio (default) — local Claude apps (Desktop/Code/Cowork) +// Streamable HTTP (--http | MCP_TRANSPORT=http) — for hosting in a container +// +// The HTTP endpoint is guarded by a shared bearer token (MCP_AUTH_TOKEN). App-only +// auth means any caller has full access to the granted SharePoint sites, so do not +// run the HTTP transport without a token outside a trusted network. // // Env: // SP_TENANT_ID, SP_CLIENT_ID, SP_CLIENT_SECRET (required) -// SP_SITE_URL optional default site so the `site` arg can be omitted -// SP_READONLY if "true"/"1", write tools (create/update/delete) are NOT registered +// SP_SITE_URL optional default site so the `site` arg can be omitted +// SP_READONLY if "true"/"1", write tools (create/update/delete) are NOT registered +// MCP_AUTH_TOKEN (HTTP only) shared bearer token required on /mcp; empty = unauthenticated +// PORT (HTTP only) listen port, default 3838 import { readFileSync, existsSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { createHash, timingSafeEqual } from "node:crypto"; import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; // Single source of truth — the same client the skill's CLI uses. import { SharePointListsClient, GraphError } from "../.claude/skills/sharepoint-lists/scripts/lib/graph.mjs"; @@ -52,6 +60,12 @@ const fail = (err) => ({ isError: true, }); +/** Constant-time token comparison (hash first so lengths never leak / throw). */ +function tokenMatches(got, expected) { + const h = (s) => createHash("sha256").update(String(s)).digest(); + return timingSafeEqual(h(got), h(expected)); +} + /** Resolve the site arg (or SP_SITE_URL default) to a Graph site id. */ async function siteId(site) { const s = site || process.env.SP_SITE_URL; @@ -67,7 +81,7 @@ const SITE = z ); const LIST = z.string().describe("List display name, internal name, or GUID."); -// ---- server --------------------------------------------------------------- +// ---- server factory (a fresh instance per stdio process / per HTTP request) - function buildServer() { const server = new McpServer({ name: "sharepoint-lists", version: "1.0.0" }); @@ -235,13 +249,69 @@ function buildServer() { return server; } -// ---- start (stdio) -------------------------------------------------------- +// ---- transports ----------------------------------------------------------- -const server = buildServer(); -await server.connect(new StdioServerTransport()); -console.error( - `[sharepoint-lists] MCP server ready on stdio (${READONLY ? "read-only" : "read/write"}).`, -); +async function runStdio() { + const server = buildServer(); + await server.connect(new StdioServerTransport()); + console.error( + `[sharepoint-lists] MCP server ready on stdio (${READONLY ? "read-only" : "read/write"}).`, + ); +} + +async function runHttp() { + const { default: express } = await import("express"); + const app = express(); + app.use(express.json({ limit: "4mb" })); + + const TOKEN = process.env.MCP_AUTH_TOKEN || ""; + if (!TOKEN) { + console.error( + "[sharepoint-lists] WARNING: MCP_AUTH_TOKEN is not set — the /mcp endpoint is UNAUTHENTICATED.", + ); + } + const requireAuth = (req, res, next) => { + if (!TOKEN) return next(); + const m = /^Bearer\s+(.+)$/i.exec(req.headers.authorization || ""); + if (m && tokenMatches(m[1], TOKEN)) return next(); + res.status(401).json({ error: "Unauthorized" }); + }; + + // Liveness probe (no secrets) — intentionally unauthenticated for healthchecks. + app.get("/health", (_req, res) => res.json({ ok: true, readonly: READONLY })); + + // Stateless: a fresh server + transport per request. + app.post("/mcp", requireAuth, async (req, res) => { + const server = buildServer(); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + res.on("close", () => { + transport.close(); + server.close(); + }); + try { + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (e) { + console.error("[sharepoint-lists] request error:", e); + if (!res.headersSent) res.status(500).json({ error: String(e?.message || e) }); + } + }); + app.get("/mcp", requireAuth, (_req, res) => res.status(405).json({ error: "Method Not Allowed" })); + app.delete("/mcp", requireAuth, (_req, res) => res.status(405).json({ error: "Method Not Allowed" })); + + const port = Number(process.env.PORT) || 3838; + app.listen(port, () => { + console.error( + `[sharepoint-lists] HTTP MCP server on :${port}/mcp (${READONLY ? "read-only" : "read/write"}, auth ${TOKEN ? "on" : "OFF"}).`, + ); + }); +} + +const useHttp = process.argv.includes("--http") || /^http$/i.test(process.env.MCP_TRANSPORT ?? ""); +(useHttp ? runHttp() : runStdio()).catch((e) => { + console.error("[sharepoint-lists] fatal:", e); + process.exit(1); +}); // ---- minimal .env loader (no dependency) ---------------------------------- diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json index e6d272d..3d571a5 100644 --- a/mcp-server/package-lock.json +++ b/mcp-server/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", + "express": "^5.0.0", "zod": "^3.23.8" }, "bin": { diff --git a/mcp-server/package.json b/mcp-server/package.json index 4a945b2..6ec0df0 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -8,10 +8,12 @@ "sharepoint-lists-mcp": "index.mjs" }, "scripts": { - "start": "node index.mjs" + "start": "node index.mjs", + "start:http": "node index.mjs --http" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", + "express": "^5.0.0", "zod": "^3.23.8" } }