Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,22 @@ claude mcp add planner --env MS_TENANT_ID=<tenant> --env MS_CLIENT_ID=<client-id
npx -y mcp-planner --transport http --port 3000
```

Sessions authenticate per-request with `x-ms-tenant-id` / `x-ms-client-id` / `x-ms-client-secret` headers (BYOK), or fall back to the `MS_*` environment credentials when set. Health probe at `GET /health`.
Sessions authenticate per-request (BYOK) with `x-ms-tenant-id` + `x-ms-client-id` plus **either** `x-ms-client-secret` (app-only) **or** `x-ms-refresh-token` (delegated — see below), or fall back to the `MS_*` environment credentials when set. When both a secret and a refresh token arrive, the refresh token wins (header-overlay proxies can add but not remove headers). Health probe at `GET /health`.

### Delegated mode — act as the signed-in user

App-only sessions act as the app registration; delegated sessions act as a **user**: their Planner permissions apply and every write is attributed to them.

1. A separate, **public** app registration: Authentication → *Allow public client flows* → Yes; API permissions → **Delegated** `Tasks.ReadWrite`, `Group.Read.All`, `User.ReadBasic.All` (+ admin consent where the tenant requires it).
2. Each user signs in once via the device-code helper and keeps the printed refresh token:

```bash
node scripts/device-login.mjs --tenant <tenant-id> --client <public-client-id>
```

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

Expand Down
20 changes: 16 additions & 4 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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}",
Expand Down Expand Up @@ -59,7 +67,11 @@
}
},
"compatibility": {
"platforms": ["darwin", "win32", "linux"],
"platforms": [
"darwin",
"win32",
"linux"
],
"runtimes": {
"node": ">=20"
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
73 changes: 73 additions & 0 deletions scripts/device-login.mjs
Original file line number Diff line number Diff line change
@@ -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 <tenant-id> --client <public-client-id>
*
* 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 <tenant-id> --client <public-client-id>");
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);
}
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
22 changes: 22 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Expand Down
30 changes: 22 additions & 8 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
Expand Down Expand Up @@ -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"
);
}

Expand All @@ -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 };
}
60 changes: 42 additions & 18 deletions src/graph/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<tenant>/oauth2/v2.0/token with the
* .default scope. Tokens are cached until shortly before expiry.
* - Auth, two shapes against
* https://login.microsoftonline.com/<tenant>/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.
Expand Down Expand Up @@ -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:
Expand All @@ -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<string> {
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) {
Expand All @@ -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;
}

Expand Down
Loading