@avocadostudio-ai/mcp-server 0.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/dist/http.js ADDED
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Avocado Studio MCP server — streamable HTTP transport.
4
+ *
5
+ * Run this when you want the MCP server accessible as a URL (Claude Desktop's
6
+ * "Add custom connector" flow, remote deployments). The stdio entry at
7
+ * src/index.ts remains the right choice for local Claude Code installs.
8
+ *
9
+ * Env vars (in addition to the stdio ones):
10
+ * AVOCADO_MCP_BEARER_TOKEN required — clients must send Authorization: Bearer <token>
11
+ * AVOCADO_MCP_PORT optional — defaults to 4300
12
+ */
13
+ import { createServer } from "node:http";
14
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
16
+ import { loadConfig } from "./config.js";
17
+ import { OrchestratorClient } from "./orchestrator-client.js";
18
+ import { registerAllTools } from "./tools/index.js";
19
+ import { createCapabilityGate } from "./capabilities.js";
20
+ import { checkBearer } from "./http-auth.js";
21
+ import { SERVER_VERSION } from "./version.js";
22
+ const config = loadConfig();
23
+ const bearerToken = process.env.AVOCADO_MCP_BEARER_TOKEN?.trim();
24
+ if (!bearerToken) {
25
+ console.error("AVOCADO_MCP_BEARER_TOKEN is required for HTTP mode. Generate one with `openssl rand -hex 32`.");
26
+ process.exit(1);
27
+ }
28
+ const port = Number(process.env.AVOCADO_MCP_PORT ?? "4300");
29
+ if (!Number.isFinite(port) || port <= 0) {
30
+ console.error(`AVOCADO_MCP_PORT must be a positive integer, got: ${process.env.AVOCADO_MCP_PORT}`);
31
+ process.exit(1);
32
+ }
33
+ const orchestratorClient = new OrchestratorClient(config);
34
+ /**
35
+ * Build a fresh McpServer for each request. Per the SDK's stateless example,
36
+ * the server+transport pair is disposed when the HTTP response closes. Registering
37
+ * tools is cheap (pure wiring) — the heavy side effect (block schema registration)
38
+ * ran once at module load.
39
+ */
40
+ /*
41
+ * One gate for the process, not one per request: the capability answer is a
42
+ * property of the site, and re-probing on every request would put a network
43
+ * call in front of every tool list.
44
+ */
45
+ const capabilityGate = createCapabilityGate(orchestratorClient);
46
+ void capabilityGate.refresh();
47
+ function buildServer() {
48
+ const server = new McpServer({ name: "avocado-studio", version: SERVER_VERSION });
49
+ registerAllTools(server, orchestratorClient, capabilityGate);
50
+ return server;
51
+ }
52
+ async function readBody(req) {
53
+ const chunks = [];
54
+ for await (const chunk of req)
55
+ chunks.push(chunk);
56
+ if (chunks.length === 0)
57
+ return undefined;
58
+ const text = Buffer.concat(chunks).toString("utf8");
59
+ if (!text)
60
+ return undefined;
61
+ try {
62
+ return JSON.parse(text);
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ }
68
+ function sendJson(res, status, body) {
69
+ res.statusCode = status;
70
+ res.setHeader("content-type", "application/json");
71
+ res.end(JSON.stringify(body));
72
+ }
73
+ const httpServer = createServer(async (req, res) => {
74
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
75
+ // Health probe — no auth, useful for load balancers and Claude Desktop connectivity tests.
76
+ if (req.method === "GET" && url.pathname === "/healthz") {
77
+ return sendJson(res, 200, { ok: true, name: "avocado-studio", siteId: config.siteId });
78
+ }
79
+ if (url.pathname !== "/mcp") {
80
+ return sendJson(res, 404, { error: "not found" });
81
+ }
82
+ const auth = checkBearer(req, bearerToken);
83
+ if (!auth.ok) {
84
+ res.setHeader("www-authenticate", "Bearer");
85
+ return sendJson(res, auth.status, { error: auth.message });
86
+ }
87
+ // Stateless mode only supports POST (initialize + JSON-RPC calls in one shot).
88
+ // Reject GET/DELETE explicitly so clients fall back to the POST-only path.
89
+ if (req.method !== "POST") {
90
+ res.setHeader("allow", "POST");
91
+ return sendJson(res, 405, {
92
+ jsonrpc: "2.0",
93
+ error: { code: -32000, message: "Method not allowed. Stateless MCP transport uses POST only." },
94
+ id: null,
95
+ });
96
+ }
97
+ const server = buildServer();
98
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
99
+ res.on("close", () => {
100
+ transport.close().catch(() => { });
101
+ server.close().catch(() => { });
102
+ });
103
+ try {
104
+ const body = await readBody(req);
105
+ await server.connect(transport);
106
+ await transport.handleRequest(req, res, body);
107
+ }
108
+ catch (err) {
109
+ if (!res.headersSent) {
110
+ sendJson(res, 500, {
111
+ jsonrpc: "2.0",
112
+ error: { code: -32603, message: err instanceof Error ? err.message : String(err) },
113
+ id: null,
114
+ });
115
+ }
116
+ else {
117
+ try {
118
+ res.end();
119
+ }
120
+ catch { /* already closed */ }
121
+ }
122
+ }
123
+ });
124
+ httpServer.listen(port, () => {
125
+ console.error(`avocado-studio MCP server listening on http://localhost:${port}/mcp (siteId: ${config.siteId})`);
126
+ });
127
+ const shutdown = () => {
128
+ httpServer.close(() => process.exit(0));
129
+ setTimeout(() => process.exit(1), 5000).unref();
130
+ };
131
+ process.on("SIGINT", shutdown);
132
+ process.on("SIGTERM", shutdown);
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Avocado Studio MCP server — stdio transport.
4
+ *
5
+ * Spawned by an MCP host (Claude Desktop, Claude Code, etc.) with env vars:
6
+ * ORCHESTRATOR_URL — e.g. http://localhost:4200 or https://orchestrator.example.com
7
+ * AVOCADO_SESSION — session key to scope drafts (defaults to "dev")
8
+ * AVOCADO_SITE_ID — required; which site this install edits
9
+ */
10
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Avocado Studio MCP server — stdio transport.
4
+ *
5
+ * Spawned by an MCP host (Claude Desktop, Claude Code, etc.) with env vars:
6
+ * ORCHESTRATOR_URL — e.g. http://localhost:4200 or https://orchestrator.example.com
7
+ * AVOCADO_SESSION — session key to scope drafts (defaults to "dev")
8
+ * AVOCADO_SITE_ID — required; which site this install edits
9
+ */
10
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
+ import { loadConfig } from "./config.js";
13
+ import { OrchestratorClient } from "./orchestrator-client.js";
14
+ import { registerAllTools } from "./tools/index.js";
15
+ import { createCapabilityGate } from "./capabilities.js";
16
+ import { SERVER_VERSION } from "./version.js";
17
+ const config = loadConfig();
18
+ const client = new OrchestratorClient(config);
19
+ const server = new McpServer({ name: "avocado-studio", version: SERVER_VERSION });
20
+ const gate = createCapabilityGate(client);
21
+ registerAllTools(server, client, gate);
22
+ const transport = new StdioServerTransport();
23
+ await server.connect(transport);
24
+ /*
25
+ * Ask what this site can honour only AFTER the transport is connected, and
26
+ * never await it here. Registration cannot wait on the network: a probe in
27
+ * front of `connect` delays the `initialize` handshake by however long the
28
+ * site takes to answer, and makes the server fail to start when the site is
29
+ * down. Tools that turn out to be unavailable are disabled once the answer
30
+ * arrives, and the SDK emits `tools/list_changed`.
31
+ */
32
+ void gate.refresh();
@@ -0,0 +1,93 @@
1
+ import type { McpConfig } from "./config.ts";
2
+ import type { Operation, PageDoc, SiteConfig } from "@avocadostudio-ai/shared";
3
+ /**
4
+ * Thin typed wrapper around the orchestrator HTTP API.
5
+ *
6
+ * All tools go through this client so:
7
+ * - the orchestrator remains the single source of truth for validation,
8
+ * persistence, undo stacks, and demo-mode gating;
9
+ * - tests can stub a single `fetch` surface instead of reimplementing state.
10
+ */
11
+ export type Fetcher = typeof fetch;
12
+ export type RequestOptions = {
13
+ query?: Record<string, string | number | undefined>;
14
+ body?: unknown;
15
+ /** FormData body — bypasses JSON serialization, no content-type header set. */
16
+ formData?: FormData;
17
+ /** Bearer token for endpoints that require auth (e.g. /publish). */
18
+ bearer?: string;
19
+ /** Abort the fetch when this signal fires — lets callers enforce per-request timeouts. */
20
+ signal?: AbortSignal;
21
+ };
22
+ export declare class OrchestratorClient {
23
+ readonly config: McpConfig;
24
+ private readonly fetcher;
25
+ constructor(config: McpConfig, fetcher?: Fetcher);
26
+ /** Low-level request. Exposed so tools can hit arbitrary endpoints without growing per-method wrappers for every call. */
27
+ request<T>(method: "GET" | "POST" | "PUT" | "DELETE", path: string, opts?: RequestOptions): Promise<T>;
28
+ /** Convenience: session + siteId pre-filled for endpoints that take them as query params. */
29
+ scoped(extra?: Record<string, string | number | undefined>): Record<string, string | number | undefined>;
30
+ /** Convenience: session + siteId pre-filled for endpoints that take them in the body. */
31
+ scopedBody<T extends Record<string, unknown>>(extra: T): T & {
32
+ session: string;
33
+ siteId: string;
34
+ };
35
+ getPage(slug: string): Promise<PageDoc>;
36
+ listSlugs(): Promise<PagesIndexResponse>;
37
+ getSiteConfig(): Promise<SiteConfig>;
38
+ applyOps(ops: Operation[]): Promise<ApplyOpsResponse>;
39
+ whoami(): Promise<WhoamiResponse>;
40
+ listSessions(): Promise<ListSessionsResponse>;
41
+ }
42
+ export type PagesIndexEntry = {
43
+ slug: string;
44
+ title: string;
45
+ updatedAt: string;
46
+ blockCount: number;
47
+ };
48
+ export type PagesIndexResponse = {
49
+ slugs: string[];
50
+ pages: PagesIndexEntry[];
51
+ };
52
+ export type SessionSummary = {
53
+ sessionKey: string;
54
+ session: string;
55
+ siteId: string;
56
+ version: number;
57
+ draftPageCount: number;
58
+ lastMutatedAt: string | null;
59
+ };
60
+ export type WhoamiResponse = SessionSummary & {
61
+ /** Null unless this is the demo site — the orchestrator does not track what is live elsewhere. */
62
+ publishedPageCount: number | null;
63
+ /** Present only when the count is null, explaining where the live side actually lives. */
64
+ publishedPageCountNote?: string;
65
+ orchestratorUrl: string;
66
+ };
67
+ export type ListSessionsResponse = {
68
+ sessions: SessionSummary[];
69
+ /** Bundled demo pages, not any site's published count. */
70
+ demoPublishedPageCount: number;
71
+ };
72
+ export type ApplyOpsResponse = {
73
+ status: string;
74
+ summary: string;
75
+ /**
76
+ * One human-readable line per operation ("Updated Hero heading on /about").
77
+ * Was `unknown[]` while both transports hardcoded it empty.
78
+ */
79
+ changes: string[];
80
+ mentionedSlugs: string[];
81
+ previewVersion: number;
82
+ focusBlockId?: string;
83
+ updatedSlug?: string;
84
+ /**
85
+ * Only present when the batch contained at least one duplicate_page op.
86
+ * Each entry maps the new page's slug to a { oldBlockId: newBlockId } table
87
+ * so callers can target the copied blocks without a follow-up get-page.
88
+ */
89
+ duplicatedPages?: Array<{
90
+ slug: string;
91
+ blockIdMap: Record<string, string>;
92
+ }>;
93
+ };
@@ -0,0 +1,64 @@
1
+ export class OrchestratorClient {
2
+ config;
3
+ fetcher;
4
+ constructor(config, fetcher = fetch) {
5
+ this.config = config;
6
+ this.fetcher = fetcher;
7
+ }
8
+ /** Low-level request. Exposed so tools can hit arbitrary endpoints without growing per-method wrappers for every call. */
9
+ async request(method, path, opts = {}) {
10
+ const url = new URL(this.config.orchestratorUrl + path);
11
+ for (const [key, value] of Object.entries(opts.query ?? {})) {
12
+ if (value !== undefined)
13
+ url.searchParams.set(key, String(value));
14
+ }
15
+ const headers = {};
16
+ let body;
17
+ if (opts.formData) {
18
+ body = opts.formData;
19
+ }
20
+ else if (opts.body !== undefined) {
21
+ headers["content-type"] = "application/json";
22
+ body = JSON.stringify(opts.body);
23
+ }
24
+ if (opts.bearer)
25
+ headers.authorization = `Bearer ${opts.bearer}`;
26
+ const res = await this.fetcher(url.toString(), { method, headers, body, signal: opts.signal });
27
+ if (!res.ok) {
28
+ const text = await res.text().catch(() => "");
29
+ throw new Error(`orchestrator ${method} ${path} failed: ${res.status} ${text}`);
30
+ }
31
+ // Some endpoints return non-JSON (rare) — fall back to text.
32
+ const contentType = res.headers.get("content-type") ?? "";
33
+ if (contentType.includes("application/json"))
34
+ return (await res.json());
35
+ return (await res.text());
36
+ }
37
+ /** Convenience: session + siteId pre-filled for endpoints that take them as query params. */
38
+ scoped(extra = {}) {
39
+ return { session: this.config.session, siteId: this.config.siteId, ...extra };
40
+ }
41
+ /** Convenience: session + siteId pre-filled for endpoints that take them in the body. */
42
+ scopedBody(extra) {
43
+ return { session: this.config.session, siteId: this.config.siteId, ...extra };
44
+ }
45
+ // ── Typed helpers for the hottest paths ──
46
+ getPage(slug) {
47
+ return this.request("GET", "/draft/pages", { query: this.scoped({ slug }) });
48
+ }
49
+ listSlugs() {
50
+ return this.request("GET", "/draft/slugs", { query: this.scoped() });
51
+ }
52
+ getSiteConfig() {
53
+ return this.request("GET", "/draft/site-config", { query: this.scoped() });
54
+ }
55
+ applyOps(ops) {
56
+ return this.request("POST", "/ops", { body: this.scopedBody({ ops }) });
57
+ }
58
+ whoami() {
59
+ return this.request("GET", "/whoami", { query: this.scoped() });
60
+ }
61
+ listSessions() {
62
+ return this.request("GET", "/sessions");
63
+ }
64
+ }
@@ -0,0 +1,33 @@
1
+ import type { OrchestratorClient } from "../orchestrator-client.ts";
2
+ export declare function jsonResult(payload: unknown): {
3
+ content: {
4
+ type: "text";
5
+ text: string;
6
+ }[];
7
+ };
8
+ export declare function errorResult(err: unknown): {
9
+ content: {
10
+ type: "text";
11
+ text: string;
12
+ }[];
13
+ isError: true;
14
+ };
15
+ type ChatResult = {
16
+ status?: string;
17
+ undoSlug?: string;
18
+ mentionedSlugs?: string[];
19
+ };
20
+ type ToolContent = {
21
+ type: "text";
22
+ text: string;
23
+ } | {
24
+ type: "image";
25
+ data: string;
26
+ mimeType: string;
27
+ };
28
+ export declare function chatResult(client: OrchestratorClient, payload: ChatResult, opts?: {
29
+ screenshot?: boolean;
30
+ }): Promise<{
31
+ content: ToolContent[];
32
+ }>;
33
+ export {};
@@ -0,0 +1,58 @@
1
+ export function jsonResult(payload) {
2
+ return {
3
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
4
+ };
5
+ }
6
+ export function errorResult(err) {
7
+ const message = err instanceof Error ? err.message : String(err);
8
+ return {
9
+ content: [{ type: "text", text: message }],
10
+ isError: true,
11
+ };
12
+ }
13
+ /**
14
+ * Build a tool result for a /chat response, optionally chaining a draft-preview
15
+ * screenshot of the page that was just mutated. We only screenshot when the
16
+ * chat returned `applied` — pending/clarification states haven't changed state
17
+ * so there's nothing new to show. Screenshot failures are non-fatal.
18
+ *
19
+ * The screenshot is hard-capped at CHAT_SCREENSHOT_TIMEOUT_MS because Claude
20
+ * Desktop/web clients abandon tool results after ~30s. A slow Playwright load
21
+ * on top of a long planner run can exceed that window, and the user sees
22
+ * "Tool result could not be submitted." On timeout we degrade to a note
23
+ * instead of blocking the whole response.
24
+ */
25
+ const CHAT_SCREENSHOT_TIMEOUT_MS = 8000;
26
+ export async function chatResult(client, payload, opts = {}) {
27
+ const content = [
28
+ { type: "text", text: JSON.stringify(payload, null, 2) },
29
+ ];
30
+ const shouldShoot = opts.screenshot !== false && payload.status === "applied";
31
+ const slug = payload.undoSlug ?? payload.mentionedSlugs?.[0];
32
+ if (shouldShoot && slug) {
33
+ const controller = new AbortController();
34
+ const timer = setTimeout(() => controller.abort(), CHAT_SCREENSHOT_TIMEOUT_MS);
35
+ try {
36
+ const shot = await client.request("POST", "/preview/screenshot", {
37
+ body: client.scopedBody({ slug }),
38
+ signal: controller.signal,
39
+ });
40
+ content.push({
41
+ type: "text",
42
+ text: `Preview of ${shot.url} (draft, ${shot.width}×${shot.height})`,
43
+ });
44
+ content.push({ type: "image", data: shot.base64, mimeType: shot.mimeType });
45
+ }
46
+ catch (err) {
47
+ const aborted = controller.signal.aborted;
48
+ const reason = aborted
49
+ ? `timeout after ${CHAT_SCREENSHOT_TIMEOUT_MS}ms`
50
+ : err instanceof Error ? err.message : String(err);
51
+ content.push({ type: "text", text: `(screenshot skipped: ${reason})` });
52
+ }
53
+ finally {
54
+ clearTimeout(timer);
55
+ }
56
+ }
57
+ return { content };
58
+ }
@@ -0,0 +1,4 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { OrchestratorClient } from "../orchestrator-client.ts";
3
+ import type { CapabilityGate } from "../capabilities.ts";
4
+ export declare function registerBlockTools(server: McpServer, client: OrchestratorClient, gate?: CapabilityGate): void;