Skip to content

Commit 026bc43

Browse files
committed
docs: add 4 new Python recipes (closes #25)
- email-vendor-leases.md: vendor-specific lease fields via x-* extensions - mcp-skill.md: expose an MCP tool as an ARCP agent - multi-agent-budget.md: coordinate cost budgets across agent chains - stream-resume.md: full stream resume with reconnection loop
1 parent 6272827 commit 026bc43

4 files changed

Lines changed: 645 additions & 0 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# Email Vendor Leases
2+
3+
This recipe shows how to attach vendor-specific lease fields to a job using
4+
`x-*` extensions (spec [§15](https://arcp.dev/spec/v1.1#section-15)) alongside
5+
the standard cost budget (spec [§9](https://arcp.dev/spec/v1.1#section-9)) to
6+
enforce per-job email-sending limits.
7+
8+
## Use-case
9+
10+
Your agent sends transactional emails through a third-party provider (e.g.,
11+
SendGrid, Postmark). You want to:
12+
13+
* Cap the dollar cost of a single job.
14+
* Cap the number of email messages the job may send.
15+
* Expose the remaining quota back to the caller through structured events.
16+
17+
The cost budget is a first-class ARCP concept; the email quota is represented
18+
as a vendor extension field (`x-email-max-messages`) that your runtime
19+
evaluates and your agent reads from `ctx.lease`.
20+
21+
## Server
22+
23+
```python
24+
import asyncio
25+
from decimal import Decimal
26+
from arcp import ARCPRuntime, JobContext
27+
from arcp.auth import StaticBearerVerifier
28+
from arcp.transport import pair_memory_transports
29+
30+
MAX_EMAILS = 50
31+
32+
33+
async def email_agent(ctx: JobContext) -> None:
34+
# Read the vendor extension from the negotiated lease.
35+
raw = ctx.lease.extensions.get("x-email-max-messages")
36+
email_budget = int(raw) if raw is not None else MAX_EMAILS
37+
sent = 0
38+
39+
async for item in ctx.input_stream():
40+
if sent >= email_budget:
41+
await ctx.emit_event(
42+
"vendor.email.quota_exceeded",
43+
{"sent": sent, "limit": email_budget},
44+
)
45+
await ctx.cancel("email quota reached")
46+
return
47+
48+
# Simulate sending an email.
49+
recipient = item.get("to", "")
50+
await _send_email(recipient, item)
51+
sent += 1
52+
await ctx.emit_event(
53+
"vendor.email.sent",
54+
{
55+
"to": recipient,
56+
"sent": sent,
57+
"remaining": email_budget - sent,
58+
},
59+
)
60+
61+
await ctx.emit_event("vendor.email.summary", {"total_sent": sent})
62+
63+
64+
async def _send_email(to: str, payload: dict) -> None:
65+
"""Placeholder — swap in your actual provider SDK call."""
66+
await asyncio.sleep(0.01)
67+
68+
69+
server_transport, client_transport = pair_memory_transports()
70+
71+
runtime = ARCPRuntime(
72+
transport=server_transport,
73+
auth=StaticBearerVerifier("secret"),
74+
)
75+
runtime.register_agent("email", email_agent)
76+
```
77+
78+
## Client — submitting with vendor lease extensions
79+
80+
```python
81+
from arcp import ARCPClient
82+
from arcp.models import CostBudget, Lease
83+
84+
85+
async def main() -> None:
86+
async with ARCPClient(client_transport, token="secret") as client:
87+
handle = await client.submit(
88+
agent="email",
89+
input=[
90+
{"to": "alice@example.com", "subject": "Hello", "body": "Hi!"},
91+
{"to": "bob@example.com", "subject": "Hello", "body": "Hi!"},
92+
],
93+
lease=Lease(
94+
cost_budget=CostBudget(usd=Decimal("0.50")),
95+
extensions={
96+
# Vendor field: cap this job at 10 emails.
97+
"x-email-max-messages": "10",
98+
},
99+
),
100+
)
101+
102+
async for event in handle.events():
103+
kind = event.kind
104+
if kind == "vendor.email.sent":
105+
print(
106+
f"Sent to {event.data['to']} "
107+
f"({event.data['remaining']} remaining)"
108+
)
109+
elif kind == "vendor.email.quota_exceeded":
110+
print(f"Quota exceeded after {event.data['sent']} emails")
111+
elif kind == "vendor.email.summary":
112+
print(f"Done — total sent: {event.data['total_sent']}")
113+
114+
await handle.done
115+
116+
117+
asyncio.run(main())
118+
```
119+
120+
## How it works
121+
122+
| Layer | Mechanism |
123+
|---|---|
124+
| Cost budget | `Lease.cost_budget` — enforced natively by the runtime (spec §9) |
125+
| Email quota | `Lease.extensions["x-email-max-messages"]` — enforced by the agent |
126+
| Quota events | `vendor.email.*` — structured events consumed by the caller |
127+
128+
`x-*` extension fields are passed through unchanged; the runtime does not
129+
interpret them. The naming convention `x-<vendor>-<field>` avoids collisions
130+
with future ARCP spec additions (spec §15.2).
131+
132+
## Related
133+
134+
- [Leases guide](../guides/leases.md)
135+
- [Vendor extensions guide](../guides/vendor-extensions.md)
136+
- [Cost budget recipe](cost-budget.md)
137+
- [Lease violation recipe](lease-violation.md)

docs/recipes/mcp-skill.md

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# MCP Skill
2+
3+
This recipe shows how to wrap a [Model Context Protocol (MCP)](https://modelcontextprotocol.io)
4+
tool as an ARCP agent so that any ARCP caller can invoke it without knowing
5+
anything about MCP internals.
6+
7+
## Concept
8+
9+
MCP tools are invoked synchronously (request → response). ARCP jobs are
10+
streaming and asynchronous. The adapter pattern here:
11+
12+
1. Receives a single-item ARCP input stream containing the tool call arguments.
13+
2. Calls the MCP tool via the MCP Python SDK.
14+
3. Streams the result back as an ARCP `result.chunk` event followed by job
15+
completion.
16+
17+
## Prerequisites
18+
19+
```bash
20+
uv add arcp mcp
21+
```
22+
23+
## Adapter
24+
25+
```python
26+
import asyncio
27+
import json
28+
from typing import Any
29+
30+
from mcp import ClientSession, StdioServerParameters
31+
from mcp.client.stdio import stdio_client
32+
33+
from arcp import ARCPRuntime, JobContext
34+
from arcp.auth import StaticBearerVerifier
35+
from arcp.transport import pair_memory_transports
36+
37+
38+
def make_mcp_skill(tool_name: str, server_params: StdioServerParameters):
39+
"""Return an ARCP agent function that proxies *tool_name* on the MCP server."""
40+
41+
async def agent(ctx: JobContext) -> None:
42+
# Collect arguments from the single-item input stream.
43+
args: dict[str, Any] = {}
44+
async for item in ctx.input_stream():
45+
args.update(item)
46+
47+
# Connect to the MCP server and call the tool.
48+
async with stdio_client(server_params) as (read, write):
49+
async with ClientSession(read, write) as session:
50+
await session.initialize()
51+
result = await session.call_tool(tool_name, args)
52+
53+
# Emit MCP result content as ARCP result chunks.
54+
for content_block in result.content:
55+
if content_block.type == "text":
56+
await ctx.emit_event(
57+
"result.chunk",
58+
{"text": content_block.text},
59+
)
60+
else:
61+
await ctx.emit_event(
62+
"result.chunk",
63+
{"data": json.loads(content_block.model_dump_json())},
64+
)
65+
66+
agent.__name__ = f"mcp_{tool_name}"
67+
return agent
68+
69+
70+
# ---------------------------------------------------------------------------
71+
# Example: expose the MCP filesystem server's "read_file" tool as an ARCP agent
72+
# ---------------------------------------------------------------------------
73+
74+
fs_server = StdioServerParameters(
75+
command="uvx",
76+
args=["mcp-server-filesystem", "/tmp"],
77+
)
78+
79+
server_transport, client_transport = pair_memory_transports()
80+
81+
runtime = ARCPRuntime(
82+
transport=server_transport,
83+
auth=StaticBearerVerifier("secret"),
84+
)
85+
runtime.register_agent("read_file", make_mcp_skill("read_file", fs_server))
86+
```
87+
88+
## Client
89+
90+
```python
91+
from arcp import ARCPClient
92+
93+
94+
async def main() -> None:
95+
async with ARCPClient(client_transport, token="secret") as client:
96+
handle = await client.submit(
97+
agent="read_file",
98+
input=[{"path": "/tmp/hello.txt"}],
99+
)
100+
101+
async for event in handle.events():
102+
if event.kind == "result.chunk":
103+
print(event.data.get("text", event.data))
104+
105+
await handle.done
106+
107+
108+
import asyncio
109+
asyncio.run(main())
110+
```
111+
112+
## Registering multiple MCP tools
113+
114+
```python
115+
TOOLS = ["read_file", "write_file", "list_directory"]
116+
117+
for tool in TOOLS:
118+
runtime.register_agent(tool, make_mcp_skill(tool, fs_server))
119+
```
120+
121+
## Error propagation
122+
123+
If the MCP tool raises, the exception propagates naturally and ARCP converts it
124+
to a `job.failed` event with an appropriate error code (spec
125+
[§12](https://arcp.dev/spec/v1.1#section-12)). You can also catch MCP errors
126+
explicitly and re-raise as typed ARCP exceptions:
127+
128+
```python
129+
from mcp.exceptions import McpError
130+
from arcp.errors import AgentError
131+
132+
try:
133+
result = await session.call_tool(tool_name, args)
134+
except McpError as exc:
135+
raise AgentError(str(exc)) from exc
136+
```
137+
138+
## Related
139+
140+
- [Vendor extensions guide](../guides/vendor-extensions.md)
141+
- [Errors guide](../guides/errors.md)
142+
- [Result chunk recipe](result-chunk.md)
143+
- [Submit and stream recipe](submit-and-stream.md)

0 commit comments

Comments
 (0)