@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/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) {
|
|
@@ -47,6 +101,10 @@ export class SutraAdminClient {
|
|
|
47
101
|
return this.request("PATCH", path, { body, headers });
|
|
48
102
|
}
|
|
49
103
|
|
|
104
|
+
put(path, body, headers) {
|
|
105
|
+
return this.request("PUT", path, { body, headers });
|
|
106
|
+
}
|
|
107
|
+
|
|
50
108
|
delete(path, headers) {
|
|
51
109
|
return this.request("DELETE", path, { headers });
|
|
52
110
|
}
|
package/src/index.js
CHANGED
|
@@ -7,6 +7,8 @@ import { SutraAdminClient } from "./client.js";
|
|
|
7
7
|
import { registerSpaceTools } from "./tools/spaces.js";
|
|
8
8
|
import { registerMemberTools } from "./tools/members.js";
|
|
9
9
|
import { registerContentTools } from "./tools/content.js";
|
|
10
|
+
import { registerDocumentTools } from "./tools/documents.js";
|
|
11
|
+
import { registerMediaTools } from "./tools/media.js";
|
|
10
12
|
import { registerPropertyTools } from "./tools/properties.js";
|
|
11
13
|
import { registerInvitationTools } from "./tools/invitations.js";
|
|
12
14
|
import { registerSurveyTools } from "./tools/surveys.js";
|
|
@@ -16,32 +18,117 @@ import { registerBroadcastTools } from "./tools/broadcasts.js";
|
|
|
16
18
|
import { registerDeepTool } from "./tools/deep.js";
|
|
17
19
|
import { registerAutomationTools } from "./tools/automations.js";
|
|
18
20
|
import { registerHelpCenterTools } from "./tools/help-center.js";
|
|
21
|
+
import { registerBlogTools } from "./tools/blog.js";
|
|
22
|
+
import { registerDesignTools } from "./tools/design.js";
|
|
19
23
|
import { registerAdminApiResources } from "./resources/admin-api.js";
|
|
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
|
+
// ---------------------------------------------------------------------------
|
|
20
56
|
|
|
21
57
|
const SUTRA_API_TOKEN = process.env.SUTRA_API_TOKEN;
|
|
22
58
|
const SUTRA_BASE_URL = process.env.SUTRA_BASE_URL;
|
|
59
|
+
const SUTRA_OAUTH_ISSUER = process.env.SUTRA_OAUTH_ISSUER || "https://sutra.co";
|
|
23
60
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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);
|
|
31
114
|
}
|
|
32
115
|
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// MCP server setup
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
33
120
|
const server = new McpServer({
|
|
34
121
|
name: "sutra",
|
|
35
|
-
version: "1.
|
|
122
|
+
version: "1.2.0",
|
|
36
123
|
description:
|
|
37
124
|
"Sutra Admin API — manage spaces, members, contacts, content, discussions, surveys, plans, broadcasts, and more.",
|
|
38
125
|
});
|
|
39
126
|
|
|
40
|
-
const client = new SutraAdminClient(SUTRA_API_TOKEN, SUTRA_BASE_URL);
|
|
41
|
-
|
|
42
127
|
registerSpaceTools(server, client);
|
|
43
128
|
registerMemberTools(server, client);
|
|
44
129
|
registerContentTools(server, client);
|
|
130
|
+
registerDocumentTools(server, client);
|
|
131
|
+
registerMediaTools(server, client);
|
|
45
132
|
registerPropertyTools(server, client);
|
|
46
133
|
registerInvitationTools(server, client);
|
|
47
134
|
registerSurveyTools(server, client);
|
|
@@ -50,16 +137,19 @@ registerCouponTools(server, client);
|
|
|
50
137
|
registerBroadcastTools(server, client);
|
|
51
138
|
registerDeepTool(server, client);
|
|
52
139
|
registerAutomationTools(server, client);
|
|
140
|
+
registerDesignTools(server, client);
|
|
53
141
|
registerHelpCenterTools(server);
|
|
142
|
+
registerBlogTools(server);
|
|
54
143
|
registerAdminApiResources(server);
|
|
144
|
+
registerDesignResources(server, client);
|
|
55
145
|
|
|
56
146
|
async function main() {
|
|
57
147
|
const transport = new StdioServerTransport();
|
|
58
148
|
await server.connect(transport);
|
|
59
|
-
|
|
149
|
+
process.stderr.write("Sutra MCP server running on stdio\n");
|
|
60
150
|
}
|
|
61
151
|
|
|
62
152
|
main().catch((err) => {
|
|
63
|
-
|
|
153
|
+
process.stderr.write(`Fatal: ${err}\n`);
|
|
64
154
|
process.exit(1);
|
|
65
155
|
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const BUNDLED_CAPABILITIES_MARKDOWN = `# Sutra Design Capabilities
|
|
2
|
+
|
|
3
|
+
Read this before designing Sutra pages.
|
|
4
|
+
|
|
5
|
+
- Use get_design_capabilities for the live manifest whenever possible.
|
|
6
|
+
- Read get_space_design before changing a page and keep its content_digest as base_digest.
|
|
7
|
+
- Validate nodes before creating or publishing a draft.
|
|
8
|
+
- Use create_or_update_design_draft and publish_design_draft instead of raw content writes.
|
|
9
|
+
- If publish returns 409 conflict, re-read the design and rebase instead of retrying blindly.
|
|
10
|
+
- Do not emit image_keywords; direct design writes require real image URLs.
|
|
11
|
+
- Use actionCallbackValue and actionCallbackTarget for action buttons.
|
|
12
|
+
- Use 12-unit grid dist arrays such as [6,6], [8,4], or [4,4,4].
|
|
13
|
+
- Use only font families returned by get_design_capabilities fonts.available; other fonts fall back at render time.
|
|
14
|
+
`;
|
|
15
|
+
|
|
16
|
+
export function registerDesignResources(server, client) {
|
|
17
|
+
server.registerResource(
|
|
18
|
+
"sutra-design-capabilities",
|
|
19
|
+
"sutra://design/capabilities",
|
|
20
|
+
{
|
|
21
|
+
title: "Sutra Design Capabilities",
|
|
22
|
+
description: "Renderer-aware Sutra design rules and supported fonts for page-generation tools.",
|
|
23
|
+
mimeType: "text/markdown",
|
|
24
|
+
},
|
|
25
|
+
async () => {
|
|
26
|
+
let text = BUNDLED_CAPABILITIES_MARKDOWN;
|
|
27
|
+
try {
|
|
28
|
+
const live = await client.get("/design/capabilities");
|
|
29
|
+
text = manifestToMarkdown(live.data || live);
|
|
30
|
+
} catch (_err) {
|
|
31
|
+
text += "\n\nLive capabilities could not be fetched; this bundled guidance may be stale.\n";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
contents: [
|
|
36
|
+
{
|
|
37
|
+
uri: "sutra://design/capabilities",
|
|
38
|
+
mimeType: "text/markdown",
|
|
39
|
+
text,
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function manifestToMarkdown(manifest) {
|
|
48
|
+
const lines = [
|
|
49
|
+
"# Sutra Design Capabilities",
|
|
50
|
+
"",
|
|
51
|
+
`Version: ${manifest.version || "unknown"}`,
|
|
52
|
+
"",
|
|
53
|
+
"## Rules",
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
for (const rule of Object.values(manifest.rules || {})) lines.push(`- ${rule}`);
|
|
57
|
+
lines.push("", "## Node Types");
|
|
58
|
+
for (const [type, spec] of Object.entries(manifest.nodes || {})) {
|
|
59
|
+
lines.push(`- \`${type}\` attrs: ${(spec.attrs || []).map((attr) => `\`${attr}\``).join(", ")}`);
|
|
60
|
+
}
|
|
61
|
+
lines.push("", "## Marks");
|
|
62
|
+
for (const [type, spec] of Object.entries(manifest.marks || {})) {
|
|
63
|
+
lines.push(`- \`${type}\` attrs: ${(spec.attrs || []).map((attr) => `\`${attr}\``).join(", ")}`);
|
|
64
|
+
}
|
|
65
|
+
const fonts = manifest.fonts || {};
|
|
66
|
+
const availableFonts = fonts.available || [];
|
|
67
|
+
lines.push("", "## Fonts");
|
|
68
|
+
if (availableFonts.length > 0) {
|
|
69
|
+
for (const font of availableFonts) lines.push(`- ${font}`);
|
|
70
|
+
} else {
|
|
71
|
+
lines.push("- No font list returned by the live manifest.");
|
|
72
|
+
}
|
|
73
|
+
if (fonts.note) lines.push(`- ${fonts.note}`);
|
|
74
|
+
lines.push("", "## Anti-patterns");
|
|
75
|
+
for (const rule of manifest.anti_patterns || []) lines.push(`- ${rule}`);
|
|
76
|
+
return lines.join("\n");
|
|
77
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// MCP tools for the Sutra Blog (public surface at /resources). Mirrors
|
|
4
|
+
// help-center.js, blog-shaped: category is optional, tags are supported, and
|
|
5
|
+
// the support-only fields (product_area, intent_type, applies_to_plan, …) are
|
|
6
|
+
// dropped. Calls the admin API under /api/v4/lotus/blog.
|
|
7
|
+
const BLOG_BASE_URL = process.env.SUTRA_BLOG_BASE_URL || "https://api.sutra.co/api/v4/lotus/blog";
|
|
8
|
+
|
|
9
|
+
export function registerBlogTools(server) {
|
|
10
|
+
const token = process.env.SUTRA_BLOG_TOKEN || process.env.SUTRA_HELP_CENTER_TOKEN;
|
|
11
|
+
if (!token) return;
|
|
12
|
+
|
|
13
|
+
async function request(method, path, { params, body } = {}) {
|
|
14
|
+
const url = new URL(`${BLOG_BASE_URL}${path}`);
|
|
15
|
+
if (params) {
|
|
16
|
+
for (const [k, v] of Object.entries(params)) {
|
|
17
|
+
if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const headers = {
|
|
21
|
+
Authorization: `Bearer ${token}`,
|
|
22
|
+
Accept: "application/json",
|
|
23
|
+
...(body ? { "Content-Type": "application/json" } : {}),
|
|
24
|
+
};
|
|
25
|
+
const res = await fetch(url.toString(), {
|
|
26
|
+
method,
|
|
27
|
+
headers,
|
|
28
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
29
|
+
});
|
|
30
|
+
const text = await res.text();
|
|
31
|
+
if (!res.ok) throw new Error(`${method} ${path} → ${res.status}: ${text}`);
|
|
32
|
+
return text ? JSON.parse(text) : {};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Shared optional post fields (used by create / propose / update).
|
|
36
|
+
const postFields = {
|
|
37
|
+
title: z.string().optional().describe("Post title"),
|
|
38
|
+
slug: z.string().optional().describe("URL slug (auto-generated from title if omitted; cannot equal a category slug)"),
|
|
39
|
+
subtitle: z.string().optional().describe("Subtitle / deck"),
|
|
40
|
+
excerpt: z.string().optional().describe("Short summary shown on cards"),
|
|
41
|
+
blog_category_id: z.number().optional().describe("Category ID — OPTIONAL (posts can be uncategorized)"),
|
|
42
|
+
hero_image_url: z.string().optional().describe("Hero / cover image URL"),
|
|
43
|
+
og_image_url: z.string().optional().describe("Open Graph image URL"),
|
|
44
|
+
meta_title: z.string().optional().describe("SEO meta title"),
|
|
45
|
+
meta_description: z.string().optional().describe("SEO meta description"),
|
|
46
|
+
featured: z.boolean().optional().describe("Feature on the landing page"),
|
|
47
|
+
position: z.number().optional().describe("Sort position"),
|
|
48
|
+
search_keywords: z.array(z.string()).optional().describe("Extra search keywords for recall"),
|
|
49
|
+
tags: z.array(z.string()).optional().describe("Tag slugs (created if missing)"),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// --- Posts ---
|
|
53
|
+
|
|
54
|
+
server.tool(
|
|
55
|
+
"blog_list_posts",
|
|
56
|
+
"List blog posts (the public Resources surface, served at /resources) with optional filters. Returns post summaries and stats.",
|
|
57
|
+
{
|
|
58
|
+
status: z.enum(["draft", "published", "archived"]).optional().describe("Filter by status"),
|
|
59
|
+
review_status: z.string().optional().describe("Filter by review status"),
|
|
60
|
+
category_id: z.number().optional().describe("Filter by category ID"),
|
|
61
|
+
featured: z.boolean().optional().describe("Filter by featured flag"),
|
|
62
|
+
q: z.string().optional().describe("Search title and excerpt"),
|
|
63
|
+
},
|
|
64
|
+
async (params) => json(await request("GET", "/posts.json", { params }))
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
server.tool(
|
|
68
|
+
"blog_get_post",
|
|
69
|
+
"Get a blog post with full content, metadata, and version history.",
|
|
70
|
+
{ id: z.number().describe("Post ID") },
|
|
71
|
+
async ({ id }) => json(await request("GET", `/posts/${id}.json`))
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
server.tool(
|
|
75
|
+
"blog_create_post",
|
|
76
|
+
"Create a new blog post. Returns the post and its first version.",
|
|
77
|
+
{
|
|
78
|
+
...postFields,
|
|
79
|
+
title: z.string().describe("Post title"),
|
|
80
|
+
status: z.enum(["draft", "published", "archived"]).optional().describe("Initial status (default: draft)"),
|
|
81
|
+
content: z.any().optional().describe("Tiptap JSON content"),
|
|
82
|
+
commit_message: z.string().optional().describe("Version commit message"),
|
|
83
|
+
publish: z.boolean().optional().describe("Publish immediately after creation"),
|
|
84
|
+
},
|
|
85
|
+
async ({ publish, ...post }) => json(await request("POST", "/posts.json", { body: { post, publish } }))
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
server.tool(
|
|
89
|
+
"blog_propose_post",
|
|
90
|
+
"Submit an AI-authored blog post draft for human review. Creates an ai_cobolt version and marks the post as AI changes pending (not published).",
|
|
91
|
+
{
|
|
92
|
+
...postFields,
|
|
93
|
+
title: z.string().describe("Post title"),
|
|
94
|
+
content: z.any().describe("Tiptap JSON content"),
|
|
95
|
+
commit_message: z.string().optional().describe("Version commit message"),
|
|
96
|
+
ai_agent_id: z.string().optional().describe("Identifier for the AI agent submitting the draft"),
|
|
97
|
+
source_signal: z.record(z.any()).optional().describe("Evidence or trigger that led to this draft"),
|
|
98
|
+
},
|
|
99
|
+
async ({ ai_agent_id, source_signal, ...post }) =>
|
|
100
|
+
json(await request("POST", "/posts/propose.json", { body: { post, ai_agent_id, source_signal } }))
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
server.tool(
|
|
104
|
+
"blog_update_post",
|
|
105
|
+
"Update an existing blog post and create a new version.",
|
|
106
|
+
{
|
|
107
|
+
id: z.number().describe("Post ID"),
|
|
108
|
+
...postFields,
|
|
109
|
+
status: z.string().optional().describe("Status (draft/published/archived)"),
|
|
110
|
+
content: z.any().optional().describe("Tiptap JSON content"),
|
|
111
|
+
commit_message: z.string().optional().describe("Version commit message"),
|
|
112
|
+
publish: z.boolean().optional().describe("Publish this version immediately"),
|
|
113
|
+
},
|
|
114
|
+
async ({ id, publish, ...post }) => json(await request("PUT", `/posts/${id}.json`, { body: { post, publish } }))
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
server.tool(
|
|
118
|
+
"blog_propose_post_update",
|
|
119
|
+
"Submit AI-authored changes to an existing blog post for human review. Creates an ai_cobolt pending version without publishing.",
|
|
120
|
+
{
|
|
121
|
+
id: z.number().describe("Post ID"),
|
|
122
|
+
...postFields,
|
|
123
|
+
content: z.any().optional().describe("Proposed Tiptap content; omit for metadata-only proposals"),
|
|
124
|
+
commit_message: z.string().optional().describe("Version commit message"),
|
|
125
|
+
ai_agent_id: z.string().optional().describe("Identifier for the AI agent submitting the proposal"),
|
|
126
|
+
source_signal: z.record(z.any()).optional().describe("Evidence or trigger that led to this update"),
|
|
127
|
+
},
|
|
128
|
+
async ({ id, ai_agent_id, source_signal, ...post }) =>
|
|
129
|
+
json(await request("POST", `/posts/${id}/propose_update.json`, { body: { post, ai_agent_id, source_signal } }))
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
server.tool(
|
|
133
|
+
"blog_publish_post",
|
|
134
|
+
"Publish a blog post version. Publishes the latest version if no version_id is given.",
|
|
135
|
+
{
|
|
136
|
+
id: z.number().describe("Post ID"),
|
|
137
|
+
version_id: z.number().optional().describe("Specific version ID to publish (defaults to latest)"),
|
|
138
|
+
},
|
|
139
|
+
async ({ id, version_id }) =>
|
|
140
|
+
json(await request("POST", `/posts/${id}/publish.json`, { body: version_id ? { version_id } : {} }))
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
server.tool(
|
|
144
|
+
"blog_review_post",
|
|
145
|
+
"Approve or reject a pending blog post version.",
|
|
146
|
+
{
|
|
147
|
+
id: z.number().describe("Post ID"),
|
|
148
|
+
version_id: z.number().describe("Version ID to review"),
|
|
149
|
+
decision: z.enum(["approve", "reject"]).describe("Review decision"),
|
|
150
|
+
comment: z.string().optional().describe("Review comment"),
|
|
151
|
+
},
|
|
152
|
+
async ({ id, ...body }) => json(await request("POST", `/posts/${id}/review.json`, { body }))
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// --- Categories ---
|
|
156
|
+
|
|
157
|
+
server.tool("blog_list_categories", "List all blog categories.", {}, async () =>
|
|
158
|
+
json(await request("GET", "/categories.json")));
|
|
159
|
+
|
|
160
|
+
server.tool(
|
|
161
|
+
"blog_create_category",
|
|
162
|
+
"Create a new blog category.",
|
|
163
|
+
{
|
|
164
|
+
name: z.string().describe("Category name"),
|
|
165
|
+
slug: z.string().optional().describe("URL slug"),
|
|
166
|
+
description: z.string().optional().describe("Category description"),
|
|
167
|
+
icon_library: z.string().optional().describe("Icon library name"),
|
|
168
|
+
icon_name: z.string().optional().describe("Icon name"),
|
|
169
|
+
icon_color: z.string().optional().describe("Icon color hex"),
|
|
170
|
+
position: z.number().optional().describe("Sort position"),
|
|
171
|
+
published: z.boolean().optional().describe("Whether the category is published"),
|
|
172
|
+
},
|
|
173
|
+
async (category) => json(await request("POST", "/categories.json", { body: { category } }))
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
server.tool(
|
|
177
|
+
"blog_update_category",
|
|
178
|
+
"Update a blog category.",
|
|
179
|
+
{
|
|
180
|
+
id: z.number().describe("Category ID"),
|
|
181
|
+
name: z.string().optional(),
|
|
182
|
+
slug: z.string().optional(),
|
|
183
|
+
description: z.string().optional(),
|
|
184
|
+
icon_library: z.string().optional(),
|
|
185
|
+
icon_name: z.string().optional(),
|
|
186
|
+
icon_color: z.string().optional(),
|
|
187
|
+
position: z.number().optional(),
|
|
188
|
+
published: z.boolean().optional(),
|
|
189
|
+
},
|
|
190
|
+
async ({ id, ...category }) => json(await request("PUT", `/categories/${id}.json`, { body: { category } }))
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
server.tool("blog_delete_category", "Delete a blog category.", { id: z.number().describe("Category ID") },
|
|
194
|
+
async ({ id }) => json(await request("DELETE", `/categories/${id}.json`)));
|
|
195
|
+
|
|
196
|
+
// --- Tags ---
|
|
197
|
+
|
|
198
|
+
server.tool("blog_list_tags", "List all blog tags.", {}, async () =>
|
|
199
|
+
json(await request("GET", "/tags.json")));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function json(data) {
|
|
203
|
+
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
204
|
+
}
|