Compare commits

3 Commits

Author SHA1 Message Date
ang3l12
a4384655d0 README accuracy pass: fix mcp-remote header pattern, add skill install steps
The Desktop config examples showed an inline 'Authorization: Bearer ...' header, which Claude Desktop breaks by splitting args on spaces — replace with the verified env-var pattern (Authorization:${MCP_AUTH_HEADER}, no space). Also: document how the skill is installed/discovered in Claude Code, note the person command in the CLI summary, and correct /health description (it now reports the secret-expiry countdown).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:16:38 -06:00
ang3l12
65fe53710a Add person resolver: email → LookupId for writing person columns
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>
2026-07-08 19:02:25 -06:00
ang3l12
7d41c1ca15 Warn before the client secret expires (SP_SECRET_EXPIRES)
Azure client secrets lapse silently into opaque 401s (AADSTS7000222). Add a self-reported expiry date (SP_SECRET_EXPIRES=YYYY-MM-DD) and a shared secretExpiryStatus() helper in graph.mjs; surface warnings <30 days out via the CLI (stderr on every command + test output), MCP server startup log, /health, and the sharepoint_test tool. Documented in both .env.examples, setup.md, and READMEs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:13:48 -06:00
10 changed files with 229 additions and 18 deletions

View File

@@ -7,5 +7,10 @@ SP_TENANT_ID=00000000-0000-0000-0000-000000000000
SP_CLIENT_ID=00000000-0000-0000-0000-000000000000
SP_CLIENT_SECRET=your-client-secret-value-here
# When the client secret expires (shown in Entra > Certificates & secrets).
# Optional but recommended: the CLI warns on stderr when <30 days remain,
# instead of a surprise 401 later.
# SP_SECRET_EXPIRES=2027-01-01
# Optional: default site so you can omit --site on every command.
# SP_SITE_URL=https://contoso.sharepoint.com/sites/Marketing

View File

@@ -84,6 +84,14 @@ often differ from their display names (a column shown as "Due Date" may be
[references/graph-api.md](references/graph-api.md) for field-type formats
(person, lookup, choice, date, etc.) and OData query details.
To write a **person column**, first resolve the person's email to the numeric
LookupId the column stores, then write `<Column>LookupId`:
```bash
node scripts/sp.mjs person --site "<SITE_URL>" --email user@domain.com
# → {"lookupId": 53, ...}; then: --fields '{"ProjectManagerLookupId": 53}'
```
## Reading items
```bash

View File

@@ -57,10 +57,18 @@ field name is usually `<DisplayInternalName>LookupId`. Getting the right id:
- **Lookup**: the id is the target list item's `id`. Query the source list to
find it.
- **Person**: the id is the user's row id in the site's hidden *User Information
List*. This is awkward to obtain via Graph alone; if you need to set people
fields by email/name often, that's a good reason to add a resolver helper (or
do it via the SharePoint REST `ensureUser` endpoint). Flag this to the user
rather than guessing an id.
List*. Resolve it by email with the built-in resolver — never guess:
```bash
node scripts/sp.mjs person --site "<SITE_URL>" --email nashotay@pescoinc.biz
# → { "lookupId": 53, "displayName": "Nashota Yazzie", "email": "..." }
```
Then write `{"ProjectManagerLookupId": 53}`. Limitation: the hidden list only
contains people who have accessed the site before. SharePoint's `ensureUser`
(which force-adds someone) needs certificate-based app-only auth that client
secrets can't provide — so if the resolver reports "not found", grant the
person site access (or assign them once in the SharePoint UI), then retry.
If a write returns `400 invalidRequest` or `400 generalException`, the field
name or one of these value formats is almost always the cause.

View File

@@ -26,6 +26,9 @@ On the app's **Overview** page, copy:
1. App → **Certificates & secrets****Client secrets****New client secret**.
2. Set an expiry (e.g. 612 months — note when it expires; you'll have to rotate).
3. Copy the secret **Value** immediately (it's only shown once) → `SP_CLIENT_SECRET`.
4. Record the expiry date in `.env` as `SP_SECRET_EXPIRES=YYYY-MM-DD` — the CLI
and MCP server use it to warn you 30 days ahead instead of failing cold with
a 401 (AADSTS7000222) when the secret lapses.
> Certificates are more secure than secrets for production. This skill uses a
> secret for simplicity; swapping to a certificate is a future enhancement.

View File

@@ -310,6 +310,66 @@ export class SharePointListsClient {
return true;
}
// ---- People -------------------------------------------------------------
/**
* Resolve a person to the numeric LookupId that person/group columns store,
* by querying the site's hidden "User Information List" (its item id IS the
* LookupId). Write the result to `<Column>LookupId`, e.g.
* `{ ProjectManagerLookupId: 53 }`.
*
* Limitation: the hidden list only contains people who have touched the site
* (visited it, been granted access, or been referenced before). SharePoint's
* ensureUser endpoint — which force-adds a user — requires certificate-based
* app-only auth that client secrets can't provide, so an unknown user must
* first be given access to the site (or @-mentioned/assigned once in the UI).
*
* @param {string} siteId Graph site id (from resolveSiteId).
* @param {string} email The person's email (case-insensitive).
* @returns {{ lookupId: number, displayName: string, email: string }}
*/
async resolvePersonByEmail(siteId, email) {
const needle = String(email).trim().toLowerCase();
if (!needle) throw new Error("An email is required.");
const base = `/sites/${siteId}/lists/User%20Information%20List/items`;
const headers = { Prefer: "HonorNonIndexedQueriesWarningMayFailRandomly" };
const expand = "fields($select=EMail,UserName,Title)";
const esc = needle.replace(/'/g, "''");
// Fast path: server-side filter on EMail, then UserName (some entries only
// carry the address there).
for (const field of ["EMail", "UserName"]) {
try {
const data = await this.graph("GET", base, {
query: { expand, $filter: `fields/${field} eq '${esc}'` },
headers,
});
if (data.value?.length) return personHit(data.value[0]);
} catch {
break; // non-indexed filter refused — fall through to the full scan
}
}
// Slow path: scan the list (it's small on most sites) and match in code.
let data = await this.graph("GET", base, { query: { expand, $top: 200 }, headers });
for (;;) {
for (const item of data.value) {
const f = item.fields ?? {};
if ([f.EMail, f.UserName].some((v) => v?.toLowerCase() === needle)) {
return personHit(item);
}
}
if (!data["@odata.nextLink"]) break;
data = await this.graph("GET", data["@odata.nextLink"], { headers });
}
throw new Error(
`No user with email "${email}" in this site's user information list. ` +
`They may never have accessed the site — grant them access (or assign ` +
`them to any item once in the SharePoint UI), then retry.`,
);
}
// ---- Convenience --------------------------------------------------------
/** Verify credentials + connectivity. Returns the resolved site (if given). */
@@ -327,6 +387,59 @@ export class SharePointListsClient {
}
}
/**
* Report how close the app's client secret is to expiring, so consumers can
* warn BEFORE auth starts failing with an opaque 401 (AADSTS7000222).
*
* Azure can't be queried for its own secret's expiry with app-only creds, so
* the date is self-reported: set SP_SECRET_EXPIRES=YYYY-MM-DD in .env when you
* create/rotate the secret (the Entra portal shows it in Certificates & secrets).
*
* @param {string} [expiresStr] The configured date; falsy → null (feature unused).
* @returns {null | { expiresAt: string, daysLeft: number, level: "ok"|"warning"|"expired", message: string|null }}
*/
export function secretExpiryStatus(expiresStr, { warnDays = 30 } = {}) {
if (!expiresStr) return null;
const exp = new Date(expiresStr);
if (Number.isNaN(exp.getTime())) {
return {
expiresAt: expiresStr,
daysLeft: NaN,
level: "warning",
message: `SP_SECRET_EXPIRES is set but unparseable ("${expiresStr}") — use YYYY-MM-DD.`,
};
}
const daysLeft = Math.floor((exp.getTime() - Date.now()) / 86_400_000);
const expiresAt = exp.toISOString().slice(0, 10);
if (daysLeft < 0) {
return {
expiresAt,
daysLeft,
level: "expired",
message: `Client secret EXPIRED ${-daysLeft} day(s) ago (${expiresAt}). Auth will fail with 401 — create a new secret in Entra (Certificates & secrets), update SP_CLIENT_SECRET and SP_SECRET_EXPIRES.`,
};
}
if (daysLeft <= warnDays) {
return {
expiresAt,
daysLeft,
level: "warning",
message: `Client secret expires in ${daysLeft} day(s) (${expiresAt}) — rotate it soon: new secret in Entra, then update SP_CLIENT_SECRET and SP_SECRET_EXPIRES.`,
};
}
return { expiresAt, daysLeft, level: "ok", message: null };
}
/** Shape a User Information List item into a person-resolution result. */
function personHit(item) {
const f = item.fields ?? {};
return {
lookupId: Number(item.id),
displayName: f.Title ?? "",
email: f.EMail || f.UserName || "",
};
}
/** Flatten the Graph item shape into { id, fields, webUrl, ...meta }. */
function simplifyItem(item) {
if (!item) return item;

View File

@@ -25,7 +25,7 @@
import { readFileSync, existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { SharePointListsClient, GraphError } from "./lib/graph.mjs";
import { SharePointListsClient, GraphError, secretExpiryStatus } from "./lib/graph.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -57,6 +57,11 @@ async function main() {
clientSecret: env("SP_CLIENT_SECRET", "AZURE_CLIENT_SECRET"),
});
// Nag (on stderr, so stdout stays clean JSON) when the client secret is
// close to its self-reported expiry — see secretExpiryStatus in graph.mjs.
const expiry = secretExpiryStatus(process.env.SP_SECRET_EXPIRES);
if (expiry?.message) process.stderr.write(`${expiry.message}\n`);
const site = flags.site || process.env.SP_SITE_URL;
const needSite = () => {
if (!site) throw new Error("Missing --site URL (or set SP_SITE_URL).");
@@ -75,6 +80,7 @@ async function main() {
switch (command) {
case "test":
result = await client.test(site);
if (expiry) result.secretExpiry = expiry;
break;
case "lists":
result = await client.listLists(await needSite());
@@ -94,6 +100,11 @@ async function main() {
case "get":
result = await client.getItem(await needSite(), needList(), needId());
break;
case "person": {
if (!flags.email) throw new Error("Missing --email user@domain.com");
result = await client.resolvePersonByEmail(await needSite(), flags.email);
break;
}
case "create":
result = await client.createItem(await needSite(), needList(), readFields(flags));
break;
@@ -181,6 +192,7 @@ Commands:
columns --site URL --list NAME_OR_ID Show column internal names + types
items --site URL --list NAME_OR_ID [filters] Query list items
get --site URL --list NAME_OR_ID --id ID Get one item
person --site URL --email USER@DOMAIN Resolve a person to the LookupId used by person columns
create --site URL --list NAME_OR_ID --fields J Create an item
update --site URL --list NAME_OR_ID --id ID --fields J Update an item
delete --site URL --list NAME_OR_ID --id ID Delete an item

View File

@@ -20,7 +20,7 @@ The repo ships the same capability in two forms, sharing one core Graph client
│ ├── SKILL.md # how the skill works + usage
│ ├── .env.example # credential template (.env is git-ignored)
│ ├── scripts/
│ │ ├── sp.mjs # CLI: test|lists|columns|items|get|create|update|delete
│ │ ├── sp.mjs # CLI: test|lists|columns|items|get|person|create|update|delete
│ │ └── lib/graph.mjs # shared, dependency-free Graph client (single source of truth)
│ └── references/
│ ├── setup.md # Azure AD (Entra) app registration walkthrough
@@ -48,6 +48,12 @@ Credentials live only in git-ignored `.env` files — **never commit secrets.**
## Option A — the skill (Claude Code)
**Install:** clone this repo and open it in Claude Code — skills under
`.claude/skills/` are discovered automatically at session start (invokable as
`/sharepoint-lists`). To use the skill in a *different* project, copy the
`.claude/skills/sharepoint-lists/` folder into that project (or into
`~/.claude/skills/` to make it available everywhere), then create its `.env`:
```bash
cp .claude/skills/sharepoint-lists/.env.example .claude/skills/sharepoint-lists/.env
# fill in SP_TENANT_ID / SP_CLIENT_ID / SP_CLIENT_SECRET (optionally SP_SITE_URL)
@@ -75,6 +81,7 @@ skill uses, so there is one source of truth.
| `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_resolve_person` | read | Email → the LookupId person columns store (for writes) |
| `sharepoint_create_item` | write | Create an item |
| `sharepoint_update_item` | write | Update an item (partial) |
| `sharepoint_delete_item` | write | Delete an item (irreversible) |
@@ -97,6 +104,7 @@ a containerized/remote deployment **must** use the HTTP transport.
|---|---|---|
| `SP_TENANT_ID` / `SP_CLIENT_ID` / `SP_CLIENT_SECRET` | yes | from the app registration |
| `SP_SITE_URL` | no | default site so tools can omit the `site` argument |
| `SP_SECRET_EXPIRES` | no | client secret expiry (`YYYY-MM-DD`); warns via CLI/`/health`/`sharepoint_test` when <30 days remain |
| `SP_READONLY` | no | `true` → read-only tool set |
| `MCP_AUTH_TOKEN` | HTTP only | shared bearer token required on `/mcp`; empty = unauthenticated |
| `PORT` | HTTP only | listen port (default 3838) |
@@ -127,7 +135,7 @@ curl -s http://localhost:3838/health # -> {"ok":true,...}
The service has `restart: unless-stopped` and a healthcheck. The port publishes on
all host interfaces; restrict it to a LAN IP in `docker-compose.yml` (or via
firewall) if desired — the bearer token guards `/mcp` regardless. `/health` is
intentionally open (liveness only, returns no data).
intentionally open (liveness + secret-expiry countdown only; no secrets).
Full hosting details: [mcp-server/README.md](mcp-server/README.md).
@@ -161,16 +169,19 @@ server automatically.
"-y", "mcp-remote",
"http://<host>:3838/mcp",
"--allow-http",
"--header", "Authorization: Bearer ${SP_MCP_TOKEN}"
"--header", "Authorization:${MCP_AUTH_HEADER}"
],
"env": { "SP_MCP_TOKEN": "<your MCP_AUTH_TOKEN>" }
"env": { "MCP_AUTH_HEADER": "Bearer <your MCP_AUTH_TOKEN>" }
}
}
}
```
`--allow-http` permits a plain-HTTP LAN URL; the token must match the container's
`MCP_AUTH_TOKEN`. Fully quit and reopen Claude Desktop after editing the config.
`MCP_AUTH_TOKEN`. Note the header value is passed via the env var (including the
`Bearer ` prefix) and there is **no space after `Authorization:`** — Claude
Desktop splits config args on spaces, so an inline `Bearer <token>` breaks.
Fully quit and reopen Claude Desktop after editing the config.
---

View File

@@ -3,6 +3,11 @@ SP_TENANT_ID=00000000-0000-0000-0000-000000000000
SP_CLIENT_ID=00000000-0000-0000-0000-000000000000
SP_CLIENT_SECRET=your-client-secret-value-here
# When the client secret expires (shown in Entra > Certificates & secrets).
# Optional but recommended: the server warns at startup, in /health, and in the
# sharepoint_test tool when <30 days remain — instead of a surprise 401 later.
# SP_SECRET_EXPIRES=2027-01-01
# Optional: default site so tools can omit the `site` argument.
# SP_SITE_URL=https://contoso.sharepoint.com/sites/Marketing

View File

@@ -26,6 +26,7 @@ on a different machine.
| `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_resolve_person` | read | Email → the LookupId person columns store (for writes) |
| `sharepoint_create_item` | write | Create an item |
| `sharepoint_update_item` | write | Update an item (partial) |
| `sharepoint_delete_item` | write | Delete an item (irreversible) |
@@ -41,6 +42,7 @@ Credentials come from the environment or `mcp-server/.env` (git-ignored). See
|---|---|---|
| `SP_TENANT_ID` / `SP_CLIENT_ID` / `SP_CLIENT_SECRET` | yes | app registration |
| `SP_SITE_URL` | no | default site so tools can omit `site` |
| `SP_SECRET_EXPIRES` | no | client secret's expiry date (`YYYY-MM-DD`); warns at startup, in `/health`, and in `sharepoint_test` when <30 days remain |
| `SP_READONLY` | no | `true` → read-only tool set |
| `MCP_AUTH_TOKEN` | HTTP only | shared bearer token required on `/mcp`; empty = unauthenticated |
| `PORT` | HTTP only | listen port (default 3838) |
@@ -89,16 +91,18 @@ Desktop:
"-y", "mcp-remote",
"http://192.168.101.12:3838/mcp",
"--allow-http",
"--header", "Authorization: Bearer ${SP_MCP_TOKEN}"
"--header", "Authorization:${MCP_AUTH_HEADER}"
],
"env": { "SP_MCP_TOKEN": "<your MCP_AUTH_TOKEN>" }
"env": { "MCP_AUTH_HEADER": "Bearer <your MCP_AUTH_TOKEN>" }
}
}
}
```
`--allow-http` permits the plain-HTTP LAN URL (no TLS). The token must match the
container's `MCP_AUTH_TOKEN`.
container's `MCP_AUTH_TOKEN`. The header value goes through the env var (with the
`Bearer ` prefix) and there is **no space after `Authorization:`** — Claude
Desktop splits config args on spaces, so an inline `Bearer <token>` breaks.
### Claude Desktop → local stdio (no container)
@@ -122,6 +126,7 @@ The repo-root `.mcp.json` registers the local stdio server automatically.
- App-only auth = the server has full access to the granted SharePoint sites
with no per-user check. Anyone who can reach an unauthenticated `/mcp` has that
access — always set `MCP_AUTH_TOKEN` for the HTTP transport.
- `/health` is intentionally unauthenticated (liveness only; returns no data).
- `/health` is intentionally unauthenticated (liveness + secret-expiry countdown
only; no secrets).
- Field formats for writes (person/lookup/choice/date) are in
`../.claude/skills/sharepoint-lists/references/graph-api.md`.

View File

@@ -29,7 +29,7 @@ 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";
import { SharePointListsClient, GraphError, secretExpiryStatus } from "../.claude/skills/sharepoint-lists/scripts/lib/graph.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
loadDotEnv();
@@ -42,6 +42,15 @@ const client = new SharePointListsClient({
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) => ({
@@ -97,7 +106,10 @@ function buildServer() {
},
async ({ site }) => {
try {
return ok(await client.test(site || process.env.SP_SITE_URL));
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);
}
@@ -184,6 +196,27 @@ function buildServer() {
},
);
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())
@@ -277,8 +310,16 @@ async function runHttp() {
res.status(401).json({ error: "Unauthorized" });
};
// Liveness probe (no secrets)intentionally unauthenticated for healthchecks.
app.get("/health", (_req, res) => res.json({ ok: true, readonly: READONLY }));
// 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) => {