@usegraft/mcp 0.2.0 → 1.0.0-beta.1

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
@@ -3,10 +3,9 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
3
3
 
4
4
  // src/server.ts
5
5
  import { unlinkSync } from "fs";
6
- import { join as join3 } from "path";
7
6
  import { createStorage, storageConfigFromEnv } from "@usegraft/assets";
8
- import { compile, compileStatic } from "@usegraft/compiler";
9
- import { GraftError as GraftError8 } from "@usegraft/contracts";
7
+ import { compile, compileStatic, resolveContained as resolveContained4 } from "@usegraft/compiler";
8
+ import { GraftError as GraftError10 } from "@usegraft/contracts";
10
9
  import {
11
10
  createFunctionsHandler,
12
11
  defineFunction,
@@ -27,7 +26,7 @@ var findDoc = (contentDir, collectionName, collection, slug) => findDocIn(conten
27
26
  // src/tools/approvals.ts
28
27
  import { GraftError as GraftError2 } from "@usegraft/contracts";
29
28
  import { decideApproval, listPendingApprovals } from "@usegraft/db";
30
- import { z } from "zod";
29
+ import { z as z2 } from "zod";
31
30
 
32
31
  // src/tool-result.ts
33
32
  import { GraftError } from "@usegraft/contracts";
@@ -161,9 +160,9 @@ var ERROR_KNOWLEDGE = {
161
160
  meaning: "The Graft server (`graft serve`) has nothing mounted at the requested path \u2014 the request reached the right process but the wrong URL.",
162
161
  typicalCauses: [
163
162
  "A typo in the endpoint path (e.g. /api/fns instead of /api/fn/<name>)",
164
- "Expecting the frontend app's routes on the headless runtime \u2014 graft serve hosts only the function, MCP, and health endpoints"
163
+ "Expecting the frontend app's routes on the headless runtime \u2014 graft serve hosts functions, MCP, authored-content reads, and health"
165
164
  ],
166
- 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."
165
+ howToRecover: "Use POST /api/fn/<name> for typed functions, POST /api/mcp for the MCP Streamable HTTP surface, GET /api/content/v1/documents or GET /api/content/v1/search for authored content, or GET /healthz for liveness. The error's details carry the path that missed."
167
166
  },
168
167
  AUTHORITY_MISMATCH: {
169
168
  code: "AUTHORITY_MISMATCH",
@@ -310,9 +309,10 @@ var ERROR_KNOWLEDGE = {
310
309
  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.",
311
310
  typicalCauses: [
312
311
  "Calling a function marked `destructive: true` (deletes or irreversibly overwrites data)",
313
- "Calling any mutation on a deployment whose approvalPolicy is 'human'"
312
+ "Calling any mutation on a deployment whose approvalPolicy is 'human'",
313
+ "Calling a destructive function over MCP at all \u2014 the 'unattended' policy that lifts this gate for a headless deployment is not part of the MCP surface, so there is no server setting that makes this tool call stop asking. That is deliberate, and not something to route around"
314
314
  ],
315
- 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."
315
+ 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. If you are seeing this over MCP, the server did not ask your client directly: either the mount has not opted into elicited approvals (`approvalElicitation`, off by default and intended for a local server whose operator is present) or your client did not declare the elicitation capability. Both are ordinary \u2014 the gate is the same either way, only the way the human is reached differs."
316
316
  },
317
317
  APPROVAL_INVALID: {
318
318
  code: "APPROVAL_INVALID",
@@ -434,8 +434,14 @@ function explainCode(code) {
434
434
  }
435
435
 
436
436
  // src/tool-result.ts
437
- function ok(payload) {
438
- return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
437
+ var isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
438
+ function ok(payload, links = []) {
439
+ const result = {
440
+ // Text first: a client that ignores the rest still gets the whole answer.
441
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }, ...links]
442
+ };
443
+ if (isPlainObject(payload)) result.structuredContent = payload;
444
+ return result;
439
445
  }
440
446
  function fail(error) {
441
447
  const explanation = ERROR_KNOWLEDGE[error.code];
@@ -453,14 +459,212 @@ function fail(error) {
453
459
  ]
454
460
  };
455
461
  }
456
- async function guarded(body) {
462
+ async function guarded(body, linksFor) {
457
463
  try {
458
- return ok(await body());
464
+ const payload = await body();
465
+ return ok(payload, linksFor?.(payload));
459
466
  } catch (error) {
460
467
  if (error instanceof GraftError) return fail(error);
461
468
  throw error;
462
469
  }
463
470
  }
471
+ async function guardedResource(body) {
472
+ try {
473
+ return await body();
474
+ } catch (error) {
475
+ if (!(error instanceof GraftError)) throw error;
476
+ const explanation = ERROR_KNOWLEDGE[error.code];
477
+ throw new Error(
478
+ [error.message, error.fix && `Fix: ${error.fix}`, explanation?.howToRecover].filter(Boolean).join(" "),
479
+ { cause: error }
480
+ );
481
+ }
482
+ }
483
+
484
+ // src/tools/annotations.ts
485
+ var READS = {
486
+ readOnlyHint: true,
487
+ openWorldHint: false
488
+ };
489
+ var WRITES = {
490
+ readOnlyHint: false,
491
+ destructiveHint: false,
492
+ idempotentHint: false,
493
+ openWorldHint: false
494
+ };
495
+ var DESTROYS = {
496
+ readOnlyHint: false,
497
+ destructiveHint: true,
498
+ idempotentHint: false,
499
+ openWorldHint: false
500
+ };
501
+
502
+ // src/tools/outputs.ts
503
+ import { FunctionDescriptor, RegistryItemDescriptor, SchemaDescription } from "@usegraft/contracts";
504
+ import { z } from "zod";
505
+ var DocumentData = z.record(z.string(), z.unknown());
506
+ var ChangeSet = z.object({
507
+ added: z.array(z.string()),
508
+ changed: z.array(z.string()),
509
+ removed: z.array(z.string()),
510
+ unchanged: z.number()
511
+ });
512
+ var listCollectionsOutput = {
513
+ branch: z.string(),
514
+ collections: z.array(
515
+ z.object({
516
+ name: z.string(),
517
+ description: z.string().optional(),
518
+ authority: z.string(),
519
+ fields: z.number()
520
+ })
521
+ )
522
+ };
523
+ var describeSchemaOutput = SchemaDescription.shape;
524
+ var listFunctionsOutput = {
525
+ branch: z.string(),
526
+ functions: z.array(
527
+ z.object({
528
+ name: z.string(),
529
+ kind: z.string(),
530
+ description: z.string().optional(),
531
+ public: z.boolean().optional(),
532
+ destructive: z.boolean().optional(),
533
+ args: z.number()
534
+ })
535
+ )
536
+ };
537
+ var describeFunctionOutput = FunctionDescriptor.shape;
538
+ var listRegistryOutput = {
539
+ items: z.array(
540
+ z.object({
541
+ name: z.string(),
542
+ type: z.string(),
543
+ description: z.string(),
544
+ registryDependencies: z.array(z.string()).optional()
545
+ })
546
+ )
547
+ };
548
+ var describeItemOutput = RegistryItemDescriptor.shape;
549
+ var listPackagesOutput = {
550
+ packages: z.array(
551
+ z.object({
552
+ name: z.string(),
553
+ role: z.string(),
554
+ when: z.string(),
555
+ tier: z.enum(["static", "postgres", "either"]),
556
+ framework: z.string().optional(),
557
+ direct: z.boolean()
558
+ })
559
+ )
560
+ };
561
+ var listContentOutput = {
562
+ collection: z.string(),
563
+ documents: z.array(
564
+ z.object({
565
+ slug: z.string(),
566
+ sourcePath: z.string(),
567
+ data: DocumentData
568
+ })
569
+ )
570
+ };
571
+ var getContentOutput = {
572
+ collection: z.string(),
573
+ slug: z.string(),
574
+ sourcePath: z.string(),
575
+ data: DocumentData,
576
+ body: z.string()
577
+ };
578
+ var searchContentOutput = {
579
+ branch: z.string(),
580
+ /** The resolved overlay chain, leaf-first — which branches were searched. */
581
+ chain: z.array(z.string()),
582
+ query: z.string(),
583
+ hits: z.array(
584
+ z.object({
585
+ collection: z.string(),
586
+ slug: z.string(),
587
+ sourcePath: z.string(),
588
+ rank: z.number(),
589
+ snippet: z.string(),
590
+ data: DocumentData
591
+ })
592
+ )
593
+ };
594
+ var writeContentOutput = {
595
+ written: z.string(),
596
+ branch: z.string(),
597
+ /** Null when the content tree is not in a git checkout. */
598
+ gitSha: z.string().nullable(),
599
+ changes: ChangeSet
600
+ };
601
+ var listBranchesOutput = {
602
+ branches: z.array(
603
+ z.object({
604
+ name: z.string(),
605
+ parent: z.string().nullable(),
606
+ backend: z.string(),
607
+ status: z.string(),
608
+ createdAt: z.string(),
609
+ endpointHost: z.string().nullable()
610
+ })
611
+ )
612
+ };
613
+ var listCompilationsOutput = {
614
+ compilations: z.array(
615
+ z.object({
616
+ id: z.string(),
617
+ branchId: z.string(),
618
+ gitSha: z.string().nullable(),
619
+ docCount: z.number(),
620
+ added: z.number(),
621
+ changed: z.number(),
622
+ removed: z.number(),
623
+ createdAt: z.string()
624
+ })
625
+ )
626
+ };
627
+ var listApprovalsOutput = {
628
+ approvals: z.array(
629
+ z.object({
630
+ id: z.string(),
631
+ branchId: z.string(),
632
+ functionName: z.string(),
633
+ /** The canonical input the approval is bound to; jsonb, so open. */
634
+ input: z.unknown(),
635
+ requestedByKind: z.string(),
636
+ requestedById: z.string().nullable(),
637
+ correlationId: z.string().nullable(),
638
+ createdAt: z.string()
639
+ })
640
+ )
641
+ };
642
+ var decideApprovalOutput = {
643
+ id: z.string(),
644
+ status: z.string(),
645
+ decidedBy: z.string().nullable(),
646
+ functionName: z.string()
647
+ };
648
+ var putAssetOutput = {
649
+ key: z.string(),
650
+ contentType: z.string(),
651
+ bytes: z.number(),
652
+ url: z.string(),
653
+ /** A ready-to-paste `field.asset` frontmatter snippet. */
654
+ frontmatter: z.string()
655
+ };
656
+ var explainErrorOutput = {
657
+ code: z.string().optional(),
658
+ known: z.boolean().optional(),
659
+ meaning: z.string().optional(),
660
+ typicalCauses: z.array(z.string()).optional(),
661
+ howToRecover: z.string().optional(),
662
+ /** The `fix` off the actual error, which beats the general advice. */
663
+ specificFix: z.string().optional(),
664
+ message: z.string().optional(),
665
+ knownCodes: z.array(z.string()).optional(),
666
+ hint: z.string().optional()
667
+ };
464
668
 
465
669
  // src/tools/approvals.ts
466
670
  var registerApprovalTools = (server, deps) => {
@@ -469,6 +673,8 @@ var registerApprovalTools = (server, deps) => {
469
673
  "list_approvals",
470
674
  {
471
675
  title: "List pending approvals",
676
+ outputSchema: listApprovalsOutput,
677
+ annotations: READS,
472
678
  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.",
473
679
  inputSchema: {}
474
680
  },
@@ -494,10 +700,12 @@ var registerApprovalTools = (server, deps) => {
494
700
  "decide_approval",
495
701
  {
496
702
  title: "Approve or deny a pending approval",
703
+ outputSchema: decideApprovalOutput,
704
+ annotations: DESTROYS,
497
705
  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
706
  inputSchema: {
499
- id: z.string().describe("Pending approval id from list_approvals"),
500
- decision: z.enum(["approved", "denied"]).describe("approved or denied")
707
+ id: z2.string().describe("Pending approval id from list_approvals"),
708
+ decision: z2.enum(["approved", "denied"]).describe("approved or denied")
501
709
  }
502
710
  },
503
711
  ({ id, decision }) => guarded(async () => {
@@ -535,7 +743,7 @@ import { contentTypeFor, defaultKeyFor } from "@usegraft/assets";
535
743
  import { AssetRef } from "@usegraft/core";
536
744
  import { resolveContained } from "@usegraft/compiler";
537
745
  import { GraftError as GraftError3 } from "@usegraft/contracts";
538
- import { z as z2 } from "zod";
746
+ import { z as z3 } from "zod";
539
747
  var registerAssetTools = (server, deps) => {
540
748
  const { getStorage, options, requireScope } = deps;
541
749
  const uploadRoot = options.localUploadRoot;
@@ -543,17 +751,19 @@ var registerAssetTools = (server, deps) => {
543
751
  "put_asset",
544
752
  {
545
753
  title: "Upload an asset (image / binary)",
754
+ outputSchema: putAssetOutput,
755
+ annotations: DESTROYS,
546
756
  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.",
547
757
  inputSchema: {
548
- key: z2.string().optional().describe(
758
+ key: z3.string().optional().describe(
549
759
  'Asset key \u2014 a lowercase path like "pages/pricing/hero.png". Required with base64; defaults to assets/<filename> with path.'
550
760
  ),
551
- path: z2.string().optional().describe(
761
+ path: z3.string().optional().describe(
552
762
  uploadRoot ? `Path to a file inside ${uploadRoot} (local/stdio agents).` : "Not available on this server \u2014 send the bytes as `base64` instead."
553
763
  ),
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.")
764
+ base64: z3.string().optional().describe("The file's bytes, base64-encoded (remote/HTTP agents)."),
765
+ contentType: z3.string().optional().describe("MIME type. Defaults to an inference from the key/path extension."),
766
+ overwrite: z3.boolean().optional().describe("Replace an existing binary at this key. Off by default.")
557
767
  }
558
768
  },
559
769
  ({ key: keyArg, path, base64, contentType, overwrite }) => guarded(async () => {
@@ -642,13 +852,15 @@ var registerAssetTools = (server, deps) => {
642
852
 
643
853
  // src/tools/branches.ts
644
854
  import { listBranches, listCompilations } from "@usegraft/db";
645
- import { z as z3 } from "zod";
855
+ import { z as z4 } from "zod";
646
856
  var registerBranchTools = (server, deps) => {
647
857
  const { branchId, requireDb } = deps;
648
858
  server.registerTool(
649
859
  "list_branches",
650
860
  {
651
861
  title: "List branches",
862
+ outputSchema: listBranchesOutput,
863
+ annotations: READS,
652
864
  description: "List registered content branches (name, parent, backend, status). Same data as GET /api/studio/v1/branches and `graft branch`.",
653
865
  inputSchema: {}
654
866
  },
@@ -672,10 +884,12 @@ var registerBranchTools = (server, deps) => {
672
884
  "list_compilations",
673
885
  {
674
886
  title: "List compilations",
887
+ outputSchema: listCompilationsOutput,
888
+ annotations: READS,
675
889
  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`.",
676
890
  inputSchema: {
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)")
891
+ branch: z4.string().optional().describe("Restrict to one branch id (default: all branches)"),
892
+ limit: z4.number().optional().describe("Max rows, newest first (default 20, max 100)")
679
893
  }
680
894
  },
681
895
  ({ branch, limit }) => guarded(async () => ({
@@ -704,17 +918,18 @@ var registerBranchTools = (server, deps) => {
704
918
 
705
919
  // src/tools/content.ts
706
920
  import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
707
- import { join as join2 } from "path";
708
921
  import {
709
922
  composeDocument,
710
923
  parseDocument as parseDocument2,
711
924
  readCollectionDocs,
925
+ resolveContained as resolveContained2,
926
+ SLUG_RE,
712
927
  writeDocumentFile
713
928
  } from "@usegraft/compiler";
714
929
  import { GraftError as GraftError5 } from "@usegraft/contracts";
715
930
  import { assertSafeMdx } from "@usegraft/mdx-safety";
716
931
  import { assertSearchQuery, scopeChain } from "@usegraft/db";
717
- import { z as z4 } from "zod";
932
+ import { z as z5 } from "zod";
718
933
 
719
934
  // src/tool-helpers.ts
720
935
  import { existsSync, readdirSync, readFileSync as readFileSync2, statSync } from "fs";
@@ -749,6 +964,26 @@ async function invokeFunction(handler, name, input, identity) {
749
964
  const data = body !== null && typeof body === "object" && "data" in body ? body.data : body;
750
965
  return { data, correlationId, status: response.status };
751
966
  }
967
+ function filedApprovalId(error) {
968
+ if (!(error instanceof GraftError4) || error.code !== "DESTRUCTIVE_OP_REQUIRES_APPROVAL") {
969
+ return void 0;
970
+ }
971
+ const id = error.details?.approvalId;
972
+ return typeof id === "string" ? id : void 0;
973
+ }
974
+ async function invokeFunctionWithApproval(handler, name, input, identity, elicit) {
975
+ try {
976
+ return await invokeFunction(handler, name, input, identity);
977
+ } catch (error) {
978
+ const approvalId = filedApprovalId(error);
979
+ if (approvalId === void 0 || elicit === void 0 || identity.approval !== void 0) {
980
+ throw error;
981
+ }
982
+ const approved = await elicit({ approvalId, functionName: name, input });
983
+ if (approved === void 0) throw error;
984
+ return invokeFunction(handler, name, input, { ...identity, approval: approved });
985
+ }
986
+ }
752
987
  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
988
  function teachAssetFields(fieldDescriptor) {
754
989
  const taught = {
@@ -814,155 +1049,205 @@ function assertSlugFree(contentDir, collectionName, collection, slug, targetSour
814
1049
  }
815
1050
  }
816
1051
 
1052
+ // src/tools/resource-uri.ts
1053
+ var documentUri = (branchId, collection, slug) => `graft://${branchId}/${collection}/${slug}`;
1054
+ var schemaUri = (branchId) => `graft://${branchId}/schema`;
1055
+ var documentUriTemplate = (branchId) => `graft://${branchId}/{collection}/{slug}`;
1056
+ var documentLink = (branchId, collection, slug) => ({
1057
+ type: "resource_link",
1058
+ uri: documentUri(branchId, collection, slug),
1059
+ name: `${collection}/${slug}`,
1060
+ mimeType: "text/markdown"
1061
+ });
1062
+
817
1063
  // 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;
1064
+ function assertSlugShape(slug, collection) {
1065
+ if (SLUG_RE.test(slug)) return;
1066
+ throw new GraftError5({
1067
+ code: "INVALID_SLUG",
1068
+ message: `Slug "${slug}" is not URL-safe.`,
1069
+ fix: 'Slugs must be kebab-case: lowercase letters, digits, and single hyphens (e.g. "getting-started"). A slug names a file inside the collection directory, so it cannot contain "/", ".." or a drive letter.',
1070
+ details: { slug, collection, pattern: SLUG_RE.source }
1071
+ });
1072
+ }
1073
+ var registerContentReadTools = (server, deps) => {
1074
+ const { branchId, collections, contentDir, getScope, searchIndex, staticIndexPath } = deps;
832
1075
  server.registerTool(
833
1076
  "list_content",
834
1077
  {
835
1078
  title: "List documents in a collection",
1079
+ outputSchema: listContentOutput,
1080
+ annotations: READS,
836
1081
  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.",
837
1082
  inputSchema: {
838
- collection: z4.string().describe("Collection name, as returned by list_collections")
1083
+ collection: z5.string().describe("Collection name, as returned by list_collections")
839
1084
  }
840
1085
  },
841
- ({ collection: name }) => guarded(() => {
842
- const collection = requireCollection(collections, name);
843
- const docs = readCollectionDocs(contentDir, name, collection);
844
- return {
845
- collection: name,
846
- documents: docs.map((doc) => ({
847
- slug: doc.slug,
848
- sourcePath: doc.sourcePath,
849
- data: doc.data
850
- }))
851
- };
852
- })
1086
+ ({ collection: name }) => guarded(
1087
+ () => {
1088
+ const collection = requireCollection(collections, name);
1089
+ const docs = readCollectionDocs(contentDir, name, collection);
1090
+ return {
1091
+ collection: name,
1092
+ documents: docs.map((doc) => ({
1093
+ slug: doc.slug,
1094
+ sourcePath: doc.sourcePath,
1095
+ data: doc.data
1096
+ }))
1097
+ };
1098
+ },
1099
+ (payload) => payload.documents.map((doc) => documentLink(branchId, name, doc.slug))
1100
+ )
853
1101
  );
854
1102
  server.registerTool(
855
1103
  "get_content",
856
1104
  {
857
1105
  title: "Get one document",
1106
+ outputSchema: getContentOutput,
1107
+ annotations: READS,
858
1108
  description: "Read a single document by collection + slug from the authored MDX files: validated frontmatter data, MDX body, and the file path to edit.",
859
1109
  inputSchema: {
860
- collection: z4.string().describe("Collection name"),
861
- slug: z4.string().describe("Document slug (kebab-case)")
1110
+ collection: z5.string().describe("Collection name"),
1111
+ slug: z5.string().describe("Document slug (kebab-case)")
862
1112
  }
863
1113
  },
864
- ({ collection: name, slug }) => guarded(() => {
865
- const collection = requireCollection(collections, name);
866
- const doc = findDoc(contentDir, name, collection, slug);
867
- return {
868
- collection: name,
869
- slug: doc.slug,
870
- sourcePath: doc.sourcePath,
871
- data: doc.data,
872
- body: doc.body
873
- };
874
- })
1114
+ ({ collection: name, slug }) => guarded(
1115
+ () => {
1116
+ const collection = requireCollection(collections, name);
1117
+ const doc = findDoc(contentDir, name, collection, slug);
1118
+ return {
1119
+ collection: name,
1120
+ slug: doc.slug,
1121
+ sourcePath: doc.sourcePath,
1122
+ data: doc.data,
1123
+ body: doc.body
1124
+ };
1125
+ },
1126
+ // The document's own address, so a client that reached it by search or
1127
+ // by guesswork can pin it as context without constructing the URI.
1128
+ (payload) => [documentLink(branchId, name, payload.slug)]
1129
+ )
875
1130
  );
876
1131
  server.registerTool(
877
1132
  "search_content",
878
1133
  {
879
1134
  title: "Full-text search across content",
1135
+ outputSchema: searchContentOutput,
1136
+ annotations: READS,
880
1137
  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.',
881
1138
  inputSchema: {
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)")
1139
+ query: z5.string().describe('What to find, e.g. pricing "free tier" -enterprise'),
1140
+ collection: z5.string().optional().describe("Restrict to one collection (default: all registered collections)"),
1141
+ limit: z5.number().optional().describe("Max hits, best-ranked first (default 20)")
885
1142
  }
886
1143
  },
887
- ({ query, collection: name, limit }) => guarded(async () => {
888
- if (name !== void 0) requireCollection(collections, name);
889
- assertSearchQuery(query);
890
- const collectionNames = name === void 0 ? Object.keys(collections) : [name];
891
- const chain = staticIndexPath === void 0 ? scopeChain(await getScope()) : [branchId];
892
- const hits = await searchIndex({ query, chain, collections: collectionNames, limit });
893
- return {
894
- branch: branchId,
895
- chain,
896
- query,
897
- hits: hits.map(({ row, rank, snippet }) => ({
898
- collection: row.collection,
899
- slug: row.slug,
900
- sourcePath: row.sourcePath,
901
- rank,
902
- snippet,
903
- data: row.data
904
- }))
905
- };
906
- })
1144
+ ({ query, collection: name, limit }) => guarded(
1145
+ async () => {
1146
+ if (name !== void 0) requireCollection(collections, name);
1147
+ assertSearchQuery(query);
1148
+ const collectionNames = name === void 0 ? Object.keys(collections) : [name];
1149
+ const chain = staticIndexPath === void 0 ? scopeChain(await getScope()) : [branchId];
1150
+ const hits = await searchIndex({ query, chain, collections: collectionNames, limit });
1151
+ return {
1152
+ branch: branchId,
1153
+ chain,
1154
+ query,
1155
+ hits: hits.map(({ row, rank, snippet }) => ({
1156
+ collection: row.collection,
1157
+ slug: row.slug,
1158
+ sourcePath: row.sourcePath,
1159
+ rank,
1160
+ snippet,
1161
+ data: row.data
1162
+ }))
1163
+ };
1164
+ },
1165
+ // A hit already knows its collection and slug; the link saves the
1166
+ // client a get_content round trip to reach the document itself.
1167
+ (payload) => payload.hits.map((hit) => documentLink(branchId, hit.collection, hit.slug))
1168
+ )
907
1169
  );
1170
+ };
1171
+ var registerContentWriteTools = (server, deps) => {
1172
+ const {
1173
+ branchId,
1174
+ collections,
1175
+ contentDir,
1176
+ elicitApproval,
1177
+ functions,
1178
+ getDeleteHandler,
1179
+ options,
1180
+ projectContent,
1181
+ requireScope
1182
+ } = deps;
908
1183
  server.registerTool(
909
1184
  "write_content",
910
1185
  {
911
1186
  title: "Write a document (create or update)",
1187
+ outputSchema: writeContentOutput,
1188
+ annotations: WRITES,
912
1189
  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.",
913
1190
  inputSchema: {
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.")
1191
+ collection: z5.string().describe("Collection name"),
1192
+ slug: z5.string().describe("Document slug \u2014 kebab-case; becomes the filename and the URL segment"),
1193
+ data: z5.record(z5.string(), z5.unknown()).describe("Frontmatter data; must satisfy the collection schema (see describe_schema)"),
1194
+ body: z5.string().optional().describe("MDX body (markdown). Defaults to empty.")
918
1195
  }
919
1196
  },
920
- ({ collection: name, slug, data, body }) => guarded(async () => {
921
- requireScope("write_content", "content:write");
922
- const collection = requireCollection(collections, name);
923
- if (collection.authority === "db-authoritative") {
924
- throw new GraftError5({
925
- code: "AUTHORITY_MISMATCH",
926
- message: `Collection "${name}" is db-authoritative \u2014 its records live in Postgres, not as MDX files.`,
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.`,
928
- details: { collection: name, authority: collection.authority }
929
- });
930
- }
931
- const frontmatterSlug = data.slug;
932
- if (frontmatterSlug !== void 0 && frontmatterSlug !== slug) {
933
- throw new GraftError5({
934
- code: "INVALID_SLUG",
935
- message: `data.slug ("${String(frontmatterSlug)}") conflicts with the slug argument ("${slug}")`,
936
- fix: "Omit `slug` from data \u2014 the slug argument names the file and the document.",
937
- details: { slug, frontmatterSlug }
1197
+ ({ collection: name, slug, data, body }) => guarded(
1198
+ async () => {
1199
+ requireScope("write_content", "content:write");
1200
+ const collection = requireCollection(collections, name);
1201
+ if (collection.authority === "db-authoritative") {
1202
+ throw new GraftError5({
1203
+ code: "AUTHORITY_MISMATCH",
1204
+ message: `Collection "${name}" is db-authoritative \u2014 its records live in Postgres, not as MDX files.`,
1205
+ 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.`,
1206
+ details: { collection: name, authority: collection.authority }
1207
+ });
1208
+ }
1209
+ const frontmatterSlug = data.slug;
1210
+ if (frontmatterSlug !== void 0 && frontmatterSlug !== slug) {
1211
+ throw new GraftError5({
1212
+ code: "INVALID_SLUG",
1213
+ message: `data.slug ("${String(frontmatterSlug)}") conflicts with the slug argument ("${slug}")`,
1214
+ fix: "Omit `slug` from data \u2014 the slug argument names the file and the document.",
1215
+ details: { slug, frontmatterSlug }
1216
+ });
1217
+ }
1218
+ assertSlugShape(slug, name);
1219
+ const sourcePath = `${name}/${slug}.mdx`;
1220
+ const fullPath = resolveContained2(contentDir, sourcePath, {
1221
+ label: "document path"
938
1222
  });
939
- }
940
- const sourcePath = `${name}/${slug}.mdx`;
941
- const fullPath = join2(contentDir, ...sourcePath.split("/"));
942
- assertSafeMdx(body ?? "", { label: `${name}/${slug}` });
943
- const existingRaw = existsSync2(fullPath) ? readFileSync3(fullPath, "utf8") : void 0;
944
- const raw = composeDocument(existingRaw, data, body ?? "");
945
- parseDocument2(raw, collection, sourcePath);
946
- assertSlugFree(contentDir, name, collection, slug, sourcePath);
947
- writeDocumentFile(fullPath, raw);
948
- const result = await projectContent();
949
- return {
950
- written: sourcePath,
951
- branch: branchId,
952
- gitSha: result.gitSha,
953
- changes: result.changes
954
- };
955
- })
1223
+ assertSafeMdx(body ?? "", { label: `${name}/${slug}` });
1224
+ const existingRaw = existsSync2(fullPath) ? readFileSync3(fullPath, "utf8") : void 0;
1225
+ const raw = composeDocument(existingRaw, data, body ?? "");
1226
+ parseDocument2(raw, collection, sourcePath);
1227
+ assertSlugFree(contentDir, name, collection, slug, sourcePath);
1228
+ writeDocumentFile(contentDir, sourcePath, raw);
1229
+ const result = await projectContent();
1230
+ return {
1231
+ written: sourcePath,
1232
+ branch: branchId,
1233
+ gitSha: result.gitSha,
1234
+ changes: result.changes
1235
+ };
1236
+ },
1237
+ // Where the thing it just wrote now lives.
1238
+ () => [documentLink(branchId, name, slug)]
1239
+ )
956
1240
  );
957
1241
  server.registerTool(
958
1242
  "delete_content",
959
1243
  {
960
1244
  title: "Delete a document (human-gated)",
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.",
1245
+ annotations: DESTROYS,
1246
+ 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. On a server that has opted into elicited approvals AND a client that supports elicitation, you may instead be asked to confirm in-band and the call completes in one step \u2014 the gate is identical, only the way the human is reached differs, so do not treat either outcome as unusual. 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.",
962
1247
  inputSchema: {
963
- collection: z4.string().describe("Collection name"),
964
- slug: z4.string().describe("Document slug to delete"),
965
- approval: z4.string().optional().describe(
1248
+ collection: z5.string().describe("Collection name"),
1249
+ slug: z5.string().describe("Document slug to delete"),
1250
+ approval: z5.string().optional().describe(
966
1251
  "Approval id from a prior DESTRUCTIVE_OP_REQUIRES_APPROVAL response, after a human ran `graft approve <id>`."
967
1252
  )
968
1253
  }
@@ -978,31 +1263,34 @@ var registerContentTools = (server, deps) => {
978
1263
  details: { collection: name, authority: collection.authority }
979
1264
  });
980
1265
  }
1266
+ assertSlugShape(slug, name);
981
1267
  findDoc(contentDir, name, collection, slug);
982
- const { data, correlationId } = await invokeFunction(
1268
+ const { data, correlationId } = await invokeFunctionWithApproval(
983
1269
  getDeleteHandler(),
984
1270
  "delete_content",
985
1271
  { collection: name, slug },
986
- { credential: options.defaultAuthorization, approval }
1272
+ { credential: options.defaultAuthorization, approval },
1273
+ elicitApproval
987
1274
  );
988
1275
  return { ...data, correlationId };
989
1276
  })
990
1277
  );
991
- const uploadRoot = options.localUploadRoot;
992
1278
  };
993
1279
 
994
1280
  // src/tools/errors.ts
995
- import { z as z5 } from "zod";
1281
+ import { z as z6 } from "zod";
996
1282
  var registerErrorTools = (server, deps) => {
997
1283
  void deps;
998
1284
  server.registerTool(
999
1285
  "explain_error",
1000
1286
  {
1001
1287
  title: "Explain a Graft error",
1288
+ outputSchema: explainErrorOutput,
1289
+ annotations: READS,
1002
1290
  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.",
1003
1291
  inputSchema: {
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")
1292
+ code: z6.string().optional().describe("An error code, e.g. SCHEMA_VALIDATION_FAILED"),
1293
+ error: z6.string().optional().describe("A full GraftError JSON string, if you have one")
1006
1294
  }
1007
1295
  },
1008
1296
  ({ code, error }) => guarded(() => {
@@ -1039,23 +1327,215 @@ var registerErrorTools = (server, deps) => {
1039
1327
  );
1040
1328
  };
1041
1329
 
1330
+ // src/tools/packages.ts
1331
+ import { z as z7 } from "zod";
1332
+
1333
+ // src/packages.ts
1334
+ var PACKAGE_KNOWLEDGE = {
1335
+ "@usegraft/cli": {
1336
+ name: "@usegraft/cli",
1337
+ role: "The `graft` command: init, compile, dev, serve, migrate, merge, add.",
1338
+ when: "Always. It scaffolds the project and compiles content into the index.",
1339
+ tier: "either",
1340
+ direct: true
1341
+ },
1342
+ "@usegraft/sdk-next": {
1343
+ name: "@usegraft/sdk-next",
1344
+ role: "Next.js adapter: typed reads in Server Components, plus route handlers.",
1345
+ when: "You are on Next.js (App Router).",
1346
+ tier: "either",
1347
+ framework: "next",
1348
+ direct: true
1349
+ },
1350
+ "@usegraft/sdk-astro": {
1351
+ name: "@usegraft/sdk-astro",
1352
+ role: "Astro adapter: typed reads in components, plus `graftRoute`.",
1353
+ when: "You are on Astro.",
1354
+ tier: "either",
1355
+ framework: "astro",
1356
+ direct: true
1357
+ },
1358
+ "@usegraft/sdk-sveltekit": {
1359
+ name: "@usegraft/sdk-sveltekit",
1360
+ role: "SvelteKit adapter: typed reads in load functions, plus route handlers.",
1361
+ when: "You are on SvelteKit.",
1362
+ tier: "either",
1363
+ framework: "sveltekit",
1364
+ direct: true
1365
+ },
1366
+ "@usegraft/sdk-react-router": {
1367
+ name: "@usegraft/sdk-react-router",
1368
+ role: "React Router adapter: typed reads in loaders and actions.",
1369
+ when: "You are on React Router in framework mode, which runs a server.",
1370
+ tier: "either",
1371
+ framework: "react-router",
1372
+ direct: true
1373
+ },
1374
+ "@usegraft/sdk-tanstack-start": {
1375
+ name: "@usegraft/sdk-tanstack-start",
1376
+ role: "TanStack Start adapter: typed reads in server functions and loaders.",
1377
+ when: "You are on TanStack Start.",
1378
+ tier: "either",
1379
+ framework: "tanstack-start",
1380
+ direct: true
1381
+ },
1382
+ "@usegraft/sdk-react": {
1383
+ name: "@usegraft/sdk-react",
1384
+ role: "Browser client: typed reads over the content API, with a provider and hooks.",
1385
+ when: "You need reads in the browser \u2014 a search box, an editor preview, a widget on an already-rendered page. No database reaches this package.",
1386
+ tier: "postgres",
1387
+ framework: "react",
1388
+ direct: true
1389
+ },
1390
+ "@usegraft/sdk-core": {
1391
+ name: "@usegraft/sdk-core",
1392
+ role: "The framework-agnostic read client the adapters are built on.",
1393
+ when: "You are on a framework with no adapter, or you want the client directly.",
1394
+ tier: "either",
1395
+ direct: true
1396
+ },
1397
+ "@usegraft/content-api": {
1398
+ name: "@usegraft/content-api",
1399
+ role: "The HTTP content API: a handler to mount, and a reader to consume it.",
1400
+ when: "Something reads content over HTTP rather than in-process \u2014 the browser client, or another service.",
1401
+ tier: "either",
1402
+ direct: true
1403
+ },
1404
+ "@usegraft/mcp": {
1405
+ name: "@usegraft/mcp",
1406
+ role: "The MCP server: authoring, schema introspection, functions and approvals as tools.",
1407
+ when: "You want an agent to read and write content. `graft mcp` and `graft serve` both mount it.",
1408
+ tier: "either",
1409
+ direct: true
1410
+ },
1411
+ "@usegraft/studio": {
1412
+ name: "@usegraft/studio",
1413
+ role: "The editing UI: documents, compiles, commits, branches and approvals.",
1414
+ when: "A human needs to edit without touching the repository.",
1415
+ tier: "postgres",
1416
+ direct: true
1417
+ },
1418
+ "@usegraft/core": {
1419
+ name: "@usegraft/core",
1420
+ role: "defineCollection, defineFunction, the functions handler, and the field primitives.",
1421
+ when: "Always \u2014 it is what graft.config.ts imports to declare a schema.",
1422
+ tier: "either",
1423
+ direct: true
1424
+ },
1425
+ "@usegraft/db": {
1426
+ name: "@usegraft/db",
1427
+ role: "The Postgres schema and Drizzle handle for operational data.",
1428
+ when: "You are on the Postgres tier. Never reaches a browser.",
1429
+ tier: "postgres",
1430
+ direct: true
1431
+ },
1432
+ "@usegraft/auth": {
1433
+ name: "@usegraft/auth",
1434
+ role: "Actor resolution: bearer tokens, OIDC issuers and scopes.",
1435
+ when: "You are authenticating callers to functions, MCP or Studio.",
1436
+ tier: "postgres",
1437
+ direct: true
1438
+ },
1439
+ "@usegraft/assets": {
1440
+ name: "@usegraft/assets",
1441
+ role: "The S3-compatible asset store behind `graft asset put` and `put_asset`.",
1442
+ when: "Documents reference images or files.",
1443
+ tier: "either",
1444
+ direct: true
1445
+ },
1446
+ "@usegraft/compiler": {
1447
+ name: "@usegraft/compiler",
1448
+ role: "Reads authored MDX and projects it into the content index.",
1449
+ when: "Pulled in by the CLI. Install directly only to compile from your own code.",
1450
+ tier: "either",
1451
+ direct: false
1452
+ },
1453
+ "@usegraft/contracts": {
1454
+ name: "@usegraft/contracts",
1455
+ role: "Shared types, error codes and the content-index interface.",
1456
+ when: "A dependency of everything. Install directly only to implement your own index reader.",
1457
+ tier: "either",
1458
+ direct: false
1459
+ },
1460
+ "@usegraft/mdx-safety": {
1461
+ name: "@usegraft/mdx-safety",
1462
+ role: "Refuses executable MDX in authored bodies, per the project's mdxTrust.",
1463
+ when: "Pulled in by the compiler and MCP. Install directly only to check MDX yourself.",
1464
+ tier: "either",
1465
+ direct: false
1466
+ },
1467
+ "@usegraft/content-migrations": {
1468
+ name: "@usegraft/content-migrations",
1469
+ role: "Migrations for authored documents, as opposed to database rows.",
1470
+ when: "A schema change needs existing MDX rewritten.",
1471
+ tier: "either",
1472
+ direct: true
1473
+ },
1474
+ "@usegraft/tokens": {
1475
+ name: "@usegraft/tokens",
1476
+ role: "Graft's design tokens as plain CSS custom properties.",
1477
+ when: "You are restyling Studio, or want a UI that matches it. One CSS import, no JavaScript.",
1478
+ tier: "either",
1479
+ direct: true
1480
+ },
1481
+ "@usegraft/registry": {
1482
+ name: "@usegraft/registry",
1483
+ role: "The owned primitives `graft add` copies into a project.",
1484
+ when: "Pulled in by the CLI. Browse the items with list_registry.",
1485
+ tier: "either",
1486
+ direct: false
1487
+ }
1488
+ };
1489
+ function allPackages() {
1490
+ return Object.values(PACKAGE_KNOWLEDGE);
1491
+ }
1492
+
1493
+ // src/tools/packages.ts
1494
+ var registerPackageTools = (server, deps) => {
1495
+ void deps;
1496
+ server.registerTool(
1497
+ "list_packages",
1498
+ {
1499
+ title: "List Graft packages",
1500
+ outputSchema: listPackagesOutput,
1501
+ annotations: READS,
1502
+ description: "Which @usegraft/* package to install for a given framework or job: what each one is, when you need it, and which tier it requires. Filter by `framework` when the user is on a known one, or by `tier`. Use this before suggesting an install; use list_registry for copy-in primitives instead.",
1503
+ inputSchema: {
1504
+ framework: z7.enum(["next", "astro", "sveltekit", "react-router", "tanstack-start", "react"]).optional().describe("Only the adapter for this framework, plus the packages every project needs"),
1505
+ tier: z7.enum(["static", "postgres"]).optional().describe("Only packages usable on this tier"),
1506
+ includeIndirect: z7.boolean().optional().describe("Include packages pulled in as dependencies rather than installed directly")
1507
+ }
1508
+ },
1509
+ ({ framework, tier, includeIndirect }) => guarded(() => {
1510
+ let packages = allPackages();
1511
+ if (!includeIndirect) packages = packages.filter((p) => p.direct);
1512
+ if (framework) {
1513
+ packages = packages.filter((p) => p.framework === void 0 || p.framework === framework);
1514
+ }
1515
+ if (tier) packages = packages.filter((p) => p.tier === "either" || p.tier === tier);
1516
+ return { packages: packages.sort((a, b) => a.name.localeCompare(b.name)) };
1517
+ })
1518
+ );
1519
+ };
1520
+
1042
1521
  // src/tools/functions.ts
1043
1522
  import { GraftError as GraftError6 } from "@usegraft/contracts";
1044
- import { z as z6 } from "zod";
1523
+ import { z as z8 } from "zod";
1045
1524
  var registerFunctionTools = (server, deps) => {
1046
- const { functions, functionsByName, getFunctionsHandler, options } = deps;
1525
+ const { elicitApproval, functions, functionsByName, getFunctionsHandler, options } = deps;
1047
1526
  server.registerTool(
1048
1527
  "run_function",
1049
1528
  {
1050
1529
  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.",
1530
+ annotations: DESTROYS,
1531
+ 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 \u2014 or, where the server has opted into elicited approvals and your client supports elicitation, expect to be asked to confirm in-band and the call completes in one step. The gate is the same either way. Success returns { data, correlationId }; failures are GraftError JSON with a fix.",
1052
1532
  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(
1533
+ name: z8.string().describe("Function name (defineFunction name, not the export key)"),
1534
+ input: z8.record(z8.string(), z8.unknown()).optional().describe("Input fields object; defaults to {}. See describe_function for the schema."),
1535
+ authorization: z8.string().optional().describe(
1056
1536
  "Bearer token override (with or without the 'Bearer ' prefix). Usually unnecessary \u2014 the server's configured identity applies when omitted."
1057
1537
  ),
1058
- approval: z6.string().optional().describe(
1538
+ approval: z8.string().optional().describe(
1059
1539
  "Approval id from a prior DESTRUCTIVE_OP_REQUIRES_APPROVAL response (after `graft approve <id>`)."
1060
1540
  )
1061
1541
  }
@@ -1077,23 +1557,28 @@ var registerFunctionTools = (server, deps) => {
1077
1557
  details: { requested: name, available: [...functionsByName.keys()] }
1078
1558
  });
1079
1559
  }
1080
- return invokeFunction(getFunctionsHandler(), name, input ?? {}, {
1081
- credential: authorization ?? options.defaultAuthorization,
1082
- approval
1083
- });
1560
+ return invokeFunctionWithApproval(
1561
+ getFunctionsHandler(),
1562
+ name,
1563
+ input ?? {},
1564
+ { credential: authorization ?? options.defaultAuthorization, approval },
1565
+ elicitApproval
1566
+ );
1084
1567
  })
1085
1568
  );
1086
1569
  };
1087
1570
 
1088
1571
  // src/tools/introspection.ts
1089
1572
  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;
1573
+ import { z as z9 } from "zod";
1574
+ var registerCollectionIntrospection = (server, deps) => {
1575
+ const { branchId, collections } = deps;
1093
1576
  server.registerTool(
1094
1577
  "list_collections",
1095
1578
  {
1096
1579
  title: "List collections",
1580
+ outputSchema: listCollectionsOutput,
1581
+ annotations: READS,
1097
1582
  description: "List every registered content collection (name, description, authority, field count). Start here to learn what kinds of content this project has.",
1098
1583
  inputSchema: {}
1099
1584
  },
@@ -1110,10 +1595,15 @@ var registerIntrospectionTools = (server, deps) => {
1110
1595
  })
1111
1596
  }))
1112
1597
  );
1598
+ };
1599
+ var registerFunctionIntrospection = (server, deps) => {
1600
+ const { branchId, collections, functions, functionsByName } = deps;
1113
1601
  server.registerTool(
1114
1602
  "describe_schema",
1115
1603
  {
1116
1604
  title: "Describe the content schema",
1605
+ outputSchema: describeSchemaOutput,
1606
+ annotations: READS,
1117
1607
  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
1608
  inputSchema: {}
1119
1609
  },
@@ -1131,6 +1621,8 @@ var registerIntrospectionTools = (server, deps) => {
1131
1621
  "list_functions",
1132
1622
  {
1133
1623
  title: "List functions",
1624
+ outputSchema: listFunctionsOutput,
1625
+ annotations: READS,
1134
1626
  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).",
1135
1627
  inputSchema: {}
1136
1628
  },
@@ -1153,9 +1645,11 @@ var registerIntrospectionTools = (server, deps) => {
1153
1645
  "describe_function",
1154
1646
  {
1155
1647
  title: "Describe one function",
1648
+ outputSchema: describeFunctionOutput,
1649
+ annotations: READS,
1156
1650
  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.",
1157
1651
  inputSchema: {
1158
- name: z7.string().describe("Function name as returned by list_functions")
1652
+ name: z9.string().describe("Function name as returned by list_functions")
1159
1653
  }
1160
1654
  },
1161
1655
  ({ name }) => guarded(() => {
@@ -1173,15 +1667,256 @@ var registerIntrospectionTools = (server, deps) => {
1173
1667
  );
1174
1668
  };
1175
1669
 
1670
+ // src/approval-elicitation.ts
1671
+ import { GraftError as GraftError8 } from "@usegraft/contracts";
1672
+ import { decideApproval as decideApproval2 } from "@usegraft/db";
1673
+ var INPUT_PREVIEW_LIMIT = 1e3;
1674
+ function describeCall(functionName, input) {
1675
+ const rendered = JSON.stringify(input);
1676
+ if (rendered === void 0) return functionName;
1677
+ if (rendered.length <= INPUT_PREVIEW_LIMIT) return `${functionName} ${rendered}`;
1678
+ return [
1679
+ `${functionName} ${rendered.slice(0, INPUT_PREVIEW_LIMIT)}\u2026`,
1680
+ "",
1681
+ `\u26A0\uFE0F Input truncated (${rendered.length} characters). Run \`graft approvals\` to read it in full before deciding \u2014 the approval binds to the whole input, not the part shown here.`
1682
+ ].join("\n");
1683
+ }
1684
+ function createApprovalElicitor(options) {
1685
+ return async ({ approvalId, functionName, input }) => {
1686
+ if (options.server.server.getClientCapabilities()?.elicitation === void 0) {
1687
+ return void 0;
1688
+ }
1689
+ let result;
1690
+ try {
1691
+ result = await options.server.server.elicitInput({
1692
+ message: [
1693
+ `Approve this destructive call?`,
1694
+ "",
1695
+ describeCall(functionName, input),
1696
+ "",
1697
+ `It will be recorded as decided by ${options.decider.kind}:${options.decider.id}.`,
1698
+ "The approval is one-shot and bound to exactly this input."
1699
+ ].join("\n"),
1700
+ requestedSchema: {
1701
+ type: "object",
1702
+ properties: {
1703
+ approve: {
1704
+ type: "boolean",
1705
+ title: "Approve",
1706
+ description: `Allow ${functionName} to run once, with exactly this input.`
1707
+ }
1708
+ },
1709
+ required: ["approve"]
1710
+ }
1711
+ });
1712
+ } catch {
1713
+ return void 0;
1714
+ }
1715
+ if (result.action === "cancel") return void 0;
1716
+ const approved = result.action === "accept" && result.content?.approve === true;
1717
+ const row = await decideApproval2(
1718
+ options.db(),
1719
+ approvalId,
1720
+ approved ? "approved" : "denied",
1721
+ options.decider
1722
+ );
1723
+ if (!row) {
1724
+ throw new GraftError8({
1725
+ code: "APPROVAL_INVALID",
1726
+ message: `Approval "${approvalId}" was no longer pending when the decision arrived.`,
1727
+ fix: "Someone or something decided it while the prompt was open. Call the tool again to file a fresh approval.",
1728
+ details: { id: approvalId, function: functionName }
1729
+ });
1730
+ }
1731
+ return approved ? approvalId : void 0;
1732
+ };
1733
+ }
1734
+
1735
+ // src/tools/prompts.ts
1736
+ import { z as z10 } from "zod";
1737
+ import { completable } from "@modelcontextprotocol/sdk/server/completable.js";
1738
+ import { readCollectionDocs as readCollectionDocs2 } from "@usegraft/compiler";
1739
+ var say = (text) => ({
1740
+ messages: [{ role: "user", content: { type: "text", text } }]
1741
+ });
1742
+ var registerContentPrompts = (server, deps) => {
1743
+ const { branchId, collections, contentDir } = deps;
1744
+ const fileCollections = () => Object.values(collections).filter((collection) => collection.describe().authority !== "db-authoritative").map((collection) => collection.describe().name);
1745
+ const migrationSteps = (name) => {
1746
+ if (collections[name]?.describe().authority === "db-authoritative") {
1747
+ return [
1748
+ "This collection is db-authoritative: its records live in Postgres, not as MDX files. `graft compile` has nothing to say about them, and no file will fail to name the work.",
1749
+ "1. Edit the collection in graft.config.ts (or under graft/) to the new shape.",
1750
+ "2. Write `migrations/<seq>-<name>.ts` default-exporting defineDataMigration. It backfills data_records and writes its ledger row in ONE transaction, so a half-applied migration is not a state this can reach.",
1751
+ "3. `graft migrate` is a dry run. `graft migrate --apply` is the operator's consent, and is theirs to give \u2014 propose the command, do not assume it.",
1752
+ "4. Commit. The migration is a reviewable commit, which is the whole reason it is a file rather than a live edit."
1753
+ ];
1754
+ }
1755
+ return [
1756
+ "How migrations work here \u2014 they are code, not a console action:",
1757
+ "1. Edit the collection in graft.config.ts (or under graft/) to the new shape.",
1758
+ "2. `graft compile` now fails, once per document that no longer satisfies the schema. That is the point: the failure names every file to fix.",
1759
+ "3. Write `migrations/<seq>-<name>.ts` default-exporting defineContentMigration. It transforms old-shape frontmatter, validates every output against the NEW schema, and rewrites the files all-or-nothing.",
1760
+ "4. `graft migrate` is a dry run. `graft migrate --apply` is the operator's consent, and is theirs to give \u2014 propose the command, do not assume it.",
1761
+ "5. Compile again, then commit. The migration is a reviewable commit, which is the whole reason it is a file rather than a live edit."
1762
+ ];
1763
+ };
1764
+ const slugsIn = (name) => {
1765
+ const collection = collections[name];
1766
+ if (!collection) return [];
1767
+ try {
1768
+ return readCollectionDocs2(contentDir, name, collection).map((doc) => doc.slug);
1769
+ } catch {
1770
+ return [];
1771
+ }
1772
+ };
1773
+ const fieldLines = (name) => {
1774
+ const collection = collections[name];
1775
+ if (!collection) return "(unknown collection)";
1776
+ return collection.describe().fields.map((field2) => {
1777
+ const optional = field2.optional ? " (optional)" : " (required)";
1778
+ const description = field2.description ? ` \u2014 ${field2.description}` : "";
1779
+ return `- ${field2.name}: ${field2.type}${optional}${description}`;
1780
+ }).join("\n");
1781
+ };
1782
+ const collectionArg = completable(
1783
+ z10.string().describe("Collection name"),
1784
+ (value) => fileCollections().filter((name) => name.startsWith(value))
1785
+ );
1786
+ server.registerPrompt(
1787
+ "author-document",
1788
+ {
1789
+ title: "Author a document",
1790
+ description: "Write a new document into a collection, with that collection's actual field list already filled in.",
1791
+ argsSchema: {
1792
+ collection: collectionArg,
1793
+ topic: z10.string().describe("What the document should be about")
1794
+ }
1795
+ },
1796
+ ({ collection, topic }) => say(
1797
+ [
1798
+ `Author a new document in the "${collection}" collection of this Graft project. Topic: ${topic}`,
1799
+ "",
1800
+ "Frontmatter must satisfy these fields exactly:",
1801
+ fieldLines(collection),
1802
+ "",
1803
+ "How to do it:",
1804
+ "1. Choose a kebab-case slug. It becomes the filename and the URL segment.",
1805
+ "2. Call write_content with the collection, the slug, the frontmatter as `data`, and the MDX body.",
1806
+ "3. write_content validates against the schema and compiles in one step \u2014 a schema failure comes back with a fix, so read it and correct the data rather than retrying unchanged.",
1807
+ "",
1808
+ "Body rules: prose and Markdown. Components are allowed only with literal attributes \u2014 the MDX safety gate refuses `{expressions}` and `import`, because rendering evaluates them as JavaScript on the server.",
1809
+ "",
1810
+ "Git owns the version history. If you are working in the server's checkout, commit the file afterwards; if you are remote, the checkout's operator owns the commit and you do not need to."
1811
+ ].join("\n")
1812
+ )
1813
+ );
1814
+ server.registerPrompt(
1815
+ "revise-document",
1816
+ {
1817
+ title: "Revise a document",
1818
+ description: "Edit an existing document, with its current content and its address already attached.",
1819
+ argsSchema: {
1820
+ collection: collectionArg,
1821
+ slug: completable(z10.string().describe("Document slug"), (value, context) => {
1822
+ const chosen = context?.arguments?.collection;
1823
+ const candidates = chosen ? slugsIn(chosen) : fileCollections().flatMap(slugsIn);
1824
+ return candidates.filter((slug) => slug.startsWith(value));
1825
+ }),
1826
+ goal: z10.string().describe("What should change")
1827
+ }
1828
+ },
1829
+ ({ collection, slug, goal }) => say(
1830
+ [
1831
+ `Revise "${collection}/${slug}" in this Graft project. Goal: ${goal}`,
1832
+ "",
1833
+ `Read it first: ${documentUri(branchId, collection, slug)} (or get_content).`,
1834
+ "",
1835
+ "Frontmatter fields for this collection:",
1836
+ fieldLines(collection),
1837
+ "",
1838
+ "Then call write_content with the same collection and slug. Send the full frontmatter and body, not a patch \u2014 write_content replaces the document.",
1839
+ "",
1840
+ "Change only what the goal asks for. Frontmatter keys you are not changing must come back byte-identical: the writer preserves untouched lines, and a reformatted file is a diff the author has to review for nothing.",
1841
+ "",
1842
+ "If that write comes back SLUG_NOT_UNIQUE, the document is not where write_content would put it \u2014 it is a .md file, or it lives under a different filename and claims this slug in its frontmatter. write_content always targets <collection>/<slug>.mdx, so it cannot edit that file in place. The error names the path that owns the slug; report it rather than writing a second document with the same slug."
1843
+ ].join("\n")
1844
+ )
1845
+ );
1846
+ server.registerPrompt(
1847
+ "fix-error",
1848
+ {
1849
+ title: "Recover from a Graft error",
1850
+ description: "Turn a GraftError into the next action, with this build's recovery text already resolved.",
1851
+ argsSchema: {
1852
+ code: completable(
1853
+ z10.string().describe("The error code, e.g. SCHEMA_VALIDATION_FAILED"),
1854
+ (value) => Object.keys(ERROR_KNOWLEDGE).filter((code) => code.startsWith(value.toUpperCase()))
1855
+ )
1856
+ }
1857
+ },
1858
+ ({ code }) => {
1859
+ const explanation = explainCode(code);
1860
+ if (!explanation) {
1861
+ return say(
1862
+ [
1863
+ `"${code}" is not a Graft error code.`,
1864
+ "",
1865
+ `Call explain_error with no arguments to list the ${Object.keys(ERROR_KNOWLEDGE).length} codes this build knows, or pass the full GraftError JSON as \`error\`.`,
1866
+ "If it came from another system, resolve it there \u2014 Graft has nothing to say about it."
1867
+ ].join("\n")
1868
+ );
1869
+ }
1870
+ return say(
1871
+ [
1872
+ `Recover from ${code} in this Graft project.`,
1873
+ "",
1874
+ `What it means: ${explanation.meaning}`,
1875
+ "",
1876
+ "Usually because:",
1877
+ ...explanation.typicalCauses.map((cause) => `- ${cause}`),
1878
+ "",
1879
+ `How to recover: ${explanation.howToRecover}`,
1880
+ "",
1881
+ "The failing call's own `fix` is more specific than this page and beats it wherever the two differ \u2014 this is the general lesson behind the code, that was the advice for the one failure."
1882
+ ].join("\n")
1883
+ );
1884
+ }
1885
+ );
1886
+ server.registerPrompt(
1887
+ "plan-migration",
1888
+ {
1889
+ title: "Plan a schema migration",
1890
+ description: "Change a collection's shape without breaking the documents already written against it.",
1891
+ argsSchema: {
1892
+ collection: collectionArg,
1893
+ change: z10.string().describe("The schema change, e.g. add a required `description`")
1894
+ }
1895
+ },
1896
+ ({ collection, change }) => say(
1897
+ [
1898
+ `Plan a migration for the "${collection}" collection of this Graft project. Change: ${change}`,
1899
+ "",
1900
+ "Current fields:",
1901
+ fieldLines(collection),
1902
+ "",
1903
+ ...migrationSteps(collection)
1904
+ ].join("\n")
1905
+ )
1906
+ );
1907
+ };
1908
+
1176
1909
  // src/tools/registry.ts
1177
1910
  import { describeItem, listItems, loadItem } from "@usegraft/registry";
1178
- import { z as z8 } from "zod";
1911
+ import { z as z11 } from "zod";
1179
1912
  var registerRegistryTools = (server, deps) => {
1180
1913
  const { options } = deps;
1181
1914
  server.registerTool(
1182
1915
  "list_registry",
1183
1916
  {
1184
1917
  title: "List registry items",
1918
+ outputSchema: listRegistryOutput,
1919
+ annotations: READS,
1185
1920
  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
1921
  inputSchema: {}
1187
1922
  },
@@ -1198,23 +1933,153 @@ var registerRegistryTools = (server, deps) => {
1198
1933
  "describe_item",
1199
1934
  {
1200
1935
  title: "Describe a registry item",
1936
+ outputSchema: describeItemOutput,
1937
+ annotations: READS,
1201
1938
  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.",
1202
1939
  inputSchema: {
1203
- name: z8.string().describe("Item name as returned by list_registry")
1940
+ name: z11.string().describe("Item name as returned by list_registry")
1204
1941
  }
1205
1942
  },
1206
1943
  ({ name }) => guarded(() => describeItem(loadItem(name, options.registryRoot)))
1207
1944
  );
1208
1945
  };
1209
1946
 
1947
+ // src/tools/resources.ts
1948
+ import { readFileSync as readFileSync4 } from "fs";
1949
+ import { GraftError as GraftError9 } from "@usegraft/contracts";
1950
+ import { readCollectionDocs as readCollectionDocs3, resolveContained as resolveContained3 } from "@usegraft/compiler";
1951
+ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
1952
+ var isFileAuthoritative = (authority) => authority !== "db-authoritative";
1953
+ var asString = (value) => typeof value === "string" && value !== "" ? value : void 0;
1954
+ var registerDocumentResources = (server, deps) => {
1955
+ const { branchId, collections, contentDir } = deps;
1956
+ const fileCollections = () => Object.values(collections).filter((collection) => isFileAuthoritative(collection.describe().authority)).map((collection) => collection.describe().name);
1957
+ const allDocuments = () => {
1958
+ const out = [];
1959
+ for (const name of fileCollections()) {
1960
+ try {
1961
+ for (const doc of readCollectionDocs3(contentDir, name, collections[name])) {
1962
+ out.push({
1963
+ collection: name,
1964
+ slug: doc.slug,
1965
+ sourcePath: doc.sourcePath,
1966
+ title: asString(doc.data.title),
1967
+ description: asString(doc.data.description)
1968
+ });
1969
+ }
1970
+ } catch {
1971
+ }
1972
+ }
1973
+ return out;
1974
+ };
1975
+ server.registerResource(
1976
+ "document",
1977
+ new ResourceTemplate(documentUriTemplate(branchId), {
1978
+ list: () => ({
1979
+ resources: allDocuments().map((doc) => ({
1980
+ uri: documentUri(branchId, doc.collection, doc.slug),
1981
+ name: `${doc.collection}/${doc.slug}`,
1982
+ title: doc.title ?? doc.slug,
1983
+ description: doc.description ?? `Authored MDX at ${doc.sourcePath}`,
1984
+ mimeType: "text/markdown"
1985
+ }))
1986
+ }),
1987
+ // The URI variables autocomplete from what actually exists, and the slug
1988
+ // list narrows to the collection already chosen — the context carries the
1989
+ // variables filled in so far.
1990
+ complete: {
1991
+ collection: (value) => fileCollections().filter((name) => name.startsWith(value)),
1992
+ slug: (value, context) => {
1993
+ const chosen = context?.arguments?.collection;
1994
+ return allDocuments().filter((doc) => chosen === void 0 || doc.collection === chosen).map((doc) => doc.slug).filter((slug) => slug.startsWith(value));
1995
+ }
1996
+ }
1997
+ }),
1998
+ {
1999
+ title: "Authored document",
2000
+ description: "One MDX document as authored, frontmatter and body, read from the content directory rather than the index \u2014 so it reflects the working tree, not the last compile. Attach it as context; edit it with write_content.",
2001
+ mimeType: "text/markdown"
2002
+ },
2003
+ (uri, variables) => guardedResource(() => {
2004
+ const collectionName = String(variables.collection);
2005
+ const slug = String(variables.slug);
2006
+ const collection = requireCollection(collections, collectionName);
2007
+ if (!isFileAuthoritative(collection.describe().authority)) {
2008
+ throw new GraftError9({
2009
+ code: "AUTHORITY_MISMATCH",
2010
+ message: `Collection "${collectionName}" is db-authoritative \u2014 its records live in Postgres, not as MDX files, so they have no document resource.`,
2011
+ fix: "Read these through the collection's typed functions (see list_functions), not as a resource.",
2012
+ details: { collection: collectionName, authority: collection.describe().authority }
2013
+ });
2014
+ }
2015
+ const docs = readCollectionDocs3(contentDir, collectionName, collection);
2016
+ const doc = docs.find((candidate) => candidate.slug === slug);
2017
+ if (!doc) {
2018
+ throw new GraftError9({
2019
+ code: "DOCUMENT_NOT_FOUND",
2020
+ message: `No document "${slug}" in collection "${collectionName}".`,
2021
+ fix: `Known slugs: ${docs.map((d) => d.slug).join(", ") || "(none)"}. List the document resources, or author it with write_content.`,
2022
+ details: { collection: collectionName, slug }
2023
+ });
2024
+ }
2025
+ return {
2026
+ contents: [
2027
+ {
2028
+ uri: uri.href,
2029
+ mimeType: "text/markdown",
2030
+ // Contained rather than joined: the scan that produced
2031
+ // sourcePath happily lists a symlink, and this mount can be the
2032
+ // unauthenticated docs server, where following one would serve
2033
+ // bytes from outside the content tree to anybody who asks.
2034
+ text: readFileSync4(
2035
+ resolveContained3(contentDir, doc.sourcePath, { label: "document source" }),
2036
+ "utf8"
2037
+ )
2038
+ }
2039
+ ]
2040
+ };
2041
+ })
2042
+ );
2043
+ };
2044
+ var registerSchemaResource = (server, deps) => {
2045
+ const { branchId, collections, functionsByName } = deps;
2046
+ server.registerResource(
2047
+ "schema",
2048
+ schemaUri(branchId),
2049
+ {
2050
+ title: "Project schema",
2051
+ description: "Every collection with its typed fields, and every registered function \u2014 the same payload as describe_schema. Attach it once instead of calling the tool each time.",
2052
+ mimeType: "application/json"
2053
+ },
2054
+ (uri) => {
2055
+ const description = {
2056
+ collections: Object.values(collections).map((collection) => {
2057
+ const descriptor = collection.describe();
2058
+ return { ...descriptor, fields: descriptor.fields.map(teachAssetFields) };
2059
+ }),
2060
+ functions: [...functionsByName.values()].map((fn) => fn.describe())
2061
+ };
2062
+ return {
2063
+ contents: [
2064
+ {
2065
+ uri: uri.href,
2066
+ mimeType: "application/json",
2067
+ text: JSON.stringify(description, null, 2)
2068
+ }
2069
+ ]
2070
+ };
2071
+ }
2072
+ );
2073
+ };
2074
+
1210
2075
  // src/server.ts
1211
- function createGraftMcp(options) {
2076
+ function buildServer(options, register) {
1212
2077
  const { contentDir, collections } = options;
1213
2078
  const branchId = options.branchId ?? "main";
1214
2079
  const staticIndexPath = options.db === void 0 ? options.staticIndexPath : void 0;
1215
2080
  const maybeDb = options.db;
1216
2081
  if (maybeDb === void 0 && staticIndexPath === void 0) {
1217
- throw new GraftError8({
2082
+ throw new GraftError10({
1218
2083
  code: "CONFIG_INVALID",
1219
2084
  message: "createGraftMcp needs an index: pass `db` (Postgres) or `staticIndexPath`.",
1220
2085
  fix: "Pass `db` from createDb(DATABASE_URL), or `staticIndexPath` pointing at the compiled artifact (.graft/index.db) for a static project."
@@ -1222,7 +2087,7 @@ function createGraftMcp(options) {
1222
2087
  }
1223
2088
  const requireDb = (feature, insteadDo) => {
1224
2089
  if (maybeDb !== void 0) return maybeDb;
1225
- throw new GraftError8({
2090
+ throw new GraftError10({
1226
2091
  code: "NEEDS_DATABASE",
1227
2092
  message: `${feature} needs the Postgres index; this project serves a static index (${staticIndexPath}).`,
1228
2093
  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\`.`,
@@ -1233,7 +2098,7 @@ function createGraftMcp(options) {
1233
2098
  const actor = options.connectionActor;
1234
2099
  if (actor === void 0) {
1235
2100
  if (options.actor === void 0) return;
1236
- throw new GraftError8({
2101
+ throw new GraftError10({
1237
2102
  code: "CONFIG_INVALID",
1238
2103
  message: `${tool} cannot be authorized: this server has an actor resolver but was given no connectionActor.`,
1239
2104
  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.",
@@ -1242,7 +2107,7 @@ function createGraftMcp(options) {
1242
2107
  }
1243
2108
  if (actor.kind === "anonymous") return;
1244
2109
  if ((actor.scopes ?? []).includes(scope)) return;
1245
- throw new GraftError8({
2110
+ throw new GraftError10({
1246
2111
  code: "UNAUTHORIZED",
1247
2112
  message: `${tool} requires the "${scope}" scope, and this credential does not carry it.`,
1248
2113
  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.`,
@@ -1252,7 +2117,7 @@ function createGraftMcp(options) {
1252
2117
  const requireDecider = () => {
1253
2118
  const actor = options.connectionActor;
1254
2119
  if (actor === void 0 || actor.kind === "anonymous" || !actor.id) {
1255
- throw new GraftError8({
2120
+ throw new GraftError10({
1256
2121
  code: "UNAUTHORIZED",
1257
2122
  message: "decide_approval needs to know who is deciding, and this connection is not authenticated as anyone.",
1258
2123
  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.",
@@ -1328,7 +2193,7 @@ function createGraftMcp(options) {
1328
2193
  handler: async ({ input }) => {
1329
2194
  const collection = requireCollection(collections, input.collection);
1330
2195
  const doc = findDoc(contentDir, input.collection, collection, input.slug);
1331
- unlinkSync(join3(contentDir, ...doc.sourcePath.split("/")));
2196
+ unlinkSync(resolveContained4(contentDir, doc.sourcePath, { label: "document path" }));
1332
2197
  const result = await projectContent();
1333
2198
  return {
1334
2199
  deleted: doc.sourcePath,
@@ -1367,7 +2232,7 @@ function createGraftMcp(options) {
1367
2232
  try {
1368
2233
  return createStorage(storageConfigFromEnv());
1369
2234
  } catch (error) {
1370
- throw new GraftError8({
2235
+ throw new GraftError10({
1371
2236
  code: "ENV_VAR_MISSING",
1372
2237
  message: error instanceof Error ? error.message : String(error),
1373
2238
  fix: "Set S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY, and S3_BUCKET in the MCP server's environment (.env), then retry.",
@@ -1397,21 +2262,53 @@ function createGraftMcp(options) {
1397
2262
  functionsByName,
1398
2263
  getFunctionsHandler,
1399
2264
  getDeleteHandler,
1400
- getStorage
2265
+ getStorage,
2266
+ // Absent unless the mount opted in. The default stays the out-of-band
2267
+ // flow, which is the one a remote agent with no human attached must get.
2268
+ elicitApproval: options.approvalElicitation ? createApprovalElicitor({
2269
+ server,
2270
+ db: () => requireDb(
2271
+ "approval elicitation",
2272
+ "Approvals gate destructive operations on operational data, which a static project does not have."
2273
+ ),
2274
+ decider: options.approvalElicitation.decider
2275
+ }) : void 0
1401
2276
  };
1402
- registerIntrospectionTools(server, deps);
2277
+ register(server, deps);
2278
+ return server;
2279
+ }
2280
+ var FULL_SURFACE = (server, deps) => {
2281
+ registerCollectionIntrospection(server, deps);
2282
+ registerFunctionIntrospection(server, deps);
1403
2283
  registerFunctionTools(server, deps);
1404
2284
  registerRegistryTools(server, deps);
1405
- registerContentTools(server, deps);
2285
+ registerContentReadTools(server, deps);
2286
+ registerContentWriteTools(server, deps);
1406
2287
  registerAssetTools(server, deps);
1407
2288
  registerBranchTools(server, deps);
1408
2289
  registerApprovalTools(server, deps);
1409
2290
  registerErrorTools(server, deps);
1410
- return server;
2291
+ registerPackageTools(server, deps);
2292
+ registerDocumentResources(server, deps);
2293
+ registerSchemaResource(server, deps);
2294
+ registerContentPrompts(server, deps);
2295
+ };
2296
+ var DOCS_SURFACE = (server, deps) => {
2297
+ registerCollectionIntrospection(server, deps);
2298
+ registerContentReadTools(server, deps);
2299
+ registerErrorTools(server, deps);
2300
+ registerPackageTools(server, deps);
2301
+ registerDocumentResources(server, deps);
2302
+ };
2303
+ function createGraftMcp(options) {
2304
+ return buildServer(options, FULL_SURFACE);
2305
+ }
2306
+ function createDocsMcp(options) {
2307
+ return buildServer({ ...options, functions: void 0 }, DOCS_SURFACE);
1411
2308
  }
1412
2309
 
1413
2310
  // src/http.ts
1414
- import { GraftError as GraftError9 } from "@usegraft/contracts";
2311
+ import { GraftError as GraftError11 } from "@usegraft/contracts";
1415
2312
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
1416
2313
  function jsonRpcError(status, code, message, headers) {
1417
2314
  return Response.json({ jsonrpc: "2.0", error: { code, message }, id: null }, { status, headers });
@@ -1419,12 +2316,19 @@ function jsonRpcError(status, code, message, headers) {
1419
2316
  function createGraftMcpHandler(options) {
1420
2317
  const { actor: resolveActor, allowAnonymous, ...serverOptions } = options;
1421
2318
  if (resolveActor === void 0 && allowAnonymous !== true) {
1422
- throw new GraftError9({
2319
+ throw new GraftError11({
1423
2320
  code: "CONFIG_INVALID",
1424
2321
  message: "createGraftMcpHandler was given no way to authenticate callers, and this endpoint serves content writes, asset uploads and approval decisions.",
1425
2322
  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
2323
  });
1427
2324
  }
2325
+ if (serverOptions.approvalElicitation !== void 0) {
2326
+ throw new GraftError11({
2327
+ code: "CONFIG_INVALID",
2328
+ message: "createGraftMcpHandler cannot use `approvalElicitation`: over HTTP the client being asked to approve is the agent that made the call.",
2329
+ fix: "Drop `approvalElicitation` from this handler. Approvals over HTTP go through the out-of-band path \u2014 the call returns DESTRUCTIVE_OP_REQUIRES_APPROVAL with an id, a human runs `graft approve <id>`, and the caller retries with it. Elicitation is for a stdio server (`createGraftMcp` / `graft mcp --elicit-approvals`) whose operator is at the machine."
2330
+ });
2331
+ }
1428
2332
  return async (request) => {
1429
2333
  if (request.method !== "POST") {
1430
2334
  return jsonRpcError(405, -32e3, "Method not allowed: this server is stateless (POST only)", {
@@ -1436,7 +2340,7 @@ function createGraftMcpHandler(options) {
1436
2340
  try {
1437
2341
  actor = await resolveActor(request);
1438
2342
  } catch (err) {
1439
- const message = err instanceof GraftError9 ? `${err.message} ${err.fix ?? ""}`.trim() : "Unauthorized";
2343
+ const message = err instanceof GraftError11 ? `${err.message} ${err.fix ?? ""}`.trim() : "Unauthorized";
1440
2344
  return jsonRpcError(401, -32001, message);
1441
2345
  }
1442
2346
  if (allowAnonymous !== true && actor.kind === "anonymous") {
@@ -1468,6 +2372,26 @@ function createGraftMcpHandler(options) {
1468
2372
  }
1469
2373
  };
1470
2374
  }
2375
+ function createDocsMcpHandler(options) {
2376
+ return async (request) => {
2377
+ if (request.method !== "POST") {
2378
+ return jsonRpcError(405, -32e3, "Method not allowed: this server is stateless (POST only)", {
2379
+ allow: "POST"
2380
+ });
2381
+ }
2382
+ const server = createDocsMcp(options);
2383
+ const transport = new WebStandardStreamableHTTPServerTransport({
2384
+ sessionIdGenerator: void 0,
2385
+ enableJsonResponse: true
2386
+ });
2387
+ try {
2388
+ await server.connect(transport);
2389
+ return await transport.handleRequest(request);
2390
+ } finally {
2391
+ void server.close().catch(() => void 0);
2392
+ }
2393
+ };
2394
+ }
1471
2395
 
1472
2396
  // src/index.ts
1473
2397
  async function serveStdio(server) {
@@ -1483,6 +2407,8 @@ async function serveStdio(server) {
1483
2407
  }
1484
2408
  export {
1485
2409
  ERROR_KNOWLEDGE,
2410
+ createDocsMcp,
2411
+ createDocsMcpHandler,
1486
2412
  createGraftMcp,
1487
2413
  createGraftMcpHandler,
1488
2414
  explainCode,