diff --git a/.claude/skills/sharepoint-lists/SKILL.md b/.claude/skills/sharepoint-lists/SKILL.md index 98565ed..29e654a 100644 --- a/.claude/skills/sharepoint-lists/SKILL.md +++ b/.claude/skills/sharepoint-lists/SKILL.md @@ -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 `LookupId`: + +```bash +node scripts/sp.mjs person --site "" --email user@domain.com +# → {"lookupId": 53, ...}; then: --fields '{"ProjectManagerLookupId": 53}' +``` + ## Reading items ```bash diff --git a/.claude/skills/sharepoint-lists/references/graph-api.md b/.claude/skills/sharepoint-lists/references/graph-api.md index 4b1450d..44ef0e9 100644 --- a/.claude/skills/sharepoint-lists/references/graph-api.md +++ b/.claude/skills/sharepoint-lists/references/graph-api.md @@ -57,10 +57,18 @@ field name is usually `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 "" --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. diff --git a/.claude/skills/sharepoint-lists/scripts/lib/graph.mjs b/.claude/skills/sharepoint-lists/scripts/lib/graph.mjs index 8985797..488118e 100644 --- a/.claude/skills/sharepoint-lists/scripts/lib/graph.mjs +++ b/.claude/skills/sharepoint-lists/scripts/lib/graph.mjs @@ -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 `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). */ @@ -370,6 +430,16 @@ export function secretExpiryStatus(expiresStr, { warnDays = 30 } = {}) { 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; diff --git a/.claude/skills/sharepoint-lists/scripts/sp.mjs b/.claude/skills/sharepoint-lists/scripts/sp.mjs index 9e724ef..911b080 100644 --- a/.claude/skills/sharepoint-lists/scripts/sp.mjs +++ b/.claude/skills/sharepoint-lists/scripts/sp.mjs @@ -100,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; @@ -187,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 diff --git a/README.md b/README.md index 661c19a..68d37dc 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,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) | diff --git a/mcp-server/README.md b/mcp-server/README.md index 735965c..841703e 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -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) | diff --git a/mcp-server/index.mjs b/mcp-server/index.mjs index f243a96..237c8d5 100644 --- a/mcp-server/index.mjs +++ b/mcp-server/index.mjs @@ -196,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())