@capacms/mcp 0.2.1 → 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 +247 -39
- package/bin/capa-mcp.mjs +17 -59
- package/lib/annotations.mjs +4 -4
- package/lib/arguments.mjs +2 -1
- package/lib/client.mjs +102 -20
- 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 +10 -3
- 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 +6 -2
package/README.md
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
# @capacms/mcp
|
|
2
2
|
|
|
3
|
-
stdio MCP server for Capa.
|
|
3
|
+
stdio MCP server for Capa. Eighteen reads, seven writes, two resources and three
|
|
4
4
|
prompts. People set it up from the
|
|
5
5
|
[AI assistants guide](https://docs.capacms.com/ai/mcp), which has
|
|
6
|
-
the Claude Code, Cursor and Codex config.
|
|
6
|
+
the Claude Code, Cursor and Codex config. `capa init claude`, `capa init codex`
|
|
7
|
+
and `capa init cursor` from `@capacms/cli` write that config, and serve these
|
|
8
|
+
tools as `capa mcp` with the key `capa login` kept in the keychain.
|
|
9
|
+
|
|
10
|
+
The server is also a module (`import { connect, runTool } from
|
|
11
|
+
"@capacms/mcp"`): the `capa` CLI's content commands are these tools by name.
|
|
7
12
|
|
|
8
13
|
It has no dependencies, so `npx` fetches the package and runs it with nothing
|
|
9
14
|
else to install. It needs Node 20.3 or later: every request is bounded with
|
|
@@ -43,10 +48,10 @@ as a proxy's `/capa`, is kept. Spaces around `CAPA_API_URL`, `CAPA_KEY` and
|
|
|
43
48
|
`CAPA_TENANT_ID` is needed by a **legacy key only**: `pk_`, `sk_`, or an
|
|
44
49
|
older key with no prefix, since every key but `cap_` is legacy. The `/v2` and
|
|
45
50
|
`/v3` mounts every legacy tool calls read it as `X-Tenant-Key`. A `cap_` key
|
|
46
|
-
reaches `/api/`,
|
|
47
|
-
|
|
48
|
-
so the server starts without it rather than
|
|
49
|
-
can call will read. Started with no key at all, the server names
|
|
51
|
+
reaches `/api/`, and `/v2/agent` on a deployment that accepts it there. Both
|
|
52
|
+
resolve the tenant from the key and never read that header, and the server
|
|
53
|
+
never sends it for a `cap_` key, so the server starts without it rather than
|
|
54
|
+
refusing over a value nothing it can call will read. Started with no key at all, the server names
|
|
50
55
|
`CAPA_API_URL` and `CAPA_KEY` as missing, and labels `CAPA_TENANT_ID` as for
|
|
51
56
|
legacy keys only.
|
|
52
57
|
|
|
@@ -54,23 +59,24 @@ legacy keys only.
|
|
|
54
59
|
they send the newest date this package knows. A date the deployment does not
|
|
55
60
|
serve is refused with a list of the ones it does.
|
|
56
61
|
|
|
57
|
-
|
|
58
|
-
the
|
|
59
|
-
|
|
60
|
-
at all (see
|
|
62
|
+
Twenty-five tools, of which a given key is offered a subset. The `needs` column is
|
|
63
|
+
the permission a legacy key needs, then the scope a `cap_` key needs where it
|
|
64
|
+
is offered the tool. The `surface` column is which part of the API it calls,
|
|
65
|
+
which is what decides whether it is registered at all (see
|
|
66
|
+
[which tools a key is offered](#which-tools-a-key-is-offered)).
|
|
61
67
|
|
|
62
68
|
| tool | answers | surface | needs |
|
|
63
69
|
|---|---|---|---|
|
|
64
|
-
| `capa_list_models` | what content exists here, and what relates to what | legacy | read |
|
|
65
|
-
| `capa_get_model` | what shape is this model: fields, types, relations, and how its editor is laid out |
|
|
66
|
-
| `capa_set_model_layout` | design that model's entry editor, or reset it to linear |
|
|
67
|
-
| `capa_list_content` | which instances of this model exist | legacy | read |
|
|
68
|
-
| `capa_get_content` | this one instance, in full, with relations | legacy | read |
|
|
69
|
-
| `capa_search_content` | where does this text live, when you don't know the model | legacy | read |
|
|
70
|
-
| `capa_get_types` | the generated TypeScript, for writing code against a tenant | legacy | read |
|
|
71
|
-
| `capa_list_workspaces` | which saved arrangements of the admin rail exist |
|
|
72
|
-
| `capa_get_workspace` | one workspace as a document you can edit and write back |
|
|
73
|
-
| `capa_set_workspace` | create a workspace, or apply a document to one |
|
|
70
|
+
| `capa_list_models` | what content exists here, and what relates to what | legacy | read; never a `cap_` key |
|
|
71
|
+
| `capa_get_model` | what shape is this model: fields, types, relations, and how its editor is laid out | `/v2/agent` | read, or `model:read` |
|
|
72
|
+
| `capa_set_model_layout` | design that model's entry editor, or reset it to linear | `/v2/agent` | **agent**, or `model:update` |
|
|
73
|
+
| `capa_list_content` | which instances of this model exist | legacy | read; never a `cap_` key |
|
|
74
|
+
| `capa_get_content` | this one instance, in full, with relations | legacy | read; never a `cap_` key |
|
|
75
|
+
| `capa_search_content` | where does this text live, when you don't know the model | legacy | read; never a `cap_` key |
|
|
76
|
+
| `capa_get_types` | the generated TypeScript, for writing code against a tenant | legacy | read; never a `cap_` key |
|
|
77
|
+
| `capa_list_workspaces` | which saved arrangements of the admin rail exist | `/v2/agent` | read, or `workspace:read` |
|
|
78
|
+
| `capa_get_workspace` | one workspace as a document you can edit and write back | `/v2/agent` | read, or `workspace:read` |
|
|
79
|
+
| `capa_set_workspace` | create a workspace, or apply a document to one | `/v2/agent` | **write**, or `workspace:create` or `workspace:update` |
|
|
74
80
|
| `capa_list_pages` | which pages this site has, what they read and how busy they are | `/api/` | `instance:read` |
|
|
75
81
|
| `capa_get_page` | one page: its models, its entries, and the exact queries it issues | `/api/` | `instance:read` |
|
|
76
82
|
| `capa_suggest_queries` | what Capa suggests changing about how one page fetches | `/api/` | `instance:read` |
|
|
@@ -79,15 +85,26 @@ at all (see [the page tools](#the-page-tools)).
|
|
|
79
85
|
| `capa_graphql_query` | run a GraphQL read and get data, errors with hints, cost and budget | `/api/` | `instance:read` for any model |
|
|
80
86
|
| `capa_explore_data` | what the content actually holds: counts, ranges, values, fan-out | `/api/` | `instance:read` for any model |
|
|
81
87
|
| `capa_explain_error` | what an API error means and what to do next | none (offline) | nothing |
|
|
82
|
-
| `capa_read_entries` | read entries over REST
|
|
88
|
+
| `capa_read_entries` | read entries over REST: for a `cap_` key always; for a legacy key where the deployment serves no GraphQL, or of the models GraphQL leaves out | `/api/` | `instance:read` for any model |
|
|
89
|
+
| `capa_create_entry` | save a new entry in a model, as a draft | `/v2/agent` | `instance:create`; never a legacy key |
|
|
90
|
+
| `capa_update_entry` | change fields of one entry, saved as a new draft version | `/v2/agent` | `instance:update`; never a legacy key |
|
|
91
|
+
| `capa_publish_entry` | make an entry's newest draft the version sites read | `/v2/agent` | `instance:publish`; never a legacy key |
|
|
92
|
+
| `capa_unpublish_entry` | stop sites reading an entry, keeping it as a draft | `/v2/agent` | `instance:publish`; never a legacy key |
|
|
93
|
+
| `capa_list_media` | which files the project has, as the objects image, video and file fields take | `/v2/agent` | `media:read`; never a legacy key; only where uploads are on, and not for a production key that may neither upload nor write entries |
|
|
94
|
+
| `capa_upload_media` | upload a file from the project folder, answered as that field value | `/v2/agent` | `media:create`; never a legacy key; only where uploads are on, and with an uploader address |
|
|
83
95
|
|
|
84
96
|
Every tool has a `title` and MCP annotations, so a client can run the reads
|
|
85
|
-
without asking and confirm a write. The
|
|
86
|
-
`readOnlyHint: true`. The
|
|
87
|
-
`destructiveHint: true`, since each can replace what is there
|
|
97
|
+
without asking and confirm a write. The eighteen reads are
|
|
98
|
+
`readOnlyHint: true`. The seven writes are `readOnlyHint: false`. Six are
|
|
99
|
+
`destructiveHint: true`, since each can replace what is there;
|
|
100
|
+
`capa_upload_media` only adds a file, so it is `destructiveHint: false` and
|
|
101
|
+
`idempotentHint: false`.
|
|
88
102
|
`capa_set_model_layout` is `idempotentHint: true`: the same layout twice is one
|
|
89
|
-
layout
|
|
90
|
-
|
|
103
|
+
layout, and so are `capa_publish_entry` and `capa_unpublish_entry`: a
|
|
104
|
+
repeated publish or unpublish answers `skipped`. `capa_set_workspace` is not,
|
|
105
|
+
because without an `id` it creates a new workspace on every call, and neither
|
|
106
|
+
is `capa_create_entry`. Nor is `capa_update_entry`: every call saves a new
|
|
107
|
+
draft version, the same values included. Every tool is `openWorldHint: false`, since none
|
|
91
108
|
reaches past this Capa deployment.
|
|
92
109
|
|
|
93
110
|
Every answer is held to 20,000 characters. The GraphQL tools and
|
|
@@ -215,7 +232,7 @@ names and a suggestion, never silently dropped. A relation spec in `capa_graphql
|
|
|
215
232
|
either surface, gives up after 25 seconds with the next step. Every other
|
|
216
233
|
refusal is in band and names the next tool for its own error: a typo points
|
|
217
234
|
at `capa_graphql_schema`, a bad argument at `capa_graphql_build`, and a
|
|
218
|
-
mutation at
|
|
235
|
+
mutation at the entry tools, since GraphQL here only reads. A budget refusal passes on what
|
|
219
236
|
the API measured and the limit, and names the tool for that budget:
|
|
220
237
|
`capa_graphql_build` to lower `first` or nest less, `capa_graphql_query` to
|
|
221
238
|
split a document with too many root fields, connections or `in` values, and
|
|
@@ -232,15 +249,18 @@ the startup probe could not tell, answers:
|
|
|
232
249
|
|
|
233
250
|
```text
|
|
234
251
|
This deployment does not serve GraphQL: POST /api/graphql answered 405 mutations_not_enabled as a path it does not serve, which is what CAPA_API_GRAPHQL=off does. No GraphQL tool can answer until it is switched back on.
|
|
235
|
-
Next: tell the person running Capa that GraphQL is off here. Code can read the same content over REST with this key: GET /api/entries/<model>.
|
|
252
|
+
Next: tell the person running Capa that GraphQL is off here. To read entries meanwhile, capa_read_entries reads them over REST with this key. Code can read the same content over REST with this key: GET /api/entries/<model>.
|
|
236
253
|
```
|
|
237
254
|
|
|
238
|
-
A legacy key is
|
|
255
|
+
That is a `cap_` key's answer. A legacy key is pointed at `capa_list_content`
|
|
256
|
+
and `capa_get_content` instead.
|
|
239
257
|
A real mutation is still told it tried to write, and nothing more.
|
|
240
258
|
|
|
241
259
|
Where the startup probe finds GraphQL off, the server registers
|
|
242
260
|
`capa_read_entries` in its place, so a key keeps a read tool for as long as
|
|
243
|
-
GraphQL is off.
|
|
261
|
+
GraphQL is off. A `cap_` key gets it wherever GraphQL is served too, described
|
|
262
|
+
as the REST read beside the GraphQL tools, since it has no legacy content tool
|
|
263
|
+
to read with. It reads `GET /api/entries/<model>` with REST's own
|
|
244
264
|
parameters (`select`, `where`, `sort`, `limit`, `after`, `before`, `count`),
|
|
245
265
|
answers the models the key reads when called without `model`, and is cut like
|
|
246
266
|
every other answer. A cut page's `next` would skip what was cut, so it is
|
|
@@ -581,7 +601,7 @@ misconfiguration instead of naming it.
|
|
|
581
601
|
|
|
582
602
|
`mutations_not_enabled` is the one code with two causes, so its advice
|
|
583
603
|
depends on what came with it. The GraphQL handler's refusal of a mutation
|
|
584
|
-
carries a hint, and gets "Send a query instead;
|
|
604
|
+
carries a hint, and gets "Send a query instead; GraphQL here cannot write.", with the entry tools named. A
|
|
585
605
|
bare code also gets the other cause: a query sent where GraphQL is off.
|
|
586
606
|
|
|
587
607
|
### Resources and prompts
|
|
@@ -702,13 +722,43 @@ from the prefix:
|
|
|
702
722
|
|
|
703
723
|
| key | gets |
|
|
704
724
|
|---|---|
|
|
705
|
-
| `cap_…` | the
|
|
706
|
-
| any other key: `pk_…`, `sk_…` or unprefixed |
|
|
725
|
+
| `cap_…` | the `/api/` and offline tools, `capa_read_entries` among them, and the nine `/v2/agent` tools where the deployment accepts a `cap_` key there, plus the two media tools where it also takes uploads |
|
|
726
|
+
| any other key: `pk_…`, `sk_…` or unprefixed | the `/api/` and offline tools **and** all ten legacy and `/v2/agent` tools, as before 0.3.0; never the entry or media tools |
|
|
727
|
+
|
|
728
|
+
`/v2/schema`, `/v2/api` and `/v2/schema/types` refuse a `cap_` key before the
|
|
729
|
+
lookup even runs, so `capa_list_models`, `capa_list_content`,
|
|
730
|
+
`capa_get_content`, `capa_search_content` and `capa_get_types` are never
|
|
731
|
+
offered to one. A legacy key loses nothing: `/api/` accepts it and derives its
|
|
732
|
+
scopes from the key's bundle.
|
|
733
|
+
|
|
734
|
+
`/v2/agent` accepts a `cap_` key on a deployment that turned that on, and
|
|
735
|
+
`GET /api/me` says so: the key's `surfaces` list `"/v2/agent"` there. On such
|
|
736
|
+
a deployment a `cap_` key also gets `capa_get_model`,
|
|
737
|
+
`capa_set_model_layout`, `capa_list_workspaces`, `capa_get_workspace`,
|
|
738
|
+
`capa_set_workspace`, `capa_create_entry`, `capa_update_entry`,
|
|
739
|
+
`capa_publish_entry` and `capa_unpublish_entry`, each only with the scope its
|
|
740
|
+
route checks, from the `needs` column. `capa_set_workspace` needs either scope: without an `id` it
|
|
741
|
+
creates a workspace (`workspace:create`), with one it applies a document
|
|
742
|
+
(`workspace:update`). For a `cap_` key `capa_get_model` reads
|
|
743
|
+
`/v2/agent/models/<namespace>` alone, never `/v2/schema`, and answers the same
|
|
744
|
+
document; a legacy key's call reads `/v2/schema` first, as before. No call
|
|
745
|
+
sends `X-Tenant-Key` for a `cap_` key. Where `surfaces` does not list
|
|
746
|
+
`"/v2/agent"`, or is not reported, these nine are left out, and a line on
|
|
747
|
+
stderr names the ones the key's scopes would get, and that they need a
|
|
748
|
+
deployment that accepts `cap_` keys there.
|
|
749
|
+
|
|
750
|
+
A `cap_` key holding the Read only preset is offered, on a deployment without
|
|
751
|
+
the page routes:
|
|
752
|
+
|
|
753
|
+
| deployment | tools |
|
|
754
|
+
|---|---|
|
|
755
|
+
| keeps a `cap_` key on `/api/` | `capa_graphql_schema`, `capa_graphql_build`, `capa_graphql_query`, `capa_explore_data`, `capa_explain_error`, `capa_read_entries` |
|
|
756
|
+
| accepts a `cap_` key on `/v2/agent` | those six, and `capa_get_model`, `capa_list_workspaces`, `capa_get_workspace` |
|
|
707
757
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
758
|
+
The six write tools need `model:update`, `workspace:create` or
|
|
759
|
+
`workspace:update`, `instance:create`, `instance:update` and
|
|
760
|
+
`instance:publish`, which the Read only preset does not hold. The Content
|
|
761
|
+
writer preset holds the three entry scopes.
|
|
712
762
|
|
|
713
763
|
Then `GET /api/me` is asked what the key holds, and an `/api/` tool whose scope
|
|
714
764
|
is missing is left out rather than registered and refused every time. The page
|
|
@@ -756,7 +806,9 @@ out, since it and twin-a would have the same GraphQL type name."` with
|
|
|
756
806
|
|
|
757
807
|
If `/api/me` does not answer (a deployment with the `/api/` surface switched
|
|
758
808
|
off answers 404, an unreachable host answers nothing), the `/api/` tools register
|
|
759
|
-
anyway and the reason goes to stderr.
|
|
809
|
+
anyway and the reason goes to stderr. A `cap_` key's `/v2/agent` tools do not:
|
|
810
|
+
nothing then says the deployment accepts the key there, and the stderr line
|
|
811
|
+
says that too. Neither failure is evidence about what the
|
|
760
812
|
key may do, and dropping the tools there would turn a reachability problem into
|
|
761
813
|
"Capa has no page tools", which is the wrong conclusion for an agent and an
|
|
762
814
|
impossible one for a person to debug from a tool list. After a 404 from
|
|
@@ -856,6 +908,161 @@ Content and Media, keeping the rule it came with: a key the document does not
|
|
|
856
908
|
name is left alone even in `replace` mode, so a half document cannot empty a
|
|
857
909
|
section by omission. Send a `Node[]` instead.
|
|
858
910
|
|
|
911
|
+
## The entry tools
|
|
912
|
+
|
|
913
|
+
`capa_create_entry`, `capa_update_entry`, `capa_publish_entry` and
|
|
914
|
+
`capa_unpublish_entry` write content through `/v2/agent/model-instances`. They
|
|
915
|
+
are offered to a `cap_` key only, where the deployment accepts one on
|
|
916
|
+
`/v2/agent` and the key holds the tool's scope (`instance:create`,
|
|
917
|
+
`instance:update`, `instance:publish`); the Content writer preset holds all
|
|
918
|
+
three. A legacy key never gets them, so its tool list is the one it had
|
|
919
|
+
before.
|
|
920
|
+
|
|
921
|
+
Both save a **draft**. Neither ever publishes, whatever the key may do, so
|
|
922
|
+
what a site reads does not change until the entry is published. A model is
|
|
923
|
+
named by its namespace, the name every read answers with, and `data` is flat:
|
|
924
|
+
`{ "<field namespace>": <value> }`. A relation takes the related entry's id;
|
|
925
|
+
an image, video or file field takes the whole file object an upload answered.
|
|
926
|
+
|
|
927
|
+
```json
|
|
928
|
+
{"model":"articles","data":{"title":"Hello from an agent","body":"First draft."}}
|
|
929
|
+
{"created":true,"entry":{"id":"...","model":"articles","title":"Hello from an agent","status":"draft","live":false,"versionId":"...","versionNumber":1,
|
|
930
|
+
"data":{"title":"Hello from an agent","body":"First draft."}},"next":"Saved as a draft. A development or draft key reads it now with capa_read_entries { model, id }; ..."}
|
|
931
|
+
```
|
|
932
|
+
|
|
933
|
+
`capa_update_entry` takes `id` and `data`, and **merges**: a field `data`
|
|
934
|
+
leaves out keeps its value, and `null` clears one. Each update is a new
|
|
935
|
+
draft version. A published entry keeps serving its published version until
|
|
936
|
+
it is published again.
|
|
937
|
+
|
|
938
|
+
Every key of `data` must be one of the model's field namespaces. The API
|
|
939
|
+
builds what it stores from the model's own fields, so it drops any other key
|
|
940
|
+
without a word and answers success: `{"titel":"x"}` saved an identical
|
|
941
|
+
version. Both tools refuse such a key before writing anything:
|
|
942
|
+
|
|
943
|
+
```json
|
|
944
|
+
{"error":"The model \"articles\" has no field \"titel\".","didYouMean":["title"],"available":["title","body","views"],
|
|
945
|
+
"next":"Nothing was written. Name each field by a namespace from available and send it again. ..."}
|
|
946
|
+
```
|
|
947
|
+
|
|
948
|
+
The check reads the model, which takes `model:read` (and, for an update,
|
|
949
|
+
`instance:read` to find the entry's model). A key without them writes
|
|
950
|
+
unchecked, and the answer says so in `unchecked`; `entry.data` in it is what
|
|
951
|
+
was stored. Such a key cannot name a model by its namespace either: that is
|
|
952
|
+
refused with the two ways forward, the model's id or a key with `model:read`.
|
|
953
|
+
|
|
954
|
+
A key of any environment writes. What it reads back differs: a development
|
|
955
|
+
or draft key reads drafts, so `capa_read_entries { model, id }` returns the
|
|
956
|
+
new entry at once; a production key reads published entries only, so a draft
|
|
957
|
+
it wrote shows up in its reads once the entry is published. Every answer says
|
|
958
|
+
so in `next`.
|
|
959
|
+
|
|
960
|
+
Publishing is its own tool, with its own scope. `capa_publish_entry { id }`
|
|
961
|
+
makes the newest draft the version sites read (`versionId` publishes an older
|
|
962
|
+
one); `capa_unpublish_entry { id }` stops sites reading the entry and keeps
|
|
963
|
+
its content as a draft. Both change what every visitor reads, so both are
|
|
964
|
+
marked destructive, and a client asks its person before each call unless told
|
|
965
|
+
not to. Their descriptions ask the agent to publish only what the person
|
|
966
|
+
asked for. A second publish of the same version, or an unpublish of an entry
|
|
967
|
+
with nothing published, answers `skipped` and changes nothing. The agent
|
|
968
|
+
mount has no one-entry unpublish route, so `capa_unpublish_entry` sends
|
|
969
|
+
`POST /v2/agent/model-instances/bulk-unpublish` with one id.
|
|
970
|
+
|
|
971
|
+
A refusal comes back in band with the API's own words and the next step: a
|
|
972
|
+
missing or wrong field gets the API's message and points at
|
|
973
|
+
`capa_get_model`, a model that does not exist gets `didYouMean` and
|
|
974
|
+
`available`, and a key without the scope gets the scope to ask for. An
|
|
975
|
+
unpublish the API could not do answers `unpublished: false` with its `error`
|
|
976
|
+
and `code`, says the entry is still published, and says to try again or tell
|
|
977
|
+
the person.
|
|
978
|
+
|
|
979
|
+
## Changes in 0.3.0
|
|
980
|
+
|
|
981
|
+
- **Deep imports are refused.** 0.2.1 had no `exports` map, so
|
|
982
|
+
`@capacms/mcp/lib/<file>.mjs` could be imported. 0.3.0 exports the package
|
|
983
|
+
root (`connect`, `loadConfig`, `runTool`, `serveStdio`, `selectTools`,
|
|
984
|
+
`TOOLS`, `TOOLS_BY_NAME` and the rest of `lib/index.mjs`) and
|
|
985
|
+
`package.json` only, so a deep import fails with
|
|
986
|
+
`ERR_PACKAGE_PATH_NOT_EXPORTED`. Import the same names from
|
|
987
|
+
`@capacms/mcp`. The `capa-mcp` command is unchanged.
|
|
988
|
+
- **The entry tools** (`capa_create_entry`, `capa_update_entry`,
|
|
989
|
+
`capa_publish_entry`, `capa_unpublish_entry`) are new, for a `cap_` key on a
|
|
990
|
+
deployment that accepts one on `/v2/agent`. A legacy key's tool list and
|
|
991
|
+
its stderr lines are unchanged.
|
|
992
|
+
- **A `cap_` key's stderr lines name the entry tools.** Four startup lines list
|
|
993
|
+
the `/v2/agent` tools a `cap_` key is not offered, and each now lists the
|
|
994
|
+
four entry tools too:
|
|
995
|
+
- "this deployment does not accept a cap_ key on /v2/agent, so ... are not
|
|
996
|
+
offered", which is production's case while `CAPA_AGENT_CAP_KEYS` is off;
|
|
997
|
+
- "this deployment does not say it accepts a cap_ key on /v2/agent, so ...
|
|
998
|
+
are not offered";
|
|
999
|
+
- "this key holds ..., so ... are not offered: they need ...", which now
|
|
1000
|
+
also names `instance:create`, `instance:update` and `instance:publish`;
|
|
1001
|
+
- the line when `/api/me` cannot be read, which now says "The model,
|
|
1002
|
+
workspace and entry tools (...) are not" where 0.2.1 said "The model and
|
|
1003
|
+
workspace tools".
|
|
1004
|
+
- **The media tools** (`capa_list_media`, `capa_upload_media`) are new, for a
|
|
1005
|
+
`cap_` key on a deployment that accepts one on `/v2/agent` and takes
|
|
1006
|
+
uploads with an API key (`CAPA_AGENT_UPLOADS=on`). The upload tool also
|
|
1007
|
+
needs the uploader's address. At startup a `cap_` key now asks
|
|
1008
|
+
`GET /v2/agent/file-uploads?size=1` once. Where uploads are off, the list
|
|
1009
|
+
is not the key's, or there is no uploader address, a new stderr line says
|
|
1010
|
+
which tool is left out and why. A legacy key's tool list, startup requests
|
|
1011
|
+
and stderr lines are unchanged. See [the media tools](#the-media-tools).
|
|
1012
|
+
|
|
1013
|
+
## The media tools
|
|
1014
|
+
|
|
1015
|
+
`capa_list_media` and `capa_upload_media` are offered to a `cap_` key only,
|
|
1016
|
+
where the deployment accepts one on `/v2/agent` **and** takes uploads with an
|
|
1017
|
+
API key (`CAPA_AGENT_UPLOADS=on`). The server asks
|
|
1018
|
+
`GET /v2/agent/file-uploads?size=1` at startup: with the switch off that
|
|
1019
|
+
answers `404 agent_uploads_disabled`, and both tools are left out with a line
|
|
1020
|
+
on stderr. Unlike the page and GraphQL probes, an answer that says nothing (no
|
|
1021
|
+
answer, or an API older than the route) also leaves them out, since every call
|
|
1022
|
+
would be refused. A key that sees published content only there (a
|
|
1023
|
+
production key that may neither upload nor write entries) is answered
|
|
1024
|
+
`403 agent_route_needs_draft_access`: uploads are on, but files have no
|
|
1025
|
+
published view, so the list is left out for that key, with a line on stderr.
|
|
1026
|
+
A production key holding `media:create` uploads like any other, and lists the
|
|
1027
|
+
files too: on the file routes a media write reads drafts. `workspace:update`
|
|
1028
|
+
and `model:update` do not count there.
|
|
1029
|
+
|
|
1030
|
+
`capa_list_media` reads `GET /v2/agent/file-uploads` (`media:read`): the
|
|
1031
|
+
project's files, newest first, narrowed by `search` (part of the name),
|
|
1032
|
+
`type` (`image`, `video`, `file`, `audio`, `document` with PDFs, or `pdf`) or
|
|
1033
|
+
`folderId`, a page of `size` at a time. Each file is the object an image,
|
|
1034
|
+
video or file field takes, so it goes into `capa_create_entry` or
|
|
1035
|
+
`capa_update_entry` whole.
|
|
1036
|
+
|
|
1037
|
+
`capa_upload_media { path }` uploads one file in two calls, so the key never
|
|
1038
|
+
reaches the uploader:
|
|
1039
|
+
|
|
1040
|
+
1. `POST /v2/agent/file-uploads/ticket` with the key (`media:create`) answers
|
|
1041
|
+
a ten-minute ticket. The API decides which keys get one, and a refused key
|
|
1042
|
+
gets the API's reason in band.
|
|
1043
|
+
2. `POST <CAPA_UPLOAD_URL>/upload?wait=true`, multipart field `file`, with
|
|
1044
|
+
the ticket as the bearer token. `wait=true` answers once the file is
|
|
1045
|
+
stored, so its URL works when the agent uses it.
|
|
1046
|
+
|
|
1047
|
+
`CAPA_UPLOAD_URL` is the uploader's address, the host the Capa admin sends
|
|
1048
|
+
files to. Capa's hosted API (`CAPA_API_URL` of `https://api.capacms.com`,
|
|
1049
|
+
`https://cdn.capacms.com` or its Fly hosts) defaults it to
|
|
1050
|
+
`https://uploads.capacms.com` (`HOSTED_UPLOAD_URL`), Capa's uploader. Any other API has no default, so a file never
|
|
1051
|
+
goes to Capa's uploader with a ticket from somewhere else. With no address
|
|
1052
|
+
the upload tool is not offered, and stderr says so.
|
|
1053
|
+
|
|
1054
|
+
The tool reads a path an agent chose, so it reads only inside one folder:
|
|
1055
|
+
`CAPA_UPLOAD_ROOT` when the person sets it, else `CLAUDE_PROJECT_DIR` (the
|
|
1056
|
+
project Claude Code names), else the folder the server started in. A folder
|
|
1057
|
+
nobody chose that is the home folder or holds it (`~`, `/Users`, `/`) is
|
|
1058
|
+
refused outright. The path is resolved with symlinks followed, and a path
|
|
1059
|
+
outside the folder, under any hidden name (`.env`, `.git/`, `.npmrc`), or
|
|
1060
|
+
named like a private key or certificate (`id_rsa*`, `*.pem`, `*.key`,
|
|
1061
|
+
`*.p12`, `*.pfx`, `*.ppk`, `*.p8`, `*.jks`, `*.keystore`, `*.gpg`, or a backup
|
|
1062
|
+
of one such as `server.pem.bak`) is refused before a ticket is asked for.
|
|
1063
|
+
`CAPA_UPLOAD_ALLOW`, separated as `PATH` is, names files the person lets
|
|
1064
|
+
through anyway; the agent cannot set it.
|
|
1065
|
+
|
|
859
1066
|
## Tests
|
|
860
1067
|
|
|
861
1068
|
`pnpm -F @capacms/mcp test` runs every case with no network beyond localhost. The
|
|
@@ -863,7 +1070,8 @@ GraphQL tools run against `test/support/graphql-stub.mjs`, a local
|
|
|
863
1070
|
`/api/graphql` that serves the seed schema from `packages/sdk/test/fixtures`
|
|
864
1071
|
with a small content set of its own, not the seed's. It also serves the
|
|
865
1072
|
stored values of `/api/entries/<model>` that `capa_explore_data` reads, and
|
|
866
|
-
with `{ graphql: false }` answers as `CAPA_API_GRAPHQL=off` does
|
|
1073
|
+
with `{ graphql: false }` answers as `CAPA_API_GRAPHQL=off` does, and with
|
|
1074
|
+
`{ surfaces }` reports those surfaces on `/api/me`. Run it on
|
|
867
1075
|
its own with `node test/support/graphql-stub.mjs 4461` to drive the stdio
|
|
868
1076
|
server by hand. The worked examples above come from a real API, not the stub.
|
|
869
1077
|
The tests also hold the context budget: each new description at most 700
|
package/bin/capa-mcp.mjs
CHANGED
|
@@ -12,25 +12,25 @@
|
|
|
12
12
|
* From a checkout of this repository, `node packages/mcp/bin/capa-mcp.mjs`
|
|
13
13
|
* runs the same server.
|
|
14
14
|
*
|
|
15
|
-
* A `cap_` key needs no tenant id: `/api/`
|
|
16
|
-
* and the mounts that read `X-Tenant-Key` are the ones a `cap_`
|
|
17
|
-
* on anyway.
|
|
15
|
+
* A `cap_` key needs no tenant id: `/api/` and `/v2/agent` resolve the tenant
|
|
16
|
+
* from the key, and the mounts that read `X-Tenant-Key` are the ones a `cap_`
|
|
17
|
+
* key is refused on anyway.
|
|
18
18
|
*
|
|
19
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* loop starts. A `cap_` key gets the `/api/` tools it holds the scopes for,
|
|
21
|
+
* capa_read_entries among them, and the model and workspace tools where
|
|
22
|
+
* `/api/me` says the deployment accepts it on `/v2/agent`; a `pk_`/`sk_` key
|
|
23
|
+
* gets the `/api/` tools plus every legacy and agent tool. The GraphQL tools
|
|
24
|
+
* need only some `instance:read` scope, a one-model key's included, and
|
|
23
25
|
* `capa_explain_error` is offline and always there. `lib/registry.mjs` carries
|
|
24
26
|
* the rule and the reason. README.md lists what each tool answers.
|
|
25
27
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* fails teaches an agent to stop trying.
|
|
28
|
+
* Content is written as drafts only (lib/entry-tools.mjs), for a `cap_` key
|
|
29
|
+
* holding the scopes, where the deployment accepts it on `/v2/agent`.
|
|
29
30
|
*/
|
|
30
|
-
import { createInterface } from "node:readline";
|
|
31
31
|
import { loadConfig } from "../lib/client.mjs";
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
32
|
+
import { connect } from "../lib/connect.mjs";
|
|
33
|
+
import { serveStdio } from "../lib/stdio.mjs";
|
|
34
34
|
|
|
35
35
|
// Every request is bounded with AbortSignal.any (Node 20.3), and Node 18's
|
|
36
36
|
// fetch tried only ::1 for localhost, so an API listening on IPv4 looked down.
|
|
@@ -47,50 +47,8 @@ try {
|
|
|
47
47
|
process.exit(1);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
});
|
|
50
|
+
// The startup calls, and what the person running the server reads about the
|
|
51
|
+
// tool list, live in lib/connect.mjs, which `capa mcp` shares.
|
|
52
|
+
const { ctx, notes } = await connect(config);
|
|
53
|
+
for (const note of notes) process.stderr.write(`${note}\n`);
|
|
54
|
+
await serveStdio(ctx);
|
package/lib/annotations.mjs
CHANGED
|
@@ -19,9 +19,9 @@ export function reads(title) {
|
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* A tool that writes. `idempotent`: the same arguments a second time leave
|
|
22
|
-
* the same result.
|
|
23
|
-
*
|
|
22
|
+
* the same result. `destructive`: it can replace what is there, which every
|
|
23
|
+
* writer here but the upload can; an upload only adds a file.
|
|
24
24
|
*/
|
|
25
|
-
export function writes(title, { idempotent }) {
|
|
26
|
-
return { title, annotations: { title, readOnlyHint: false, destructiveHint:
|
|
25
|
+
export function writes(title, { idempotent, destructive = true }) {
|
|
26
|
+
return { title, annotations: { title, readOnlyHint: false, destructiveHint: destructive, idempotentHint: idempotent, openWorldHint: false } };
|
|
27
27
|
}
|
package/lib/arguments.mjs
CHANGED
|
@@ -88,7 +88,8 @@ function check(schema, value, path) {
|
|
|
88
88
|
if (schema.minimum !== undefined && value < schema.minimum) return `${label(path)} must be at least ${schema.minimum}.`;
|
|
89
89
|
if (schema.maximum !== undefined && value > schema.maximum) return `${label(path)} must be at most ${schema.maximum}.`;
|
|
90
90
|
}
|
|
91
|
-
|
|
91
|
+
// Measured without surrounding whitespace: " " is as empty as "" to every handler here.
|
|
92
|
+
if (typeof value === "string" && schema.minLength !== undefined && value.trim().length < schema.minLength) {
|
|
92
93
|
return schema.minLength === 1 ? `${label(path)} must not be empty.` : `${label(path)} must be at least ${schema.minLength} characters.`;
|
|
93
94
|
}
|
|
94
95
|
if (Array.isArray(value)) {
|