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:
- resolves the kcp from discovery;
- applies one manifest containing a
socialweb-computer-kcp-readonlyServiceAccount, aClusterRolewithget,list, andwatch, and aClusterRoleBindingin that workspace; - calls
kubectl create token ... --duration=900s; - 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; - 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:
./target/debug/asyncai \
--mcp-stdio "./target/debug/socialweb-computer-kcp-readonly --ssh hat-7 --sudo --allow-prod"
Invariants
- No endpoint or port is compiled in. A server URL comes from a kubeconfig
read at runtime or from
--sshplus that kubeconfig. - The read-only binary never passes the admin kubeconfig to
get,logs,api-resources, or any other read command. - A read-only token is minted per workspace. Tokens are never reused across workspaces.
- A minted token has a 900 second lifetime and is refreshed before it expires.
- 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. - Tool output and command logs never contain the admin token, the minted token, or kubeconfig contents.
- The read-only tool set contains no mutating verb. Its only writes are the RBAC bootstrap and the TokenRequest.
- The read-write server uses the admin kubeconfig and no read-only token.
apply_objectvalidatesapiVersion,kind, andmetadata.name, and rejects aListandgenerateName.- Discovery excludes
sharedadmin kubeconfigs and collapses slot files under their kcp root. - Protected hosts require
--allow-prod, in both binaries. - Tests use a fake
kubectl, a temporary state directory, and an ephemeral loopback port. No test reaches a real kcp. apply_objectwithdry_runperforms server-side admission and stores nothing.- Object-returning kubectl calls use
-o json, so code mode (ADR 0004) parses them into Pythondicts and the model filters them with the stdlib.get_api_resourcesconverts kubectl's wide table into a JSON array of{name, short_names, api_version, namespaced, kind, verbs, categories}.delete_objectuses-o name;get_logsis plain text. Output is never truncated. A failed tool returns an MCP result withis_error: trueand anerror: ...text, so code mode raises it asRuntimeError. - JSON tools declare an MCP
outputSchemaand returnstructuredContent(plus the same JSON as text). BecausestructuredContentmust be an object, array results are wrapped:list_workspacesreturns{"workspaces": [...]}andget_api_resourcesreturns{"resources": [...]}. Kubernetes objects and lists use a sharedKubeObject/KubeListschema. Each description ends with a one-line Python example for code mode. - Tools carry MCP annotations. Read tools are
read_only_hint: true.apply_object,patch_object, andscale_objectareread_only_hint: false,destructive_hint: false.delete_objectisdestructive_hint: true. Code mode logs every non-read-only call atwarn. - The
kcpargument is optional. When omitted, the server uses the only discovered kcp, or fails with the list of kcp ids if there are several. - 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, streamskubectl get -w --output-watch-events -o json --request-timeout=0with the read credential (minted token), dedupes on uid + resourceVersion, stripsmanagedFields, keeps a 256-event ring withseq, pushesnotifications/resources/updatedviasubscriptions/listen, and reconciles by re-listing after a stream ends. It gives up after 5 consecutive failures with anERRORevent. No polling.MODIFIEDevents (streamed or reconciled) carryold, the previous known object. kcp-api://{workspace}/resources{?kcp}is a readable, non-watchable resource with the same JSON asget_api_resources.list_objects/get_objectwith an unknown resource type appenddid 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, andreadonly_credentials, and does not listapply_objectordelete_object; - discovery reads a context from the fake kubeconfig and reports the state path;
- a recursive workspace walk visits
rootandroot:appand returns both; list_objectstriggers 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_objectrunskubectl apply --server-sidewith the admin kubeconfig and sends the manifest on stdin; - object-returning calls pass
-o json;get_api_resourcesreturns a JSON array; apply_objectrejects a manifest withoutmetadata.namebefore runningkubectl.
Unit tests cover kubeconfig context parsing, workspace URL derivation, remote shell quoting, token kubeconfig construction, and API-resource table to JSON conversion.
Verification
$ 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:
Error from server (NotFound): configmaps "mcp-dry-run-probe" not found
The minted identity on the host:
Username system:serviceaccount:default:socialweb-computer-kcp-readonly
Extra authentication.kcp.io/scopes [cluster:1mrhuj6frnoznfpq]
Permission probe with that token, in root:app:
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.