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>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -41,6 +41,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) |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -277,8 +289,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) => {
|
||||
|
||||
Reference in New Issue
Block a user