@sutraspaces/mcp-server 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sutra MCP Server
2
2
 
3
- An [MCP](https://modelcontextprotocol.io) server that connects AI agents to the [Sutra Admin API](https://sutra.co). Manage spaces, members, contacts, content, discussions, surveys, plans, broadcasts, and more — all through your AI tools.
3
+ An MCP server that connects AI agents to the Sutra Admin API. Manage spaces, members, contacts, content, discussions, surveys, plans, broadcasts, and more — all through your AI tools. See the [Sutra developer docs](https://sutra.co/developers/docs) for API documentation.
4
4
 
5
5
  ## Quick Start
6
6
 
@@ -11,16 +11,45 @@ npm install -g @sutraspaces/mcp-server
11
11
  Or run directly with npx:
12
12
 
13
13
  ```bash
14
+ # OAuth — opens your browser to log in on first run (recommended)
15
+ npx -y @sutraspaces/mcp-server
16
+
17
+ # Or with an API token (no browser)
14
18
  SUTRA_API_TOKEN="sutra_live_sk_..." npx -y @sutraspaces/mcp-server
15
19
  ```
16
20
 
17
- You can get an API token from your Sutra account settings or by contacting support@sutra.co.
21
+ There are two ways to authenticate:
22
+
23
+ - **OAuth (recommended)** — run the server with no token. It opens your browser, you log in to Sutra and approve access, and the token is cached locally (`~/.sutra/credentials.json`) and refreshed automatically. No copy-pasting keys.
24
+ - **API token** — set `SUTRA_API_TOKEN`. Best for headless/server environments. Get a token from your Sutra account settings (**Settings → Sutra API**) or by contacting support@sutra.co.
25
+
26
+ If `SUTRA_API_TOKEN` is set, the server uses it and skips OAuth.
27
+
28
+ ### Managing OAuth login
29
+
30
+ ```bash
31
+ npx -y @sutraspaces/mcp-server login # log in / re-authorize without starting the server
32
+ npx -y @sutraspaces/mcp-server logout # clear cached credentials
33
+ ```
18
34
 
19
35
  ## Usage
20
36
 
21
37
  ### Claude Desktop
22
38
 
23
- Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
39
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`. With OAuth, no token is embedded — the server opens your browser the first time it runs:
40
+
41
+ ```json
42
+ {
43
+ "mcpServers": {
44
+ "sutra": {
45
+ "command": "npx",
46
+ "args": ["-y", "@sutraspaces/mcp-server"]
47
+ }
48
+ }
49
+ }
50
+ ```
51
+
52
+ To use an API token instead, add it under `env`:
24
53
 
25
54
  ```json
26
55
  {
@@ -36,22 +65,33 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
36
65
  }
37
66
  ```
38
67
 
68
+ If your client doesn't surface the login prompt on first start, run `npx -y @sutraspaces/mcp-server login` once in a terminal, then start the client.
69
+
39
70
  ### Claude Code
40
71
 
41
72
  ```bash
73
+ # OAuth (opens browser on first run)
74
+ claude mcp add sutra -- npx -y @sutraspaces/mcp-server
75
+
76
+ # Or with an API token
42
77
  claude mcp add sutra -- env SUTRA_API_TOKEN=sutra_live_sk_... npx -y @sutraspaces/mcp-server
43
78
  ```
44
79
 
45
80
  ### Cursor / Windsurf / Other MCP Clients
46
81
 
47
- Point your MCP client at `npx -y @sutraspaces/mcp-server` with the `SUTRA_API_TOKEN` environment variable set. The server communicates over stdio.
82
+ Point your MCP client at `npx -y @sutraspaces/mcp-server`. The server communicates over stdio. Leave the environment empty to authenticate with OAuth, or set `SUTRA_API_TOKEN` to use a token.
48
83
 
49
84
  ## Configuration
50
85
 
51
86
  | Variable | Required | Description |
52
87
  |---|---|---|
53
- | `SUTRA_API_TOKEN` | Yes | Your Sutra Admin API token (`sutra_live_sk_...`) |
88
+ | `SUTRA_API_TOKEN` | No* | Your Sutra Admin API token (`sutra_live_sk_...`). *Required only if not using OAuth. When set, OAuth is skipped. |
89
+ | `SUTRA_OAUTH_ISSUER` | No | OAuth issuer base URL (default: `https://sutra.co`). Must be `https://` (only loopback hosts may use `http://`). Separate from the Admin API URL. |
90
+ | `SUTRA_SCOPES` | No | Space-separated scopes to request during OAuth login (default: all scopes your account can authorize) |
91
+ | `SUTRA_CONFIG_DIR` | No | Directory for cached OAuth credentials (default: `~/.sutra`) |
54
92
  | `SUTRA_BASE_URL` | No | Override the API URL (default: `https://api.sutra.co/api/admin/v1`) |
93
+ | `SUTRA_HELP_CENTER_TOKEN` | No | Internal scoped admin key (`sutra_admin_...`) for Lotus Help Center tools |
94
+ | `SUTRA_HELP_CENTER_BASE_URL` | No | Override the Help Center API URL (default: `https://api.sutra.co/api/v4/lotus/help`) |
55
95
 
56
96
  ## Available Tools
57
97
 
@@ -64,10 +104,16 @@ Point your MCP client at `npx -y @sutraspaces/mcp-server` with the `SUTRA_API_TO
64
104
  - **get_space** — Get details for a single space
65
105
  - **list_child_spaces** — List direct child spaces
66
106
  - **create_space** — Create a new space (top-level or child)
107
+ - **create_child_space** — Create a child space and make it visible through placement
108
+ - **attach_child_space** — Attach an existing space under a parent and make it visible
109
+ - **update_child_space_placement** — Move or repair a child space display
110
+ - **detach_child_space** — Detach a child from one parent without deleting it
67
111
  - **update_space** — Update space name, description, type, privacy, or state
68
112
  - **delete_space** — Delete/archive a space
69
113
  - **reorder_child_spaces** — Reorder children within a parent
70
114
 
115
+ Use `sp_...` IDs, not slugs. For normal course and section pages, use `placement.surface = "auto"`.
116
+
71
117
  ### Members
72
118
  - **list_members** — List space members with optional email, user_id, search, role, state, and custom property filtering
73
119
  - **get_member** — Get member details
@@ -86,8 +132,27 @@ Point your MCP client at `npx -y @sutraspaces/mcp-server` with the `SUTRA_API_TO
86
132
  - **bulk_update_contact_property_values** — Set or clear property values for up to 100 contacts at once
87
133
 
88
134
  ### Content & Discussions
89
- - **list_content** / **get_content_block** / **create_content** / **update_content** / **delete_content** Manage content blocks
135
+ - **get_document_capabilities** / **get_document** Read document editing rules and the visible Tiptap document
136
+ - **replace_document** / **insert_document_nodes** / **update_document_node** / **delete_document_node** / **move_document_node** — Edit visible document content by Tiptap node UID
137
+ - **list_content** / **get_content_block** / **create_content** / **update_content** / **delete_content** — Legacy content-block mirror tools
90
138
  - **reorder_content** — Reorder content blocks within a space
139
+
140
+ ### Media Uploads
141
+ - **create_media_upload** — Create a server-mediated direct-to-S3 upload session
142
+ - **complete_media_upload** — Verify the uploaded object and queue video processing
143
+ - **get_media_upload** — Read upload and processing status
144
+ - **cancel_media_upload** — Abandon a pending upload
145
+ - **create_media_reference** — Insert a Loom, YouTube, or Vimeo embed without uploading a file
146
+
147
+ ### Design Tools
148
+
149
+ - **get_design_capabilities** — Read Sutra's renderer-aware design manifest
150
+ - **get_space_design** — Read current page-design nodes and digest for a space
151
+ - **validate_space_design** — Validate proposed page-design nodes
152
+ - **create_or_update_design_draft** — Create or update a digest-guarded design draft
153
+ - **publish_design_draft** — Publish a draft with conflict and destructive-omission protection
154
+ - **restore_design_draft** — Restore the pre-publish backup for a published design draft
155
+ - **import_design_asset** — Re-host an external image URL and return the canonical Sutra URL
91
156
  - **list_messages** / **get_message** / **create_message** / **update_message** / **delete_message** — Manage discussion messages
92
157
  - **list_reflections** / **get_reflection** / **create_reflection** / **update_reflection** / **delete_reflection** — Manage threaded replies
93
158
 
@@ -120,12 +185,24 @@ Point your MCP client at `npx -y @sutraspaces/mcp-server` with the `SUTRA_API_TO
120
185
  - **send_broadcast** — Send a broadcast to space members
121
186
  - **get_broadcast_delivery_status** — Check delivery progress
122
187
 
188
+ ### Help Center
189
+ These tools are available only when `SUTRA_HELP_CENTER_TOKEN` is set.
190
+
191
+ - **help_list_articles** / **help_get_article** — Read Lotus Help Center articles, metadata, and version history
192
+ - **help_create_article** / **help_update_article** — Human/admin-style create and update through Lotus
193
+ - **help_propose_article** — Submit an AI-authored draft as `ai_cobolt` and mark it pending human review
194
+ - **help_propose_article_update** — Submit AI-authored changes to an existing article without publishing them
195
+ - **help_review_article** — Approve or reject a pending article version
196
+ - **help_publish_article** — Explicitly publish an article version
197
+ - **help_list_collections** / **help_create_collection** / **help_update_collection** / **help_delete_collection** — Manage Help Center collections
198
+
123
199
  ## Available Resources
124
200
 
125
201
  - **sutra://admin-api/overview** — Core Admin API concepts, public ID prefixes, scopes, pagination, and filtering
126
202
  - **sutra://admin-api/membership-spaces** — Difference between admin-manageable spaces and limited membership-space inventory
127
203
  - **sutra://admin-api/contacts-properties** — Contact listing and member/contact property value workflows
128
204
  - **sutra://admin-api/people-filtering** — Member and contact search, email, state, role, and custom property filtering
205
+ - **sutra://design/capabilities** — Renderer-aware design rules and supported fonts for Sutra page-design tools
129
206
 
130
207
  ## API Concepts
131
208
 
@@ -161,4 +238,4 @@ Point your MCP client at `npx -y @sutraspaces/mcp-server` with the `SUTRA_API_TO
161
238
  AI Agent → MCP Protocol (stdio) → sutra-mcp → Sutra Admin API → Sutra Platform
162
239
  ```
163
240
 
164
- The server is a thin, stateless wrapper. All data flows through the Sutra Admin API with Bearer token authentication. No data is cached or stored locally.
241
+ The server is a thin, stateless wrapper. All data flows through the Sutra Admin API with Bearer token authentication. No Sutra customer data is cached or stored locally. The only thing written to disk is your OAuth credential cache at `~/.sutra/credentials.json` (file mode `600`), used to keep you logged in between runs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sutraspaces/mcp-server",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "MCP server for the Sutra Admin API — manage spaces, members, contacts, content, and more via AI agents",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -12,7 +12,8 @@
12
12
  "README.md"
13
13
  ],
14
14
  "scripts": {
15
- "start": "node src/index.js"
15
+ "start": "node src/index.js",
16
+ "test": "node --test"
16
17
  },
17
18
  "dependencies": {
18
19
  "@modelcontextprotocol/sdk": "^1.12.1",
@@ -33,12 +34,12 @@
33
34
  ],
34
35
  "author": "Sutra <support@sutra.co> (https://sutra.co)",
35
36
  "license": "MIT",
36
- "homepage": "https://github.com/joinsutra/mcp-server",
37
+ "homepage": "https://github.com/lorenzsell/sutra-mcp",
37
38
  "repository": {
38
39
  "type": "git",
39
- "url": "https://github.com/joinsutra/mcp-server.git"
40
+ "url": "https://github.com/lorenzsell/sutra-mcp.git"
40
41
  },
41
42
  "bugs": {
42
- "url": "https://github.com/joinsutra/mcp-server/issues"
43
+ "url": "https://github.com/lorenzsell/sutra-mcp/issues"
43
44
  }
44
45
  }
@@ -0,0 +1,470 @@
1
+ import { createServer } from "node:http";
2
+ import { spawn } from "node:child_process";
3
+ import { generateCodeVerifier, generateCodeChallenge, generateState } from "./pkce.js";
4
+ import { load, save, clear } from "./store.js";
5
+
6
+ const LOGIN_TIMEOUT_MS = 300_000; // 5 minutes
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // URL safety
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
13
+
14
+ /**
15
+ * Require a URL to be HTTPS (or HTTP only for loopback dev hosts). This is the
16
+ * boundary that prevents the PKCE verifier, authorization code, and refresh
17
+ * token from ever being POSTed over plaintext to an attacker.
18
+ * @param {string} urlString
19
+ * @param {string} label
20
+ * @returns {URL}
21
+ */
22
+ function assertHttpsOrLoopback(urlString, label) {
23
+ let u;
24
+ try {
25
+ u = new URL(urlString);
26
+ } catch {
27
+ throw new Error(`${label} is not a valid URL`);
28
+ }
29
+ const loopback = LOOPBACK_HOSTNAMES.has(u.hostname);
30
+ if (u.protocol !== "https:" && !(u.protocol === "http:" && loopback)) {
31
+ throw new Error(
32
+ `${label} must use HTTPS (got ${u.protocol}//${u.host}). ` +
33
+ `Set SUTRA_OAUTH_ISSUER to an https:// URL.`
34
+ );
35
+ }
36
+ return u;
37
+ }
38
+
39
+ /**
40
+ * Require an OAuth endpoint to be HTTPS/loopback AND same-origin as the issuer.
41
+ * Same-origin blocks a tampered or hijacked metadata document from redirecting
42
+ * the token exchange (and thus the verifier/refresh token) to another host.
43
+ * @param {string} urlString
44
+ * @param {string} issuerOrigin
45
+ * @param {string} label
46
+ */
47
+ function assertEndpointSafe(urlString, issuerOrigin, label) {
48
+ const u = assertHttpsOrLoopback(urlString, label);
49
+ if (u.origin !== issuerOrigin) {
50
+ throw new Error(
51
+ `${label} (${u.origin}) is not on the same origin as the issuer ` +
52
+ `(${issuerOrigin}); refusing to use it.`
53
+ );
54
+ }
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Discovery
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /**
62
+ * Fetch OAuth server metadata from the well-known endpoint.
63
+ * Falls back to conventional paths if discovery fails.
64
+ * @param {string} issuer
65
+ * @returns {Promise<{ authorization_endpoint: string, token_endpoint: string }>}
66
+ */
67
+ export async function discoverMetadata(issuer) {
68
+ const issuerOrigin = assertHttpsOrLoopback(issuer, "SUTRA_OAUTH_ISSUER").origin;
69
+
70
+ const validate = (md) => {
71
+ assertEndpointSafe(md.authorization_endpoint, issuerOrigin, "authorization_endpoint");
72
+ assertEndpointSafe(md.token_endpoint, issuerOrigin, "token_endpoint");
73
+ return md;
74
+ };
75
+
76
+ const wellKnown = `${issuer}/.well-known/oauth-authorization-server`;
77
+ try {
78
+ const res = await fetch(wellKnown, {
79
+ headers: { Accept: "application/json" },
80
+ signal: AbortSignal.timeout(10_000),
81
+ });
82
+ if (res.ok) {
83
+ const ct = res.headers.get("content-type") ?? "";
84
+ if (ct.includes("json")) {
85
+ const data = await res.json();
86
+ if (data.authorization_endpoint && data.token_endpoint) {
87
+ return validate(data);
88
+ }
89
+ }
90
+ }
91
+ } catch (err) {
92
+ // A validation failure (untrusted endpoint) must propagate, not silently
93
+ // fall back. Only swallow network/timeout/parse errors.
94
+ if (err instanceof TypeError === false && /must use HTTPS|same origin/.test(err.message)) {
95
+ throw err;
96
+ }
97
+ // network error or timeout — fall through to defaults
98
+ }
99
+ // Fallback to conventional paths (same origin as issuer by construction).
100
+ return validate({
101
+ authorization_endpoint: `${issuer}/oauth/authorize`,
102
+ token_endpoint: `${issuer}/oauth/token`,
103
+ });
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Browser opener
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /**
111
+ * Open a URL in the system browser. Never throws.
112
+ * @param {string} url
113
+ */
114
+ function openBrowser(url) {
115
+ try {
116
+ const cmd =
117
+ process.platform === "darwin"
118
+ ? "open"
119
+ : process.platform === "win32"
120
+ ? "start"
121
+ : "xdg-open";
122
+ // On win32 'start' is a shell built-in; needs shell:true
123
+ spawn(cmd, [url], {
124
+ detached: true,
125
+ stdio: "ignore",
126
+ shell: process.platform === "win32",
127
+ }).unref();
128
+ } catch {
129
+ // Swallow — we still print the URL to stderr
130
+ }
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Loopback HTTP server helpers
135
+ // ---------------------------------------------------------------------------
136
+
137
+ const SUCCESS_HTML = `<!DOCTYPE html><html><head><meta charset="utf-8">
138
+ <title>Sutra Login</title>
139
+ <style>body{font-family:sans-serif;max-width:480px;margin:80px auto;text-align:center}
140
+ h1{color:#1a7f5a}.check{font-size:64px}</style></head><body>
141
+ <div class="check">&#10003;</div>
142
+ <h1>You are logged in to Sutra</h1>
143
+ <p>You can close this tab and return to your terminal.</p>
144
+ </body></html>`;
145
+
146
+ const ERROR_HTML = (msg) =>
147
+ `<!DOCTYPE html><html><head><meta charset="utf-8">
148
+ <title>Sutra Login Error</title>
149
+ <style>body{font-family:sans-serif;max-width:480px;margin:80px auto;text-align:center}
150
+ h1{color:#c0392b}</style></head><body>
151
+ <h1>Login failed</h1>
152
+ <p>${escapeHtml(msg)}</p>
153
+ <p>You can close this tab and check your terminal for details.</p>
154
+ </body></html>`;
155
+
156
+ function escapeHtml(s) {
157
+ return String(s)
158
+ .replace(/&/g, "&amp;")
159
+ .replace(/</g, "&lt;")
160
+ .replace(/>/g, "&gt;")
161
+ .replace(/"/g, "&quot;");
162
+ }
163
+
164
+ /**
165
+ * Start an ephemeral loopback HTTP server on 127.0.0.1.
166
+ * Returns the server and its port.
167
+ * @returns {Promise<{ server: import("node:http").Server, port: number }>}
168
+ */
169
+ function startCallbackServer() {
170
+ return new Promise((resolve, reject) => {
171
+ const server = createServer();
172
+ server.listen(0, "127.0.0.1", () => {
173
+ const addr = server.address();
174
+ if (!addr || typeof addr === "string") {
175
+ reject(new Error("Failed to bind loopback server"));
176
+ return;
177
+ }
178
+ resolve({ server, port: addr.port });
179
+ });
180
+ server.on("error", reject);
181
+ });
182
+ }
183
+
184
+ /**
185
+ * Wait for the OAuth callback on the given server.
186
+ * Resolves with { code, state } or rejects with an Error.
187
+ * Sends a friendly HTML response to the browser.
188
+ * @param {import("node:http").Server} server
189
+ * @param {string} expectedState
190
+ * @param {number} timeoutMs
191
+ * @returns {Promise<{ code: string, state: string }>}
192
+ */
193
+ function waitForCallback(server, expectedState, timeoutMs) {
194
+ return new Promise((resolve, reject) => {
195
+ let settled = false;
196
+
197
+ const timer = setTimeout(() => {
198
+ if (settled) return;
199
+ settled = true;
200
+ server.close();
201
+ reject(new Error("Login timed out — no callback received within 5 minutes"));
202
+ }, timeoutMs);
203
+
204
+ server.on("request", (req, res) => {
205
+ if (settled) {
206
+ res.writeHead(204);
207
+ res.end();
208
+ return;
209
+ }
210
+
211
+ // Only handle /callback
212
+ const url = new URL(req.url, "http://127.0.0.1");
213
+ if (url.pathname !== "/callback") {
214
+ res.writeHead(404);
215
+ res.end("Not found");
216
+ return;
217
+ }
218
+
219
+ const error = url.searchParams.get("error");
220
+ const errorDesc =
221
+ url.searchParams.get("error_description") ?? error ?? "Unknown error";
222
+ const code = url.searchParams.get("code");
223
+ const returnedState = url.searchParams.get("state");
224
+
225
+ if (error) {
226
+ settled = true;
227
+ clearTimeout(timer);
228
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
229
+ res.end(ERROR_HTML(errorDesc));
230
+ server.close();
231
+ reject(new Error(`OAuth error: ${errorDesc}`));
232
+ return;
233
+ }
234
+
235
+ if (returnedState !== expectedState) {
236
+ settled = true;
237
+ clearTimeout(timer);
238
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
239
+ res.end(ERROR_HTML("State mismatch — possible CSRF attempt. Please try again."));
240
+ server.close();
241
+ reject(new Error("OAuth state mismatch (CSRF check failed)"));
242
+ return;
243
+ }
244
+
245
+ if (!code) {
246
+ settled = true;
247
+ clearTimeout(timer);
248
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
249
+ res.end(ERROR_HTML("No authorization code received"));
250
+ server.close();
251
+ reject(new Error("OAuth callback missing authorization code"));
252
+ return;
253
+ }
254
+
255
+ // Success
256
+ settled = true;
257
+ clearTimeout(timer);
258
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
259
+ res.end(SUCCESS_HTML);
260
+ server.close();
261
+ resolve({ code, state: returnedState });
262
+ });
263
+ });
264
+ }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Token exchange helpers
268
+ // ---------------------------------------------------------------------------
269
+
270
+ /**
271
+ * POST to the token endpoint with application/x-www-form-urlencoded body.
272
+ * Returns the parsed JSON response or throws.
273
+ * @param {string} tokenEndpoint
274
+ * @param {Record<string, string>} params
275
+ * @returns {Promise<{ access_token: string, token_type: string, expires_in: number, refresh_token?: string, scope?: string }>}
276
+ */
277
+ async function postToken(tokenEndpoint, params) {
278
+ const body = new URLSearchParams(params).toString();
279
+ const res = await fetch(tokenEndpoint, {
280
+ method: "POST",
281
+ headers: {
282
+ "Content-Type": "application/x-www-form-urlencoded",
283
+ Accept: "application/json",
284
+ },
285
+ body,
286
+ signal: AbortSignal.timeout(30_000),
287
+ });
288
+ const text = await res.text();
289
+ if (!res.ok) {
290
+ throw new Error(`Token endpoint error ${res.status}: ${safeErrorDetail(text)}`);
291
+ }
292
+ try {
293
+ return JSON.parse(text);
294
+ } catch {
295
+ throw new Error(`Token endpoint returned a non-JSON response (status ${res.status})`);
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Extract only the OAuth `error`/`error_description` fields from a token-endpoint
301
+ * body. Never returns the raw body, which on a misbehaving or hostile endpoint
302
+ * could contain an access_token / refresh_token that would then land in logs.
303
+ * @param {string} text
304
+ * @returns {string}
305
+ */
306
+ function safeErrorDetail(text) {
307
+ try {
308
+ const j = JSON.parse(text);
309
+ const detail = [j.error, j.error_description].filter(Boolean).join(": ");
310
+ return detail ? detail.slice(0, 200) : "unrecognized error response";
311
+ } catch {
312
+ return "unrecognized error response";
313
+ }
314
+ }
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // Public API
318
+ // ---------------------------------------------------------------------------
319
+
320
+ /**
321
+ * Run the full Authorization-Code + PKCE login flow.
322
+ * Opens a browser, waits for the callback, exchanges the code, saves creds.
323
+ *
324
+ * @param {{ issuer: string, interactive?: boolean }} options
325
+ * @returns {Promise<{ access_token: string, refresh_token?: string, expires_at: number, scope?: string, token_endpoint: string }>}
326
+ */
327
+ export async function login({ issuer, interactive = true }) {
328
+ const metadata = await discoverMetadata(issuer);
329
+ const { authorization_endpoint, token_endpoint, scopes_supported } = metadata;
330
+
331
+ const verifier = generateCodeVerifier();
332
+ const challenge = generateCodeChallenge(verifier);
333
+ const state = generateState();
334
+
335
+ const { server, port } = await startCallbackServer();
336
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
337
+
338
+ const authorizeUrl = new URL(authorization_endpoint);
339
+ authorizeUrl.searchParams.set("response_type", "code");
340
+ authorizeUrl.searchParams.set("client_id", "sutra-mcp");
341
+ authorizeUrl.searchParams.set("redirect_uri", redirectUri);
342
+ authorizeUrl.searchParams.set("state", state);
343
+ authorizeUrl.searchParams.set("code_challenge", challenge);
344
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
345
+ // Request scopes explicitly. SUTRA_SCOPES narrows the grant; otherwise default
346
+ // to the full set the server advertises (scopes_supported) so the MCP tools
347
+ // keep working. We always send an explicit scope when one is known rather than
348
+ // relying on the server's blank-scope default (which is read-only) — this
349
+ // keeps the grant visible on the consent screen and least-privilege-friendly.
350
+ const requestedScopes =
351
+ process.env.SUTRA_SCOPES?.trim() ||
352
+ (Array.isArray(scopes_supported) ? scopes_supported.join(" ") : "");
353
+ if (requestedScopes) {
354
+ authorizeUrl.searchParams.set("scope", requestedScopes);
355
+ } else {
356
+ // No SUTRA_SCOPES, and discovery didn't advertise scopes_supported (a
357
+ // degraded/fallback metadata fetch). Without a scope param the server grants
358
+ // its read-only default — and that token gets cached and silently reused. Make
359
+ // the downgrade visible so the user can re-run with SUTRA_SCOPES for writes.
360
+ process.stderr.write(
361
+ "\nWarning: the server did not advertise its available scopes (metadata " +
362
+ "discovery may have failed); requesting the server default, which is " +
363
+ "read-only. If you need write tools, set SUTRA_SCOPES and log in again.\n"
364
+ );
365
+ }
366
+
367
+ const urlString = authorizeUrl.toString();
368
+
369
+ process.stderr.write(
370
+ `\nOpen this URL to log in to Sutra:\n\n ${urlString}\n\n` +
371
+ `Waiting for browser callback (5 min timeout)...\n`
372
+ );
373
+
374
+ if (interactive) {
375
+ openBrowser(urlString);
376
+ }
377
+
378
+ // Wait for callback — server is closed inside waitForCallback
379
+ const { code } = await waitForCallback(server, state, LOGIN_TIMEOUT_MS);
380
+
381
+ process.stderr.write("Authorization code received — exchanging for tokens...\n");
382
+
383
+ const tokenData = await postToken(token_endpoint, {
384
+ grant_type: "authorization_code",
385
+ code,
386
+ redirect_uri: redirectUri,
387
+ client_id: "sutra-mcp",
388
+ code_verifier: verifier,
389
+ });
390
+
391
+ const creds = {
392
+ access_token: tokenData.access_token,
393
+ refresh_token: tokenData.refresh_token ?? null,
394
+ expires_at: Date.now() + (tokenData.expires_in ?? 3600) * 1000,
395
+ scope: tokenData.scope ?? null,
396
+ token_endpoint,
397
+ };
398
+
399
+ save(issuer, creds);
400
+ process.stderr.write("Login successful. Credentials cached.\n");
401
+ return creds;
402
+ }
403
+
404
+ /**
405
+ * Refresh an access token using the stored refresh_token.
406
+ * Saves rotated credentials on success. Clears and throws on failure.
407
+ *
408
+ * @param {{ issuer: string, creds: object }} options
409
+ * @returns {Promise<object>} updated creds
410
+ */
411
+ export async function refresh({ issuer, creds }) {
412
+ if (!creds.refresh_token) {
413
+ clear(issuer);
414
+ throw new Error("No refresh token available — please log in again");
415
+ }
416
+
417
+ try {
418
+ const tokenData = await postToken(creds.token_endpoint, {
419
+ grant_type: "refresh_token",
420
+ refresh_token: creds.refresh_token,
421
+ client_id: "sutra-mcp",
422
+ });
423
+
424
+ const updated = {
425
+ access_token: tokenData.access_token,
426
+ refresh_token: tokenData.refresh_token ?? creds.refresh_token,
427
+ expires_at: Date.now() + (tokenData.expires_in ?? 3600) * 1000,
428
+ scope: tokenData.scope ?? creds.scope,
429
+ token_endpoint: creds.token_endpoint,
430
+ };
431
+
432
+ save(issuer, updated);
433
+ return updated;
434
+ } catch (err) {
435
+ clear(issuer);
436
+ throw new Error(`Token refresh failed (${err.message}) — please log in again`);
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Return a valid access token, refreshing or re-logging-in as needed.
442
+ * This is the main entry point called by the client bootstrap.
443
+ *
444
+ * @param {{ issuer: string }} options
445
+ * @returns {Promise<string>} a valid access token
446
+ */
447
+ export async function getValidAccessToken({ issuer }) {
448
+ let creds = load(issuer);
449
+
450
+ if (creds) {
451
+ // Token still valid (with 60s buffer)
452
+ if (creds.expires_at - 60_000 > Date.now()) {
453
+ return creds.access_token;
454
+ }
455
+ // Expired but we have a refresh token
456
+ if (creds.refresh_token) {
457
+ try {
458
+ const updated = await refresh({ issuer, creds });
459
+ return updated.access_token;
460
+ } catch (err) {
461
+ process.stderr.write(`Refresh failed: ${err.message}\n`);
462
+ // Fall through to full login
463
+ }
464
+ }
465
+ }
466
+
467
+ // No valid creds — run full login
468
+ const freshCreds = await login({ issuer });
469
+ return freshCreds.access_token;
470
+ }