@usegraft/mcp 0.1.0 → 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/index.js CHANGED
@@ -2,98 +2,35 @@
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
3
 
4
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";
5
+ import { unlinkSync } from "fs";
6
+ import { join as join3 } from "path";
7
+ import { createStorage, storageConfigFromEnv } from "@usegraft/assets";
8
+ import { compile, compileStatic } from "@usegraft/compiler";
9
+ import { GraftError as GraftError8 } from "@usegraft/contracts";
24
10
  import {
25
- APPROVAL_HEADER,
26
- AssetRef,
27
11
  createFunctionsHandler,
28
12
  defineFunction,
29
13
  field
30
14
  } 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";
15
+ import { openStaticIndex, resolveBranchScope, searchContent } from "@usegraft/db";
43
16
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
+
18
+ // src/content-hints.ts
19
+ import { findDoc as findDocIn, requireCollection as requireCollectionIn } from "@usegraft/compiler";
20
+ var MCP_HINTS = {
21
+ listCollections: "see list_collections",
22
+ authorDocument: "or author it with write_content."
23
+ };
24
+ var requireCollection = (collections, name) => requireCollectionIn(collections, name, MCP_HINTS);
25
+ var findDoc = (contentDir, collectionName, collection, slug) => findDocIn(contentDir, collectionName, collection, slug, MCP_HINTS);
26
+
27
+ // src/tools/approvals.ts
28
+ import { GraftError as GraftError2 } from "@usegraft/contracts";
29
+ import { decideApproval, listPendingApprovals } from "@usegraft/db";
44
30
  import { z } from "zod";
45
31
 
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";
32
+ // src/tool-result.ts
50
33
  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
34
 
98
35
  // src/explain.ts
99
36
  import { ErrorCodes } from "@usegraft/contracts";
@@ -388,6 +325,16 @@ var ERROR_KNOWLEDGE = {
388
325
  ],
389
326
  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
327
  },
328
+ APPROVAL_UNATTRIBUTED: {
329
+ code: "APPROVAL_UNATTRIBUTED",
330
+ meaning: "The approval was filed by a caller with no stable identity, so there is nobody the request can be held to and no meaningful separation of duties to enforce. It cannot be decided by anyone.",
331
+ typicalCauses: [
332
+ "An unauthenticated caller reached a destructive function (an MCP mount or functions route serving anonymous actors)",
333
+ "A trusted issuer minted a token with no `sub` claim, so the actor authenticated but carried no id",
334
+ "A row left over from before approvals required an identified requester"
335
+ ],
336
+ howToRecover: "Fix the filing surface, not the row: require authentication where the approval was requested (an actor resolver, or a token that carries `sub`), then have the caller request the operation again so the approval records who wants it. A stale row can be resolved directly in the database."
337
+ },
391
338
  APPROVAL_SELF_DECISION: {
392
339
  code: "APPROVAL_SELF_DECISION",
393
340
  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.",
@@ -486,7 +433,7 @@ function explainCode(code) {
486
433
  return code in ErrorCodes ? ERROR_KNOWLEDGE[code] : void 0;
487
434
  }
488
435
 
489
- // src/server.ts
436
+ // src/tool-result.ts
490
437
  function ok(payload) {
491
438
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
492
439
  }
@@ -510,294 +457,385 @@ async function guarded(body) {
510
457
  try {
511
458
  return ok(await body());
512
459
  } catch (error) {
513
- if (error instanceof GraftError2) return fail(error);
460
+ if (error instanceof GraftError) return fail(error);
514
461
  throw error;
515
462
  }
516
463
  }
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
- });
464
+
465
+ // src/tools/approvals.ts
466
+ var registerApprovalTools = (server, deps) => {
467
+ const { branchId, requireDb, requireDecider, requireScope } = deps;
647
468
  server.registerTool(
648
- "list_collections",
469
+ "list_approvals",
649
470
  {
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.",
471
+ title: "List pending approvals",
472
+ 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.",
652
473
  inputSchema: {}
653
474
  },
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
- })
475
+ () => guarded(async () => ({
476
+ approvals: (await listPendingApprovals(
477
+ requireDb(
478
+ "list_approvals",
479
+ "Approvals gate destructive operations on operational data, which a static project does not have."
480
+ )
481
+ )).map((row) => ({
482
+ id: row.id,
483
+ branchId: row.branchId,
484
+ functionName: row.functionName,
485
+ input: row.input,
486
+ requestedByKind: row.requestedByKind,
487
+ requestedById: row.requestedById,
488
+ correlationId: row.correlationId,
489
+ createdAt: row.createdAt.toISOString()
490
+ }))
665
491
  }))
666
492
  );
667
493
  server.registerTool(
668
- "describe_schema",
494
+ "decide_approval",
669
495
  {
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: {}
496
+ title: "Approve or deny a pending approval",
497
+ description: "Record a decision on a pending approval (same as Studio Approve/Deny and `graft approve` / `graft deny`). The decision is attributed to the identity THIS connection authenticated as \u2014 there is no way to name a different decider \u2014 and a requester can never decide their own approval. Requires an authenticated caller and an owner DB role that can UPDATE approvals.",
498
+ inputSchema: {
499
+ id: z.string().describe("Pending approval id from list_approvals"),
500
+ decision: z.enum(["approved", "denied"]).describe("approved or denied")
501
+ }
673
502
  },
674
- () => guarded(() => {
503
+ ({ id, decision }) => guarded(async () => {
504
+ requireScope("decide_approval", "approvals:decide");
505
+ const row = await decideApproval(
506
+ requireDb(
507
+ "decide_approval",
508
+ "Approvals gate destructive operations on operational data, which a static project does not have."
509
+ ),
510
+ id,
511
+ decision,
512
+ requireDecider()
513
+ );
514
+ if (!row) {
515
+ throw new GraftError2({
516
+ code: "APPROVAL_INVALID",
517
+ message: `No PENDING approval "${id}" exists \u2014 it may already be decided, consumed, or mistyped.`,
518
+ fix: "Call list_approvals and use a pending id.",
519
+ details: { id }
520
+ });
521
+ }
675
522
  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())
523
+ id: row.id,
524
+ status: row.status,
525
+ decidedBy: row.decidedBy,
526
+ functionName: row.functionName
681
527
  };
682
528
  })
683
529
  );
530
+ };
531
+
532
+ // src/tools/assets.ts
533
+ import { readFileSync } from "fs";
534
+ import { contentTypeFor, defaultKeyFor } from "@usegraft/assets";
535
+ import { AssetRef } from "@usegraft/core";
536
+ import { resolveContained } from "@usegraft/compiler";
537
+ import { GraftError as GraftError3 } from "@usegraft/contracts";
538
+ import { z as z2 } from "zod";
539
+ var registerAssetTools = (server, deps) => {
540
+ const { getStorage, options, requireScope } = deps;
541
+ const uploadRoot = options.localUploadRoot;
684
542
  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",
543
+ "put_asset",
708
544
  {
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.",
545
+ title: "Upload an asset (image / binary)",
546
+ description: (uploadRoot ? "Upload a binary to the asset store and get the frontmatter reference for an `asset` field. Pass `path` (a file inside this project) OR `base64` + `key`. " : "Upload a binary to the asset store and get the frontmatter reference for an `asset` field. Pass `base64` + `key` \u2014 this server reads no files from its own disk. ") + "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.",
711
547
  inputSchema: {
712
- name: z.string().describe("Function name as returned by list_functions")
548
+ key: z2.string().optional().describe(
549
+ 'Asset key \u2014 a lowercase path like "pages/pricing/hero.png". Required with base64; defaults to assets/<filename> with path.'
550
+ ),
551
+ path: z2.string().optional().describe(
552
+ uploadRoot ? `Path to a file inside ${uploadRoot} (local/stdio agents).` : "Not available on this server \u2014 send the bytes as `base64` instead."
553
+ ),
554
+ base64: z2.string().optional().describe("The file's bytes, base64-encoded (remote/HTTP agents)."),
555
+ contentType: z2.string().optional().describe("MIME type. Defaults to an inference from the key/path extension."),
556
+ overwrite: z2.boolean().optional().describe("Replace an existing binary at this key. Off by default.")
713
557
  }
714
558
  },
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()] }
559
+ ({ key: keyArg, path, base64, contentType, overwrite }) => guarded(async () => {
560
+ requireScope("put_asset", "content:write");
561
+ if (path === void 0 === (base64 === void 0)) {
562
+ throw new GraftError3({
563
+ code: "INPUT_VALIDATION_FAILED",
564
+ message: "Pass exactly one of `path` (a file on the MCP server's machine) or `base64` (the file's bytes).",
565
+ fix: "Local/stdio agents: pass path. Remote/HTTP agents: read the file yourself and pass base64 + key."
723
566
  });
724
567
  }
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
- )
568
+ let bytes;
569
+ if (path !== void 0) {
570
+ if (uploadRoot === void 0) {
571
+ throw new GraftError3({
572
+ code: "UNAUTHORIZED",
573
+ message: "This server does not read files from its own disk.",
574
+ fix: "Read the file yourself and send its bytes as `base64` with a `key`. Reading server-local paths is only available to a local stdio server, which grants it explicitly.",
575
+ details: { tool: "put_asset" }
576
+ });
577
+ }
578
+ const full = resolveContained(uploadRoot, path, {
579
+ label: "asset source",
580
+ allowAbsolute: true
581
+ });
582
+ try {
583
+ bytes = readFileSync(full);
584
+ } catch {
585
+ throw new GraftError3({
586
+ code: "DOCUMENT_NOT_FOUND",
587
+ message: `File not found: ${path}`,
588
+ fix: "Pass a path to a file that exists on the machine running this MCP server, or send the bytes as base64 instead.",
589
+ details: { path }
590
+ });
591
+ }
592
+ } else {
593
+ if (!/^[A-Za-z0-9+/=\s]+$/.test(base64)) {
594
+ throw new GraftError3({
595
+ code: "INPUT_VALIDATION_FAILED",
596
+ message: "`base64` contains characters outside the base64 alphabet.",
597
+ 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."
598
+ });
599
+ }
600
+ bytes = Buffer.from(base64, "base64");
742
601
  }
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: [] }
602
+ const key = keyArg ?? (path !== void 0 ? defaultKeyFor(path) : void 0);
603
+ if (key === void 0) {
604
+ throw new GraftError3({
605
+ code: "INPUT_VALIDATION_FAILED",
606
+ message: "`key` is required when uploading via base64.",
607
+ fix: 'Pass a lowercase path key naming the asset, e.g. "pages/pricing/hero.png".'
751
608
  });
752
609
  }
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()] }
610
+ const keyCheck = AssetRef.shape.key.safeParse(key);
611
+ if (!keyCheck.success) {
612
+ throw new GraftError3({
613
+ code: "INPUT_VALIDATION_FAILED",
614
+ message: `"${key}" is not a valid asset key.`,
615
+ fix: 'Use a lowercase path of letters, digits, ".", "_", "-" with "/" separators, each segment starting alphanumeric \u2014 e.g. "pages/pricing/hero.png".',
616
+ details: { key }
759
617
  });
760
618
  }
761
- return invokeFunction(getFunctionsHandler(), name, input ?? {}, {
762
- credential: authorization ?? options.defaultAuthorization,
763
- approval
764
- });
619
+ const storage = await getStorage();
620
+ if (overwrite !== true && await storage.exists(key)) {
621
+ throw new GraftError3({
622
+ code: "ASSET_EXISTS",
623
+ message: `Asset key "${key}" already holds a binary.`,
624
+ fix: "Pick a distinct key (the store keeps no version history), or pass overwrite: true if replacing the existing binary is the actual intent.",
625
+ details: { key }
626
+ });
627
+ }
628
+ const type = contentType ?? contentTypeFor(key);
629
+ await storage.put(key, bytes, type);
630
+ return {
631
+ key,
632
+ contentType: type,
633
+ bytes: bytes.byteLength,
634
+ url: await storage.url(key),
635
+ frontmatter: `image:
636
+ key: ${key}
637
+ alt: describe the image for screen readers`
638
+ };
765
639
  })
766
640
  );
641
+ };
642
+
643
+ // src/tools/branches.ts
644
+ import { listBranches, listCompilations } from "@usegraft/db";
645
+ import { z as z3 } from "zod";
646
+ var registerBranchTools = (server, deps) => {
647
+ const { branchId, requireDb } = deps;
767
648
  server.registerTool(
768
- "list_registry",
649
+ "list_branches",
769
650
  {
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.",
651
+ title: "List branches",
652
+ description: "List registered content branches (name, parent, backend, status). Same data as GET /api/studio/v1/branches and `graft branch`.",
772
653
  inputSchema: {}
773
654
  },
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
655
+ () => guarded(async () => ({
656
+ branches: (await listBranches(
657
+ requireDb(
658
+ "list_branches",
659
+ "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."
660
+ )
661
+ )).map((row) => ({
662
+ name: row.name,
663
+ parent: row.parent,
664
+ backend: row.backend,
665
+ status: row.status,
666
+ createdAt: row.createdAt.toISOString(),
667
+ endpointHost: row.endpointHost
780
668
  }))
781
669
  }))
782
670
  );
783
671
  server.registerTool(
784
- "describe_item",
672
+ "list_compilations",
785
673
  {
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.",
674
+ title: "List compilations",
675
+ 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`.",
788
676
  inputSchema: {
789
- name: z.string().describe("Item name as returned by list_registry")
677
+ branch: z3.string().optional().describe("Restrict to one branch id (default: all branches)"),
678
+ limit: z3.number().optional().describe("Max rows, newest first (default 20, max 100)")
790
679
  }
791
680
  },
792
- ({ name }) => guarded(() => describeItem(loadItem(name, options.registryRoot)))
681
+ ({ branch, limit }) => guarded(async () => ({
682
+ compilations: (await listCompilations(
683
+ requireDb(
684
+ "list_compilations",
685
+ "The Postgres index keeps the full projection trail; a static artifact carries only the runs that built it."
686
+ ),
687
+ {
688
+ branchId: branch,
689
+ limit
690
+ }
691
+ )).map((row) => ({
692
+ id: row.id,
693
+ branchId: row.branchId,
694
+ gitSha: row.gitSha,
695
+ docCount: row.docCount,
696
+ added: row.added,
697
+ changed: row.changed,
698
+ removed: row.removed,
699
+ createdAt: row.createdAt.toISOString()
700
+ }))
701
+ }))
702
+ );
703
+ };
704
+
705
+ // src/tools/content.ts
706
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
707
+ import { join as join2 } from "path";
708
+ import {
709
+ composeDocument,
710
+ parseDocument as parseDocument2,
711
+ readCollectionDocs,
712
+ writeDocumentFile
713
+ } from "@usegraft/compiler";
714
+ import { GraftError as GraftError5 } from "@usegraft/contracts";
715
+ import { assertSafeMdx } from "@usegraft/mdx-safety";
716
+ import { assertSearchQuery, scopeChain } from "@usegraft/db";
717
+ import { z as z4 } from "zod";
718
+
719
+ // src/tool-helpers.ts
720
+ import { existsSync, readdirSync, readFileSync as readFileSync2, statSync } from "fs";
721
+ import { join } from "path";
722
+ import { parseDocument } from "@usegraft/compiler";
723
+ import {
724
+ GraftError as GraftError4
725
+ } from "@usegraft/contracts";
726
+ import { APPROVAL_HEADER } from "@usegraft/core";
727
+ async function invokeFunction(handler, name, input, identity) {
728
+ const headers = new Headers({ "content-type": "application/json" });
729
+ if (identity.credential) {
730
+ const token = identity.credential.trim();
731
+ headers.set(
732
+ "authorization",
733
+ token.toLowerCase().startsWith("bearer ") ? token : `Bearer ${token}`
734
+ );
735
+ }
736
+ if (identity.approval) headers.set(APPROVAL_HEADER, identity.approval);
737
+ const response = await handler(
738
+ new Request(`http://graft.local/fn/${encodeURIComponent(name)}`, {
739
+ method: "POST",
740
+ headers,
741
+ body: JSON.stringify(input)
742
+ })
793
743
  );
744
+ const body = await response.json();
745
+ const correlationId = response.headers.get("x-graft-correlation-id") ?? void 0;
746
+ if (!response.ok) {
747
+ throw graftErrorFromBody(body, correlationId);
748
+ }
749
+ const data = body !== null && typeof body === "object" && "data" in body ? body.data : body;
750
+ return { data, correlationId, status: response.status };
751
+ }
752
+ 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.";
753
+ function teachAssetFields(fieldDescriptor) {
754
+ const taught = {
755
+ ...fieldDescriptor,
756
+ ...fieldDescriptor.type === "asset" ? {
757
+ description: fieldDescriptor.description ? `${fieldDescriptor.description} ${ASSET_FIELD_HINT}` : ASSET_FIELD_HINT
758
+ } : {}
759
+ };
760
+ if (fieldDescriptor.fields) taught.fields = fieldDescriptor.fields.map(teachAssetFields);
761
+ if (fieldDescriptor.items) taught.items = teachAssetFields(fieldDescriptor.items);
762
+ return taught;
763
+ }
764
+ function toMcpFix(fix) {
765
+ if (!fix) return fix;
766
+ 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");
767
+ }
768
+ function graftErrorFromBody(body, correlationId) {
769
+ if (body !== null && typeof body === "object") {
770
+ const json = body;
771
+ if (typeof json.error === "string" && typeof json.message === "string") {
772
+ return new GraftError4({
773
+ code: json.error,
774
+ message: json.message,
775
+ fix: toMcpFix(json.fix),
776
+ details: {
777
+ ...json.details,
778
+ ...correlationId ? { correlationId } : {}
779
+ }
780
+ });
781
+ }
782
+ }
783
+ return new GraftError4({
784
+ code: "FUNCTION_EXECUTION_FAILED",
785
+ message: "Function invocation failed with a non-GraftError response.",
786
+ fix: "Inspect the server logs; retry with list_functions / describe_function to confirm the name and input shape.",
787
+ details: { body, correlationId }
788
+ });
789
+ }
790
+ function assertSlugFree(contentDir, collectionName, collection, slug, targetSourcePath) {
791
+ const dir = join(contentDir, collectionName);
792
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) return;
793
+ for (const name of readdirSync(dir, { recursive: true, encoding: "utf8" })) {
794
+ const normalized = name.split("\\").join("/");
795
+ const sourcePath = `${collectionName}/${normalized}`;
796
+ const full = join(dir, name);
797
+ if (sourcePath === targetSourcePath || !/\.mdx?$/.test(name) || statSync(full).isDirectory()) {
798
+ continue;
799
+ }
800
+ let existingSlug;
801
+ try {
802
+ existingSlug = parseDocument(readFileSync2(full, "utf8"), collection, sourcePath).slug;
803
+ } catch {
804
+ continue;
805
+ }
806
+ if (existingSlug === slug) {
807
+ throw new GraftError4({
808
+ code: "SLUG_NOT_UNIQUE",
809
+ message: `Slug "${slug}" in collection "${collectionName}" is already used by ${sourcePath}`,
810
+ fix: `Update that document instead (write_content with slug "${slug}" targets ${targetSourcePath}, but ${sourcePath} owns the slug via frontmatter), or pick a different slug.`,
811
+ details: { slug, collection: collectionName, existing: sourcePath }
812
+ });
813
+ }
814
+ }
815
+ }
816
+
817
+ // src/tools/content.ts
818
+ var registerContentTools = (server, deps) => {
819
+ const {
820
+ branchId,
821
+ collections,
822
+ contentDir,
823
+ functions,
824
+ getDeleteHandler,
825
+ getScope,
826
+ options,
827
+ projectContent,
828
+ requireScope,
829
+ searchIndex,
830
+ staticIndexPath
831
+ } = deps;
794
832
  server.registerTool(
795
833
  "list_content",
796
834
  {
797
835
  title: "List documents in a collection",
798
836
  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
837
  inputSchema: {
800
- collection: z.string().describe("Collection name, as returned by list_collections")
838
+ collection: z4.string().describe("Collection name, as returned by list_collections")
801
839
  }
802
840
  },
803
841
  ({ collection: name }) => guarded(() => {
@@ -819,8 +857,8 @@ function createGraftMcp(options) {
819
857
  title: "Get one document",
820
858
  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
859
  inputSchema: {
822
- collection: z.string().describe("Collection name"),
823
- slug: z.string().describe("Document slug (kebab-case)")
860
+ collection: z4.string().describe("Collection name"),
861
+ slug: z4.string().describe("Document slug (kebab-case)")
824
862
  }
825
863
  },
826
864
  ({ collection: name, slug }) => guarded(() => {
@@ -841,9 +879,9 @@ function createGraftMcp(options) {
841
879
  title: "Full-text search across content",
842
880
  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
881
  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)")
882
+ query: z4.string().describe('What to find, e.g. pricing "free tier" -enterprise'),
883
+ collection: z4.string().optional().describe("Restrict to one collection (default: all registered collections)"),
884
+ limit: z4.number().optional().describe("Max hits, best-ranked first (default 20)")
847
885
  }
848
886
  },
849
887
  ({ query, collection: name, limit }) => guarded(async () => {
@@ -873,16 +911,17 @@ function createGraftMcp(options) {
873
911
  title: "Write a document (create or update)",
874
912
  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
913
  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.")
914
+ collection: z4.string().describe("Collection name"),
915
+ slug: z4.string().describe("Document slug \u2014 kebab-case; becomes the filename and the URL segment"),
916
+ data: z4.record(z4.string(), z4.unknown()).describe("Frontmatter data; must satisfy the collection schema (see describe_schema)"),
917
+ body: z4.string().optional().describe("MDX body (markdown). Defaults to empty.")
880
918
  }
881
919
  },
882
920
  ({ collection: name, slug, data, body }) => guarded(async () => {
921
+ requireScope("write_content", "content:write");
883
922
  const collection = requireCollection(collections, name);
884
923
  if (collection.authority === "db-authoritative") {
885
- throw new GraftError2({
924
+ throw new GraftError5({
886
925
  code: "AUTHORITY_MISMATCH",
887
926
  message: `Collection "${name}" is db-authoritative \u2014 its records live in Postgres, not as MDX files.`,
888
927
  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.`,
@@ -891,7 +930,7 @@ function createGraftMcp(options) {
891
930
  }
892
931
  const frontmatterSlug = data.slug;
893
932
  if (frontmatterSlug !== void 0 && frontmatterSlug !== slug) {
894
- throw new GraftError2({
933
+ throw new GraftError5({
895
934
  code: "INVALID_SLUG",
896
935
  message: `data.slug ("${String(frontmatterSlug)}") conflicts with the slug argument ("${slug}")`,
897
936
  fix: "Omit `slug` from data \u2014 the slug argument names the file and the document.",
@@ -900,7 +939,8 @@ function createGraftMcp(options) {
900
939
  }
901
940
  const sourcePath = `${name}/${slug}.mdx`;
902
941
  const fullPath = join2(contentDir, ...sourcePath.split("/"));
903
- const existingRaw = existsSync2(fullPath) ? readFileSync2(fullPath, "utf8") : void 0;
942
+ assertSafeMdx(body ?? "", { label: `${name}/${slug}` });
943
+ const existingRaw = existsSync2(fullPath) ? readFileSync3(fullPath, "utf8") : void 0;
904
944
  const raw = composeDocument(existingRaw, data, body ?? "");
905
945
  parseDocument2(raw, collection, sourcePath);
906
946
  assertSlugFree(contentDir, name, collection, slug, sourcePath);
@@ -920,17 +960,18 @@ function createGraftMcp(options) {
920
960
  title: "Delete a document (human-gated)",
921
961
  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
962
  inputSchema: {
923
- collection: z.string().describe("Collection name"),
924
- slug: z.string().describe("Document slug to delete"),
925
- approval: z.string().optional().describe(
963
+ collection: z4.string().describe("Collection name"),
964
+ slug: z4.string().describe("Document slug to delete"),
965
+ approval: z4.string().optional().describe(
926
966
  "Approval id from a prior DESTRUCTIVE_OP_REQUIRES_APPROVAL response, after a human ran `graft approve <id>`."
927
967
  )
928
968
  }
929
969
  },
930
970
  ({ collection: name, slug, approval }) => guarded(async () => {
971
+ requireScope("delete_content", "content:write");
931
972
  const collection = requireCollection(collections, name);
932
973
  if (collection.authority === "db-authoritative") {
933
- throw new GraftError2({
974
+ throw new GraftError5({
934
975
  code: "AUTHORITY_MISMATCH",
935
976
  message: `Collection "${name}" is db-authoritative \u2014 its records live in Postgres, not as MDX files.`,
936
977
  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.",
@@ -947,380 +988,472 @@ function createGraftMcp(options) {
947
988
  return { ...data, correlationId };
948
989
  })
949
990
  );
991
+ const uploadRoot = options.localUploadRoot;
992
+ };
993
+
994
+ // src/tools/errors.ts
995
+ import { z as z5 } from "zod";
996
+ var registerErrorTools = (server, deps) => {
997
+ void deps;
950
998
  server.registerTool(
951
- "put_asset",
999
+ "explain_error",
952
1000
  {
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.",
1001
+ title: "Explain a Graft error",
1002
+ 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.",
955
1003
  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.")
1004
+ code: z5.string().optional().describe("An error code, e.g. SCHEMA_VALIDATION_FAILED"),
1005
+ error: z5.string().optional().describe("A full GraftError JSON string, if you have one")
963
1006
  }
964
1007
  },
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) {
1008
+ ({ code, error }) => guarded(() => {
1009
+ let parsed;
1010
+ if (error) {
975
1011
  try {
976
- bytes = readFileSync2(path);
1012
+ parsed = JSON.parse(error);
977
1013
  } 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
1014
  }
993
- bytes = Buffer.from(base64, "base64");
994
1015
  }
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
- });
1016
+ const effective = code ?? parsed?.error;
1017
+ if (!effective) {
1018
+ return {
1019
+ knownCodes: Object.keys(ERROR_KNOWLEDGE),
1020
+ hint: "Pass `code` or the GraftError JSON as `error`."
1021
+ };
1002
1022
  }
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 }
1023
+ const explanation = explainCode(effective);
1024
+ if (!explanation) {
1025
+ return {
1026
+ code: effective,
1027
+ known: false,
1028
+ knownCodes: Object.keys(ERROR_KNOWLEDGE),
1029
+ hint: "Not a Graft error code. If this came from another system, resolve it there."
1030
+ };
1031
+ }
1032
+ return {
1033
+ ...explanation,
1034
+ // The specific fix from the actual error beats the general recovery advice.
1035
+ specificFix: parsed?.fix,
1036
+ message: parsed?.message
1037
+ };
1038
+ })
1039
+ );
1040
+ };
1041
+
1042
+ // src/tools/functions.ts
1043
+ import { GraftError as GraftError6 } from "@usegraft/contracts";
1044
+ import { z as z6 } from "zod";
1045
+ var registerFunctionTools = (server, deps) => {
1046
+ const { functions, functionsByName, getFunctionsHandler, options } = deps;
1047
+ server.registerTool(
1048
+ "run_function",
1049
+ {
1050
+ title: "Run a typed function",
1051
+ 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.",
1052
+ inputSchema: {
1053
+ name: z6.string().describe("Function name (defineFunction name, not the export key)"),
1054
+ input: z6.record(z6.string(), z6.unknown()).optional().describe("Input fields object; defaults to {}. See describe_function for the schema."),
1055
+ authorization: z6.string().optional().describe(
1056
+ "Bearer token override (with or without the 'Bearer ' prefix). Usually unnecessary \u2014 the server's configured identity applies when omitted."
1057
+ ),
1058
+ approval: z6.string().optional().describe(
1059
+ "Approval id from a prior DESTRUCTIVE_OP_REQUIRES_APPROVAL response (after `graft approve <id>`)."
1060
+ )
1061
+ }
1062
+ },
1063
+ ({ name, input, authorization, approval }) => guarded(async () => {
1064
+ if (functionsByName.size === 0) {
1065
+ throw new GraftError6({
1066
+ code: "FUNCTION_NOT_FOUND",
1067
+ message: "This MCP server has no functions registered.",
1068
+ fix: "Export `functions` from graft.config.ts (defineFunction results, often via mergePrimitives) and restart the MCP server / pass them to createGraftMcp({ functions }).",
1069
+ details: { requested: name, available: [] }
1010
1070
  });
1011
1071
  }
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 }
1072
+ if (!functionsByName.has(name)) {
1073
+ throw new GraftError6({
1074
+ code: "FUNCTION_NOT_FOUND",
1075
+ message: `No function named "${name}" is registered.`,
1076
+ fix: `Call list_functions and use one of: ${[...functionsByName.keys()].join(", ")}.`,
1077
+ details: { requested: name, available: [...functionsByName.keys()] }
1019
1078
  });
1020
1079
  }
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
- };
1080
+ return invokeFunction(getFunctionsHandler(), name, input ?? {}, {
1081
+ credential: authorization ?? options.defaultAuthorization,
1082
+ approval
1083
+ });
1032
1084
  })
1033
1085
  );
1086
+ };
1087
+
1088
+ // src/tools/introspection.ts
1089
+ import { GraftError as GraftError7 } from "@usegraft/contracts";
1090
+ import { z as z7 } from "zod";
1091
+ var registerIntrospectionTools = (server, deps) => {
1092
+ const { branchId, collections, functions, functionsByName } = deps;
1034
1093
  server.registerTool(
1035
- "list_branches",
1094
+ "list_collections",
1036
1095
  {
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`.",
1096
+ title: "List collections",
1097
+ description: "List every registered content collection (name, description, authority, field count). Start here to learn what kinds of content this project has.",
1039
1098
  inputSchema: {}
1040
1099
  },
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
- }))
1100
+ () => guarded(() => ({
1101
+ branch: branchId,
1102
+ collections: Object.values(collections).map((collection) => {
1103
+ const descriptor = collection.describe();
1104
+ return {
1105
+ name: descriptor.name,
1106
+ description: descriptor.description,
1107
+ authority: descriptor.authority,
1108
+ fields: descriptor.fields.length
1109
+ };
1110
+ })
1055
1111
  }))
1056
1112
  );
1057
1113
  server.registerTool(
1058
- "list_compilations",
1114
+ "describe_schema",
1059
1115
  {
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
- }
1116
+ title: "Describe the content schema",
1117
+ 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.",
1118
+ inputSchema: {}
1066
1119
  },
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
- }))
1120
+ () => guarded(() => {
1121
+ return {
1122
+ collections: Object.values(collections).map((collection) => {
1123
+ const descriptor = collection.describe();
1124
+ return { ...descriptor, fields: descriptor.fields.map(teachAssetFields) };
1125
+ }),
1126
+ functions: [...functionsByName.values()].map((fn) => fn.describe())
1127
+ };
1128
+ })
1088
1129
  );
1089
1130
  server.registerTool(
1090
- "list_approvals",
1131
+ "list_functions",
1091
1132
  {
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.",
1133
+ title: "List functions",
1134
+ 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).",
1094
1135
  inputSchema: {}
1095
1136
  },
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
- }))
1137
+ () => guarded(() => ({
1138
+ branch: branchId,
1139
+ functions: [...functionsByName.values()].map((fn) => {
1140
+ const d = fn.describe();
1141
+ return {
1142
+ name: d.name,
1143
+ kind: d.kind,
1144
+ description: d.description,
1145
+ public: d.public,
1146
+ destructive: d.destructive,
1147
+ args: d.args.length
1148
+ };
1149
+ })
1112
1150
  }))
1113
1151
  );
1114
1152
  server.registerTool(
1115
- "decide_approval",
1153
+ "describe_function",
1116
1154
  {
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.",
1155
+ title: "Describe one function",
1156
+ 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.",
1119
1157
  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)")
1158
+ name: z7.string().describe("Function name as returned by list_functions")
1123
1159
  }
1124
1160
  },
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 }
1161
+ ({ name }) => guarded(() => {
1162
+ const fn = functionsByName.get(name);
1163
+ if (!fn) {
1164
+ throw new GraftError7({
1165
+ code: "FUNCTION_NOT_FOUND",
1166
+ message: `No function named "${name}" is registered.`,
1167
+ fix: `Call list_functions and use one of: ${[...functionsByName.keys()].join(", ") || "(none registered)"}.`,
1168
+ details: { requested: name, available: [...functionsByName.keys()] }
1141
1169
  });
1142
1170
  }
1143
- return {
1144
- id: row.id,
1145
- status: row.status,
1146
- decidedBy: row.decidedBy,
1147
- functionName: row.functionName
1148
- };
1171
+ return fn.describe();
1149
1172
  })
1150
1173
  );
1174
+ };
1175
+
1176
+ // src/tools/registry.ts
1177
+ import { describeItem, listItems, loadItem } from "@usegraft/registry";
1178
+ import { z as z8 } from "zod";
1179
+ var registerRegistryTools = (server, deps) => {
1180
+ const { options } = deps;
1181
+ server.registerTool(
1182
+ "list_registry",
1183
+ {
1184
+ title: "List registry items",
1185
+ 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.",
1186
+ inputSchema: {}
1187
+ },
1188
+ () => guarded(() => ({
1189
+ items: listItems(options.registryRoot).map((item) => ({
1190
+ name: item.name,
1191
+ type: item.type,
1192
+ description: item.description,
1193
+ registryDependencies: item.registryDependencies
1194
+ }))
1195
+ }))
1196
+ );
1151
1197
  server.registerTool(
1152
- "explain_error",
1198
+ "describe_item",
1153
1199
  {
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.",
1200
+ title: "Describe a registry item",
1201
+ 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.",
1156
1202
  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")
1203
+ name: z8.string().describe("Item name as returned by list_registry")
1159
1204
  }
1160
1205
  },
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
- })
1206
+ ({ name }) => guarded(() => describeItem(loadItem(name, options.registryRoot)))
1211
1207
  );
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);
1208
+ };
1209
+
1210
+ // src/server.ts
1211
+ function createGraftMcp(options) {
1212
+ const { contentDir, collections } = options;
1213
+ const branchId = options.branchId ?? "main";
1214
+ const staticIndexPath = options.db === void 0 ? options.staticIndexPath : void 0;
1215
+ const maybeDb = options.db;
1216
+ if (maybeDb === void 0 && staticIndexPath === void 0) {
1217
+ throw new GraftError8({
1218
+ code: "CONFIG_INVALID",
1219
+ message: "createGraftMcp needs an index: pass `db` (Postgres) or `staticIndexPath`.",
1220
+ fix: "Pass `db` from createDb(DATABASE_URL), or `staticIndexPath` pointing at the compiled artifact (.graft/index.db) for a static project."
1221
+ });
1216
1222
  }
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
- } : {}
1223
+ const requireDb = (feature, insteadDo) => {
1224
+ if (maybeDb !== void 0) return maybeDb;
1225
+ throw new GraftError8({
1226
+ code: "NEEDS_DATABASE",
1227
+ message: `${feature} needs the Postgres index; this project serves a static index (${staticIndexPath}).`,
1228
+ 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\`.`,
1229
+ details: { feature, index: "static" }
1230
+ });
1227
1231
  };
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
- }
1232
+ const requireScope = (tool, scope) => {
1233
+ const actor = options.connectionActor;
1234
+ if (actor === void 0) {
1235
+ if (options.actor === void 0) return;
1236
+ throw new GraftError8({
1237
+ code: "CONFIG_INVALID",
1238
+ message: `${tool} cannot be authorized: this server has an actor resolver but was given no connectionActor.`,
1239
+ fix: "Pass `connectionActor` alongside `actor` when building the server \u2014 createGraftMcpHandler does this for you from the bearer it verified. Without it every scope check passes and write tools are ungated.",
1240
+ details: { tool, required: scope }
1248
1241
  });
1249
1242
  }
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 }
1243
+ if (actor.kind === "anonymous") return;
1244
+ if ((actor.scopes ?? []).includes(scope)) return;
1245
+ throw new GraftError8({
1246
+ code: "UNAUTHORIZED",
1247
+ message: `${tool} requires the "${scope}" scope, and this credential does not carry it.`,
1248
+ fix: `Mint a token whose scope claim includes "${scope}" (for \`graft serve\`, add it to GRAFT_DEV_SCOPES). Content authoring, asset upload and approval decisions are deliberately separate from ordinary read scopes.`,
1249
+ details: { tool, required: scope, held: actor.scopes ?? [] }
1250
+ });
1251
+ };
1252
+ const requireDecider = () => {
1253
+ const actor = options.connectionActor;
1254
+ if (actor === void 0 || actor.kind === "anonymous" || !actor.id) {
1255
+ throw new GraftError8({
1256
+ code: "UNAUTHORIZED",
1257
+ message: "decide_approval needs to know who is deciding, and this connection is not authenticated as anyone.",
1258
+ fix: "Connect with `Authorization: Bearer <token>` from a trusted issuer (or set GRAFT_DEV_TOKEN for `graft mcp`). Deciding anonymously would make the requester-cannot-decide check meaningless, so it is refused rather than attributed to a placeholder.",
1259
+ details: { tool: "decide_approval", actor: actor?.kind ?? "anonymous" }
1260
+ });
1261
+ }
1262
+ return { kind: actor.kind, id: actor.id };
1263
+ };
1264
+ const projectContent = async () => staticIndexPath === void 0 ? compile({
1265
+ contentDir,
1266
+ collections,
1267
+ db: requireDb("compile", ""),
1268
+ branchId,
1269
+ mdxTrust: options.mdxTrust
1270
+ }) : compileStatic({
1271
+ contentDir,
1272
+ collections,
1273
+ indexPath: staticIndexPath,
1274
+ mdxTrust: options.mdxTrust
1256
1275
  });
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;
1276
+ const searchIndex = async (query) => {
1277
+ if (staticIndexPath === void 0) {
1278
+ return searchContent(requireDb("search_content", ""), query);
1267
1279
  }
1268
- let existingSlug;
1280
+ const index = await openStaticIndex(staticIndexPath);
1269
1281
  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 }
1282
+ return await index.searchContent({
1283
+ query: query.query,
1284
+ collections: query.collections,
1285
+ limit: query.limit
1280
1286
  });
1287
+ } finally {
1288
+ await index.close();
1281
1289
  }
1282
- }
1290
+ };
1291
+ const functions = options.functions ?? {};
1292
+ const functionsByName = /* @__PURE__ */ new Map();
1293
+ for (const fn of Object.values(functions)) functionsByName.set(fn.name, fn);
1294
+ let scopePromise;
1295
+ const getScope = () => {
1296
+ scopePromise ??= options.scope ? Promise.resolve(options.scope) : resolveBranchScope(requireDb("Branch scope resolution", ""), branchId);
1297
+ return scopePromise;
1298
+ };
1299
+ let functionsHandler;
1300
+ const getFunctionsHandler = () => {
1301
+ functionsHandler ??= createFunctionsHandler({
1302
+ functions,
1303
+ db: requireDb(
1304
+ "run_function",
1305
+ "Typed functions read and write operational data in Postgres, so a static project has none."
1306
+ ),
1307
+ branch: branchId,
1308
+ actor: options.actor,
1309
+ approvalPolicy: options.approvalPolicy,
1310
+ rateLimit: options.rateLimit,
1311
+ gitSha: options.gitSha,
1312
+ audit: options.audit,
1313
+ approvals: options.approvals
1314
+ });
1315
+ return functionsHandler;
1316
+ };
1317
+ const deleteContentFn = defineFunction({
1318
+ name: "delete_content",
1319
+ kind: "mutation",
1320
+ destructive: true,
1321
+ public: true,
1322
+ description: "Delete an authored MDX document and recompile (MCP delete_content tool).",
1323
+ returns: "{ deleted, branch, gitSha, changes }",
1324
+ input: {
1325
+ collection: field.string({ description: "Collection name" }),
1326
+ slug: field.string({ description: "Document slug to delete" })
1327
+ },
1328
+ handler: async ({ input }) => {
1329
+ const collection = requireCollection(collections, input.collection);
1330
+ const doc = findDoc(contentDir, input.collection, collection, input.slug);
1331
+ unlinkSync(join3(contentDir, ...doc.sourcePath.split("/")));
1332
+ const result = await projectContent();
1333
+ return {
1334
+ deleted: doc.sourcePath,
1335
+ branch: branchId,
1336
+ gitSha: result.gitSha,
1337
+ changes: result.changes
1338
+ };
1339
+ }
1340
+ });
1341
+ let deleteHandler;
1342
+ const getDeleteHandler = () => {
1343
+ deleteHandler ??= createFunctionsHandler({
1344
+ // The one-shot, input-bound human approval lives in Postgres. Rather than
1345
+ // silently downgrading to an ungated delete, a static project is told to
1346
+ // do it the way git already makes safe: delete the file and recompile.
1347
+ db: requireDb(
1348
+ "delete_content",
1349
+ "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."
1350
+ ),
1351
+ functions: { delete_content: deleteContentFn },
1352
+ branch: branchId,
1353
+ actor: options.actor,
1354
+ rateLimit: options.rateLimit,
1355
+ gitSha: options.gitSha,
1356
+ audit: options.audit,
1357
+ approvals: options.approvals
1358
+ });
1359
+ return deleteHandler;
1360
+ };
1361
+ let storagePromise;
1362
+ const getStorage = () => {
1363
+ storagePromise ??= (async () => {
1364
+ if (options.storage) {
1365
+ return typeof options.storage === "function" ? options.storage() : options.storage;
1366
+ }
1367
+ try {
1368
+ return createStorage(storageConfigFromEnv());
1369
+ } catch (error) {
1370
+ throw new GraftError8({
1371
+ code: "ENV_VAR_MISSING",
1372
+ message: error instanceof Error ? error.message : String(error),
1373
+ fix: "Set S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY, and S3_BUCKET in the MCP server's environment (.env), then retry.",
1374
+ details: { variables: ["S3_ENDPOINT", "S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_BUCKET"] }
1375
+ });
1376
+ }
1377
+ })();
1378
+ return storagePromise;
1379
+ };
1380
+ const server = new McpServer({
1381
+ name: options.name ?? "graft",
1382
+ version: options.version ?? "0.0.0"
1383
+ });
1384
+ const deps = {
1385
+ options,
1386
+ contentDir,
1387
+ collections,
1388
+ branchId,
1389
+ staticIndexPath,
1390
+ requireDb,
1391
+ requireScope,
1392
+ requireDecider,
1393
+ projectContent,
1394
+ searchIndex,
1395
+ getScope,
1396
+ functions,
1397
+ functionsByName,
1398
+ getFunctionsHandler,
1399
+ getDeleteHandler,
1400
+ getStorage
1401
+ };
1402
+ registerIntrospectionTools(server, deps);
1403
+ registerFunctionTools(server, deps);
1404
+ registerRegistryTools(server, deps);
1405
+ registerContentTools(server, deps);
1406
+ registerAssetTools(server, deps);
1407
+ registerBranchTools(server, deps);
1408
+ registerApprovalTools(server, deps);
1409
+ registerErrorTools(server, deps);
1410
+ return server;
1283
1411
  }
1284
1412
 
1285
1413
  // src/http.ts
1286
- import { GraftError as GraftError3 } from "@usegraft/contracts";
1414
+ import { GraftError as GraftError9 } from "@usegraft/contracts";
1287
1415
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
1288
1416
  function jsonRpcError(status, code, message, headers) {
1289
1417
  return Response.json({ jsonrpc: "2.0", error: { code, message }, id: null }, { status, headers });
1290
1418
  }
1291
1419
  function createGraftMcpHandler(options) {
1292
- const { actor: resolveActor, requireActor, ...serverOptions } = options;
1420
+ const { actor: resolveActor, allowAnonymous, ...serverOptions } = options;
1421
+ if (resolveActor === void 0 && allowAnonymous !== true) {
1422
+ throw new GraftError9({
1423
+ code: "CONFIG_INVALID",
1424
+ message: "createGraftMcpHandler was given no way to authenticate callers, and this endpoint serves content writes, asset uploads and approval decisions.",
1425
+ fix: "Pass `actor` \u2014 the @usegraft/auth `createActorResolver` seam, the same one the functions route uses. For a local dev server with no auth at all, pass `allowAnonymous: true` explicitly; never do that on anything reachable from a network."
1426
+ });
1427
+ }
1293
1428
  return async (request) => {
1294
1429
  if (request.method !== "POST") {
1295
1430
  return jsonRpcError(405, -32e3, "Method not allowed: this server is stateless (POST only)", {
1296
1431
  allow: "POST"
1297
1432
  });
1298
1433
  }
1434
+ let actor;
1299
1435
  if (resolveActor) {
1300
- let actor;
1301
1436
  try {
1302
1437
  actor = await resolveActor(request);
1303
1438
  } catch (err) {
1304
- const message = err instanceof GraftError3 ? `${err.message} ${err.fix ?? ""}`.trim() : "Unauthorized";
1439
+ const message = err instanceof GraftError9 ? `${err.message} ${err.fix ?? ""}`.trim() : "Unauthorized";
1305
1440
  return jsonRpcError(401, -32001, message);
1306
1441
  }
1307
- if (requireActor && actor.kind === "anonymous") {
1442
+ if (allowAnonymous !== true && actor.kind === "anonymous") {
1308
1443
  return jsonRpcError(
1309
1444
  401,
1310
1445
  -32001,
1311
1446
  "Unauthorized: this MCP endpoint requires authentication. Send `Authorization: Bearer <token>` from a trusted issuer."
1312
1447
  );
1313
1448
  }
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
1449
  }
1321
1450
  const server = createGraftMcp({
1322
1451
  ...serverOptions,
1323
1452
  actor: resolveActor,
1453
+ // Tools that need to know WHO is calling (rather than forward a
1454
+ // credential) read this. It is the same identity the check above just
1455
+ // verified, so a tool can never be told a different one.
1456
+ ...actor === void 0 ? {} : { connectionActor: actor },
1324
1457
  defaultAuthorization: request.headers.get("authorization") ?? serverOptions.defaultAuthorization
1325
1458
  });
1326
1459
  const transport = new WebStandardStreamableHTTPServerTransport({