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>
This commit is contained in:
@@ -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
|
[references/graph-api.md](references/graph-api.md) for field-type formats
|
||||||
(person, lookup, choice, date, etc.) and OData query details.
|
(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
|
## Reading items
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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
|
- **Lookup**: the id is the target list item's `id`. Query the source list to
|
||||||
find it.
|
find it.
|
||||||
- **Person**: the id is the user's row id in the site's hidden *User Information
|
- **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
|
List*. Resolve it by email with the built-in resolver — never guess:
|
||||||
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
|
```bash
|
||||||
rather than guessing an id.
|
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
|
If a write returns `400 invalidRequest` or `400 generalException`, the field
|
||||||
name or one of these value formats is almost always the cause.
|
name or one of these value formats is almost always the cause.
|
||||||
|
|||||||
@@ -310,6 +310,66 @@ export class SharePointListsClient {
|
|||||||
return true;
|
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 --------------------------------------------------------
|
// ---- Convenience --------------------------------------------------------
|
||||||
|
|
||||||
/** Verify credentials + connectivity. Returns the resolved site (if given). */
|
/** 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 };
|
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 }. */
|
/** Flatten the Graph item shape into { id, fields, webUrl, ...meta }. */
|
||||||
function simplifyItem(item) {
|
function simplifyItem(item) {
|
||||||
if (!item) return item;
|
if (!item) return item;
|
||||||
|
|||||||
@@ -100,6 +100,11 @@ async function main() {
|
|||||||
case "get":
|
case "get":
|
||||||
result = await client.getItem(await needSite(), needList(), needId());
|
result = await client.getItem(await needSite(), needList(), needId());
|
||||||
break;
|
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":
|
case "create":
|
||||||
result = await client.createItem(await needSite(), needList(), readFields(flags));
|
result = await client.createItem(await needSite(), needList(), readFields(flags));
|
||||||
break;
|
break;
|
||||||
@@ -187,6 +192,7 @@ Commands:
|
|||||||
columns --site URL --list NAME_OR_ID Show column internal names + types
|
columns --site URL --list NAME_OR_ID Show column internal names + types
|
||||||
items --site URL --list NAME_OR_ID [filters] Query list items
|
items --site URL --list NAME_OR_ID [filters] Query list items
|
||||||
get --site URL --list NAME_OR_ID --id ID Get one item
|
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
|
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
|
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
|
delete --site URL --list NAME_OR_ID --id ID Delete an item
|
||||||
|
|||||||
@@ -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_get_columns` | read | Column internal names + types (call before writing) |
|
||||||
| `sharepoint_list_items` | read | Query items (filter/select/orderby/top/all) |
|
| `sharepoint_list_items` | read | Query items (filter/select/orderby/top/all) |
|
||||||
| `sharepoint_get_item` | read | Get one item by id |
|
| `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_create_item` | write | Create an item |
|
||||||
| `sharepoint_update_item` | write | Update an item (partial) |
|
| `sharepoint_update_item` | write | Update an item (partial) |
|
||||||
| `sharepoint_delete_item` | write | Delete an item (irreversible) |
|
| `sharepoint_delete_item` | write | Delete an item (irreversible) |
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ on a different machine.
|
|||||||
| `sharepoint_get_columns` | read | Column internal names + types (call before writing) |
|
| `sharepoint_get_columns` | read | Column internal names + types (call before writing) |
|
||||||
| `sharepoint_list_items` | read | Query items (filter/select/orderby/top/all) |
|
| `sharepoint_list_items` | read | Query items (filter/select/orderby/top/all) |
|
||||||
| `sharepoint_get_item` | read | Get one item by id |
|
| `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_create_item` | write | Create an item |
|
||||||
| `sharepoint_update_item` | write | Update an item (partial) |
|
| `sharepoint_update_item` | write | Update an item (partial) |
|
||||||
| `sharepoint_delete_item` | write | Delete an item (irreversible) |
|
| `sharepoint_delete_item` | write | Delete an item (irreversible) |
|
||||||
|
|||||||
@@ -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) {
|
if (!READONLY) {
|
||||||
const FIELDS = z
|
const FIELDS = z
|
||||||
.record(z.any())
|
.record(z.any())
|
||||||
|
|||||||
Reference in New Issue
Block a user