Ecosystem & interop
Make governed agents first-class citizens elsewhere: consume MCP tools, serve over A2A, publish as a Microsoft 365 / Foundry custom-engine agent, emit a full-run OpenTelemetry span tree, and wire human-in-the-loop approvals into the audit chain. Everything here is optional and local-first — protocol SDKs are extras, and the telemetry is a no-op unless OpenTelemetry is installed and configured.
MCP — consume Model Context Protocol tools
load_mcp_tools(session) turns an MCP server’s tools into governed Tools (the schema comes
from the server). MCP is async, so use them with run.aio(...). Install the client with
pip install "cendor-sdk[mcp]".
from mcp import ClientSession
from mcp.client.stdio import stdio_client, StdioServerParameters
from cendor.sdk import Agent, run, load_mcp_tools
params = StdioServerParameters(command="my-mcp-server")
async with stdio_client(params) as (r, w), ClientSession(r, w) as session:
await session.initialize()
tools = await load_mcp_tools(session) # each MCP tool -> a governed Tool
agent = Agent(name="assistant", model="gpt-4o", tools=tools, instructions="Use the tools.")
result = await run.aio(agent, "…") # MCP calls flow through the bus/audit/budget
import { Agent, run, loadMcpTools } from '@cendor/sdk';
// `session` is your MCP client session — the duck-typed shape of @modelcontextprotocol/sdk's
// `Client` (camelCase `listTools()` / `callTool(name, args)`); a fake session tests it offline.
const tools = await loadMcpTools(session); // each MCP tool -> a governed Tool
const agent = new Agent({ name: 'assistant', model: 'gpt-4o', tools, instructions: 'Use the tools.' });
const result = await run(agent, '…'); // MCP calls flow through the bus/audit/budget
The integration is duck-typed against a session with async list_tools() /
call_tool(name, args), so a fake session tests it offline. load_mcp_resources(session) reads
resources into {uri: contents}; load_mcp_prompts / get_mcp_prompt cover prompts.
A2A — serve an agent over the Agent-to-Agent protocol
from cendor.sdk import Agent, A2AServer, A2AClient
agent = Agent(name="greeter", model="gpt-4o", instructions="Greet.")
server = A2AServer(agent)
# In-process (offline / embedded):
client = A2AClient(server)
print(client.card()) # the A2A agent card (name, skills, IO modes)
print(client.send("hi")) # -> the agent's reply
full = client.send_full("hi") # full A2A result incl. governance metadata:
# {"trace_id": ..., "cost_usd": ..., "agents": [...]}
# Over local HTTP (optional, opt-in — stdlib only):
from cendor.sdk.a2a import serve
httpd = serve(agent, host="127.0.0.1", port=8080) # GET /.well-known/agent-card.json ; POST /
# httpd.serve_forever() (run in a thread; httpd.shutdown() to stop)
import { Agent, A2AServer, A2AClient, serve } from '@cendor/sdk';
const agent = new Agent({ name: 'greeter', model: 'gpt-4o', instructions: 'Greet.' });
const server = new A2AServer(agent);
// In-process (offline / embedded):
const client = new A2AClient(server);
console.log(client.card()); // the A2A agent card (name, skills, IO modes)
console.log(await client.send('hi')); // -> the agent's reply
const full = await client.sendFull('hi'); // full A2A result incl. governance metadata:
// { metadata: { trace_id, cost_usd, agents } }
// Over local HTTP (optional, opt-in — node:http only):
const httpd = serve(agent, { host: '127.0.0.1', port: 8080 }); // GET /.well-known/agent-card.json ; POST /
// httpd.close() to stop
Note what rides along: the A2A reply carries the run’s trace_id and cost, so a consumer of
your agent sees governed metadata, not just text.
Microsoft 365 / Foundry — publish as a custom-engine agent
FoundryAdapter speaks the Bot Framework Activity protocol — the surface a custom-engine
agent exposes to Copilot / Teams / Azure AI Foundry:
from cendor.sdk import Agent, FoundryAdapter
adapter = FoundryAdapter(Agent(name="assistant", model="gpt-4o", instructions="Help."))
# In your web endpoint:
reply = adapter.on_activity(incoming_activity) # -> outgoing message Activity, or None
# reply["channelData"]["cendor"] carries {"trace_id", "cost_usd", "agents"}
adapter.manifest() # minimal registration manifest
import { Agent, FoundryAdapter } from '@cendor/sdk';
const adapter = new FoundryAdapter(new Agent({ name: 'assistant', model: 'gpt-4o', instructions: 'Help.' }));
// In your web endpoint:
const incomingActivity = { type: 'message', text: 'hi', from: { id: 'user' } };
const reply = await adapter.onActivity(incomingActivity); // -> outgoing message Activity, or null
// reply?.channelData.cendor carries { trace_id, cost_usd, agents }
adapter.manifest(); // minimal registration manifest
OpenTelemetry — a full-run gen_ai span tree
span_tree(result) emits a gen_ai.* span tree for a completed run, so the whole trajectory
shows up in Foundry / Datadog / Jaeger: a root agent.run, a child per agent segment, and a
grandchild per model call (chat {model}) and tool execution (execute_tool {name}). For
live spans as the run progresses, wrap it in with live_spans():. Uses OpenTelemetry
directly (extra [otel]) and is a no-op returning False if OTel isn’t installed.
from cendor.sdk import run
from cendor.sdk.otel import span_tree
result = run(agent, "…")
span_tree(result) # spans exported to your configured OTel pipeline; mirrors result's tree
import { run, spanTree, liveSpans } from '@cendor/sdk';
const result = await run(agent, '…');
spanTree(result); // spans exported to your configured OTel pipeline; mirrors result's tree
const live = liveSpans(); // or stream spans as the run progresses…
await run(agent, '…');
live.close(); // …and stop
Spans carry gen_ai.request.model, gen_ai.system, gen_ai.usage.input_tokens /
output_tokens, gen_ai.usage.cost, and per-agent gen_ai.agent.name.
Human-in-the-loop — approvals in the audit chain
acttrace records that oversight happened; the pause/approve/resume mechanics
are your app’s job. require_approval wraps a tool so each call consults an approver (the
pause) and records the verdict via decision.human_oversight(...) on the same audit chain
the run is correlated by:
from cendor.sdk import Agent, run, AuditLog
from cendor.sdk.hitl import require_approval
def approver(tool_name, args):
return args["amount"] < 100, "auto-approved under $100" # -> (approved, note)
refund = require_approval(issue_refund, approver=approver, reviewer="ops@bank")
agent = Agent(name="support", model="gpt-4o", tools=[refund], instructions="Use tools.")
log = AuditLog(system="support", risk_tier="high", path="audit.jsonl")
run(agent, "Refund order 42 for $50", audit=log)
# audit.jsonl gains a human_oversight entry (approved/rejected), hash-chained & verify()-able.
# On rejection the real tool never runs and the model gets a denial to react to.
import { Agent, run, AuditLog, requireApproval } from '@cendor/sdk';
const approver = (toolName, args): [boolean, string] =>
[args.amount < 100, 'auto-approved under $100']; // -> [approved, note]
const refund = requireApproval(issueRefundTool, { approver, reviewer: 'ops@bank' });
const agent = new Agent({ name: 'support', model: 'gpt-4o', tools: [refund],
instructions: 'Use tools.' });
const audit = new AuditLog('support', { riskTier: 'high', path: 'audit.jsonl' });
await run(agent, 'Refund order 42 for $50', { audit });
// audit.jsonl gains a human_oversight entry (approved/rejected), hash-chained & verify()-able.
// On rejection the real tool never runs and the model gets a denial to react to.
In production the approver is where you block on a reviewer — a prompt, a queue, a webhook —
then return the decision to resume (or deny) the run.
Honest limits
- MCP stdio transport is a local-process affair — on edge runtimes use HTTP/SSE transports.
- A2A’s built-in HTTP server is stdlib-simple — fine for local and embedded use; put a real server in front of it for production traffic.
span_treeexports; it doesn’t collect. You still need an OTel pipeline (collector, backend) configured in your process.require_approvalis synchronous at the tool boundary — a long human pause holds the run open; for hours-long approvals, checkpoint the run and resume it (hardening).