@usegraft/mcp 0.1.1 → 1.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/dist/index.d.ts +136 -5
- package/dist/index.js +1739 -680
- package/package.json +24 -10
package/dist/index.js
CHANGED
|
@@ -2,98 +2,34 @@
|
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
|
|
4
4
|
// src/server.ts
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
5
|
+
import { unlinkSync } from "fs";
|
|
6
|
+
import { createStorage, storageConfigFromEnv } from "@usegraft/assets";
|
|
7
|
+
import { compile, compileStatic, resolveContained as resolveContained4 } from "@usegraft/compiler";
|
|
8
|
+
import { GraftError as GraftError10 } from "@usegraft/contracts";
|
|
8
9
|
import {
|
|
9
|
-
contentTypeFor,
|
|
10
|
-
createStorage,
|
|
11
|
-
defaultKeyFor,
|
|
12
|
-
storageConfigFromEnv
|
|
13
|
-
} from "@usegraft/assets";
|
|
14
|
-
import {
|
|
15
|
-
compile,
|
|
16
|
-
compileStatic,
|
|
17
|
-
composeDocument,
|
|
18
|
-
parseDocument as parseDocument2,
|
|
19
|
-
writeDocumentFile
|
|
20
|
-
} from "@usegraft/compiler";
|
|
21
|
-
import {
|
|
22
|
-
GraftError as GraftError2
|
|
23
|
-
} from "@usegraft/contracts";
|
|
24
|
-
import {
|
|
25
|
-
APPROVAL_HEADER,
|
|
26
|
-
AssetRef,
|
|
27
10
|
createFunctionsHandler,
|
|
28
11
|
defineFunction,
|
|
29
12
|
field
|
|
30
13
|
} 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";
|
|
14
|
+
import { openStaticIndex, resolveBranchScope, searchContent } from "@usegraft/db";
|
|
43
15
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
44
|
-
import { z } from "zod";
|
|
45
16
|
|
|
46
|
-
// src/content-
|
|
47
|
-
import {
|
|
48
|
-
|
|
49
|
-
|
|
17
|
+
// src/content-hints.ts
|
|
18
|
+
import { findDoc as findDocIn, requireCollection as requireCollectionIn } from "@usegraft/compiler";
|
|
19
|
+
var MCP_HINTS = {
|
|
20
|
+
listCollections: "see list_collections",
|
|
21
|
+
authorDocument: "or author it with write_content."
|
|
22
|
+
};
|
|
23
|
+
var requireCollection = (collections, name) => requireCollectionIn(collections, name, MCP_HINTS);
|
|
24
|
+
var findDoc = (contentDir, collectionName, collection, slug) => findDocIn(contentDir, collectionName, collection, slug, MCP_HINTS);
|
|
25
|
+
|
|
26
|
+
// src/tools/approvals.ts
|
|
27
|
+
import { GraftError as GraftError2 } from "@usegraft/contracts";
|
|
28
|
+
import { decideApproval, listPendingApprovals } from "@usegraft/db";
|
|
29
|
+
import { z as z2 } from "zod";
|
|
30
|
+
|
|
31
|
+
// src/tool-result.ts
|
|
50
32
|
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
33
|
|
|
98
34
|
// src/explain.ts
|
|
99
35
|
import { ErrorCodes } from "@usegraft/contracts";
|
|
@@ -224,9 +160,9 @@ var ERROR_KNOWLEDGE = {
|
|
|
224
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.",
|
|
225
161
|
typicalCauses: [
|
|
226
162
|
"A typo in the endpoint path (e.g. /api/fns instead of /api/fn/<name>)",
|
|
227
|
-
"Expecting the frontend app's routes on the headless runtime \u2014 graft serve hosts
|
|
163
|
+
"Expecting the frontend app's routes on the headless runtime \u2014 graft serve hosts functions, MCP, authored-content reads, and health"
|
|
228
164
|
],
|
|
229
|
-
howToRecover: "Use POST /api/fn/<name> for typed functions, POST /api/mcp for the MCP Streamable HTTP surface, or GET /healthz for liveness. The error's details carry the path that missed."
|
|
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."
|
|
230
166
|
},
|
|
231
167
|
AUTHORITY_MISMATCH: {
|
|
232
168
|
code: "AUTHORITY_MISMATCH",
|
|
@@ -373,9 +309,10 @@ var ERROR_KNOWLEDGE = {
|
|
|
373
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.",
|
|
374
310
|
typicalCauses: [
|
|
375
311
|
"Calling a function marked `destructive: true` (deletes or irreversibly overwrites data)",
|
|
376
|
-
"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"
|
|
377
314
|
],
|
|
378
|
-
howToRecover: "Ask a human operator to run `graft approve <approvalId>` (they can also `graft deny` it). Once approved, retry the EXACT same call carrying the approval id \u2014 over MCP pass it as the `approval` tool argument; over raw HTTP send the `x-graft-approval: <approvalId>` header. Approvals are one-shot and bound to the exact input \u2014 never work around the gate."
|
|
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."
|
|
379
316
|
},
|
|
380
317
|
APPROVAL_INVALID: {
|
|
381
318
|
code: "APPROVAL_INVALID",
|
|
@@ -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,9 +433,15 @@ function explainCode(code) {
|
|
|
486
433
|
return code in ErrorCodes ? ERROR_KNOWLEDGE[code] : void 0;
|
|
487
434
|
}
|
|
488
435
|
|
|
489
|
-
// src/
|
|
490
|
-
|
|
491
|
-
|
|
436
|
+
// src/tool-result.ts
|
|
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;
|
|
492
445
|
}
|
|
493
446
|
function fail(error) {
|
|
494
447
|
const explanation = ERROR_KNOWLEDGE[error.code];
|
|
@@ -506,465 +459,317 @@ function fail(error) {
|
|
|
506
459
|
]
|
|
507
460
|
};
|
|
508
461
|
}
|
|
509
|
-
async function guarded(body) {
|
|
462
|
+
async function guarded(body, linksFor) {
|
|
510
463
|
try {
|
|
511
|
-
|
|
464
|
+
const payload = await body();
|
|
465
|
+
return ok(payload, linksFor?.(payload));
|
|
512
466
|
} catch (error) {
|
|
513
|
-
if (error instanceof
|
|
467
|
+
if (error instanceof GraftError) return fail(error);
|
|
514
468
|
throw error;
|
|
515
469
|
}
|
|
516
470
|
}
|
|
517
|
-
function
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
throw new
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
});
|
|
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
|
+
);
|
|
528
481
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
),
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
+
};
|
|
668
|
+
|
|
669
|
+
// src/tools/approvals.ts
|
|
670
|
+
var registerApprovalTools = (server, deps) => {
|
|
671
|
+
const { branchId, requireDb, requireDecider, requireScope } = deps;
|
|
647
672
|
server.registerTool(
|
|
648
|
-
"
|
|
673
|
+
"list_approvals",
|
|
649
674
|
{
|
|
650
|
-
title: "List
|
|
651
|
-
|
|
675
|
+
title: "List pending approvals",
|
|
676
|
+
outputSchema: listApprovalsOutput,
|
|
677
|
+
annotations: READS,
|
|
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.",
|
|
652
679
|
inputSchema: {}
|
|
653
680
|
},
|
|
654
|
-
() => guarded(() => ({
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
681
|
+
() => guarded(async () => ({
|
|
682
|
+
approvals: (await listPendingApprovals(
|
|
683
|
+
requireDb(
|
|
684
|
+
"list_approvals",
|
|
685
|
+
"Approvals gate destructive operations on operational data, which a static project does not have."
|
|
686
|
+
)
|
|
687
|
+
)).map((row) => ({
|
|
688
|
+
id: row.id,
|
|
689
|
+
branchId: row.branchId,
|
|
690
|
+
functionName: row.functionName,
|
|
691
|
+
input: row.input,
|
|
692
|
+
requestedByKind: row.requestedByKind,
|
|
693
|
+
requestedById: row.requestedById,
|
|
694
|
+
correlationId: row.correlationId,
|
|
695
|
+
createdAt: row.createdAt.toISOString()
|
|
696
|
+
}))
|
|
665
697
|
}))
|
|
666
698
|
);
|
|
667
699
|
server.registerTool(
|
|
668
|
-
"
|
|
700
|
+
"decide_approval",
|
|
669
701
|
{
|
|
670
|
-
title: "
|
|
671
|
-
|
|
672
|
-
|
|
702
|
+
title: "Approve or deny a pending approval",
|
|
703
|
+
outputSchema: decideApprovalOutput,
|
|
704
|
+
annotations: DESTROYS,
|
|
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.",
|
|
706
|
+
inputSchema: {
|
|
707
|
+
id: z2.string().describe("Pending approval id from list_approvals"),
|
|
708
|
+
decision: z2.enum(["approved", "denied"]).describe("approved or denied")
|
|
709
|
+
}
|
|
673
710
|
},
|
|
674
|
-
() => guarded(() => {
|
|
711
|
+
({ id, decision }) => guarded(async () => {
|
|
712
|
+
requireScope("decide_approval", "approvals:decide");
|
|
713
|
+
const row = await decideApproval(
|
|
714
|
+
requireDb(
|
|
715
|
+
"decide_approval",
|
|
716
|
+
"Approvals gate destructive operations on operational data, which a static project does not have."
|
|
717
|
+
),
|
|
718
|
+
id,
|
|
719
|
+
decision,
|
|
720
|
+
requireDecider()
|
|
721
|
+
);
|
|
722
|
+
if (!row) {
|
|
723
|
+
throw new GraftError2({
|
|
724
|
+
code: "APPROVAL_INVALID",
|
|
725
|
+
message: `No PENDING approval "${id}" exists \u2014 it may already be decided, consumed, or mistyped.`,
|
|
726
|
+
fix: "Call list_approvals and use a pending id.",
|
|
727
|
+
details: { id }
|
|
728
|
+
});
|
|
729
|
+
}
|
|
675
730
|
return {
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
functions: [...functionsByName.values()].map((fn) => fn.describe())
|
|
681
|
-
};
|
|
682
|
-
})
|
|
683
|
-
);
|
|
684
|
-
server.registerTool(
|
|
685
|
-
"list_functions",
|
|
686
|
-
{
|
|
687
|
-
title: "List functions",
|
|
688
|
-
description: "List every registered typed function (name, kind, public, destructive, short description). Use describe_function for the full input schema, then run_function to invoke. Mutations reject anonymous callers unless public: true; destructive functions always require human approval (graft approve).",
|
|
689
|
-
inputSchema: {}
|
|
690
|
-
},
|
|
691
|
-
() => guarded(() => ({
|
|
692
|
-
branch: branchId,
|
|
693
|
-
functions: [...functionsByName.values()].map((fn) => {
|
|
694
|
-
const d = fn.describe();
|
|
695
|
-
return {
|
|
696
|
-
name: d.name,
|
|
697
|
-
kind: d.kind,
|
|
698
|
-
description: d.description,
|
|
699
|
-
public: d.public,
|
|
700
|
-
destructive: d.destructive,
|
|
701
|
-
args: d.args.length
|
|
702
|
-
};
|
|
703
|
-
})
|
|
704
|
-
}))
|
|
705
|
-
);
|
|
706
|
-
server.registerTool(
|
|
707
|
-
"describe_function",
|
|
708
|
-
{
|
|
709
|
-
title: "Describe one function",
|
|
710
|
-
description: "Full introspection for one function: kind, args (name/type/optional/description), returns, public, destructive. Use this before run_function so the input object matches the schema.",
|
|
711
|
-
inputSchema: {
|
|
712
|
-
name: z.string().describe("Function name as returned by list_functions")
|
|
713
|
-
}
|
|
714
|
-
},
|
|
715
|
-
({ name }) => guarded(() => {
|
|
716
|
-
const fn = functionsByName.get(name);
|
|
717
|
-
if (!fn) {
|
|
718
|
-
throw new GraftError2({
|
|
719
|
-
code: "FUNCTION_NOT_FOUND",
|
|
720
|
-
message: `No function named "${name}" is registered.`,
|
|
721
|
-
fix: `Call list_functions and use one of: ${[...functionsByName.keys()].join(", ") || "(none registered)"}.`,
|
|
722
|
-
details: { requested: name, available: [...functionsByName.keys()] }
|
|
723
|
-
});
|
|
724
|
-
}
|
|
725
|
-
return fn.describe();
|
|
726
|
-
})
|
|
727
|
-
);
|
|
728
|
-
server.registerTool(
|
|
729
|
-
"run_function",
|
|
730
|
-
{
|
|
731
|
-
title: "Run a typed function",
|
|
732
|
-
description: "Invoke a defineFunction by name with a JSON input object. Same pipeline as POST /api/fn/<name>: Zod validation, access rules, rate limits, audit log, and the human gate for destructive ops. The server may already act with a configured identity (graft mcp uses GRAFT_DEV_TOKEN; over HTTP your connection's bearer is forwarded) \u2014 only pass authorization to override it. Pass approval after a human runs `graft approve <id>` for gated calls. Success returns { data, correlationId }; failures are GraftError JSON with a fix.",
|
|
733
|
-
inputSchema: {
|
|
734
|
-
name: z.string().describe("Function name (defineFunction name, not the export key)"),
|
|
735
|
-
input: z.record(z.string(), z.unknown()).optional().describe("Input fields object; defaults to {}. See describe_function for the schema."),
|
|
736
|
-
authorization: z.string().optional().describe(
|
|
737
|
-
"Bearer token override (with or without the 'Bearer ' prefix). Usually unnecessary \u2014 the server's configured identity applies when omitted."
|
|
738
|
-
),
|
|
739
|
-
approval: z.string().optional().describe(
|
|
740
|
-
"Approval id from a prior DESTRUCTIVE_OP_REQUIRES_APPROVAL response (after `graft approve <id>`)."
|
|
741
|
-
)
|
|
742
|
-
}
|
|
743
|
-
},
|
|
744
|
-
({ name, input, authorization, approval }) => guarded(async () => {
|
|
745
|
-
if (functionsByName.size === 0) {
|
|
746
|
-
throw new GraftError2({
|
|
747
|
-
code: "FUNCTION_NOT_FOUND",
|
|
748
|
-
message: "This MCP server has no functions registered.",
|
|
749
|
-
fix: "Export `functions` from graft.config.ts (defineFunction results, often via mergePrimitives) and restart the MCP server / pass them to createGraftMcp({ functions }).",
|
|
750
|
-
details: { requested: name, available: [] }
|
|
751
|
-
});
|
|
752
|
-
}
|
|
753
|
-
if (!functionsByName.has(name)) {
|
|
754
|
-
throw new GraftError2({
|
|
755
|
-
code: "FUNCTION_NOT_FOUND",
|
|
756
|
-
message: `No function named "${name}" is registered.`,
|
|
757
|
-
fix: `Call list_functions and use one of: ${[...functionsByName.keys()].join(", ")}.`,
|
|
758
|
-
details: { requested: name, available: [...functionsByName.keys()] }
|
|
759
|
-
});
|
|
760
|
-
}
|
|
761
|
-
return invokeFunction(getFunctionsHandler(), name, input ?? {}, {
|
|
762
|
-
credential: authorization ?? options.defaultAuthorization,
|
|
763
|
-
approval
|
|
764
|
-
});
|
|
765
|
-
})
|
|
766
|
-
);
|
|
767
|
-
server.registerTool(
|
|
768
|
-
"list_registry",
|
|
769
|
-
{
|
|
770
|
-
title: "List registry items",
|
|
771
|
-
description: "List every owned primitive available to `graft add` \u2014 shadcn-style copy-in blocks / fields / access rules / bundles (name, type, one-line description, and any registry items it pulls in). Use describe_item for the full details, then install with `graft add <name>` from the CLI. MCP browses what exists; the CLI installs it.",
|
|
772
|
-
inputSchema: {}
|
|
773
|
-
},
|
|
774
|
-
() => guarded(() => ({
|
|
775
|
-
items: listItems(options.registryRoot).map((item) => ({
|
|
776
|
-
name: item.name,
|
|
777
|
-
type: item.type,
|
|
778
|
-
description: item.description,
|
|
779
|
-
registryDependencies: item.registryDependencies
|
|
780
|
-
}))
|
|
781
|
-
}))
|
|
782
|
-
);
|
|
783
|
-
server.registerTool(
|
|
784
|
-
"describe_item",
|
|
785
|
-
{
|
|
786
|
-
title: "Describe a registry item",
|
|
787
|
-
description: "Full details for one owned primitive: type, description, the files it writes into the project, npm dependencies to install, the registry items it pulls in first, and whether it ships an llms.txt fragment. Use list_registry for names; install with `graft add <name>` (CLI). MCP does not install.",
|
|
788
|
-
inputSchema: {
|
|
789
|
-
name: z.string().describe("Item name as returned by list_registry")
|
|
790
|
-
}
|
|
791
|
-
},
|
|
792
|
-
({ name }) => guarded(() => describeItem(loadItem(name, options.registryRoot)))
|
|
793
|
-
);
|
|
794
|
-
server.registerTool(
|
|
795
|
-
"list_content",
|
|
796
|
-
{
|
|
797
|
-
title: "List documents in a collection",
|
|
798
|
-
description: "List every document in a collection, read from the authored MDX files (git is the source of truth). Returns slug, sourcePath, and frontmatter data.",
|
|
799
|
-
inputSchema: {
|
|
800
|
-
collection: z.string().describe("Collection name, as returned by list_collections")
|
|
801
|
-
}
|
|
802
|
-
},
|
|
803
|
-
({ collection: name }) => guarded(() => {
|
|
804
|
-
const collection = requireCollection(collections, name);
|
|
805
|
-
const docs = readCollectionDocs(contentDir, name, collection);
|
|
806
|
-
return {
|
|
807
|
-
collection: name,
|
|
808
|
-
documents: docs.map((doc) => ({
|
|
809
|
-
slug: doc.slug,
|
|
810
|
-
sourcePath: doc.sourcePath,
|
|
811
|
-
data: doc.data
|
|
812
|
-
}))
|
|
813
|
-
};
|
|
814
|
-
})
|
|
815
|
-
);
|
|
816
|
-
server.registerTool(
|
|
817
|
-
"get_content",
|
|
818
|
-
{
|
|
819
|
-
title: "Get one document",
|
|
820
|
-
description: "Read a single document by collection + slug from the authored MDX files: validated frontmatter data, MDX body, and the file path to edit.",
|
|
821
|
-
inputSchema: {
|
|
822
|
-
collection: z.string().describe("Collection name"),
|
|
823
|
-
slug: z.string().describe("Document slug (kebab-case)")
|
|
824
|
-
}
|
|
825
|
-
},
|
|
826
|
-
({ collection: name, slug }) => guarded(() => {
|
|
827
|
-
const collection = requireCollection(collections, name);
|
|
828
|
-
const doc = findDoc(contentDir, name, collection, slug);
|
|
829
|
-
return {
|
|
830
|
-
collection: name,
|
|
831
|
-
slug: doc.slug,
|
|
832
|
-
sourcePath: doc.sourcePath,
|
|
833
|
-
data: doc.data,
|
|
834
|
-
body: doc.body
|
|
835
|
-
};
|
|
836
|
-
})
|
|
837
|
-
);
|
|
838
|
-
server.registerTool(
|
|
839
|
-
"search_content",
|
|
840
|
-
{
|
|
841
|
-
title: "Full-text search across content",
|
|
842
|
-
description: 'Search authored content by words, "quoted phrases", `or`, and -exclusions (websearch syntax). Searches the branch\'s effective content in the compiled Postgres index \u2014 on a preview branch that includes documents inherited from parent branches, with branch overrides winning \u2014 so results are as fresh as the last compile (write_content compiles automatically); every hit carries the sourcePath of the file to edit. Ranking weights slug matches over frontmatter over body.',
|
|
843
|
-
inputSchema: {
|
|
844
|
-
query: z.string().describe('What to find, e.g. pricing "free tier" -enterprise'),
|
|
845
|
-
collection: z.string().optional().describe("Restrict to one collection (default: all registered collections)"),
|
|
846
|
-
limit: z.number().optional().describe("Max hits, best-ranked first (default 20)")
|
|
847
|
-
}
|
|
848
|
-
},
|
|
849
|
-
({ query, collection: name, limit }) => guarded(async () => {
|
|
850
|
-
if (name !== void 0) requireCollection(collections, name);
|
|
851
|
-
assertSearchQuery(query);
|
|
852
|
-
const collectionNames = name === void 0 ? Object.keys(collections) : [name];
|
|
853
|
-
const chain = staticIndexPath === void 0 ? scopeChain(await getScope()) : [branchId];
|
|
854
|
-
const hits = await searchIndex({ query, chain, collections: collectionNames, limit });
|
|
855
|
-
return {
|
|
856
|
-
branch: branchId,
|
|
857
|
-
chain,
|
|
858
|
-
query,
|
|
859
|
-
hits: hits.map(({ row, rank, snippet }) => ({
|
|
860
|
-
collection: row.collection,
|
|
861
|
-
slug: row.slug,
|
|
862
|
-
sourcePath: row.sourcePath,
|
|
863
|
-
rank,
|
|
864
|
-
snippet,
|
|
865
|
-
data: row.data
|
|
866
|
-
}))
|
|
867
|
-
};
|
|
868
|
-
})
|
|
869
|
-
);
|
|
870
|
-
server.registerTool(
|
|
871
|
-
"write_content",
|
|
872
|
-
{
|
|
873
|
-
title: "Write a document (create or update)",
|
|
874
|
-
description: "Author or update a document: validates the data against the collection schema, writes <contentDir>/<collection>/<slug>.mdx, and compiles the content tree into the database. Returns exactly what changed. Git is the version history: commit the file afterwards if you have the server's checkout; remote callers can't and needn't \u2014 the checkout's operator owns the commit.",
|
|
875
|
-
inputSchema: {
|
|
876
|
-
collection: z.string().describe("Collection name"),
|
|
877
|
-
slug: z.string().describe("Document slug \u2014 kebab-case; becomes the filename and the URL segment"),
|
|
878
|
-
data: z.record(z.string(), z.unknown()).describe("Frontmatter data; must satisfy the collection schema (see describe_schema)"),
|
|
879
|
-
body: z.string().optional().describe("MDX body (markdown). Defaults to empty.")
|
|
880
|
-
}
|
|
881
|
-
},
|
|
882
|
-
({ collection: name, slug, data, body }) => guarded(async () => {
|
|
883
|
-
const collection = requireCollection(collections, name);
|
|
884
|
-
if (collection.authority === "db-authoritative") {
|
|
885
|
-
throw new GraftError2({
|
|
886
|
-
code: "AUTHORITY_MISMATCH",
|
|
887
|
-
message: `Collection "${name}" is db-authoritative \u2014 its records live in Postgres, not as MDX files.`,
|
|
888
|
-
fix: `Write this data through the collection's function endpoint (POST /api/fn/<name>, see llms.txt) instead of write_content. write_content is only for file-authoritative collections.`,
|
|
889
|
-
details: { collection: name, authority: collection.authority }
|
|
890
|
-
});
|
|
891
|
-
}
|
|
892
|
-
const frontmatterSlug = data.slug;
|
|
893
|
-
if (frontmatterSlug !== void 0 && frontmatterSlug !== slug) {
|
|
894
|
-
throw new GraftError2({
|
|
895
|
-
code: "INVALID_SLUG",
|
|
896
|
-
message: `data.slug ("${String(frontmatterSlug)}") conflicts with the slug argument ("${slug}")`,
|
|
897
|
-
fix: "Omit `slug` from data \u2014 the slug argument names the file and the document.",
|
|
898
|
-
details: { slug, frontmatterSlug }
|
|
899
|
-
});
|
|
900
|
-
}
|
|
901
|
-
const sourcePath = `${name}/${slug}.mdx`;
|
|
902
|
-
const fullPath = join2(contentDir, ...sourcePath.split("/"));
|
|
903
|
-
const existingRaw = existsSync2(fullPath) ? readFileSync2(fullPath, "utf8") : void 0;
|
|
904
|
-
const raw = composeDocument(existingRaw, data, body ?? "");
|
|
905
|
-
parseDocument2(raw, collection, sourcePath);
|
|
906
|
-
assertSlugFree(contentDir, name, collection, slug, sourcePath);
|
|
907
|
-
writeDocumentFile(fullPath, raw);
|
|
908
|
-
const result = await projectContent();
|
|
909
|
-
return {
|
|
910
|
-
written: sourcePath,
|
|
911
|
-
branch: branchId,
|
|
912
|
-
gitSha: result.gitSha,
|
|
913
|
-
changes: result.changes
|
|
731
|
+
id: row.id,
|
|
732
|
+
status: row.status,
|
|
733
|
+
decidedBy: row.decidedBy,
|
|
734
|
+
functionName: row.functionName
|
|
914
735
|
};
|
|
915
736
|
})
|
|
916
737
|
);
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
},
|
|
930
|
-
({ collection: name, slug, approval }) => guarded(async () => {
|
|
931
|
-
const collection = requireCollection(collections, name);
|
|
932
|
-
if (collection.authority === "db-authoritative") {
|
|
933
|
-
throw new GraftError2({
|
|
934
|
-
code: "AUTHORITY_MISMATCH",
|
|
935
|
-
message: `Collection "${name}" is db-authoritative \u2014 its records live in Postgres, not as MDX files.`,
|
|
936
|
-
fix: "Delete records through the collection's typed functions (a destructive defineFunction over deleteRecord \u2014 see list_functions), not delete_content. delete_content is only for file-authoritative collections.",
|
|
937
|
-
details: { collection: name, authority: collection.authority }
|
|
938
|
-
});
|
|
939
|
-
}
|
|
940
|
-
findDoc(contentDir, name, collection, slug);
|
|
941
|
-
const { data, correlationId } = await invokeFunction(
|
|
942
|
-
getDeleteHandler(),
|
|
943
|
-
"delete_content",
|
|
944
|
-
{ collection: name, slug },
|
|
945
|
-
{ credential: options.defaultAuthorization, approval }
|
|
946
|
-
);
|
|
947
|
-
return { ...data, correlationId };
|
|
948
|
-
})
|
|
949
|
-
);
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
// src/tools/assets.ts
|
|
741
|
+
import { readFileSync } from "fs";
|
|
742
|
+
import { contentTypeFor, defaultKeyFor } from "@usegraft/assets";
|
|
743
|
+
import { AssetRef } from "@usegraft/core";
|
|
744
|
+
import { resolveContained } from "@usegraft/compiler";
|
|
745
|
+
import { GraftError as GraftError3 } from "@usegraft/contracts";
|
|
746
|
+
import { z as z3 } from "zod";
|
|
747
|
+
var registerAssetTools = (server, deps) => {
|
|
748
|
+
const { getStorage, options, requireScope } = deps;
|
|
749
|
+
const uploadRoot = options.localUploadRoot;
|
|
950
750
|
server.registerTool(
|
|
951
751
|
"put_asset",
|
|
952
752
|
{
|
|
953
753
|
title: "Upload an asset (image / binary)",
|
|
954
|
-
|
|
754
|
+
outputSchema: putAssetOutput,
|
|
755
|
+
annotations: DESTROYS,
|
|
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.",
|
|
955
757
|
inputSchema: {
|
|
956
|
-
key:
|
|
758
|
+
key: z3.string().optional().describe(
|
|
957
759
|
'Asset key \u2014 a lowercase path like "pages/pricing/hero.png". Required with base64; defaults to assets/<filename> with path.'
|
|
958
760
|
),
|
|
959
|
-
path:
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
761
|
+
path: z3.string().optional().describe(
|
|
762
|
+
uploadRoot ? `Path to a file inside ${uploadRoot} (local/stdio agents).` : "Not available on this server \u2014 send the bytes as `base64` instead."
|
|
763
|
+
),
|
|
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.")
|
|
963
767
|
}
|
|
964
768
|
},
|
|
965
769
|
({ key: keyArg, path, base64, contentType, overwrite }) => guarded(async () => {
|
|
770
|
+
requireScope("put_asset", "content:write");
|
|
966
771
|
if (path === void 0 === (base64 === void 0)) {
|
|
967
|
-
throw new
|
|
772
|
+
throw new GraftError3({
|
|
968
773
|
code: "INPUT_VALIDATION_FAILED",
|
|
969
774
|
message: "Pass exactly one of `path` (a file on the MCP server's machine) or `base64` (the file's bytes).",
|
|
970
775
|
fix: "Local/stdio agents: pass path. Remote/HTTP agents: read the file yourself and pass base64 + key."
|
|
@@ -972,10 +777,22 @@ function createGraftMcp(options) {
|
|
|
972
777
|
}
|
|
973
778
|
let bytes;
|
|
974
779
|
if (path !== void 0) {
|
|
780
|
+
if (uploadRoot === void 0) {
|
|
781
|
+
throw new GraftError3({
|
|
782
|
+
code: "UNAUTHORIZED",
|
|
783
|
+
message: "This server does not read files from its own disk.",
|
|
784
|
+
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.",
|
|
785
|
+
details: { tool: "put_asset" }
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
const full = resolveContained(uploadRoot, path, {
|
|
789
|
+
label: "asset source",
|
|
790
|
+
allowAbsolute: true
|
|
791
|
+
});
|
|
975
792
|
try {
|
|
976
|
-
bytes =
|
|
793
|
+
bytes = readFileSync(full);
|
|
977
794
|
} catch {
|
|
978
|
-
throw new
|
|
795
|
+
throw new GraftError3({
|
|
979
796
|
code: "DOCUMENT_NOT_FOUND",
|
|
980
797
|
message: `File not found: ${path}`,
|
|
981
798
|
fix: "Pass a path to a file that exists on the machine running this MCP server, or send the bytes as base64 instead.",
|
|
@@ -984,7 +801,7 @@ function createGraftMcp(options) {
|
|
|
984
801
|
}
|
|
985
802
|
} else {
|
|
986
803
|
if (!/^[A-Za-z0-9+/=\s]+$/.test(base64)) {
|
|
987
|
-
throw new
|
|
804
|
+
throw new GraftError3({
|
|
988
805
|
code: "INPUT_VALIDATION_FAILED",
|
|
989
806
|
message: "`base64` contains characters outside the base64 alphabet.",
|
|
990
807
|
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."
|
|
@@ -994,7 +811,7 @@ function createGraftMcp(options) {
|
|
|
994
811
|
}
|
|
995
812
|
const key = keyArg ?? (path !== void 0 ? defaultKeyFor(path) : void 0);
|
|
996
813
|
if (key === void 0) {
|
|
997
|
-
throw new
|
|
814
|
+
throw new GraftError3({
|
|
998
815
|
code: "INPUT_VALIDATION_FAILED",
|
|
999
816
|
message: "`key` is required when uploading via base64.",
|
|
1000
817
|
fix: 'Pass a lowercase path key naming the asset, e.g. "pages/pricing/hero.png".'
|
|
@@ -1002,7 +819,7 @@ function createGraftMcp(options) {
|
|
|
1002
819
|
}
|
|
1003
820
|
const keyCheck = AssetRef.shape.key.safeParse(key);
|
|
1004
821
|
if (!keyCheck.success) {
|
|
1005
|
-
throw new
|
|
822
|
+
throw new GraftError3({
|
|
1006
823
|
code: "INPUT_VALIDATION_FAILED",
|
|
1007
824
|
message: `"${key}" is not a valid asset key.`,
|
|
1008
825
|
fix: 'Use a lowercase path of letters, digits, ".", "_", "-" with "/" separators, each segment starting alphanumeric \u2014 e.g. "pages/pricing/hero.png".',
|
|
@@ -1011,7 +828,7 @@ function createGraftMcp(options) {
|
|
|
1011
828
|
}
|
|
1012
829
|
const storage = await getStorage();
|
|
1013
830
|
if (overwrite !== true && await storage.exists(key)) {
|
|
1014
|
-
throw new
|
|
831
|
+
throw new GraftError3({
|
|
1015
832
|
code: "ASSET_EXISTS",
|
|
1016
833
|
message: `Asset key "${key}" already holds a binary.`,
|
|
1017
834
|
fix: "Pick a distinct key (the store keeps no version history), or pass overwrite: true if replacing the existing binary is the actual intent.",
|
|
@@ -1031,10 +848,19 @@ function createGraftMcp(options) {
|
|
|
1031
848
|
};
|
|
1032
849
|
})
|
|
1033
850
|
);
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
// src/tools/branches.ts
|
|
854
|
+
import { listBranches, listCompilations } from "@usegraft/db";
|
|
855
|
+
import { z as z4 } from "zod";
|
|
856
|
+
var registerBranchTools = (server, deps) => {
|
|
857
|
+
const { branchId, requireDb } = deps;
|
|
1034
858
|
server.registerTool(
|
|
1035
859
|
"list_branches",
|
|
1036
860
|
{
|
|
1037
861
|
title: "List branches",
|
|
862
|
+
outputSchema: listBranchesOutput,
|
|
863
|
+
annotations: READS,
|
|
1038
864
|
description: "List registered content branches (name, parent, backend, status). Same data as GET /api/studio/v1/branches and `graft branch`.",
|
|
1039
865
|
inputSchema: {}
|
|
1040
866
|
},
|
|
@@ -1058,10 +884,12 @@ function createGraftMcp(options) {
|
|
|
1058
884
|
"list_compilations",
|
|
1059
885
|
{
|
|
1060
886
|
title: "List compilations",
|
|
887
|
+
outputSchema: listCompilationsOutput,
|
|
888
|
+
annotations: READS,
|
|
1061
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`.",
|
|
1062
890
|
inputSchema: {
|
|
1063
|
-
branch:
|
|
1064
|
-
limit:
|
|
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)")
|
|
1065
893
|
}
|
|
1066
894
|
},
|
|
1067
895
|
({ branch, limit }) => guarded(async () => ({
|
|
@@ -1086,112 +914,31 @@ function createGraftMcp(options) {
|
|
|
1086
914
|
}))
|
|
1087
915
|
}))
|
|
1088
916
|
);
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
server.registerTool(
|
|
1115
|
-
"decide_approval",
|
|
1116
|
-
{
|
|
1117
|
-
title: "Approve or deny a pending approval",
|
|
1118
|
-
description: "Record a human decision on a pending approval (same as Studio Approve/Deny and `graft approve` / `graft deny`). Requires an owner DB role that can UPDATE approvals. The requester cannot decide their own approval.",
|
|
1119
|
-
inputSchema: {
|
|
1120
|
-
id: z.string().describe("Pending approval id from list_approvals"),
|
|
1121
|
-
decision: z.enum(["approved", "denied"]).describe("approved or denied"),
|
|
1122
|
-
decidedBy: z.string().optional().describe("Operator identity stamp (defaults to mcp-operator)")
|
|
1123
|
-
}
|
|
1124
|
-
},
|
|
1125
|
-
({ id, decision, decidedBy }) => guarded(async () => {
|
|
1126
|
-
const row = await decideApproval(
|
|
1127
|
-
requireDb(
|
|
1128
|
-
"decide_approval",
|
|
1129
|
-
"Approvals gate destructive operations on operational data, which a static project does not have."
|
|
1130
|
-
),
|
|
1131
|
-
id,
|
|
1132
|
-
decision,
|
|
1133
|
-
decidedBy?.trim() || "mcp-operator"
|
|
1134
|
-
);
|
|
1135
|
-
if (!row) {
|
|
1136
|
-
throw new GraftError2({
|
|
1137
|
-
code: "APPROVAL_INVALID",
|
|
1138
|
-
message: `No PENDING approval "${id}" exists \u2014 it may already be decided, consumed, or mistyped.`,
|
|
1139
|
-
fix: "Call list_approvals and use a pending id.",
|
|
1140
|
-
details: { id }
|
|
1141
|
-
});
|
|
1142
|
-
}
|
|
1143
|
-
return {
|
|
1144
|
-
id: row.id,
|
|
1145
|
-
status: row.status,
|
|
1146
|
-
decidedBy: row.decidedBy,
|
|
1147
|
-
functionName: row.functionName
|
|
1148
|
-
};
|
|
1149
|
-
})
|
|
1150
|
-
);
|
|
1151
|
-
server.registerTool(
|
|
1152
|
-
"explain_error",
|
|
1153
|
-
{
|
|
1154
|
-
title: "Explain a Graft error",
|
|
1155
|
-
description: "Given a GraftError code or its JSON, explain what it means, its typical causes, and how to recover. Use whenever a tool call or compile fails.",
|
|
1156
|
-
inputSchema: {
|
|
1157
|
-
code: z.string().optional().describe("An error code, e.g. SCHEMA_VALIDATION_FAILED"),
|
|
1158
|
-
error: z.string().optional().describe("A full GraftError JSON string, if you have one")
|
|
1159
|
-
}
|
|
1160
|
-
},
|
|
1161
|
-
({ code, error }) => guarded(() => {
|
|
1162
|
-
let parsed;
|
|
1163
|
-
if (error) {
|
|
1164
|
-
try {
|
|
1165
|
-
parsed = JSON.parse(error);
|
|
1166
|
-
} catch {
|
|
1167
|
-
}
|
|
1168
|
-
}
|
|
1169
|
-
const effective = code ?? parsed?.error;
|
|
1170
|
-
if (!effective) {
|
|
1171
|
-
return {
|
|
1172
|
-
knownCodes: Object.keys(ERROR_KNOWLEDGE),
|
|
1173
|
-
hint: "Pass `code` or the GraftError JSON as `error`."
|
|
1174
|
-
};
|
|
1175
|
-
}
|
|
1176
|
-
const explanation = explainCode(effective);
|
|
1177
|
-
if (!explanation) {
|
|
1178
|
-
return {
|
|
1179
|
-
code: effective,
|
|
1180
|
-
known: false,
|
|
1181
|
-
knownCodes: Object.keys(ERROR_KNOWLEDGE),
|
|
1182
|
-
hint: "Not a Graft error code. If this came from another system, resolve it there."
|
|
1183
|
-
};
|
|
1184
|
-
}
|
|
1185
|
-
return {
|
|
1186
|
-
...explanation,
|
|
1187
|
-
// The specific fix from the actual error beats the general recovery advice.
|
|
1188
|
-
specificFix: parsed?.fix,
|
|
1189
|
-
message: parsed?.message
|
|
1190
|
-
};
|
|
1191
|
-
})
|
|
1192
|
-
);
|
|
1193
|
-
return server;
|
|
1194
|
-
}
|
|
917
|
+
};
|
|
918
|
+
|
|
919
|
+
// src/tools/content.ts
|
|
920
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
|
|
921
|
+
import {
|
|
922
|
+
composeDocument,
|
|
923
|
+
parseDocument as parseDocument2,
|
|
924
|
+
readCollectionDocs,
|
|
925
|
+
resolveContained as resolveContained2,
|
|
926
|
+
SLUG_RE,
|
|
927
|
+
writeDocumentFile
|
|
928
|
+
} from "@usegraft/compiler";
|
|
929
|
+
import { GraftError as GraftError5 } from "@usegraft/contracts";
|
|
930
|
+
import { assertSafeMdx } from "@usegraft/mdx-safety";
|
|
931
|
+
import { assertSearchQuery, scopeChain } from "@usegraft/db";
|
|
932
|
+
import { z as z5 } from "zod";
|
|
933
|
+
|
|
934
|
+
// src/tool-helpers.ts
|
|
935
|
+
import { existsSync, readdirSync, readFileSync as readFileSync2, statSync } from "fs";
|
|
936
|
+
import { join } from "path";
|
|
937
|
+
import { parseDocument } from "@usegraft/compiler";
|
|
938
|
+
import {
|
|
939
|
+
GraftError as GraftError4
|
|
940
|
+
} from "@usegraft/contracts";
|
|
941
|
+
import { APPROVAL_HEADER } from "@usegraft/core";
|
|
1195
942
|
async function invokeFunction(handler, name, input, identity) {
|
|
1196
943
|
const headers = new Headers({ "content-type": "application/json" });
|
|
1197
944
|
if (identity.credential) {
|
|
@@ -1217,6 +964,26 @@ async function invokeFunction(handler, name, input, identity) {
|
|
|
1217
964
|
const data = body !== null && typeof body === "object" && "data" in body ? body.data : body;
|
|
1218
965
|
return { data, correlationId, status: response.status };
|
|
1219
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
|
+
}
|
|
1220
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.";
|
|
1221
988
|
function teachAssetFields(fieldDescriptor) {
|
|
1222
989
|
const taught = {
|
|
@@ -1237,7 +1004,7 @@ function graftErrorFromBody(body, correlationId) {
|
|
|
1237
1004
|
if (body !== null && typeof body === "object") {
|
|
1238
1005
|
const json = body;
|
|
1239
1006
|
if (typeof json.error === "string" && typeof json.message === "string") {
|
|
1240
|
-
return new
|
|
1007
|
+
return new GraftError4({
|
|
1241
1008
|
code: json.error,
|
|
1242
1009
|
message: json.message,
|
|
1243
1010
|
fix: toMcpFix(json.fix),
|
|
@@ -1248,7 +1015,7 @@ function graftErrorFromBody(body, correlationId) {
|
|
|
1248
1015
|
});
|
|
1249
1016
|
}
|
|
1250
1017
|
}
|
|
1251
|
-
return new
|
|
1018
|
+
return new GraftError4({
|
|
1252
1019
|
code: "FUNCTION_EXECUTION_FAILED",
|
|
1253
1020
|
message: "Function invocation failed with a non-GraftError response.",
|
|
1254
1021
|
fix: "Inspect the server logs; retry with list_functions / describe_function to confirm the name and input shape.",
|
|
@@ -1256,23 +1023,23 @@ function graftErrorFromBody(body, correlationId) {
|
|
|
1256
1023
|
});
|
|
1257
1024
|
}
|
|
1258
1025
|
function assertSlugFree(contentDir, collectionName, collection, slug, targetSourcePath) {
|
|
1259
|
-
const dir =
|
|
1260
|
-
if (!
|
|
1261
|
-
for (const name of
|
|
1026
|
+
const dir = join(contentDir, collectionName);
|
|
1027
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return;
|
|
1028
|
+
for (const name of readdirSync(dir, { recursive: true, encoding: "utf8" })) {
|
|
1262
1029
|
const normalized = name.split("\\").join("/");
|
|
1263
1030
|
const sourcePath = `${collectionName}/${normalized}`;
|
|
1264
|
-
const full =
|
|
1265
|
-
if (sourcePath === targetSourcePath || !/\.mdx?$/.test(name) ||
|
|
1031
|
+
const full = join(dir, name);
|
|
1032
|
+
if (sourcePath === targetSourcePath || !/\.mdx?$/.test(name) || statSync(full).isDirectory()) {
|
|
1266
1033
|
continue;
|
|
1267
1034
|
}
|
|
1268
1035
|
let existingSlug;
|
|
1269
1036
|
try {
|
|
1270
|
-
existingSlug =
|
|
1037
|
+
existingSlug = parseDocument(readFileSync2(full, "utf8"), collection, sourcePath).slug;
|
|
1271
1038
|
} catch {
|
|
1272
1039
|
continue;
|
|
1273
1040
|
}
|
|
1274
1041
|
if (existingSlug === slug) {
|
|
1275
|
-
throw new
|
|
1042
|
+
throw new GraftError4({
|
|
1276
1043
|
code: "SLUG_NOT_UNIQUE",
|
|
1277
1044
|
message: `Slug "${slug}" in collection "${collectionName}" is already used by ${sourcePath}`,
|
|
1278
1045
|
fix: `Update that document instead (write_content with slug "${slug}" targets ${targetSourcePath}, but ${sourcePath} owns the slug via frontmatter), or pick a different slug.`,
|
|
@@ -1282,47 +1049,1337 @@ function assertSlugFree(contentDir, collectionName, collection, slug, targetSour
|
|
|
1282
1049
|
}
|
|
1283
1050
|
}
|
|
1284
1051
|
|
|
1285
|
-
// src/
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
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
|
+
|
|
1063
|
+
// src/tools/content.ts
|
|
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
|
+
});
|
|
1290
1072
|
}
|
|
1291
|
-
|
|
1292
|
-
const {
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
actor = await resolveActor(request);
|
|
1303
|
-
} catch (err) {
|
|
1304
|
-
const message = err instanceof GraftError3 ? `${err.message} ${err.fix ?? ""}`.trim() : "Unauthorized";
|
|
1305
|
-
return jsonRpcError(401, -32001, message);
|
|
1073
|
+
var registerContentReadTools = (server, deps) => {
|
|
1074
|
+
const { branchId, collections, contentDir, getScope, searchIndex, staticIndexPath } = deps;
|
|
1075
|
+
server.registerTool(
|
|
1076
|
+
"list_content",
|
|
1077
|
+
{
|
|
1078
|
+
title: "List documents in a collection",
|
|
1079
|
+
outputSchema: listContentOutput,
|
|
1080
|
+
annotations: READS,
|
|
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.",
|
|
1082
|
+
inputSchema: {
|
|
1083
|
+
collection: z5.string().describe("Collection name, as returned by list_collections")
|
|
1306
1084
|
}
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1085
|
+
},
|
|
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
|
+
)
|
|
1101
|
+
);
|
|
1102
|
+
server.registerTool(
|
|
1103
|
+
"get_content",
|
|
1104
|
+
{
|
|
1105
|
+
title: "Get one document",
|
|
1106
|
+
outputSchema: getContentOutput,
|
|
1107
|
+
annotations: READS,
|
|
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.",
|
|
1109
|
+
inputSchema: {
|
|
1110
|
+
collection: z5.string().describe("Collection name"),
|
|
1111
|
+
slug: z5.string().describe("Document slug (kebab-case)")
|
|
1112
|
+
}
|
|
1113
|
+
},
|
|
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
|
+
)
|
|
1130
|
+
);
|
|
1131
|
+
server.registerTool(
|
|
1132
|
+
"search_content",
|
|
1133
|
+
{
|
|
1134
|
+
title: "Full-text search across content",
|
|
1135
|
+
outputSchema: searchContentOutput,
|
|
1136
|
+
annotations: READS,
|
|
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.',
|
|
1138
|
+
inputSchema: {
|
|
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)")
|
|
1313
1142
|
}
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1143
|
+
},
|
|
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
|
+
)
|
|
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;
|
|
1183
|
+
server.registerTool(
|
|
1184
|
+
"write_content",
|
|
1185
|
+
{
|
|
1186
|
+
title: "Write a document (create or update)",
|
|
1187
|
+
outputSchema: writeContentOutput,
|
|
1188
|
+
annotations: WRITES,
|
|
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.",
|
|
1190
|
+
inputSchema: {
|
|
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.")
|
|
1195
|
+
}
|
|
1196
|
+
},
|
|
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"
|
|
1222
|
+
});
|
|
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
|
+
)
|
|
1240
|
+
);
|
|
1241
|
+
server.registerTool(
|
|
1242
|
+
"delete_content",
|
|
1243
|
+
{
|
|
1244
|
+
title: "Delete a document (human-gated)",
|
|
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.",
|
|
1247
|
+
inputSchema: {
|
|
1248
|
+
collection: z5.string().describe("Collection name"),
|
|
1249
|
+
slug: z5.string().describe("Document slug to delete"),
|
|
1250
|
+
approval: z5.string().optional().describe(
|
|
1251
|
+
"Approval id from a prior DESTRUCTIVE_OP_REQUIRES_APPROVAL response, after a human ran `graft approve <id>`."
|
|
1252
|
+
)
|
|
1253
|
+
}
|
|
1254
|
+
},
|
|
1255
|
+
({ collection: name, slug, approval }) => guarded(async () => {
|
|
1256
|
+
requireScope("delete_content", "content:write");
|
|
1257
|
+
const collection = requireCollection(collections, name);
|
|
1258
|
+
if (collection.authority === "db-authoritative") {
|
|
1259
|
+
throw new GraftError5({
|
|
1260
|
+
code: "AUTHORITY_MISMATCH",
|
|
1261
|
+
message: `Collection "${name}" is db-authoritative \u2014 its records live in Postgres, not as MDX files.`,
|
|
1262
|
+
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.",
|
|
1263
|
+
details: { collection: name, authority: collection.authority }
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
assertSlugShape(slug, name);
|
|
1267
|
+
findDoc(contentDir, name, collection, slug);
|
|
1268
|
+
const { data, correlationId } = await invokeFunctionWithApproval(
|
|
1269
|
+
getDeleteHandler(),
|
|
1270
|
+
"delete_content",
|
|
1271
|
+
{ collection: name, slug },
|
|
1272
|
+
{ credential: options.defaultAuthorization, approval },
|
|
1273
|
+
elicitApproval
|
|
1319
1274
|
);
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1275
|
+
return { ...data, correlationId };
|
|
1276
|
+
})
|
|
1277
|
+
);
|
|
1278
|
+
};
|
|
1279
|
+
|
|
1280
|
+
// src/tools/errors.ts
|
|
1281
|
+
import { z as z6 } from "zod";
|
|
1282
|
+
var registerErrorTools = (server, deps) => {
|
|
1283
|
+
void deps;
|
|
1284
|
+
server.registerTool(
|
|
1285
|
+
"explain_error",
|
|
1286
|
+
{
|
|
1287
|
+
title: "Explain a Graft error",
|
|
1288
|
+
outputSchema: explainErrorOutput,
|
|
1289
|
+
annotations: READS,
|
|
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.",
|
|
1291
|
+
inputSchema: {
|
|
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")
|
|
1294
|
+
}
|
|
1295
|
+
},
|
|
1296
|
+
({ code, error }) => guarded(() => {
|
|
1297
|
+
let parsed;
|
|
1298
|
+
if (error) {
|
|
1299
|
+
try {
|
|
1300
|
+
parsed = JSON.parse(error);
|
|
1301
|
+
} catch {
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
const effective = code ?? parsed?.error;
|
|
1305
|
+
if (!effective) {
|
|
1306
|
+
return {
|
|
1307
|
+
knownCodes: Object.keys(ERROR_KNOWLEDGE),
|
|
1308
|
+
hint: "Pass `code` or the GraftError JSON as `error`."
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
const explanation = explainCode(effective);
|
|
1312
|
+
if (!explanation) {
|
|
1313
|
+
return {
|
|
1314
|
+
code: effective,
|
|
1315
|
+
known: false,
|
|
1316
|
+
knownCodes: Object.keys(ERROR_KNOWLEDGE),
|
|
1317
|
+
hint: "Not a Graft error code. If this came from another system, resolve it there."
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
return {
|
|
1321
|
+
...explanation,
|
|
1322
|
+
// The specific fix from the actual error beats the general recovery advice.
|
|
1323
|
+
specificFix: parsed?.fix,
|
|
1324
|
+
message: parsed?.message
|
|
1325
|
+
};
|
|
1326
|
+
})
|
|
1327
|
+
);
|
|
1328
|
+
};
|
|
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
|
+
|
|
1521
|
+
// src/tools/functions.ts
|
|
1522
|
+
import { GraftError as GraftError6 } from "@usegraft/contracts";
|
|
1523
|
+
import { z as z8 } from "zod";
|
|
1524
|
+
var registerFunctionTools = (server, deps) => {
|
|
1525
|
+
const { elicitApproval, functions, functionsByName, getFunctionsHandler, options } = deps;
|
|
1526
|
+
server.registerTool(
|
|
1527
|
+
"run_function",
|
|
1528
|
+
{
|
|
1529
|
+
title: "Run a typed function",
|
|
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.",
|
|
1532
|
+
inputSchema: {
|
|
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(
|
|
1536
|
+
"Bearer token override (with or without the 'Bearer ' prefix). Usually unnecessary \u2014 the server's configured identity applies when omitted."
|
|
1537
|
+
),
|
|
1538
|
+
approval: z8.string().optional().describe(
|
|
1539
|
+
"Approval id from a prior DESTRUCTIVE_OP_REQUIRES_APPROVAL response (after `graft approve <id>`)."
|
|
1540
|
+
)
|
|
1541
|
+
}
|
|
1542
|
+
},
|
|
1543
|
+
({ name, input, authorization, approval }) => guarded(async () => {
|
|
1544
|
+
if (functionsByName.size === 0) {
|
|
1545
|
+
throw new GraftError6({
|
|
1546
|
+
code: "FUNCTION_NOT_FOUND",
|
|
1547
|
+
message: "This MCP server has no functions registered.",
|
|
1548
|
+
fix: "Export `functions` from graft.config.ts (defineFunction results, often via mergePrimitives) and restart the MCP server / pass them to createGraftMcp({ functions }).",
|
|
1549
|
+
details: { requested: name, available: [] }
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
if (!functionsByName.has(name)) {
|
|
1553
|
+
throw new GraftError6({
|
|
1554
|
+
code: "FUNCTION_NOT_FOUND",
|
|
1555
|
+
message: `No function named "${name}" is registered.`,
|
|
1556
|
+
fix: `Call list_functions and use one of: ${[...functionsByName.keys()].join(", ")}.`,
|
|
1557
|
+
details: { requested: name, available: [...functionsByName.keys()] }
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
return invokeFunctionWithApproval(
|
|
1561
|
+
getFunctionsHandler(),
|
|
1562
|
+
name,
|
|
1563
|
+
input ?? {},
|
|
1564
|
+
{ credential: authorization ?? options.defaultAuthorization, approval },
|
|
1565
|
+
elicitApproval
|
|
1566
|
+
);
|
|
1567
|
+
})
|
|
1568
|
+
);
|
|
1569
|
+
};
|
|
1570
|
+
|
|
1571
|
+
// src/tools/introspection.ts
|
|
1572
|
+
import { GraftError as GraftError7 } from "@usegraft/contracts";
|
|
1573
|
+
import { z as z9 } from "zod";
|
|
1574
|
+
var registerCollectionIntrospection = (server, deps) => {
|
|
1575
|
+
const { branchId, collections } = deps;
|
|
1576
|
+
server.registerTool(
|
|
1577
|
+
"list_collections",
|
|
1578
|
+
{
|
|
1579
|
+
title: "List collections",
|
|
1580
|
+
outputSchema: listCollectionsOutput,
|
|
1581
|
+
annotations: READS,
|
|
1582
|
+
description: "List every registered content collection (name, description, authority, field count). Start here to learn what kinds of content this project has.",
|
|
1583
|
+
inputSchema: {}
|
|
1584
|
+
},
|
|
1585
|
+
() => guarded(() => ({
|
|
1586
|
+
branch: branchId,
|
|
1587
|
+
collections: Object.values(collections).map((collection) => {
|
|
1588
|
+
const descriptor = collection.describe();
|
|
1589
|
+
return {
|
|
1590
|
+
name: descriptor.name,
|
|
1591
|
+
description: descriptor.description,
|
|
1592
|
+
authority: descriptor.authority,
|
|
1593
|
+
fields: descriptor.fields.length
|
|
1594
|
+
};
|
|
1595
|
+
})
|
|
1596
|
+
}))
|
|
1597
|
+
);
|
|
1598
|
+
};
|
|
1599
|
+
var registerFunctionIntrospection = (server, deps) => {
|
|
1600
|
+
const { branchId, collections, functions, functionsByName } = deps;
|
|
1601
|
+
server.registerTool(
|
|
1602
|
+
"describe_schema",
|
|
1603
|
+
{
|
|
1604
|
+
title: "Describe the content schema",
|
|
1605
|
+
outputSchema: describeSchemaOutput,
|
|
1606
|
+
annotations: READS,
|
|
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.",
|
|
1608
|
+
inputSchema: {}
|
|
1609
|
+
},
|
|
1610
|
+
() => guarded(() => {
|
|
1611
|
+
return {
|
|
1612
|
+
collections: Object.values(collections).map((collection) => {
|
|
1613
|
+
const descriptor = collection.describe();
|
|
1614
|
+
return { ...descriptor, fields: descriptor.fields.map(teachAssetFields) };
|
|
1615
|
+
}),
|
|
1616
|
+
functions: [...functionsByName.values()].map((fn) => fn.describe())
|
|
1617
|
+
};
|
|
1618
|
+
})
|
|
1619
|
+
);
|
|
1620
|
+
server.registerTool(
|
|
1621
|
+
"list_functions",
|
|
1622
|
+
{
|
|
1623
|
+
title: "List functions",
|
|
1624
|
+
outputSchema: listFunctionsOutput,
|
|
1625
|
+
annotations: READS,
|
|
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).",
|
|
1627
|
+
inputSchema: {}
|
|
1628
|
+
},
|
|
1629
|
+
() => guarded(() => ({
|
|
1630
|
+
branch: branchId,
|
|
1631
|
+
functions: [...functionsByName.values()].map((fn) => {
|
|
1632
|
+
const d = fn.describe();
|
|
1633
|
+
return {
|
|
1634
|
+
name: d.name,
|
|
1635
|
+
kind: d.kind,
|
|
1636
|
+
description: d.description,
|
|
1637
|
+
public: d.public,
|
|
1638
|
+
destructive: d.destructive,
|
|
1639
|
+
args: d.args.length
|
|
1640
|
+
};
|
|
1641
|
+
})
|
|
1642
|
+
}))
|
|
1643
|
+
);
|
|
1644
|
+
server.registerTool(
|
|
1645
|
+
"describe_function",
|
|
1646
|
+
{
|
|
1647
|
+
title: "Describe one function",
|
|
1648
|
+
outputSchema: describeFunctionOutput,
|
|
1649
|
+
annotations: READS,
|
|
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.",
|
|
1651
|
+
inputSchema: {
|
|
1652
|
+
name: z9.string().describe("Function name as returned by list_functions")
|
|
1653
|
+
}
|
|
1654
|
+
},
|
|
1655
|
+
({ name }) => guarded(() => {
|
|
1656
|
+
const fn = functionsByName.get(name);
|
|
1657
|
+
if (!fn) {
|
|
1658
|
+
throw new GraftError7({
|
|
1659
|
+
code: "FUNCTION_NOT_FOUND",
|
|
1660
|
+
message: `No function named "${name}" is registered.`,
|
|
1661
|
+
fix: `Call list_functions and use one of: ${[...functionsByName.keys()].join(", ") || "(none registered)"}.`,
|
|
1662
|
+
details: { requested: name, available: [...functionsByName.keys()] }
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
return fn.describe();
|
|
1666
|
+
})
|
|
1667
|
+
);
|
|
1668
|
+
};
|
|
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
|
+
|
|
1909
|
+
// src/tools/registry.ts
|
|
1910
|
+
import { describeItem, listItems, loadItem } from "@usegraft/registry";
|
|
1911
|
+
import { z as z11 } from "zod";
|
|
1912
|
+
var registerRegistryTools = (server, deps) => {
|
|
1913
|
+
const { options } = deps;
|
|
1914
|
+
server.registerTool(
|
|
1915
|
+
"list_registry",
|
|
1916
|
+
{
|
|
1917
|
+
title: "List registry items",
|
|
1918
|
+
outputSchema: listRegistryOutput,
|
|
1919
|
+
annotations: READS,
|
|
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.",
|
|
1921
|
+
inputSchema: {}
|
|
1922
|
+
},
|
|
1923
|
+
() => guarded(() => ({
|
|
1924
|
+
items: listItems(options.registryRoot).map((item) => ({
|
|
1925
|
+
name: item.name,
|
|
1926
|
+
type: item.type,
|
|
1927
|
+
description: item.description,
|
|
1928
|
+
registryDependencies: item.registryDependencies
|
|
1929
|
+
}))
|
|
1930
|
+
}))
|
|
1931
|
+
);
|
|
1932
|
+
server.registerTool(
|
|
1933
|
+
"describe_item",
|
|
1934
|
+
{
|
|
1935
|
+
title: "Describe a registry item",
|
|
1936
|
+
outputSchema: describeItemOutput,
|
|
1937
|
+
annotations: READS,
|
|
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.",
|
|
1939
|
+
inputSchema: {
|
|
1940
|
+
name: z11.string().describe("Item name as returned by list_registry")
|
|
1941
|
+
}
|
|
1942
|
+
},
|
|
1943
|
+
({ name }) => guarded(() => describeItem(loadItem(name, options.registryRoot)))
|
|
1944
|
+
);
|
|
1945
|
+
};
|
|
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
|
+
|
|
2075
|
+
// src/server.ts
|
|
2076
|
+
function buildServer(options, register) {
|
|
2077
|
+
const { contentDir, collections } = options;
|
|
2078
|
+
const branchId = options.branchId ?? "main";
|
|
2079
|
+
const staticIndexPath = options.db === void 0 ? options.staticIndexPath : void 0;
|
|
2080
|
+
const maybeDb = options.db;
|
|
2081
|
+
if (maybeDb === void 0 && staticIndexPath === void 0) {
|
|
2082
|
+
throw new GraftError10({
|
|
2083
|
+
code: "CONFIG_INVALID",
|
|
2084
|
+
message: "createGraftMcp needs an index: pass `db` (Postgres) or `staticIndexPath`.",
|
|
2085
|
+
fix: "Pass `db` from createDb(DATABASE_URL), or `staticIndexPath` pointing at the compiled artifact (.graft/index.db) for a static project."
|
|
2086
|
+
});
|
|
2087
|
+
}
|
|
2088
|
+
const requireDb = (feature, insteadDo) => {
|
|
2089
|
+
if (maybeDb !== void 0) return maybeDb;
|
|
2090
|
+
throw new GraftError10({
|
|
2091
|
+
code: "NEEDS_DATABASE",
|
|
2092
|
+
message: `${feature} needs the Postgres index; this project serves a static index (${staticIndexPath}).`,
|
|
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\`.`,
|
|
2094
|
+
details: { feature, index: "static" }
|
|
2095
|
+
});
|
|
2096
|
+
};
|
|
2097
|
+
const requireScope = (tool, scope) => {
|
|
2098
|
+
const actor = options.connectionActor;
|
|
2099
|
+
if (actor === void 0) {
|
|
2100
|
+
if (options.actor === void 0) return;
|
|
2101
|
+
throw new GraftError10({
|
|
2102
|
+
code: "CONFIG_INVALID",
|
|
2103
|
+
message: `${tool} cannot be authorized: this server has an actor resolver but was given no connectionActor.`,
|
|
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.",
|
|
2105
|
+
details: { tool, required: scope }
|
|
2106
|
+
});
|
|
2107
|
+
}
|
|
2108
|
+
if (actor.kind === "anonymous") return;
|
|
2109
|
+
if ((actor.scopes ?? []).includes(scope)) return;
|
|
2110
|
+
throw new GraftError10({
|
|
2111
|
+
code: "UNAUTHORIZED",
|
|
2112
|
+
message: `${tool} requires the "${scope}" scope, and this credential does not carry it.`,
|
|
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.`,
|
|
2114
|
+
details: { tool, required: scope, held: actor.scopes ?? [] }
|
|
2115
|
+
});
|
|
2116
|
+
};
|
|
2117
|
+
const requireDecider = () => {
|
|
2118
|
+
const actor = options.connectionActor;
|
|
2119
|
+
if (actor === void 0 || actor.kind === "anonymous" || !actor.id) {
|
|
2120
|
+
throw new GraftError10({
|
|
2121
|
+
code: "UNAUTHORIZED",
|
|
2122
|
+
message: "decide_approval needs to know who is deciding, and this connection is not authenticated as anyone.",
|
|
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.",
|
|
2124
|
+
details: { tool: "decide_approval", actor: actor?.kind ?? "anonymous" }
|
|
2125
|
+
});
|
|
2126
|
+
}
|
|
2127
|
+
return { kind: actor.kind, id: actor.id };
|
|
2128
|
+
};
|
|
2129
|
+
const projectContent = async () => staticIndexPath === void 0 ? compile({
|
|
2130
|
+
contentDir,
|
|
2131
|
+
collections,
|
|
2132
|
+
db: requireDb("compile", ""),
|
|
2133
|
+
branchId,
|
|
2134
|
+
mdxTrust: options.mdxTrust
|
|
2135
|
+
}) : compileStatic({
|
|
2136
|
+
contentDir,
|
|
2137
|
+
collections,
|
|
2138
|
+
indexPath: staticIndexPath,
|
|
2139
|
+
mdxTrust: options.mdxTrust
|
|
2140
|
+
});
|
|
2141
|
+
const searchIndex = async (query) => {
|
|
2142
|
+
if (staticIndexPath === void 0) {
|
|
2143
|
+
return searchContent(requireDb("search_content", ""), query);
|
|
2144
|
+
}
|
|
2145
|
+
const index = await openStaticIndex(staticIndexPath);
|
|
2146
|
+
try {
|
|
2147
|
+
return await index.searchContent({
|
|
2148
|
+
query: query.query,
|
|
2149
|
+
collections: query.collections,
|
|
2150
|
+
limit: query.limit
|
|
2151
|
+
});
|
|
2152
|
+
} finally {
|
|
2153
|
+
await index.close();
|
|
2154
|
+
}
|
|
2155
|
+
};
|
|
2156
|
+
const functions = options.functions ?? {};
|
|
2157
|
+
const functionsByName = /* @__PURE__ */ new Map();
|
|
2158
|
+
for (const fn of Object.values(functions)) functionsByName.set(fn.name, fn);
|
|
2159
|
+
let scopePromise;
|
|
2160
|
+
const getScope = () => {
|
|
2161
|
+
scopePromise ??= options.scope ? Promise.resolve(options.scope) : resolveBranchScope(requireDb("Branch scope resolution", ""), branchId);
|
|
2162
|
+
return scopePromise;
|
|
2163
|
+
};
|
|
2164
|
+
let functionsHandler;
|
|
2165
|
+
const getFunctionsHandler = () => {
|
|
2166
|
+
functionsHandler ??= createFunctionsHandler({
|
|
2167
|
+
functions,
|
|
2168
|
+
db: requireDb(
|
|
2169
|
+
"run_function",
|
|
2170
|
+
"Typed functions read and write operational data in Postgres, so a static project has none."
|
|
2171
|
+
),
|
|
2172
|
+
branch: branchId,
|
|
2173
|
+
actor: options.actor,
|
|
2174
|
+
approvalPolicy: options.approvalPolicy,
|
|
2175
|
+
rateLimit: options.rateLimit,
|
|
2176
|
+
gitSha: options.gitSha,
|
|
2177
|
+
audit: options.audit,
|
|
2178
|
+
approvals: options.approvals
|
|
2179
|
+
});
|
|
2180
|
+
return functionsHandler;
|
|
2181
|
+
};
|
|
2182
|
+
const deleteContentFn = defineFunction({
|
|
2183
|
+
name: "delete_content",
|
|
2184
|
+
kind: "mutation",
|
|
2185
|
+
destructive: true,
|
|
2186
|
+
public: true,
|
|
2187
|
+
description: "Delete an authored MDX document and recompile (MCP delete_content tool).",
|
|
2188
|
+
returns: "{ deleted, branch, gitSha, changes }",
|
|
2189
|
+
input: {
|
|
2190
|
+
collection: field.string({ description: "Collection name" }),
|
|
2191
|
+
slug: field.string({ description: "Document slug to delete" })
|
|
2192
|
+
},
|
|
2193
|
+
handler: async ({ input }) => {
|
|
2194
|
+
const collection = requireCollection(collections, input.collection);
|
|
2195
|
+
const doc = findDoc(contentDir, input.collection, collection, input.slug);
|
|
2196
|
+
unlinkSync(resolveContained4(contentDir, doc.sourcePath, { label: "document path" }));
|
|
2197
|
+
const result = await projectContent();
|
|
2198
|
+
return {
|
|
2199
|
+
deleted: doc.sourcePath,
|
|
2200
|
+
branch: branchId,
|
|
2201
|
+
gitSha: result.gitSha,
|
|
2202
|
+
changes: result.changes
|
|
2203
|
+
};
|
|
2204
|
+
}
|
|
2205
|
+
});
|
|
2206
|
+
let deleteHandler;
|
|
2207
|
+
const getDeleteHandler = () => {
|
|
2208
|
+
deleteHandler ??= createFunctionsHandler({
|
|
2209
|
+
// The one-shot, input-bound human approval lives in Postgres. Rather than
|
|
2210
|
+
// silently downgrading to an ungated delete, a static project is told to
|
|
2211
|
+
// do it the way git already makes safe: delete the file and recompile.
|
|
2212
|
+
db: requireDb(
|
|
2213
|
+
"delete_content",
|
|
2214
|
+
"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."
|
|
2215
|
+
),
|
|
2216
|
+
functions: { delete_content: deleteContentFn },
|
|
2217
|
+
branch: branchId,
|
|
2218
|
+
actor: options.actor,
|
|
2219
|
+
rateLimit: options.rateLimit,
|
|
2220
|
+
gitSha: options.gitSha,
|
|
2221
|
+
audit: options.audit,
|
|
2222
|
+
approvals: options.approvals
|
|
2223
|
+
});
|
|
2224
|
+
return deleteHandler;
|
|
2225
|
+
};
|
|
2226
|
+
let storagePromise;
|
|
2227
|
+
const getStorage = () => {
|
|
2228
|
+
storagePromise ??= (async () => {
|
|
2229
|
+
if (options.storage) {
|
|
2230
|
+
return typeof options.storage === "function" ? options.storage() : options.storage;
|
|
2231
|
+
}
|
|
2232
|
+
try {
|
|
2233
|
+
return createStorage(storageConfigFromEnv());
|
|
2234
|
+
} catch (error) {
|
|
2235
|
+
throw new GraftError10({
|
|
2236
|
+
code: "ENV_VAR_MISSING",
|
|
2237
|
+
message: error instanceof Error ? error.message : String(error),
|
|
2238
|
+
fix: "Set S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY, and S3_BUCKET in the MCP server's environment (.env), then retry.",
|
|
2239
|
+
details: { variables: ["S3_ENDPOINT", "S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_BUCKET"] }
|
|
2240
|
+
});
|
|
2241
|
+
}
|
|
2242
|
+
})();
|
|
2243
|
+
return storagePromise;
|
|
2244
|
+
};
|
|
2245
|
+
const server = new McpServer({
|
|
2246
|
+
name: options.name ?? "graft",
|
|
2247
|
+
version: options.version ?? "0.0.0"
|
|
2248
|
+
});
|
|
2249
|
+
const deps = {
|
|
2250
|
+
options,
|
|
2251
|
+
contentDir,
|
|
2252
|
+
collections,
|
|
2253
|
+
branchId,
|
|
2254
|
+
staticIndexPath,
|
|
2255
|
+
requireDb,
|
|
2256
|
+
requireScope,
|
|
2257
|
+
requireDecider,
|
|
2258
|
+
projectContent,
|
|
2259
|
+
searchIndex,
|
|
2260
|
+
getScope,
|
|
2261
|
+
functions,
|
|
2262
|
+
functionsByName,
|
|
2263
|
+
getFunctionsHandler,
|
|
2264
|
+
getDeleteHandler,
|
|
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
|
|
2276
|
+
};
|
|
2277
|
+
register(server, deps);
|
|
2278
|
+
return server;
|
|
2279
|
+
}
|
|
2280
|
+
var FULL_SURFACE = (server, deps) => {
|
|
2281
|
+
registerCollectionIntrospection(server, deps);
|
|
2282
|
+
registerFunctionIntrospection(server, deps);
|
|
2283
|
+
registerFunctionTools(server, deps);
|
|
2284
|
+
registerRegistryTools(server, deps);
|
|
2285
|
+
registerContentReadTools(server, deps);
|
|
2286
|
+
registerContentWriteTools(server, deps);
|
|
2287
|
+
registerAssetTools(server, deps);
|
|
2288
|
+
registerBranchTools(server, deps);
|
|
2289
|
+
registerApprovalTools(server, deps);
|
|
2290
|
+
registerErrorTools(server, deps);
|
|
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);
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
// src/http.ts
|
|
2311
|
+
import { GraftError as GraftError11 } from "@usegraft/contracts";
|
|
2312
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
2313
|
+
function jsonRpcError(status, code, message, headers) {
|
|
2314
|
+
return Response.json({ jsonrpc: "2.0", error: { code, message }, id: null }, { status, headers });
|
|
2315
|
+
}
|
|
2316
|
+
function createGraftMcpHandler(options) {
|
|
2317
|
+
const { actor: resolveActor, allowAnonymous, ...serverOptions } = options;
|
|
2318
|
+
if (resolveActor === void 0 && allowAnonymous !== true) {
|
|
2319
|
+
throw new GraftError11({
|
|
2320
|
+
code: "CONFIG_INVALID",
|
|
2321
|
+
message: "createGraftMcpHandler was given no way to authenticate callers, and this endpoint serves content writes, asset uploads and approval decisions.",
|
|
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."
|
|
2323
|
+
});
|
|
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
|
+
}
|
|
2332
|
+
return async (request) => {
|
|
2333
|
+
if (request.method !== "POST") {
|
|
2334
|
+
return jsonRpcError(405, -32e3, "Method not allowed: this server is stateless (POST only)", {
|
|
2335
|
+
allow: "POST"
|
|
2336
|
+
});
|
|
2337
|
+
}
|
|
2338
|
+
let actor;
|
|
2339
|
+
if (resolveActor) {
|
|
2340
|
+
try {
|
|
2341
|
+
actor = await resolveActor(request);
|
|
2342
|
+
} catch (err) {
|
|
2343
|
+
const message = err instanceof GraftError11 ? `${err.message} ${err.fix ?? ""}`.trim() : "Unauthorized";
|
|
2344
|
+
return jsonRpcError(401, -32001, message);
|
|
2345
|
+
}
|
|
2346
|
+
if (allowAnonymous !== true && actor.kind === "anonymous") {
|
|
2347
|
+
return jsonRpcError(
|
|
2348
|
+
401,
|
|
2349
|
+
-32001,
|
|
2350
|
+
"Unauthorized: this MCP endpoint requires authentication. Send `Authorization: Bearer <token>` from a trusted issuer."
|
|
2351
|
+
);
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
const server = createGraftMcp({
|
|
2355
|
+
...serverOptions,
|
|
2356
|
+
actor: resolveActor,
|
|
2357
|
+
// Tools that need to know WHO is calling (rather than forward a
|
|
2358
|
+
// credential) read this. It is the same identity the check above just
|
|
2359
|
+
// verified, so a tool can never be told a different one.
|
|
2360
|
+
...actor === void 0 ? {} : { connectionActor: actor },
|
|
2361
|
+
defaultAuthorization: request.headers.get("authorization") ?? serverOptions.defaultAuthorization
|
|
2362
|
+
});
|
|
2363
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
2364
|
+
sessionIdGenerator: void 0,
|
|
2365
|
+
enableJsonResponse: true
|
|
2366
|
+
});
|
|
2367
|
+
try {
|
|
2368
|
+
await server.connect(transport);
|
|
2369
|
+
return await transport.handleRequest(request);
|
|
2370
|
+
} finally {
|
|
2371
|
+
void server.close().catch(() => void 0);
|
|
2372
|
+
}
|
|
2373
|
+
};
|
|
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);
|
|
1326
2383
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
1327
2384
|
sessionIdGenerator: void 0,
|
|
1328
2385
|
enableJsonResponse: true
|
|
@@ -1350,6 +2407,8 @@ async function serveStdio(server) {
|
|
|
1350
2407
|
}
|
|
1351
2408
|
export {
|
|
1352
2409
|
ERROR_KNOWLEDGE,
|
|
2410
|
+
createDocsMcp,
|
|
2411
|
+
createDocsMcpHandler,
|
|
1353
2412
|
createGraftMcp,
|
|
1354
2413
|
createGraftMcpHandler,
|
|
1355
2414
|
explainCode,
|