Trip2g Federation Protocol — Adapter Implementation Guide
Audience: anyone building an MCP-compatible peer/adapter that should be reachable from trip2g hub via federated_search / federated_similar / federated_note_html.
Status: v1, current as of 2026-05-02. Reference implementations:
- Hub side:
internal/case/mcp/,internal/federation/,internal/model/federation.goin trip2g. - Peer side (telegram):
trip2g_telegram_adapter(after the fixes described in this doc).
1. Transport
- HTTP POST
<base>/_system/mcp. - Request body: JSON-RPC 2.0 envelope.
- Response body: JSON-RPC 2.0 envelope.
- Content-Type:
application/jsonboth directions. - Hub per-peer fan-out timeout: 5 seconds (
FEDERATED_FANOUT_TIMEOUTenv on hub; the underlying HTTP client also enforces a 2s request timeout). - Hub fan-out parallelism: 5 peers at a time (
FEDERATED_FANOUT_CONCURRENCY). - Blind fan-out peer cap: 7 (
FEDERATED_FANOUT_LIMIT); peers over the cap come back in the response'sskippedlist. Explicitkb_id/kb_idscalls are not capped. - Recursion depth limit (hub-enforced): 3 (
MCP_FEDERATION_MAX_DEPTH).
A peer is a black box behind one URL. There is no separate metadata endpoint; everything goes through tools/list and tools/call.
2. Authentication
2.1 Channels accepted by the adapter
The peer MUST inspect Authorization: Bearer <value> and ?token=<value> query parameter. Hub uses the header.
2.2 Federation HMAC JWT (verbatim spec)
Federation tokens are HMAC-SHA256 JWTs. The peer treats any non-empty Bearer/?token= value as a JWT and validates it per the rules below. Anything that fails validation → anonymous Auth.
Header:
{"alg":"HS256","typ":"JWT","kid":"<kid>"}
Payload:
{
"iss": "<hub-url>",
"aud": "<peer-url>",
"rid": "<request-uuid>",
"iat": <unix-seconds>,
"exp": <unix-seconds>
}
Signature: HMAC-SHA256(<base64url(header)>.<base64url(payload)>, secret_bytes), base64url-encoded without padding.
Validation rules on the peer:
- Three segments, each base64url-decodable.
- Header
alg == "HS256"andtyp == "JWT". Reject otherwise. - Header
kidis non-empty; look it up in the peer's federation_secrets store. If not found or revoked → anonymous Auth (NOT error). signature == HMAC-SHA256(header.payload, secret_bytes). Constant-time compare. Mismatch → anonymous.iatandexpare present. Allow ±10s clock skew. Outside the window → anonymous.aud(when present and non-empty) MUST equal the peer's own base URL. Mismatch → anonymous.- (Optional, recommended)
ridMUST NOT have been seen before withinexp. Track in-memorysync.Map[rid → exp]with periodic eviction. Replay → anonymous.
Failure mode = anonymous, NOT error. Returning HTTP 401 or JSON-RPC error for an unrecognized kid leaks existence. Always return a successful JSON-RPC envelope with empty results.
2.3 Authorization context
After verification the peer constructs an internal Auth object:
type Auth struct {
KID string // for logging
AccountID string // peer-local scope owner
Allowed map[string]bool // scoped resource ids (e.g. dialog ids)
AllowAll bool // when scope is "*"
}
Map federation kid → adapter-local scope: which resources this caller may see. Trip2g hub does not control scope; the peer admin defines it at federation_secret creation time.
2.4 Issuing federation_secrets (peer admin UI)
Peer MUST expose admin endpoints to:
- create a secret: 32 random bytes, hex-encoded, returned once to the admin (mirrors trip2g UI).
- list secrets per scope:
kid, scope, created_at, last_used_at, revoked_at. - revoke (soft delete):
revoked_at = now().
Storage: encrypt secretHex at rest with the peer's master key. Verification needs raw bytes, so do NOT hash.
The two-step exchange (peer admin generates → sends kid + secretHex to hub admin → hub admin pastes into trip2g admin → hub stores encrypted in its own federation_secrets) is described in trip2g's user federation docs.
3. JSON-RPC envelope
3.1 Request
{
"jsonrpc": "2.0",
"method": "<method-name>",
"params": {...},
"id": "<unique-string-or-int>"
}
Hub uses string IDs (UnixNano timestamp). Peer SHOULD echo id verbatim in the response.
3.2 Response (success)
{
"jsonrpc": "2.0",
"id": "<echoed>",
"result": {...}
}
3.3 Response (error)
{
"jsonrpc": "2.0",
"id": "<echoed>",
"error": {"code": <int>, "message": "<text>"}
}
Use JSON-RPC error ONLY for protocol/transport problems (parse error, malformed envelope, method-not-found, transport timeout). Codes:
-32700parse error-32600invalid request-32601method not found-32602invalid params-32603internal error (transport, infra)
Tool execution failures are NOT JSON-RPC errors. They go inside result with isError: true (see §5).
4. Required methods
A peer MUST implement these four methods. Hub never queries anything else.
| Method | Purpose | Required by |
|---|---|---|
initialize |
Handshake, capabilities advertisement | All MCP clients |
tools/list |
Enumerate tools | Hub during peer discovery |
tools/call |
Execute a tool | All federated_* operations |
notifications/initialized |
No-op (client → server notice) | All MCP clients |
4.1 initialize
Request params: capabilities object (ignored by peer in v1).
Response result:
{
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "<peer-name>", "version": "<semver>"}
}
4.2 tools/list
Request params: empty.
Response result:
{
"tools": [
{
"name": "search",
"description": "<human-readable, includes input format examples>",
"inputSchema": {"type": "object", "properties": {...}, "required": [...]}
},
{"name": "note_html", ...},
{"name": "similar", ...}
]
}
A peer MAY expose only search and note_html if similar is meaningless for it. Trip2g hub will gracefully degrade — federated_similar calls are not used by the agent unless it explicitly asks for similar.
4.3 tools/call
Request params:
{"name": "<tool-name>", "arguments": {...}}
Response result: MUST be a CallToolResult (see §5).
4.4 notifications/initialized
Client → server only. Peer MAY respond with empty result: {} or treat as a notification (no response required by JSON-RPC, but trip2g hub accepts an echo).
5. CallToolResult — the canonical response shape
Every tools/call response MUST conform to:
{
"content": [{"type": "text", "text": "<short summary, <=500 chars>"}],
"structuredContent": <any JSON value, optional>,
"isError": <bool, optional, default false>
}
Reference Go type (internal/model/federation.go):
type FederationResult struct {
Content []FederationContent `json:"content"`
StructuredContent json.RawMessage `json:"structuredContent,omitempty"`
IsError bool `json:"isError,omitempty"`
}
type FederationContent struct {
Type string `json:"type"` // always "text"
Text string `json:"text"`
}
5.1 content (REQUIRED, even if empty)
Array of human/agent-readable text items. Always at least one item when results exist. For empty result sets, return one item with a summary like "Found 0 results for query \"X\"" — this lets clients without structuredContent handling still display something.
type is always "text" in v1. Image/audio types reserved for future use.
5.2 structuredContent (OPTIONAL but strongly recommended)
Tool-specific JSON. Hub forwards this to the agent. Agents that understand the structure parse it; legacy clients fall back to content[0].text.
Conventions per tool (see §6).
5.3 isError (OPTIONAL, default false)
Set true when the tool failed in a way the agent should know about (no results due to scope, embedding service down, malformed query, rate limited). The HTTP status is still 200 and result is still populated; the error is inside the tool result.
When isError: true:
content[0].textMUST contain a human-readable error message.structuredContentMAY contain machine-readable error fields ({error_code, retry_after}).- Hub will surface this to the agent; the federated_search fan-out will not include error results in merged output.
Do NOT use isError for auth failures — those return empty results (anonymous), per §2.
6. Tool-specific conventions
6.1 search
Arguments:
{"query": "<plain text or JSON DSL string>"}
The peer is free to define a DSL inside query (telegram adapter accepts JSON DSL with op, dialog_kinds, after, etc.). Hub treats query opaquely.
structuredContent:
{
"query": "<echo>",
"results": [
{
"note_id": <uint64>, // peer-stable id, opaque to hub
"note_path": "<peer-route>",
"title": "<display title>",
"score": <float>, // 0..1, present for semantic search
"url": "<https URL>",
"href": "<deep link, optional>",
"kind": "<peer-defined>",
"matches": [
{"match_id": "<id>", "snippet": "<...>", "context_words": <int>}
],
"proof": {<peer-defined provenance>}
}
]
}
content[0].text: "Found N results for \"<query>\"" plus optionally a one-line digest of the top match.
6.2 note_html
Arguments: at least one of pid, note_id, path, href, plus optional match_id, context_words.
structuredContent:
{
"html": "<sanitized full HTML, optional>",
"payload": {<peer-defined raw fields>},
"queued": <bool, optional>
}
content[0].text: "Loaded note <id-or-path>" or a brief plain-text excerpt (≤500 chars).
trip2g also returns assets in structuredContent: [{"name", "size", "sha256"}], one entry per attachment of the note. name is the attachment's path as the note's markdown writes it, and the only form the asset endpoint (§6.5) accepts. A peer without attachments may omit it.
6.3 similar
Arguments: at least one of pid, note_id, path, href. Optional limit.
structuredContent: same shape as search.results.
content[0].text: "Found N similar notes to <id>".
6.4 federated_* (recursive federation)
Peers MAY chain to their own peers. If a peer chooses NOT to (telegram adapter does not), it MUST still respond with a valid CallToolResult:
{"content": [{"type":"text","text":"This peer does not federate further."}],
"structuredContent": {"results": []}}
Do NOT return JSON-RPC error: -32601 method not found — hub treats peers as opaque; the method exists in the schema, the peer just chose not to walk further. Empty CallToolResult is correct.
6.5 Attachments: POST <base>/_system/mcp/federation/asset
Not a tool: an attachment is bytes, not a CallToolResult. The endpoint sits beside /_system/mcp, like the pairing description at /_system/mcp/federation.
Request: Content-Type: application/json, body {"note_path": "<note path>", "name": "<asset name>"}, optionally "describe": true. Signed with the federation JWT of §2.2, and the token MUST carry bh, the base64url SHA-256 of the body. The peer refuses a token without bh here: the body is what names the file, so a token that does not cover it would open every attachment the pairing can read.
Authorization: the note at note_path must be readable by the key's subgraph grant — the same check as note_html — and name must be one of that note's assets. A note outside the grant, an unknown note and an unknown name all answer 404, so a refusal does not reveal what exists.
Responses:
200with"describe": true:{"name", "size", "sha256"}as JSON.200otherwise: the file, withContent-Length(required — the hub refuses a body of unknown size) and aContent-Typefrom the file name.400—note_pathornamemissing.401— the token does not verify or does not bind the body. The hub retries once with its previous key, as for tool calls.404— see Authorization.413— the attachment is over the peer'sMCP_FEDERATION_ASSET_MAX_SIZE.
The hub applies its own MCP_FEDERATION_ASSET_MAX_SIZE as well, and MCP_FEDERATION_ASSET_TIMEOUT to the whole transfer. Its federated_asset tool asks with describe first, then hands its caller a five-minute, single-use link on the hub (/_system/mcp/federated_asset?link=…) that streams the file from the peer when fetched. The link is a JWT with a random jti; the relay records the jti before it contacts the peer and answers 410 to any later request with it, so a failed download needs a fresh federated_asset call. The record is an in-memory set per process that drops each id once its exp passes, so a deployment with several processes serving GETs allows one download per process.
7. Hub-side behavior (informational)
Hub fans out federated_search without kb_id to all peers visible to the calling user (KB-notes filtered by subgraph access). Each peer call:
- Hub looks up
federation_secretsrow bykb_url. - Decrypts secret, signs JWT for kid.
- POSTs
tools/call searchwith the user's query. - Reads response into
model.FederationResult. - Aggregates
result.structuredContent.resultsfrom all peers, sorts by score, takes top 20. - Returns merged result to the agent in hub-shaped CallToolResult.
If a peer returns isError: true, its results are skipped but other peers proceed.
If a peer returns malformed JSON or non-CallToolResult shape, hub silently drops the peer's contribution from the merged output. This is the bug previously seen with telegram adapter — fix is in §3 of the adapter TZ.
8. Replay protection details
If the peer chooses to implement replay protection (recommended):
- Cache:
sync.Map[rid string]int64where value isexp. - On request: lookup
rid. If present → reject (anonymous). Else insert. - Eviction: every minute, walk map, drop entries where
exp < now(). - Memory bound: O(rps × 30s window). At 100 rps this is ~3000 entries. Fine for sync.Map.
9. Observability
Peers SHOULD emit (logs/metrics):
- per-request: kid, hub-iss, latency, status (auth_ok / auth_anon / tool_error / ok).
- per-secret:
last_used_atupdated on successful auth (best-effort, fire-and-forget).
Trip2g hub logs incoming federation auth under prefix mcp:federation. Peers SHOULD do the same so two-instance debugging is grep-friendly.
10. Versioning
This is v1. Future versions will bump protocolVersion in initialize. Compatibility rules:
protocolVersionstrict equality required for new features.- Backward additions (new optional fields in
structuredContent) — non-breaking. - New required fields → bump version.
- Deprecated fields kept for one major version with a warning log.
11. Conformance checklist for adapter authors
- HTTP POST
/_system/mcpaccepts JSON-RPC 2.0. -
Authorization: BearerAND?token=both checked. - JWT shape detection (3 dot-separated base64 segments) routes to federation auth.
- HS256 verification with
kidlookup,iat/expvalidation, optionalaud/ridchecks. - Auth failures return anonymous (NOT JSON-RPC error or HTTP 4xx).
- Admin UI for federation_secrets CRUD with one-time plaintext display.
-
initialize,tools/list,tools/call,notifications/initializedimplemented. - Every
tools/callresponse is aCallToolResultwithcontent+ optionalstructuredContent. - Tool failures use
isError: trueinside result, NOT JSON-RPC error. -
federated_*methods return empty CallToolResult, not method-not-found. - Replay protection (recommended).
- Logs prefixed
mcp:federationfor grep parity with hub.
12. Reference implementations
- Hub-side validator (peer auth):
internal/case/mcp/federation_helpers.go(verifyInbound,parseFederationJWT). - Hub-side signer (outbound):
internal/case/mcp/federation_helpers.go(signOutboundAt). - Hub-side client (calls peers):
internal/federation/client.go(callTool). - Hub-side aggregator:
internal/case/mcp/federation_handlers.go(handleFederatedSearch). - Adapter Authorize:
trip2g_telegram_adapter/internal/mcp/mcp.go(Authorize,authorizeFederation). - Adapter store:
trip2g_telegram_adapter/internal/store/store.go(FederationSecret,FederationSecretByKID).
13. Out of scope (planned for v2)
- mTLS / OAuth.
- Per-request per-user
on_behalf_ofclaim (currently scope is per-kid, not per-end-user). - Streaming responses (SSE) for long-running searches.
- Image/audio content types in
content[]. - Negotiated
protocolVersionminimum duringinitialize.