Connect Your Named Services To Claude Code
Field notes from making a KDCube named service — conversations (conv) — usable by Claude Code as an
external agent over the managed named_services MCP surface. The agent now discovers one exact
capability contract before it searches objects. Binary files returned as base64 blew the model's context, so they now come back as a
short-lived download link; and refs had to be made self-contained because the
agent has no ambient session.
This journal entry records what it actually took to make a KDCube named service —
conversations (conv) — usable by Claude Code as an external agent over the managed MCP surface, and
the concrete nuances we fixed once a real agent started calling it.
The important design decision is simple:
Claude Code connects to one generic MCP surface (named_services).
It discovers the contract progressively: schema root -> capability -> exact operation.
It works with objects through refs: search -> ref -> get or call.
It has no KDCube session and no ambient conversation.
Every ref it gets back must resolve on its own.
Binary files come back as a link — never base64 in the model's context.
The article Named Services: The Interface Between Agents And App Realms covers the design; this is the field-notes companion — what bit us and how each piece now behaves.
The surface and the connect
One app, kdcube-services@1-0, exposes a generic named_services MCP surface. The agent
gets namespace-agnostic tools and picks the namespace per call:
named_services_list discover namespaces
named_services_about intro + search scopes for one namespace
named_services_capabilities what this namespace supports right now
named_services_schema root/path/query -> exact operation contract
named_services_search find objects -> refs
named_services_get read one object (or many refs) by ref
named_services_call generic operation (object.action, ...)
Connecting Claude Code is the delegated-consent flow: add the MCP URL as a connector, sign in to KDCube, approve the resource_grants and selected MCP tools.
https://<runtime>/api/integrations/bundles/<tenant>/<project>/
kdcube-services@1-0/public/mcp/named_services
Claude Code is one consumer of this surface. An agent hosted inside a KDCube app reaches the same door without the connector journey: its app declares a delegated connection, and the user grants that specific agent with one click in Connection Hub (Delegated by KDCube) — same guard, same per-call enforcement.
The consent, guard, and delegated credential are the Connection Hub series' job. This entry starts where the agent is already connected and asks: what should each call return so Claude can actually use it?
If Claude reaches consent but approval returns invalid_client / unknown client_id,
the MCP surface is not the first suspect. Dynamic client registration and consent POST validation live in
Connection Hub. Check proc logs for:
[connection-hub.oauth] authorize_consent rejected ...
[connection-hub.oauth] dynamic_client_missing ...
[connection-hub.oauth] consent params recovered from authorize referrer ...
A custom consent renderer should preserve the Connection Hub payload's hidden authorize fields
(client_id, redirect_uri, scope, resource, state, PKCE) and POST approve/deny to
form_action. Connection Hub can recover missing non-secret authorize fields from the same-origin
authorize referrer, but the renderer contract is still to submit them.
Discover the contract before the object
The external agent does not need every provider payload contract in its tool catalog. It reveals one branch of the realm as the task becomes concrete:
named_services_list
-> namespaces available on this connection
named_services_about namespace=conv
-> purpose, refs, search scopes, domain vocabulary
named_services_schema namespace=conv
-> recursive capability catalog root
named_services_schema namespace=conv, query="open a produced file"
-> compact catalog_path + object_kind + schema_operation matches
named_services_schema namespace=conv,
object_kind=<match kind>,
schema_operation=<match operation>
-> exact payload, result, selector, and authority contract
The query searches the provider's capability declaration, not conversations or other user data. The provider-owning bundle prepares that small catalog index; Claude only queries it. Object search remains a separate provider operation and is only as semantic as that provider makes it. This is the same contract-first flow a hosted agent can use, expressed entirely through MCP tools and self-contained refs.
The loop Claude runs
named_services_list -> mem, conv, task, cnv
named_services_about namespace=conv -> purpose, refs, search scopes
named_services_schema namespace=conv -> root catalog
named_services_schema namespace=conv,
query="find a file" -> compact capability matches
named_services_schema namespace=conv,
object_kind=<match kind>,
schema_operation=<match operation>
-> exact search/get contract
named_services_search namespace=conv -> conv:turn / conv:fi refs
named_services_get conv:conversation:<id> -> a lightweight timeline
named_services_get conv:fi:<...> -> the file (text inline / binary url)
Everything below is a thing that loop got wrong on the first real run, and what it does now.
Where it bit us hardest: files over MCP
We asked Claude to fetch a chart the assistant had produced in a conversation. object.get on the
conv:fi: ref returned the real 143 KB PNG — as ~190 KB of base64 inside the JSON result.
Claude's client could not hold that in context: it offloaded the whole tool result to a file and ran Python to
walk the JSON and base64-decode the bytes. It got the file, but the path was wrong.
An MCP tool result is JSON. Bytes can only ride inline as base64, and base64 lands in the model's context.
That is the wrong boundary for bytes. So conv now answers object.get for a file by
type:
object.get conv:fi:conv_<id>.turn_<id>.outputs/chart.png
->
{ ref, filename, mime, size, encoding, ... }
encoding = text content is the decoded text (inline). Small, context-safe.
encoding = url fetch the bytes from `url` over HTTP — a short-lived signed link.
The default for binaries: bytes never enter the model's context.
encoding = base64 content is base64 (small binaries only).
encoding = none metadata only (too large, or no link configured).
The link is session-less and signed: a stateless HMAC token bound to the exact file and requester (file ref + user + conversation + tenant + project + a short expiry), verified by a public download route that re-materializes the bytes under the requester's own identity. Claude fetches it with a plain GET; the model never sees the bytes.
GET .../kdcube-services@1-0/public/conv_file_download?object_ref=...&download_token=...
-> verify token (signature + expiry + exact file)
-> materialize bytes under the token's identity
-> stream them (Content-Disposition attachment)
The inverse path is not "write a local file and hope the provider sees it." For
provider actions that need bytes, the action schema names one source. Inside a chat turn
that source is a durable KDCube artifact file_path, usually a
conv:fi: ref. Outside a chat turn, the client requests an upload target,
uploads the bytes, and passes the returned staged_ref; a tiny generated file
can inline content_base64 with a filename. The provider materializes the
source under the requester's current authority, not from the model's stdout or a
sandbox-local path.
The staging handshake is three real operations: request the target, upload the
complete bytes to its upload_url, then invoke the provider action with the
returned staged_ref. The ref alone does not contain the file. An external
agent whose network policy cannot reach the deployment host must allow that
upload destination or use the small inline fallback. Inline image bytes are
checked for obvious truncation before the provider call, so a decodable but
incomplete PNG does not count as a successful upload.
Refs must stand on their own
The first cut emitted bare fi:turn_... refs. Inside the runtime those resolve against the ambient
conversation; Claude has no ambient conversation, so it passed one back and got not_found.
The fix is to make every emitted ref carry its own scope:
before: fi:turn_<id>.outputs/chart.png (needs ambient state)
after: conv:fi:conv_<id>.turn_<id>.outputs/chart.png (resolves on its own)
Now a ref round-trips: whatever search or object.get hands back is valid input to the next
object.get.
Lean, honest results
The early search results were verbose and unhelpful: empty snippets, a full single-object envelope on every hit, and titles set to the turn id. An agent cannot choose a hit from that. The search now returns lean, actionable rows:
{ ref: "conv:turn:<id>",
title: "<first meaningful snippet text>", # never blank, never the raw id
body: { conversation_id, turn_id, snippets: [ {role, path, text} ] },
score }
Three rules came out of this:
Default to the useful scope.
conv search defaults scope=user (recall across the user's conversations).
Expose only a knob the agent can set sanely.
no min_score filter — the hybrid score is not normalized 0..1, so a threshold
would be a trap; ordinal/rank_score are off the agent-facing schema.
Capabilities reflect what is wired.
search/list/get report true only when the backing service is configured.
The conversation timeline
object.get conv:conversation:<id> is not a raw dump. It fetches the rich per-turn record and
distills a compact, time-ordered timeline:
{ conversation_id, user_id, title, turn_count,
turns: [ { turn_id, events: [ ... ] } ] }
events: user.message | user.attachment | assistant.thinking |
assistant.message | assistant.file | artifacts | sources
Produced files and uploaded attachments surface as conv:fi: events with conversation-scoped refs —
which is exactly what Claude hands to the next object.get. Heavy bodies are dropped: sources collapse
to sid/title/url, artifacts to name/title/format.
Config: the secret lives in the descriptor
The download link is signed, so it needs a signing secret. It comes from one place — the bundle secret descriptor — with no environment variables and no fallback chain. One key:
# bundles.secrets.yaml
bundles:
items:
- id: kdcube-services@1-0
secrets:
conversations:
file_download_secret: <hex>
Mint (on the MCP call) and verify (on the download hit) resolve the same descriptor secret, so it is stable
across worker processes. Absent secret means no link — binaries then fall back to inline/metadata, and the
download route replies download_not_configured.
Two scenarios
Recall a discussion and open the chart the user made:
1. schema query="find a produced file" -> expand exact search/get contracts
2. search namespace=conv, query="the revenue chart", scope=user
3. get conv:conversation:<id> -> timeline; an assistant.file event carries
conv:fi:conv_<id>.turn_<id>.outputs/chart.png
4. get that conv:fi: ref -> { encoding: "url", url, expires_at }
5. Claude GETs the url over HTTP -> raw PNG, never in context
Read a spreadsheet the user uploaded last week:
1. schema expand the exact attachment-search and file-get contracts
2. search namespace=conv, targets=[attachment], from/to = last week
3. get the conv:fi: attachment ref
-> a .csv/.md comes back encoding=text (inline);
an .xlsx comes back encoding=url (fetch the bytes to analyze)
Logs to look for
[conversation.named_service.file] ref=conv:fi:... fi=fi:... conversation_id=... user_id=...
[conversation.files] materialize ref=... physical=... conversation_id=... browser_conversation_id=...
[conversation.files] rehost ref=... rehosted=... files_found=... missing=... errors=...
Download route outcomes:
200 bytes streamed (Content-Disposition attachment)
403 download_token_rejected bad signature / expired / wrong file
400 download_request_invalid missing ref or token
503 download_not_configured no conversations.file_download_secret in the descriptor
A binary that comes back encoding=none with no error usually means the secret is unset or the
public origin was unknown at mint time — check the descriptor and the request base.
Related publications
Documentation on GitHub
The live docs behind this entry: