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:
@@ -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) ----------------------------------
|
||||
|
||||
|
||||
Reference in New Issue
Block a user