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. What bit us once a real agent started
calling it: 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 works purely through object refs: search -> ref -> get.
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 object kinds, filters, scopes, grant hints
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 tools and grants.
https://<runtime>/api/integrations/bundles/<tenant>/<project>/
kdcube-services@1-0/public/mcp/named_services
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.
The loop Claude runs
named_services_list -> mem, conv, task, cnv
named_services_schema namespace=conv -> object kinds, filters, scopes
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)
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. search namespace=conv, query="the revenue chart", scope=user
2. get conv:conversation:<id> -> timeline; an assistant.file event carries
conv:fi:conv_<id>.turn_<id>.outputs/chart.png
3. get that conv:fi: ref -> { encoding: "url", url, expires_at }
4. Claude GETs the url over HTTP -> raw PNG, never in context
Read a spreadsheet the user uploaded last week:
1. search namespace=conv, targets=[attachment], from/to = last week
2. 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: