From fd957177b918e6c275d534c99a9734b39e28255b Mon Sep 17 00:00:00 2001 From: ang3l12 Date: Tue, 16 Jun 2026 11:12:50 -0600 Subject: [PATCH] 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) --- .claude/skills/sharepoint-lists/.env.example | 11 + .claude/skills/sharepoint-lists/.gitignore | 3 + .claude/skills/sharepoint-lists/SKILL.md | 155 ++++++++ .../sharepoint-lists/references/graph-api.md | 112 ++++++ .../sharepoint-lists/references/setup.md | 124 +++++++ .../sharepoint-lists/scripts/lib/graph.mjs | 341 ++++++++++++++++++ .../skills/sharepoint-lists/scripts/sp.mjs | 197 ++++++++++ .gitignore | 15 + README.md | 39 ++ 9 files changed, 997 insertions(+) create mode 100644 .claude/skills/sharepoint-lists/.env.example create mode 100644 .claude/skills/sharepoint-lists/.gitignore create mode 100644 .claude/skills/sharepoint-lists/SKILL.md create mode 100644 .claude/skills/sharepoint-lists/references/graph-api.md create mode 100644 .claude/skills/sharepoint-lists/references/setup.md create mode 100644 .claude/skills/sharepoint-lists/scripts/lib/graph.mjs create mode 100644 .claude/skills/sharepoint-lists/scripts/sp.mjs create mode 100644 .gitignore create mode 100644 README.md diff --git a/.claude/skills/sharepoint-lists/.env.example b/.claude/skills/sharepoint-lists/.env.example new file mode 100644 index 0000000..dd8beec --- /dev/null +++ b/.claude/skills/sharepoint-lists/.env.example @@ -0,0 +1,11 @@ +# Copy this file to ".env" (same directory) and fill in real values. +# The CLI loads .env automatically. NEVER commit the real .env (it is git-ignored). +# +# From the Azure AD (Entra) app registration — see references/setup.md. + +SP_TENANT_ID=00000000-0000-0000-0000-000000000000 +SP_CLIENT_ID=00000000-0000-0000-0000-000000000000 +SP_CLIENT_SECRET=your-client-secret-value-here + +# Optional: default site so you can omit --site on every command. +# SP_SITE_URL=https://contoso.sharepoint.com/sites/Marketing diff --git a/.claude/skills/sharepoint-lists/.gitignore b/.claude/skills/sharepoint-lists/.gitignore new file mode 100644 index 0000000..8ec95b7 --- /dev/null +++ b/.claude/skills/sharepoint-lists/.gitignore @@ -0,0 +1,3 @@ +# Never commit real credentials. +.env +*.env.local diff --git a/.claude/skills/sharepoint-lists/SKILL.md b/.claude/skills/sharepoint-lists/SKILL.md new file mode 100644 index 0000000..98565ed --- /dev/null +++ b/.claude/skills/sharepoint-lists/SKILL.md @@ -0,0 +1,155 @@ +--- +name: sharepoint-lists +description: >- + Read and write Microsoft SharePoint Lists via the Microsoft Graph API using + app-only (client-credentials) authentication. Use this skill whenever the user + wants to query, search, list, create, add, update, edit, or delete items in a + SharePoint List, inspect a list's columns, or enumerate the lists in a + SharePoint site — even if they just refer to "our SharePoint", "the list", + "the tracker", or a specific list/site name rather than saying "SharePoint + List" explicitly. Also use it for bulk operations over list items (export, + reporting, batch updates) and for connectivity/credential troubleshooting + against SharePoint. This is for SharePoint *Lists* (structured rows/columns), + not document libraries or files — for documents use the SharePoint search + connector instead. +--- + +# SharePoint Lists + +Connect to Microsoft SharePoint Lists and perform full CRUD (create, read, +update, delete) on list items through the Microsoft Graph API. Authentication is +**app-only** (OAuth2 client credentials) — no interactive login. + +All operations go through one CLI, `scripts/sp.mjs`, which wraps the reusable +core client in `scripts/lib/graph.mjs`. Run it with Node (already available): + +```bash +node scripts/sp.mjs [flags] +``` + +> Paths above are relative to this skill's directory. From elsewhere, use the +> absolute path, e.g. `node "/scripts/sp.mjs" ...`. + +## Prerequisites (one-time) + +The skill needs three credentials from an Azure AD (Entra) **app registration** +with an *application* permission for SharePoint and admin consent granted: + +- `SP_TENANT_ID` — Directory (tenant) ID +- `SP_CLIENT_ID` — Application (client) ID +- `SP_CLIENT_SECRET` — a client secret value + +Set them as environment variables, or copy `.env.example` to `.env` in this +skill's directory and fill it in (the CLI loads `.env` automatically; `.env` is +git-ignored). Optionally set `SP_SITE_URL` to a default site so `--site` can be +omitted. + +**If credentials are not yet set up**, do not guess — point the user to +[references/setup.md](references/setup.md), which is a step-by-step walkthrough +of creating the app registration, choosing least-privilege permissions +(`Sites.Selected` is strongly preferred over `Sites.ReadWrite.All`), and granting +consent. Then come back and run `test`. + +## First step in any task: verify connectivity + +Before doing real work, confirm auth works. This fails fast with a clear message +if credentials or permissions are wrong: + +```bash +node scripts/sp.mjs test --site "https://contoso.sharepoint.com/sites/Marketing" +``` + +A successful response includes the resolved site id and the names of the lists +in that site. + +## Identifying a site and a list + +- **Site** is given by URL: `https://{tenant}.sharepoint.com/sites/{SiteName}`. + The client resolves it to a Graph site id for you. A bare hostname means the + root site. +- **List** can be given by display name (e.g. `"Project Tracker"`), internal + name, or GUID — the client resolves names to ids and, on a typo, lists the + available names so you can correct it. + +When unsure what lists or columns exist, discover first rather than guessing: + +```bash +node scripts/sp.mjs lists --site "" +node scripts/sp.mjs columns --site "" --list "Project Tracker" +``` + +`columns` is important before writing: SharePoint **internal** column names +often differ from their display names (a column shown as "Due Date" may be +`DueDate` or even `OData__x0044_ue...`). Always write to internal names. See +[references/graph-api.md](references/graph-api.md) for field-type formats +(person, lookup, choice, date, etc.) and OData query details. + +## Reading items + +```bash +# Everything (follows pagination): +node scripts/sp.mjs items --site "" --list "Project Tracker" --all + +# Filtered + sorted + projected: +node scripts/sp.mjs items --site "" --list "Project Tracker" \ + --filter "fields/Status eq 'Open'" \ + --select "Title,Status,AssignedTo" \ + --orderby "fields/DueDate asc" --top 50 + +# One item by id: +node scripts/sp.mjs get --site "" --list "Project Tracker" --id 42 +``` + +Items come back simplified as `{ id, webUrl, createdDateTime, lastModifiedDateTime, fields }`. + +## Writing items + +`--fields` takes a JSON object of internal column name → value. It also accepts +`@file.json` or `-` (read JSON from stdin), which is cleaner for large or +quote-heavy payloads. + +```bash +# Create +node scripts/sp.mjs create --site "" --list "Project Tracker" \ + --fields '{"Title":"Migrate database","Status":"Open","Priority":2}' + +# Update (partial — only the fields you pass change) +node scripts/sp.mjs update --site "" --list "Project Tracker" \ + --id 42 --fields '{"Status":"Done"}' + +# Delete +node scripts/sp.mjs delete --site "" --list "Project Tracker" --id 42 +``` + +**Deletes are irreversible (items go to the site Recycle Bin). Confirm the item +id with the user before deleting**, and prefer showing them the item via `get` +first. For bulk deletes, confirm the full set once, then proceed. + +## Bulk operations + +For exports or batch edits, fetch with `--all`, transform the JSON however the +task needs (jq, a quick script), then loop the relevant `create`/`update`/ +`delete` calls. Keep concurrency modest — Graph throttles with HTTP 429; the +client already retries with backoff, but don't fan out hundreds of parallel +writes. + +## Handling errors + +The CLI prints a JSON error to stderr and exits non-zero. Common cases: + +- `401` / token failure → bad client id/secret or tenant; re-check `.env`. +- `403` (`accessDenied`) → the app lacks permission on this site. With + `Sites.Selected`, the site must be explicitly granted to the app — see + [references/setup.md](references/setup.md). +- `404` on a list → wrong name; run `lists` to see valid names. +- `400` on write → usually a wrong internal field name or value format; run + `columns` and check [references/graph-api.md](references/graph-api.md). + +## Extending to an MCP server + +The core logic is isolated in `scripts/lib/graph.mjs` (the +`SharePointListsClient` class, zero dependencies). To graduate this into a +standalone MCP server later, import that class and expose its methods +(`listItems`, `getItem`, `createItem`, `updateItem`, `deleteItem`, etc.) as MCP +tools — no rewrite of the Graph logic needed. See the note at the bottom of +[references/setup.md](references/setup.md). diff --git a/.claude/skills/sharepoint-lists/references/graph-api.md b/.claude/skills/sharepoint-lists/references/graph-api.md new file mode 100644 index 0000000..4b1450d --- /dev/null +++ b/.claude/skills/sharepoint-lists/references/graph-api.md @@ -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: . + +## 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 "" --list ""`. +- 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 `LookupId` to the target item id | `{"CategoryLookupId":7}` | +| Lookup (multi) | OData type hint + array of ids (see below) | | +| Person/Group (single) | set `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 `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`. diff --git a/.claude/skills/sharepoint-lists/references/setup.md b/.claude/skills/sharepoint-lists/references/setup.md new file mode 100644 index 0000000..3334625 --- /dev/null +++ b/.claude/skills/sharepoint-lists/references/setup.md @@ -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 → **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 "" ` + -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": "", "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. diff --git a/.claude/skills/sharepoint-lists/scripts/lib/graph.mjs b/.claude/skills/sharepoint-lists/scripts/lib/graph.mjs new file mode 100644 index 0000000..2aeec22 --- /dev/null +++ b/.claude/skills/sharepoint-lists/scripts/lib/graph.mjs @@ -0,0 +1,341 @@ +// SharePoint Lists core client — Microsoft Graph, app-only (client credentials). +// +// Zero dependencies. Uses Node's global fetch (Node 18+). This module is the +// single source of truth for talking to SharePoint Lists; the CLI (sp.mjs) is a +// thin wrapper around it, and a future MCP server can import this same class. +// +// Auth model: app-only via OAuth2 client credentials. The app registration must +// hold an *application* permission such as Sites.Selected (preferred) or +// Sites.ReadWrite.All, with admin consent granted. See references/setup.md. + +const LOGIN_HOST = "https://login.microsoftonline.com"; +const GRAPH_ROOT = "https://graph.microsoft.com/v1.0"; + +const GUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** Error carrying the Graph status code + structured error body, when present. */ +export class GraphError extends Error { + constructor(message, { status, code, requestId, body } = {}) { + super(message); + this.name = "GraphError"; + this.status = status; + this.code = code; + this.requestId = requestId; + this.body = body; + } +} + +export class SharePointListsClient { + /** + * @param {object} cfg + * @param {string} cfg.tenantId Directory (tenant) ID or domain. + * @param {string} cfg.clientId Application (client) ID. + * @param {string} cfg.clientSecret Client secret value. + * @param {number} [cfg.maxRetries=3] Retries for 429/503 (honors Retry-After). + */ + constructor({ tenantId, clientId, clientSecret, maxRetries = 3 } = {}) { + if (!tenantId || !clientId || !clientSecret) { + throw new Error( + "Missing credentials: tenantId, clientId, and clientSecret are all required.", + ); + } + this.tenantId = tenantId; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.maxRetries = maxRetries; + this._token = null; // { accessToken, expiresAt } + this._siteCache = new Map(); // siteUrl -> siteId + this._listCache = new Map(); // `${siteId}::${nameOrId}` -> list object + } + + // ---- Auth --------------------------------------------------------------- + + async getToken() { + const now = Date.now(); + if (this._token && this._token.expiresAt - 60_000 > now) { + return this._token.accessToken; + } + const url = `${LOGIN_HOST}/${encodeURIComponent(this.tenantId)}/oauth2/v2.0/token`; + const body = new URLSearchParams({ + grant_type: "client_credentials", + client_id: this.clientId, + client_secret: this.clientSecret, + scope: "https://graph.microsoft.com/.default", + }); + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) { + throw new GraphError( + `Token request failed (${res.status}): ${json.error_description || json.error || res.statusText}`, + { status: res.status, code: json.error, body: json }, + ); + } + this._token = { + accessToken: json.access_token, + expiresAt: now + (json.expires_in ?? 3600) * 1000, + }; + return this._token.accessToken; + } + + // ---- Low-level request -------------------------------------------------- + + /** + * Make a Graph request. `path` is relative to the v1.0 root (e.g. "/sites/..."). + * @param {string} method + * @param {string} path + * @param {object} [opts] + * @param {object} [opts.query] Query params; keys starting with "$" are OData. + * @param {object} [opts.body] JSON body (will be stringified). + * @param {object} [opts.headers] Extra headers (e.g. Prefer). + */ + async graph(method, path, { query, body, headers } = {}) { + const url = new URL(path.startsWith("http") ? path : `${GRAPH_ROOT}${path}`); + if (query) { + for (const [k, v] of Object.entries(query)) { + if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, v); + } + } + for (let attempt = 0; ; attempt++) { + const token = await this.getToken(); + const res = await fetch(url, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + ...(body ? { "Content-Type": "application/json" } : {}), + ...headers, + }, + body: body ? JSON.stringify(body) : undefined, + }); + + if ((res.status === 429 || res.status === 503) && attempt < this.maxRetries) { + const retryAfter = Number(res.headers.get("Retry-After")) || 2 ** attempt; + await sleep(retryAfter * 1000); + continue; + } + + if (res.status === 204) return null; // No Content (e.g. DELETE) + const json = await res.json().catch(() => null); + if (!res.ok) { + const err = json?.error || {}; + throw new GraphError( + `Graph ${method} ${url.pathname} failed (${res.status}): ${err.message || res.statusText}`, + { + status: res.status, + code: err.code, + requestId: res.headers.get("request-id"), + body: json, + }, + ); + } + return json; + } + } + + // ---- Site & list resolution -------------------------------------------- + + /** + * Resolve a site URL (or host:path, or raw site id) to a Graph site id. + * Accepts: + * - "https://contoso.sharepoint.com/sites/Marketing" + * - "contoso.sharepoint.com:/sites/Marketing" + * - "contoso.sharepoint.com,," (already a site id) -> returned as-is + */ + async resolveSiteId(site) { + if (!site) throw new Error("A site URL or id is required."); + // Already a composite site id (host,guid,guid)? + if (site.includes(",")) return site; + if (this._siteCache.has(site)) return this._siteCache.get(site); + + let host, relPath; + if (site.startsWith("http://") || site.startsWith("https://")) { + const u = new URL(site); + host = u.host; + relPath = u.pathname.replace(/\/$/, ""); + } else if (site.includes(":")) { + [host, relPath] = site.split(":"); + relPath = (relPath || "").replace(/\/$/, ""); + } else { + host = site; // bare hostname -> root site + relPath = ""; + } + + const path = relPath + ? `/sites/${host}:${encodeURI(relPath)}` + : `/sites/${host}`; + const data = await this.graph("GET", path, { query: { $select: "id,displayName,webUrl" } }); + this._siteCache.set(site, data.id); + return data.id; + } + + /** List all lists in a site. */ + async listLists(siteId, { select = "id,name,displayName,webUrl,list" } = {}) { + const data = await this.graph("GET", `/sites/${siteId}/lists`, { + query: { $select: select }, + }); + return data.value; + } + + /** + * Resolve a list identifier (GUID, internal name, or display name) to its id. + * Caches per (siteId, identifier). + */ + async resolveListId(siteId, listIdOrName) { + if (!listIdOrName) throw new Error("A list id or name is required."); + if (GUID_RE.test(listIdOrName)) return listIdOrName; + + const key = `${siteId}::${listIdOrName}`; + if (this._listCache.has(key)) return this._listCache.get(key).id; + + const lists = await this.listLists(siteId); + const needle = listIdOrName.toLowerCase(); + const match = lists.find( + (l) => + l.name?.toLowerCase() === needle || + l.displayName?.toLowerCase() === needle, + ); + if (!match) { + const names = lists.map((l) => l.displayName || l.name).join(", "); + throw new Error( + `No list named "${listIdOrName}" in this site. Available: ${names || "(none)"}`, + ); + } + this._listCache.set(key, match); + return match.id; + } + + /** Get a single list's metadata. */ + async getList(siteId, listIdOrName) { + const listId = await this.resolveListId(siteId, listIdOrName); + return this.graph("GET", `/sites/${siteId}/lists/${listId}`); + } + + /** Get column definitions (internal names, types) for a list. */ + async getColumns(siteId, listIdOrName) { + const listId = await this.resolveListId(siteId, listIdOrName); + const data = await this.graph("GET", `/sites/${siteId}/lists/${listId}/columns`); + return data.value; + } + + // ---- Items: read -------------------------------------------------------- + + /** + * List items with their field values. + * @param {object} [opts] + * @param {string} [opts.filter] OData $filter, e.g. "fields/Status eq 'Open'". + * @param {string} [opts.select] Fields to return, e.g. "Title,Status". + * @param {string} [opts.orderby] e.g. "fields/Created desc". + * @param {number} [opts.top] Page size / max when not fetching all. + * @param {boolean}[opts.all] Follow @odata.nextLink to fetch every page. + */ + async listItems(siteId, listIdOrName, opts = {}) { + const listId = await this.resolveListId(siteId, listIdOrName); + const { filter, select, orderby, top, all = false } = opts; + + // Filtering/sorting on non-indexed columns requires this Prefer header. + const headers = filter || orderby + ? { Prefer: "HonorNonIndexedQueriesWarningMayFailRandomly" } + : undefined; + + const expand = select + ? `fields($select=${select})` + : "fields"; + const query = { + expand, + $filter: filter, + $orderby: orderby, + $top: top, + }; + + let data = await this.graph("GET", `/sites/${siteId}/lists/${listId}/items`, { + query, + headers, + }); + const items = data.value.map(simplifyItem); + if (!all) return items; + + while (data["@odata.nextLink"]) { + data = await this.graph("GET", data["@odata.nextLink"], { headers }); + items.push(...data.value.map(simplifyItem)); + } + return items; + } + + /** Get a single item by id, including its fields. */ + async getItem(siteId, listIdOrName, itemId) { + const listId = await this.resolveListId(siteId, listIdOrName); + const data = await this.graph( + "GET", + `/sites/${siteId}/lists/${listId}/items/${itemId}`, + { query: { expand: "fields" } }, + ); + return simplifyItem(data); + } + + // ---- Items: write ------------------------------------------------------- + + /** Create an item. `fields` is a map of internal column name -> value. */ + async createItem(siteId, listIdOrName, fields) { + const listId = await this.resolveListId(siteId, listIdOrName); + const data = await this.graph( + "POST", + `/sites/${siteId}/lists/${listId}/items`, + { body: { fields } }, + ); + return simplifyItem(data); + } + + /** Update an item's fields (partial; only provided fields change). */ + async updateItem(siteId, listIdOrName, itemId, fields) { + const listId = await this.resolveListId(siteId, listIdOrName); + const data = await this.graph( + "PATCH", + `/sites/${siteId}/lists/${listId}/items/${itemId}/fields`, + { body: fields }, + ); + return { id: String(itemId), fields: data }; + } + + /** Delete an item by id. Returns true on success. */ + async deleteItem(siteId, listIdOrName, itemId) { + const listId = await this.resolveListId(siteId, listIdOrName); + await this.graph( + "DELETE", + `/sites/${siteId}/lists/${listId}/items/${itemId}`, + ); + return true; + } + + // ---- Convenience -------------------------------------------------------- + + /** Verify credentials + connectivity. Returns the resolved site (if given). */ + async test(site) { + await this.getToken(); + if (!site) return { ok: true, token: "acquired" }; + const siteId = await this.resolveSiteId(site); + const lists = await this.listLists(siteId); + return { + ok: true, + siteId, + listCount: lists.length, + lists: lists.map((l) => l.displayName || l.name), + }; + } +} + +/** Flatten the Graph item shape into { id, fields, webUrl, ...meta }. */ +function simplifyItem(item) { + if (!item) return item; + const { id, webUrl, createdDateTime, lastModifiedDateTime, fields } = item; + return { id, webUrl, createdDateTime, lastModifiedDateTime, fields: fields ?? {} }; +} + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +export default SharePointListsClient; diff --git a/.claude/skills/sharepoint-lists/scripts/sp.mjs b/.claude/skills/sharepoint-lists/scripts/sp.mjs new file mode 100644 index 0000000..26d0e43 --- /dev/null +++ b/.claude/skills/sharepoint-lists/scripts/sp.mjs @@ -0,0 +1,197 @@ +#!/usr/bin/env node +// CLI wrapper around SharePointListsClient. Prints JSON to stdout; errors to +// stderr with a non-zero exit so callers can detect failure reliably. +// +// Credentials are read from the environment (or a .env file in the CWD or this +// skill's directory). Required: +// SP_TENANT_ID (or AZURE_TENANT_ID) +// SP_CLIENT_ID (or AZURE_CLIENT_ID) +// SP_CLIENT_SECRET (or AZURE_CLIENT_SECRET) +// Optional default so you can omit --site on every call: +// SP_SITE_URL e.g. https://contoso.sharepoint.com/sites/Marketing +// +// Usage: +// node sp.mjs test [--site URL] +// node sp.mjs lists --site URL +// node sp.mjs columns --site URL --list NAME_OR_ID +// node sp.mjs items --site URL --list NAME_OR_ID [--filter ODATA] [--select F1,F2] [--orderby "fields/Created desc"] [--top N] [--all] +// node sp.mjs get --site URL --list NAME_OR_ID --id ITEM_ID +// node sp.mjs create --site URL --list NAME_OR_ID --fields '{"Title":"Hi"}' +// node sp.mjs update --site URL --list NAME_OR_ID --id ITEM_ID --fields '{"Status":"Done"}' +// node sp.mjs delete --site URL --list NAME_OR_ID --id ITEM_ID +// +// --fields accepts inline JSON, @path/to/file.json, or "-" to read JSON from stdin. + +import { readFileSync, existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { SharePointListsClient, GraphError } from "./lib/graph.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +main().catch((err) => { + const payload = { + ok: false, + error: err.message, + ...(err instanceof GraphError + ? { status: err.status, code: err.code, requestId: err.requestId } + : {}), + }; + process.stderr.write(JSON.stringify(payload, null, 2) + "\n"); + process.exit(1); +}); + +async function main() { + loadDotEnv(); + const [, , command, ...rest] = process.argv; + const flags = parseFlags(rest); + + if (!command || command === "help" || flags.help) { + printHelp(); + return; + } + + const client = new SharePointListsClient({ + tenantId: env("SP_TENANT_ID", "AZURE_TENANT_ID"), + clientId: env("SP_CLIENT_ID", "AZURE_CLIENT_ID"), + clientSecret: env("SP_CLIENT_SECRET", "AZURE_CLIENT_SECRET"), + }); + + const site = flags.site || process.env.SP_SITE_URL; + const needSite = () => { + if (!site) throw new Error("Missing --site URL (or set SP_SITE_URL)."); + return client.resolveSiteId(site); + }; + const needList = () => { + if (!flags.list) throw new Error("Missing --list NAME_OR_ID."); + return flags.list; + }; + const needId = () => { + if (!flags.id) throw new Error("Missing --id ITEM_ID."); + return flags.id; + }; + + let result; + switch (command) { + case "test": + result = await client.test(site); + break; + case "lists": + result = await client.listLists(await needSite()); + break; + case "columns": + result = await client.getColumns(await needSite(), needList()); + break; + case "items": + result = await client.listItems(await needSite(), needList(), { + filter: flags.filter, + select: flags.select, + orderby: flags.orderby, + top: flags.top ? Number(flags.top) : undefined, + all: Boolean(flags.all), + }); + break; + case "get": + result = await client.getItem(await needSite(), needList(), needId()); + break; + case "create": + result = await client.createItem(await needSite(), needList(), readFields(flags)); + break; + case "update": + result = await client.updateItem(await needSite(), needList(), needId(), readFields(flags)); + break; + case "delete": + result = await client.deleteItem(await needSite(), needList(), needId()); + result = { ok: true, deleted: needId() }; + break; + default: + throw new Error(`Unknown command "${command}". Run "node sp.mjs help".`); + } + + process.stdout.write(JSON.stringify(result, null, 2) + "\n"); +} + +// ---- helpers -------------------------------------------------------------- + +function env(...names) { + for (const n of names) if (process.env[n]) return process.env[n]; + return undefined; +} + +function parseFlags(args) { + const flags = {}; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (!a.startsWith("--")) continue; + const key = a.slice(2); + const next = args[i + 1]; + if (next === undefined || next.startsWith("--")) { + flags[key] = true; // boolean flag (e.g. --all) + } else { + flags[key] = next; + i++; + } + } + return flags; +} + +function readFields(flags) { + const raw = flags.fields; + if (raw === undefined || raw === true) { + throw new Error('Missing --fields \'{"Col":"value"}\' (or @file.json, or - for stdin).'); + } + let text = raw; + if (raw === "-") text = readFileSync(0, "utf8"); + else if (raw.startsWith("@")) text = readFileSync(raw.slice(1), "utf8"); + try { + return JSON.parse(text); + } catch (e) { + throw new Error(`--fields is not valid JSON: ${e.message}`); + } +} + +// Minimal .env loader (no dependency). Looks in CWD then this skill's dir. +function loadDotEnv() { + for (const dir of [process.cwd(), __dirname, join(__dirname, "..")]) { + 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; + const key = m[1]; + let val = m[2].trim(); + if ( + (val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'")) + ) { + val = val.slice(1, -1); + } + if (process.env[key] === undefined) process.env[key] = val; + } + } +} + +function printHelp() { + process.stdout.write( + `sharepoint-lists CLI — Microsoft Graph, app-only auth + +Commands: + test [--site URL] Verify auth (+ list lists if --site) + lists --site URL List all lists in a site + 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 + 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 + +items filters: + --filter "fields/Status eq 'Open'" --select Title,Status + --orderby "fields/Created desc" --top 50 --all + +--fields accepts inline JSON, @file.json, or - (stdin). +Credentials come from env / .env: SP_TENANT_ID, SP_CLIENT_ID, SP_CLIENT_SECRET. +Optional: SP_SITE_URL to default --site. +`, + ); +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..99d13ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# --- Secrets: never commit --- +.env +*.env +*.env.local +!.env.example + +# --- Node --- +node_modules/ +npm-debug.log* + +# --- OS / editor cruft --- +.DS_Store +*.swp +.idea/ +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e6cc237 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# Claude-SharepointLists + +A [Claude Code](https://claude.com/claude-code) skill for connecting to +**Microsoft SharePoint Lists** via the Microsoft Graph API, with full read/write +(CRUD) over list items using **app-only (client-credentials)** authentication. + +## What's here + +``` +.claude/skills/sharepoint-lists/ +├── SKILL.md # how the skill works + usage +├── .env.example # credential template (copy to .env; .env is git-ignored) +├── scripts/ +│ ├── sp.mjs # CLI: test | lists | columns | items | get | create | update | delete +│ └── lib/graph.mjs # reusable, dependency-free Graph client (SharePointListsClient) +└── references/ + ├── setup.md # Azure AD (Entra) app registration walkthrough + └── graph-api.md # field-type formats + OData query reference +``` + +## Quick start + +1. Create an Entra app registration and grant it a SharePoint Graph permission + (`Sites.Selected` preferred). See + [`references/setup.md`](.claude/skills/sharepoint-lists/references/setup.md). +2. Copy `.env.example` to `.env` and fill in `SP_TENANT_ID`, `SP_CLIENT_ID`, + `SP_CLIENT_SECRET` (optionally `SP_SITE_URL`). +3. Verify connectivity: + + ```bash + cd .claude/skills/sharepoint-lists/scripts + node sp.mjs test --site "https://.sharepoint.com/sites/" + ``` + +## Security + +Credentials live only in `.env`, which is git-ignored — **never commit secrets**. +The Graph + auth logic is isolated in `scripts/lib/graph.mjs` so it can later be +wrapped as a standalone MCP server without a rewrite.