1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
# 0003 — socialweb-computer kcp MCP servers
Status: accepted
Date: 2026-11-02
## Context
`asyncai` already runs stdio MCP servers through `rmcp`. The first server,
`asyncai-mcp-simple`, exposes one HTTP GET tool. The next tool surface is the
set of kcp instances that `socialweb-computer` starts beside each application
instance.
A `socialweb-computer` installation is not one cluster. Every application owns
a kcp. A bidder keeps its kcp under
`/var/lib/socialweb-computer/swc-bidder/handles/<hash>/kcp`; a production
gateway keeps one under `/var/lib/socialweb-computer/socialweb/kcp`; a dev
network keeps one under its state root. Each kcp exposes a Kubernetes API with
a root logical cluster and child workspaces. The state root also holds the
admin kubeconfig.
Discovery against `hat-7` and the production gateway found the same shape:
| Fact | hat-7 | gateway (`socialweb.computer`) |
|---|---|---|
| kcp front | `127.0.0.1:33059` to slot `127.0.0.1:40359` | kcp API on `127.0.0.1:6443` |
| admin kubeconfig | `/var/lib/socialweb-computer/swc-bidder/handles/<hash>/kcp/admin.kubeconfig` | `/var/lib/socialweb-computer/socialweb/kcp/slot-a/admin.kubeconfig` |
| identity | `shard-admin`, `system:masters` | `shard-admin` |
| workspaces | `root`, `root:app`, `root:socialweb-provider` | `root`, `root:app`, `root:socialweb-provider`, account workspaces |
| access | loopback only; root-owned files; agents used `ssh` + `sudo -n kubectl` | loopback only; root-owned files |
| existing read-only role | none | none |
Two facts constrain the design.
First, the admin kubeconfig is a bearer token in `system:masters`. Every agent
session inspected reached for that file directly, so every read ran with full
write authority. A read tool that accepts the admin kubeconfig is therefore not
read-only in practice.
Second, a kcp service account token is scoped to one logical cluster. A token
minted in `root` cannot read `root:app`; each workspace needs its own token and
its own binding. "Read-only credentials" are plural by construction.
The kcp API is reachable only on the host's loopback interface. Tests must not
require a live cluster, a fixed port, or a real state path, so all transport
and file access goes through a command runner that tests replace with a fake
`kubectl`.
## Decision
Add two stdio MCP binaries:
| Binary | Tools | Credential |
|---|---|---|
| `socialweb-computer-kcp-readonly` | discovery, workspaces, api-resources, list/get, logs, events, credential status | a freshly minted, workspace-scoped service account token |
| `socialweb-computer-kcp-readwrite` | the read tools plus server-side apply, delete, patch, scale | the admin kubeconfig |
Shared implementation lives in `src/mcp/servers/socialweb_kcp/`.
```
src/mcp/servers/socialweb_kcp/
config.rs clap flags, no baked-in endpoint
runner.rs local or `ssh <host> sudo -n` command execution
discovery.rs find kcp admin kubeconfigs, read context, walk workspaces
auth.rs ServiceAccount/RBAC bootstrap and TokenRequest
service.rs kcp resolution, credential cache, protected-host guard
tools.rs tool implementations
readonly.rs rmcp router for the read-only binary
readwrite.rs rmcp router for the read-write binary
src/mcp/servers/socialweb_kcp_readonly/main.rs
src/mcp/servers/socialweb_kcp_readwrite/main.rs
```
The read-only server never uses the admin kubeconfig for a read. On the first
tool call for a workspace it:
1. resolves the kcp from discovery;
2. applies one manifest containing a `socialweb-computer-kcp-readonly`
`ServiceAccount`, a `ClusterRole` with `get`, `list`, and `watch`, and a
`ClusterRoleBinding` in that workspace;
3. calls `kubectl create token ... --duration=900s`;
4. writes a token-only kubeconfig with the workspace URL and the admin
kubeconfig's CA data, mode `0600`, no client certificate and no client key;
5. runs every read with `--kubeconfig <token-kubeconfig>`.
Tokens are cached per `(kcp, workspace)` and refreshed when less than three
minutes remain. The token itself is never returned by a tool; the credential
tool reports only the private kubeconfig path and lifetime.
Discovery searches state roots for `admin.kubeconfig` under a `kcp` directory,
excludes `shared`, groups slot directories under one kcp root, and prefers the
kcp-root file over a slot file. `--root` and `--kubeconfig` are repeatable.
Nothing is searched unless configured; the default roots are the documented
state locations.
`--ssh <host>` makes the runner execute every command on that host. The admin
kubeconfig and minted token files never leave the host. The kubeconfig's server
URL is read from the file at runtime; no host or port is compiled in.
Protected hosts (`socialweb.computer`, `hat-7`, `hat-9`) refuse every operation
until `--allow-prod` is passed. This includes the read-only server, because
minting its credential creates RBAC objects.
`--mcp-stdio` wires either binary into `asyncai`:
```bash
./target/debug/asyncai \
--mcp-stdio "./target/debug/socialweb-computer-kcp-readonly --ssh hat-7 --sudo --allow-prod"
```
## Invariants
1. No endpoint or port is compiled in. A server URL comes from a kubeconfig
read at runtime or from `--ssh` plus that kubeconfig.
2. The read-only binary never passes the admin kubeconfig to `get`, `logs`,
`api-resources`, or any other read command.
3. A read-only token is minted per workspace. Tokens are never reused across
workspaces.
4. A minted token has a 900 second lifetime and is refreshed before it expires.
5. A token kubeconfig contains a bearer token, CA data, and an optional TLS
server name. It contains no client certificate and no client key, and is
mode `0600`.
6. Tool output and command logs never contain the admin token, the minted
token, or kubeconfig contents.
7. The read-only tool set contains no mutating verb. Its only writes are the
RBAC bootstrap and the TokenRequest.
8. The read-write server uses the admin kubeconfig and no read-only token.
9. `apply_object` validates `apiVersion`, `kind`, and `metadata.name`, and
rejects a `List` and `generateName`.
10. Discovery excludes `shared` admin kubeconfigs and collapses slot files
under their kcp root.
11. Protected hosts require `--allow-prod`, in both binaries.
12. Tests use a fake `kubectl`, a temporary state directory, and an ephemeral
loopback port. No test reaches a real kcp.
13. `apply_object` with `dry_run` performs server-side admission and stores
nothing.
14. Object-returning kubectl calls use `-o json`, so code mode
([ADR 0004](0004-code-mode.md)) parses them into Python `dict`s and the
model filters them with the stdlib. `get_api_resources` converts kubectl's
wide table into a JSON array of `{name, short_names, api_version,
namespaced, kind, verbs, categories}`. `delete_object` uses `-o name`;
`get_logs` is plain text. Output is never truncated. A failed tool
returns an MCP result with `is_error: true` and an `error: ...` text, so
code mode raises it as `RuntimeError`.
15. JSON tools declare an MCP `outputSchema` and return `structuredContent`
(plus the same JSON as text). Because `structuredContent` must be an
object, array results are wrapped: `list_workspaces` returns
`{"workspaces": [...]}` and `get_api_resources` returns
`{"resources": [...]}`. Kubernetes objects and lists use a shared
`KubeObject` / `KubeList` schema. Each description ends with a one-line
Python example for code mode.
16. Tools carry MCP annotations. Read tools are `read_only_hint: true`.
`apply_object`, `patch_object`, and `scale_object` are
`read_only_hint: false`, `destructive_hint: false`. `delete_object` is
`destructive_hint: true`. Code mode logs every non-read-only call at
`warn`.
17. The `kcp` argument is optional. When omitted, the server uses the only
discovered kcp, or fails with the list of kcp ids if there are several.
18. Watchable resources (ADR 0005):
`kcp-events://watch/{workspace}/{resource}{?namespace,labelSelector,fieldSelector,kcp}`
is an event-log resource. One watcher per URI seeds with a list, streams
`kubectl get -w --output-watch-events -o json --request-timeout=0` with
the read credential (minted token), dedupes on uid + resourceVersion,
strips `managedFields`, keeps a 256-event ring with `seq`, pushes
`notifications/resources/updated` via `subscriptions/listen`, and
reconciles by re-listing after a stream ends. It gives up after 5
consecutive failures with an `ERROR` event. No polling. `MODIFIED`
events (streamed or reconciled) carry `old`, the previous known object.
19. `kcp-api://{workspace}/resources{?kcp}` is a readable, non-watchable
resource with the same JSON as `get_api_resources`.
20. `list_objects` / `get_object` with an unknown resource type append
`did you mean: ...` (up to 3 names from api-resources: plural, short
name, kind, `name.group`; a `-`/`_` group typo maps to `.`).
## Test plan
Hermetic tests in `tests/socialweb_kcp_mcp_stdio.rs` drive the real binaries
through `McpToolExecutor`:
- the read-only server lists `list_kcps`, `list_workspaces`, `get_api_resources`,
`list_objects`, `get_object`, `get_logs`, `get_events`, and
`readonly_credentials`, and does not list `apply_object` or `delete_object`;
- discovery reads a context from the fake kubeconfig and reports the state path;
- a recursive workspace walk visits `root` and `root:app` and returns both;
- `list_objects` triggers the RBAC bootstrap and TokenRequest, then reads with
`--kubeconfig <temp token file>`;
- the generated token kubeconfig contains the minted token and CA data, and
contains no client certificate, client key, or admin token;
- the token kubeconfig is mode `0600`;
- the credential tool reports a lifetime and never echoes the token;
- the read-write server lists the write tools and `apply_object` runs
`kubectl apply --server-side` with the admin kubeconfig and sends the
manifest on stdin;
- object-returning calls pass `-o json`; `get_api_resources` returns a JSON
array;
- `apply_object` rejects a manifest without `metadata.name` before running
`kubectl`.
Unit tests cover kubeconfig context parsing, workspace URL derivation, remote
shell quoting, token kubeconfig construction, and API-resource table to JSON
conversion.
## Verification
```text
$ cargo fmt --check
$ cargo clippy --all-targets -- -D warnings
$ cargo test
running 3 tests
test apply_rejects_a_manifest_without_a_name ... ok
test readwrite_server_applies_with_admin_credentials ... ok
test readonly_server_discovers_and_reads_with_a_minted_token ... ok
```
All Rust tests pass without a kcp, a state directory, an SSH host, or a known
port.
Live verification is separate from `cargo test`. The read-only binary ran over
SSH with `--ssh hat-7 --sudo --allow-prod`:
| Call | Result |
|---|---|
| `list_kcps` | one kcp, discovery path from the host, `running: true` |
| `list_workspaces` recursive | `root:app` and `root:socialweb-provider`, both `Ready` |
| `get_api_resources` in `root:app` | served resource list |
| `list_objects` `computevms` in `root:app` | empty `List` |
| `readonly_credentials` in `root:app` | token kubeconfig written mode `0600`, lifetime 900 s |
| `list_objects` workspaces in `root` | two workspaces, read with the `root` token |
The read-write binary ran with `--ssh hat-7 --sudo --allow-prod`:
| Call | Result |
|---|---|
| `list_objects` `computevms` in `root:app` | empty `List` with the admin kubeconfig |
| `apply_object` dry run of a ConfigMap | object returned by server-side admission, not stored |
The dry-run object was checked afterwards and was absent:
```text
Error from server (NotFound): configmaps "mcp-dry-run-probe" not found
```
The minted identity on the host:
```text
Username system:serviceaccount:default:socialweb-computer-kcp-readonly
Extra authentication.kcp.io/scopes [cluster:1mrhuj6frnoznfpq]
```
Permission probe with that token, in `root:app`:
```text
create configmaps no - access denied
list computevms yes
list workspaces in root no - access denied
```
The last line is the workspace scoping invariant in action: the `root:app`
token cannot read the `root` workspace. A later read in `root` minted a
separate `root` token and succeeded.
The read-only ServiceAccount, ClusterRole, and ClusterRoleBinding were present
in the workspace after the call.
## Consequences
Reads from the read-only server are genuinely read-only: the admin token is
used only to create three RBAC objects and a TokenRequest, and every read after
that carries a workspace-scoped token that the server did not possess before
the first call.
The bootstrap leaves one ServiceAccount, one ClusterRole, and one
ClusterRoleBinding per workspace. They are named deterministically and applying
them again is a no-op. Deleting them revokes future tokens but does not revoke
tokens already minted; those expire within fifteen minutes.
The server shells out to `kubectl` rather than embedding a Kubernetes client.
This keeps the implementation small and uses the kubeconfig and TLS behavior
operators already rely on. It requires `kubectl` on the target host, which
`hat-7` has and the production gateway obtains from its embedded k3s. A future
change can replace the runner with a native client behind the same tool
surface.
The read-only role grants `get`, `list`, and `watch` on every resource,
including Secrets. It is read-only, not least-privilege. A caller that needs a
narrower surface should replace the generated ClusterRole; the tool surface
does not change.
`--allow-prod` is a deliberate speed bump, not an authorization boundary. The
admin kubeconfig remains the real authority in the read-write binary.