Add MCP server (stdio + HTTP) reusing the skill's Graph client
New mcp-server/ exposes SharePoint Lists as MCP tools (test, list_lists, get_columns, list_items, get_item, create/update/delete_item) via the same graph.mjs core. Supports stdio (Claude Desktop/Code/Cowork) and Streamable HTTP (--http). SP_READONLY gates write tools. Adds project .mcp.json and ignores build artifacts. Verified end-to-end live over stdio and HTTP initialize. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -8,6 +8,10 @@
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
|
||||
# --- Packaged skill build artifacts ---
|
||||
*.zip
|
||||
*.skill
|
||||
|
||||
# --- OS / editor cruft ---
|
||||
.DS_Store
|
||||
*.swp
|
||||
|
||||
8
.mcp.json
Normal file
8
.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"sharepoint-lists": {
|
||||
"command": "node",
|
||||
"args": ["mcp-server/index.mjs"]
|
||||
}
|
||||
}
|
||||
}
|
||||
13
mcp-server/.env.example
Normal file
13
mcp-server/.env.example
Normal file
@@ -0,0 +1,13 @@
|
||||
# Copy to ".env" in this folder and fill in. Git-ignored — never commit real values.
|
||||
SP_TENANT_ID=00000000-0000-0000-0000-000000000000
|
||||
SP_CLIENT_ID=00000000-0000-0000-0000-000000000000
|
||||
SP_CLIENT_SECRET=your-client-secret-value-here
|
||||
|
||||
# Optional: default site so tools can omit the `site` argument.
|
||||
# SP_SITE_URL=https://contoso.sharepoint.com/sites/Marketing
|
||||
|
||||
# Optional safety: set to true to expose ONLY read tools (no create/update/delete).
|
||||
# SP_READONLY=false
|
||||
|
||||
# Optional: HTTP port when running with --http (default 3838).
|
||||
# PORT=3838
|
||||
81
mcp-server/README.md
Normal file
81
mcp-server/README.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# sharepoint-lists MCP server
|
||||
|
||||
An [MCP](https://modelcontextprotocol.io) server exposing Microsoft SharePoint
|
||||
Lists as tools, using app-only (client-credentials) Microsoft Graph auth. It
|
||||
reuses the same core client as the skill
|
||||
(`../.claude/skills/sharepoint-lists/scripts/lib/graph.mjs`) — one source of
|
||||
truth for the Graph logic.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Access | Purpose |
|
||||
|---|---|---|
|
||||
| `sharepoint_test` | read | Verify credentials; with a site, list its lists |
|
||||
| `sharepoint_list_lists` | read | Enumerate lists in a site |
|
||||
| `sharepoint_get_columns` | read | Column internal names + types (call before writing) |
|
||||
| `sharepoint_list_items` | read | Query items (filter/select/orderby/top/all) |
|
||||
| `sharepoint_get_item` | read | Get one item by id |
|
||||
| `sharepoint_create_item` | write | Create an item |
|
||||
| `sharepoint_update_item` | write | Update an item (partial) |
|
||||
| `sharepoint_delete_item` | write | Delete an item (irreversible) |
|
||||
|
||||
Set `SP_READONLY=true` to register only the read tools.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd mcp-server
|
||||
npm install
|
||||
cp .env.example .env # then fill in SP_TENANT_ID / SP_CLIENT_ID / SP_CLIENT_SECRET
|
||||
```
|
||||
|
||||
Credentials and the optional `SP_SITE_URL` default are documented in
|
||||
`../.claude/skills/sharepoint-lists/references/setup.md`.
|
||||
|
||||
## Run
|
||||
|
||||
**stdio** (Claude Desktop / Claude Code / Cowork — local):
|
||||
|
||||
```bash
|
||||
npm start # node index.mjs
|
||||
```
|
||||
|
||||
**Streamable HTTP** (for a hosted/remote connector):
|
||||
|
||||
```bash
|
||||
npm run start:http # node index.mjs --http → http://localhost:3838/mcp
|
||||
```
|
||||
|
||||
## Connect it
|
||||
|
||||
### Claude Code
|
||||
A project `.mcp.json` at the repo root already registers this server. From the
|
||||
repo root, Claude Code will offer to start it (approve the prompt). Ensure
|
||||
`mcp-server/.env` is filled in first.
|
||||
|
||||
### Claude Desktop (stdio)
|
||||
Add to `claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"sharepoint-lists": {
|
||||
"command": "node",
|
||||
"args": ["/absolute/path/to/Claude-SharepointLists/mcp-server/index.mjs"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Claude.ai web (remote connector)
|
||||
Run with `--http` and expose it over **HTTPS** with authentication (a reverse
|
||||
proxy / tunnel, plus typically OAuth). claude.ai cannot reach `localhost`, and
|
||||
custom connectors expect an authenticated HTTPS MCP endpoint — see the repo
|
||||
README's "Remote hosting" notes before exposing this publicly.
|
||||
|
||||
## Notes
|
||||
|
||||
- App-only auth means there is no per-user permission check — every caller of
|
||||
this server acts as the app. Don't expose it unauthenticated.
|
||||
- Field formats for writes (person/lookup/choice/date) are documented in
|
||||
`../.claude/skills/sharepoint-lists/references/graph-api.md`.
|
||||
308
mcp-server/index.mjs
Normal file
308
mcp-server/index.mjs
Normal file
@@ -0,0 +1,308 @@
|
||||
#!/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) — for Claude Desktop, Claude Code, Cowork (local)
|
||||
// Streamable HTTP (--http or MCP_TRANSPORT=http) — for a hosted/remote connector
|
||||
//
|
||||
// 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
|
||||
// PORT HTTP port (default 3838) when running with --http
|
||||
|
||||
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";
|
||||
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";
|
||||
|
||||
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 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 {
|
||||
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;
|
||||
}
|
||||
|
||||
// ---- 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" }));
|
||||
|
||||
// Stateless: a fresh server + transport per request (no session persistence).
|
||||
app.post("/mcp", 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) });
|
||||
}
|
||||
});
|
||||
// Stateless mode doesn't support the GET (SSE) or DELETE session endpoints.
|
||||
app.get("/mcp", (_req, res) => res.status(405).json({ error: "Method Not Allowed" }));
|
||||
app.delete("/mcp", (_req, res) => res.status(405).json({ error: "Method Not Allowed" }));
|
||||
app.get("/health", (_req, res) => res.json({ ok: true, readonly: READONLY }));
|
||||
|
||||
const port = Number(process.env.PORT) || 3838;
|
||||
app.listen(port, () => {
|
||||
console.error(
|
||||
`[sharepoint-lists] MCP server on http://localhost:${port}/mcp (${READONLY ? "read-only" : "read/write"}).`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
1560
mcp-server/package-lock.json
generated
Normal file
1560
mcp-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
mcp-server/package.json
Normal file
19
mcp-server/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "sharepoint-lists-mcp",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "MCP server for Microsoft SharePoint Lists (app-only Microsoft Graph auth)",
|
||||
"bin": {
|
||||
"sharepoint-lists-mcp": "index.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node index.mjs",
|
||||
"start:http": "node index.mjs --http"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||
"express": "^4.21.0",
|
||||
"zod": "^3.23.8"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user