@capacms/mcp 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/guide.mjs ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * guide.mjs — the querying cheat sheet served as an MCP resource.
3
+ *
4
+ * Written for an agent, not a person: the order to call the tools in, the
5
+ * filter and sort grammar, and the limits, in as few tokens as that takes.
6
+ * The person-facing version is docs/api/ai-assistants.md.
7
+ */
8
+ import { MAX_RELATION_DEPTH } from "./graphql/build.mjs";
9
+
10
+ export const QUERY_GUIDE = `# Querying Capa content
11
+
12
+ ## Order of calls
13
+ 1. capa_graphql_schema: the models this key can read and their fields.
14
+ 2. capa_graphql_schema { model }: filter operators, sort values, an example. Add sdl: true for SDL.
15
+ 3. capa_explore_data { model }: real values, so filters match what exists.
16
+ 4. capa_graphql_build { model, fields, filter, sort, first, run: true }: GraphQL, REST URL, SDK code, data.
17
+ 5. capa_graphql_query { query, variables }: run or adjust a document.
18
+ 6. A refused call says the fix and the next tool per error code; for an error from elsewhere, capa_explain_error { error }.
19
+
20
+ ## Shapes
21
+ - List: articles(first, after, last, before, sort, filter) { nodes { id ... } pageInfo { hasNextPage endCursor } totalCount }
22
+ - One: article(id: ID!) { id ... }; any model: entry(id: ID!) { id model ... on Articles { title } }
23
+ - Relation: author { id name }. List relation: coauthors(first: 10, sort: name_ASC) { nodes { id name } pageInfo { hasNextPage endCursor } }
24
+ - A list relation that holds more than it shows is named in the answer's more. Page it in a read of one entry only:
25
+ article(id: ID!) { coauthors(after: <its endCursor>) { ... } }; capa_graphql_build's more[].next is that read.
26
+ - Media, single or a list: hero { id url alt type width height }; type is image, video, audio, document or file.
27
+ - Next page: pass pageInfo.endCursor as after, with the same sort.
28
+ - Previous page: last with before, pageInfo.startCursor as before; first with before is refused.
29
+ capa_graphql_build takes before with first, and writes last for you.
30
+ - End of the list: last with no before (REST before=end); in capa_graphql_build, before: "end".
31
+ - REST twin of each root field: rest in capa_graphql_query's answer, /api/entries/<model>?select=...&where=<JSON>&sort=...
32
+ A system key a field of the model shadows is written with $ there: $tags, $createdAt, author.$id.
33
+ A namespace holding , ( ) : . " * [ ] or a space, or starting with -, is quoted: "price.usd", "at.place"."zip.code".
34
+
35
+ ## Filters (the model's <Type>Filter)
36
+ - Text: eq ne in nin contains startsWith endsWith exists null
37
+ - Number and date: eq ne lt lte gt gte (numbers also in nin) exists null
38
+ - True/false: eq ne exists null. Lists: has hasAny hasAll exists null, with values of the list's item type:
39
+ { has: true } on true/false, { has: "news" } on text, ISO dates on dates
40
+ - Relation: eq ne in nin exists null, or one hop: { author: { name: { eq: "Ada Vale" } } }
41
+ - Combine: { and: [...] }, { or: [...] }, { not: {...} }. Keys in one object must all match.
42
+
43
+ ## Sort
44
+ - Values of <Type>Sort: views_DESC, title_ASC, author__name_ASC (one relation hop). Up to 3.
45
+
46
+ ## Limits
47
+ - first 1 to 200 (default 25; nested default 100). Depth 8, 10 root fields, 25 connections,
48
+ 5,000 nodes, 1,000 fields, 32 KB per document.
49
+ - Up to ${MAX_RELATION_DEPTH} nested relations below an entry, ${MAX_RELATION_DEPTH + 1} levels of entries with it
50
+ (author { books { publisher { country { id } } } }), and at most 12 relation fields expanded per root field.
51
+ - A production key reads published entries; a development key also reads drafts, and every answer
52
+ says which (environment, and drafts for a development key).
53
+ - A refusal over a budget states what the query measured and the limit, and its Next line quotes the
54
+ change the API's hint names; capa_graphql_build's check also gives the root first that fits.
55
+ `;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * instructions.mjs — the few sentences `initialize` gives the client to put
3
+ * before the model: where to start, and the rules that save it calls. A
4
+ * client that shows them brings the heart of the querying guide to the model
5
+ * without it opening capa://guide/querying. They name only tools this key
6
+ * was given.
7
+ */
8
+
9
+ /** The instructions for a session offered `tools`. */
10
+ export function instructionsFor(tools) {
11
+ const names = new Set(tools.map((tool) => tool.name));
12
+ const lines = [];
13
+ if (names.has("capa_graphql_schema")) {
14
+ lines.push(
15
+ "Capa is a headless CMS, and these tools read its content with one API key.",
16
+ "Start with capa_graphql_schema: it names every model and field this key can read. Never guess a model, field or sort name it has not shown; a wrong one comes back with the nearest names.",
17
+ "capa_explore_data measures real values before you filter, capa_graphql_build writes and checks a query, and capa_graphql_query runs one.",
18
+ "A production key reads published entries only; a development key also reads drafts, and every answer says which key read it.",
19
+ "The resource capa://guide/querying has the call order, the filter and sort grammar, and the limits.",
20
+ );
21
+ } else if (names.has("capa_read_entries")) {
22
+ lines.push(
23
+ "Capa is a headless CMS, and these tools read its content with one API key. This deployment serves no GraphQL.",
24
+ "Start with capa_read_entries without a model: it names every model this key reads. Never guess a model or field name it has not shown.",
25
+ "A production key reads published entries only; a development key also reads drafts.",
26
+ );
27
+ } else if (!tools.some((tool) => tool.surface !== "local")) {
28
+ lines.push("This key reads no content: its scopes hold no instance:read.");
29
+ }
30
+ if (names.has("capa_explain_error")) lines.push("capa_explain_error explains any Capa API error, offline.");
31
+ return lines.join(" ");
32
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * prompts.mjs — ready-made starting points a person can pick in their MCP
3
+ * client ("Explore my content", "Write a query"). Each expands to a user
4
+ * message naming the tools to call in order, so the agent spends no turns
5
+ * working out the workflow. `tools` names every tool a prompt sends the agent
6
+ * to: a prompt is offered only with all of them, since one naming a tool this
7
+ * key was not given starts the agent on a call that cannot work.
8
+ */
9
+
10
+ export const PROMPTS = [
11
+ {
12
+ name: "explore-content",
13
+ description: "Look through a model's content and summarise what is there.",
14
+ tools: ["capa_graphql_schema", "capa_explore_data"],
15
+ arguments: [{ name: "model", description: "The model to explore, e.g. articles. Omit to start from the list.", required: false }],
16
+ render: ({ model }) =>
17
+ (model
18
+ ? `Explore the Capa model "${model}". `
19
+ : "Explore the content in this Capa project. Start with capa_graphql_schema to list the models, then pick the one with the most fields. ") +
20
+ "Call capa_graphql_schema with the model for its fields, then capa_explore_data for real values. " +
21
+ "Summarise: how many entries and in which status, which fields are often empty or missing, the values enum-like fields take, and how entries relate. " +
22
+ "Quote numbers from the tool output; do not guess.",
23
+ },
24
+ {
25
+ name: "write-query",
26
+ description: "Turn a plain request into a working Capa query, in GraphQL, REST and SDK code.",
27
+ tools: ["capa_graphql_schema", "capa_explore_data", "capa_graphql_build", "capa_explain_error"],
28
+ arguments: [
29
+ { name: "goal", description: "What the query should return, e.g. the 5 most viewed articles with their author.", required: true },
30
+ { name: "model", description: "The model, if you know it.", required: false },
31
+ ],
32
+ render: ({ goal, model }) =>
33
+ `Write a Capa query that returns: ${goal}.${model ? ` The model is "${model}".` : ""} ` +
34
+ "Use capa_graphql_schema for exact field and sort names, capa_explore_data if a filter needs real values, " +
35
+ "then capa_graphql_build with run: true. If it fails, call capa_explain_error and try again. " +
36
+ 'Once the data is right, build it a last time with code: "all" (code: "next" for a Next.js app) for the SDK code. ' +
37
+ "Answer with the GraphQL document and variables, the REST URL and that SDK code, and a line on what the data shows.",
38
+ },
39
+ {
40
+ name: "fix-query-error",
41
+ description: "Explain a Capa API error and correct the query that caused it.",
42
+ tools: ["capa_explain_error", "capa_graphql_schema", "capa_graphql_build"],
43
+ arguments: [{ name: "error", description: "The error as you got it: JSON or text.", required: true }],
44
+ render: ({ error }) =>
45
+ `This Capa API call failed:\n\n${error}\n\n` +
46
+ "Call capa_explain_error with it, follow the fix it gives (capa_graphql_schema for names), " +
47
+ "and rebuild the query with capa_graphql_build. Show the corrected query and why the first one failed.",
48
+ },
49
+ ];
50
+
51
+ /** The prompts whose every tool is among `tools`, the registered set. */
52
+ export function availablePrompts(tools) {
53
+ const names = new Set(tools.map((t) => t.name));
54
+ return PROMPTS.filter((p) => p.tools.every((tool) => names.has(tool)));
55
+ }
56
+
57
+ /** `prompts/get`: the message for one prompt offered with `tools`, or `{ error }` naming what is missing. */
58
+ export function renderPrompt(name, args = {}, tools) {
59
+ const offered = availablePrompts(tools);
60
+ const prompt = offered.find((p) => p.name === name);
61
+ if (!prompt) return { error: `Unknown prompt: ${name}. Available: ${offered.map((p) => p.name).join(", ") || "none for this key"}` };
62
+ const missing = prompt.arguments.filter((a) => a.required && !args[a.name]).map((a) => a.name);
63
+ if (missing.length) return { error: `Prompt ${name} needs ${missing.join(", ")}.` };
64
+ return {
65
+ description: prompt.description,
66
+ messages: [{ role: "user", content: { type: "text", text: prompt.render(args) } }],
67
+ };
68
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * registry.mjs — which tools THIS key actually gets, decided once at startup.
3
+ *
4
+ * The server used to advertise every tool in `tools.mjs` regardless of the key
5
+ * it was given. That was true enough while one key family existed and every
6
+ * tool spoke to it. It stopped being true when scoped `cap_` keys arrived:
7
+ * a `cap_` key is refused on every legacy mount before the lookup even runs, so
8
+ * a server offering `capa_list_models` to one is offering a tool that cannot
9
+ * work, and an agent learns that from a failed call rather than from the list.
10
+ *
11
+ * THE RULE, in one sentence per family:
12
+ *
13
+ * cap_ `/api/` only. The legacy tools are not registered at all,
14
+ * because the surface they call refuses the prefix.
15
+ * pk_ / sk_ both. Every legacy tool registers as before, and the `/api/`
16
+ * tools register too, because `/api/` accepts a legacy key and
17
+ * derives its scope list from the key's bundle.
18
+ *
19
+ * Then the scope filter, which applies to `/api/` tools only: `GET /api/me`
20
+ * reports the scopes a key holds, and a tool whose `scope` is not among them is
21
+ * left out. A tool that is registered and always answers 403 teaches an agent
22
+ * to stop trying, which is the same reasoning that keeps unbuilt write tools
23
+ * absent rather than stubbed.
24
+ *
25
+ * Then the deployment filter, for the two features a deployment can switch
26
+ * off. `/api/pages` and its siblings sit behind `CAPA_SITE_PREVIEW`, which is
27
+ * off by default and does not launch with Capa, and a deployment without them
28
+ * answers every page path `route_not_found`. `/api/graphql` is on by default
29
+ * and `CAPA_API_GRAPHQL=off` unregisters it. `probePages` and `probeGraphQL`
30
+ * ask once at startup, and a tool whose `feature` the deployment does not
31
+ * serve is left out, for the same reason a tool that always answers 403 is.
32
+ * A tool that stands in for a feature (`whenOff`, capa_read_entries for
33
+ * GraphQL) is registered only where the probe found the feature off, or,
34
+ * with a description of its own (`besideGraphQL`), where GraphQL is served
35
+ * and leaves some of the key's models out: REST is the only way to read them.
36
+ *
37
+ * WHEN `/api/me` DOES NOT ANSWER, THE TOOLS REGISTER ANYWAY. Two real cases
38
+ * produce that: a deployment with the `/api/` surface switched off answers 404
39
+ * on every path under it, and a machine with no route to the host answers
40
+ * nothing at all. Neither is evidence about what the key may do. Dropping the
41
+ * tools there would turn a reachability problem into "Capa has no page tools",
42
+ * which is the wrong thing for an agent to conclude and an impossible one for a
43
+ * person to debug from the tool list. So they register, the first call carries
44
+ * the real error, and the reason goes to stderr where the person running the
45
+ * server can read it.
46
+ */
47
+ import { apiNextGet, apiNextPost, CapaApiError, CapaUnreachable } from "./client.mjs";
48
+ import { loadSchema } from "./graphql/schema.mjs";
49
+ import { graphqlNotServed } from "./graphql/served.mjs";
50
+ import { TOOLS } from "./tools.mjs";
51
+
52
+ /**
53
+ * How long the startup `/api/me` call may take before it counts as a failure.
54
+ *
55
+ * It is bounded because it runs BEFORE the first client message is read: a
56
+ * host that accepts the connection and then goes quiet would otherwise leave
57
+ * the client waiting on `initialize` with nothing on stderr, which looks like a
58
+ * broken server rather than an unreachable Capa. Five seconds is long enough
59
+ * for a cold serverless start and short enough that a person notices the
60
+ * stderr line rather than the silence.
61
+ */
62
+ const ME_TIMEOUT_MS = 5000;
63
+
64
+ /**
65
+ * Ask `/api/me` what this key is and what it holds.
66
+ *
67
+ * Never throws: every failure is an answer here, because the caller's job is to
68
+ * register tools rather than to decide whether Capa is up.
69
+ *
70
+ * Returns `{ ok: true, me }` with the `/api/me` body, or `{ ok: false, reason,
71
+ * apiMissing }` with a sentence naming what happened and what it means for the
72
+ * tool list, and whether the address serves no `/api/` route at all.
73
+ */
74
+ export async function loadMe(config, { timeoutMs = ME_TIMEOUT_MS } = {}) {
75
+ try {
76
+ const { data } = await apiNextGet(config, "/api/me", {}, {
77
+ signal: AbortSignal.timeout(timeoutMs),
78
+ });
79
+ return { ok: true, me: data ?? null };
80
+ } catch (error) {
81
+ const why =
82
+ error instanceof CapaApiError
83
+ ? `GET /api/me answered ${error.status}${error.code ? ` ${error.code}` : ""}` +
84
+ (error.status === 404
85
+ ? " (the address does not serve the /api/ surface: check that CAPA_API_URL is the Capa API's address, with no route after it)"
86
+ : "")
87
+ : // Named separately because "aborted" reads like something this server
88
+ // chose to do, and the person running it needs to know the host went
89
+ // quiet rather than that a request was cancelled.
90
+ error?.name === "TimeoutError"
91
+ ? `GET /api/me did not answer within ${timeoutMs}ms`
92
+ : error instanceof CapaUnreachable
93
+ ? `GET /api/me could not be reached at ${error.origin} (${error.reason})`
94
+ : `GET /api/me could not be reached (${String(error?.message ?? error)})`;
95
+ return {
96
+ ok: false,
97
+ me: null,
98
+ // A 404 on /api/me: nothing at this address serves /api/, so every /api/ tool's error names the address.
99
+ apiMissing: error instanceof CapaApiError && error.status === 404,
100
+ reason:
101
+ `${why}. The /api/ tools are registered anyway, so a call to one reports the real ` +
102
+ `error instead of the tool silently not existing.`,
103
+ };
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Whether this deployment serves the page routes: `true`, `false`, or `null`
109
+ * when the probe could not tell (no answer in time, or no route to the host).
110
+ *
111
+ * Asked with `GET /api/preview` and no token, the cheapest of the three: where
112
+ * the routes are registered it runs the key check and then refuses the
113
+ * missing token before any query; where they are not, the path falls through
114
+ * to `route_not_found`, whatever the key. Any other answer, a 401 included,
115
+ * means the route matched.
116
+ */
117
+ export async function probePages(config, { timeoutMs = ME_TIMEOUT_MS } = {}) {
118
+ try {
119
+ await apiNextGet(config, "/api/preview", {}, { signal: AbortSignal.timeout(timeoutMs) });
120
+ return true;
121
+ } catch (error) {
122
+ if (!(error instanceof CapaApiError)) return null;
123
+ return !(error.status === 404 && error.code === "route_not_found");
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Whether this deployment serves `/api/graphql`: `true`, `false`, or `null`
129
+ * when the probe could not tell (no answer in time, or no route to the host).
130
+ *
131
+ * Asked with a POST of `{ __typename }`, the cheapest document there is, and a
132
+ * POST because that is what every GraphQL tool sends: an admin-role host
133
+ * answers GraphQL by POST only, so a GET would call it off when it is not.
134
+ * Only the answer of a path the API does not serve (served.mjs) means no; any
135
+ * answer from the GraphQL handler, a refusal included, means the route is there.
136
+ */
137
+ export async function probeGraphQL(config, { timeoutMs = ME_TIMEOUT_MS } = {}) {
138
+ try {
139
+ await apiNextPost(config, "/api/graphql", { query: "{ __typename }" }, { signal: AbortSignal.timeout(timeoutMs) });
140
+ return true;
141
+ } catch (error) {
142
+ if (!(error instanceof CapaApiError)) return null;
143
+ return !graphqlNotServed(error);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * The models this key reads that GraphQL leaves out (N1: their type names
149
+ * collide), from the key's schema, which the GraphQL tools' first call then
150
+ * reads from the cache. Empty when there are none, and when the schema could
151
+ * not be read in time: a GraphQL tool's call then reports why.
152
+ */
153
+ export async function probeRestOnly(config, { timeoutMs = ME_TIMEOUT_MS } = {}) {
154
+ let timer;
155
+ const late = new Promise((resolve) => {
156
+ timer = setTimeout(() => resolve({ summary: { restOnly: [] } }), timeoutMs);
157
+ timer.unref?.();
158
+ });
159
+ try {
160
+ const { summary } = await Promise.race([loadSchema(config), late]);
161
+ return summary.restOnly ?? [];
162
+ } catch {
163
+ return [];
164
+ } finally {
165
+ clearTimeout(timer);
166
+ }
167
+ }
168
+
169
+ /**
170
+ * The tools this key gets.
171
+ *
172
+ * Pure over `(config, me, deployment)` so the rule above is a tested property
173
+ * rather than something only a running server exercises. `me` is the
174
+ * `/api/me` body, or null when the call did not answer; `deployment.pages` and
175
+ * `deployment.graphql` are what the probes found, and only `false` drops the
176
+ * tools of that feature, and adds the tool that stands in for it.
177
+ * `deployment.restOnly` lists the models GraphQL leaves out, which the
178
+ * stand-in reads beside GraphQL.
179
+ */
180
+ export function selectTools(config, me, deployment = {}) {
181
+ const family = config?.family === "cap" ? "cap" : "legacy";
182
+ // An ARRAY or nothing. `CAPA_KEY_SCOPES=off` hides `scopes` from `/api/me`
183
+ // while leaving enforcement of stored scopes on, so the field's absence means
184
+ // "this deployment is not telling", not "this key holds none". Filtering on
185
+ // an empty list there would drop every `/api/` tool on a stack where they all
186
+ // work.
187
+ const scopes = Array.isArray(me?.scopes) ? me.scopes : null;
188
+ const besideGraphQL = (tool) => Boolean(tool.besideGraphQL) && deployment[tool.whenOff] !== false && deployment.restOnly?.length > 0;
189
+ const registered = TOOLS.filter((tool) => {
190
+ // Answers from this package alone, so every key gets it.
191
+ if (tool.surface === "local") return true;
192
+ if (tool.surface === "legacy") return family === "legacy";
193
+ if (tool.feature && deployment[tool.feature] === false) return false;
194
+ if (tool.whenOff && deployment[tool.whenOff] !== false && !besideGraphQL(tool)) return false;
195
+ // Compared WHOLE, not by prefix: the page routes ask for unscoped
196
+ // `instance:read`, and a key holding only `instance:read:<modelId>` is
197
+ // refused there because a page list spans every model a page touches.
198
+ if (tool.scope && scopes) return scopes.includes(tool.scope);
199
+ // By prefix, for the GraphQL tools: `/api/graphql` serves any key that can
200
+ // read at least one model, and shows it only those models, so a one-model
201
+ // `instance:read:<modelId>` key gets them and sees one model.
202
+ if (tool.scopePrefix && scopes) {
203
+ return scopes.some((s) => s === tool.scopePrefix || s.startsWith(`${tool.scopePrefix}:`));
204
+ }
205
+ return true;
206
+ });
207
+ return registered.map((tool) => (besideGraphQL(tool) ? { ...tool, description: tool.besideGraphQL } : tool));
208
+ }
209
+
210
+ /** `a`, `a and b`, `a, b and c`. */
211
+ const inWords = (names) => (names.length < 2 ? names.join("") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`);
212
+
213
+ /**
214
+ * One line for the person running the server when the key's scopes leave
215
+ * tools out, naming the scope those tools need, or null when they leave none
216
+ * out. A key holding only `media:read` would otherwise start with
217
+ * capa_explain_error alone and nothing to say why.
218
+ */
219
+ export function scopeNote(config, me, deployment = {}) {
220
+ const scopes = Array.isArray(me?.scopes) ? me.scopes : null;
221
+ if (!scopes) return null;
222
+ const offered = selectTools(config, me, deployment);
223
+ const offeredNames = new Set(offered.map((tool) => tool.name));
224
+ const left = selectTools(config, { ...me, scopes: null }, deployment).filter((tool) => !offeredNames.has(tool.name));
225
+ if (!left.length) return null;
226
+ const needed = inWords([...new Set(left.map((tool) => tool.scope ?? tool.scopePrefix))]);
227
+ const shown = scopes.length > 4 ? [...scopes.slice(0, 4), `${scopes.length - 4} more`] : scopes;
228
+ const held = scopes.length ? `holds ${inWords(shown)}` : "holds no scope";
229
+ if (offered.every((tool) => tool.surface === "local")) {
230
+ const local = inWords(offered.map((tool) => tool.name));
231
+ return `capa-mcp: this key ${held}, and every tool that reads Capa needs ${needed}, so only ${local} ${offered.length === 1 ? "is" : "are"} offered.`;
232
+ }
233
+ const names = inWords(left.map((tool) => tool.name));
234
+ return `capa-mcp: this key ${held}, so ${names} ${left.length === 1 ? "is" : "are"} not offered: ${left.length === 1 ? "it needs" : "they need"} ${needed}.`;
235
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * resources.mjs — what the server offers to READ rather than to call.
3
+ *
4
+ * Two resources: the key's GraphQL schema as SDL (for a client that attaches
5
+ * schemas as context) and the querying guide; and one template, a single
6
+ * model's SDL. All are offered only when the GraphQL tools are registered:
7
+ * the schema is read with the same key and scope, and the guide is the order
8
+ * to call those tools in.
9
+ *
10
+ * The whole schema grows with the models a key reads (about 240 KB for a
11
+ * 44-model project), and a client may let the model read a resource on its
12
+ * own, so the list gives each resource's size and the schema's description
13
+ * says what to read instead: one model's SDL.
14
+ */
15
+ import { BuildError, findModel } from "./graphql/build.mjs";
16
+ import { loadSchema } from "./graphql/schema.mjs";
17
+ import { printSDL } from "./graphql/sdl.mjs";
18
+ import { QUERY_GUIDE } from "./guide.mjs";
19
+
20
+ /** A resource URI that names nothing this key can read: MCP's "resource not found". */
21
+ export class ResourceNotFound extends Error {}
22
+
23
+ const MODEL_SDL = "capa://graphql/schema/{model}.graphql";
24
+ const ONE_MODEL = /^capa:\/\/graphql\/schema\/([^/]+)\.graphql$/;
25
+
26
+ /** One model's SDL resource, e.g. `capa://graphql/schema/articles.graphql`. */
27
+ export const modelSDLUri = (namespace) => MODEL_SDL.replace("{model}", encodeURIComponent(namespace));
28
+
29
+ /** A size as a person reads it: `23 KB`, `under 1 KB`. */
30
+ const kilobytes = (bytes) => (bytes < 1024 ? "under 1 KB" : `${Math.round(bytes / 1024).toLocaleString("en-US")} KB`);
31
+
32
+ export const RESOURCES = [
33
+ {
34
+ uri: "capa://graphql/schema.graphql",
35
+ name: "GraphQL schema",
36
+ describe: (bytes) =>
37
+ `The whole GraphQL SDL for this key: every model it can read, with filters and sorts${bytes === undefined ? "" : `, ${kilobytes(bytes)}`}. ` +
38
+ `For one model, read ${MODEL_SDL} or call capa_graphql_schema with model and sdl: true.`,
39
+ mimeType: "application/graphql",
40
+ requiresTool: "capa_graphql_schema",
41
+ read: async (config) => {
42
+ const { introspection, summary } = await loadSchema(config);
43
+ return printSDL(introspection, summary).sdl;
44
+ },
45
+ },
46
+ {
47
+ uri: "capa://guide/querying",
48
+ name: "How to query Capa",
49
+ describe: () => "The order to call the tools in, the filter and sort grammar, and the limits.",
50
+ mimeType: "text/markdown",
51
+ requiresTool: "capa_graphql_schema",
52
+ read: async () => QUERY_GUIDE,
53
+ },
54
+ ];
55
+
56
+ export const RESOURCE_TEMPLATES = [
57
+ {
58
+ uriTemplate: MODEL_SDL,
59
+ name: "One model's GraphQL schema",
60
+ description:
61
+ "One model's SDL: its type, connection, filter and sort, and the shared types they use. " +
62
+ "{model} is a namespace or type name, e.g. articles.",
63
+ mimeType: "application/graphql",
64
+ requiresTool: "capa_graphql_schema",
65
+ /** The model a URI names, or undefined when it is not one of this template's. */
66
+ match: (uri) => {
67
+ const found = ONE_MODEL.exec(uri);
68
+ return found ? decodeURIComponent(found[1]) : undefined;
69
+ },
70
+ read: async (config, wanted) => {
71
+ const { introspection, summary } = await loadSchema(config);
72
+ let model;
73
+ try {
74
+ model = findModel(summary, wanted);
75
+ } catch (error) {
76
+ if (!(error instanceof BuildError)) throw error;
77
+ const near = error.didYouMean.length ? ` Did you mean ${error.didYouMean.map(modelSDLUri).join(" or ")}?` : "";
78
+ throw new ResourceNotFound(`${error.message}.${near}`);
79
+ }
80
+ return printSDL(introspection, summary, [model.namespace]).sdl;
81
+ },
82
+ },
83
+ ];
84
+
85
+ const offeredWith = (tools) => {
86
+ const names = new Set(tools.map((t) => t.name));
87
+ return (item) => !item.requiresTool || names.has(item.requiresTool);
88
+ };
89
+
90
+ /** The resources this server offers with the tools it registered. */
91
+ export function availableResources(tools) {
92
+ return RESOURCES.filter(offeredWith(tools));
93
+ }
94
+
95
+ /** The resource templates this server offers with the tools it registered. */
96
+ export function availableTemplates(tools) {
97
+ return RESOURCE_TEMPLATES.filter(offeredWith(tools));
98
+ }
99
+
100
+ /**
101
+ * `resources/list`: each resource with its size in bytes, read with this key.
102
+ * A resource that cannot be read now (Capa unreachable) is still listed,
103
+ * without a size: reading it reports why.
104
+ */
105
+ export async function listResources(config, tools) {
106
+ const listed = [];
107
+ for (const resource of availableResources(tools)) {
108
+ let bytes;
109
+ try {
110
+ bytes = Buffer.byteLength(await resource.read(config), "utf8");
111
+ } catch {
112
+ bytes = undefined;
113
+ }
114
+ listed.push({
115
+ uri: resource.uri,
116
+ name: resource.name,
117
+ description: resource.describe(bytes),
118
+ mimeType: resource.mimeType,
119
+ ...(bytes === undefined ? {} : { size: bytes }),
120
+ });
121
+ }
122
+ return listed;
123
+ }
124
+
125
+ /** `resources/read`: the text at `uri`, or `ResourceNotFound`. */
126
+ export async function readResource(config, tools, uri) {
127
+ const resource = availableResources(tools).find((r) => r.uri === uri);
128
+ if (resource) return { resource, text: await resource.read(config) };
129
+ for (const template of availableTemplates(tools)) {
130
+ const wanted = template.match(String(uri));
131
+ if (wanted !== undefined) return { resource: { uri, mimeType: template.mimeType }, text: await template.read(config, wanted) };
132
+ }
133
+ throw new ResourceNotFound(`Resource not found: ${String(uri)}`);
134
+ }