From 74ba32fa29e003b54ca56aad42bbe0c455373d00 Mon Sep 17 00:00:00 2001 From: Philip Meier Date: Tue, 23 Jun 2026 12:31:41 +0000 Subject: [PATCH 01/12] dirty --- docs/tutorials/agents.py | 234 +++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + src/_ravnar/agents.py | 3 +- 3 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 docs/tutorials/agents.py diff --git a/docs/tutorials/agents.py b/docs/tutorials/agents.py new file mode 100644 index 0000000..de63218 --- /dev/null +++ b/docs/tutorials/agents.py @@ -0,0 +1,234 @@ +# %% [markdown] +# # Adding an agent to ravnar +# +# This tutorial explains how to add an agent to ravnar. You will see two approaches: +# +# 1. **Full control** — subclassing the [`Agent`][ravnar.agents.Agent] ABC directly. +# 2. **Using a wrapper** — adapting an existing [pydantic-ai](https://ai.pydantic.dev/) agent via +# [`PydanticAiAgentWrapper`][ravnar.agents.PydanticAiAgentWrapper]. +# +# A special `Client` is used for the documentation. For real-world scenarios, it can be substituted with a regular HTTP +# client with the base URL set to the URL of your ravnar deployment. + +# %% +import json +import uuid +from collections.abc import AsyncIterator + +from _ravnar.docs import Client + + +def print_json(obj): + print(json.dumps(obj, indent=2, sort_keys=False)) + + +def run_agent(client, agent_id: str, message: str) -> None: + """Send a message to an agent and display the response in a human-readable format.""" + import httpx_sse + + body = { + "thread_id": str(uuid.uuid4()), + "run_id": str(uuid.uuid4()), + "state": None, + "tools": [], + "context": [], + "forwardedProps": None, + "messages": [ + { + "role": "user", + "content": [{"type": "text", "text": message}], + "id": str(uuid.uuid4()), + } + ], + } + + with httpx_sse.connect_sse(client, "POST", f"/api/agents/{agent_id}/run", json=body) as event_source: + text = "" + for sse in event_source.iter_sse(): + event = json.loads(sse.data) + match event["type"]: + case "TEXT_MESSAGE_CONTENT": + text += event["delta"] + case "TOOL_CALL_START": + print(f" 🛠 Calling tool: {event['toolCallName']}") + case "TOOL_CALL_RESULT": + print(f" ✅ {event['content']}") + case "RUN_ERROR": + print(f" ❌ Error: {event.get('error', 'Unknown error')}") + + if text: + print(f"\n{text}") + + +# %% [markdown] +# ## Full control with the Agent ABC +# +# The [`Agent`][ravnar.agents.Agent] abstract base class gives you complete control over the agent's behaviour. +# You implement a single method, [`run()`][ravnar.agents.Agent.run], which receives the incoming +# `RunAgentInput` and a [`User`][ravnar.authenticators.User] object, and yields `Event`s. +# +# Let's build a simple agent that greets the current user. + +# %% +import ag_ui.core + +from _ravnar.agents import Agent +from _ravnar.security import User + + +class WhoAmIAgent(Agent): + """A simple agent that greets the current user.""" + + async def run( + self, input: ag_ui.core.RunAgentInput, user: User + ) -> AsyncIterator[ag_ui.core.Event]: + message_id = str(uuid.uuid4()) + + yield ag_ui.core.RunStartedEvent( + thread_id=input.thread_id, + run_id=input.run_id, + parent_run_id=input.parent_run_id, + ) + yield ag_ui.core.TextMessageStartEvent(message_id=message_id) + + text = f"Hello, {user.id}!" + for word in text.split(): + yield ag_ui.core.TextMessageContentEvent(message_id=message_id, delta=word + " ") + + yield ag_ui.core.TextMessageEndEvent(message_id=message_id) + yield ag_ui.core.RunFinishedEvent(thread_id=input.thread_id, run_id=input.run_id) + + +# %% [markdown] +# The [`User`][ravnar.authenticators.User] object carries the authenticated user's identity along with any additional data and permissions. +# When no authenticator is configured, the user defaults to the current system user, and all permissions are granted. +# +# Now we register it as a static agent through the ravnar configuration. Static agents are declared upfront and are +# available for the entire lifetime of the server. + +# %% +config = { + "agents": { + "static": { + "whoami": WhoAmIAgent, + } + } +} +client = Client(config) + +# %% [markdown] +# Let's verify that our agent is registered and inspect its capabilities. + +# %% +agents = client.get("/api/agents").raise_for_status().json() +print_json(agents) + +# %% [markdown] +# The capabilities are derived from the default [`get_capabilities()`][ravnar.agents.Agent.get_capabilities] method of the base class which, among others, +# reports that the agent supports streaming. No tools are declared — this is a purely conversational agent. +# +# Time to send it a message. + +# %% +run_agent(client, "whoami", "Who am I?") + +# %% [markdown] +# The `run_agent()` helper parsed the SSE event stream and printed only the text content. The agent reads the user +# ID from the [`User`][ravnar.authenticators.User] object and includes it in the greeting. +# +# Subclassing [`Agent`][ravnar.agents.Agent] is the most flexible approach — you have full control over the event stream and can integrate +# virtually any protocol or library. However, it also means you are responsible for producing the right events at the +# right time. + +# %% [markdown] +# ## Using the Pydantic AI wrapper +# +# If you already use [pydantic-ai](https://ai.pydantic.dev/), you do not need to implement the [`Agent`][ravnar.agents.Agent] +# interface yourself. ravnar ships with [`PydanticAiAgentWrapper`][ravnar.agents.PydanticAiAgentWrapper] that adapts any +# `pydantic_ai.Agent` into a ravnar agent. It handles event generation, tool call streaming, and capability detection +# automatically. +# +# Let's build a pydantic-ai agent with a `whoami` tool that accesses the authenticated user. The tool is a regular +# async function that takes [`RunContext[User]`][pydantic_ai.RunContext] as its first parameter — ravnar injects the +# [`User`][ravnar.authenticators.User] object as the dependency when the agent runs. + +# %% +from pydantic_ai import RunContext + + +async def whoami(ctx: RunContext[User]) -> str: + """Get the current user's identity.""" + return ctx.deps.id + + +# %% [markdown] +# Now we register the agent purely through the configuration — no Python instantiation needed. ravnar's +# `ImportStringWithParams` mechanism resolves nested definitions recursively, so we can declare the entire agent +# tree (model, tools, wrapper) as a single config block. + +# %% +config = { + "agents": { + "static": { + "pydantic-whoami": { + "cls_or_fn": "ravnar.agents.PydanticAiAgentWrapper", + "params": { + "agent": { + "cls_or_fn": "pydantic_ai.Agent", + "params": { + "model": { + "cls_or_fn": "pydantic_ai.models.test.TestModel", + "params": { + "call_tools": "all", + }, + }, + "deps_type": User, + "tools": [whoami], + }, + }, + }, + }, + }, + }, +} +client = Client(config) + +# %% [markdown] +# The wrapper automatically discovers the tool and reports it in the capabilities. + +# %% +agents = client.get("/api/agents").raise_for_status().json() +print_json(agents) + +# %% [markdown] +# Notice the `whoami` tool is listed with its description and an empty parameter schema (it takes no arguments). +# The [`PydanticAiAgentWrapper`][ravnar.agents.PydanticAiAgentWrapper] introspects the underlying pydantic-ai agent +# to build the capability object dynamically via +# [`extract_capabilities()`][ravnar.agents.PydanticAiAgentWrapper.extract_capabilities]. +# +# Let's run it. + +# %% +run_agent(client, "pydantic-whoami", "Who am I?") + +# %% [markdown] +# The `TestModel` immediately invokes the `whoami` tool. The helper shows: +# +# - `🛠 Calling tool: whoami` — the tool was invoked. +# - `✅ agent` — the tool returned the user ID (your system username). +# - The final message containing the tool's output in JSON format. +# +# Because we used the config-driven approach, the entire agent lifecycle (model instantiation, tool registration, +# wrapper setup) is handled automatically. In production you would swap `TestModel` for a real model +# (e.g. `openai`, `anthropic`, `openrouter`) — everything else stays the same. + +# %% [markdown] +# ## Summary +# +# - Subclass [`Agent`][ravnar.agents.Agent] directly when you need full control over the event stream or want to +# integrate a custom protocol. +# - Use [`PydanticAiAgentWrapper`][ravnar.agents.PydanticAiAgentWrapper] when you already have a pydantic-ai agent — +# ravnar plugs it in automatically. +# - ravnar injects the [`User`][ravnar.authenticators.User] object into the agent's `run()` method. For pydantic-ai +# agents, it is available as `deps` in tools via `RunContext.deps`. +# - All agents are registered through the same configuration mechanism, whether they are custom subclasses or wrappers. diff --git a/mkdocs.yml b/mkdocs.yml index 45195d7..46fee03 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -69,6 +69,7 @@ nav: - Deployment: deploy.md - Tutorials: - Authentication: tutorials/authentication.py + - Adding an agent: tutorials/agents.py - References: - Configuration: references/config.md - Python API: references/python_api.md diff --git a/src/_ravnar/agents.py b/src/_ravnar/agents.py index 6ff526f..0b18847 100644 --- a/src/_ravnar/agents.py +++ b/src/_ravnar/agents.py @@ -2,6 +2,7 @@ import abc import dataclasses +import re import textwrap import uuid from collections.abc import AsyncIterator @@ -49,7 +50,7 @@ async def run(self, input: ag_ui.core.RunAgentInput, user: User) -> AsyncIterato thread_id=input.thread_id, run_id=input.run_id, parent_run_id=input.parent_run_id ) yield ag_ui.core.TextMessageStartEvent(message_id=message_id) - for delta in textwrap.dedent(message.strip()).split(): + for delta in re.findall(r'\s*\S+', textwrap.dedent(message.strip())): yield ag_ui.core.TextMessageContentEvent(message_id=message_id, delta=delta) yield ag_ui.core.TextMessageEndEvent(message_id=message_id) yield ag_ui.core.RunFinishedEvent(thread_id=input.thread_id, run_id=input.run_id) From 138afe8567b5009a967ab86e818adc94784b7be1 Mon Sep 17 00:00:00 2001 From: Adam Lewis <23342526+Adam-D-Lewis@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:57:39 +0000 Subject: [PATCH 02/12] Add MCP server example to the agent tutorial Extends the "Adding an agent" tutorial with a section that connects a pydantic-ai agent to an MCP server, plus small polish to the existing sections. - New "Connecting an MCP server" section: a minimal stdio FastMCP server exposing an `add` tool, wired to the agent via `MCPToolset` purely through ravnar config. The wrapper discovers the MCP tool and streams the call with no code changes. - Use the public `ravnar.*` namespace in tutorial imports (was `_ravnar.*`). - Clarify the `TestModel` tool-call output explanation. - Add the `mcp` extra to `pydantic-ai-slim` so `MCPToolset` and the docs build have FastMCP available; refresh uv.lock. Verified: `mkdocs build` executes the tutorial end-to-end (including the MCP subprocess) with fail_on_warning, and the existing 247 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/tutorials/agents.py | 113 ++++++++++++++++- pyproject.toml | 2 +- uv.lock | 257 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 357 insertions(+), 15 deletions(-) diff --git a/docs/tutorials/agents.py b/docs/tutorials/agents.py index de63218..ef533da 100644 --- a/docs/tutorials/agents.py +++ b/docs/tutorials/agents.py @@ -7,6 +7,9 @@ # 2. **Using a wrapper** — adapting an existing [pydantic-ai](https://ai.pydantic.dev/) agent via # [`PydanticAiAgentWrapper`][ravnar.agents.PydanticAiAgentWrapper]. # +# Building on the wrapper, the final section shows how to give an agent the tools of an +# [MCP](https://modelcontextprotocol.io/) server. +# # A special `Client` is used for the documentation. For real-world scenarios, it can be substituted with a regular HTTP # client with the base URL set to the URL of your ravnar deployment. @@ -72,8 +75,8 @@ def run_agent(client, agent_id: str, message: str) -> None: # %% import ag_ui.core -from _ravnar.agents import Agent -from _ravnar.security import User +from ravnar.agents import Agent +from ravnar.authenticators import User class WhoAmIAgent(Agent): @@ -212,16 +215,114 @@ async def whoami(ctx: RunContext[User]) -> str: run_agent(client, "pydantic-whoami", "Who am I?") # %% [markdown] -# The `TestModel` immediately invokes the `whoami` tool. The helper shows: +# `TestModel` is pydantic-ai's stand-in for a real model: it exercises the full agent plumbing without calling an +# LLM, and with `call_tools="all"` it invokes every available tool once. The helper shows: # # - `🛠 Calling tool: whoami` — the tool was invoked. -# - `✅ agent` — the tool returned the user ID (your system username). -# - The final message containing the tool's output in JSON format. +# - `✅ agent` — the value returned by the tool, i.e. the user ID. Since no authenticator is configured, this is your +# system username. +# - The final assistant message, which `TestModel` builds by echoing the tool's output as JSON. # # Because we used the config-driven approach, the entire agent lifecycle (model instantiation, tool registration, # wrapper setup) is handled automatically. In production you would swap `TestModel` for a real model # (e.g. `openai`, `anthropic`, `openrouter`) — everything else stays the same. +# %% [markdown] +# ## Connecting an MCP server +# +# [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) servers expose tools, resources, and prompts over +# a standard protocol, so any MCP-compatible client can use them. pydantic-ai connects to an MCP server with +# [`MCPToolset`](https://ai.pydantic.dev/mcp/client/), and because the wrapper introspects the agent's toolsets, the +# server's tools are discovered and become callable exactly like the in-process `whoami` tool above — no extra wiring +# on the ravnar side. +# +# One difference matters: an MCP server runs as a *separate process* (or a remote service), so its tools cannot access +# ravnar's injected [`User`][ravnar.authenticators.User]. Reach for an in-process pydantic-ai tool when a tool needs +# the caller's identity, and for an MCP server when the capability is self-contained. +# +# Let's write a minimal stdio MCP server that exposes a single `add` tool. In a real project this would be a separate +# service; here we write it to a temporary file so the tutorial stays self-contained. + +# %% +import pathlib +import tempfile + +mcp_server = pathlib.Path(tempfile.mkdtemp()) / "calculator.py" +mcp_server.write_text( + ''' +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("calculator") + + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + +if __name__ == "__main__": + mcp.run() +''' +) + +# %% [markdown] +# Now we declare an agent that connects to it. [`MCPToolset`](https://ai.pydantic.dev/mcp/client/) accepts the path to +# a Python script and launches it as a stdio server (it also accepts a URL for a remote HTTP/SSE server). We add it to +# the agent's `toolsets` through the same recursive config mechanism — no Python instantiation needed. + +# %% +config = { + "agents": { + "static": { + "calculator": { + "cls_or_fn": "ravnar.agents.PydanticAiAgentWrapper", + "params": { + "agent": { + "cls_or_fn": "pydantic_ai.Agent", + "params": { + "model": { + "cls_or_fn": "pydantic_ai.models.test.TestModel", + "params": {"call_tools": "all"}, + }, + "toolsets": [ + { + "cls_or_fn": "pydantic_ai.mcp.MCPToolset", + "params": {"client": str(mcp_server)}, + } + ], + }, + }, + }, + }, + }, + }, +} +client = Client(config) + +# %% [markdown] +# During setup the wrapper connects to the MCP server and discovers its tools. The `add` tool appears in the +# capabilities — this time *with* a parameter schema, unlike the argument-less `whoami`. + +# %% +agents = client.get("/api/agents").raise_for_status().json() +print_json(agents) + +# %% [markdown] +# Let's run it. + +# %% +run_agent(client, "calculator", "What is 2 + 3?") + +# %% [markdown] +# `TestModel` calls `add` with placeholder arguments (`0` and `0`), so the result is `0` rather than `5` — it does +# not read the operands from the message. A real model would call `add(a=2, b=3)` and get `5`. The point of the +# example is the plumbing: ravnar connected to the MCP server, advertised its tools, and routed the tool call to it +# with no code changes — only configuration. +# +# To connect to a server you do not run yourself (the common case), pass its URL instead of a script path, e.g. +# `{"cls_or_fn": "pydantic_ai.mcp.MCPToolset", "params": {"client": "https://example.com/mcp"}}`. + # %% [markdown] # ## Summary # @@ -231,4 +332,6 @@ async def whoami(ctx: RunContext[User]) -> str: # ravnar plugs it in automatically. # - ravnar injects the [`User`][ravnar.authenticators.User] object into the agent's `run()` method. For pydantic-ai # agents, it is available as `deps` in tools via `RunContext.deps`. +# - Add [`MCPToolset`](https://ai.pydantic.dev/mcp/client/) to a pydantic-ai agent's `toolsets` to expose the tools of +# any MCP server; the wrapper discovers and streams them automatically. # - All agents are registered through the same configuration mechanism, whether they are custom subclasses or wrappers. diff --git a/pyproject.toml b/pyproject.toml index 84868f1..a171c07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ serve = [ "uvloop; sys_platform != 'win32'", ] pydantic-ai = [ - "pydantic-ai-slim[ag-ui]>=1.87,<2", + "pydantic-ai-slim[ag-ui,mcp]>=1.87,<2", ] agno = [ # FIXME: find the lowest version for AG-UI diff --git a/uv.lock b/uv.lock index 31784d0..5d38741 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,10 @@ version = 1 revision = 3 requires-python = ">=3.11, <3.15" resolution-markers = [ - "python_full_version >= '3.13'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'win32'", "python_full_version < '3.13'", ] @@ -42,6 +45,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/7d/9d9f080ccd0d30da47ac80b3c84ff7d2b8bfa0eec0e29b3ac429dc1e90ab/agno-2.5.5-py3-none-any.whl", hash = "sha256:cbaa0135b63e0e77b091daefe6a9583f5fffb16051c894acfcd456e369ac9756", size = 2061779, upload-time = "2026-02-25T17:27:29.828Z" }, ] +[[package]] +name = "aiofile" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "caio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/41/2fea7e193e061ce54eacc3b7bc0e6a99e4fcff43c78cf0a76dd781ed8334/aiofile-3.11.1.tar.gz", hash = "sha256:1f91912c6643d2a4e49ca4ae3514f0bf3867ce948a36d99a6411b8f4755f4cf9", size = 19342, upload-time = "2026-05-16T08:18:33.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/cd/0d76dfc5de72bde52f55f53e925c7d152d9c7906634ec1e0cbc7e8d4ad93/aiofile-3.11.1-py3-none-any.whl", hash = "sha256:ce77d14ac07f77bc2b757834a5c129321f3f705c474593deed5ab209079a52c9", size = 20446, upload-time = "2026-05-16T08:18:32.051Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -110,6 +125,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, ] +[[package]] +name = "authlib" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "joserfc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, +] + [[package]] name = "babel" version = "2.17.0" @@ -142,6 +170,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/ff/392bff89415399a979be4a65357a41d92729ae8580a66073d8ec8d810f98/backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60", size = 380265, upload-time = "2025-06-22T19:34:12.405Z" }, ] +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.14.2" @@ -172,6 +209,40 @@ css = [ { name = "tinycss2" }, ] +[[package]] +name = "cachetools" +version = "7.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/8b/0d3945a13955303b81272f759a0331e54c5c793da455e6f5706b89d2639c/cachetools-7.1.4.tar.gz", hash = "sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6", size = 40085, upload-time = "2026-05-21T22:40:43.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/7b/1fc1c09cc0756cf25861a3be10565915953876da48bb228fb9a672b20a42/cachetools-7.1.4-py3-none-any.whl", hash = "sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54", size = 16761, upload-time = "2026-05-21T22:40:41.845Z" }, +] + +[[package]] +name = "caio" +version = "0.9.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/90/543f556fcfcfa270713eef906b6352ab048e1e557afec12925c991dc93c2/caio-0.9.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6956d9e4a27021c8bd6c9677f3a59eb1d820cc32d0343cea7961a03b1371965", size = 36839, upload-time = "2025-12-26T15:21:40.267Z" }, + { url = "https://files.pythonhosted.org/packages/51/3b/36f3e8ec38dafe8de4831decd2e44c69303d2a3892d16ceda42afed44e1b/caio-0.9.25-cp311-cp311-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf84bfa039f25ad91f4f52944452a5f6f405e8afab4d445450978cd6241d1478", size = 80255, upload-time = "2025-12-26T15:22:20.271Z" }, + { url = "https://files.pythonhosted.org/packages/df/ce/65e64867d928e6aff1b4f0e12dba0ef6d5bf412c240dc1df9d421ac10573/caio-0.9.25-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ae3d62587332bce600f861a8de6256b1014d6485cfd25d68c15caf1611dd1f7c", size = 80052, upload-time = "2026-03-04T22:08:20.402Z" }, + { url = "https://files.pythonhosted.org/packages/46/90/e278863c47e14ec58309aa2e38a45882fbe67b4cc29ec9bc8f65852d3e45/caio-0.9.25-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:fc220b8533dcf0f238a6b1a4a937f92024c71e7b10b5a2dfc1c73604a25709bc", size = 78273, upload-time = "2026-03-04T22:08:21.368Z" }, + { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, +] + [[package]] name = "certifi" version = "2025.8.3" @@ -443,6 +514,7 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/a7/35/c495bffc2056f2dadb32434f1feedd79abde2a7f8363e1974afa9c33c7e2/cryptography-45.0.7.tar.gz", hash = "sha256:4b1654dfc64ea479c242508eb8c724044f1e964a47d1d1cacc5132292d851971", size = 744980, upload-time = "2025-09-01T11:15:03.146Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/91/925c0ac74362172ae4516000fe877912e33b5983df735ff290c653de4913/cryptography-45.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3be4f21c6245930688bd9e162829480de027f8bf962ede33d4f8ba7d67a00cee", size = 7041105, upload-time = "2025-09-01T11:13:59.684Z" }, { url = "https://files.pythonhosted.org/packages/fc/63/43641c5acce3a6105cf8bd5baeceeb1846bb63067d26dae3e5db59f1513a/cryptography-45.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:67285f8a611b0ebc0857ced2081e30302909f571a46bfa7a3cc0ad303fe015c6", size = 4205799, upload-time = "2025-09-01T11:14:02.517Z" }, { url = "https://files.pythonhosted.org/packages/bc/29/c238dd9107f10bfde09a4d1c52fd38828b1aa353ced11f358b5dd2507d24/cryptography-45.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:577470e39e60a6cd7780793202e63536026d9b8641de011ed9d8174da9ca5339", size = 4430504, upload-time = "2025-09-01T11:14:04.522Z" }, { url = "https://files.pythonhosted.org/packages/62/62/24203e7cbcc9bd7c94739428cd30680b18ae6b18377ae66075c8e4771b1b/cryptography-45.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4bd3e5c4b9682bc112d634f2c6ccc6736ed3635fc3319ac2bb11d768cc5a00d8", size = 4209542, upload-time = "2025-09-01T11:14:06.309Z" }, @@ -452,6 +524,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/e4/b3e68a4ac363406a56cf7b741eeb80d05284d8c60ee1a55cdc7587e2a553/cryptography-45.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b6a0e535baec27b528cb07a119f321ac024592388c5681a5ced167ae98e9fff3", size = 4460397, upload-time = "2025-09-01T11:14:12.924Z" }, { url = "https://files.pythonhosted.org/packages/22/49/2c93f3cd4e3efc8cb22b02678c1fad691cff9dd71bb889e030d100acbfe0/cryptography-45.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a24ee598d10befaec178efdff6054bc4d7e883f615bfbcd08126a0f4931c83a6", size = 4337244, upload-time = "2025-09-01T11:14:14.431Z" }, { url = "https://files.pythonhosted.org/packages/04/19/030f400de0bccccc09aa262706d90f2ec23d56bc4eb4f4e8268d0ddf3fb8/cryptography-45.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fa26fa54c0a9384c27fcdc905a2fb7d60ac6e47d14bc2692145f2b3b1e2cfdbd", size = 4568862, upload-time = "2025-09-01T11:14:16.185Z" }, + { url = "https://files.pythonhosted.org/packages/29/56/3034a3a353efa65116fa20eb3c990a8c9f0d3db4085429040a7eef9ada5f/cryptography-45.0.7-cp311-abi3-win32.whl", hash = "sha256:bef32a5e327bd8e5af915d3416ffefdbe65ed975b646b3805be81b23580b57b8", size = 2936578, upload-time = "2025-09-01T11:14:17.638Z" }, + { url = "https://files.pythonhosted.org/packages/b3/61/0ab90f421c6194705a99d0fa9f6ee2045d916e4455fdbb095a9c2c9a520f/cryptography-45.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:3808e6b2e5f0b46d981c24d79648e5c25c35e59902ea4391a0dcb3e667bf7443", size = 3405400, upload-time = "2025-09-01T11:14:18.958Z" }, + { url = "https://files.pythonhosted.org/packages/63/e8/c436233ddf19c5f15b25ace33979a9dd2e7aa1a59209a0ee8554179f1cc0/cryptography-45.0.7-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bfb4c801f65dd61cedfc61a83732327fafbac55a47282e6f26f073ca7a41c3b2", size = 7021824, upload-time = "2025-09-01T11:14:20.954Z" }, { url = "https://files.pythonhosted.org/packages/bc/4c/8f57f2500d0ccd2675c5d0cc462095adf3faa8c52294ba085c036befb901/cryptography-45.0.7-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:81823935e2f8d476707e85a78a405953a03ef7b7b4f55f93f7c2d9680e5e0691", size = 4202233, upload-time = "2025-09-01T11:14:22.454Z" }, { url = "https://files.pythonhosted.org/packages/eb/ac/59b7790b4ccaed739fc44775ce4645c9b8ce54cbec53edf16c74fd80cb2b/cryptography-45.0.7-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3994c809c17fc570c2af12c9b840d7cea85a9fd3e5c0e0491f4fa3c029216d59", size = 4423075, upload-time = "2025-09-01T11:14:24.287Z" }, { url = "https://files.pythonhosted.org/packages/b8/56/d4f07ea21434bf891faa088a6ac15d6d98093a66e75e30ad08e88aa2b9ba/cryptography-45.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dad43797959a74103cb59c5dac71409f9c27d34c8a05921341fb64ea8ccb1dd4", size = 4204517, upload-time = "2025-09-01T11:14:25.679Z" }, @@ -461,10 +536,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/fa/1d5745d878048699b8eb87c984d4ccc5da4f5008dfd3ad7a94040caca23a/cryptography-45.0.7-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f3df7b3d0f91b88b2106031fd995802a2e9ae13e02c36c1fc075b43f420f3a17", size = 4449383, upload-time = "2025-09-01T11:14:32.046Z" }, { url = "https://files.pythonhosted.org/packages/36/8b/fc61f87931bc030598e1876c45b936867bb72777eac693e905ab89832670/cryptography-45.0.7-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd342f085542f6eb894ca00ef70236ea46070c8a13824c6bde0dfdcd36065b9b", size = 4332186, upload-time = "2025-09-01T11:14:33.95Z" }, { url = "https://files.pythonhosted.org/packages/0b/11/09700ddad7443ccb11d674efdbe9a832b4455dc1f16566d9bd3834922ce5/cryptography-45.0.7-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1993a1bb7e4eccfb922b6cd414f072e08ff5816702a0bdb8941c247a6b1b287c", size = 4561639, upload-time = "2025-09-01T11:14:35.343Z" }, + { url = "https://files.pythonhosted.org/packages/71/ed/8f4c1337e9d3b94d8e50ae0b08ad0304a5709d483bfcadfcc77a23dbcb52/cryptography-45.0.7-cp37-abi3-win32.whl", hash = "sha256:18fcf70f243fe07252dcb1b268a687f2358025ce32f9f88028ca5c364b123ef5", size = 2926552, upload-time = "2025-09-01T11:14:36.929Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ff/026513ecad58dacd45d1d24ebe52b852165a26e287177de1d545325c0c25/cryptography-45.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:7285a89df4900ed3bfaad5679b1e668cb4b38a8de1ccbfc84b05f34512da0a90", size = 3392742, upload-time = "2025-09-01T11:14:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/99/4e/49199a4c82946938a3e05d2e8ad9482484ba48bbc1e809e3d506c686d051/cryptography-45.0.7-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a862753b36620af6fc54209264f92c716367f2f0ff4624952276a6bbd18cbde", size = 3584634, upload-time = "2025-09-01T11:14:50.593Z" }, { url = "https://files.pythonhosted.org/packages/16/ce/5f6ff59ea9c7779dba51b84871c19962529bdcc12e1a6ea172664916c550/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:06ce84dc14df0bf6ea84666f958e6080cdb6fe1231be2a51f3fc1267d9f3fb34", size = 4149533, upload-time = "2025-09-01T11:14:52.091Z" }, { url = "https://files.pythonhosted.org/packages/ce/13/b3cfbd257ac96da4b88b46372e662009b7a16833bfc5da33bb97dd5631ae/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d0c5c6bac22b177bf8da7435d9d27a6834ee130309749d162b26c3105c0795a9", size = 4385557, upload-time = "2025-09-01T11:14:53.551Z" }, { url = "https://files.pythonhosted.org/packages/1c/c5/8c59d6b7c7b439ba4fc8d0cab868027fd095f215031bc123c3a070962912/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:2f641b64acc00811da98df63df7d59fd4706c0df449da71cb7ac39a0732b40ae", size = 4149023, upload-time = "2025-09-01T11:14:55.022Z" }, { url = "https://files.pythonhosted.org/packages/55/32/05385c86d6ca9ab0b4d5bb442d2e3d85e727939a11f3e163fc776ce5eb40/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:f5414a788ecc6ee6bc58560e85ca624258a55ca434884445440a810796ea0e0b", size = 4385722, upload-time = "2025-09-01T11:14:57.319Z" }, + { url = "https://files.pythonhosted.org/packages/23/87/7ce86f3fa14bc11a5a48c30d8103c26e09b6465f8d8e9d74cf7a0714f043/cryptography-45.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:1f3d56f73595376f4244646dd5c5870c14c196949807be39e79e7bd9bac3da63", size = 3332908, upload-time = "2025-09-01T11:14:58.78Z" }, ] [[package]] @@ -531,6 +610,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + [[package]] name = "docstring-parser" version = "0.17.0" @@ -552,6 +640,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/41/04e2a649058b0713b00d6c9bd22da35618bb157289e05d068e51fddf8d7e/dunamai-1.25.0-py3-none-any.whl", hash = "sha256:7f9dc687dd3256e613b6cc978d9daabfd2bb5deb8adc541fc135ee423ffa98ab", size = 27022, upload-time = "2025-07-04T19:25:54.863Z" }, ] +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + [[package]] name = "executing" version = "2.2.1" @@ -586,6 +699,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, ] +[[package]] +name = "fastmcp-slim" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "pydantic", extra = ["email"] }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/2e/d627b28b7403ecc526991ef732921b08bde010006e6148635f053fd29f4c/fastmcp_slim-3.4.2.tar.gz", hash = "sha256:290646e0955a516235a317151034559aa48336cb843d3f006131aedad8759bb4", size = 576291, upload-time = "2026-06-06T01:30:12.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/58/22afebf18df7260b09148199cbeb90cdcc4b3a4e1b5d7460e3591c3a7add/fastmcp_slim-3.4.2-py3-none-any.whl", hash = "sha256:bdc72492212681ca502755fa8acc0457f559295da1fc3dfc0599adc1c04b82f3", size = 749195, upload-time = "2026-06-06T01:30:11.22Z" }, +] + +[package.optional-dependencies] +client = [ + { name = "authlib" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "opentelemetry-api" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "starlette" }, +] + [[package]] name = "fastsse" version = "0.0.2" @@ -885,7 +1026,10 @@ name = "httpx-sse" version = "0.4.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.13'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624, upload-time = "2023-12-22T08:01:21.083Z" } wheels = [ @@ -1106,6 +1250,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "joserfc" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/90/25cb27518750218e4f850be63d8bbb2343efaad1c01c3571aaa4b3c33bd7/joserfc-1.7.1.tar.gz", hash = "sha256:77d0b76514879c68c6f433bc5b7357a4ab72008ff1e33d8379fd11d72bd8ca81", size = 233181, upload-time = "2026-06-08T07:21:33.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/00/fa62404c3e347f946faa13aa21085205f9cc06ad17671e37f81a51662ae8/joserfc-1.7.1-py3-none-any.whl", hash = "sha256:b3e3d655612e2e1ef67b2600f2f420e12e537b020208fab1761fad647319c164", size = 70423, upload-time = "2026-06-08T07:21:32.001Z" }, +] + [[package]] name = "jsonpatch" version = "1.33" @@ -1337,6 +1493,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, ] +[[package]] +name = "mcp" +version = "1.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse", version = "0.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, + { name = "httpx-sse", version = "0.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/ee/94c6c50ffc5b5cf4737052275d11b57367f32d1a8516e31dcd60591b3916/mcp-1.28.0.tar.gz", hash = "sha256:559d3f9943674cafbe5744c5d3794f3237e8b47f9bbc58e20c0fad680d8487c2", size = 636040, upload-time = "2026-06-16T21:37:17.996Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/e1/4c1dc1fbb688641a712d34650c3d58bbbdcb314ddb75bc5817bbf33515a4/mcp-1.28.0-py3-none-any.whl", hash = "sha256:9c1e7cf3a9125557e418ecd4fed8e9adddce81b0dfdae4d6601d700f5beb71a4", size = 221959, upload-time = "2026-06-16T21:37:16.579Z" }, +] + [[package]] name = "mdit-py-plugins" version = "0.5.0" @@ -2038,6 +2220,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, ] +[[package]] +name = "py-key-value-aio" +version = "0.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/e2/d689d922894a7ecde73b6daeaf9b13dab5aae06fe6aaaf7514722644d382/py_key_value_aio-0.4.5.tar.gz", hash = "sha256:c6563a2c6abe5da5e20f4f9e875c2a9b425a2244a54fadbf46cf140a9eea45d7", size = 107547, upload-time = "2026-05-27T16:37:08.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/95/b8ba862968712caa12a19666175334fa979e1f198b896a430adb3bacfe87/py_key_value_aio-0.4.5-py3-none-any.whl", hash = "sha256:ab862adbcb8c72547d1c57821f22cbbb71ab86509039c96f36e914e0336c8dd7", size = 170005, upload-time = "2026-05-27T16:37:06.629Z" }, +] + +[package.optional-dependencies] +filetree = [ + { name = "aiofile" }, + { name = "anyio" }, +] +keyring = [ + { name = "keyring" }, +] +memory = [ + { name = "cachetools" }, +] + [[package]] name = "pycparser" version = "2.22" @@ -2062,6 +2269,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/d7/c3a52c61f5b7be648e919005820fbac33028c6149994cd64453f49951c17/pydantic-2.13.0-py3-none-any.whl", hash = "sha256:ab0078b90da5f3e2fd2e71e3d9b457ddcb35d0350854fbda93b451e28d56baaf", size = 471872, upload-time = "2026-04-13T10:51:33.343Z" }, ] +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-ai-slim" version = "1.103.0" @@ -2085,6 +2297,9 @@ ag-ui = [ { name = "ag-ui-protocol" }, { name = "starlette" }, ] +mcp = [ + { name = "fastmcp-slim", extra = ["client"] }, +] [[package]] name = "pydantic-core" @@ -2240,6 +2455,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, ] +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pymdown-extensions" version = "10.16.1" @@ -2365,6 +2585,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -2513,7 +2752,7 @@ all = [ { name = "httpx-sse", version = "0.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, { name = "httpx-sse", version = "0.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, { name = "psycopg", extra = ["binary", "pool"] }, - { name = "pydantic-ai-slim", extra = ["ag-ui"] }, + { name = "pydantic-ai-slim", extra = ["ag-ui", "mcp"] }, { name = "pyjwt" }, { name = "uvicorn" }, { name = "uvloop", marker = "sys_platform != 'win32'" }, @@ -2522,7 +2761,7 @@ oidc = [ { name = "pyjwt" }, ] pydantic-ai = [ - { name = "pydantic-ai-slim", extra = ["ag-ui"] }, + { name = "pydantic-ai-slim", extra = ["ag-ui", "mcp"] }, ] serve = [ { name = "psycopg", extra = ["binary", "pool"] }, @@ -2606,8 +2845,8 @@ requires-dist = [ { name = "psycopg", extras = ["binary", "pool"], marker = "extra == 'all'", specifier = ">=3.2.9,<4" }, { name = "psycopg", extras = ["binary", "pool"], marker = "extra == 'serve'", specifier = ">=3.2.9,<4" }, { name = "pydantic", specifier = ">=2.13" }, - { name = "pydantic-ai-slim", extras = ["ag-ui"], marker = "extra == 'all'", specifier = ">=1.87,<2" }, - { name = "pydantic-ai-slim", extras = ["ag-ui"], marker = "extra == 'pydantic-ai'", specifier = ">=1.87,<2" }, + { name = "pydantic-ai-slim", extras = ["ag-ui", "mcp"], marker = "extra == 'all'", specifier = ">=1.87,<2" }, + { name = "pydantic-ai-slim", extras = ["ag-ui", "mcp"], marker = "extra == 'pydantic-ai'", specifier = ">=1.87,<2" }, { name = "pydantic-core" }, { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.13" }, { name = "pyjwt", marker = "extra == 'all'", specifier = ">=2,<3" }, @@ -2955,15 +3194,15 @@ wheels = [ [[package]] name = "starlette" -version = "1.0.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] From 8f4d7fec5393b3a4cc12d758681189e2c7e39645 Mon Sep 17 00:00:00 2001 From: Adam Lewis <23342526+Adam-D-Lewis@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:35:30 -0500 Subject: [PATCH 03/12] Apply ruff-format to satisfy CI format hook - docs/tutorials/agents.py: wrap long calls at the tutorial's 79-col limit - src/_ravnar/agents.py: normalize regex literal to double quotes --- docs/tutorials/agents.py | 12 +++++++++--- src/_ravnar/agents.py | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/tutorials/agents.py b/docs/tutorials/agents.py index ef533da..6444258 100644 --- a/docs/tutorials/agents.py +++ b/docs/tutorials/agents.py @@ -45,7 +45,9 @@ def run_agent(client, agent_id: str, message: str) -> None: ], } - with httpx_sse.connect_sse(client, "POST", f"/api/agents/{agent_id}/run", json=body) as event_source: + with httpx_sse.connect_sse( + client, "POST", f"/api/agents/{agent_id}/run", json=body + ) as event_source: text = "" for sse in event_source.iter_sse(): event = json.loads(sse.data) @@ -96,10 +98,14 @@ async def run( text = f"Hello, {user.id}!" for word in text.split(): - yield ag_ui.core.TextMessageContentEvent(message_id=message_id, delta=word + " ") + yield ag_ui.core.TextMessageContentEvent( + message_id=message_id, delta=word + " " + ) yield ag_ui.core.TextMessageEndEvent(message_id=message_id) - yield ag_ui.core.RunFinishedEvent(thread_id=input.thread_id, run_id=input.run_id) + yield ag_ui.core.RunFinishedEvent( + thread_id=input.thread_id, run_id=input.run_id + ) # %% [markdown] diff --git a/src/_ravnar/agents.py b/src/_ravnar/agents.py index 0b18847..a0ccc24 100644 --- a/src/_ravnar/agents.py +++ b/src/_ravnar/agents.py @@ -50,7 +50,7 @@ async def run(self, input: ag_ui.core.RunAgentInput, user: User) -> AsyncIterato thread_id=input.thread_id, run_id=input.run_id, parent_run_id=input.parent_run_id ) yield ag_ui.core.TextMessageStartEvent(message_id=message_id) - for delta in re.findall(r'\s*\S+', textwrap.dedent(message.strip())): + for delta in re.findall(r"\s*\S+", textwrap.dedent(message.strip())): yield ag_ui.core.TextMessageContentEvent(message_id=message_id, delta=delta) yield ag_ui.core.TextMessageEndEvent(message_id=message_id) yield ag_ui.core.RunFinishedEvent(thread_id=input.thread_id, run_id=input.run_id) From 5e76f9b9758407836ce436df1f98ee1349dcccd9 Mon Sep 17 00:00:00 2001 From: Adam Lewis <23342526+Adam-D-Lewis@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:18:52 -0500 Subject: [PATCH 04/12] Render API names as code spans in the agent tutorial mkdocstrings cross-references ([`Name`][dotted.path]) don't resolve inside mkdocs-jupyter markdown cells: nbconvert renders those cells outside MkDocs's Markdown pipeline where autorefs lives, so all 19 showed as literal [text][id] brackets. Convert them to plain code spans, which render cleanly under nbconvert. Real parenthesized links (pydantic-ai, MCP, MCPToolset) are unchanged. Upstream limitation: https://github.com/danielfrg/mkdocs-jupyter/issues/236 --- docs/tutorials/agents.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/tutorials/agents.py b/docs/tutorials/agents.py index 6444258..ca02508 100644 --- a/docs/tutorials/agents.py +++ b/docs/tutorials/agents.py @@ -3,9 +3,9 @@ # # This tutorial explains how to add an agent to ravnar. You will see two approaches: # -# 1. **Full control** — subclassing the [`Agent`][ravnar.agents.Agent] ABC directly. +# 1. **Full control** — subclassing the `Agent` ABC directly. # 2. **Using a wrapper** — adapting an existing [pydantic-ai](https://ai.pydantic.dev/) agent via -# [`PydanticAiAgentWrapper`][ravnar.agents.PydanticAiAgentWrapper]. +# `PydanticAiAgentWrapper`. # # Building on the wrapper, the final section shows how to give an agent the tools of an # [MCP](https://modelcontextprotocol.io/) server. @@ -68,9 +68,9 @@ def run_agent(client, agent_id: str, message: str) -> None: # %% [markdown] # ## Full control with the Agent ABC # -# The [`Agent`][ravnar.agents.Agent] abstract base class gives you complete control over the agent's behaviour. -# You implement a single method, [`run()`][ravnar.agents.Agent.run], which receives the incoming -# `RunAgentInput` and a [`User`][ravnar.authenticators.User] object, and yields `Event`s. +# The `Agent` abstract base class gives you complete control over the agent's behaviour. +# You implement a single method, `run()`, which receives the incoming +# `RunAgentInput` and a `User` object, and yields `Event`s. # # Let's build a simple agent that greets the current user. @@ -109,7 +109,7 @@ async def run( # %% [markdown] -# The [`User`][ravnar.authenticators.User] object carries the authenticated user's identity along with any additional data and permissions. +# The `User` object carries the authenticated user's identity along with any additional data and permissions. # When no authenticator is configured, the user defaults to the current system user, and all permissions are granted. # # Now we register it as a static agent through the ravnar configuration. Static agents are declared upfront and are @@ -133,7 +133,7 @@ async def run( print_json(agents) # %% [markdown] -# The capabilities are derived from the default [`get_capabilities()`][ravnar.agents.Agent.get_capabilities] method of the base class which, among others, +# The capabilities are derived from the default `get_capabilities()` method of the base class which, among others, # reports that the agent supports streaming. No tools are declared — this is a purely conversational agent. # # Time to send it a message. @@ -143,23 +143,23 @@ async def run( # %% [markdown] # The `run_agent()` helper parsed the SSE event stream and printed only the text content. The agent reads the user -# ID from the [`User`][ravnar.authenticators.User] object and includes it in the greeting. +# ID from the `User` object and includes it in the greeting. # -# Subclassing [`Agent`][ravnar.agents.Agent] is the most flexible approach — you have full control over the event stream and can integrate +# Subclassing `Agent` is the most flexible approach — you have full control over the event stream and can integrate # virtually any protocol or library. However, it also means you are responsible for producing the right events at the # right time. # %% [markdown] # ## Using the Pydantic AI wrapper # -# If you already use [pydantic-ai](https://ai.pydantic.dev/), you do not need to implement the [`Agent`][ravnar.agents.Agent] -# interface yourself. ravnar ships with [`PydanticAiAgentWrapper`][ravnar.agents.PydanticAiAgentWrapper] that adapts any +# If you already use [pydantic-ai](https://ai.pydantic.dev/), you do not need to implement the `Agent` +# interface yourself. ravnar ships with `PydanticAiAgentWrapper` that adapts any # `pydantic_ai.Agent` into a ravnar agent. It handles event generation, tool call streaming, and capability detection # automatically. # # Let's build a pydantic-ai agent with a `whoami` tool that accesses the authenticated user. The tool is a regular -# async function that takes [`RunContext[User]`][pydantic_ai.RunContext] as its first parameter — ravnar injects the -# [`User`][ravnar.authenticators.User] object as the dependency when the agent runs. +# async function that takes `RunContext[User]` as its first parameter — ravnar injects the +# `User` object as the dependency when the agent runs. # %% from pydantic_ai import RunContext @@ -211,9 +211,9 @@ async def whoami(ctx: RunContext[User]) -> str: # %% [markdown] # Notice the `whoami` tool is listed with its description and an empty parameter schema (it takes no arguments). -# The [`PydanticAiAgentWrapper`][ravnar.agents.PydanticAiAgentWrapper] introspects the underlying pydantic-ai agent +# The `PydanticAiAgentWrapper` introspects the underlying pydantic-ai agent # to build the capability object dynamically via -# [`extract_capabilities()`][ravnar.agents.PydanticAiAgentWrapper.extract_capabilities]. +# `extract_capabilities()`. # # Let's run it. @@ -243,7 +243,7 @@ async def whoami(ctx: RunContext[User]) -> str: # on the ravnar side. # # One difference matters: an MCP server runs as a *separate process* (or a remote service), so its tools cannot access -# ravnar's injected [`User`][ravnar.authenticators.User]. Reach for an in-process pydantic-ai tool when a tool needs +# ravnar's injected `User`. Reach for an in-process pydantic-ai tool when a tool needs # the caller's identity, and for an MCP server when the capability is self-contained. # # Let's write a minimal stdio MCP server that exposes a single `add` tool. In a real project this would be a separate @@ -332,11 +332,11 @@ def add(a: int, b: int) -> int: # %% [markdown] # ## Summary # -# - Subclass [`Agent`][ravnar.agents.Agent] directly when you need full control over the event stream or want to +# - Subclass `Agent` directly when you need full control over the event stream or want to # integrate a custom protocol. -# - Use [`PydanticAiAgentWrapper`][ravnar.agents.PydanticAiAgentWrapper] when you already have a pydantic-ai agent — +# - Use `PydanticAiAgentWrapper` when you already have a pydantic-ai agent — # ravnar plugs it in automatically. -# - ravnar injects the [`User`][ravnar.authenticators.User] object into the agent's `run()` method. For pydantic-ai +# - ravnar injects the `User` object into the agent's `run()` method. For pydantic-ai # agents, it is available as `deps` in tools via `RunContext.deps`. # - Add [`MCPToolset`](https://ai.pydantic.dev/mcp/client/) to a pydantic-ai agent's `toolsets` to expose the tools of # any MCP server; the wrapper discovers and streams them automatically. From f74e41187e07b3b9d7e7df2171ce5ce30fcc2bf1 Mon Sep 17 00:00:00 2001 From: Adam Lewis <23342526+Adam-D-Lewis@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:32:08 -0500 Subject: [PATCH 05/12] Silence ravnar runtime logging in the docs Client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each executed-docs cell builds a real Ravnar app, whose lifespan configures logging at INFO — surfacing httpx "HTTP Request" INFO lines and repeated OpenTelemetry "already instrumented" warnings as cell output. Pin the docs Client's level to ERROR so rendered tutorials show only each example's own output. Production logging is unaffected. --- src/_ravnar/docs.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/_ravnar/docs.py b/src/_ravnar/docs.py index 7ea9398..1c45aea 100644 --- a/src/_ravnar/docs.py +++ b/src/_ravnar/docs.py @@ -1,5 +1,6 @@ from typing import Any +import l2sl from fastapi.testclient import TestClient from _ravnar.core import Ravnar @@ -14,7 +15,14 @@ def Client(config: Any = None) -> TestClient: if _CLIENT is not None: _CLIENT.__exit__(None, None, None) - _CLIENT = TestClient(Ravnar(BaseConfig.model_validate(config or {})).app) + config = BaseConfig.model_validate(config or {}) + # Keep the executed-docs output focused on each example: silence ravnar's + # runtime logging (httpx request logs, repeated OTel "already instrumented" + # warnings from building a fresh app per cell) that nbconvert would + # otherwise capture as cell output. + config.observability.logging.level = l2sl.LogLevel("error") + + _CLIENT = TestClient(Ravnar(config).app) # context needs to be entered here to trigger the lifespan events _CLIENT.__enter__() return _CLIENT From 286100ee8d6603f5fe9b036c8e5d37316ff2b8e0 Mon Sep 17 00:00:00 2001 From: Adam Lewis <23342526+Adam-D-Lewis@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:45:07 +0000 Subject: [PATCH 06/12] Note other agent integrations in the tutorial Add a framing line to the intro and a summary bullet pointing at ravnar's other built-in agents (AgnoAgentWrapper, SSEAgent, and the Agent ABC), so readers see pydantic-ai is the featured example, not a requirement. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/tutorials/agents.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/tutorials/agents.py b/docs/tutorials/agents.py index ca02508..b720373 100644 --- a/docs/tutorials/agents.py +++ b/docs/tutorials/agents.py @@ -10,6 +10,10 @@ # Building on the wrapper, the final section shows how to give an agent the tools of an # [MCP](https://modelcontextprotocol.io/) server. # +# pydantic-ai is used throughout as the example, but ravnar is not tied to it: under the hood ravnar is an AG-UI +# server, so any agent that emits AG-UI events is a first-class citizen. Other built-in options are summarised at the +# end. +# # A special `Client` is used for the documentation. For real-world scenarios, it can be substituted with a regular HTTP # client with the base URL set to the URL of your ravnar deployment. @@ -340,4 +344,7 @@ def add(a: int, b: int) -> int: # agents, it is available as `deps` in tools via `RunContext.deps`. # - Add [`MCPToolset`](https://ai.pydantic.dev/mcp/client/) to a pydantic-ai agent's `toolsets` to expose the tools of # any MCP server; the wrapper discovers and streams them automatically. +# - Not using pydantic-ai? ravnar also ships `AgnoAgentWrapper` for [Agno](https://docs.agno.com/) agents and +# `SSEAgent` to connect any agent that already speaks AG-UI over HTTP — and you can always implement the `Agent` ABC +# directly. See the Python API reference for the full list of built-in agents. # - All agents are registered through the same configuration mechanism, whether they are custom subclasses or wrappers. From f12967cad52552777284fb5a68c9d0e2502dc37b Mon Sep 17 00:00:00 2001 From: Adam Lewis <23342526+Adam-D-Lewis@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:32:18 -0500 Subject: [PATCH 07/12] Fold the MCP example into the Pydantic AI wrapper section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP was a separate top-level section, which framed it as its own feature. Instead, show it as just another tool on the wrapper: after the in-process whoami example, attach an MCPToolset to the *same* agent alongside tools=[whoami]. Capabilities then list both whoami and add, and a run invokes both — reinforcing that the wrapper treats an MCP server's tools identically to in-process ones. Verified end-to-end (both tools listed and called). --- docs/tutorials/agents.py | 44 ++++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/docs/tutorials/agents.py b/docs/tutorials/agents.py index b720373..d55715b 100644 --- a/docs/tutorials/agents.py +++ b/docs/tutorials/agents.py @@ -238,17 +238,14 @@ async def whoami(ctx: RunContext[User]) -> str: # (e.g. `openai`, `anthropic`, `openrouter`) — everything else stays the same. # %% [markdown] -# ## Connecting an MCP server -# -# [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) servers expose tools, resources, and prompts over -# a standard protocol, so any MCP-compatible client can use them. pydantic-ai connects to an MCP server with -# [`MCPToolset`](https://ai.pydantic.dev/mcp/client/), and because the wrapper introspects the agent's toolsets, the -# server's tools are discovered and become callable exactly like the in-process `whoami` tool above — no extra wiring -# on the ravnar side. +# So far the agent's only tool is an in-process Python function. The wrapper can just as easily expose the tools of an +# [MCP](https://modelcontextprotocol.io/) server: pydantic-ai connects to one with +# [`MCPToolset`](https://ai.pydantic.dev/mcp/client/), and because the wrapper introspects the agent's `toolsets`, +# those tools are discovered and called exactly like `whoami` — no extra wiring on the ravnar side. # # One difference matters: an MCP server runs as a *separate process* (or a remote service), so its tools cannot access -# ravnar's injected `User`. Reach for an in-process pydantic-ai tool when a tool needs -# the caller's identity, and for an MCP server when the capability is self-contained. +# ravnar's injected `User`. Reach for an in-process tool when it needs the caller's identity, and an MCP server when +# the capability is self-contained. # # Let's write a minimal stdio MCP server that exposes a single `add` tool. In a real project this would be a separate # service; here we write it to a temporary file so the tutorial stays self-contained. @@ -277,15 +274,16 @@ def add(a: int, b: int) -> int: ) # %% [markdown] -# Now we declare an agent that connects to it. [`MCPToolset`](https://ai.pydantic.dev/mcp/client/) accepts the path to -# a Python script and launches it as a stdio server (it also accepts a URL for a remote HTTP/SSE server). We add it to -# the agent's `toolsets` through the same recursive config mechanism — no Python instantiation needed. +# Now we give the same agent both kinds of tool — the in-process `whoami` and the server's `add` — by adding an +# [`MCPToolset`](https://ai.pydantic.dev/mcp/client/) to its `toolsets` alongside `tools`. It's the same recursive +# config, no Python instantiation. `MCPToolset` takes the path to a script and launches it as a stdio server (it also +# accepts a URL for a remote HTTP/SSE server). # %% config = { "agents": { "static": { - "calculator": { + "assistant": { "cls_or_fn": "ravnar.agents.PydanticAiAgentWrapper", "params": { "agent": { @@ -295,6 +293,8 @@ def add(a: int, b: int) -> int: "cls_or_fn": "pydantic_ai.models.test.TestModel", "params": {"call_tools": "all"}, }, + "deps_type": User, + "tools": [whoami], "toolsets": [ { "cls_or_fn": "pydantic_ai.mcp.MCPToolset", @@ -311,26 +311,26 @@ def add(a: int, b: int) -> int: client = Client(config) # %% [markdown] -# During setup the wrapper connects to the MCP server and discovers its tools. The `add` tool appears in the -# capabilities — this time *with* a parameter schema, unlike the argument-less `whoami`. +# The capabilities now list *both* tools: the argument-less `whoami` and `add`, the latter with a parameter schema +# introspected from the MCP server. # %% agents = client.get("/api/agents").raise_for_status().json() print_json(agents) # %% [markdown] -# Let's run it. +# Let's run it. With `call_tools="all"`, `TestModel` exercises every tool — the in-process one and the MCP one. # %% -run_agent(client, "calculator", "What is 2 + 3?") +run_agent(client, "assistant", "What is 2 + 3, and who am I?") # %% [markdown] -# `TestModel` calls `add` with placeholder arguments (`0` and `0`), so the result is `0` rather than `5` — it does -# not read the operands from the message. A real model would call `add(a=2, b=3)` and get `5`. The point of the -# example is the plumbing: ravnar connected to the MCP server, advertised its tools, and routed the tool call to it -# with no code changes — only configuration. +# `TestModel` calls each tool with placeholder arguments, so `add` returns `0` rather than `5` — it does not read the +# operands from the message. A real model would call `add(a=2, b=3)`. The point is the plumbing: ravnar exposed an +# in-process tool and an MCP server's tool through the same wrapper, advertised both, and routed the calls — with only +# configuration. # -# To connect to a server you do not run yourself (the common case), pass its URL instead of a script path, e.g. +# To use a server you do not run yourself (the common case), pass its URL instead of a script path, e.g. # `{"cls_or_fn": "pydantic_ai.mcp.MCPToolset", "params": {"client": "https://example.com/mcp"}}`. # %% [markdown] From 4b7f3801d18151be603cce5953e691843e3aef3c Mon Sep 17 00:00:00 2001 From: Adam Lewis <23342526+Adam-D-Lewis@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:33:20 -0500 Subject: [PATCH 08/12] Retitle tutorial to 'Configuring an agent in ravnar' The tutorial is entirely config-driven, so 'configuring' fits better than 'adding'. Updates the H1, the nav entry, and folds the now-removed MCP section's intro mention into the wrapper approach. --- docs/tutorials/agents.py | 9 +++------ mkdocs.yml | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/tutorials/agents.py b/docs/tutorials/agents.py index d55715b..f504b74 100644 --- a/docs/tutorials/agents.py +++ b/docs/tutorials/agents.py @@ -1,14 +1,11 @@ # %% [markdown] -# # Adding an agent to ravnar +# # Configuring an agent in ravnar # -# This tutorial explains how to add an agent to ravnar. You will see two approaches: +# This tutorial explains how to configure an agent in ravnar. You will see two approaches: # # 1. **Full control** — subclassing the `Agent` ABC directly. # 2. **Using a wrapper** — adapting an existing [pydantic-ai](https://ai.pydantic.dev/) agent via -# `PydanticAiAgentWrapper`. -# -# Building on the wrapper, the final section shows how to give an agent the tools of an -# [MCP](https://modelcontextprotocol.io/) server. +# `PydanticAiAgentWrapper`, and giving it the tools of an [MCP](https://modelcontextprotocol.io/) server. # # pydantic-ai is used throughout as the example, but ravnar is not tied to it: under the hood ravnar is an AG-UI # server, so any agent that emits AG-UI events is a first-class citizen. Other built-in options are summarised at the diff --git a/mkdocs.yml b/mkdocs.yml index 46fee03..8371c84 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -69,7 +69,7 @@ nav: - Deployment: deploy.md - Tutorials: - Authentication: tutorials/authentication.py - - Adding an agent: tutorials/agents.py + - Configuring an agent: tutorials/agents.py - References: - Configuration: references/config.md - Python API: references/python_api.md From 6b688423f95d5518a959bd33c92440176e39c915 Mon Sep 17 00:00:00 2001 From: Adam Lewis <23342526+Adam-D-Lewis@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:43:20 -0500 Subject: [PATCH 09/12] Clarify that MCP tools can be passed the User explicitly The tutorial said an MCP server's tools 'cannot access' ravnar's injected User. That overstates it: they don't receive it *automatically* the way an in-process tool does, but it can be forwarded explicitly (e.g. via MCPToolset's process_tool_call hook, which gets the RunContext). Soften the wording accordingly. --- docs/tutorials/agents.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/tutorials/agents.py b/docs/tutorials/agents.py index f504b74..fe3d6c8 100644 --- a/docs/tutorials/agents.py +++ b/docs/tutorials/agents.py @@ -240,8 +240,9 @@ async def whoami(ctx: RunContext[User]) -> str: # [`MCPToolset`](https://ai.pydantic.dev/mcp/client/), and because the wrapper introspects the agent's `toolsets`, # those tools are discovered and called exactly like `whoami` — no extra wiring on the ravnar side. # -# One difference matters: an MCP server runs as a *separate process* (or a remote service), so its tools cannot access -# ravnar's injected `User`. Reach for an in-process tool when it needs the caller's identity, and an MCP server when +# One difference matters: an MCP server runs as a *separate process* (or a remote service), so its tools don't +# automatically receive ravnar's injected `User` the way an in-process tool does — though you can forward it +# explicitly if needed. Reach for an in-process tool when a tool needs the caller's identity, and an MCP server when # the capability is self-contained. # # Let's write a minimal stdio MCP server that exposes a single `add` tool. In a real project this would be a separate From 517289332b6fb550b3d45bf7fad9ef02f2ec8645 Mon Sep 17 00:00:00 2001 From: Adam Lewis <23342526+Adam-D-Lewis@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:50:33 -0500 Subject: [PATCH 10/12] docs: collapse wrapper section into one agent with both tool kinds The wrapper section registered, inspected, and ran an agent twice: first with only the in-process whoami tool, then again with whoami plus an MCP server's tools. The second agent was a superset of the first, so the whoami-only pass was redundant. Present a single 'assistant' agent that has both kinds of tool from the start: introduce the in-process tool, introduce the MCP server, then one config -> one capabilities check (both tools) -> one run (both called). --- docs/tutorials/agents.py | 111 +++++++++------------------------------ 1 file changed, 26 insertions(+), 85 deletions(-) diff --git a/docs/tutorials/agents.py b/docs/tutorials/agents.py index fe3d6c8..51d693a 100644 --- a/docs/tutorials/agents.py +++ b/docs/tutorials/agents.py @@ -153,14 +153,15 @@ async def run( # %% [markdown] # ## Using the Pydantic AI wrapper # -# If you already use [pydantic-ai](https://ai.pydantic.dev/), you do not need to implement the `Agent` -# interface yourself. ravnar ships with `PydanticAiAgentWrapper` that adapts any -# `pydantic_ai.Agent` into a ravnar agent. It handles event generation, tool call streaming, and capability detection -# automatically. +# If you already use [pydantic-ai](https://ai.pydantic.dev/), you do not need to implement the `Agent` interface +# yourself. ravnar ships with `PydanticAiAgentWrapper`, which adapts any `pydantic_ai.Agent` into a ravnar agent — +# handling event generation, tool call streaming, and capability detection automatically. # -# Let's build a pydantic-ai agent with a `whoami` tool that accesses the authenticated user. The tool is a regular -# async function that takes `RunContext[User]` as its first parameter — ravnar injects the -# `User` object as the dependency when the agent runs. +# We'll give a single agent two kinds of tool: an in-process Python function, and the tools of an +# [MCP](https://modelcontextprotocol.io/) server. +# +# First, the in-process tool. `whoami` is a regular async function that takes `RunContext[User]` as its first +# parameter — ravnar injects the authenticated `User` as the dependency when the agent runs. # %% from pydantic_ai import RunContext @@ -172,73 +173,9 @@ async def whoami(ctx: RunContext[User]) -> str: # %% [markdown] -# Now we register the agent purely through the configuration — no Python instantiation needed. ravnar's -# `ImportStringWithParams` mechanism resolves nested definitions recursively, so we can declare the entire agent -# tree (model, tools, wrapper) as a single config block. - -# %% -config = { - "agents": { - "static": { - "pydantic-whoami": { - "cls_or_fn": "ravnar.agents.PydanticAiAgentWrapper", - "params": { - "agent": { - "cls_or_fn": "pydantic_ai.Agent", - "params": { - "model": { - "cls_or_fn": "pydantic_ai.models.test.TestModel", - "params": { - "call_tools": "all", - }, - }, - "deps_type": User, - "tools": [whoami], - }, - }, - }, - }, - }, - }, -} -client = Client(config) - -# %% [markdown] -# The wrapper automatically discovers the tool and reports it in the capabilities. - -# %% -agents = client.get("/api/agents").raise_for_status().json() -print_json(agents) - -# %% [markdown] -# Notice the `whoami` tool is listed with its description and an empty parameter schema (it takes no arguments). -# The `PydanticAiAgentWrapper` introspects the underlying pydantic-ai agent -# to build the capability object dynamically via -# `extract_capabilities()`. -# -# Let's run it. - -# %% -run_agent(client, "pydantic-whoami", "Who am I?") - -# %% [markdown] -# `TestModel` is pydantic-ai's stand-in for a real model: it exercises the full agent plumbing without calling an -# LLM, and with `call_tools="all"` it invokes every available tool once. The helper shows: -# -# - `🛠 Calling tool: whoami` — the tool was invoked. -# - `✅ agent` — the value returned by the tool, i.e. the user ID. Since no authenticator is configured, this is your -# system username. -# - The final assistant message, which `TestModel` builds by echoing the tool's output as JSON. -# -# Because we used the config-driven approach, the entire agent lifecycle (model instantiation, tool registration, -# wrapper setup) is handled automatically. In production you would swap `TestModel` for a real model -# (e.g. `openai`, `anthropic`, `openrouter`) — everything else stays the same. - -# %% [markdown] -# So far the agent's only tool is an in-process Python function. The wrapper can just as easily expose the tools of an -# [MCP](https://modelcontextprotocol.io/) server: pydantic-ai connects to one with -# [`MCPToolset`](https://ai.pydantic.dev/mcp/client/), and because the wrapper introspects the agent's `toolsets`, -# those tools are discovered and called exactly like `whoami` — no extra wiring on the ravnar side. +# Second, an MCP server. pydantic-ai connects to one with [`MCPToolset`](https://ai.pydantic.dev/mcp/client/), and +# because the wrapper introspects the agent's `toolsets`, the server's tools are discovered and called exactly like +# `whoami` — no extra wiring on the ravnar side. # # One difference matters: an MCP server runs as a *separate process* (or a remote service), so its tools don't # automatically receive ravnar's injected `User` the way an in-process tool does — though you can forward it @@ -272,10 +209,10 @@ def add(a: int, b: int) -> int: ) # %% [markdown] -# Now we give the same agent both kinds of tool — the in-process `whoami` and the server's `add` — by adding an -# [`MCPToolset`](https://ai.pydantic.dev/mcp/client/) to its `toolsets` alongside `tools`. It's the same recursive -# config, no Python instantiation. `MCPToolset` takes the path to a script and launches it as a stdio server (it also -# accepts a URL for a remote HTTP/SSE server). +# Now we register the agent purely through configuration — no Python instantiation needed. ravnar's +# `ImportStringWithParams` mechanism resolves nested definitions recursively, so we declare the whole agent tree — +# model, wrapper, the in-process `tools`, and the MCP `toolsets` — as a single config block. `MCPToolset` takes the +# path to a script and launches it as a stdio server (it also accepts a URL for a remote HTTP/SSE server). # %% config = { @@ -309,24 +246,28 @@ def add(a: int, b: int) -> int: client = Client(config) # %% [markdown] -# The capabilities now list *both* tools: the argument-less `whoami` and `add`, the latter with a parameter schema -# introspected from the MCP server. +# The wrapper builds the capability object dynamically by introspecting the underlying pydantic-ai agent (via +# `extract_capabilities()`), so both tools show up: the argument-less `whoami`, and `add` with a parameter schema +# discovered from the MCP server. # %% agents = client.get("/api/agents").raise_for_status().json() print_json(agents) # %% [markdown] -# Let's run it. With `call_tools="all"`, `TestModel` exercises every tool — the in-process one and the MCP one. +# Let's run it. `TestModel` is pydantic-ai's stand-in for a real model: it exercises the full agent plumbing without +# calling an LLM, and with `call_tools="all"` it invokes every available tool once — both the in-process `whoami` and +# the MCP `add`. # %% run_agent(client, "assistant", "What is 2 + 3, and who am I?") # %% [markdown] -# `TestModel` calls each tool with placeholder arguments, so `add` returns `0` rather than `5` — it does not read the -# operands from the message. A real model would call `add(a=2, b=3)`. The point is the plumbing: ravnar exposed an -# in-process tool and an MCP server's tool through the same wrapper, advertised both, and routed the calls — with only -# configuration. +# `TestModel` calls each tool with placeholder arguments, so `add` returns `0` rather than `5`, and `whoami` returns +# your system username (no authenticator is configured). A real model would read the message and call `add(a=2, b=3)`. +# The point is the plumbing: ravnar exposed an in-process tool and an MCP server's tool through the *same* wrapper, +# advertised both, and routed the calls — all from configuration. In production you would swap `TestModel` for a real +# model (e.g. `openai`, `anthropic`, `openrouter`); everything else stays the same. # # To use a server you do not run yourself (the common case), pass its URL instead of a script path, e.g. # `{"cls_or_fn": "pydantic_ai.mcp.MCPToolset", "params": {"client": "https://example.com/mcp"}}`. From c35dd8db7c981523b9c7a448909bdfabb243bb6c Mon Sep 17 00:00:00 2001 From: Adam Lewis <23342526+Adam-D-Lewis@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:08:39 -0500 Subject: [PATCH 11/12] Address review feedback on the agent tutorial - Move the `mcp` extra out of the `pydantic-ai` optional dependency and into the `docs` group, since it is only needed to build the tutorial. - Keep the executed-docs access logs: drop the log-level override in `_ravnar.docs.Client` and instead make SQLAlchemy instrumentation idempotent (guard on `is_instrumented_by_opentelemetry`). This removes the repeated "already instrumented" warning without silencing logging, and is behavior-preserving since the repeat call was already a no-op. - Restore mkdocstrings cross-references for the internal API symbols that are rendered in the reference (Agent, get_capabilities, PydanticAiAgentWrapper, AgnoAgentWrapper, SSEAgent); leave the undocumented ones as code spans. - Expand the Agent ABC section to explain ravnar's AG-UI event model. - Replace the MCP `add(a, b)` example with an argument-less `greet()` so TestModel produces correct output; greet with "Hello!". - Link the config reference's import mechanism instead of naming the private `ImportStringWithParams`. --- docs/tutorials/agents.py | 93 +++++++++++++++++++++++----------------- pyproject.toml | 4 +- src/_ravnar/database.py | 15 ++++--- src/_ravnar/docs.py | 10 +---- 4 files changed, 67 insertions(+), 55 deletions(-) diff --git a/docs/tutorials/agents.py b/docs/tutorials/agents.py index 51d693a..f77ee5e 100644 --- a/docs/tutorials/agents.py +++ b/docs/tutorials/agents.py @@ -3,9 +3,10 @@ # # This tutorial explains how to configure an agent in ravnar. You will see two approaches: # -# 1. **Full control** — subclassing the `Agent` ABC directly. +# 1. **Full control** — subclassing the [`Agent`][ravnar.agents.Agent] ABC directly. # 2. **Using a wrapper** — adapting an existing [pydantic-ai](https://ai.pydantic.dev/) agent via -# `PydanticAiAgentWrapper`, and giving it the tools of an [MCP](https://modelcontextprotocol.io/) server. +# [`PydanticAiAgentWrapper`][ravnar.agents.PydanticAiAgentWrapper], and giving it the tools of an +# [MCP](https://modelcontextprotocol.io/) server. # # pydantic-ai is used throughout as the example, but ravnar is not tied to it: under the hood ravnar is an AG-UI # server, so any agent that emits AG-UI events is a first-class citizen. Other built-in options are summarised at the @@ -69,11 +70,18 @@ def run_agent(client, agent_id: str, message: str) -> None: # %% [markdown] # ## Full control with the Agent ABC # -# The `Agent` abstract base class gives you complete control over the agent's behaviour. -# You implement a single method, `run()`, which receives the incoming -# `RunAgentInput` and a `User` object, and yields `Event`s. +# ravnar is, at its core, an [AG-UI](https://ag-ui.com) server: every agent talks to clients by emitting a stream of +# AG-UI events — `RUN_STARTED`, `TEXT_MESSAGE_CONTENT`, `TOOL_CALL_START`, `RUN_FINISHED`, and so on. ravnar itself +# doesn't decide what those events are; it streams whatever the agent produces to the client over Server-Sent Events. +# Every integration in this tutorial is ultimately just a different way of producing that event stream. # -# Let's build a simple agent that greets the current user. +# The [`Agent`][ravnar.agents.Agent] abstract base class is the most direct way to produce it, giving you complete +# control over the agent's behaviour. You implement a single method, `run()`, which receives the incoming +# `RunAgentInput` (the AG-UI request — messages, thread and run IDs, and any client-supplied tools and state) and the +# authenticated `User`, and yields the `ag_ui.core.Event`s that make up the response. +# +# Let's build a simple agent that greets the current user. The sequence below — start the run, open a text message, +# stream its content word by word, end the message, finish the run — is the canonical shape of an AG-UI text response. # %% import ag_ui.core @@ -134,28 +142,30 @@ async def run( print_json(agents) # %% [markdown] -# The capabilities are derived from the default `get_capabilities()` method of the base class which, among others, -# reports that the agent supports streaming. No tools are declared — this is a purely conversational agent. +# The capabilities are derived from the default [`get_capabilities()`][ravnar.agents.Agent.get_capabilities] method of +# the base class which, among others, reports that the agent supports streaming. No tools are declared — this is a +# purely conversational agent. # # Time to send it a message. # %% -run_agent(client, "whoami", "Who am I?") +run_agent(client, "whoami", "Hello!") # %% [markdown] # The `run_agent()` helper parsed the SSE event stream and printed only the text content. The agent reads the user # ID from the `User` object and includes it in the greeting. # -# Subclassing `Agent` is the most flexible approach — you have full control over the event stream and can integrate -# virtually any protocol or library. However, it also means you are responsible for producing the right events at the -# right time. +# Subclassing [`Agent`][ravnar.agents.Agent] is the most flexible approach — you have full control over the event +# stream and can integrate virtually any protocol or library. However, it also means you are responsible for producing +# the right events at the right time. # %% [markdown] # ## Using the Pydantic AI wrapper # -# If you already use [pydantic-ai](https://ai.pydantic.dev/), you do not need to implement the `Agent` interface -# yourself. ravnar ships with `PydanticAiAgentWrapper`, which adapts any `pydantic_ai.Agent` into a ravnar agent — -# handling event generation, tool call streaming, and capability detection automatically. +# If you already use [pydantic-ai](https://ai.pydantic.dev/), you do not need to implement the +# [`Agent`][ravnar.agents.Agent] interface yourself. ravnar ships with +# [`PydanticAiAgentWrapper`][ravnar.agents.PydanticAiAgentWrapper], which adapts any `pydantic_ai.Agent` into a ravnar +# agent — handling AG-UI event generation, tool call streaming, and capability detection automatically. # # We'll give a single agent two kinds of tool: an in-process Python function, and the tools of an # [MCP](https://modelcontextprotocol.io/) server. @@ -182,25 +192,25 @@ async def whoami(ctx: RunContext[User]) -> str: # explicitly if needed. Reach for an in-process tool when a tool needs the caller's identity, and an MCP server when # the capability is self-contained. # -# Let's write a minimal stdio MCP server that exposes a single `add` tool. In a real project this would be a separate -# service; here we write it to a temporary file so the tutorial stays self-contained. +# Let's write a minimal stdio MCP server that exposes a single `greet` tool. In a real project this would be a +# separate service; here we write it to a temporary file so the tutorial stays self-contained. # %% import pathlib import tempfile -mcp_server = pathlib.Path(tempfile.mkdtemp()) / "calculator.py" +mcp_server = pathlib.Path(tempfile.mkdtemp()) / "greeter.py" mcp_server.write_text( ''' from mcp.server.fastmcp import FastMCP -mcp = FastMCP("calculator") +mcp = FastMCP("greeter") @mcp.tool() -def add(a: int, b: int) -> int: - """Add two numbers.""" - return a + b +def greet() -> str: + """Return a friendly greeting from the MCP server.""" + return "Hello from the MCP server!" if __name__ == "__main__": @@ -210,9 +220,10 @@ def add(a: int, b: int) -> int: # %% [markdown] # Now we register the agent purely through configuration — no Python instantiation needed. ravnar's -# `ImportStringWithParams` mechanism resolves nested definitions recursively, so we declare the whole agent tree — -# model, wrapper, the in-process `tools`, and the MCP `toolsets` — as a single config block. `MCPToolset` takes the -# path to a script and launches it as a stdio server (it also accepts a URL for a remote HTTP/SSE server). +# [configuration import mechanism](../../references/config/#plugins) resolves nested `cls_or_fn`/`params` definitions +# recursively, so we declare the whole agent tree — model, wrapper, the in-process `tools`, and the MCP `toolsets` — +# as a single config block. `MCPToolset` takes the path to a script and launches it as a stdio server (it also accepts +# a URL for a remote HTTP/SSE server). # %% config = { @@ -247,8 +258,8 @@ def add(a: int, b: int) -> int: # %% [markdown] # The wrapper builds the capability object dynamically by introspecting the underlying pydantic-ai agent (via -# `extract_capabilities()`), so both tools show up: the argument-less `whoami`, and `add` with a parameter schema -# discovered from the MCP server. +# `extract_capabilities()`), so both tools show up: the in-process `whoami`, and `greet`, whose definition — name, +# description, and (empty) parameter schema — was discovered from the MCP server. # %% agents = client.get("/api/agents").raise_for_status().json() @@ -257,17 +268,18 @@ def add(a: int, b: int) -> int: # %% [markdown] # Let's run it. `TestModel` is pydantic-ai's stand-in for a real model: it exercises the full agent plumbing without # calling an LLM, and with `call_tools="all"` it invokes every available tool once — both the in-process `whoami` and -# the MCP `add`. +# the MCP `greet`. # %% -run_agent(client, "assistant", "What is 2 + 3, and who am I?") +run_agent(client, "assistant", "Hello!") # %% [markdown] -# `TestModel` calls each tool with placeholder arguments, so `add` returns `0` rather than `5`, and `whoami` returns -# your system username (no authenticator is configured). A real model would read the message and call `add(a=2, b=3)`. -# The point is the plumbing: ravnar exposed an in-process tool and an MCP server's tool through the *same* wrapper, -# advertised both, and routed the calls — all from configuration. In production you would swap `TestModel` for a real -# model (e.g. `openai`, `anthropic`, `openrouter`); everything else stays the same. +# Both tools take no arguments, so `TestModel` calls them exactly as a real model would and the results are real: +# `whoami` returns your system username (no authenticator is configured) and `greet` returns the MCP server's +# greeting. The point is the plumbing: ravnar exposed an in-process tool and an MCP server's tool through the *same* +# wrapper, advertised both, and routed the calls — all from configuration. In production you would swap `TestModel` +# for a real model (e.g. `openai`, `anthropic`, `openrouter`), which would decide when to call each tool based on the +# conversation; everything else stays the same. # # To use a server you do not run yourself (the common case), pass its URL instead of a script path, e.g. # `{"cls_or_fn": "pydantic_ai.mcp.MCPToolset", "params": {"client": "https://example.com/mcp"}}`. @@ -275,15 +287,18 @@ def add(a: int, b: int) -> int: # %% [markdown] # ## Summary # -# - Subclass `Agent` directly when you need full control over the event stream or want to +# - ravnar is an AG-UI server: an agent is anything that produces a stream of AG-UI events, however you choose to +# build it. +# - Subclass [`Agent`][ravnar.agents.Agent] directly when you need full control over the event stream or want to # integrate a custom protocol. -# - Use `PydanticAiAgentWrapper` when you already have a pydantic-ai agent — +# - Use [`PydanticAiAgentWrapper`][ravnar.agents.PydanticAiAgentWrapper] when you already have a pydantic-ai agent — # ravnar plugs it in automatically. # - ravnar injects the `User` object into the agent's `run()` method. For pydantic-ai # agents, it is available as `deps` in tools via `RunContext.deps`. # - Add [`MCPToolset`](https://ai.pydantic.dev/mcp/client/) to a pydantic-ai agent's `toolsets` to expose the tools of # any MCP server; the wrapper discovers and streams them automatically. -# - Not using pydantic-ai? ravnar also ships `AgnoAgentWrapper` for [Agno](https://docs.agno.com/) agents and -# `SSEAgent` to connect any agent that already speaks AG-UI over HTTP — and you can always implement the `Agent` ABC -# directly. See the Python API reference for the full list of built-in agents. +# - Not using pydantic-ai? ravnar also ships [`AgnoAgentWrapper`][ravnar.agents.AgnoAgentWrapper] for +# [Agno](https://docs.agno.com/) agents and [`SSEAgent`][ravnar.agents.SSEAgent] to connect any agent that already +# speaks AG-UI over HTTP — and you can always implement the [`Agent`][ravnar.agents.Agent] ABC directly. See the +# Python API reference for the full list of built-in agents. # - All agents are registered through the same configuration mechanism, whether they are custom subclasses or wrappers. diff --git a/pyproject.toml b/pyproject.toml index a171c07..089af04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ serve = [ "uvloop; sys_platform != 'win32'", ] pydantic-ai = [ - "pydantic-ai-slim[ag-ui,mcp]>=1.87,<2", + "pydantic-ai-slim[ag-ui]>=1.87,<2", ] agno = [ # FIXME: find the lowest version for AG-UI @@ -93,6 +93,8 @@ docs = [ "mkdocs-macros-plugin>=1.3.9", "mkdocs-material>=9.6.20", "mkdocstrings[python]>=0.30.0", + # the agents tutorial builds a small MCP server; the mcp extra is only needed to render the docs + "pydantic-ai-slim[mcp]>=1.87,<2", "pygments", ] dev = [ diff --git a/src/_ravnar/database.py b/src/_ravnar/database.py index e9bc014..9878cb5 100644 --- a/src/_ravnar/database.py +++ b/src/_ravnar/database.py @@ -54,18 +54,21 @@ def __init__(self, config: DatabaseConfig) -> None: async def setup(self) -> None: # type: ignore[override] session_factory_params = SessionFactoryParams(expire_on_commit=False) + # ``SQLAlchemyInstrumentor`` is a global singleton: once it is instrumented, further ``instrument`` calls are a + # no-op that only logs an "already instrumented" warning. That happens whenever several engines are built in one + # process -- e.g. the executed docs build a fresh app per example -- so guard the call to keep the logs clean. + instrumentor = SQLAlchemyInstrumentor() + if isinstance(self._engine, Engine): - SQLAlchemyInstrumentor().instrument( - engine=self._engine, - ) + if not instrumentor.is_instrumented_by_opentelemetry: + instrumentor.instrument(engine=self._engine) orm.Base.metadata.create_all(bind=self._engine) self._session_factory = sessionmaker(bind=self._engine, **session_factory_params) else: - SQLAlchemyInstrumentor().instrument( - engine=self._engine.sync_engine, - ) + if not instrumentor.is_instrumented_by_opentelemetry: + instrumentor.instrument(engine=self._engine.sync_engine) async with self._engine.begin() as conn: await conn.run_sync(orm.Base.metadata.create_all) diff --git a/src/_ravnar/docs.py b/src/_ravnar/docs.py index 1c45aea..7ea9398 100644 --- a/src/_ravnar/docs.py +++ b/src/_ravnar/docs.py @@ -1,6 +1,5 @@ from typing import Any -import l2sl from fastapi.testclient import TestClient from _ravnar.core import Ravnar @@ -15,14 +14,7 @@ def Client(config: Any = None) -> TestClient: if _CLIENT is not None: _CLIENT.__exit__(None, None, None) - config = BaseConfig.model_validate(config or {}) - # Keep the executed-docs output focused on each example: silence ravnar's - # runtime logging (httpx request logs, repeated OTel "already instrumented" - # warnings from building a fresh app per cell) that nbconvert would - # otherwise capture as cell output. - config.observability.logging.level = l2sl.LogLevel("error") - - _CLIENT = TestClient(Ravnar(config).app) + _CLIENT = TestClient(Ravnar(BaseConfig.model_validate(config or {})).app) # context needs to be entered here to trigger the lifespan events _CLIENT.__enter__() return _CLIENT From 0245cfcdcc6db03653a58fd7c56f86ef60d88938 Mon Sep 17 00:00:00 2001 From: Adam Lewis <23342526+Adam-D-Lewis@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:25:08 -0500 Subject: [PATCH 12/12] Regenerate uv.lock to match pyproject.toml The docs tutorial added pydantic-ai-slim[mcp], but the committed uv.lock was generated with a different uv version, leaving it out of sync. Every CI job and Read the Docs run `uv sync --locked` first and fail immediately. Regenerated with uv 0.8.15; pytest/mypy/pre-commit and a strict mkdocs build all pass locally. --- uv.lock | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/uv.lock b/uv.lock index 5d38741..5dc0868 100644 --- a/uv.lock +++ b/uv.lock @@ -2752,7 +2752,7 @@ all = [ { name = "httpx-sse", version = "0.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, { name = "httpx-sse", version = "0.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, { name = "psycopg", extra = ["binary", "pool"] }, - { name = "pydantic-ai-slim", extra = ["ag-ui", "mcp"] }, + { name = "pydantic-ai-slim", extra = ["ag-ui"] }, { name = "pyjwt" }, { name = "uvicorn" }, { name = "uvloop", marker = "sys_platform != 'win32'" }, @@ -2761,7 +2761,7 @@ oidc = [ { name = "pyjwt" }, ] pydantic-ai = [ - { name = "pydantic-ai-slim", extra = ["ag-ui", "mcp"] }, + { name = "pydantic-ai-slim", extra = ["ag-ui"] }, ] serve = [ { name = "psycopg", extra = ["binary", "pool"] }, @@ -2788,6 +2788,7 @@ dev = [ { name = "mkdocstrings", extra = ["python"] }, { name = "mypy" }, { name = "pre-commit" }, + { name = "pydantic-ai-slim", extra = ["mcp"] }, { name = "pygments" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -2807,6 +2808,7 @@ docs = [ { name = "mkdocs-macros-plugin" }, { name = "mkdocs-material" }, { name = "mkdocstrings", extra = ["python"] }, + { name = "pydantic-ai-slim", extra = ["mcp"] }, { name = "pygments" }, ] lint = [ @@ -2845,8 +2847,8 @@ requires-dist = [ { name = "psycopg", extras = ["binary", "pool"], marker = "extra == 'all'", specifier = ">=3.2.9,<4" }, { name = "psycopg", extras = ["binary", "pool"], marker = "extra == 'serve'", specifier = ">=3.2.9,<4" }, { name = "pydantic", specifier = ">=2.13" }, - { name = "pydantic-ai-slim", extras = ["ag-ui", "mcp"], marker = "extra == 'all'", specifier = ">=1.87,<2" }, - { name = "pydantic-ai-slim", extras = ["ag-ui", "mcp"], marker = "extra == 'pydantic-ai'", specifier = ">=1.87,<2" }, + { name = "pydantic-ai-slim", extras = ["ag-ui"], marker = "extra == 'all'", specifier = ">=1.87,<2" }, + { name = "pydantic-ai-slim", extras = ["ag-ui"], marker = "extra == 'pydantic-ai'", specifier = ">=1.87,<2" }, { name = "pydantic-core" }, { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.13" }, { name = "pyjwt", marker = "extra == 'all'", specifier = ">=2,<3" }, @@ -2878,6 +2880,7 @@ dev = [ { name = "mkdocstrings", extras = ["python"], specifier = ">=0.30.0" }, { name = "mypy", specifier = "==1.17.*" }, { name = "pre-commit", specifier = ">=4.2,<5" }, + { name = "pydantic-ai-slim", extras = ["mcp"], specifier = ">=1.87,<2" }, { name = "pygments" }, { name = "pytest", specifier = ">=9" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, @@ -2897,6 +2900,7 @@ docs = [ { name = "mkdocs-macros-plugin", specifier = ">=1.3.9" }, { name = "mkdocs-material", specifier = ">=9.6.20" }, { name = "mkdocstrings", extras = ["python"], specifier = ">=0.30.0" }, + { name = "pydantic-ai-slim", extras = ["mcp"], specifier = ">=1.87,<2" }, { name = "pygments" }, ] lint = [ @@ -3070,8 +3074,8 @@ name = "secretstorage" version = "3.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, + { name = "cryptography", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, + { name = "jeepney", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", size = 19739, upload-time = "2022-08-13T16:22:46.976Z" } wheels = [