2026-06-16 14:02:11 -06:00
#!/usr/bin/env node
2026-06-16 14:19:51 -06:00
// SharePoint Lists MCP server.
2026-06-16 14:02:11 -06:00
//
// 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).
//
2026-06-16 14:19:51 -06:00
// 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.
2026-06-16 14:02:11 -06:00
//
// Env:
// SP_TENANT_ID, SP_CLIENT_ID, SP_CLIENT_SECRET (required)
2026-06-16 14:19:51 -06:00
// 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
2026-06-16 14:02:11 -06:00
import { readFileSync , existsSync } from "node:fs" ;
import { dirname , join } from "node:path" ;
import { fileURLToPath } from "node:url" ;
2026-06-16 14:19:51 -06:00
import { createHash , timingSafeEqual } from "node:crypto" ;
2026-06-16 14:02:11 -06:00
import { z } from "zod" ;
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" ;
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" ;
2026-06-16 14:19:51 -06:00
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js" ;
2026-06-16 14:02:11 -06:00
// Single source of truth — the same client the skill's CLI uses.
2026-07-08 15:13:48 -06:00
import { SharePointListsClient , GraphError , secretExpiryStatus } from "../.claude/skills/sharepoint-lists/scripts/lib/graph.mjs" ;
2026-06-16 14:02:11 -06:00
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 ,
} ) ;
2026-07-08 15:13:48 -06:00
// 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 } ` ) ;
}
2026-06-16 14:02:11 -06:00
// ---- 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 ,
} ) ;
2026-06-16 14:19:51 -06:00
/** 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 ) ) ;
}
2026-06-16 14:02:11 -06:00
/** 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." ,
) ;
2026-06-16 14:09:12 -06:00
const LIST = z . string ( ) . describe ( "List display name, internal name, or GUID." ) ;
2026-06-16 14:02:11 -06:00
2026-06-16 14:19:51 -06:00
// ---- server factory (a fresh instance per stdio process / per HTTP request) -
2026-06-16 14:02:11 -06:00
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 {
2026-07-08 15:13:48 -06:00
const result = await client . test ( site || process . env . SP _SITE _URL ) ;
const s = secretExpiry ( ) ;
if ( s ) result . secretExpiry = s ;
return ok ( result ) ;
2026-06-16 14:02:11 -06:00
} 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 ;
}
2026-06-16 14:19:51 -06:00
// ---- transports -----------------------------------------------------------
2026-06-16 14:02:11 -06:00
2026-06-16 14:19:51 -06:00
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" } ) ;
} ;
2026-07-08 15:13:48 -06:00
// 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 } } : { } ) ,
} ) ;
} ) ;
2026-06-16 14:19:51 -06:00
// 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 ) ;
} ) ;
2026-06-16 14:02:11 -06:00
// ---- 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 ;
}
}
}