@sutraspaces/mcp-server 1.1.2 → 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
@@ -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,21 +65,30 @@ 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`) |
55
93
  | `SUTRA_HELP_CENTER_TOKEN` | No | Internal scoped admin key (`sutra_admin_...`) for Lotus Help Center tools |
56
94
  | `SUTRA_HELP_CENTER_BASE_URL` | No | Override the Help Center API URL (default: `https://api.sutra.co/api/v4/lotus/help`) |
@@ -200,4 +238,4 @@ These tools are available only when `SUTRA_HELP_CENTER_TOKEN` is set.
200
238
  AI Agent → MCP Protocol (stdio) → sutra-mcp → Sutra Admin API → Sutra Platform
201
239
  ```
202
240
 
203
- 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.2",
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",
@@ -36,7 +37,7 @@
36
37
  "homepage": "https://github.com/lorenzsell/sutra-mcp",
37
38
  "repository": {
38
39
  "type": "git",
39
- "url": "git+https://github.com/lorenzsell/sutra-mcp.git"
40
+ "url": "https://github.com/lorenzsell/sutra-mcp.git"
40
41
  },
41
42
  "bugs": {
42
43
  "url": "https://github.com/lorenzsell/sutra-mcp/issues"
@@ -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
+ }
@@ -0,0 +1,38 @@
1
+ import { randomBytes, createHash } from "node:crypto";
2
+
3
+ /**
4
+ * Generate a PKCE code_verifier: 32 random bytes → base64url (43 chars, well
5
+ * within the 43-128 spec limit).
6
+ */
7
+ export function generateCodeVerifier() {
8
+ return base64url(randomBytes(32));
9
+ }
10
+
11
+ /**
12
+ * Derive the code_challenge from the verifier: base64url(SHA-256(verifier))
13
+ * with no padding.
14
+ */
15
+ export function generateCodeChallenge(verifier) {
16
+ const hash = createHash("sha256").update(verifier).digest();
17
+ return base64url(hash);
18
+ }
19
+
20
+ /**
21
+ * Generate a random opaque state value for CSRF protection.
22
+ */
23
+ export function generateState() {
24
+ return base64url(randomBytes(16));
25
+ }
26
+
27
+ /**
28
+ * Base64url encode a Buffer — no padding, + → -, / → _.
29
+ * @param {Buffer} buf
30
+ * @returns {string}
31
+ */
32
+ function base64url(buf) {
33
+ return buf
34
+ .toString("base64")
35
+ .replace(/\+/g, "-")
36
+ .replace(/\//g, "_")
37
+ .replace(/=/g, "");
38
+ }
@@ -0,0 +1,84 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+
5
+ /**
6
+ * Return the path to the credentials file.
7
+ * Uses SUTRA_CONFIG_DIR env or ~/.sutra.
8
+ */
9
+ function credentialsPath() {
10
+ const dir = process.env.SUTRA_CONFIG_DIR
11
+ ? process.env.SUTRA_CONFIG_DIR
12
+ : join(homedir(), ".sutra");
13
+ return { dir, file: join(dir, "credentials.json") };
14
+ }
15
+
16
+ /**
17
+ * Read the full credentials store from disk.
18
+ * Returns {} if missing or invalid.
19
+ * @returns {Record<string, object>}
20
+ */
21
+ function readStore() {
22
+ const { file } = credentialsPath();
23
+ if (!existsSync(file)) return {};
24
+ try {
25
+ return JSON.parse(readFileSync(file, "utf8"));
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Write the full credentials store to disk.
33
+ * Creates the directory with mode 0700 if absent.
34
+ * Writes the file with mode 0600.
35
+ * @param {Record<string, object>} store
36
+ */
37
+ function writeStore(store) {
38
+ const { dir, file } = credentialsPath();
39
+ if (!existsSync(dir)) {
40
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
41
+ }
42
+ writeFileSync(file, JSON.stringify(store, null, 2), { mode: 0o600 });
43
+ // The `mode` option above is ignored when the file already exists, and is
44
+ // masked by the process umask even on creation. Enforce 0600/0700
45
+ // explicitly so the secrets are never left group/world-readable.
46
+ try {
47
+ chmodSync(file, 0o600);
48
+ chmodSync(dir, 0o700);
49
+ } catch {
50
+ // Best-effort on platforms without POSIX permissions (e.g. Windows).
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Load cached credentials for the given issuer.
56
+ * @param {string} issuer
57
+ * @returns {{ access_token: string, refresh_token?: string, expires_at: number, scope?: string, token_endpoint: string } | null}
58
+ */
59
+ export function load(issuer) {
60
+ const store = readStore();
61
+ return store[issuer] ?? null;
62
+ }
63
+
64
+ /**
65
+ * Persist credentials for the given issuer.
66
+ * @param {string} issuer
67
+ * @param {{ access_token: string, refresh_token?: string, expires_at: number, scope?: string, token_endpoint: string }} creds
68
+ */
69
+ export function save(issuer, creds) {
70
+ const store = readStore();
71
+ store[issuer] = creds;
72
+ writeStore(store);
73
+ }
74
+
75
+ /**
76
+ * Remove cached credentials for the given issuer.
77
+ * @param {string} issuer
78
+ */
79
+ export function clear(issuer) {
80
+ const store = readStore();
81
+ if (!Object.prototype.hasOwnProperty.call(store, issuer)) return;
82
+ delete store[issuer];
83
+ writeStore(store);
84
+ }
package/src/client.js CHANGED
@@ -1,12 +1,70 @@
1
1
  const BASE_URL = "https://api.sutra.co/api/admin/v1";
2
2
 
3
3
  export class SutraAdminClient {
4
- constructor(apiToken, baseUrl) {
5
- this.apiToken = apiToken;
4
+ /**
5
+ * @param {string | { getToken: () => Promise<string>, onUnauthorized?: () => Promise<void> }} tokenOrOptions
6
+ * - string: static API token (original behavior, fully preserved)
7
+ * - object: { getToken, onUnauthorized? } for OAuth token provider
8
+ * @param {string} [baseUrl]
9
+ */
10
+ constructor(tokenOrOptions, baseUrl) {
6
11
  this.baseUrl = (baseUrl || BASE_URL).replace(/\/+$/, "");
12
+
13
+ if (typeof tokenOrOptions === "string") {
14
+ // Original static-token path — unchanged behaviour
15
+ this.apiToken = tokenOrOptions;
16
+ this._getToken = null;
17
+ this._onUnauthorized = null;
18
+ } else {
19
+ // Token provider path (OAuth)
20
+ this.apiToken = null;
21
+ this._getToken = tokenOrOptions.getToken;
22
+ this._onUnauthorized = tokenOrOptions.onUnauthorized ?? null;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Resolve the current bearer token.
28
+ * @returns {Promise<string>}
29
+ */
30
+ async _resolveToken() {
31
+ if (this._getToken) {
32
+ return this._getToken();
33
+ }
34
+ return this.apiToken;
7
35
  }
8
36
 
9
37
  async request(method, path, { params, body, headers: extra } = {}) {
38
+ const token = await this._resolveToken();
39
+ const result = await this._doRequest(method, path, { params, body, extra, token });
40
+
41
+ // 401 retry — only once, only when a handler is registered
42
+ if (result.status === 401 && this._onUnauthorized) {
43
+ await this._onUnauthorized();
44
+ const retryToken = await this._resolveToken();
45
+ const retryResult = await this._doRequest(method, path, {
46
+ params,
47
+ body,
48
+ extra,
49
+ token: retryToken,
50
+ });
51
+ if (!retryResult.ok) {
52
+ throw new Error(`${method} ${path} → ${retryResult.status}: ${retryResult.text}`);
53
+ }
54
+ return retryResult.text ? JSON.parse(retryResult.text) : {};
55
+ }
56
+
57
+ if (!result.ok) {
58
+ throw new Error(`${method} ${path} → ${result.status}: ${result.text}`);
59
+ }
60
+ return result.text ? JSON.parse(result.text) : {};
61
+ }
62
+
63
+ /**
64
+ * Execute a single HTTP request and return raw status + text (no throw).
65
+ * @private
66
+ */
67
+ async _doRequest(method, path, { params, body, extra, token }) {
10
68
  const url = new URL(`${this.baseUrl}${path}`);
11
69
  if (params) {
12
70
  for (const [k, v] of Object.entries(params)) {
@@ -15,7 +73,7 @@ export class SutraAdminClient {
15
73
  }
16
74
 
17
75
  const headers = {
18
- Authorization: `Bearer ${this.apiToken}`,
76
+ Authorization: `Bearer ${token}`,
19
77
  Accept: "application/json",
20
78
  ...(body ? { "Content-Type": "application/json" } : {}),
21
79
  ...extra,
@@ -28,11 +86,7 @@ export class SutraAdminClient {
28
86
  });
29
87
 
30
88
  const text = await res.text();
31
- if (!res.ok) {
32
- throw new Error(`${method} ${path} → ${res.status}: ${text}`);
33
- }
34
-
35
- return text ? JSON.parse(text) : {};
89
+ return { ok: res.ok, status: res.status, text };
36
90
  }
37
91
 
38
92
  get(path, params) {
package/src/index.js CHANGED
@@ -22,28 +22,108 @@ import { registerBlogTools } from "./tools/blog.js";
22
22
  import { registerDesignTools } from "./tools/design.js";
23
23
  import { registerAdminApiResources } from "./resources/admin-api.js";
24
24
  import { registerDesignResources } from "./resources/design.js";
25
+ import { getValidAccessToken, login, refresh } from "./auth/oauth.js";
26
+ import { clear, load } from "./auth/store.js";
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // CLI subcommands — must be handled before any MCP server setup so that
30
+ // stdout is never written to (it is the MCP channel).
31
+ // ---------------------------------------------------------------------------
32
+
33
+ const args = process.argv.slice(2);
34
+
35
+ if (args.includes("login")) {
36
+ const issuer = process.env.SUTRA_OAUTH_ISSUER || "https://sutra.co";
37
+ try {
38
+ await login({ issuer });
39
+ process.exit(0);
40
+ } catch (err) {
41
+ process.stderr.write(`Login failed: ${err.message}\n`);
42
+ process.exit(1);
43
+ }
44
+ }
45
+
46
+ if (args.includes("logout")) {
47
+ const issuer = process.env.SUTRA_OAUTH_ISSUER || "https://sutra.co";
48
+ clear(issuer);
49
+ process.stderr.write("Logged out — cached credentials removed.\n");
50
+ process.exit(0);
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Auth bootstrap
55
+ // ---------------------------------------------------------------------------
25
56
 
26
57
  const SUTRA_API_TOKEN = process.env.SUTRA_API_TOKEN;
27
58
  const SUTRA_BASE_URL = process.env.SUTRA_BASE_URL;
59
+ const SUTRA_OAUTH_ISSUER = process.env.SUTRA_OAUTH_ISSUER || "https://sutra.co";
28
60
 
29
- if (!SUTRA_API_TOKEN) {
30
- console.error(
31
- "SUTRA_API_TOKEN is required.\n\n" +
32
- "Set it to your Sutra Admin API token (sutra_live_sk_...).\n" +
33
- "Get one from your Sutra account settings or contact support@sutra.co.\n"
34
- );
35
- process.exit(1);
61
+ let client;
62
+
63
+ if (SUTRA_API_TOKEN) {
64
+ // Static token path original behaviour, unchanged
65
+ client = new SutraAdminClient(SUTRA_API_TOKEN, SUTRA_BASE_URL);
66
+ } else {
67
+ // OAuth path — obtain (or reuse/refresh) a token before connecting
68
+ process.stderr.write("No SUTRA_API_TOKEN set — using OAuth login.\n");
69
+
70
+ let cachedToken;
71
+ try {
72
+ cachedToken = await getValidAccessToken({ issuer: SUTRA_OAUTH_ISSUER });
73
+ } catch (err) {
74
+ process.stderr.write(
75
+ `Authentication failed: ${err.message}\n\n` +
76
+ "Run `npx -y @sutraspaces/mcp-server login` to authenticate, or set the\n" +
77
+ "SUTRA_API_TOKEN environment variable to a Sutra Admin API token.\n"
78
+ );
79
+ process.exit(1);
80
+ }
81
+
82
+ // Token provider: always return the in-memory cached token (fast path).
83
+ // The onUnauthorized handler refreshes it or triggers a new login.
84
+ const getToken = () => Promise.resolve(cachedToken);
85
+
86
+ // On a mid-session 401, only attempt a silent refresh. We deliberately do
87
+ // NOT trigger a full interactive browser login here: this runs inside a
88
+ // long-lived stdio server with no TTY, where spawning a browser and blocking
89
+ // a tool call for up to 5 minutes would be surprising and disruptive. If the
90
+ // refresh token is gone or rejected, surface a clear "re-run login" message.
91
+ const onUnauthorized = async () => {
92
+ process.stderr.write("Received 401 — attempting silent token refresh...\n");
93
+ try {
94
+ const stored = load(SUTRA_OAUTH_ISSUER);
95
+ if (!stored || !stored.refresh_token) {
96
+ process.stderr.write(
97
+ "No refresh token available. Run `npx -y @sutraspaces/mcp-server login` to re-authenticate.\n"
98
+ );
99
+ return;
100
+ }
101
+ const updated = await refresh({ issuer: SUTRA_OAUTH_ISSUER, creds: stored });
102
+ cachedToken = updated.access_token;
103
+ process.stderr.write("Token refreshed.\n");
104
+ } catch (err) {
105
+ process.stderr.write(
106
+ `Token refresh failed: ${err.message}\n` +
107
+ "Run `npx -y @sutraspaces/mcp-server login` to re-authenticate.\n"
108
+ );
109
+ // cachedToken unchanged; the retry will fail and surface a clear error.
110
+ }
111
+ };
112
+
113
+ client = new SutraAdminClient({ getToken, onUnauthorized }, SUTRA_BASE_URL);
36
114
  }
37
115
 
116
+ // ---------------------------------------------------------------------------
117
+ // MCP server setup
118
+ // ---------------------------------------------------------------------------
119
+
38
120
  const server = new McpServer({
39
121
  name: "sutra",
40
- version: "1.1.0",
122
+ version: "1.2.0",
41
123
  description:
42
124
  "Sutra Admin API — manage spaces, members, contacts, content, discussions, surveys, plans, broadcasts, and more.",
43
125
  });
44
126
 
45
- const client = new SutraAdminClient(SUTRA_API_TOKEN, SUTRA_BASE_URL);
46
-
47
127
  registerSpaceTools(server, client);
48
128
  registerMemberTools(server, client);
49
129
  registerContentTools(server, client);
@@ -66,10 +146,10 @@ registerDesignResources(server, client);
66
146
  async function main() {
67
147
  const transport = new StdioServerTransport();
68
148
  await server.connect(transport);
69
- console.error("Sutra MCP server running on stdio");
149
+ process.stderr.write("Sutra MCP server running on stdio\n");
70
150
  }
71
151
 
72
152
  main().catch((err) => {
73
- console.error("Fatal:", err);
153
+ process.stderr.write(`Fatal: ${err}\n`);
74
154
  process.exit(1);
75
155
  });