@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 +84 -7
- package/package.json +6 -5
- 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 +66 -8
- package/src/index.js +102 -12
- package/src/resources/design.js +77 -0
- package/src/tools/blog.js +204 -0
- package/src/tools/design.js +136 -0
- package/src/tools/documents.js +108 -0
- package/src/tools/help-center.js +75 -2
- package/src/tools/media.js +86 -0
- package/src/tools/spaces.js +82 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Sutra MCP Server
|
|
2
2
|
|
|
3
|
-
An
|
|
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
|
-
|
|
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
|
|
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`) |
|
|
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
|
-
- **
|
|
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.
|
|
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/
|
|
37
|
+
"homepage": "https://github.com/lorenzsell/sutra-mcp",
|
|
37
38
|
"repository": {
|
|
38
39
|
"type": "git",
|
|
39
|
-
"url": "https://github.com/
|
|
40
|
+
"url": "https://github.com/lorenzsell/sutra-mcp.git"
|
|
40
41
|
},
|
|
41
42
|
"bugs": {
|
|
42
|
-
"url": "https://github.com/
|
|
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">✓</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
|
+
}
|