From ddc3c6a4f15d8127f4b12585d592a4fe71a4968f Mon Sep 17 00:00:00 2001 From: Eugene Samotija Date: Fri, 17 Jul 2026 03:20:03 -0400 Subject: [PATCH 1/2] auth: delegated sessions via refresh-token grant (act as the signed-in user) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second BYOK shape: x-ms-tenant-id + x-ms-client-id + x-ms-refresh-token (or MS_REFRESH_TOKEN for stdio) redeems a delegated Graph token — the user's own Planner permissions apply and writes are attributed to them, which app-only client-credentials can never do. Entra-rotated refresh tokens are adopted for the session's lifetime. When both a secret and a refresh token arrive the refresh token wins: header-overlay proxies (the MCP gateway's per-user sessions) can add headers but not remove the shared spec's placeholders. scripts/device-login.mjs is the one-time sign-in helper that prints the refresh token to register. Co-Authored-By: Claude Fable 5 --- README.md | 17 +++++- scripts/device-login.mjs | 73 ++++++++++++++++++++++++ src/config.test.ts | 22 ++++++++ src/config.ts | 30 +++++++--- src/graph/client.ts | 60 ++++++++++++++------ src/http/app.test.ts | 118 +++++++++++++++++++++++++++++++++++++++ src/http/app.ts | 51 +++++++++++++---- src/index.ts | 21 ++++--- 8 files changed, 345 insertions(+), 47 deletions(-) create mode 100644 scripts/device-login.mjs create mode 100644 src/http/app.test.ts diff --git a/README.md b/README.md index 6283b7b..50becb4 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,22 @@ claude mcp add planner --env MS_TENANT_ID= --env MS_CLIENT_ID= --client +``` + +3. Use `MS_REFRESH_TOKEN` instead of `MS_CLIENT_SECRET` (stdio), or the `x-ms-refresh-token` header (HTTP). Behind the MCP gateway, register it as a personal credential (field `x-ms-refresh-token`). + +The refresh token is a secret — it acts as you — and stays valid ~90 days past its last use; re-run the helper when it expires. ### Docker diff --git a/scripts/device-login.mjs b/scripts/device-login.mjs new file mode 100644 index 0000000..beb806a --- /dev/null +++ b/scripts/device-login.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +/** + * One-time delegated sign-in: runs the OAuth device-code flow against Entra + * and prints the refresh token to register as a personal credential (the MCP + * gateway's /me page, field x-ms-refresh-token) or to set as MS_REFRESH_TOKEN. + * + * node scripts/device-login.mjs --tenant --client + * + * The app registration must be a PUBLIC client ("Allow public client flows") + * with the delegated scopes below. The refresh token is a secret: it acts as + * you. It stays valid ~90 days past its last use; re-run this script when it + * expires. + */ + +const SCOPES = "openid profile offline_access Tasks.ReadWrite Group.Read.All User.ReadBasic.All"; + +function arg(name) { + const idx = process.argv.indexOf(name); + return idx === -1 ? undefined : process.argv[idx + 1]; +} + +const tenant = arg("--tenant") ?? process.env.MS_TENANT_ID; +const client = arg("--client") ?? process.env.MS_CLIENT_ID; +if (!tenant || !client) { + console.error("Usage: node scripts/device-login.mjs --tenant --client "); + process.exit(1); +} + +const base = `https://login.microsoftonline.com/${tenant}/oauth2/v2.0`; + +const dc = await ( + await fetch(`${base}/devicecode`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ client_id: client, scope: SCOPES }), + }) +).json(); +if (!dc.device_code) { + console.error("Device-code request failed:", dc.error_description ?? JSON.stringify(dc)); + process.exit(1); +} + +console.log(`\n${dc.message}\n`); + +const startedAt = Date.now(); +const intervalMs = (dc.interval ?? 5) * 1000; +for (;;) { + if (Date.now() - startedAt > (dc.expires_in ?? 900) * 1000) { + console.error("Timed out waiting for sign-in — run the script again."); + process.exit(1); + } + await new Promise((r) => setTimeout(r, intervalMs)); + const token = await ( + await fetch(`${base}/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + client_id: client, + device_code: dc.device_code, + }), + }) + ).json(); + if (token.error === "authorization_pending") continue; + if (token.error) { + console.error("Sign-in failed:", token.error_description ?? token.error); + process.exit(1); + } + console.log("Signed in. Register this as your personal credential (field x-ms-refresh-token):\n"); + console.log(token.refresh_token); + console.log("\nTreat it like a password — it acts as you. Valid ~90 days past last use."); + process.exit(0); +} diff --git a/src/config.test.ts b/src/config.test.ts index bbbfb8e..5d3a216 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -31,6 +31,28 @@ describe("loadConfig", () => { ).toThrow(/must be set together/); }); + it("accepts a delegated refresh token instead of a client secret", () => { + const config = loadConfig([], { + MS_TENANT_ID: "tenant", + MS_CLIENT_ID: "client", + MS_REFRESH_TOKEN: "rt-1", + } as NodeJS.ProcessEnv); + expect(config.refreshToken).toBe("rt-1"); + expect(config.clientSecret).toBeUndefined(); + }); + + it("rejects setting both a secret and a refresh token", () => { + expect(() => + loadConfig([], { ...FULL_ENV, MS_REFRESH_TOKEN: "rt-1" } as NodeJS.ProcessEnv) + ).toThrow(/not both/); + }); + + it("rejects a refresh token without tenant/client", () => { + expect(() => + loadConfig(["--transport", "http"], { MS_REFRESH_TOKEN: "rt-1" } as NodeJS.ProcessEnv) + ).toThrow(/must be set together/); + }); + it("treats empty-string env values as unset (MCPB hosts)", () => { const config = loadConfig(["--transport", "http"], { MS_TENANT_ID: "", diff --git a/src/config.ts b/src/config.ts index 9de1e01..57f806c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,7 +4,9 @@ * Environment variables: * MS_TENANT_ID Entra ID (Azure AD) tenant id (required for stdio) * MS_CLIENT_ID App registration client id (required for stdio) - * MS_CLIENT_SECRET App registration client secret (required for stdio) + * MS_CLIENT_SECRET App registration client secret (app-only), OR + * MS_REFRESH_TOKEN delegated refresh token (public client; acts as the + * signed-in user — obtain via scripts/device-login.mjs) * TRANSPORT stdio | http (default: stdio) * PORT HTTP port (default: 3000) * PLANNER_ADVANCED_TOOLSET @@ -26,10 +28,13 @@ export type Transport = "stdio" | "http"; export interface ServerConfig { transport: Transport; port: number; - /** Server-wide app credentials; used by stdio, absent on HTTP (BYOK). */ + /** Server-wide credentials; used by stdio, absent on HTTP (BYOK). */ tenantId: string | undefined; clientId: string | undefined; + /** App-only (client credentials). Mutually exclusive with refreshToken. */ clientSecret: string | undefined; + /** Delegated (refresh-token grant) — the server acts as the signed-in user. */ + refreshToken: string | undefined; /** Register the advanced toolset (graph_get / graph_find_endpoint). */ advancedToolset: boolean; } @@ -65,15 +70,24 @@ export function loadConfig( const tenantId = env.MS_TENANT_ID || undefined; const clientId = env.MS_CLIENT_ID || undefined; const clientSecret = env.MS_CLIENT_SECRET || undefined; + const refreshToken = env.MS_REFRESH_TOKEN || undefined; - const provided = [tenantId, clientId, clientSecret].filter(Boolean).length; - if (provided > 0 && provided < 3) { - throw new ConfigError("MS_TENANT_ID, MS_CLIENT_ID and MS_CLIENT_SECRET must be set together"); + if (clientSecret && refreshToken) { + throw new ConfigError( + "Set MS_CLIENT_SECRET (app-only) or MS_REFRESH_TOKEN (delegated), not both" + ); + } + const anySet = Boolean(tenantId || clientId || clientSecret || refreshToken); + const complete = Boolean(tenantId && clientId && (clientSecret || refreshToken)); + if (anySet && !complete) { + throw new ConfigError( + "MS_TENANT_ID and MS_CLIENT_ID must be set together with MS_CLIENT_SECRET or MS_REFRESH_TOKEN" + ); } - if (transport === "stdio" && !tenantId) { + if (transport === "stdio" && !complete) { throw new ConfigError( - "MS_TENANT_ID / MS_CLIENT_ID / MS_CLIENT_SECRET are required for stdio transport" + "MS_TENANT_ID / MS_CLIENT_ID plus MS_CLIENT_SECRET or MS_REFRESH_TOKEN are required for stdio transport" ); } @@ -84,5 +98,5 @@ export function loadConfig( ).toLowerCase(); const advancedToolset = argv.includes("--advanced") || advancedEnv === "true" || advancedEnv === "1"; - return { transport, port, tenantId, clientId, clientSecret, advancedToolset }; + return { transport, port, tenantId, clientId, clientSecret, refreshToken, advancedToolset }; } diff --git a/src/graph/client.ts b/src/graph/client.ts index ee58044..65d502c 100644 --- a/src/graph/client.ts +++ b/src/graph/client.ts @@ -2,9 +2,15 @@ * Microsoft Graph client for Planner on the global fetch API. * * - Base URL: https://graph.microsoft.com/v1.0 - * - Auth: OAuth2 client-credentials flow against - * https://login.microsoftonline.com//oauth2/v2.0/token with the - * .default scope. Tokens are cached until shortly before expiry. + * - Auth, two shapes against + * https://login.microsoftonline.com//oauth2/v2.0/token: + * · app-only — client-credentials (clientSecret), the app's application + * permissions apply and writes are attributed to the app; + * · delegated — refresh-token grant (refreshToken, public client), the + * signed-in USER's permissions apply and writes are attributed to them. + * Tokens are cached until shortly before expiry. Entra may rotate the + * refresh token on redemption; the successor replaces the stored one for + * the lifetime of this client (i.e. the session). * - Planner concurrency: every PATCH/DELETE on planner resources requires an * If-Match header carrying the resource's @odata.etag. `patch`/`delete` * take the etag explicitly; tools fetch the current resource first. @@ -34,9 +40,9 @@ export function describeError(error: unknown): string { case 400: return `Error: Bad request.${detail} Check the parameters.`; case 401: - return "Error: Microsoft Graph authentication failed — check the tenant id, client id and client secret."; + return "Error: Microsoft Graph authentication failed — check the tenant id, client id, and the client secret (app-only) or refresh token (delegated; refresh tokens expire after ~90 days of inactivity — sign in again to get a new one)."; case 403: - return `Error: Permission denied.${detail} The app registration may be missing an application permission (Tasks.ReadWrite.All, GroupMember.Read.All, User.Read.All) or admin consent.`; + return `Error: Permission denied.${detail} App-only sessions need application permissions with admin consent (Tasks.ReadWrite.All, GroupMember.Read.All, User.Read.All); delegated sessions need the matching delegated scopes (Tasks.ReadWrite, Group.Read.All, User.ReadBasic.All) and the user must have access to the plan.`; case 404: return "Error: Resource not found. Verify the ID is correct."; case 409: @@ -61,35 +67,52 @@ export function describeError(error: unknown): string { export interface GraphCredentials { tenantId: string; clientId: string; - clientSecret: string; + /** App-only (client-credentials flow). Exactly one of secret/refreshToken is set. */ + clientSecret?: string; + /** Delegated (refresh-token grant, public client) — acts as the signed-in user. */ + refreshToken?: string; } interface TokenResponse { access_token: string; expires_in: number; + /** Entra may rotate the refresh token; when present, use the successor. */ + refresh_token?: string; } export class GraphClient { private token: string | undefined; private tokenExpiresAt = 0; + /** Session-local successor when Entra rotates the delegated refresh token. */ + private refreshToken: string | undefined; - constructor(private readonly credentials: GraphCredentials) {} + constructor(private readonly credentials: GraphCredentials) { + this.refreshToken = credentials.refreshToken; + } private async getToken(): Promise { if (this.token && Date.now() < this.tokenExpiresAt - TOKEN_SKEW_MS) return this.token; const url = `https://login.microsoftonline.com/${this.credentials.tenantId}/oauth2/v2.0/token`; + const body = this.refreshToken + ? new URLSearchParams({ + grant_type: "refresh_token", + client_id: this.credentials.clientId, + refresh_token: this.refreshToken, + scope: "https://graph.microsoft.com/.default offline_access", + }) + : new URLSearchParams({ + grant_type: "client_credentials", + client_id: this.credentials.clientId, + client_secret: this.credentials.clientSecret ?? "", + scope: "https://graph.microsoft.com/.default", + }); let response: Response; try { response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "client_credentials", - client_id: this.credentials.clientId, - client_secret: this.credentials.clientSecret, - scope: "https://graph.microsoft.com/.default", - }), + body, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); } catch (err) { @@ -102,17 +125,18 @@ export class GraphClient { if (!response.ok) { let detail: string | undefined; try { - const body = (await response.json()) as { error_description?: string; error?: string }; - detail = body.error_description ?? body.error; + const errBody = (await response.json()) as { error_description?: string; error?: string }; + detail = errBody.error_description ?? errBody.error; } catch { // non-JSON error body } throw new GraphApiError("Token request failed", 401, detail); } - const body = (await response.json()) as TokenResponse; - this.token = body.access_token; - this.tokenExpiresAt = Date.now() + body.expires_in * 1000; + const tokens = (await response.json()) as TokenResponse; + this.token = tokens.access_token; + this.tokenExpiresAt = Date.now() + tokens.expires_in * 1000; + if (tokens.refresh_token) this.refreshToken = tokens.refresh_token; return this.token; } diff --git a/src/http/app.test.ts b/src/http/app.test.ts new file mode 100644 index 0000000..d2547df --- /dev/null +++ b/src/http/app.test.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Request } from "express"; +import { loadConfig } from "../config.js"; +import { GraphClient } from "../graph/client.js"; +import { resolveAuth } from "./app.js"; + +const makeRequest = (headers: Record): Request => + ({ headers }) as unknown as Request; + +const httpConfig = loadConfig(["--transport", "http"], {} as NodeJS.ProcessEnv); + +describe("resolveAuth", () => { + it("accepts the app-only header triple", () => { + const auth = resolveAuth( + makeRequest({ "x-ms-tenant-id": "t", "x-ms-client-id": "c", "x-ms-client-secret": "s" }), + httpConfig + ); + expect(auth).toMatchObject({ ok: true, credentials: { tenantId: "t", clientId: "c", clientSecret: "s" } }); + }); + + it("accepts the delegated shape (refresh token, no secret)", () => { + const auth = resolveAuth( + makeRequest({ "x-ms-tenant-id": "t", "x-ms-client-id": "c", "x-ms-refresh-token": "rt" }), + httpConfig + ); + expect(auth).toMatchObject({ ok: true, credentials: { tenantId: "t", clientId: "c", refreshToken: "rt" } }); + if (auth.ok) expect(auth.label).toMatch(/^byok-user:/); + }); + + it("prefers the refresh token when a (placeholder) secret rides along", () => { + // Header-overlay proxies (the gateway's per-user sessions) add headers but + // can't remove the shared spec's placeholder secret. + const auth = resolveAuth( + makeRequest({ + "x-ms-tenant-id": "t", + "x-ms-client-id": "c", + "x-ms-client-secret": "placeholder", + "x-ms-refresh-token": "rt", + }), + httpConfig + ); + expect(auth.ok).toBe(true); + if (auth.ok) { + expect(auth.credentials.refreshToken).toBe("rt"); + expect(auth.credentials.clientSecret).toBeUndefined(); + } + }); + + it("rejects incomplete shapes", () => { + expect(resolveAuth(makeRequest({ "x-ms-tenant-id": "t" }), httpConfig).ok).toBe(false); + expect(resolveAuth(makeRequest({ "x-ms-refresh-token": "rt" }), httpConfig).ok).toBe(false); + expect(resolveAuth(makeRequest({}), httpConfig).ok).toBe(false); + }); + + it("distinct refresh tokens get distinct session key hashes", () => { + const a = resolveAuth( + makeRequest({ "x-ms-tenant-id": "t", "x-ms-client-id": "c", "x-ms-refresh-token": "rt-a" }), + httpConfig + ); + const b = resolveAuth( + makeRequest({ "x-ms-tenant-id": "t", "x-ms-client-id": "c", "x-ms-refresh-token": "rt-b" }), + httpConfig + ); + if (a.ok && b.ok) expect(a.keyHash).not.toBe(b.keyHash); + }); +}); + +describe("GraphClient delegated token flow", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("uses the refresh grant, caches the access token, and adopts rotated refresh tokens", async () => { + const tokenBodies: URLSearchParams[] = []; + let issued = 0; + vi.stubGlobal("fetch", vi.fn(async (input: string | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/oauth2/v2.0/token")) { + tokenBodies.push(new URLSearchParams(String(init?.body))); + issued += 1; + return new Response( + JSON.stringify({ access_token: `at-${issued}`, expires_in: 3600, refresh_token: `rt-gen${issued + 1}` }), + { status: 200 } + ); + } + return new Response(JSON.stringify({ value: [] }), { status: 200 }); + })); + + const client = new GraphClient({ tenantId: "t", clientId: "c", refreshToken: "rt-gen1" }); + await client.getList("/groups"); + await client.getList("/groups"); // cached access token — no second token call + expect(tokenBodies.length).toBe(1); + expect(tokenBodies[0]!.get("grant_type")).toBe("refresh_token"); + expect(tokenBodies[0]!.get("refresh_token")).toBe("rt-gen1"); + expect(tokenBodies[0]!.get("client_secret")).toBeNull(); + + // Force re-auth: the rotated token (rt-gen2) must be used, not the original. + (client as unknown as { tokenExpiresAt: number }).tokenExpiresAt = 0; + await client.getList("/groups"); + expect(tokenBodies.length).toBe(2); + expect(tokenBodies[1]!.get("refresh_token")).toBe("rt-gen2"); + }); + + it("app-only credentials keep using client_credentials", async () => { + const tokenBodies: URLSearchParams[] = []; + vi.stubGlobal("fetch", vi.fn(async (input: string | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/oauth2/v2.0/token")) { + tokenBodies.push(new URLSearchParams(String(init?.body))); + return new Response(JSON.stringify({ access_token: "at", expires_in: 3600 }), { status: 200 }); + } + return new Response(JSON.stringify({ value: [] }), { status: 200 }); + })); + + const client = new GraphClient({ tenantId: "t", clientId: "c", clientSecret: "s" }); + await client.getList("/groups"); + expect(tokenBodies[0]!.get("grant_type")).toBe("client_credentials"); + expect(tokenBodies[0]!.get("client_secret")).toBe("s"); + }); +}); diff --git a/src/http/app.ts b/src/http/app.ts index 3d6dd77..b6ce91d 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -4,13 +4,18 @@ * POST/GET/DELETE /mcp MCP streamable-http endpoint (BYOK auth) * GET /health liveness probe * - * Authentication model — bring-your-own-credentials: - * - Every session presents its own Entra app credentials via the - * x-ms-tenant-id + x-ms-client-id + x-ms-client-secret headers. Those - * credentials are both the credential and the permission model: Microsoft - * Graph enforces the app registration's granted application permissions. - * - When the server was started with MS_TENANT_ID/MS_CLIENT_ID/MS_CLIENT_SECRET, - * sessions may omit the headers and use the server-wide credentials instead. + * Authentication model — bring-your-own-credentials, two shapes: + * - App-only: x-ms-tenant-id + x-ms-client-id + x-ms-client-secret + * (client-credentials flow; the app registration's application permissions + * apply, writes are attributed to the app). + * - Delegated: x-ms-tenant-id + x-ms-client-id + x-ms-refresh-token + * (refresh-token grant on a public client; the signed-in USER's permissions + * apply and writes are attributed to them). When both a secret and a + * refresh token arrive, the refresh token wins — header-overlay proxies + * (e.g. the MCP gateway's per-user sessions) can add but not remove headers. + * - When the server was started with MS_TENANT_ID/MS_CLIENT_ID and a secret + * or refresh token, sessions may omit the headers and use the server-wide + * credentials instead. * - The full tool surface is exposed; there is no MCP-level role gating. * - A session id never carries privilege: every request re-authenticates and * must present the same credentials (SHA-256 hash) the session was created with. @@ -58,11 +63,30 @@ export function resolveAuth(req: Request, config: ServerConfig): AuthOutcome { const tenantId = headerValue(req, "x-ms-tenant-id"); const clientId = headerValue(req, "x-ms-client-id"); const clientSecret = headerValue(req, "x-ms-client-secret"); + const refreshToken = headerValue(req, "x-ms-refresh-token"); + + // Delegated wins over app-only when both credentials arrive: header-overlay + // proxies (the MCP gateway's per-user sessions) can add headers but never + // remove the shared spec's, so a placeholder secret may ride along. + if (refreshToken) { + if (!tenantId || !clientId) { + return unauthorized( + "x-ms-refresh-token needs x-ms-tenant-id and x-ms-client-id alongside it." + ); + } + const keyHash = sha256(`${tenantId}:${clientId}:rt:${refreshToken}`); + return { + ok: true, + label: `byok-user:${keyHash.slice(0, 8)}`, + credentials: { tenantId, clientId, refreshToken }, + keyHash, + }; + } const provided = [tenantId, clientId, clientSecret].filter(Boolean).length; if (provided > 0 && provided < 3) { return unauthorized( - "All three headers are required together: x-ms-tenant-id, x-ms-client-id, x-ms-client-secret." + "Incomplete credentials: send x-ms-tenant-id + x-ms-client-id with either x-ms-client-secret (app-only) or x-ms-refresh-token (delegated)." ); } @@ -76,22 +100,25 @@ export function resolveAuth(req: Request, config: ServerConfig): AuthOutcome { }; } - if (config.tenantId && config.clientId && config.clientSecret) { - const keyHash = sha256(`${config.tenantId}:${config.clientId}:${config.clientSecret}`); + if (config.tenantId && config.clientId && (config.clientSecret || config.refreshToken)) { + const keyHash = sha256( + `${config.tenantId}:${config.clientId}:${config.clientSecret ?? `rt:${config.refreshToken}`}` + ); return { ok: true, label: "server-env", credentials: { tenantId: config.tenantId, clientId: config.clientId, - clientSecret: config.clientSecret, + ...(config.clientSecret ? { clientSecret: config.clientSecret } : {}), + ...(config.refreshToken ? { refreshToken: config.refreshToken } : {}), }, keyHash, }; } return unauthorized( - "Supply Entra app credentials via the x-ms-tenant-id, x-ms-client-id and x-ms-client-secret headers." + "Supply Entra credentials via x-ms-tenant-id + x-ms-client-id plus x-ms-client-secret (app-only) or x-ms-refresh-token (delegated)." ); } diff --git a/src/index.ts b/src/index.ts index ee9efd4..ae99107 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,14 +20,18 @@ Options: Environment: MS_TENANT_ID Entra ID tenant id (required for stdio) MS_CLIENT_ID App registration client id (required for stdio) - MS_CLIENT_SECRET App registration client secret (required for stdio) + MS_CLIENT_SECRET App registration client secret (app-only), OR + MS_REFRESH_TOKEN delegated refresh token — the server acts as the + signed-in user (obtain via scripts/device-login.mjs) -The app registration needs application permissions with admin consent: -Tasks.ReadWrite.All, GroupMember.Read.All, User.Read.All. +App-only needs application permissions with admin consent (Tasks.ReadWrite.All, +GroupMember.Read.All, User.Read.All). Delegated needs the matching delegated +scopes on a PUBLIC client (Tasks.ReadWrite, Group.Read.All, User.ReadBasic.All) +and attributes every write to the signed-in user. -HTTP sessions may authenticate per-request with their own app credentials via -the x-ms-tenant-id / x-ms-client-id / x-ms-client-secret headers (BYOK), or -fall back to the MS_* environment credentials when those are set. +HTTP sessions authenticate per-request (BYOK): x-ms-tenant-id + x-ms-client-id +plus x-ms-client-secret (app-only) or x-ms-refresh-token (delegated), or fall +back to the MS_* environment credentials when those are set. `; function logStartupSummary(config: ServerConfig): void { @@ -37,7 +41,7 @@ function logStartupSummary(config: ServerConfig): void { ); } else { console.error( - "[auth] HTTP: each session must present x-ms-tenant-id / x-ms-client-id / x-ms-client-secret (BYOK)." + "[auth] HTTP: each session must present x-ms-tenant-id + x-ms-client-id plus x-ms-client-secret (app-only) or x-ms-refresh-token (delegated)." ); } } @@ -48,7 +52,8 @@ async function runStdio(config: ServerConfig): Promise { credentials: { tenantId: config.tenantId!, clientId: config.clientId!, - clientSecret: config.clientSecret!, + ...(config.clientSecret ? { clientSecret: config.clientSecret } : {}), + ...(config.refreshToken ? { refreshToken: config.refreshToken } : {}), }, }); await server.connect(new StdioServerTransport()); From d76aee6cbdeee234d730476e1cac220be0bb767b Mon Sep 17 00:00:00 2001 From: Eugene Samotija Date: Fri, 17 Jul 2026 03:21:53 -0400 Subject: [PATCH 2/2] Release v0.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delegated sessions via refresh-token BYOK — act as the signed-in user. Co-Authored-By: Claude Fable 5 --- manifest.json | 20 ++++++++++++++++---- package.json | 2 +- server.json | 4 ++-- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/manifest.json b/manifest.json index 99abf55..62aee2a 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": "0.3", "name": "mcp-planner", "display_name": "Microsoft Planner", - "version": "0.2.1", + "version": "0.3.0", "description": "Microsoft Planner — plans, buckets, and tasks via Microsoft Graph", "long_description": "MCP server for Microsoft Planner via the Microsoft Graph API. Find groups and plans, list buckets and tasks, and create, update, assign, complete, or delete tasks — including descriptions and checklists — with Planner's ETag concurrency handled automatically. Uses an Entra ID app registration with application permissions (client-credentials flow).", "author": { @@ -15,13 +15,21 @@ "url": "https://github.com/mspstack/mcp-planner" }, "homepage": "https://github.com/mspstack/mcp-planner", - "keywords": ["microsoft-planner", "planner", "microsoft-graph", "tasks", "microsoft-365"], + "keywords": [ + "microsoft-planner", + "planner", + "microsoft-graph", + "tasks", + "microsoft-365" + ], "server": { "type": "node", "entry_point": "dist/index.js", "mcp_config": { "command": "node", - "args": ["${__dirname}/dist/index.js"], + "args": [ + "${__dirname}/dist/index.js" + ], "env": { "MS_TENANT_ID": "${user_config.ms_tenant_id}", "MS_CLIENT_ID": "${user_config.ms_client_id}", @@ -59,7 +67,11 @@ } }, "compatibility": { - "platforms": ["darwin", "win32", "linux"], + "platforms": [ + "darwin", + "win32", + "linux" + ], "runtimes": { "node": ">=20" } diff --git a/package.json b/package.json index d4d61f2..f39ebfa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-planner", - "version": "0.2.1", + "version": "0.3.0", "description": "MCP server for Microsoft Planner via Microsoft Graph — plans, buckets, and tasks (create, update, assign, complete) with automatic ETag concurrency handling", "mcpName": "io.github.mspstack/mcp-planner", "type": "module", diff --git a/server.json b/server.json index b51c6a7..6a9b685 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.mspstack/mcp-planner", "title": "Microsoft Planner", "description": "Microsoft Planner MCP server — plans, buckets, tasks via Microsoft Graph, ETags handled", - "version": "0.2.1", + "version": "0.3.0", "repository": { "url": "https://github.com/mspstack/mcp-planner", "source": "github" @@ -14,7 +14,7 @@ "registryType": "npm", "registryBaseUrl": "https://registry.npmjs.org", "identifier": "mcp-planner", - "version": "0.2.1", + "version": "0.3.0", "runtimeHint": "npx", "transport": { "type": "stdio"