Skip to content
Merged
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
98 changes: 98 additions & 0 deletions examples/mcp-server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# MCP server example

The smallest possible Extra system, exposed as an MCP server over stdio. It
exists to demonstrate that ``agentctl mcp serve`` works end-to-end:

1. an MCP client can discover the ``extra_chat`` tool,
2. the client can send a message and get an answer back,
3. the same ``session_id`` continues a previous conversation,
4. different ``session_id`` values keep their own history,
5. the Extra engine is built once and reused for every request.

## Files

```
examples/mcp-server/
├── agents.yaml # one-agent system
├── prompts/echo_agent/system.md
├── plugins/ # generated stub + implementation
│ ├── plugins.toml
│ └── tools/echo.py
└── client.py # tiny MCP client that drives the server
```

## Run it

The example uses Ollama through the OpenAI-compatible API by default, just
like `examples/starter`. Make sure Ollama is running and the model is pulled:

```bash
ollama pull qwen2.5:14b
```

Then start the server in one terminal:

```bash
agentctl mcp serve --config examples/mcp-server/agents.yaml
```

And run the bundled client in another:

```bash
python examples/mcp-server/client.py
```

If you want to use a different model, change `defaults.model.name` in
`agents.yaml` and set the corresponding provider key in your environment
(`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, etc.).

The server speaks MCP over stdio, so any MCP-aware client can point at it.
For example, to wire it into Claude Desktop, add an entry like:

```json
{
"mcpServers": {
"extra": {
"command": "agentctl",
"args": ["mcp", "serve", "--config", "/abs/path/to/agents.yaml"]
}
}
}
```

## What the tool looks like

The server exposes a single tool, ``extra_chat``:

```json
{
"message": "Search the internal documentation",
"session_id": "optional-session-id",
"user_id": "optional-user-id"
}
```

Response:

```json
{
"session_id": "abc123",
"answer": "The relevant documentation is...",
"visited": ["root", "knowledge_agent"],
"used_tools": [{"name": "search_internal_documents", "provider": "local"}]
}
```

## How it works

The MCP layer in ``agentctl/mcp_serve.py`` does only five things:

1. validate the YAML spec,
2. open the existing application repositories (one process-lifetime DB),
3. build the existing ``LangGraphEngine`` once,
4. on each tool call, create or load a session and call the existing
``ConversationService.send``,
5. return the ``RunResult`` as an MCP ``TextContent`` block.

Routing, tool execution, approvals, hooks, and access control are not
re-implemented — they continue to live in the Extra runtime.
33 changes: 33 additions & 0 deletions examples/mcp-server/agents.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
system:
name: Echo Agent MCP Server

defaults:
model:
provider: openai
name: qwen2.5:14b
temperature: 0.0

execution:
max_iterations: 4
max_tool_calls: 2
max_tool_calls_per_agent: 2
max_child_agent_calls: 1
allow_duplicate_tool_calls: false

tools:
echo:
description: Echoes the input back. Useful for verifying the MCP wiring.

plugins:
import_roots: ["."]

agents:
echo_agent:
name: Echo Agent
description: A minimal agent that answers questions directly.
prompts:
system: prompts/echo_agent/system.md
tools: [echo]

graph:
echo_agent:
78 changes: 78 additions & 0 deletions examples/mcp-server/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Tiny MCP client that talks to ``agentctl mcp serve``.

Start the server in one terminal::

agentctl mcp serve --config examples/mcp-server/agents.yaml

Then run this in another::

python examples/mcp-server/client.py

It discovers the ``extra_chat`` tool, sends a message, follows up in the
same session, and verifies that two different sessions keep independent
history.
"""

from __future__ import annotations

import asyncio
import json
import sys

from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client


async def call_chat(
session: ClientSession, message: str, session_id: str | None = None
) -> dict:
args: dict[str, str] = {"message": message}
if session_id:
args["session_id"] = session_id
result = await session.call_tool("extra_chat", args)
if result.isError:
raise RuntimeError(f"tool call failed: {result.content}")
for block in result.content:
if getattr(block, "text", None):
return json.loads(block.text)
raise RuntimeError("no text in tool response")


async def main() -> None:
server_cmd = [
sys.executable,
"-m",
"agentctl",
"mcp",
"serve",
"--config",
"examples/mcp-server/agents.yaml",
]
params = StdioServerParameters(command=server_cmd[0], args=server_cmd[1:])

async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()

tools = await session.list_tools()
names = [t.name for t in tools.tools]
print(f"discovered tools: {names}")
assert "extra_chat" in names

first = await call_chat(session, "hello there")
sid = first["session_id"]
print(f"first sid={sid} answer={first['answer']!r}")

second = await call_chat(session, "what did I just say?", session_id=sid)
print(
f"second sid={second['session_id']} answer={second['answer']!r}"
)
assert second["session_id"] == sid

fresh = await call_chat(session, "fresh session, no history")
print(f"third sid={fresh['session_id']} answer={fresh['answer']!r}")
assert fresh["session_id"] != sid


if __name__ == "__main__":
asyncio.run(main())
3 changes: 3 additions & 0 deletions examples/mcp-server/plugins/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Extra plugin stubs for the MCP-server example."""

from __future__ import annotations
39 changes: 39 additions & 0 deletions examples/mcp-server/plugins/plugins.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# plugins.toml — unified manifest for this client extension package.
#
# ONE manifest for ALL client extension code: hooks, resolvers, and tools.
# It is a catalog/generation companion. The runtime reads only [hooks.plugins]
# to resolve managed hook ids; resolvers and tools load by file path.
# `agentctl generate` creates this file if missing and merges new entries in
# without overwriting manual edits.
#
# SECURITY: never put secrets here. Only import refs and metadata — no tokens,
# client secrets, HMAC keys, or Authorization values.

[package]
name = "plugins"
description = "Client extension package for hooks, resolvers, and tools."

[paths]
hooks = "plugins.hooks"
resolvers = "plugins.resolvers"
tools = "plugins.tools"

[hooks]
on_engine_start = []
on_engine_stop = []
on_run_start = []
on_run_end = []
on_run_error = []
before_tool_call = []
after_tool_call = []
transform_tool_result = []
on_tool_error = []
before_mcp_request = []
after_mcp_response = []

[hooks.plugins]

[resolvers]

[tools]
echo = "plugins.tools.echo:echo"
3 changes: 3 additions & 0 deletions examples/mcp-server/plugins/tools/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Tool stubs for the MCP-server example."""

from __future__ import annotations
3 changes: 3 additions & 0 deletions examples/mcp-server/plugins/tools/echo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
def echo(input: dict) -> str:
"""Echoes the input back. Useful for verifying the MCP wiring."""
return str(input.get("text", input))
3 changes: 3 additions & 0 deletions examples/mcp-server/prompts/echo_agent/system.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
You are Echo Agent. Answer the user's question directly and concisely.
You have an `echo` tool that simply echoes its input back — use it when asked
to "echo" something verbatim. Otherwise just answer in one or two sentences.
26 changes: 26 additions & 0 deletions src/agentctl/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from agent_engine.engine.langgraph.engine import LangGraphEngine
from agent_engine.generate.generator import Generator
from agent_engine.parsers.yaml.parser import YAMLParser
from agentctl.mcp_serve import create_server
from agentctl.session import SpecError, load_and_validate, load_env

LOCAL_USER_ID = "local-user"
Expand Down Expand Up @@ -281,5 +282,30 @@ def chat(
asyncio.run(run_remote_chat(url, stream, session_id=session_id))


@cli.group()
def mcp() -> None:
"""MCP server commands."""


@mcp.command(name="serve")
@click.option("--config", required=True, help="Path to agents.yml")
@click.option("--env", default=None, help="Path to .env file")
def mcp_serve(config: str, env: str | None) -> None:
"""Run the Extra agent system as an MCP server (stdio transport)."""
load_env(config, env)
from agent_manager.infrastructure.persistence.database import upgrade_database

upgrade_database()
from agentctl.diagnostics import format_validation_report, validate_spec

validation = validate_spec(config)
if not validation.ok:
click.echo(format_validation_report(validation), err=True)
sys.exit(1)

server = create_server(config, env)
asyncio.run(server.run())


if __name__ == "__main__":
cli()
Loading
Loading