Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 43 additions & 68 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,94 +350,69 @@ Consider the previous example with an MCP server added as the resource provider:
Alice ─────────▶ Assistant ─────────▶ MCP Server
```

To secure this interaction, AgentDNA must be integrated at the LLM execution layer, the MCP client layer, and the MCP server layer.
To secure this interaction, AgentDNA is added on both sides of the MCP connection:

**1. LLM invocation**
- on the agent's side, the **Provenance Relay** signs every MCP tool call the agent makes (it handles only agent-to-MCP-server traffic, not communication between agents);
- on the MCP server's side, the **MCP Server Middleware** verifies each call before the tool runs, and signs its answer.

Assume the AI Assistant uses LangGraph to invoke the LLM:

```py
result = await workflow.ainvoke({"messages": [HumanMessage(content=task)]})
final_message = result["messages"][-1]
```text
Alice ──▶ Assistant ──▶ MCP client ──▶ Provenance Relay ──▶ MCP Server + AgentDNA Middleware
(unchanged) signs each call verifies the call, runs the tool,
checks each answer signs the answer
```

AgentDNA provides `agentdna_context`() for maintaining the current workflow state during agent execution. MCP client integrations use this context to retrieve the current workflow and propagate it to MCP requests.

AgentDNA context takes two params:

- AgentDNA instance representing the current actor
- Current `IntentWorkflow`

```py
from agentdna.core import AgentDNA
from agentdna.mcp.context import agentdna_context
**1. Agent side: Provenance Relay**

AGENT = AgentDNA(...)
Your agent keeps using the MCP client it already uses: `langchain-mcp-adapters`, CrewAI's `MCPServerAdapter`, `fastmcp.Client`, or any other. The only change is the URL you give that client: instead of the MCP server's URL, you give it a URL from a relay session. The relay runs inside your process, so there's no extra service to start. For every tool call, it:

with agentdna_context(AGENT, existing_intent_workflow) as ctx:
result = await workflow.ainvoke({"messages": [HumanMessage(content=task)]})
final_message = result["messages"][-1]
1. signs the request as your agent and attaches the current `IntentWorkflow` to it;
2. forwards the request to the real MCP server;
3. checks the server's signed answer before your agent sees the result.

# ctx.workflows consists of the update Intent Workflow(s) propagated
# from the MCP server
if len(ctx.workflows) == 0:
raise RuntimeError("No workflows were created during agent execution")
The relay is only for an agent's calls to MCP servers. Communication between agents, such as one agent handing a task or its result to another, doesn't go through it: secure it with `build()` and `verify()`, as shown in [Securing Agent-User Interaction](#securing-agent-user-interaction).

# Use ctx.workflows to build the IntentWorkflow and pass to the next actor
adna_workflow_from_agent = SQLITE_AGENT.build(
payload=str(output),
previous_workflows=ctx.workflows,
)
```
Using it takes three steps. Assume the AI Assistant uses LangGraph with `langchain-mcp-adapters`:

**2. MCP Client Adapter**

AgentDNA provides framework-specific MCP client adapters that intercept MCP tool calls and propagate the current `IntentWorkflow` through MCP request metadata.
```py
from langchain_mcp_adapters.client import MultiServerMCPClient

The adapter should be installed in the application before the MCP client is used.
from agentdna.core import AgentDNA
from agentdna.mcp.relay import ProvenanceRelay

For instance, when use `langchain-mcp-adapters` for building the MCP Client:
AGENT = AgentDNA(...)

```py
from __future__ import annotations
# 1. Once per process: give each MCP server a name, and its URL.
PROVENANCE_RELAY = ProvenanceRelay({"rss": "http://127.0.0.1:8013/mcp"})

from langchain_core.tools import BaseTool
from langchain_mcp_adapters.client import MultiServerMCPClient
from langchain_mcp_adapters.sessions import StreamableHttpConnection

from config import settings

# Import and use the AgentDNA MCP Client Adapter
from agentdna.mcp.client.langchain import install_mcp_client
install_mcp_client()

def build_client() -> MultiServerMCPClient:
return MultiServerMCPClient(
{
"rss": StreamableHttpConnection(
transport="streamable_http",
url=settings.rss_mcp_url,
timeout=settings.mcp_timeout_seconds,
sse_read_timeout=settings.mcp_timeout_seconds,
)
},
# 2. For each task: open a session with the IntentWorkflow the Assistant received.
# The session verifies that workflow before anything else happens.
async with PROVENANCE_RELAY.session(AGENT, existing_intent_workflow) as session:
client = MultiServerMCPClient(
{"rss": {"transport": "streamable_http", "url": session.url("rss")}}
)
workflow = create_react_agent(llm, await client.get_tools())
result = await workflow.ainvoke({"messages": [HumanMessage(content=task)]})


async def load_tools() -> list[BaseTool]:
"""Discover the configured RSS server's tools; direct network access is prohibited for agents."""
return await build_client().get_tools()
# 3. After the session: session.workflows is the chain so far, including every tool call.
# Build the Assistant's answer on it, and pass that to the next actor.
adna_workflow_from_agent = AGENT.build(
payload=str(result["messages"][-1].content),
previous_workflows=session.workflows,
)
```

The AgentDNA client adapter adds the workflow to the MCP request `_meta` field. The MCP server can then extract and verify the workflow before executing the requested tool.

Currently supported MCP client integrations are:
Good to know:

- [Langchain MCP Adaptors](https://github.com/langchain-ai/langchain-mcp-adapters) - `from agentdna.mcp.client.langchain import install_mcp_client`
- [CrewAI Tools](https://github.com/crewAIInc/crewAI/tree/main/lib/crewai-tools) - `from agentdna.mcp.client.crewai import install_mcp_client`
- `session.url("rss")` uses the name from step 1. One relay can front several MCP servers: call `session.url(name)` for each.
- The agent doesn't have to call a tool. If it calls none, `session.workflows` is simply the workflow it received.
- If the incoming workflow fails verification, opening the session raises `WorkflowRejected`. If an MCP server's answer can't be verified, the agent gets an error instead of the result, so it never acts on output that's missing from the chain.
- If an MCP server refuses a tool call (its whitelist, CoCA or CBAC check fails), it records the refusal, and that chain has ended. The relay then refuses the session's further tool calls, and closing the session raises `ChainTerminated`: stop the run there, and don't build on or pass on its workflow.
- In synchronous code, use `with` instead of `async with`.
- MCP servers must be reachable over Streamable HTTP.

For how the relay works, see [agentdna/mcp/relay/README.md](agentdna/mcp/relay/README.md). For complete examples, see [GitHub Repository Agent](examples/github_repository_agent) (LangGraph), [RSS Research Agent](examples/rss_research_agent) (LangGraph, two agents) and [SQLite Analytics Agent](examples/sqlite_analytics_agent) (CrewAI).

**3. MCP Server Middleware**
**2. MCP Server Middleware**

`AgentDNAMCPMiddleware` intercepts protected MCP requests on the server side and performs security checks before allowing the underlying MCP handler to execute.

Expand Down
47 changes: 33 additions & 14 deletions agentdna/auth/httpobserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,24 +246,43 @@ def _record(dna, source, headers, host, body=None, status=None) -> None:
return # not serving a request, so nothing to attach this to
request_id, run_id = envelope.signature, envelope.run_id

facts = observe(dict(headers), load_key(dna.config_dir, dna.logger))

sink.send(
AuthEvidence.from_facts(
facts,
run_id=run_id,
request_id=request_id,
source=source,
destination=str(host),
auth_status=_auth_status(status),
observed_at=time.time(),
),
dna,
)
_send(dna, source, headers, host, status, request_id, run_id)
except Exception as exc:
dna.logger.warning("agentdna.authevidence.outbound_failed", error=str(exc))


def record_call(dna, step, source, headers, host, status=None) -> None:
"""Record one call whose agent and signed step the caller already knows.

For the relay: it signs the step, forwards it and gets the answer, so it
hands everything over instead of the hook guessing. Never raises.
"""
if not sink.enabled():
return
try:
_send(dna, source, headers, host, status, step.signature, step.run_id or "")
except Exception as exc:
dna.logger.warning("agentdna.authevidence.outbound_failed", error=str(exc))


def _send(dna, source, headers, host, status, request_id, run_id) -> None:
"""Observe the headers and write the record. Raises; callers catch."""
facts = observe(dict(headers), load_key(dna.config_dir, dna.logger))

sink.send(
AuthEvidence.from_facts(
facts,
run_id=run_id,
request_id=request_id,
source=source,
destination=str(host),
auth_status=_auth_status(status),
observed_at=time.time(),
),
dna,
)


def _auth_status(status_code) -> str:
"""What the destination's answer says about the credential.

Expand Down
Loading
Loading