@capacms/mcp 0.2.0 → 0.3.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 +250 -42
- package/bin/capa-mcp.mjs +18 -60
- package/lib/annotations.mjs +4 -4
- package/lib/arguments.mjs +2 -1
- package/lib/client.mjs +104 -22
- package/lib/connect.mjs +67 -0
- package/lib/entry-tools.mjs +425 -0
- package/lib/error-guide.mjs +1 -1
- package/lib/explore.mjs +19 -4
- package/lib/graphql/schema.mjs +6 -2
- package/lib/graphql/served.mjs +11 -4
- package/lib/graphql-tools.mjs +37 -4
- package/lib/index.mjs +27 -0
- package/lib/instructions.mjs +2 -0
- package/lib/media-tools.mjs +311 -0
- package/lib/registry.mjs +201 -33
- package/lib/rest-tools.mjs +17 -3
- package/lib/server.mjs +39 -22
- package/lib/stdio.mjs +47 -0
- package/lib/tools.mjs +210 -49
- package/lib/uploader-retry.mjs +82 -0
- package/package.json +7 -3
package/lib/rest-tools.mjs
CHANGED
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
* registered there (registry.mjs), and without this tool an assistant would
|
|
7
7
|
* lose every read for as long as GraphQL is off. It is registered then, and
|
|
8
8
|
* where GraphQL leaves some of the key's models out (N1: their type names
|
|
9
|
-
* collide), for those.
|
|
10
|
-
*
|
|
9
|
+
* collide), for those. For a legacy key that is all: elsewhere it has
|
|
10
|
+
* capa_graphql_build, which writes the same REST read and checks it, and the
|
|
11
|
+
* legacy content tools besides, so a second way to read would only cost
|
|
12
|
+
* context. A `cap_` key gets it always (0.3.0): it has no legacy content tool,
|
|
13
|
+
* and REST reads every model it holds whatever else the deployment serves.
|
|
11
14
|
*
|
|
12
15
|
* It takes REST's parameters as docs/api/entries.md writes them and passes
|
|
13
16
|
* them through, so the API's own grammar, limits and refusals apply. The
|
|
@@ -113,13 +116,24 @@ export const REST_TOOLS = [
|
|
|
113
116
|
...BOUNDED,
|
|
114
117
|
surface: "api",
|
|
115
118
|
scopePrefix: "instance:read",
|
|
116
|
-
//
|
|
119
|
+
// For a legacy key, registered where the deployment serves no GraphQL, or
|
|
120
|
+
// where GraphQL leaves models out. For a cap_ key, always (registry.mjs).
|
|
117
121
|
whenOff: "graphql",
|
|
118
122
|
description: `Read entries over REST (GET /api/entries/<model>): this deployment serves no GraphQL. Without model: the models this key reads. ${USAGE}`,
|
|
119
123
|
/** The description where GraphQL is served and leaves models out, which this tool is then registered to read. */
|
|
120
124
|
besideGraphQL:
|
|
121
125
|
"Read entries over REST (GET /api/entries/<model>) of the models GraphQL leaves out, which capa_graphql_schema " +
|
|
122
126
|
`lists as readable over REST only; read every other model with capa_graphql_build. ${USAGE}`,
|
|
127
|
+
/**
|
|
128
|
+
* The description a `cap_` key reads where GraphQL is not known to be off
|
|
129
|
+
* and leaves no model out: the tool is registered for every `cap_` key,
|
|
130
|
+
* since REST is the read a `cap_` key always has, and the GraphQL tools
|
|
131
|
+
* stay the way to shape one.
|
|
132
|
+
*/
|
|
133
|
+
capDescription:
|
|
134
|
+
"Read entries over REST (GET /api/entries/<model>) of any model this key reads. Without model: the " +
|
|
135
|
+
"models this key reads. To shape a read (fields, filters, relations) as one checked query, start from " +
|
|
136
|
+
`capa_graphql_build. ${USAGE}`,
|
|
123
137
|
inputSchema: {
|
|
124
138
|
type: "object",
|
|
125
139
|
properties: {
|
package/lib/server.mjs
CHANGED
|
@@ -28,7 +28,7 @@ import { instructionsFor } from "./instructions.mjs";
|
|
|
28
28
|
const PROTOCOL_VERSION = "2025-06-18";
|
|
29
29
|
const SUPPORTED = new Set(["2025-06-18", "2025-03-26", "2024-11-05"]);
|
|
30
30
|
|
|
31
|
-
export const SERVER_INFO = { name: "capa-mcp", version: "0.
|
|
31
|
+
export const SERVER_INFO = { name: "capa-mcp", version: "0.3.0" };
|
|
32
32
|
|
|
33
33
|
const ok = (id, result) => ({ jsonrpc: "2.0", id, result });
|
|
34
34
|
const fail = (id, code, message) => ({ jsonrpc: "2.0", id, error: { code, message } });
|
|
@@ -58,8 +58,26 @@ function withAliases(tool, args) {
|
|
|
58
58
|
*/
|
|
59
59
|
const configFor = (ctx, signal) => (signal ? { ...ctx.config, signal } : ctx.config);
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
/**
|
|
62
|
+
* One tool call, for every front end: the `tools/call` answer below, and the
|
|
63
|
+
* `capa` CLI, which runs the same tools by name and needs to tell a refusal
|
|
64
|
+
* from an answer without reading the words. Either way the caller gets the
|
|
65
|
+
* same checks and the same sentences, so a CLI command and an MCP tool can
|
|
66
|
+
* never disagree about an argument or an error.
|
|
67
|
+
*
|
|
68
|
+
* { ok: true, tool, answer } the tool's answer, bounded
|
|
69
|
+
* { ok: false, reason, text, tool?, error? }
|
|
70
|
+
*
|
|
71
|
+
* `reason` is "unknown_tool", "invalid_arguments" or "failed" (the handler
|
|
72
|
+
* threw: an API refusal, a host that never answered, a timeout). `text` is
|
|
73
|
+
* what the model reads. `error` is what the handler threw, for a caller that
|
|
74
|
+
* classifies it (`CapaApiError`, `CapaUnreachable`, `CapaTimeout`).
|
|
75
|
+
*
|
|
76
|
+
* An answer the API gave and the tool shaped into a refusal (`{ error,
|
|
77
|
+
* didYouMean }` for a model that does not exist) is `ok: true`: the model is
|
|
78
|
+
* meant to read it and correct in one step, and MCP sends it as a result.
|
|
79
|
+
*/
|
|
80
|
+
export async function runTool(ctx, name, rawArgs, { signal } = {}) {
|
|
63
81
|
const tools = ctx.tools;
|
|
64
82
|
const tool = name ? tools.find((t) => t.name === name) : undefined;
|
|
65
83
|
if (!tool) {
|
|
@@ -71,24 +89,16 @@ async function callTool(ctx, params, signal) {
|
|
|
71
89
|
// cannot reach would answer "that one does not exist" with a suggestion
|
|
72
90
|
// that is equally unusable.
|
|
73
91
|
return {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
text:
|
|
78
|
-
`Unknown tool: ${String(name)}\n` +
|
|
79
|
-
`Available: ${tools.map((t) => t.name).join(", ")}`,
|
|
80
|
-
},
|
|
81
|
-
],
|
|
82
|
-
isError: true,
|
|
92
|
+
ok: false,
|
|
93
|
+
reason: "unknown_tool",
|
|
94
|
+
text: `Unknown tool: ${String(name)}\nAvailable: ${tools.map((t) => t.name).join(", ")}`,
|
|
83
95
|
};
|
|
84
96
|
}
|
|
85
97
|
// Checked against the schema the tool advertised, so a misspelled or
|
|
86
98
|
// mistyped argument is refused by name instead of silently ignored.
|
|
87
|
-
const args = withAliases(tool,
|
|
99
|
+
const args = withAliases(tool, rawArgs);
|
|
88
100
|
const problem = checkArguments(tool.inputSchema, args);
|
|
89
|
-
if (problem) {
|
|
90
|
-
return { content: [{ type: "text", text: `Invalid arguments for ${name}: ${problem}` }], isError: true };
|
|
91
|
-
}
|
|
101
|
+
if (problem) return { ok: false, reason: "invalid_arguments", tool, text: `Invalid arguments for ${name}: ${problem}` };
|
|
92
102
|
try {
|
|
93
103
|
const answer = await tool.handler(configFor(ctx, signal), args ?? {});
|
|
94
104
|
// Every answer fits its budget and says how to ask for less (lib/bound.mjs),
|
|
@@ -97,11 +107,7 @@ async function callTool(ctx, params, signal) {
|
|
|
97
107
|
// not cut again: only it knows which parts are never cut (a built query's
|
|
98
108
|
// document and REST twin) and how a connection pages on once cut.
|
|
99
109
|
const data = tool.whole || tool.bounded ? answer : boundAnswer(answer, args?.maxChars ?? DEFAULT_MAX_CHARS, { hint: tool.cutHint });
|
|
100
|
-
|
|
101
|
-
const content = [{ type: "text", text: JSON.stringify(data) }];
|
|
102
|
-
// A tool with an output schema also answers the object itself, for a
|
|
103
|
-
// client that reads results as data; the text stays for one that does not.
|
|
104
|
-
return tool.outputSchema ? { content, structuredContent: data } : { content };
|
|
110
|
+
return { ok: true, tool, answer: data };
|
|
105
111
|
} catch (error) {
|
|
106
112
|
// Same reasoning: an API failure is reported to the MODEL so it can adapt
|
|
107
113
|
// (retry narrower, pick another tool), rather than surfaced as a transport
|
|
@@ -118,10 +124,21 @@ async function callTool(ctx, params, signal) {
|
|
|
118
124
|
"(the Capa API rejected this call; a 401 means the key or tenant is wrong, a 404 means the model or id does not exist)"
|
|
119
125
|
}`
|
|
120
126
|
: String(error?.message ?? error);
|
|
121
|
-
return {
|
|
127
|
+
return { ok: false, reason: "failed", tool, text, error };
|
|
122
128
|
}
|
|
123
129
|
}
|
|
124
130
|
|
|
131
|
+
/** `runTool` as MCP's `tools/call` result. */
|
|
132
|
+
export async function callTool(ctx, params, signal) {
|
|
133
|
+
const run = await runTool(ctx, params?.name, params?.arguments, { signal });
|
|
134
|
+
if (!run.ok) return { content: [{ type: "text", text: run.text }], isError: true };
|
|
135
|
+
// Compact: indentation is a third of the characters and tells a model nothing.
|
|
136
|
+
const content = [{ type: "text", text: JSON.stringify(run.answer) }];
|
|
137
|
+
// A tool with an output schema also answers the object itself, for a
|
|
138
|
+
// client that reads results as data; the text stays for one that does not.
|
|
139
|
+
return run.tool.outputSchema ? { content, structuredContent: run.answer } : { content };
|
|
140
|
+
}
|
|
141
|
+
|
|
125
142
|
/**
|
|
126
143
|
* One message's answer. `signal` is the call's own, aborted when the client
|
|
127
144
|
* cancels it (session.mjs); a message that never reaches Capa has none.
|
package/lib/stdio.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stdio.mjs — the server on a pair of streams: one JSON-RPC message per line
|
|
3
|
+
* in, one per line out, and every log line on the error stream.
|
|
4
|
+
*
|
|
5
|
+
* The bin runs it on the process's own streams, and so does `capa mcp`, which
|
|
6
|
+
* starts the same server with the credentials `capa login` stored. A test can
|
|
7
|
+
* run it on streams of its own.
|
|
8
|
+
*/
|
|
9
|
+
import { createInterface } from "node:readline";
|
|
10
|
+
import { createSession } from "./session.mjs";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Serve `ctx` (connect.mjs) until the input ends. Resolves once every message
|
|
14
|
+
* read has been answered and the output has flushed, then calls `exit(0)`,
|
|
15
|
+
* which is `process.exit` unless the caller passes its own.
|
|
16
|
+
*/
|
|
17
|
+
export function serveStdio(ctx, { input = process.stdin, output = process.stdout, error = process.stderr, exit = (code) => process.exit(code) } = {}) {
|
|
18
|
+
const rl = createInterface({ input, crlfDelay: Infinity });
|
|
19
|
+
const session = createSession(ctx, {
|
|
20
|
+
write: (message) => output.write(JSON.stringify(message) + "\n"),
|
|
21
|
+
log: (text) => error.write(`${text}\n`),
|
|
22
|
+
});
|
|
23
|
+
rl.on("line", session.receive);
|
|
24
|
+
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
// Drain before exiting. `close` fires as soon as the input ends, which for
|
|
27
|
+
// a piped or scripted client is right after the last line, while a call may
|
|
28
|
+
// still be mid-fetch: exiting there would leave it unanswered, with nothing
|
|
29
|
+
// on stderr. A long-lived client holds stdin open, so only a pipe meets this.
|
|
30
|
+
rl.on("close", async () => {
|
|
31
|
+
try {
|
|
32
|
+
await session.drain();
|
|
33
|
+
} catch (e) {
|
|
34
|
+
error.write(`capa-mcp: drain failed: ${String(e?.stack ?? e)}\n`);
|
|
35
|
+
}
|
|
36
|
+
// Exit once the output has flushed, not before. process.exit() drops what
|
|
37
|
+
// stdout still buffers, and on macOS a write to a pipe is asynchronous: the
|
|
38
|
+
// answer to one tools/list came back cut at 8,192 bytes ("Unterminated
|
|
39
|
+
// string in JSON at position 8192", graphql-tools.test.mjs on Node 22.13).
|
|
40
|
+
// The empty write's callback runs after every earlier write has gone out.
|
|
41
|
+
output.write("", () => {
|
|
42
|
+
resolve();
|
|
43
|
+
exit(0);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
package/lib/tools.mjs
CHANGED
|
@@ -23,9 +23,8 @@
|
|
|
23
23
|
* the key has only `read` — see the corrected note in client.mjs, which used to
|
|
24
24
|
* claim no write verb existed behind a key at all.
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* stubbed, because a tool that always fails teaches an agent to stop trying.
|
|
26
|
+
* Entries are written as drafts by lib/entry-tools.mjs, against
|
|
27
|
+
* `/v2/agent/model-instances`, for `cap_` keys only.
|
|
29
28
|
*
|
|
30
29
|
* WHY /v2/seo/ai-bundle IS NOT A TOOL, having been measured rather than assumed
|
|
31
30
|
* It is API-key reachable and the source calls it a "RAG-ready export across
|
|
@@ -45,25 +44,37 @@
|
|
|
45
44
|
* different tool with a different contract, and it should be built deliberately
|
|
46
45
|
* rather than by pointing a `capa_get_*` at a large endpoint.
|
|
47
46
|
*
|
|
48
|
-
*
|
|
47
|
+
* THREE SURFACES, AND EVERY TOOL SAYS WHICH IT IS ON.
|
|
49
48
|
*
|
|
50
|
-
* `surface: "legacy"` means the tool calls `/v2
|
|
51
|
-
* `sk_` key reaches and a `cap_` key is
|
|
49
|
+
* `surface: "legacy"` means the tool calls `/v2/schema`, `/v2/api` or
|
|
50
|
+
* `/v2/schema/types`, which a `pk_` or `sk_` key reaches and a `cap_` key is
|
|
51
|
+
* refused on before any lookup.
|
|
52
|
+
* `surface: "agent"` means every call it makes goes to `/v2/agent/*`, which a
|
|
53
|
+
* legacy key reaches and a `cap_` key reaches where the deployment accepts one
|
|
54
|
+
* there (`GET /api/me` lists `/v2/agent` among the key's `surfaces`).
|
|
52
55
|
* `surface: "api"` means it calls `/api/`, which both families reach.
|
|
53
|
-
* `lib/registry.mjs` turns those
|
|
56
|
+
* `lib/registry.mjs` turns those words into the tool list one key actually
|
|
54
57
|
* gets, so an agent is never offered a tool its key cannot use.
|
|
55
58
|
*
|
|
56
|
-
* An `api` tool also declares the `scope` it needs. `/api/me`
|
|
57
|
-
* scopes a key holds, and a tool whose scope is missing from that
|
|
58
|
-
* out rather than registered and refused. The field is a string
|
|
59
|
-
* whole
|
|
60
|
-
*
|
|
61
|
-
* the API would give.
|
|
59
|
+
* An `api` or `agent` tool also declares the `scope` it needs. `/api/me`
|
|
60
|
+
* reports the scopes a key holds, and a tool whose scope is missing from that
|
|
61
|
+
* list is left out rather than registered and refused. The field is a string
|
|
62
|
+
* compared whole, or a list of which any one will do: the page routes ask for
|
|
63
|
+
* UNSCOPED `instance:read`, so a key holding only `instance:read:<modelId>`
|
|
64
|
+
* does not get them, which is the same answer the API would give. An `agent`
|
|
65
|
+
* tool's scope filters a `cap_` key only; a legacy key gets every agent tool,
|
|
66
|
+
* as it did before a `cap_` key could reach them.
|
|
67
|
+
*
|
|
68
|
+
* `capDescription` is what a `cap_` key's agent reads instead of
|
|
69
|
+
* `description`, where the two keys need different words: a legacy key's
|
|
70
|
+
* write is gated by its permission, a `cap_` key's by a scope.
|
|
62
71
|
*/
|
|
63
72
|
import { reads, writes } from "./annotations.mjs";
|
|
64
73
|
import { DEFAULT_MAX_CHARS, someNames } from "./bound.mjs";
|
|
65
74
|
import { apiGet, apiGetText, apiNextGet, apiWrite, CapaApiError } from "./client.mjs";
|
|
66
75
|
import { GRAPHQL_TOOLS } from "./graphql-tools.mjs";
|
|
76
|
+
import { ENTRY_TOOLS } from "./entry-tools.mjs";
|
|
77
|
+
import { MEDIA_TOOLS } from "./media-tools.mjs";
|
|
67
78
|
import { REST_TOOLS } from "./rest-tools.mjs";
|
|
68
79
|
|
|
69
80
|
/**
|
|
@@ -104,18 +115,98 @@ function typesAnswer(blocks, extra = {}) {
|
|
|
104
115
|
return cut(fits);
|
|
105
116
|
}
|
|
106
117
|
|
|
107
|
-
/**
|
|
118
|
+
/**
|
|
119
|
+
* /v2/schema is the one call that describes everything; cache it per process,
|
|
120
|
+
* or in `config.schemaCache` when the config carries one (graphql/schema.mjs
|
|
121
|
+
* says why the hosted server's does).
|
|
122
|
+
*/
|
|
108
123
|
let schemaCache = null;
|
|
109
124
|
export function __resetSchemaCacheForTests() {
|
|
110
125
|
schemaCache = null;
|
|
111
126
|
}
|
|
112
127
|
async function schema(config) {
|
|
128
|
+
if (config.schemaCache) {
|
|
129
|
+
if (!config.schemaCache.has("/v2/schema")) config.schemaCache.set("/v2/schema", await apiGet(config, "/v2/schema"));
|
|
130
|
+
return config.schemaCache.get("/v2/schema");
|
|
131
|
+
}
|
|
113
132
|
if (!schemaCache) schemaCache = await apiGet(config, "/v2/schema");
|
|
114
133
|
return schemaCache;
|
|
115
134
|
}
|
|
116
135
|
|
|
117
136
|
const lc = (s) => String(s ?? "").toLowerCase();
|
|
118
137
|
|
|
138
|
+
/**
|
|
139
|
+
* capa_get_model for a `cap_` key: ONE request, to `/v2/agent/models/<namespace>`.
|
|
140
|
+
*
|
|
141
|
+
* The legacy path reads `/v2/schema` first, and that mount refuses a `cap_`
|
|
142
|
+
* key whatever the deployment does on `/v2/agent`. The agent route resolves a
|
|
143
|
+
* namespace (or an id) itself and answers the fields, their ids, the layout
|
|
144
|
+
* and `embedByDefault` in one body, so everything the legacy answer holds
|
|
145
|
+
* comes from it, in the same shape:
|
|
146
|
+
*
|
|
147
|
+
* - `enumValues` is `[]` there where `/v2/schema` says null, so an empty
|
|
148
|
+
* list reads as absent, as it does on the legacy path.
|
|
149
|
+
* - `relationRef` is `""` or null on a field that relates to nothing.
|
|
150
|
+
* - `relations` is built from the fields as `/v2/schema` builds it. Its
|
|
151
|
+
* `toModel` is the field's `relationRef` as stored, which is the target's
|
|
152
|
+
* namespace on every real tenant (apps/api schema.ts); `/v2/schema`
|
|
153
|
+
* resolves the rare id-form one against every model, which one model's
|
|
154
|
+
* detail cannot, so an edge stored by id names the id.
|
|
155
|
+
*
|
|
156
|
+
* A miss is answered as the legacy path answers one, with the near names, read
|
|
157
|
+
* from the model list on the same mount (`model:read`, the scope this tool is
|
|
158
|
+
* registered by). A refusal is not a miss and throws as the API's error.
|
|
159
|
+
*/
|
|
160
|
+
async function agentModel(config, namespace) {
|
|
161
|
+
let detail;
|
|
162
|
+
try {
|
|
163
|
+
detail = await apiGet(config, `/v2/agent/models/${encodeURIComponent(namespace)}`);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (!(error instanceof CapaApiError && error.status === 404 && /model not found/i.test(error.body))) throw error;
|
|
166
|
+
let available = null;
|
|
167
|
+
try {
|
|
168
|
+
const list = await apiGet(config, "/v2/agent/models", { limit: 250 });
|
|
169
|
+
available = (list.data ?? []).map((m) => m.namespace);
|
|
170
|
+
} catch {
|
|
171
|
+
available = null;
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
error: `No model with namespace "${namespace}".`,
|
|
175
|
+
// Omitted, not empty, when the list call failed too: `[]` would claim this tenant has no models.
|
|
176
|
+
...(available === null
|
|
177
|
+
? {}
|
|
178
|
+
: { didYouMean: available.filter((n) => lc(n).includes(lc(namespace)) || lc(namespace).includes(lc(n))), available }),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const fields = detail.fields ?? [];
|
|
182
|
+
const relations = [];
|
|
183
|
+
for (const f of fields) {
|
|
184
|
+
if (!f.relationRef) continue;
|
|
185
|
+
const isOne = f.type === "relation";
|
|
186
|
+
const isMany = f.type === "array" && f.arrayType === "relation";
|
|
187
|
+
if (!isOne && !isMany) continue;
|
|
188
|
+
relations.push({ fromModel: detail.namespace, fromField: f.namespace, toModel: f.relationRef, type: isOne ? "one" : "many" });
|
|
189
|
+
}
|
|
190
|
+
// Key for key what the legacy path answers, which the tests hold byte-equal.
|
|
191
|
+
return {
|
|
192
|
+
namespace: detail.namespace,
|
|
193
|
+
name: detail.modelName,
|
|
194
|
+
singleInstance: detail.isSingleInstance,
|
|
195
|
+
fields: fields.map((f) => ({
|
|
196
|
+
id: f.id,
|
|
197
|
+
namespace: f.namespace,
|
|
198
|
+
name: f.name,
|
|
199
|
+
type: f.arrayType ? `${f.type}<${f.arrayType}>` : f.type,
|
|
200
|
+
required: f.required,
|
|
201
|
+
relatesTo: f.relationRef || undefined,
|
|
202
|
+
enumValues: f.enumValues?.length > 0 ? f.enumValues : undefined,
|
|
203
|
+
})),
|
|
204
|
+
relations,
|
|
205
|
+
layout: detail.layout ?? null,
|
|
206
|
+
embedByDefault: detail.embedByDefault === true,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
119
210
|
/**
|
|
120
211
|
* Capa stores most values as `{ type, value, sortOrder }` rather than bare
|
|
121
212
|
* scalars — including `title`, which reads like a plain column and is not.
|
|
@@ -166,6 +257,57 @@ const instanceIdOf = (row) => {
|
|
|
166
257
|
return typeof row?.id === "string" && UUID_RE.test(row.id) ? row.id : null;
|
|
167
258
|
};
|
|
168
259
|
|
|
260
|
+
/** capa_set_model_layout's description, around the sentence that says what its write needs, which differs by key family. */
|
|
261
|
+
const LAYOUT_LEAD =
|
|
262
|
+
"Design a model's entry editor: cards in a main column and a side column, with each field " +
|
|
263
|
+
"placed in one of them. ";
|
|
264
|
+
const LAYOUT_DOCUMENT =
|
|
265
|
+
"Pass " +
|
|
266
|
+
"layout: null to reset the model to the plain linear editor. The document is " +
|
|
267
|
+
"{ v: 1, main: Card[], aside: Card[] } where a Card is { id, title, description?, " +
|
|
268
|
+
"collapsible, collapsed, items: Field[] } and a Field is { id, fieldId, width, display?, " +
|
|
269
|
+
"inline? }. `id` is any id you choose, unique in the document; `fieldId` is the field's id " +
|
|
270
|
+
"from capa_get_model. Widths are full, two_thirds, half or third. Relation fields may take " +
|
|
271
|
+
"display \"picker\", \"inline\" or \"embedded\" (embedded draws the related entry's own " +
|
|
272
|
+
"fields directly in the parent card), plus inline { allowCreate, allowRemove, " +
|
|
273
|
+
"allowReorder, summary } where summary is up to 3 field ids OF THE RELATED model. Leave " +
|
|
274
|
+
"display out and the related model's own embedByDefault decides, which capa_get_model " +
|
|
275
|
+
"reports. Read the current one with capa_get_model first: it returns the same document, " +
|
|
276
|
+
"so you can edit and write it back.";
|
|
277
|
+
|
|
278
|
+
/** capa_set_workspace's description, around the sentence that says what its write needs. */
|
|
279
|
+
const WORKSPACE_LEAD = "Create a workspace, or apply a document to one that exists. ";
|
|
280
|
+
const WORKSPACE_REST =
|
|
281
|
+
"`mode: \"replace\"` makes the workspace exactly the document you " +
|
|
282
|
+
"send; `mode: \"merge\"` adds what is missing and removes nothing. Applying the same " +
|
|
283
|
+
"document twice changes nothing, so it is safe to retry. It never touches a record: a " +
|
|
284
|
+
"workspace is navigation, and removing something from it does not delete it. It creates no " +
|
|
285
|
+
"models or entries: a reference to one that does not exist is not placed, and the answer says so first.";
|
|
286
|
+
|
|
287
|
+
/** The API's unresolved references, `kind:ref`, as a person would name them. */
|
|
288
|
+
const UNRESOLVED_KIND = { model: "model", instance: "entry", media_folder: "media folder" };
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* A workspace answer led by a sentence for what it could not place. The API
|
|
292
|
+
* applies the rest of the document and lists the misses as `unresolved`; an
|
|
293
|
+
* agent that asked for a model which does not exist read that list past and
|
|
294
|
+
* told the person the model had been created (scripts/tool-evals, M20).
|
|
295
|
+
*/
|
|
296
|
+
function withNotPlaced(answer) {
|
|
297
|
+
const missed = Array.isArray(answer.unresolved) ? answer.unresolved : [];
|
|
298
|
+
if (missed.length === 0) return answer;
|
|
299
|
+
const named = missed.map((ref) => {
|
|
300
|
+
const at = String(ref).indexOf(":");
|
|
301
|
+
const kind = at > 0 ? String(ref).slice(0, at) : "";
|
|
302
|
+
return UNRESOLVED_KIND[kind] ? `${UNRESOLVED_KIND[kind]} ${String(ref).slice(at + 1)}` : String(ref);
|
|
303
|
+
});
|
|
304
|
+
const notPlaced =
|
|
305
|
+
`${missed.length} of the document's ${missed.length === 1 ? "references was" : "references were"} not placed, because nothing in this ` +
|
|
306
|
+
`project has that id or namespace: ${named.join(", ")}. This tool arranges what exists; it creates no models or entries. ` +
|
|
307
|
+
"Everything else was applied.";
|
|
308
|
+
return { notPlaced, ...answer };
|
|
309
|
+
}
|
|
310
|
+
|
|
169
311
|
export const TOOLS = [
|
|
170
312
|
{
|
|
171
313
|
name: "capa_list_models",
|
|
@@ -212,7 +354,10 @@ export const TOOLS = [
|
|
|
212
354
|
name: "capa_get_model",
|
|
213
355
|
...reads("Get a model and its editor layout"),
|
|
214
356
|
whole: true,
|
|
215
|
-
|
|
357
|
+
// Every call goes to /v2/agent for a cap_ key (agentModel). A legacy key
|
|
358
|
+
// reads /v2/schema first, which its key reaches, exactly as it always did.
|
|
359
|
+
surface: "agent",
|
|
360
|
+
scope: "model:read",
|
|
216
361
|
...MODEL_ALIAS,
|
|
217
362
|
description:
|
|
218
363
|
"Full field definitions for ONE model as the editor sees them: every field's namespace, " +
|
|
@@ -228,6 +373,7 @@ export const TOOLS = [
|
|
|
228
373
|
additionalProperties: false,
|
|
229
374
|
},
|
|
230
375
|
handler: async (config, args) => {
|
|
376
|
+
if (config.family === "cap") return agentModel(config, args.namespace);
|
|
231
377
|
const s = await schema(config);
|
|
232
378
|
const model = (s.models ?? []).find((m) => m.namespace === args.namespace);
|
|
233
379
|
if (!model) {
|
|
@@ -309,22 +455,14 @@ export const TOOLS = [
|
|
|
309
455
|
name: "capa_set_model_layout",
|
|
310
456
|
...writes("Set a model's editor layout", { idempotent: true }),
|
|
311
457
|
whole: true,
|
|
312
|
-
surface: "
|
|
458
|
+
surface: "agent",
|
|
459
|
+
scope: "model:update",
|
|
313
460
|
aliases: { model: "modelId" },
|
|
314
461
|
description:
|
|
315
|
-
|
|
316
|
-
"
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
"collapsible, collapsed, items: Field[] } and a Field is { id, fieldId, width, display?, " +
|
|
320
|
-
"inline? }. `id` is any id you choose, unique in the document; `fieldId` is the field's id " +
|
|
321
|
-
"from capa_get_model. Widths are full, two_thirds, half or third. Relation fields may take " +
|
|
322
|
-
"display \"picker\", \"inline\" or \"embedded\" (embedded draws the related entry's own " +
|
|
323
|
-
"fields directly in the parent card), plus inline { allowCreate, allowRemove, " +
|
|
324
|
-
"allowReorder, summary } where summary is up to 3 field ids OF THE RELATED model. Leave " +
|
|
325
|
-
"display out and the related model's own embedByDefault decides, which capa_get_model " +
|
|
326
|
-
"reports. Read the current one with capa_get_model first: it returns the same document, " +
|
|
327
|
-
"so you can edit and write it back.",
|
|
462
|
+
LAYOUT_LEAD +
|
|
463
|
+
"WRITES: it needs an API key whose permission is `agent`. " +
|
|
464
|
+
LAYOUT_DOCUMENT,
|
|
465
|
+
capDescription: `${LAYOUT_LEAD}WRITES: it needs a key holding model:update. ${LAYOUT_DOCUMENT}`,
|
|
328
466
|
inputSchema: {
|
|
329
467
|
type: "object",
|
|
330
468
|
properties: {
|
|
@@ -371,8 +509,11 @@ export const TOOLS = [
|
|
|
371
509
|
return {
|
|
372
510
|
error: error.message,
|
|
373
511
|
hint:
|
|
374
|
-
|
|
375
|
-
|
|
512
|
+
config.family === "cap"
|
|
513
|
+
? "This tool writes a model, so it needs a key holding the model:update scope. A key " +
|
|
514
|
+
"holding model:read can call capa_get_model but not this one."
|
|
515
|
+
: "This tool writes a model, so it needs a Capa API key whose permission is `agent`. " +
|
|
516
|
+
"`read`, `write` and `delete` keys can call capa_get_model but not this one.",
|
|
376
517
|
};
|
|
377
518
|
}
|
|
378
519
|
throw error;
|
|
@@ -670,7 +811,8 @@ export const TOOLS = [
|
|
|
670
811
|
name: "capa_list_workspaces",
|
|
671
812
|
...reads("List workspaces"),
|
|
672
813
|
cutHint: "Name the workspace you want and read it with capa_get_workspace.",
|
|
673
|
-
surface: "
|
|
814
|
+
surface: "agent",
|
|
815
|
+
scope: "workspace:read",
|
|
674
816
|
description:
|
|
675
817
|
"List this tenant's saved workspaces: the arrangements of the Capa admin's left rail. " +
|
|
676
818
|
"Each one is a named tree of models, entries and media folders. Start here before " +
|
|
@@ -682,8 +824,8 @@ export const TOOLS = [
|
|
|
682
824
|
includePrivate: {
|
|
683
825
|
type: "boolean",
|
|
684
826
|
description:
|
|
685
|
-
"
|
|
686
|
-
"
|
|
827
|
+
"Has no effect: workspaces people made for themselves are personal, and the API " +
|
|
828
|
+
"never lists them to an API key. Accepted so existing calls keep working.",
|
|
687
829
|
},
|
|
688
830
|
},
|
|
689
831
|
additionalProperties: false,
|
|
@@ -709,13 +851,16 @@ export const TOOLS = [
|
|
|
709
851
|
name: "capa_get_workspace",
|
|
710
852
|
...reads("Get a workspace document"),
|
|
711
853
|
whole: true,
|
|
712
|
-
surface: "
|
|
854
|
+
surface: "agent",
|
|
855
|
+
scope: "workspace:read",
|
|
713
856
|
description:
|
|
714
857
|
"One workspace as a JSON document you can edit and write back with capa_set_workspace. " +
|
|
715
858
|
"The tree is `Node[]`, where a Node is `{folder, children?}`, `{model}`, `{instance}` or " +
|
|
716
859
|
"`{media_folder}`. A folder is named by the customer and holds any mix of the three, so " +
|
|
717
860
|
"there are no fixed sections. Models come back as NAMESPACES, so you can read and " +
|
|
718
|
-
"rearrange without resolving ids first."
|
|
861
|
+
"rearrange without resolving ids first. A key that may update workspaces is shown every pin, " +
|
|
862
|
+
"so the document can name an entry that /v2/agent/model-instances does not serve that key: " +
|
|
863
|
+
"one never published, in a model it does not write.",
|
|
719
864
|
inputSchema: {
|
|
720
865
|
type: "object",
|
|
721
866
|
properties: {
|
|
@@ -732,13 +877,15 @@ export const TOOLS = [
|
|
|
732
877
|
name: "capa_set_workspace",
|
|
733
878
|
...writes("Create or apply a workspace", { idempotent: false }),
|
|
734
879
|
cutHint: "The write is done; capa_get_workspace reads the whole document.",
|
|
735
|
-
surface: "
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
880
|
+
surface: "agent",
|
|
881
|
+
// Either will do: without an id it creates (POST, workspace:create), with
|
|
882
|
+
// one it applies (PUT, workspace:update), and a key holding one of the two
|
|
883
|
+
// can do that half. The other half answers the API's 403 and the fix.
|
|
884
|
+
scope: ["workspace:create", "workspace:update"],
|
|
885
|
+
description: `${WORKSPACE_LEAD}WRITES: it needs an API key with write permission. ${WORKSPACE_REST}`,
|
|
886
|
+
capDescription:
|
|
887
|
+
`${WORKSPACE_LEAD}WRITES: it needs a key holding workspace:create to create one and ` +
|
|
888
|
+
`workspace:update to apply to one. ${WORKSPACE_REST}`,
|
|
742
889
|
inputSchema: {
|
|
743
890
|
type: "object",
|
|
744
891
|
properties: {
|
|
@@ -788,7 +935,7 @@ export const TOOLS = [
|
|
|
788
935
|
// workspace from this surface; saying so up front beats a 400.
|
|
789
936
|
if (args.template !== undefined) body.template = args.template;
|
|
790
937
|
const created = await apiWrite(config, "POST", "/v2/agent/workspaces", { ...body, visibility: "team" });
|
|
791
|
-
return { created: true, workspace: created.workspace, changes: created.changes ?? null, unresolved: created.unresolved ?? [] };
|
|
938
|
+
return withNotPlaced({ created: true, workspace: created.workspace, changes: created.changes ?? null, unresolved: created.unresolved ?? [] });
|
|
792
939
|
}
|
|
793
940
|
const applied = await apiWrite(
|
|
794
941
|
config,
|
|
@@ -796,14 +943,14 @@ export const TOOLS = [
|
|
|
796
943
|
`/v2/agent/workspaces/${encodeURIComponent(args.id)}/tree`,
|
|
797
944
|
{ ...body, mode },
|
|
798
945
|
);
|
|
799
|
-
return {
|
|
946
|
+
return withNotPlaced({
|
|
800
947
|
created: false,
|
|
801
948
|
workspace: applied.workspace,
|
|
802
949
|
// The summary is the point of the call: an agent reads it to find out
|
|
803
950
|
// whether it actually changed anything, and a no-op says so.
|
|
804
951
|
changes: applied.changes,
|
|
805
952
|
unresolved: applied.unresolved ?? [],
|
|
806
|
-
};
|
|
953
|
+
});
|
|
807
954
|
} catch (error) {
|
|
808
955
|
// The API's OWN message, not a guess at it. A 403 here means the key
|
|
809
956
|
// may read and not write, which is a configuration fact somebody can
|
|
@@ -812,9 +959,13 @@ export const TOOLS = [
|
|
|
812
959
|
return {
|
|
813
960
|
error: error.message,
|
|
814
961
|
hint:
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
962
|
+
config.family === "cap"
|
|
963
|
+
? "This tool writes, so it needs a key holding workspace:create to create a " +
|
|
964
|
+
"workspace and workspace:update to apply a document to one. A key holding " +
|
|
965
|
+
"workspace:read can call capa_list_workspaces and capa_get_workspace but not this one."
|
|
966
|
+
: "This tool writes, so it needs a Capa API key whose permission is `write` " +
|
|
967
|
+
"(or `agent`). A `read` key can call capa_list_workspaces and capa_get_workspace " +
|
|
968
|
+
"but not this one.",
|
|
818
969
|
};
|
|
819
970
|
}
|
|
820
971
|
throw error;
|
|
@@ -1124,6 +1275,16 @@ export const TOOLS = [
|
|
|
1124
1275
|
//
|
|
1125
1276
|
// Reading entries when the deployment serves no GraphQL: lib/rest-tools.mjs.
|
|
1126
1277
|
...REST_TOOLS,
|
|
1278
|
+
|
|
1279
|
+
// ----------------------------------------- entries (/v2/agent, drafts) ---
|
|
1280
|
+
//
|
|
1281
|
+
// Creating and changing entries as drafts: lib/entry-tools.mjs.
|
|
1282
|
+
...ENTRY_TOOLS,
|
|
1283
|
+
|
|
1284
|
+
// ------------------------------------ media (/v2/agent/file-uploads) ---
|
|
1285
|
+
//
|
|
1286
|
+
// Listing files and uploading one: lib/media-tools.mjs.
|
|
1287
|
+
...MEDIA_TOOLS,
|
|
1127
1288
|
];
|
|
1128
1289
|
|
|
1129
1290
|
/**
|