#!/usr/bin/env node // 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). // // 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 // 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, secretExpiryStatus } from "../.claude/skills/sharepoint-lists/scripts/lib/graph.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); loadDotEnv(); const READONLY = /^(1|true|yes)$/i.test(process.env.SP_READONLY ?? ""); const client = new SharePointListsClient({ tenantId: process.env.SP_TENANT_ID, clientId: process.env.SP_CLIENT_ID, clientSecret: process.env.SP_CLIENT_SECRET, }); // Self-reported client-secret expiry (SP_SECRET_EXPIRES=YYYY-MM-DD) — warn // loudly before auth starts failing with an opaque 401. Computed fresh on each // use so a long-running container keeps counting down. const secretExpiry = () => secretExpiryStatus(process.env.SP_SECRET_EXPIRES); { const s = secretExpiry(); if (s?.message) console.error(`[sharepoint-lists] ⚠ ${s.message}`); } // ---- helpers -------------------------------------------------------------- const ok = (data) => ({ content: [{ type: "text", text: JSON.stringify(data, null, 2) }], }); const fail = (err) => ({ content: [ { type: "text", text: err instanceof GraphError ? `Graph error (${err.status}${err.code ? " " + err.code : ""}): ${err.message}` : `Error: ${err.message}`, }, ], 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; if (!s) throw new Error("No `site` provided and SP_SITE_URL is not set."); return client.resolveSiteId(s); } const SITE = z .string() .optional() .describe( "SharePoint site URL, e.g. https://contoso.sharepoint.com/sites/Marketing. Omit to use the SP_SITE_URL default.", ); const LIST = z.string().describe("List display name, internal name, or GUID."); // ---- server factory (a fresh instance per stdio process / per HTTP request) - function buildServer() { const server = new McpServer({ name: "sharepoint-lists", version: "1.0.0" }); server.registerTool( "sharepoint_test", { title: "Test SharePoint connectivity", description: "Verify app-only credentials work. With a site, also lists the lists in that site. Use this first when troubleshooting.", inputSchema: { site: SITE }, annotations: { readOnlyHint: true }, }, async ({ site }) => { try { const result = await client.test(site || process.env.SP_SITE_URL); const s = secretExpiry(); if (s) result.secretExpiry = s; return ok(result); } catch (e) { return fail(e); } }, ); server.registerTool( "sharepoint_list_lists", { title: "List the lists in a site", description: "Enumerate all SharePoint Lists in a site (name, displayName, id, webUrl).", inputSchema: { site: SITE }, annotations: { readOnlyHint: true }, }, async ({ site }) => { try { return ok(await client.listLists(await siteId(site))); } catch (e) { return fail(e); } }, ); server.registerTool( "sharepoint_get_columns", { title: "Get a list's columns", description: "Show a list's column definitions (internal name, display name, type). Internal names differ from display names — call this before writing items.", inputSchema: { site: SITE, list: LIST }, annotations: { readOnlyHint: true }, }, async ({ site, list }) => { try { return ok(await client.getColumns(await siteId(site), list)); } catch (e) { return fail(e); } }, ); server.registerTool( "sharepoint_list_items", { title: "Query list items", description: "Query items in a list. Filter/orderby use the fields/ prefix, e.g. filter \"fields/Status eq 'Open'\", orderby \"fields/DueDate asc\". select is a comma list of internal field names. Set all=true to fetch every page.", inputSchema: { site: SITE, list: LIST, filter: z.string().optional().describe("OData $filter, e.g. fields/Status eq 'Open'"), select: z.string().optional().describe("Comma-separated internal field names to return"), orderby: z.string().optional().describe("OData $orderby, e.g. fields/DueDate asc"), top: z.number().int().positive().optional().describe("Page size / max items"), all: z.boolean().optional().describe("Follow pagination and return all items"), }, annotations: { readOnlyHint: true }, }, async ({ site, list, filter, select, orderby, top, all }) => { try { return ok( await client.listItems(await siteId(site), list, { filter, select, orderby, top, all }), ); } catch (e) { return fail(e); } }, ); server.registerTool( "sharepoint_get_item", { title: "Get one item", description: "Fetch a single list item (with its fields) by item id.", inputSchema: { site: SITE, list: LIST, itemId: z.string().describe("The list item id.") }, annotations: { readOnlyHint: true }, }, async ({ site, list, itemId }) => { try { return ok(await client.getItem(await siteId(site), list, itemId)); } catch (e) { return fail(e); } }, ); if (!READONLY) { const FIELDS = z .record(z.any()) .describe( "Map of INTERNAL column name -> value. Internal names come from sharepoint_get_columns. See the skill's references/graph-api.md for person/lookup/choice formats.", ); server.registerTool( "sharepoint_create_item", { title: "Create an item", description: "Create a new list item. fields is a map of internal column name to value.", inputSchema: { site: SITE, list: LIST, fields: FIELDS }, annotations: { readOnlyHint: false, destructiveHint: false }, }, async ({ site, list, fields }) => { try { return ok(await client.createItem(await siteId(site), list, fields)); } catch (e) { return fail(e); } }, ); server.registerTool( "sharepoint_update_item", { title: "Update an item", description: "Update an existing item's fields (partial — only provided fields change). fields is a map of internal column name to value.", inputSchema: { site: SITE, list: LIST, itemId: z.string().describe("The list item id."), fields: FIELDS }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, }, async ({ site, list, itemId, fields }) => { try { return ok(await client.updateItem(await siteId(site), list, itemId, fields)); } catch (e) { return fail(e); } }, ); server.registerTool( "sharepoint_delete_item", { title: "Delete an item", description: "Delete a list item by id. IRREVERSIBLE (item goes to the site Recycle Bin). Confirm the id before calling.", inputSchema: { site: SITE, list: LIST, itemId: z.string().describe("The list item id.") }, annotations: { readOnlyHint: false, destructiveHint: true }, }, async ({ site, list, itemId }) => { try { await client.deleteItem(await siteId(site), list, itemId); return ok({ ok: true, deleted: itemId }); } catch (e) { return fail(e); } }, ); } return server; } // ---- transports ----------------------------------------------------------- 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 — expiry days-left only) — intentionally // unauthenticated so healthchecks/monitors can watch it. app.get("/health", (_req, res) => { const s = secretExpiry(); res.json({ ok: true, readonly: READONLY, ...(s ? { secretExpiry: { level: s.level, daysLeft: s.daysLeft, expiresAt: s.expiresAt } } : {}), }); }); // 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) ---------------------------------- function loadDotEnv() { for (const dir of [__dirname, join(__dirname, ".."), process.cwd()]) { const file = join(dir, ".env"); if (!existsSync(file)) continue; for (const line of readFileSync(file, "utf8").split("\n")) { const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i); if (!m) continue; let val = m[2].trim(); if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { val = val.slice(1, -1); } if (process.env[m[1]] === undefined) process.env[m[1]] = val; } } }