@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/README.md +881 -0
- package/bin/capa-mcp.mjs +96 -0
- package/lib/annotations.mjs +27 -0
- package/lib/answers.mjs +69 -0
- package/lib/arguments.mjs +140 -0
- package/lib/bound.mjs +546 -0
- package/lib/client.mjs +512 -0
- package/lib/error-guide.mjs +750 -0
- package/lib/explore.mjs +471 -0
- package/lib/graphql/build.mjs +725 -0
- package/lib/graphql/document.mjs +388 -0
- package/lib/graphql/filter-values.mjs +92 -0
- package/lib/graphql/more.mjs +97 -0
- package/lib/graphql/names.mjs +131 -0
- package/lib/graphql/schema.mjs +237 -0
- package/lib/graphql/sdl.mjs +144 -0
- package/lib/graphql/served.mjs +82 -0
- package/lib/graphql-tools.mjs +1177 -0
- package/lib/guide.mjs +55 -0
- package/lib/instructions.mjs +32 -0
- package/lib/prompts.mjs +68 -0
- package/lib/registry.mjs +235 -0
- package/lib/resources.mjs +134 -0
- package/lib/rest-tools.mjs +176 -0
- package/lib/server.mjs +194 -0
- package/lib/session.mjs +90 -0
- package/lib/suggest.mjs +32 -0
- package/lib/tools.mjs +1158 -0
- package/package.json +24 -0
package/bin/capa-mcp.mjs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* capa-mcp — stdio MCP server for Capa. Mostly reads; two tools write.
|
|
4
|
+
*
|
|
5
|
+
* Usage (Claude Code / Codex / any MCP client), on Node 20 or later:
|
|
6
|
+
* CAPA_API_URL=https://api.capacms.com \ # or CAPA_BASE_URL
|
|
7
|
+
* CAPA_KEY=<tenant api key> \ # or CAPA_API_KEY
|
|
8
|
+
* CAPA_TENANT_ID=<tenant id> \ # legacy pk_/sk_ keys only
|
|
9
|
+
* CAPA_API_VERSION=2026-10-01 \ # optional
|
|
10
|
+
* npx -y @capacms/mcp
|
|
11
|
+
*
|
|
12
|
+
* From a checkout of this repository, `node packages/mcp/bin/capa-mcp.mjs`
|
|
13
|
+
* runs the same server.
|
|
14
|
+
*
|
|
15
|
+
* A `cap_` key needs no tenant id: `/api/` resolves the tenant from the key,
|
|
16
|
+
* and the mounts that read `X-Tenant-Key` are the ones a `cap_` key is refused
|
|
17
|
+
* on anyway.
|
|
18
|
+
*
|
|
19
|
+
* THE TOOL LIST DEPENDS ON THE KEY, and is decided once, here, before the
|
|
20
|
+
* loop starts. A `cap_` key gets the `/api/` tools it holds the scopes for; a
|
|
21
|
+
* `pk_`/`sk_` key gets those plus every legacy tool. The GraphQL tools need
|
|
22
|
+
* only some `instance:read` scope, a one-model key's included, and
|
|
23
|
+
* `capa_explain_error` is offline and always there. `lib/registry.mjs` carries
|
|
24
|
+
* the rule and the reason. README.md lists what each tool answers.
|
|
25
|
+
*
|
|
26
|
+
* Still absent rather than stubbed: tools that edit or publish CONTENT. They
|
|
27
|
+
* want their own shaping against the write surface, and a tool that always
|
|
28
|
+
* fails teaches an agent to stop trying.
|
|
29
|
+
*/
|
|
30
|
+
import { createInterface } from "node:readline";
|
|
31
|
+
import { loadConfig } from "../lib/client.mjs";
|
|
32
|
+
import { loadMe, probeGraphQL, probePages, probeRestOnly, scopeNote, selectTools } from "../lib/registry.mjs";
|
|
33
|
+
import { createSession } from "../lib/session.mjs";
|
|
34
|
+
|
|
35
|
+
// Every request is bounded with AbortSignal.any (Node 20.3), and Node 18's
|
|
36
|
+
// fetch tried only ::1 for localhost, so an API listening on IPv4 looked down.
|
|
37
|
+
if (typeof AbortSignal.any !== "function") {
|
|
38
|
+
process.stderr.write(`capa-mcp: needs Node 20 or later (20.3 at least); this is Node ${process.versions.node}.\n`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let config;
|
|
43
|
+
try {
|
|
44
|
+
config = loadConfig();
|
|
45
|
+
} catch (e) {
|
|
46
|
+
process.stderr.write(String(e.message) + "\n");
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// One `/api/me` call, before any client message is read. It is what turns the
|
|
51
|
+
// key's family and scopes into the advertised list, and a client reads that
|
|
52
|
+
// list once at startup — deciding later would mean advertising tools and then
|
|
53
|
+
// changing our mind about them mid-session.
|
|
54
|
+
const [me, pages, graphql, restOnly] = await Promise.all([loadMe(config), probePages(config), probeGraphQL(config), probeRestOnly(config)]);
|
|
55
|
+
if (!me.ok) process.stderr.write(`capa-mcp: ${me.reason}\n`);
|
|
56
|
+
// Only an answering /api/ says anything about its features: without one, every /api/ tool registers and reports the real error.
|
|
57
|
+
const deployment = { pages: me.ok ? pages : null, graphql: me.ok ? graphql : null, restOnly };
|
|
58
|
+
if (deployment.pages === false) {
|
|
59
|
+
process.stderr.write("capa-mcp: this deployment does not serve /api/pages (CAPA_SITE_PREVIEW is off), so the page tools are not registered.\n");
|
|
60
|
+
}
|
|
61
|
+
const tools = selectTools(config, me.me, deployment);
|
|
62
|
+
const scoped = scopeNote(config, me.me, deployment);
|
|
63
|
+
if (scoped) process.stderr.write(`${scoped}\n`);
|
|
64
|
+
if (deployment.graphql === false) {
|
|
65
|
+
const instead = tools.some((tool) => tool.name === "capa_read_entries") ? " capa_read_entries reads the same entries over REST instead." : "";
|
|
66
|
+
process.stderr.write(`capa-mcp: this deployment does not serve /api/graphql (CAPA_API_GRAPHQL is off), so the GraphQL tools are not registered.${instead}\n`);
|
|
67
|
+
}
|
|
68
|
+
if (deployment.graphql !== false && restOnly.length && tools.some((tool) => tool.name === "capa_read_entries")) {
|
|
69
|
+
process.stderr.write(`capa-mcp: GraphQL leaves out ${restOnly.join(", ")} (their type names collide), so capa_read_entries is registered to read them over REST.\n`);
|
|
70
|
+
}
|
|
71
|
+
const ctx = { config: me.apiMissing ? { ...config, apiMissing: true } : config, tools };
|
|
72
|
+
|
|
73
|
+
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
74
|
+
const session = createSession(ctx, {
|
|
75
|
+
write: (message) => process.stdout.write(JSON.stringify(message) + "\n"),
|
|
76
|
+
log: (text) => process.stderr.write(`${text}\n`),
|
|
77
|
+
});
|
|
78
|
+
rl.on("line", session.receive);
|
|
79
|
+
|
|
80
|
+
// Drain before exiting. `close` fires as soon as stdin ends, which for a piped
|
|
81
|
+
// or scripted client is right after the last line, while a call may still be
|
|
82
|
+
// mid-fetch: exiting there would leave it unanswered, with nothing on stderr.
|
|
83
|
+
// A long-lived client holds stdin open, so only a pipe meets this.
|
|
84
|
+
rl.on("close", async () => {
|
|
85
|
+
try {
|
|
86
|
+
await session.drain();
|
|
87
|
+
} catch (e) {
|
|
88
|
+
process.stderr.write(`capa-mcp: drain failed: ${String(e?.stack ?? e)}\n`);
|
|
89
|
+
}
|
|
90
|
+
// Exit once stdout has flushed, not before. process.exit() drops what stdout
|
|
91
|
+
// still buffers, and on macOS a write to a pipe is asynchronous: the answer
|
|
92
|
+
// to one tools/list came back cut at 8,192 bytes ("Unterminated string in
|
|
93
|
+
// JSON at position 8192", graphql-tools.test.mjs on Node 22.13). The empty
|
|
94
|
+
// write's callback runs after every earlier write has gone out.
|
|
95
|
+
process.stdout.write("", () => process.exit(0));
|
|
96
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* annotations.mjs — what a client may know about a tool before it calls it.
|
|
3
|
+
*
|
|
4
|
+
* MCP tool annotations (protocol 2025-03-26 and later): whether the tool only
|
|
5
|
+
* reads, whether a write can replace what is there, and whether calling it
|
|
6
|
+
* twice with the same arguments changes anything more. A client uses them to
|
|
7
|
+
* run reads without asking and to confirm before a write. Every tool here
|
|
8
|
+
* talks to this Capa deployment and nothing else, so none is open-world.
|
|
9
|
+
*
|
|
10
|
+
* `title` is the name a client shows a person. It is set at the top level
|
|
11
|
+
* (2025-06-18) and inside `annotations` (2025-03-26), which is where the
|
|
12
|
+
* older protocol reads it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** A tool that changes nothing. */
|
|
16
|
+
export function reads(title) {
|
|
17
|
+
return { title, annotations: { title, readOnlyHint: true, openWorldHint: false } };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A tool that writes. `idempotent`: the same arguments a second time leave
|
|
22
|
+
* the same result. Every writer here can replace what is there, so each is
|
|
23
|
+
* destructive.
|
|
24
|
+
*/
|
|
25
|
+
export function writes(title, { idempotent }) {
|
|
26
|
+
return { title, annotations: { title, readOnlyHint: false, destructiveHint: true, idempotentHint: idempotent, openWorldHint: false } };
|
|
27
|
+
}
|
package/lib/answers.mjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* answers.mjs — what every `/api/` tool's answer shares: the keys its output
|
|
3
|
+
* schema allows, the answer budget it takes, and what it says about the key
|
|
4
|
+
* that read it.
|
|
5
|
+
*
|
|
6
|
+
* Output schemas are for a client that reads results as data
|
|
7
|
+
* (`structuredContent`). Every key an answer can carry is listed and no other
|
|
8
|
+
* is allowed, so a key added to an answer and not to its schema fails the
|
|
9
|
+
* test that checks real answers against these. A description only where the
|
|
10
|
+
* name does not say it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** A tool that bounds its own answer, keeping what must never be cut, so the server does not cut it again (server.mjs). */
|
|
14
|
+
export const BOUNDED = { bounded: true };
|
|
15
|
+
|
|
16
|
+
/** The `maxChars` argument of every tool that answers with content. */
|
|
17
|
+
export const MAX_CHARS = { type: "integer", minimum: 1000, maximum: 100000, description: "Default 20000." };
|
|
18
|
+
|
|
19
|
+
/** What boundAnswer adds to a cut answer, or answers in its place (lib/bound.mjs). */
|
|
20
|
+
const CUT_KEYS = {
|
|
21
|
+
truncated: { type: "array" },
|
|
22
|
+
truncatedMore: { type: "integer" },
|
|
23
|
+
clipped: { type: "array" },
|
|
24
|
+
clippedMore: { type: "integer" },
|
|
25
|
+
hint: { type: "string" },
|
|
26
|
+
note: { type: "string" },
|
|
27
|
+
};
|
|
28
|
+
/** A name the key's schema does not have, or a spec it cannot build, refused in band. */
|
|
29
|
+
const REFUSAL_KEYS = {
|
|
30
|
+
error: { type: "string" },
|
|
31
|
+
didYouMean: { type: "array" },
|
|
32
|
+
available: { type: "array" },
|
|
33
|
+
next: { type: "string" },
|
|
34
|
+
};
|
|
35
|
+
/** What an answer the API ran says about the key that read it (`readWith`). */
|
|
36
|
+
export const READ_WITH_KEYS = { environment: { type: "string" }, drafts: { type: "string" }, notFound: { type: "string" } };
|
|
37
|
+
|
|
38
|
+
/** An output schema: `properties` beside the keys every answer may carry. */
|
|
39
|
+
export const answerSchema = (properties) => ({
|
|
40
|
+
type: "object",
|
|
41
|
+
properties: { ...REFUSAL_KEYS, ...CUT_KEYS, ...properties },
|
|
42
|
+
additionalProperties: false,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** What a development key's answer says: it holds what a site does not show. */
|
|
46
|
+
export const DRAFTS_NOTE = "A development key reads drafts and unpublished changes; a production key reads published entries only.";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What an answer says about the key that read it: `environment`, and for a
|
|
50
|
+
* development key the one line that the entries include drafts, so an agent
|
|
51
|
+
* never describes a draft title as what the site shows. For a production key,
|
|
52
|
+
* a root field of `data` that reads one entry and answered null
|
|
53
|
+
* (`data.article`, with no error of its own) could be a wrong id or a
|
|
54
|
+
* draft-only entry, and the answer says so.
|
|
55
|
+
*/
|
|
56
|
+
export function readWith(environment, data, errors = []) {
|
|
57
|
+
if (!environment) return {};
|
|
58
|
+
if (environment === "development") return { environment, drafts: DRAFTS_NOTE };
|
|
59
|
+
const unread = new Set(errors.map((e) => e.path?.[0]));
|
|
60
|
+
const missing = Object.entries(data ?? {})
|
|
61
|
+
.filter(([key, value]) => value === null && !unread.has(key))
|
|
62
|
+
.map(([key]) => key);
|
|
63
|
+
if (!missing.length) return { environment };
|
|
64
|
+
const names = missing.length === 1 ? `${missing[0]} is` : `${missing.slice(0, -1).join(", ")} and ${missing.at(-1)} are`;
|
|
65
|
+
return {
|
|
66
|
+
environment,
|
|
67
|
+
notFound: `${names} null: no entry this key reads has that id. A production key reads published entries only, so an entry that exists only as a draft reads as null too.`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* arguments.mjs — check a tool call's arguments against the tool's own
|
|
3
|
+
* `inputSchema` before the handler sees them.
|
|
4
|
+
*
|
|
5
|
+
* A model that misspells an argument (`frist`, `filters`) must hear about it.
|
|
6
|
+
* Without this check the handler reads only the keys it knows, the typo is
|
|
7
|
+
* dropped, and a query runs unfiltered while the answer says it worked.
|
|
8
|
+
*
|
|
9
|
+
* The subset of JSON Schema checked is the subset these tools declare: `type`
|
|
10
|
+
* (one or several), `enum`, `required`, `properties`, `additionalProperties:
|
|
11
|
+
* false`, `items`, `anyOf`, `minimum`, `maximum`, `minLength`, `minItems` and
|
|
12
|
+
* `maxItems`. A keyword outside it is ignored rather than guessed at.
|
|
13
|
+
*/
|
|
14
|
+
import { didYouMean } from "./suggest.mjs";
|
|
15
|
+
|
|
16
|
+
function typeOf(value) {
|
|
17
|
+
if (value === null) return "null";
|
|
18
|
+
if (Array.isArray(value)) return "array";
|
|
19
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
20
|
+
return typeof value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function matchesType(value, type) {
|
|
24
|
+
const actual = typeOf(value);
|
|
25
|
+
return actual === type || (type === "number" && actual === "integer");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const label = (path) => path || "the arguments";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Names one tool of this server takes for what another calls by a different
|
|
32
|
+
* name, and the words REST uses for GraphQL's arguments: an agent that learnt
|
|
33
|
+
* `model` from the GraphQL tools, or `limit` from a REST URL, is pointed at
|
|
34
|
+
* the name this tool takes, which edit distance cannot find.
|
|
35
|
+
*/
|
|
36
|
+
const SYNONYMS = {
|
|
37
|
+
model: ["namespace", "modelId"],
|
|
38
|
+
namespace: ["model", "modelId"],
|
|
39
|
+
modelId: ["model", "namespace"],
|
|
40
|
+
limit: ["first"],
|
|
41
|
+
first: ["limit"],
|
|
42
|
+
where: ["filter"],
|
|
43
|
+
filter: ["where"],
|
|
44
|
+
select: ["fields"],
|
|
45
|
+
fields: ["select"],
|
|
46
|
+
orderBy: ["sort"],
|
|
47
|
+
order: ["sort"],
|
|
48
|
+
cursor: ["after"],
|
|
49
|
+
q: ["query"],
|
|
50
|
+
query: ["q"],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** The allowed names nearest `key`: by spelling, then by meaning. */
|
|
54
|
+
function nearestNames(key, allowed) {
|
|
55
|
+
const near = didYouMean(key, allowed);
|
|
56
|
+
for (const synonym of SYNONYMS[key] ?? []) if (allowed.includes(synonym) && !near.includes(synonym)) near.push(synonym);
|
|
57
|
+
return near;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A short description of what a schema accepts, for an `anyOf` refusal. */
|
|
61
|
+
function describe(schema) {
|
|
62
|
+
if (schema.type === "object" && schema.properties) return `an object with ${Object.keys(schema.properties).join(", ")}`;
|
|
63
|
+
return Array.isArray(schema.type) ? schema.type.join(" or ") : schema.type ?? "a value";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The first problem with `value` under `schema`, as a sentence, or null. */
|
|
67
|
+
function check(schema, value, path) {
|
|
68
|
+
if (!schema || typeof schema !== "object") return null;
|
|
69
|
+
|
|
70
|
+
if (schema.anyOf) {
|
|
71
|
+
if (schema.anyOf.some((option) => check(option, value, path) === null)) return null;
|
|
72
|
+
// One option of the right type explains the failure better than a list.
|
|
73
|
+
const sameType = schema.anyOf.filter((option) => option.type && matchesType(value, option.type));
|
|
74
|
+
if (sameType.length === 1) return check(sameType[0], value, path);
|
|
75
|
+
return `${label(path)} must be ${schema.anyOf.map(describe).join(", or ")}.`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (schema.type) {
|
|
79
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
80
|
+
if (!types.some((type) => matchesType(value, type))) {
|
|
81
|
+
return `${label(path)} must be ${types.join(" or ")}, not ${typeOf(value)}.`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (schema.enum && !schema.enum.includes(value)) {
|
|
85
|
+
return `${label(path)} must be one of ${schema.enum.map((v) => JSON.stringify(v)).join(", ")}.`;
|
|
86
|
+
}
|
|
87
|
+
if (typeof value === "number") {
|
|
88
|
+
if (schema.minimum !== undefined && value < schema.minimum) return `${label(path)} must be at least ${schema.minimum}.`;
|
|
89
|
+
if (schema.maximum !== undefined && value > schema.maximum) return `${label(path)} must be at most ${schema.maximum}.`;
|
|
90
|
+
}
|
|
91
|
+
if (typeof value === "string" && schema.minLength !== undefined && value.length < schema.minLength) {
|
|
92
|
+
return schema.minLength === 1 ? `${label(path)} must not be empty.` : `${label(path)} must be at least ${schema.minLength} characters.`;
|
|
93
|
+
}
|
|
94
|
+
if (Array.isArray(value)) {
|
|
95
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) return `${label(path)} needs at least ${schema.minItems} items.`;
|
|
96
|
+
if (schema.maxItems !== undefined && value.length > schema.maxItems) return `${label(path)} takes at most ${schema.maxItems} items.`;
|
|
97
|
+
if (schema.items) {
|
|
98
|
+
for (let i = 0; i < value.length; i++) {
|
|
99
|
+
const problem = check(schema.items, value[i], `${path}[${i}]`);
|
|
100
|
+
if (problem) return problem;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (typeOf(value) === "object") return checkObject(schema, value, path);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function checkObject(schema, value, path) {
|
|
109
|
+
const properties = schema.properties ?? {};
|
|
110
|
+
const allowed = Object.keys(properties);
|
|
111
|
+
const where = path ? ` in ${path}` : "";
|
|
112
|
+
if (schema.additionalProperties === false) {
|
|
113
|
+
for (const key of Object.keys(value)) {
|
|
114
|
+
if (Object.hasOwn(properties, key)) continue;
|
|
115
|
+
const near = nearestNames(key, allowed);
|
|
116
|
+
return (
|
|
117
|
+
`Unknown argument ${key}${where}.` +
|
|
118
|
+
(near.length ? ` Did you mean ${near.join(" or ")}?` : "") +
|
|
119
|
+
` Allowed: ${allowed.join(", ") || "none"}.`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const key of schema.required ?? []) {
|
|
124
|
+
if (value[key] === undefined) return `Missing argument ${key}${where}.`;
|
|
125
|
+
}
|
|
126
|
+
for (const key of allowed) {
|
|
127
|
+
if (value[key] === undefined) continue;
|
|
128
|
+
const problem = check(properties[key], value[key], path ? `${path}.${key}` : key);
|
|
129
|
+
if (problem) return problem;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* `null` when `args` satisfies `schema`, else one sentence naming the first
|
|
136
|
+
* problem and the fix. `args` of `null` or `undefined` is checked as `{}`.
|
|
137
|
+
*/
|
|
138
|
+
export function checkArguments(schema, args) {
|
|
139
|
+
return check(schema, args ?? {}, "");
|
|
140
|
+
}
|