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:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.zip
|
||||||
|
*.skill
|
||||||
|
**/_smoketest.mjs
|
||||||
29
docker-compose.yml
Normal file
29
docker-compose.yml
Normal file
@@ -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
|
||||||
@@ -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).
|
# Optional safety: set to true to expose ONLY read tools (no create/update/delete).
|
||||||
# SP_READONLY=false
|
# 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
|
||||||
|
|||||||
26
mcp-server/Dockerfile
Normal file
26
mcp-server/Dockerfile
Normal file
@@ -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"]
|
||||||
@@ -6,9 +6,16 @@ reuses the same core client as the skill
|
|||||||
(`../.claude/skills/sharepoint-lists/scripts/lib/graph.mjs`) — one source of
|
(`../.claude/skills/sharepoint-lists/scripts/lib/graph.mjs`) — one source of
|
||||||
truth for the Graph logic.
|
truth for the Graph logic.
|
||||||
|
|
||||||
**Transport: stdio only.** This server is meant to run locally and be launched
|
## Transports
|
||||||
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.
|
| 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
|
## 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.
|
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
|
```bash
|
||||||
cd mcp-server
|
cd mcp-server
|
||||||
npm install
|
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
|
## Run as a container (Docker Compose)
|
||||||
`../.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.
|
|
||||||
|
|
||||||
## Connect it
|
From the **repo root** (the build context needs the shared `graph.mjs`):
|
||||||
|
|
||||||
### Claude Desktop (primary)
|
```bash
|
||||||
Add an entry to `claude_desktop_config.json`
|
# 1. Create mcp-server/.env with SP_* creds + a token:
|
||||||
(`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS),
|
# echo "MCP_AUTH_TOKEN=$(openssl rand -hex 32)" >> mcp-server/.env
|
||||||
then fully quit and reopen Claude Desktop:
|
# 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": "<your MCP_AUTH_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
|
```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
|
### 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
|
## Security notes
|
||||||
as the app. Keeping this stdio-only (no network listener) is the intended
|
|
||||||
containment.
|
- App-only auth = the server has full access to the granted SharePoint sites
|
||||||
- Field formats for writes (person/lookup/choice/date) are documented in
|
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`.
|
`../.claude/skills/sharepoint-lists/references/graph-api.md`.
|
||||||
|
|||||||
@@ -1,25 +1,33 @@
|
|||||||
#!/usr/bin/env node
|
#!/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
|
// 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
|
// tools. App-only Microsoft Graph auth; credentials come from the environment
|
||||||
// (or an mcp-server/.env file — git-ignored).
|
// (or an mcp-server/.env file — git-ignored).
|
||||||
//
|
//
|
||||||
// Transport: stdio only — for the local Claude apps (Claude Desktop, Claude
|
// Transports:
|
||||||
// Code, Cowork). There is intentionally no network/HTTP transport, so the
|
// stdio (default) — local Claude apps (Desktop/Code/Cowork)
|
||||||
// server cannot be reached remotely or from web chat.
|
// 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:
|
// Env:
|
||||||
// SP_TENANT_ID, SP_CLIENT_ID, SP_CLIENT_SECRET (required)
|
// SP_TENANT_ID, SP_CLIENT_ID, SP_CLIENT_SECRET (required)
|
||||||
// SP_SITE_URL optional default site so the `site` arg can be omitted
|
// 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_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 { readFileSync, existsSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { createHash, timingSafeEqual } from "node:crypto";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.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.
|
// Single source of truth — the same client the skill's CLI uses.
|
||||||
import { SharePointListsClient, GraphError } from "../.claude/skills/sharepoint-lists/scripts/lib/graph.mjs";
|
import { SharePointListsClient, GraphError } from "../.claude/skills/sharepoint-lists/scripts/lib/graph.mjs";
|
||||||
|
|
||||||
@@ -52,6 +60,12 @@ const fail = (err) => ({
|
|||||||
isError: true,
|
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. */
|
/** Resolve the site arg (or SP_SITE_URL default) to a Graph site id. */
|
||||||
async function siteId(site) {
|
async function siteId(site) {
|
||||||
const s = site || process.env.SP_SITE_URL;
|
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.");
|
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() {
|
function buildServer() {
|
||||||
const server = new McpServer({ name: "sharepoint-lists", version: "1.0.0" });
|
const server = new McpServer({ name: "sharepoint-lists", version: "1.0.0" });
|
||||||
@@ -235,13 +249,69 @@ function buildServer() {
|
|||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- start (stdio) --------------------------------------------------------
|
// ---- transports -----------------------------------------------------------
|
||||||
|
|
||||||
const server = buildServer();
|
async function runStdio() {
|
||||||
await server.connect(new StdioServerTransport());
|
const server = buildServer();
|
||||||
console.error(
|
await server.connect(new StdioServerTransport());
|
||||||
`[sharepoint-lists] MCP server ready on stdio (${READONLY ? "read-only" : "read/write"}).`,
|
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) ----------------------------------
|
// ---- minimal .env loader (no dependency) ----------------------------------
|
||||||
|
|
||||||
|
|||||||
1
mcp-server/package-lock.json
generated
1
mcp-server/package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.12.0",
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||||
|
"express": "^5.0.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -8,10 +8,12 @@
|
|||||||
"sharepoint-lists-mcp": "index.mjs"
|
"sharepoint-lists-mcp": "index.mjs"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node index.mjs"
|
"start": "node index.mjs",
|
||||||
|
"start:http": "node index.mjs --http"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.12.0",
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||||
|
"express": "^5.0.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user