@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 CHANGED
@@ -1,9 +1,14 @@
1
1
  # @capacms/mcp
2
2
 
3
- stdio MCP server for Capa. Seventeen reads, two writes, two resources and three
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/`, which resolves the tenant from the key and never reads that
47
- header, and no legacy tool that could send it is registered for a `cap_` key,
48
- so the server starts without it rather than refusing over a value nothing it
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
- Nineteen tools, of which a given key is offered a subset. The `needs` column is
58
- the key permission or the scope a tool asks for, and the `surface` column is
59
- which half of the API it calls, which is what decides whether it is registered
60
- at all (see [the page tools](#the-page-tools)).
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 | legacy | read |
66
- | `capa_set_model_layout` | design that model's entry editor, or reset it to linear | legacy | **agent** |
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 | legacy | read |
72
- | `capa_get_workspace` | one workspace as a document you can edit and write back | legacy | read |
73
- | `capa_set_workspace` | create a workspace, or apply a document to one | legacy | **write** |
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, where the deployment serves no GraphQL, or of the models GraphQL leaves out | `/api/` | `instance:read` for any model |
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 seventeen reads are
86
- `readOnlyHint: true`. The two writes are `readOnlyHint: false` and
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. `capa_set_workspace` is not, because without an `id` it creates a new
90
- workspace on every call. Every tool is `openWorldHint: false`, since none
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 nothing, since no tool can write. A budget refusal passes on what
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 also pointed at `capa_list_content` and `capa_get_content`.
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. It reads `GET /api/entries/<model>` with REST's own
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; nothing here can write." A
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 eight `/api/` and offline tools, and nothing else (`capa_read_entries` in place of the four GraphQL tools where GraphQL is off) |
706
- | any other key: `pk_…`, `sk_…` or unprefixed | those eight **and** all ten legacy tools |
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
- A `cap_` key is refused on every legacy mount before the lookup even runs, so
709
- offering it `capa_list_models` would be offering a tool that cannot work. A
710
- legacy key loses nothing: `/api/` accepts it and derives its scopes from the
711
- key's bundle.
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. Neither failure is evidence about what the
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. Run it on
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/` 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.
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; 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
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
- * 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.
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 { loadMe, probeGraphQL, probePages, probeRestOnly, scopeNote, selectTools } from "../lib/registry.mjs";
33
- import { createSession } from "../lib/session.mjs";
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
- // 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
- });
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);
@@ -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. Every writer here can replace what is there, so each is
23
- * destructive.
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: true, idempotentHint: idempotent, openWorldHint: false } };
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
- if (typeof value === "string" && schema.minLength !== undefined && value.length < schema.minLength) {
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)) {