@usegraft/mcp 0.1.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/index.js ADDED
@@ -0,0 +1,1357 @@
1
+ // src/index.ts
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+
4
+ // src/server.ts
5
+ import { readdirSync as readdirSync2, readFileSync as readFileSync2, statSync as statSync2, unlinkSync } from "fs";
6
+ import { existsSync as existsSync2 } from "fs";
7
+ import { join as join2 } from "path";
8
+ import {
9
+ contentTypeFor,
10
+ createStorage,
11
+ defaultKeyFor,
12
+ storageConfigFromEnv
13
+ } from "@usegraft/assets";
14
+ import {
15
+ compile,
16
+ compileStatic,
17
+ composeDocument,
18
+ parseDocument as parseDocument2,
19
+ writeDocumentFile
20
+ } from "@usegraft/compiler";
21
+ import {
22
+ GraftError as GraftError2
23
+ } from "@usegraft/contracts";
24
+ import {
25
+ APPROVAL_HEADER,
26
+ AssetRef,
27
+ createFunctionsHandler,
28
+ defineFunction,
29
+ field
30
+ } from "@usegraft/core";
31
+ import {
32
+ assertSearchQuery,
33
+ decideApproval,
34
+ listBranches,
35
+ listCompilations,
36
+ listPendingApprovals,
37
+ openStaticIndex,
38
+ resolveBranchScope,
39
+ scopeChain,
40
+ searchContent
41
+ } from "@usegraft/db";
42
+ import { describeItem, listItems, loadItem } from "@usegraft/registry";
43
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
44
+ import { z } from "zod";
45
+
46
+ // src/content-files.ts
47
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs";
48
+ import { join, relative, sep } from "path";
49
+ import { parseDocument } from "@usegraft/compiler";
50
+ import { GraftError } from "@usegraft/contracts";
51
+ function requireCollection(collections, name) {
52
+ const collection = collections[name];
53
+ if (!collection) {
54
+ const known = Object.keys(collections).join(", ") || "(none registered)";
55
+ throw new GraftError({
56
+ code: "COLLECTION_NOT_FOUND",
57
+ message: `No collection named "${name}" is registered`,
58
+ fix: `Use one of the registered collections: ${known} (see list_collections), or add defineCollection({ name: "${name}", \u2026 }) to the schema.`,
59
+ details: { collection: name, registered: Object.keys(collections) }
60
+ });
61
+ }
62
+ return collection;
63
+ }
64
+ function readCollectionDocs(contentDir, collectionName, collection) {
65
+ const dir = join(contentDir, collectionName);
66
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
67
+ const docs = [];
68
+ for (const file of walk(dir)) {
69
+ const sourcePath = relative(contentDir, file).split(sep).join("/");
70
+ docs.push(parseDocument(readFileSync(file, "utf8"), collection, sourcePath));
71
+ }
72
+ return docs.sort((a, b) => a.slug.localeCompare(b.slug));
73
+ }
74
+ function findDoc(contentDir, collectionName, collection, slug) {
75
+ const docs = readCollectionDocs(contentDir, collectionName, collection);
76
+ const doc = docs.find((candidate) => candidate.slug === slug);
77
+ if (!doc) {
78
+ const slugs = docs.map((candidate) => candidate.slug);
79
+ throw new GraftError({
80
+ code: "DOCUMENT_NOT_FOUND",
81
+ message: `No document with slug "${slug}" in collection "${collectionName}"`,
82
+ fix: slugs.length > 0 ? `Existing slugs: ${slugs.join(", ")}. Use one of those, or author the document with write_content.` : `Collection "${collectionName}" has no documents yet \u2014 author the first one with write_content.`,
83
+ details: { collection: collectionName, slug, existing: slugs }
84
+ });
85
+ }
86
+ return doc;
87
+ }
88
+ function walk(dir) {
89
+ const out = [];
90
+ for (const name of readdirSync(dir)) {
91
+ const full = join(dir, name);
92
+ if (statSync(full).isDirectory()) out.push(...walk(full));
93
+ else if (/\.mdx?$/.test(name)) out.push(full);
94
+ }
95
+ return out.sort();
96
+ }
97
+
98
+ // src/explain.ts
99
+ import { ErrorCodes } from "@usegraft/contracts";
100
+ var ERROR_KNOWLEDGE = {
101
+ SCHEMA_VALIDATION_FAILED: {
102
+ code: "SCHEMA_VALIDATION_FAILED",
103
+ meaning: "A document's frontmatter does not satisfy its collection's Zod schema.",
104
+ typicalCauses: [
105
+ "A required field is missing from the frontmatter",
106
+ "A field has the wrong type (e.g. a string where a number is expected)",
107
+ "The schema changed in graft.config.ts and existing documents were not updated"
108
+ ],
109
+ howToRecover: "Call describe_schema to see the exact fields the collection expects, fix the frontmatter (the error's `details.issues` lists every violation), then retry the write or re-run compile."
110
+ },
111
+ COLLECTION_NOT_FOUND: {
112
+ code: "COLLECTION_NOT_FOUND",
113
+ meaning: "Content referenced a collection that is not registered in the schema.",
114
+ typicalCauses: [
115
+ "A typo in the collection name",
116
+ "A document placed in a folder that has no matching defineCollection",
117
+ "The collection exists in code but was not passed to compile()/the MCP server"
118
+ ],
119
+ howToRecover: "Call list_collections to see what is registered. Either use one of those names, move the file into a registered collection folder, or add a defineCollection for it in graft.config.ts."
120
+ },
121
+ CONFIG_NOT_FOUND: {
122
+ code: "CONFIG_NOT_FOUND",
123
+ meaning: "No graft.config.{ts,js} was found in the working directory or any parent.",
124
+ typicalCauses: [
125
+ "The command was run outside a Graft project",
126
+ "The project has not been initialized yet"
127
+ ],
128
+ howToRecover: "cd into the project root (the directory holding graft.config.ts) and retry, or scaffold a new project with `graft init`."
129
+ },
130
+ CONFIG_INVALID: {
131
+ code: "CONFIG_INVALID",
132
+ meaning: "graft.config exists but could not be loaded, or it does not export a valid `collections` record.",
133
+ typicalCauses: [
134
+ "A syntax or import error in graft.config.ts",
135
+ "Missing `export const collections = { \u2026 }`",
136
+ "A collections entry that was not created with defineCollection"
137
+ ],
138
+ howToRecover: "Fix graft.config.ts so it imports defineCollection/field from @usegraft/core and exports `collections` as a record of defineCollection results \u2014 the error message names exactly what failed to load or validate."
139
+ },
140
+ ALREADY_INITIALIZED: {
141
+ code: "ALREADY_INITIALIZED",
142
+ meaning: "`graft init` was run in a directory that already has a graft.config.",
143
+ typicalCauses: [
144
+ "Re-running init in an existing project",
145
+ "Pointing init at the wrong directory"
146
+ ],
147
+ howToRecover: "This is already a Graft project \u2014 evolve it by editing the existing graft.config.ts (add collections or fields there), or run `graft init <dir>` against an empty directory."
148
+ },
149
+ ENV_VAR_MISSING: {
150
+ code: "ENV_VAR_MISSING",
151
+ meaning: "A required environment variable is not set.",
152
+ typicalCauses: [
153
+ "The project has no .env file yet",
154
+ "The variable exists in a different environment (e.g. CI but not local)"
155
+ ],
156
+ howToRecover: "Add the variable named in `details.variable` to the project's .env (the CLI walks parent directories to find one) or export it in the environment, then retry."
157
+ },
158
+ CONTENT_DIR_NOT_FOUND: {
159
+ code: "CONTENT_DIR_NOT_FOUND",
160
+ meaning: "The configured content directory does not exist on disk.",
161
+ typicalCauses: [
162
+ "The project has no content/ directory yet",
163
+ "compile() was pointed at the wrong path"
164
+ ],
165
+ howToRecover: "Create the directory (documents live at <contentDir>/<collection>/<slug>.mdx) or correct the contentDir passed to compile()/the MCP server. write_content creates folders automatically."
166
+ },
167
+ CONTENT_REFERENCE_NOT_FOUND: {
168
+ code: "CONTENT_REFERENCE_NOT_FOUND",
169
+ meaning: "A document references another document that does not exist.",
170
+ typicalCauses: [
171
+ "The referenced document was deleted or renamed",
172
+ "A slug typo in the reference"
173
+ ],
174
+ howToRecover: "Create the missing document, or update the reference to point at an existing collection/slug (list_content shows what exists)."
175
+ },
176
+ DOCUMENT_NOT_FOUND: {
177
+ code: "DOCUMENT_NOT_FOUND",
178
+ meaning: "No document with the requested slug exists in that collection's files.",
179
+ typicalCauses: [
180
+ "A slug typo",
181
+ "The document lives in a different collection",
182
+ "The document was deleted (git is authoritative \u2014 the files are the truth)"
183
+ ],
184
+ howToRecover: "Call list_content for the collection to see every slug that exists, then retry with a real one \u2014 or author the document with write_content."
185
+ },
186
+ FUNCTION_NOT_FOUND: {
187
+ code: "FUNCTION_NOT_FOUND",
188
+ meaning: "The invoked function name is not registered in the functions runtime.",
189
+ typicalCauses: [
190
+ "A typo in the function name (the last path segment of the POST URL)",
191
+ "The function exists in code but was not included in the exported `functions` record"
192
+ ],
193
+ howToRecover: "The error's `details.available` lists every registered function \u2014 call one of those, or add a defineFunction to graft.config.ts and include it in the exported `functions`."
194
+ },
195
+ INPUT_VALIDATION_FAILED: {
196
+ code: "INPUT_VALIDATION_FAILED",
197
+ meaning: "A function invocation's input does not satisfy the function's Zod input schema.",
198
+ typicalCauses: [
199
+ "The request body is not a JSON object",
200
+ "A required input field is missing or has the wrong type"
201
+ ],
202
+ howToRecover: "Fix the fields listed in `details.issues` and retry. describe_schema shows each function's exact input fields; an empty body is treated as {}."
203
+ },
204
+ FUNCTION_EXECUTION_FAILED: {
205
+ code: "FUNCTION_EXECUTION_FAILED",
206
+ meaning: "The function's handler threw an unexpected (non-Graft) error \u2014 a bug in the handler or its environment, not in the caller's input.",
207
+ typicalCauses: [
208
+ "A runtime error inside the handler code",
209
+ "The database is unreachable or a required env var is missing on the server"
210
+ ],
211
+ howToRecover: "Retrying with the same input will fail again. Find the invocation in the server logs via `details.correlationId`, fix the handler code or the server environment, then retry."
212
+ },
213
+ METHOD_NOT_ALLOWED: {
214
+ code: "METHOD_NOT_ALLOWED",
215
+ meaning: "The endpoint was called with an HTTP method it does not serve.",
216
+ typicalCauses: [
217
+ "GETting a function endpoint (functions are RPC over POST)",
218
+ "A client following a redirect that downgraded the method"
219
+ ],
220
+ howToRecover: "Use the method named in the `Allow` response header \u2014 for Graft functions, POST a JSON object body to the same URL."
221
+ },
222
+ ROUTE_NOT_FOUND: {
223
+ code: "ROUTE_NOT_FOUND",
224
+ meaning: "The Graft server (`graft serve`) has nothing mounted at the requested path \u2014 the request reached the right process but the wrong URL.",
225
+ typicalCauses: [
226
+ "A typo in the endpoint path (e.g. /api/fns instead of /api/fn/<name>)",
227
+ "Expecting the frontend app's routes on the headless runtime \u2014 graft serve hosts only the function, MCP, and health endpoints"
228
+ ],
229
+ howToRecover: "Use POST /api/fn/<name> for typed functions, POST /api/mcp for the MCP Streamable HTTP surface, or GET /healthz for liveness. The error's details carry the path that missed."
230
+ },
231
+ AUTHORITY_MISMATCH: {
232
+ code: "AUTHORITY_MISMATCH",
233
+ meaning: "An operation used the wrong interface for a collection's authority: files/write_content on a db-authoritative collection, or record helpers on a file-authoritative one.",
234
+ typicalCauses: [
235
+ "Authoring an MDX file for a collection whose rows live in Postgres",
236
+ "Calling insertRecord/listRecords on a collection whose documents are files"
237
+ ],
238
+ howToRecover: "Check the collection's authority with describe_schema. File-authoritative \u2192 author MDX (write_content / files + compile). Db-authoritative \u2192 go through its functions (POST /api/fn/<name>); the rows are operational data Postgres owns."
239
+ },
240
+ INDEX_OWNERSHIP: {
241
+ code: "INDEX_OWNERSHIP",
242
+ meaning: "A projection would soft-delete every document in collections this project's schema doesn't know \u2014 the signature of two projects pointing at one DATABASE_URL. Nothing was written; the transaction rolled back.",
243
+ typicalCauses: [
244
+ "DATABASE_URL points at another project's database (e.g. a shared repo-root .env)",
245
+ "A collection was renamed or deleted in graft.config.ts, so its old rows look foreign"
246
+ ],
247
+ howToRecover: "Each project needs its own database or branch: set DATABASE_URL in a .env next to graft.config.ts (it overrides parent .envs). If the schema really did drop or rename a collection, a human runs `graft compile --prune-unknown` once \u2014 the override is CLI-only by design."
248
+ },
249
+ NEEDS_DATABASE: {
250
+ code: "NEEDS_DATABASE",
251
+ meaning: 'This project runs in static index mode (index = "static" in graft.config), and the requested feature is Postgres-tier: db-authoritative collections, typed functions, or database branching.',
252
+ typicalCauses: [
253
+ "A db-authoritative collection or a defineFunction was added to a static-mode project",
254
+ "graft compile --branch <name> was run in static mode (branches are git branches there)"
255
+ ],
256
+ howToRecover: 'Either stay static (content-only: use git branches for previews) or upgrade: set DATABASE_URL in .env and switch graft.config to `export const index = "postgres"`, then re-run `graft compile`.'
257
+ },
258
+ CONTENT_TREE_READ_ONLY: {
259
+ code: "CONTENT_TREE_READ_ONLY",
260
+ meaning: "A write reached the content tree, but the filesystem refused it. Authored content is files, so writing requires a writable checkout \u2014 serverless platforms deploy a read-only filesystem.",
261
+ typicalCauses: [
262
+ "Studio or an MCP write served from a serverless deployment (Vercel, Netlify, Cloudflare)",
263
+ "A container with the project mounted read-only",
264
+ "File permissions on the content directory"
265
+ ],
266
+ howToRecover: "Run the writing surface where the checkout is writable \u2014 local `graft studio` / `graft mcp`, or a self-hosted container with the project mounted read-write \u2014 and let the deployment serve reads only. Writes then arrive as git commits, which is the model: git is authoritative for authored content."
267
+ },
268
+ GIT_UNAVAILABLE: {
269
+ code: "GIT_UNAVAILABLE",
270
+ meaning: "An operation needed git and could not reach it: either the `git` binary is not on PATH, or the content directory is not inside a git work tree.",
271
+ typicalCauses: [
272
+ "The project was scaffolded but `git init` was never run",
273
+ "Studio or the CLI running in a container image that ships no git binary",
274
+ "The content directory lives outside the repository (a mount, a symlink target)"
275
+ ],
276
+ howToRecover: "Run `git init` at the project root and make a first commit, or install git. Nothing else in Graft requires it \u2014 content still compiles and serves \u2014 but the change history, `graft compile`'s recorded SHA, and reverting all depend on it."
277
+ },
278
+ COMMIT_FAILED: {
279
+ code: "COMMIT_FAILED",
280
+ meaning: "git refused to record the commit. The working tree is untouched by the refusal.",
281
+ typicalCauses: [
282
+ "No committer identity configured (user.name / user.email)",
283
+ "A pre-commit or commit-msg hook rejected the change",
284
+ "Nothing to commit \u2014 the selected paths match the last commit already",
285
+ "The repository is mid-merge or mid-rebase"
286
+ ],
287
+ howToRecover: 'The error\'s `details.stderr` carries git\'s own words. For an unset identity, run `git config user.name "\u2026"` and `git config user.email "\u2026"`. Selected files may already be staged; `git status` shows the current state, and committing from a terminal always remains available.'
288
+ },
289
+ STATIC_INDEX_NOT_FOUND: {
290
+ code: "STATIC_INDEX_NOT_FOUND",
291
+ meaning: "A read tried to open the static index artifact (.graft/index.db by default), but the file does not exist \u2014 the project has not been compiled yet.",
292
+ typicalCauses: [
293
+ "graft compile has never run in this checkout",
294
+ "The artifact path in graft.config's `index` setting does not match where compile wrote it",
295
+ "A deploy shipped the app without running graft compile in the build step"
296
+ ],
297
+ howToRecover: "Run `graft compile` (or add it before the framework build in the deploy's build command). The artifact must be deployed with the app."
298
+ },
299
+ STATIC_INDEX_UNSUPPORTED: {
300
+ code: "STATIC_INDEX_UNSUPPORTED",
301
+ meaning: "Static index mode needs the node:sqlite built-in with FTS5, which this Node runtime does not provide (FTS5 ships in the bundled SQLite only from Node 22.16).",
302
+ typicalCauses: ["Node older than 22.16 running the CLI or the app server"],
303
+ howToRecover: 'Upgrade Node to 22.16+ (24 LTS recommended), or switch the project to the Postgres index (DATABASE_URL + `export const index = "postgres"`).'
304
+ },
305
+ SLUG_NOT_UNIQUE: {
306
+ code: "SLUG_NOT_UNIQUE",
307
+ meaning: "Two documents in the same collection resolve to the same slug.",
308
+ typicalCauses: [
309
+ "Two files share a filename-derived slug",
310
+ "A frontmatter `slug` duplicates another file's slug"
311
+ ],
312
+ howToRecover: "The error's details list both files. Give one of them a unique slug (frontmatter `slug:` wins over the filename) and re-run compile."
313
+ },
314
+ INVALID_SLUG: {
315
+ code: "INVALID_SLUG",
316
+ meaning: "A slug is not URL-safe kebab-case.",
317
+ typicalCauses: [
318
+ "Uppercase letters, spaces, or punctuation in the slug or filename",
319
+ "Leading/trailing/double hyphens"
320
+ ],
321
+ howToRecover: 'Use lowercase letters, digits, and single hyphens only (e.g. "getting-started"). Set a valid `slug` in frontmatter or rename the file.'
322
+ },
323
+ MIGRATION_REQUIRED: {
324
+ code: "MIGRATION_REQUIRED",
325
+ meaning: "The schema and the stored content/data have drifted; a migration must run first.",
326
+ typicalCauses: [
327
+ "graft.config.ts changed shape while documents still use the old shape",
328
+ "A database schema change is pending"
329
+ ],
330
+ howToRecover: "Run the pending migration (content migrations update the files; DB migrations via @usegraft/db), then retry the operation."
331
+ },
332
+ MIGRATION_FAILED: {
333
+ code: "MIGRATION_FAILED",
334
+ meaning: "A content or data migration could not be applied; nothing was written (runs are all-or-nothing).",
335
+ typicalCauses: [
336
+ "The transform's output does not satisfy the collection's current schema",
337
+ "The transform threw for some documents/rows",
338
+ "A file's frontmatter is not parseable YAML"
339
+ ],
340
+ howToRecover: "Read details.failures \u2014 each entry names the file/row and why it failed. Fix the transform (or the listed files) in migrations/<id>.ts, then re-run `graft migrate --apply`. Dry-run first with `graft migrate` to see what would change."
341
+ },
342
+ UNAUTHORIZED: {
343
+ code: "UNAUTHORIZED",
344
+ meaning: "The caller's token does not permit this operation.",
345
+ typicalCauses: [
346
+ "A missing or expired agent token",
347
+ "A token scoped to reads used for a write",
348
+ "An anonymous call to a mutation that is not marked `public: true` (the secure default)"
349
+ ],
350
+ howToRecover: "Obtain a token with the required scope from the project owner; do not retry with the same credentials. If the function is meant to accept anonymous callers, its definition needs `public: true`."
351
+ },
352
+ TOKEN_INVALID: {
353
+ code: "TOKEN_INVALID",
354
+ meaning: "A bearer token was sent but could not be verified \u2014 different from sending no token (which makes the caller anonymous).",
355
+ typicalCauses: [
356
+ "An expired or not-yet-valid JWT",
357
+ "A token issued by an issuer this deployment does not trust",
358
+ "A wrong audience claim, a bad signature, or a malformed token"
359
+ ],
360
+ howToRecover: "Mint a fresh token from a trusted issuer (details.reason states what failed). Do not strip the Authorization header to fall back to anonymous \u2014 fix the token instead."
361
+ },
362
+ RATE_LIMITED: {
363
+ code: "RATE_LIMITED",
364
+ meaning: "This caller has invoked the function more times than its per-window limit allows. Every attempt counts, including rejected ones.",
365
+ typicalCauses: [
366
+ "A retry loop hammering a function after failures",
367
+ "Many calls from one actor (or one IP, for anonymous callers) in a short window"
368
+ ],
369
+ howToRecover: "Wait out the window (the Retry-After header says how long) before retrying, and fix whatever caused the burst \u2014 the limit is per caller per function, so backing off actually works."
370
+ },
371
+ DESTRUCTIVE_OP_REQUIRES_APPROVAL: {
372
+ code: "DESTRUCTIVE_OP_REQUIRES_APPROVAL",
373
+ meaning: "The operation is human-gated: a pending approval request was filed (details.approvalId) and the call will not run until a human approves it. Destructive functions are always gated; under the 'human' approval policy every mutation is.",
374
+ typicalCauses: [
375
+ "Calling a function marked `destructive: true` (deletes or irreversibly overwrites data)",
376
+ "Calling any mutation on a deployment whose approvalPolicy is 'human'"
377
+ ],
378
+ howToRecover: "Ask a human operator to run `graft approve <approvalId>` (they can also `graft deny` it). Once approved, retry the EXACT same call carrying the approval id \u2014 over MCP pass it as the `approval` tool argument; over raw HTTP send the `x-graft-approval: <approvalId>` header. Approvals are one-shot and bound to the exact input \u2014 never work around the gate."
379
+ },
380
+ APPROVAL_INVALID: {
381
+ code: "APPROVAL_INVALID",
382
+ meaning: "An approval id was supplied (the `approval` argument over MCP, the x-graft-approval header over raw HTTP), but it cannot authorize this call \u2014 details.reason says why (pending, denied, already_consumed, mismatch, or not_found).",
383
+ typicalCauses: [
384
+ "Retrying before a human has decided (pending)",
385
+ "Reusing an approval \u2014 they are one-shot (already_consumed)",
386
+ "Changing the input or function between request and retry (mismatch)",
387
+ "A human refused the operation (denied)"
388
+ ],
389
+ howToRecover: "pending \u2192 wait for the human decision; denied \u2192 do not retry, ask the operator; already_consumed or not_found \u2192 call again without the approval id to file a fresh request; mismatch \u2192 retry with exactly the approved input."
390
+ },
391
+ APPROVAL_SELF_DECISION: {
392
+ code: "APPROVAL_SELF_DECISION",
393
+ meaning: "The identity deciding an approval is the same identity that requested it. Separation of duties: a requester can never approve (or deny) their own destructive operation.",
394
+ typicalCauses: [
395
+ "An agent (or a wrapper acting for it) running `graft approve` on an approval it filed itself",
396
+ "Passing the requester's identity as the decider (e.g. reusing the same dev-token id)"
397
+ ],
398
+ howToRecover: "A DIFFERENT operator must review it: they run `graft approve <id>` (or `graft deny <id>`) under their own identity. Do not retry as the requester and do not switch identities to impersonate a reviewer \u2014 the gate exists so a second party sees the exact function + input before it runs."
399
+ },
400
+ BRANCH_NOT_FOUND: {
401
+ code: "BRANCH_NOT_FOUND",
402
+ meaning: "An operation referenced a branch that is not registered in the topology.",
403
+ typicalCauses: [
404
+ "A typo in the branch name",
405
+ "Forking from or dropping a branch that was never created",
406
+ "Expecting a branch to exist that a teammate has not created yet"
407
+ ],
408
+ howToRecover: 'List the registered branches (graft branch) to see valid names. "main" is always seeded; create previews off it before forking from or merging them.'
409
+ },
410
+ BRANCH_EXISTS: {
411
+ code: "BRANCH_EXISTS",
412
+ meaning: "A branch with that name is already registered.",
413
+ typicalCauses: [
414
+ "Re-running a create for a branch that already exists",
415
+ "Two previews competing for the same name"
416
+ ],
417
+ howToRecover: "Use the existing branch, pick a different name, or drop the existing one first. Registering a branch is idempotent only if you check first \u2014 names are unique."
418
+ },
419
+ BRANCH_INVALID: {
420
+ code: "BRANCH_INVALID",
421
+ meaning: "A branch operation was rejected because the name or the topology change is not allowed.",
422
+ typicalCauses: [
423
+ "A branch name that is not URL-safe (uppercase, spaces, punctuation)",
424
+ "Making a branch its own parent, or dropping main",
425
+ "Dropping a branch that still has child branches"
426
+ ],
427
+ howToRecover: 'Use lowercase kebab names with optional "/" segments (e.g. "preview/checkout"). Fork previews from main; drop child branches before their parent; main is the root and cannot be dropped.'
428
+ },
429
+ BRANCH_BACKEND_FAILED: {
430
+ code: "BRANCH_BACKEND_FAILED",
431
+ meaning: "The branch backend's control plane (the Neon API for `neon` branches) rejected or failed an operation, so the branch's physical state may not match the registry.",
432
+ typicalCauses: [
433
+ "An invalid or expired NEON_API_KEY, or one scoped to a different project",
434
+ "A wrong GRAFT_NEON_PROJECT_ID",
435
+ "Neon-side limits (max branches) or a transient API outage",
436
+ "The branch's compute endpoint never became reachable after create"
437
+ ],
438
+ howToRecover: "Check NEON_API_KEY and GRAFT_NEON_PROJECT_ID in the environment, then retry. If a create failed partway, the error says whether a Neon branch was left behind \u2014 delete it in the Neon console (or retry the drop) before recreating. Overlay branches never hit this: they need no external API."
439
+ },
440
+ REGISTRY_ITEM_NOT_FOUND: {
441
+ code: "REGISTRY_ITEM_NOT_FOUND",
442
+ meaning: "`graft add` was asked for a registry item (a copy-in primitive) that does not exist.",
443
+ typicalCauses: [
444
+ "A typo in the item name",
445
+ "Expecting a community/remote item \u2014 only the bundled Tier-1 registry ships today",
446
+ "The item was renamed or removed"
447
+ ],
448
+ howToRecover: "The error's `details.available` lists every item you can add. Run `graft add <name>` with one of those, or build the primitive by hand as owned code under graft/."
449
+ },
450
+ REGISTRY_ITEM_INVALID: {
451
+ code: "REGISTRY_ITEM_INVALID",
452
+ meaning: "A registry item's manifest is malformed, or the item requires a different @usegraft/core version than the one installed.",
453
+ typicalCauses: [
454
+ "registry.item.json does not match the manifest schema",
455
+ "The item's `graftVersion` range does not include the installed core version",
456
+ "A file the manifest lists is missing from the item directory"
457
+ ],
458
+ howToRecover: "The error names what failed (a manifest field or the version mismatch). For a version mismatch, move @usegraft/core to the range the item needs; a malformed manifest is a registry bug \u2014 fix the item or report it."
459
+ },
460
+ REGISTRY_FILE_EXISTS: {
461
+ code: "REGISTRY_FILE_EXISTS",
462
+ meaning: "`graft add` would overwrite a file that already exists in the project, so it wrote nothing (adds are all-or-nothing).",
463
+ typicalCauses: [
464
+ "The item (or one of its dependencies) was already added",
465
+ "A project file happens to share a target path with the item"
466
+ ],
467
+ howToRecover: "Inspect the listed file(s). If replacing them is intended, re-run with `--overwrite`; otherwise move/rename your file first. `graft add --dry-run <name>` previews every path an item would write."
468
+ },
469
+ ASSET_EXISTS: {
470
+ code: "ASSET_EXISTS",
471
+ meaning: "An asset upload targeted a key that already holds a binary. The store has no version history, so an overwrite would irreversibly replace it \u2014 uploads refuse unless overwrite is explicit.",
472
+ typicalCauses: [
473
+ "Re-uploading with the same key instead of picking a new one",
474
+ "Two documents' assets colliding on a generic key like assets/hero.png"
475
+ ],
476
+ howToRecover: "Pick a distinct key (e.g. prefix it with the document: pages/pricing/hero.png) \u2014 that is almost always right. Only pass `overwrite: true` when replacing the existing binary is the actual intent; every document referencing that key will show the new bytes."
477
+ },
478
+ NOT_IMPLEMENTED: {
479
+ code: "NOT_IMPLEMENTED",
480
+ meaning: "The capability is planned but not built yet.",
481
+ typicalCauses: ["Calling a placeholder API from a later phase"],
482
+ howToRecover: "Use the documented alternative (the error's `fix` names it if one exists), or accomplish the task by editing files directly \u2014 git is always a valid interface."
483
+ }
484
+ };
485
+ function explainCode(code) {
486
+ return code in ErrorCodes ? ERROR_KNOWLEDGE[code] : void 0;
487
+ }
488
+
489
+ // src/server.ts
490
+ function ok(payload) {
491
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
492
+ }
493
+ function fail(error) {
494
+ const explanation = ERROR_KNOWLEDGE[error.code];
495
+ return {
496
+ isError: true,
497
+ content: [
498
+ {
499
+ type: "text",
500
+ text: JSON.stringify(
501
+ { ...error.toJSON(), howToRecover: explanation.howToRecover },
502
+ null,
503
+ 2
504
+ )
505
+ }
506
+ ]
507
+ };
508
+ }
509
+ async function guarded(body) {
510
+ try {
511
+ return ok(await body());
512
+ } catch (error) {
513
+ if (error instanceof GraftError2) return fail(error);
514
+ throw error;
515
+ }
516
+ }
517
+ function createGraftMcp(options) {
518
+ const { contentDir, collections } = options;
519
+ const branchId = options.branchId ?? "main";
520
+ const staticIndexPath = options.db === void 0 ? options.staticIndexPath : void 0;
521
+ const maybeDb = options.db;
522
+ if (maybeDb === void 0 && staticIndexPath === void 0) {
523
+ throw new GraftError2({
524
+ code: "CONFIG_INVALID",
525
+ message: "createGraftMcp needs an index: pass `db` (Postgres) or `staticIndexPath`.",
526
+ fix: "Pass `db` from createDb(DATABASE_URL), or `staticIndexPath` pointing at the compiled artifact (.graft/index.db) for a static project."
527
+ });
528
+ }
529
+ const requireDb = (feature, insteadDo) => {
530
+ if (maybeDb !== void 0) return maybeDb;
531
+ throw new GraftError2({
532
+ code: "NEEDS_DATABASE",
533
+ message: `${feature} needs the Postgres index; this project serves a static index (${staticIndexPath}).`,
534
+ fix: `${insteadDo} To move this project to the Postgres tier: set DATABASE_URL, change graft.config to \`export const index = "postgres"\`, run \`graft db migrate\`, then \`graft compile\`.`,
535
+ details: { feature, index: "static" }
536
+ });
537
+ };
538
+ const projectContent = async () => staticIndexPath === void 0 ? compile({ contentDir, collections, db: requireDb("compile", ""), branchId }) : compileStatic({ contentDir, collections, indexPath: staticIndexPath });
539
+ const searchIndex = async (query) => {
540
+ if (staticIndexPath === void 0) {
541
+ return searchContent(requireDb("search_content", ""), query);
542
+ }
543
+ const index = await openStaticIndex(staticIndexPath);
544
+ try {
545
+ return await index.searchContent({
546
+ query: query.query,
547
+ collections: query.collections,
548
+ limit: query.limit
549
+ });
550
+ } finally {
551
+ await index.close();
552
+ }
553
+ };
554
+ const functions = options.functions ?? {};
555
+ const functionsByName = /* @__PURE__ */ new Map();
556
+ for (const fn of Object.values(functions)) functionsByName.set(fn.name, fn);
557
+ let scopePromise;
558
+ const getScope = () => {
559
+ scopePromise ??= options.scope ? Promise.resolve(options.scope) : resolveBranchScope(requireDb("Branch scope resolution", ""), branchId);
560
+ return scopePromise;
561
+ };
562
+ let functionsHandler;
563
+ const getFunctionsHandler = () => {
564
+ functionsHandler ??= createFunctionsHandler({
565
+ functions,
566
+ db: requireDb(
567
+ "run_function",
568
+ "Typed functions read and write operational data in Postgres, so a static project has none."
569
+ ),
570
+ branch: branchId,
571
+ actor: options.actor,
572
+ approvalPolicy: options.approvalPolicy,
573
+ rateLimit: options.rateLimit,
574
+ gitSha: options.gitSha,
575
+ audit: options.audit,
576
+ approvals: options.approvals
577
+ });
578
+ return functionsHandler;
579
+ };
580
+ const deleteContentFn = defineFunction({
581
+ name: "delete_content",
582
+ kind: "mutation",
583
+ destructive: true,
584
+ public: true,
585
+ description: "Delete an authored MDX document and recompile (MCP delete_content tool).",
586
+ returns: "{ deleted, branch, gitSha, changes }",
587
+ input: {
588
+ collection: field.string({ description: "Collection name" }),
589
+ slug: field.string({ description: "Document slug to delete" })
590
+ },
591
+ handler: async ({ input }) => {
592
+ const collection = requireCollection(collections, input.collection);
593
+ const doc = findDoc(contentDir, input.collection, collection, input.slug);
594
+ unlinkSync(join2(contentDir, ...doc.sourcePath.split("/")));
595
+ const result = await projectContent();
596
+ return {
597
+ deleted: doc.sourcePath,
598
+ branch: branchId,
599
+ gitSha: result.gitSha,
600
+ changes: result.changes
601
+ };
602
+ }
603
+ });
604
+ let deleteHandler;
605
+ const getDeleteHandler = () => {
606
+ deleteHandler ??= createFunctionsHandler({
607
+ // The one-shot, input-bound human approval lives in Postgres. Rather than
608
+ // silently downgrading to an ungated delete, a static project is told to
609
+ // do it the way git already makes safe: delete the file and recompile.
610
+ db: requireDb(
611
+ "delete_content",
612
+ "Its human approval gate is a Postgres table, and dropping the gate would make the delete ungated. In a static project, delete the file and recompile \u2014 git is authoritative, so the file IS the document and git history is the undo."
613
+ ),
614
+ functions: { delete_content: deleteContentFn },
615
+ branch: branchId,
616
+ actor: options.actor,
617
+ rateLimit: options.rateLimit,
618
+ gitSha: options.gitSha,
619
+ audit: options.audit,
620
+ approvals: options.approvals
621
+ });
622
+ return deleteHandler;
623
+ };
624
+ let storagePromise;
625
+ const getStorage = () => {
626
+ storagePromise ??= (async () => {
627
+ if (options.storage) {
628
+ return typeof options.storage === "function" ? options.storage() : options.storage;
629
+ }
630
+ try {
631
+ return createStorage(storageConfigFromEnv());
632
+ } catch (error) {
633
+ throw new GraftError2({
634
+ code: "ENV_VAR_MISSING",
635
+ message: error instanceof Error ? error.message : String(error),
636
+ fix: "Set S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY, and S3_BUCKET in the MCP server's environment (.env), then retry.",
637
+ details: { variables: ["S3_ENDPOINT", "S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_BUCKET"] }
638
+ });
639
+ }
640
+ })();
641
+ return storagePromise;
642
+ };
643
+ const server = new McpServer({
644
+ name: options.name ?? "graft",
645
+ version: options.version ?? "0.0.0"
646
+ });
647
+ server.registerTool(
648
+ "list_collections",
649
+ {
650
+ title: "List collections",
651
+ description: "List every registered content collection (name, description, authority, field count). Start here to learn what kinds of content this project has.",
652
+ inputSchema: {}
653
+ },
654
+ () => guarded(() => ({
655
+ branch: branchId,
656
+ collections: Object.values(collections).map((collection) => {
657
+ const descriptor = collection.describe();
658
+ return {
659
+ name: descriptor.name,
660
+ description: descriptor.description,
661
+ authority: descriptor.authority,
662
+ fields: descriptor.fields.length
663
+ };
664
+ })
665
+ }))
666
+ );
667
+ server.registerTool(
668
+ "describe_schema",
669
+ {
670
+ title: "Describe the content schema",
671
+ description: "Full schema introspection: every collection with its typed fields (name, type, optional, description), plus every registered function (kind, args, public/destructive). Documents also accept an optional kebab-case `slug` (defaults to the filename). Prefer list_functions / describe_function when you only need the function surface.",
672
+ inputSchema: {}
673
+ },
674
+ () => guarded(() => {
675
+ return {
676
+ collections: Object.values(collections).map((collection) => {
677
+ const descriptor = collection.describe();
678
+ return { ...descriptor, fields: descriptor.fields.map(teachAssetFields) };
679
+ }),
680
+ functions: [...functionsByName.values()].map((fn) => fn.describe())
681
+ };
682
+ })
683
+ );
684
+ server.registerTool(
685
+ "list_functions",
686
+ {
687
+ title: "List functions",
688
+ description: "List every registered typed function (name, kind, public, destructive, short description). Use describe_function for the full input schema, then run_function to invoke. Mutations reject anonymous callers unless public: true; destructive functions always require human approval (graft approve).",
689
+ inputSchema: {}
690
+ },
691
+ () => guarded(() => ({
692
+ branch: branchId,
693
+ functions: [...functionsByName.values()].map((fn) => {
694
+ const d = fn.describe();
695
+ return {
696
+ name: d.name,
697
+ kind: d.kind,
698
+ description: d.description,
699
+ public: d.public,
700
+ destructive: d.destructive,
701
+ args: d.args.length
702
+ };
703
+ })
704
+ }))
705
+ );
706
+ server.registerTool(
707
+ "describe_function",
708
+ {
709
+ title: "Describe one function",
710
+ description: "Full introspection for one function: kind, args (name/type/optional/description), returns, public, destructive. Use this before run_function so the input object matches the schema.",
711
+ inputSchema: {
712
+ name: z.string().describe("Function name as returned by list_functions")
713
+ }
714
+ },
715
+ ({ name }) => guarded(() => {
716
+ const fn = functionsByName.get(name);
717
+ if (!fn) {
718
+ throw new GraftError2({
719
+ code: "FUNCTION_NOT_FOUND",
720
+ message: `No function named "${name}" is registered.`,
721
+ fix: `Call list_functions and use one of: ${[...functionsByName.keys()].join(", ") || "(none registered)"}.`,
722
+ details: { requested: name, available: [...functionsByName.keys()] }
723
+ });
724
+ }
725
+ return fn.describe();
726
+ })
727
+ );
728
+ server.registerTool(
729
+ "run_function",
730
+ {
731
+ title: "Run a typed function",
732
+ description: "Invoke a defineFunction by name with a JSON input object. Same pipeline as POST /api/fn/<name>: Zod validation, access rules, rate limits, audit log, and the human gate for destructive ops. The server may already act with a configured identity (graft mcp uses GRAFT_DEV_TOKEN; over HTTP your connection's bearer is forwarded) \u2014 only pass authorization to override it. Pass approval after a human runs `graft approve <id>` for gated calls. Success returns { data, correlationId }; failures are GraftError JSON with a fix.",
733
+ inputSchema: {
734
+ name: z.string().describe("Function name (defineFunction name, not the export key)"),
735
+ input: z.record(z.string(), z.unknown()).optional().describe("Input fields object; defaults to {}. See describe_function for the schema."),
736
+ authorization: z.string().optional().describe(
737
+ "Bearer token override (with or without the 'Bearer ' prefix). Usually unnecessary \u2014 the server's configured identity applies when omitted."
738
+ ),
739
+ approval: z.string().optional().describe(
740
+ "Approval id from a prior DESTRUCTIVE_OP_REQUIRES_APPROVAL response (after `graft approve <id>`)."
741
+ )
742
+ }
743
+ },
744
+ ({ name, input, authorization, approval }) => guarded(async () => {
745
+ if (functionsByName.size === 0) {
746
+ throw new GraftError2({
747
+ code: "FUNCTION_NOT_FOUND",
748
+ message: "This MCP server has no functions registered.",
749
+ fix: "Export `functions` from graft.config.ts (defineFunction results, often via mergePrimitives) and restart the MCP server / pass them to createGraftMcp({ functions }).",
750
+ details: { requested: name, available: [] }
751
+ });
752
+ }
753
+ if (!functionsByName.has(name)) {
754
+ throw new GraftError2({
755
+ code: "FUNCTION_NOT_FOUND",
756
+ message: `No function named "${name}" is registered.`,
757
+ fix: `Call list_functions and use one of: ${[...functionsByName.keys()].join(", ")}.`,
758
+ details: { requested: name, available: [...functionsByName.keys()] }
759
+ });
760
+ }
761
+ return invokeFunction(getFunctionsHandler(), name, input ?? {}, {
762
+ credential: authorization ?? options.defaultAuthorization,
763
+ approval
764
+ });
765
+ })
766
+ );
767
+ server.registerTool(
768
+ "list_registry",
769
+ {
770
+ title: "List registry items",
771
+ description: "List every owned primitive available to `graft add` \u2014 shadcn-style copy-in blocks / fields / access rules / bundles (name, type, one-line description, and any registry items it pulls in). Use describe_item for the full details, then install with `graft add <name>` from the CLI. MCP browses what exists; the CLI installs it.",
772
+ inputSchema: {}
773
+ },
774
+ () => guarded(() => ({
775
+ items: listItems(options.registryRoot).map((item) => ({
776
+ name: item.name,
777
+ type: item.type,
778
+ description: item.description,
779
+ registryDependencies: item.registryDependencies
780
+ }))
781
+ }))
782
+ );
783
+ server.registerTool(
784
+ "describe_item",
785
+ {
786
+ title: "Describe a registry item",
787
+ description: "Full details for one owned primitive: type, description, the files it writes into the project, npm dependencies to install, the registry items it pulls in first, and whether it ships an llms.txt fragment. Use list_registry for names; install with `graft add <name>` (CLI). MCP does not install.",
788
+ inputSchema: {
789
+ name: z.string().describe("Item name as returned by list_registry")
790
+ }
791
+ },
792
+ ({ name }) => guarded(() => describeItem(loadItem(name, options.registryRoot)))
793
+ );
794
+ server.registerTool(
795
+ "list_content",
796
+ {
797
+ title: "List documents in a collection",
798
+ description: "List every document in a collection, read from the authored MDX files (git is the source of truth). Returns slug, sourcePath, and frontmatter data.",
799
+ inputSchema: {
800
+ collection: z.string().describe("Collection name, as returned by list_collections")
801
+ }
802
+ },
803
+ ({ collection: name }) => guarded(() => {
804
+ const collection = requireCollection(collections, name);
805
+ const docs = readCollectionDocs(contentDir, name, collection);
806
+ return {
807
+ collection: name,
808
+ documents: docs.map((doc) => ({
809
+ slug: doc.slug,
810
+ sourcePath: doc.sourcePath,
811
+ data: doc.data
812
+ }))
813
+ };
814
+ })
815
+ );
816
+ server.registerTool(
817
+ "get_content",
818
+ {
819
+ title: "Get one document",
820
+ description: "Read a single document by collection + slug from the authored MDX files: validated frontmatter data, MDX body, and the file path to edit.",
821
+ inputSchema: {
822
+ collection: z.string().describe("Collection name"),
823
+ slug: z.string().describe("Document slug (kebab-case)")
824
+ }
825
+ },
826
+ ({ collection: name, slug }) => guarded(() => {
827
+ const collection = requireCollection(collections, name);
828
+ const doc = findDoc(contentDir, name, collection, slug);
829
+ return {
830
+ collection: name,
831
+ slug: doc.slug,
832
+ sourcePath: doc.sourcePath,
833
+ data: doc.data,
834
+ body: doc.body
835
+ };
836
+ })
837
+ );
838
+ server.registerTool(
839
+ "search_content",
840
+ {
841
+ title: "Full-text search across content",
842
+ description: 'Search authored content by words, "quoted phrases", `or`, and -exclusions (websearch syntax). Searches the branch\'s effective content in the compiled Postgres index \u2014 on a preview branch that includes documents inherited from parent branches, with branch overrides winning \u2014 so results are as fresh as the last compile (write_content compiles automatically); every hit carries the sourcePath of the file to edit. Ranking weights slug matches over frontmatter over body.',
843
+ inputSchema: {
844
+ query: z.string().describe('What to find, e.g. pricing "free tier" -enterprise'),
845
+ collection: z.string().optional().describe("Restrict to one collection (default: all registered collections)"),
846
+ limit: z.number().optional().describe("Max hits, best-ranked first (default 20)")
847
+ }
848
+ },
849
+ ({ query, collection: name, limit }) => guarded(async () => {
850
+ if (name !== void 0) requireCollection(collections, name);
851
+ assertSearchQuery(query);
852
+ const collectionNames = name === void 0 ? Object.keys(collections) : [name];
853
+ const chain = staticIndexPath === void 0 ? scopeChain(await getScope()) : [branchId];
854
+ const hits = await searchIndex({ query, chain, collections: collectionNames, limit });
855
+ return {
856
+ branch: branchId,
857
+ chain,
858
+ query,
859
+ hits: hits.map(({ row, rank, snippet }) => ({
860
+ collection: row.collection,
861
+ slug: row.slug,
862
+ sourcePath: row.sourcePath,
863
+ rank,
864
+ snippet,
865
+ data: row.data
866
+ }))
867
+ };
868
+ })
869
+ );
870
+ server.registerTool(
871
+ "write_content",
872
+ {
873
+ title: "Write a document (create or update)",
874
+ description: "Author or update a document: validates the data against the collection schema, writes <contentDir>/<collection>/<slug>.mdx, and compiles the content tree into the database. Returns exactly what changed. Git is the version history: commit the file afterwards if you have the server's checkout; remote callers can't and needn't \u2014 the checkout's operator owns the commit.",
875
+ inputSchema: {
876
+ collection: z.string().describe("Collection name"),
877
+ slug: z.string().describe("Document slug \u2014 kebab-case; becomes the filename and the URL segment"),
878
+ data: z.record(z.string(), z.unknown()).describe("Frontmatter data; must satisfy the collection schema (see describe_schema)"),
879
+ body: z.string().optional().describe("MDX body (markdown). Defaults to empty.")
880
+ }
881
+ },
882
+ ({ collection: name, slug, data, body }) => guarded(async () => {
883
+ const collection = requireCollection(collections, name);
884
+ if (collection.authority === "db-authoritative") {
885
+ throw new GraftError2({
886
+ code: "AUTHORITY_MISMATCH",
887
+ message: `Collection "${name}" is db-authoritative \u2014 its records live in Postgres, not as MDX files.`,
888
+ fix: `Write this data through the collection's function endpoint (POST /api/fn/<name>, see llms.txt) instead of write_content. write_content is only for file-authoritative collections.`,
889
+ details: { collection: name, authority: collection.authority }
890
+ });
891
+ }
892
+ const frontmatterSlug = data.slug;
893
+ if (frontmatterSlug !== void 0 && frontmatterSlug !== slug) {
894
+ throw new GraftError2({
895
+ code: "INVALID_SLUG",
896
+ message: `data.slug ("${String(frontmatterSlug)}") conflicts with the slug argument ("${slug}")`,
897
+ fix: "Omit `slug` from data \u2014 the slug argument names the file and the document.",
898
+ details: { slug, frontmatterSlug }
899
+ });
900
+ }
901
+ const sourcePath = `${name}/${slug}.mdx`;
902
+ const fullPath = join2(contentDir, ...sourcePath.split("/"));
903
+ const existingRaw = existsSync2(fullPath) ? readFileSync2(fullPath, "utf8") : void 0;
904
+ const raw = composeDocument(existingRaw, data, body ?? "");
905
+ parseDocument2(raw, collection, sourcePath);
906
+ assertSlugFree(contentDir, name, collection, slug, sourcePath);
907
+ writeDocumentFile(fullPath, raw);
908
+ const result = await projectContent();
909
+ return {
910
+ written: sourcePath,
911
+ branch: branchId,
912
+ gitSha: result.gitSha,
913
+ changes: result.changes
914
+ };
915
+ })
916
+ );
917
+ server.registerTool(
918
+ "delete_content",
919
+ {
920
+ title: "Delete a document (human-gated)",
921
+ description: "Delete an authored document: removes <contentDir>/<collection>/<slug>.mdx and compiles, so the index soft-deletes it. DESTRUCTIVE and always human-gated \u2014 the first call files an approval and fails with its id; a human decides with `graft approve <id>` (or deny); then retry the SAME collection+slug with `approval: <id>` (the MCP form of the x-graft-approval header). Approvals are one-shot and bound to that exact input. Git is the version history: commit the deletion afterwards if you have the server's checkout; remote callers can't and needn't \u2014 the checkout's operator owns the commit.",
922
+ inputSchema: {
923
+ collection: z.string().describe("Collection name"),
924
+ slug: z.string().describe("Document slug to delete"),
925
+ approval: z.string().optional().describe(
926
+ "Approval id from a prior DESTRUCTIVE_OP_REQUIRES_APPROVAL response, after a human ran `graft approve <id>`."
927
+ )
928
+ }
929
+ },
930
+ ({ collection: name, slug, approval }) => guarded(async () => {
931
+ const collection = requireCollection(collections, name);
932
+ if (collection.authority === "db-authoritative") {
933
+ throw new GraftError2({
934
+ code: "AUTHORITY_MISMATCH",
935
+ message: `Collection "${name}" is db-authoritative \u2014 its records live in Postgres, not as MDX files.`,
936
+ fix: "Delete records through the collection's typed functions (a destructive defineFunction over deleteRecord \u2014 see list_functions), not delete_content. delete_content is only for file-authoritative collections.",
937
+ details: { collection: name, authority: collection.authority }
938
+ });
939
+ }
940
+ findDoc(contentDir, name, collection, slug);
941
+ const { data, correlationId } = await invokeFunction(
942
+ getDeleteHandler(),
943
+ "delete_content",
944
+ { collection: name, slug },
945
+ { credential: options.defaultAuthorization, approval }
946
+ );
947
+ return { ...data, correlationId };
948
+ })
949
+ );
950
+ server.registerTool(
951
+ "put_asset",
952
+ {
953
+ title: "Upload an asset (image / binary)",
954
+ description: "Upload a binary to the asset store and get the frontmatter reference for an `asset` field. Pass `path` (a file on the machine running this MCP server \u2014 the stdio case) OR `base64` + `key` (remote agents send the bytes). Refuses to overwrite an existing key unless overwrite: true \u2014 the store keeps no version history. Then reference the returned key from an asset field via write_content.",
955
+ inputSchema: {
956
+ key: z.string().optional().describe(
957
+ 'Asset key \u2014 a lowercase path like "pages/pricing/hero.png". Required with base64; defaults to assets/<filename> with path.'
958
+ ),
959
+ path: z.string().optional().describe("Path to a file on the MCP server's machine (local/stdio agents)."),
960
+ base64: z.string().optional().describe("The file's bytes, base64-encoded (remote/HTTP agents)."),
961
+ contentType: z.string().optional().describe("MIME type. Defaults to an inference from the key/path extension."),
962
+ overwrite: z.boolean().optional().describe("Replace an existing binary at this key. Off by default.")
963
+ }
964
+ },
965
+ ({ key: keyArg, path, base64, contentType, overwrite }) => guarded(async () => {
966
+ if (path === void 0 === (base64 === void 0)) {
967
+ throw new GraftError2({
968
+ code: "INPUT_VALIDATION_FAILED",
969
+ message: "Pass exactly one of `path` (a file on the MCP server's machine) or `base64` (the file's bytes).",
970
+ fix: "Local/stdio agents: pass path. Remote/HTTP agents: read the file yourself and pass base64 + key."
971
+ });
972
+ }
973
+ let bytes;
974
+ if (path !== void 0) {
975
+ try {
976
+ bytes = readFileSync2(path);
977
+ } catch {
978
+ throw new GraftError2({
979
+ code: "DOCUMENT_NOT_FOUND",
980
+ message: `File not found: ${path}`,
981
+ fix: "Pass a path to a file that exists on the machine running this MCP server, or send the bytes as base64 instead.",
982
+ details: { path }
983
+ });
984
+ }
985
+ } else {
986
+ if (!/^[A-Za-z0-9+/=\s]+$/.test(base64)) {
987
+ throw new GraftError2({
988
+ code: "INPUT_VALIDATION_FAILED",
989
+ message: "`base64` contains characters outside the base64 alphabet.",
990
+ fix: "Encode the file's raw bytes as standard base64 (A-Z a-z 0-9 + / =). To upload a file by its location on the server's machine, use `path` instead."
991
+ });
992
+ }
993
+ bytes = Buffer.from(base64, "base64");
994
+ }
995
+ const key = keyArg ?? (path !== void 0 ? defaultKeyFor(path) : void 0);
996
+ if (key === void 0) {
997
+ throw new GraftError2({
998
+ code: "INPUT_VALIDATION_FAILED",
999
+ message: "`key` is required when uploading via base64.",
1000
+ fix: 'Pass a lowercase path key naming the asset, e.g. "pages/pricing/hero.png".'
1001
+ });
1002
+ }
1003
+ const keyCheck = AssetRef.shape.key.safeParse(key);
1004
+ if (!keyCheck.success) {
1005
+ throw new GraftError2({
1006
+ code: "INPUT_VALIDATION_FAILED",
1007
+ message: `"${key}" is not a valid asset key.`,
1008
+ fix: 'Use a lowercase path of letters, digits, ".", "_", "-" with "/" separators, each segment starting alphanumeric \u2014 e.g. "pages/pricing/hero.png".',
1009
+ details: { key }
1010
+ });
1011
+ }
1012
+ const storage = await getStorage();
1013
+ if (overwrite !== true && await storage.exists(key)) {
1014
+ throw new GraftError2({
1015
+ code: "ASSET_EXISTS",
1016
+ message: `Asset key "${key}" already holds a binary.`,
1017
+ fix: "Pick a distinct key (the store keeps no version history), or pass overwrite: true if replacing the existing binary is the actual intent.",
1018
+ details: { key }
1019
+ });
1020
+ }
1021
+ const type = contentType ?? contentTypeFor(key);
1022
+ await storage.put(key, bytes, type);
1023
+ return {
1024
+ key,
1025
+ contentType: type,
1026
+ bytes: bytes.byteLength,
1027
+ url: await storage.url(key),
1028
+ frontmatter: `image:
1029
+ key: ${key}
1030
+ alt: describe the image for screen readers`
1031
+ };
1032
+ })
1033
+ );
1034
+ server.registerTool(
1035
+ "list_branches",
1036
+ {
1037
+ title: "List branches",
1038
+ description: "List registered content branches (name, parent, backend, status). Same data as GET /api/studio/v1/branches and `graft branch`.",
1039
+ inputSchema: {}
1040
+ },
1041
+ () => guarded(async () => ({
1042
+ branches: (await listBranches(
1043
+ requireDb(
1044
+ "list_branches",
1045
+ "Copy-on-write preview branches are a database feature; in a static project a branch is simply a git branch, and each checkout compiles its own artifact."
1046
+ )
1047
+ )).map((row) => ({
1048
+ name: row.name,
1049
+ parent: row.parent,
1050
+ backend: row.backend,
1051
+ status: row.status,
1052
+ createdAt: row.createdAt.toISOString(),
1053
+ endpointHost: row.endpointHost
1054
+ }))
1055
+ }))
1056
+ );
1057
+ server.registerTool(
1058
+ "list_compilations",
1059
+ {
1060
+ title: "List compilations",
1061
+ description: "Recent content projection trail rows (git SHA, added/changed/removed counts), newest first. Same data as GET /api/studio/v1/compilations and `graft compilations`.",
1062
+ inputSchema: {
1063
+ branch: z.string().optional().describe("Restrict to one branch id (default: all branches)"),
1064
+ limit: z.number().optional().describe("Max rows, newest first (default 20, max 100)")
1065
+ }
1066
+ },
1067
+ ({ branch, limit }) => guarded(async () => ({
1068
+ compilations: (await listCompilations(
1069
+ requireDb(
1070
+ "list_compilations",
1071
+ "The Postgres index keeps the full projection trail; a static artifact carries only the runs that built it."
1072
+ ),
1073
+ {
1074
+ branchId: branch,
1075
+ limit
1076
+ }
1077
+ )).map((row) => ({
1078
+ id: row.id,
1079
+ branchId: row.branchId,
1080
+ gitSha: row.gitSha,
1081
+ docCount: row.docCount,
1082
+ added: row.added,
1083
+ changed: row.changed,
1084
+ removed: row.removed,
1085
+ createdAt: row.createdAt.toISOString()
1086
+ }))
1087
+ }))
1088
+ );
1089
+ server.registerTool(
1090
+ "list_approvals",
1091
+ {
1092
+ title: "List pending approvals",
1093
+ description: "Pending human-gated approvals. Decide with decide_approval, Studio Approve/Deny, or `graft approve` / `graft deny`. Same data as GET /api/studio/v1/approvals.",
1094
+ inputSchema: {}
1095
+ },
1096
+ () => guarded(async () => ({
1097
+ approvals: (await listPendingApprovals(
1098
+ requireDb(
1099
+ "list_approvals",
1100
+ "Approvals gate destructive operations on operational data, which a static project does not have."
1101
+ )
1102
+ )).map((row) => ({
1103
+ id: row.id,
1104
+ branchId: row.branchId,
1105
+ functionName: row.functionName,
1106
+ input: row.input,
1107
+ requestedByKind: row.requestedByKind,
1108
+ requestedById: row.requestedById,
1109
+ correlationId: row.correlationId,
1110
+ createdAt: row.createdAt.toISOString()
1111
+ }))
1112
+ }))
1113
+ );
1114
+ server.registerTool(
1115
+ "decide_approval",
1116
+ {
1117
+ title: "Approve or deny a pending approval",
1118
+ description: "Record a human decision on a pending approval (same as Studio Approve/Deny and `graft approve` / `graft deny`). Requires an owner DB role that can UPDATE approvals. The requester cannot decide their own approval.",
1119
+ inputSchema: {
1120
+ id: z.string().describe("Pending approval id from list_approvals"),
1121
+ decision: z.enum(["approved", "denied"]).describe("approved or denied"),
1122
+ decidedBy: z.string().optional().describe("Operator identity stamp (defaults to mcp-operator)")
1123
+ }
1124
+ },
1125
+ ({ id, decision, decidedBy }) => guarded(async () => {
1126
+ const row = await decideApproval(
1127
+ requireDb(
1128
+ "decide_approval",
1129
+ "Approvals gate destructive operations on operational data, which a static project does not have."
1130
+ ),
1131
+ id,
1132
+ decision,
1133
+ decidedBy?.trim() || "mcp-operator"
1134
+ );
1135
+ if (!row) {
1136
+ throw new GraftError2({
1137
+ code: "APPROVAL_INVALID",
1138
+ message: `No PENDING approval "${id}" exists \u2014 it may already be decided, consumed, or mistyped.`,
1139
+ fix: "Call list_approvals and use a pending id.",
1140
+ details: { id }
1141
+ });
1142
+ }
1143
+ return {
1144
+ id: row.id,
1145
+ status: row.status,
1146
+ decidedBy: row.decidedBy,
1147
+ functionName: row.functionName
1148
+ };
1149
+ })
1150
+ );
1151
+ server.registerTool(
1152
+ "explain_error",
1153
+ {
1154
+ title: "Explain a Graft error",
1155
+ description: "Given a GraftError code or its JSON, explain what it means, its typical causes, and how to recover. Use whenever a tool call or compile fails.",
1156
+ inputSchema: {
1157
+ code: z.string().optional().describe("An error code, e.g. SCHEMA_VALIDATION_FAILED"),
1158
+ error: z.string().optional().describe("A full GraftError JSON string, if you have one")
1159
+ }
1160
+ },
1161
+ ({ code, error }) => guarded(() => {
1162
+ let parsed;
1163
+ if (error) {
1164
+ try {
1165
+ parsed = JSON.parse(error);
1166
+ } catch {
1167
+ }
1168
+ }
1169
+ const effective = code ?? parsed?.error;
1170
+ if (!effective) {
1171
+ return {
1172
+ knownCodes: Object.keys(ERROR_KNOWLEDGE),
1173
+ hint: "Pass `code` or the GraftError JSON as `error`."
1174
+ };
1175
+ }
1176
+ const explanation = explainCode(effective);
1177
+ if (!explanation) {
1178
+ return {
1179
+ code: effective,
1180
+ known: false,
1181
+ knownCodes: Object.keys(ERROR_KNOWLEDGE),
1182
+ hint: "Not a Graft error code. If this came from another system, resolve it there."
1183
+ };
1184
+ }
1185
+ return {
1186
+ ...explanation,
1187
+ // The specific fix from the actual error beats the general recovery advice.
1188
+ specificFix: parsed?.fix,
1189
+ message: parsed?.message
1190
+ };
1191
+ })
1192
+ );
1193
+ return server;
1194
+ }
1195
+ async function invokeFunction(handler, name, input, identity) {
1196
+ const headers = new Headers({ "content-type": "application/json" });
1197
+ if (identity.credential) {
1198
+ const token = identity.credential.trim();
1199
+ headers.set(
1200
+ "authorization",
1201
+ token.toLowerCase().startsWith("bearer ") ? token : `Bearer ${token}`
1202
+ );
1203
+ }
1204
+ if (identity.approval) headers.set(APPROVAL_HEADER, identity.approval);
1205
+ const response = await handler(
1206
+ new Request(`http://graft.local/fn/${encodeURIComponent(name)}`, {
1207
+ method: "POST",
1208
+ headers,
1209
+ body: JSON.stringify(input)
1210
+ })
1211
+ );
1212
+ const body = await response.json();
1213
+ const correlationId = response.headers.get("x-graft-correlation-id") ?? void 0;
1214
+ if (!response.ok) {
1215
+ throw graftErrorFromBody(body, correlationId);
1216
+ }
1217
+ const data = body !== null && typeof body === "object" && "data" in body ? body.data : body;
1218
+ return { data, correlationId, status: response.status };
1219
+ }
1220
+ var ASSET_FIELD_HINT = "Asset reference: the value is an object { key, alt? }. Upload the file with the put_asset tool first \u2014 its response includes the exact snippet to use here.";
1221
+ function teachAssetFields(fieldDescriptor) {
1222
+ const taught = {
1223
+ ...fieldDescriptor,
1224
+ ...fieldDescriptor.type === "asset" ? {
1225
+ description: fieldDescriptor.description ? `${fieldDescriptor.description} ${ASSET_FIELD_HINT}` : ASSET_FIELD_HINT
1226
+ } : {}
1227
+ };
1228
+ if (fieldDescriptor.fields) taught.fields = fieldDescriptor.fields.map(teachAssetFields);
1229
+ if (fieldDescriptor.items) taught.items = teachAssetFields(fieldDescriptor.items);
1230
+ return taught;
1231
+ }
1232
+ function toMcpFix(fix) {
1233
+ if (!fix) return fix;
1234
+ return fix.replace(/the header `x-graft-approval: ([^`]+)`/g, 'the `approval` argument set to "$1"').replace(/WITHOUT the x-graft-approval header/g, "WITHOUT the `approval` argument");
1235
+ }
1236
+ function graftErrorFromBody(body, correlationId) {
1237
+ if (body !== null && typeof body === "object") {
1238
+ const json = body;
1239
+ if (typeof json.error === "string" && typeof json.message === "string") {
1240
+ return new GraftError2({
1241
+ code: json.error,
1242
+ message: json.message,
1243
+ fix: toMcpFix(json.fix),
1244
+ details: {
1245
+ ...json.details,
1246
+ ...correlationId ? { correlationId } : {}
1247
+ }
1248
+ });
1249
+ }
1250
+ }
1251
+ return new GraftError2({
1252
+ code: "FUNCTION_EXECUTION_FAILED",
1253
+ message: "Function invocation failed with a non-GraftError response.",
1254
+ fix: "Inspect the server logs; retry with list_functions / describe_function to confirm the name and input shape.",
1255
+ details: { body, correlationId }
1256
+ });
1257
+ }
1258
+ function assertSlugFree(contentDir, collectionName, collection, slug, targetSourcePath) {
1259
+ const dir = join2(contentDir, collectionName);
1260
+ if (!existsSync2(dir) || !statSync2(dir).isDirectory()) return;
1261
+ for (const name of readdirSync2(dir, { recursive: true, encoding: "utf8" })) {
1262
+ const normalized = name.split("\\").join("/");
1263
+ const sourcePath = `${collectionName}/${normalized}`;
1264
+ const full = join2(dir, name);
1265
+ if (sourcePath === targetSourcePath || !/\.mdx?$/.test(name) || statSync2(full).isDirectory()) {
1266
+ continue;
1267
+ }
1268
+ let existingSlug;
1269
+ try {
1270
+ existingSlug = parseDocument2(readFileSync2(full, "utf8"), collection, sourcePath).slug;
1271
+ } catch {
1272
+ continue;
1273
+ }
1274
+ if (existingSlug === slug) {
1275
+ throw new GraftError2({
1276
+ code: "SLUG_NOT_UNIQUE",
1277
+ message: `Slug "${slug}" in collection "${collectionName}" is already used by ${sourcePath}`,
1278
+ fix: `Update that document instead (write_content with slug "${slug}" targets ${targetSourcePath}, but ${sourcePath} owns the slug via frontmatter), or pick a different slug.`,
1279
+ details: { slug, collection: collectionName, existing: sourcePath }
1280
+ });
1281
+ }
1282
+ }
1283
+ }
1284
+
1285
+ // src/http.ts
1286
+ import { GraftError as GraftError3 } from "@usegraft/contracts";
1287
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
1288
+ function jsonRpcError(status, code, message, headers) {
1289
+ return Response.json({ jsonrpc: "2.0", error: { code, message }, id: null }, { status, headers });
1290
+ }
1291
+ function createGraftMcpHandler(options) {
1292
+ const { actor: resolveActor, requireActor, ...serverOptions } = options;
1293
+ return async (request) => {
1294
+ if (request.method !== "POST") {
1295
+ return jsonRpcError(405, -32e3, "Method not allowed: this server is stateless (POST only)", {
1296
+ allow: "POST"
1297
+ });
1298
+ }
1299
+ if (resolveActor) {
1300
+ let actor;
1301
+ try {
1302
+ actor = await resolveActor(request);
1303
+ } catch (err) {
1304
+ const message = err instanceof GraftError3 ? `${err.message} ${err.fix ?? ""}`.trim() : "Unauthorized";
1305
+ return jsonRpcError(401, -32001, message);
1306
+ }
1307
+ if (requireActor && actor.kind === "anonymous") {
1308
+ return jsonRpcError(
1309
+ 401,
1310
+ -32001,
1311
+ "Unauthorized: this MCP endpoint requires authentication. Send `Authorization: Bearer <token>` from a trusted issuer."
1312
+ );
1313
+ }
1314
+ } else if (requireActor) {
1315
+ return jsonRpcError(
1316
+ 401,
1317
+ -32001,
1318
+ "Unauthorized: requireActor is set but no actor resolver is configured \u2014 the server cannot authenticate anyone."
1319
+ );
1320
+ }
1321
+ const server = createGraftMcp({
1322
+ ...serverOptions,
1323
+ actor: resolveActor,
1324
+ defaultAuthorization: request.headers.get("authorization") ?? serverOptions.defaultAuthorization
1325
+ });
1326
+ const transport = new WebStandardStreamableHTTPServerTransport({
1327
+ sessionIdGenerator: void 0,
1328
+ enableJsonResponse: true
1329
+ });
1330
+ try {
1331
+ await server.connect(transport);
1332
+ return await transport.handleRequest(request);
1333
+ } finally {
1334
+ void server.close().catch(() => void 0);
1335
+ }
1336
+ };
1337
+ }
1338
+
1339
+ // src/index.ts
1340
+ async function serveStdio(server) {
1341
+ const transport = new StdioServerTransport();
1342
+ await server.connect(transport);
1343
+ await new Promise((resolve) => {
1344
+ const prior = transport.onclose;
1345
+ transport.onclose = () => {
1346
+ prior?.();
1347
+ resolve();
1348
+ };
1349
+ });
1350
+ }
1351
+ export {
1352
+ ERROR_KNOWLEDGE,
1353
+ createGraftMcp,
1354
+ createGraftMcpHandler,
1355
+ explainCode,
1356
+ serveStdio
1357
+ };