Add HTTP transport + bearer auth + Docker/Compose for self-hosting

Restore the Streamable HTTP transport (kept alongside stdio) guarded by a shared bearer token (MCP_AUTH_TOKEN, constant-time check; /health stays open for probes). Add mcp-server/Dockerfile, root docker-compose.yml and .dockerignore to run it as a container on the Gitea box. README documents hosting + connecting Claude Desktop via mcp-remote. Verified HTTP 401-without/200-with-token locally; npm ci validated for the image build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ang3l12
2026-06-16 14:19:51 -06:00
parent f65cad865d
commit 540a49d94c
8 changed files with 236 additions and 37 deletions

View File

@@ -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) ----------------------------------