@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 +43 -5
- package/package.json +4 -3
- package/src/auth/oauth.js +470 -0
- package/src/auth/pkce.js +38 -0
- package/src/auth/store.js +84 -0
- package/src/client.js +62 -8
- package/src/index.js +92 -12
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
|
-
|
|
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
|
|
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` |
|
|
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.
|
|
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": "
|
|
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">✓</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, "&")
|
|
159
|
+
.replace(/</g, "<")
|
|
160
|
+
.replace(/>/g, ">")
|
|
161
|
+
.replace(/"/g, """);
|
|
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
|
+
}
|
package/src/auth/pkce.js
ADDED
|
@@ -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
|
-
|
|
5
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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.
|
|
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
|
-
|
|
149
|
+
process.stderr.write("Sutra MCP server running on stdio\n");
|
|
70
150
|
}
|
|
71
151
|
|
|
72
152
|
main().catch((err) => {
|
|
73
|
-
|
|
153
|
+
process.stderr.write(`Fatal: ${err}\n`);
|
|
74
154
|
process.exit(1);
|
|
75
155
|
});
|