Files
Claude-SharepointLists/mcp-server/index.mjs
ang3l12 f65cad865d Simplify MCP server to stdio-only (local desktop use)
Drop the Streamable HTTP transport and direct express dependency so the server has no network listener and cannot be reached from web chat — matches the desktop-only use case. Update README/.env.example accordingly. (express remains only as a transitive dep of the MCP SDK; unused.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:09:12 -06:00

263 lines
8.8 KiB
JavaScript

#!/usr/bin/env node
// SharePoint Lists MCP server (stdio).
//
// 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.
//
// 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
import { readFileSync, existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.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";
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,
});
// ---- 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,
});
/** 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 ---------------------------------------------------------------
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 {
return ok(await client.test(site || process.env.SP_SITE_URL));
} 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;
}
// ---- start (stdio) --------------------------------------------------------
const server = buildServer();
await server.connect(new StdioServerTransport());
console.error(
`[sharepoint-lists] MCP server ready on stdio (${READONLY ? "read-only" : "read/write"}).`,
);
// ---- 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;
}
}
}