Initial import: SharePoint Lists skill
Claude Code skill for SharePoint Lists CRUD via Microsoft Graph (app-only auth): reusable graph.mjs client, sp.mjs CLI, and setup + API reference docs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
112
.claude/skills/sharepoint-lists/references/graph-api.md
Normal file
112
.claude/skills/sharepoint-lists/references/graph-api.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Graph API reference for SharePoint List items
|
||||
|
||||
Quick reference for the parts of the Microsoft Graph Lists API that this skill
|
||||
relies on. Full docs: <https://learn.microsoft.com/graph/api/resources/listitem>.
|
||||
|
||||
## Internal vs. display column names
|
||||
|
||||
Graph reads and writes use a column's **internal name**, which is frequently NOT
|
||||
the display name you see in the UI:
|
||||
|
||||
- Spaces and special characters are encoded: "Due Date" → `DueDate`; a column
|
||||
later renamed keeps its original internal name; some become `OData__x005f...`.
|
||||
- Always discover internal names before writing:
|
||||
`node scripts/sp.mjs columns --site "<URL>" --list "<NAME>"`.
|
||||
- In the `columns` output, the `name` field is the internal name; `displayName`
|
||||
is what the UI shows. Write to `name`.
|
||||
|
||||
`Title` is the built-in primary text column on most lists.
|
||||
|
||||
## Field value formats by column type
|
||||
|
||||
When creating/updating, `--fields` is a JSON object of internal name → value:
|
||||
|
||||
| Column type | JSON value | Example |
|
||||
|---|---|---|
|
||||
| Single line / multi-line text | string | `{"Title":"Hello"}` |
|
||||
| Number / Currency | number | `{"Estimate":3.5}` |
|
||||
| Yes/No (boolean) | boolean | `{"Approved":true}` |
|
||||
| Date / DateTime | ISO 8601 string (UTC) | `{"DueDate":"2026-07-01T00:00:00Z"}` |
|
||||
| Choice (single) | the choice string | `{"Status":"In Progress"}` |
|
||||
| Choice (multi) | needs an OData type hint (see below) | |
|
||||
| Hyperlink | `{ "Url": "...", "Description": "..." }` | `{"Link":{"Url":"https://x","Description":"X"}}` |
|
||||
| Lookup (single) | set `<Name>LookupId` to the target item id | `{"CategoryLookupId":7}` |
|
||||
| Lookup (multi) | OData type hint + array of ids (see below) | |
|
||||
| Person/Group (single) | set `<Name>LookupId` to the user's lookup id | `{"AssignedToLookupId":12}` |
|
||||
|
||||
### Multi-value fields need an `@odata.type` annotation
|
||||
|
||||
Graph requires you to declare the collection type alongside the value. Include
|
||||
**both** keys in the fields object:
|
||||
|
||||
```json
|
||||
{
|
||||
"Categories@odata.type": "Collection(Edm.String)",
|
||||
"Categories": ["Marketing", "Sales"],
|
||||
|
||||
"RelatedItemsLookupId@odata.type": "Collection(Edm.Int32)",
|
||||
"RelatedItemsLookupId": [3, 9, 14]
|
||||
}
|
||||
```
|
||||
|
||||
### Person / Lookup ids
|
||||
|
||||
Person and lookup columns store an **integer id**, not a name/email. The internal
|
||||
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.
|
||||
|
||||
If a write returns `400 invalidRequest` or `400 generalException`, the field
|
||||
name or one of these value formats is almost always the cause.
|
||||
|
||||
## Querying items (OData)
|
||||
|
||||
The CLI maps flags to OData query options on
|
||||
`/sites/{site}/lists/{list}/items`:
|
||||
|
||||
- `--filter` → `$filter`. Filter on fields with the `fields/` prefix:
|
||||
- `fields/Status eq 'Open'`
|
||||
- `fields/Priority ge 2`
|
||||
- `fields/Title eq 'Exact'` (text is case-sensitive in eq)
|
||||
- `startswith(fields/Title,'Mig')`
|
||||
- combine with `and` / `or`: `fields/Status eq 'Open' and fields/Priority ge 2`
|
||||
- `--orderby` → `$orderby`, e.g. `fields/DueDate asc` or `fields/Created desc`.
|
||||
- `--select` → projects which fields come back: `Title,Status,DueDate`.
|
||||
- `--top` → page size; `--all` follows `@odata.nextLink` to return every page.
|
||||
|
||||
### Non-indexed columns
|
||||
|
||||
SharePoint only allows server-side filter/sort on **indexed** columns unless you
|
||||
opt into a best-effort mode. The client automatically sends
|
||||
`Prefer: HonorNonIndexedQueriesWarningMayFailRandomly` whenever `--filter` or
|
||||
`--orderby` is present, which lets queries on non-indexed columns run (they may
|
||||
occasionally fail on very large lists — add a column index in SharePoint for
|
||||
heavy use). If a filter intermittently errors on a big list, that's why.
|
||||
|
||||
### Escaping quotes
|
||||
|
||||
OData string literals use single quotes; a literal single quote is doubled:
|
||||
`fields/Title eq 'O''Brien'`. In the shell, wrap the whole `--filter` value in
|
||||
double quotes.
|
||||
|
||||
## Response shape
|
||||
|
||||
Reads return items simplified to:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "42",
|
||||
"webUrl": "https://contoso.sharepoint.com/sites/.../DispForm.aspx?ID=42",
|
||||
"createdDateTime": "2026-06-01T10:00:00Z",
|
||||
"lastModifiedDateTime": "2026-06-10T12:30:00Z",
|
||||
"fields": { "Title": "...", "Status": "Open", "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
`id` is the item id you pass to `get`, `update`, and `delete`.
|
||||
124
.claude/skills/sharepoint-lists/references/setup.md
Normal file
124
.claude/skills/sharepoint-lists/references/setup.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Setup: Azure AD app registration for app-only SharePoint access
|
||||
|
||||
This skill authenticates as an **application** (no user sign-in) using the OAuth2
|
||||
client-credentials flow. You create an Entra (Azure AD) app registration once,
|
||||
grant it permission to SharePoint, and hand the skill three secrets.
|
||||
|
||||
You need an Entra **admin** (or someone who is) to grant admin consent and, for
|
||||
the least-privilege option, to grant the app access to specific sites.
|
||||
|
||||
## 1. Register the application
|
||||
|
||||
1. Go to <https://entra.microsoft.com> → **Identity** → **Applications** →
|
||||
**App registrations** → **New registration**.
|
||||
2. Name it something recognizable, e.g. `claude-sharepoint-lists`.
|
||||
3. Supported account types: **Accounts in this organizational directory only**
|
||||
(single tenant) is correct for app-only.
|
||||
4. Leave **Redirect URI** blank — client-credentials doesn't use one.
|
||||
5. **Register**.
|
||||
|
||||
On the app's **Overview** page, copy:
|
||||
- **Application (client) ID** → `SP_CLIENT_ID`
|
||||
- **Directory (tenant) ID** → `SP_TENANT_ID`
|
||||
|
||||
## 2. Create a client secret
|
||||
|
||||
1. App → **Certificates & secrets** → **Client secrets** → **New client secret**.
|
||||
2. Set an expiry (e.g. 6–12 months — note when it expires; you'll have to rotate).
|
||||
3. Copy the secret **Value** immediately (it's only shown once) → `SP_CLIENT_SECRET`.
|
||||
|
||||
> Certificates are more secure than secrets for production. This skill uses a
|
||||
> secret for simplicity; swapping to a certificate is a future enhancement.
|
||||
|
||||
## 3. Grant a Microsoft Graph application permission
|
||||
|
||||
App → **API permissions** → **Add a permission** → **Microsoft Graph** →
|
||||
**Application permissions**. Pick **one** of:
|
||||
|
||||
| Permission | Scope | Use when |
|
||||
|---|---|---|
|
||||
| **`Sites.Selected`** | Only sites an admin explicitly grants (see step 4) | **Preferred.** Least privilege — the app can touch only the sites you allow. |
|
||||
| `Sites.ReadWrite.All` | Read/write items in **all** site collections | Simpler, but broad. Use only if `Sites.Selected` isn't workable. |
|
||||
|
||||
Then click **Grant admin consent for <tenant>** and confirm the status shows
|
||||
a green check. Without consent, every call returns `401`/`403`.
|
||||
|
||||
## 4. (Only for `Sites.Selected`) Grant the app access to a site
|
||||
|
||||
`Sites.Selected` grants nothing until an admin authorizes the app on each site.
|
||||
Do this per site you want the skill to use. Two ways:
|
||||
|
||||
**Option A — PnP PowerShell (simplest for admins):**
|
||||
|
||||
```powershell
|
||||
Install-Module PnP.PowerShell -Scope CurrentUser # once
|
||||
Connect-PnPOnline -Url "https://contoso.sharepoint.com/sites/Marketing" -Interactive
|
||||
Grant-PnPAzureADAppSitePermission `
|
||||
-AppId "<SP_CLIENT_ID>" `
|
||||
-DisplayName "claude-sharepoint-lists" `
|
||||
-Site "https://contoso.sharepoint.com/sites/Marketing" `
|
||||
-Permissions Write # Read | Write | Manage | FullControl
|
||||
```
|
||||
|
||||
**Option B — Microsoft Graph REST** (e.g. via Graph Explorer signed in as an
|
||||
admin with `Sites.FullControl.All` delegated). First get the site id, then grant:
|
||||
|
||||
```http
|
||||
GET https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/Marketing
|
||||
POST https://graph.microsoft.com/v1.0/sites/{siteId}/permissions
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"roles": ["write"],
|
||||
"grantedToIdentities": [
|
||||
{ "application": { "id": "<SP_CLIENT_ID>", "displayName": "claude-sharepoint-lists" } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Use `read` instead of `write` for read-only access to that site.
|
||||
|
||||
## 5. Give the skill the credentials
|
||||
|
||||
Copy `.env.example` to `.env` in the skill directory and fill in the three
|
||||
values (and optionally `SP_SITE_URL`). The CLI loads `.env` automatically, and
|
||||
`.env` is git-ignored so secrets aren't committed. Alternatively, export them as
|
||||
environment variables.
|
||||
|
||||
## 6. Verify
|
||||
|
||||
```bash
|
||||
node scripts/sp.mjs test --site "https://contoso.sharepoint.com/sites/Marketing"
|
||||
```
|
||||
|
||||
Expected: `{ "ok": true, "siteId": "...", "listCount": N, "lists": [...] }`.
|
||||
|
||||
Troubleshooting:
|
||||
- **Token request failed (401)** — wrong tenant id, client id, or secret (or the
|
||||
secret expired). Re-copy from the portal.
|
||||
- **403 accessDenied** — admin consent not granted (step 3), or with
|
||||
`Sites.Selected` the site wasn't granted to the app (step 4).
|
||||
- **Works for `test` (no site) but 403 with `--site`** — classic `Sites.Selected`
|
||||
signal: token is fine, site grant is missing.
|
||||
|
||||
---
|
||||
|
||||
## Note: graduating to an MCP server
|
||||
|
||||
When you want this available across all sessions as native tools rather than a
|
||||
CLI, build a small MCP server that imports `scripts/lib/graph.mjs`:
|
||||
|
||||
```js
|
||||
import { SharePointListsClient } from "./graph.mjs";
|
||||
const client = new SharePointListsClient({
|
||||
tenantId: process.env.SP_TENANT_ID,
|
||||
clientId: process.env.SP_CLIENT_ID,
|
||||
clientSecret: process.env.SP_CLIENT_SECRET,
|
||||
});
|
||||
// Expose client.listItems / getItem / createItem / updateItem / deleteItem
|
||||
// as MCP tools. The Graph + auth logic is already done.
|
||||
```
|
||||
|
||||
The client is dependency-free and stateless apart from an in-memory token cache,
|
||||
so it drops straight into an MCP server (`@modelcontextprotocol/sdk`) or any
|
||||
other host.
|
||||
Reference in New Issue
Block a user