Person/group columns store a numeric LookupId into each site's hidden User Information List, and SPO's ensureUser needs certificate auth that client secrets can't provide — so resolve by querying the hidden list directly via Graph (item id IS the LookupId). New resolvePersonByEmail() in graph.mjs (server-side EMail/UserName filter with a paged case-insensitive scan fallback), a 'person' CLI command, and a sharepoint_resolve_person MCP tool (9 tools now). Verified live against known ground truth (LookupIds 53 and 136) plus the not-found path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
374 lines
13 KiB
JavaScript
374 lines
13 KiB
JavaScript
#!/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);
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"sharepoint_resolve_person",
|
|
{
|
|
title: "Resolve a person to their LookupId",
|
|
description:
|
|
"Look up the numeric LookupId a person/group column stores, by email. Use the result when writing person fields, e.g. fields {\"ProjectManagerLookupId\": 53}. Only finds people who have accessed the site before; if not found, grant them site access first and retry.",
|
|
inputSchema: {
|
|
site: SITE,
|
|
email: z.string().describe("The person's email address (case-insensitive)."),
|
|
},
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
async ({ site, email }) => {
|
|
try {
|
|
return ok(await client.resolvePersonByEmail(await siteId(site), email));
|
|
} 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;
|
|
}
|
|
}
|
|
}
|