Skip to content
33 changes: 33 additions & 0 deletions docs/INTEGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,39 @@ except ValidationError:
pass
```

### MCP (Model Context Protocol) Tools

Prompture can consume tools from any MCP server. MCP tools are registered
into a `ToolRegistry` just like native tools or skills, so agents call them
through the normal tool-calling path. Requires the optional extra:

```bash
pip install 'prompture[mcp]'
```

```python
import asyncio
from prompture import AsyncAgent, ToolRegistry
from prompture.integrations.mcp_bridge import mcp_session_from_stdio, register_mcp_tools

async def main():
registry = ToolRegistry()
async with mcp_session_from_stdio("npx", ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]) as session:
await register_mcp_tools(registry, session, prefix="fs")
agent = AsyncAgent("openai/gpt-4o", tools=registry)
result = await agent.run("List the files in /tmp")
print(result.output_text)

asyncio.run(main())
```

With an already-initialized `mcp.ClientSession` (any transport), use
`await register_mcp_tools(registry, session)` directly. For sync `Agent`
code outside an event loop, `register_mcp_tools_sync(registry, session)`
does the same thing. MCP tool results are flattened to strings: text blocks
are joined, non-text blocks (images, resources) become a short placeholder,
and MCP call errors are returned to the model as `Error: ...` strings.

---

## Extending Prompture
Expand Down
326 changes: 269 additions & 57 deletions prompture/agents/agent.py

Large diffs are not rendered by default.

522 changes: 377 additions & 145 deletions prompture/agents/async_agent.py

Large diffs are not rendered by default.

343 changes: 280 additions & 63 deletions prompture/agents/async_conversation.py

Large diffs are not rendered by default.

318 changes: 276 additions & 42 deletions prompture/agents/conversation.py

Large diffs are not rendered by default.

17 changes: 14 additions & 3 deletions prompture/agents/live_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,20 @@ class ToolInputDelta:

@dataclass(frozen=True)
class ToolUseStop:
"""The tool's input is complete. ``input`` is the parsed dict."""
"""The tool's input is complete. ``input`` is the parsed dict.

``truncated`` is set when the provider stopped early (``length`` /
``max_tokens`` finish reason) and the streamed arguments failed to
parse — the conversation layer treats these like malformed-argument
calls (no execution, error fed back to the model). ``raw_stop_reason``
preserves the provider's original finish reason for diagnostics.
"""

id: str
name: str
input: dict[str, Any]
truncated: bool = False
raw_stop_reason: str | None = None
event_type: Literal["tool_use_stop"] = field(default="tool_use_stop", init=False)


Expand All @@ -97,8 +106,10 @@ class ToolResult:

@dataclass(frozen=True)
class MessageStop:
"""End of one assistant turn. ``stop_reason`` matches provider semantics
(``end_turn``, ``tool_use``, ``max_tokens``, ``stop``, ``length`` …)."""
"""End of one assistant turn. ``stop_reason`` uses the shared vocabulary
(``end_turn``, ``tool_use``, ``max_tokens``, ``content_filter``,
``error``); drivers preserve the provider's raw value in
``usage["raw_stop_reason"]``."""
Comment on lines +109 to +112

stop_reason: str
usage: dict[str, Any] = field(default_factory=dict)
Expand Down
21 changes: 17 additions & 4 deletions prompture/agents/tool_grammars.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,20 +66,29 @@ class ToolGrammar:
"""Given the OpenAI-style ``tools`` list, return the instruction block
appended to the system prompt so the model emits this grammar."""

open_prefix: str = "<tool_call"
"""Literal leading characters of :attr:`open_regex`. The streaming
parser derives its holdback from this: while in narration it never
emits a trailing run of characters that could still grow into this
prefix, so a delimiter split across chunks is still detected."""


# ---------------------------------------------------------------------------
# Default grammar: XML-style tags with name + id attributes
# ---------------------------------------------------------------------------

# Matches <tool_call name="x"> or <tool_call name="x" id="y">
# Attribute values may be double- or single-quoted.
_XML_OPEN_RE = re.compile(
r"<tool_call\s+name\s*=\s*\"(?P<name>[^\"]+)\""
r"(?:\s+id\s*=\s*\"(?P<id>[^\"]+)\")?\s*>"
r"<tool_call\s+name\s*=\s*(?:\"(?P<name_dq>[^\"]+)\"|'(?P<name_sq>[^']+)')"
r"(?:\s+id\s*=\s*(?:\"(?P<id_dq>[^\"]+)\"|'(?P<id_sq>[^']+)'))?\s*>"
)


def _xml_parse_open(match: re.Match[str]) -> tuple[str, str | None]:
return match.group("name"), match.group("id")
name = match.group("name_dq") or match.group("name_sq")
tag_id = match.group("id_dq") or match.group("id_sq")
return name, tag_id


def _xml_render_system_prompt(tools: list[dict[str, Any]]) -> str:
Expand All @@ -103,7 +112,10 @@ def _xml_render_system_prompt(tools: list[dict[str, Any]]) -> str:
desc = fn.get("description", "") or "(no description)"
params = fn.get("parameters", {}) or {}
params_json = json.dumps(params, separators=(",", ":"))
lines.append(f"- **{name}** — {desc}")
desc_lines = desc.strip().split("\n")
lines.append(f"- **{name}** — {desc_lines[0]}")
# Indent continuation lines so a multi-line description stays inside the bullet.
lines.extend(f" {line.strip()}" if line.strip() else "" for line in desc_lines[1:])
lines.append(f" Parameters JSON Schema: `{params_json}`")
lines.extend(
[
Expand Down Expand Up @@ -135,6 +147,7 @@ def _xml_render_system_prompt(tools: list[dict[str, Any]]) -> str:
close_marker="</tool_call>",
parse_open_tag=_xml_parse_open,
render_system_prompt=_xml_render_system_prompt,
open_prefix="<tool_call",
)
"""Default prompted-tool grammar — XML-style ``<tool_call name="..." [id="..."]>{json}</tool_call>``.

Expand Down
Loading
Loading