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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: CI

on:
pull_request:
branches: ["*"]
push:
branches: [main, master]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: uv sync --group lint
- name: Ruff check (mcphero)
run: uv run ruff check mcphero
- name: Ruff format check (mcphero)
run: uv run ruff format mcphero --check
- name: Ruff check (tests)
run: uv run ruff check tests
- name: Ruff format check (tests)
run: uv run ruff format tests --check

typecheck:
name: Type check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: uv sync --group lint
- name: Pyright
run: uv run pyright mcphero

test:
name: Tests (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: uv sync --group testing --all-extras
- name: Run tests
run: uv run pytest tests
8 changes: 8 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,11 @@ repos:
require_serial: true
verbose: true
pass_filenames: false
- id: ruff (tests)
name: ruff (tests)
entry: make lint-tests
language: system
types: [ python ]
require_serial: true
verbose: true
pass_filenames: false
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [1.0.0] - 2026-02-02
### Added
- Support for multiple MCPServers per adapter - with name collision handling and parallel invocation
- Github CI
### Changed
- Fully changed the API. There weren't any production users that we know of, so this breaking change is fine, no LTS for pre-1.0.0 version.
### Fixed

## [0.2.1] - 2026-02-01

Expand Down
155 changes: 142 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ pip install "mcphero[google-genai]"
```python
import asyncio
from openai import OpenAI
from mcphero.adapters.openai import MCPToolAdapterOpenAI
from mcphero import MCPToolAdapterOpenAI

async def main():
adapter = MCPToolAdapterOpenAI("https://api.mcphero.app/mcp/your-server-id")
Expand Down Expand Up @@ -75,7 +75,7 @@ asyncio.run(main())
import asyncio
from google import genai
from google.genai import types
from mcphero.adapters.gemini import MCPToolAdapterGemini
from mcphero import MCPToolAdapterGemini

async def main():
adapter = MCPToolAdapterGemini("https://api.mcphero.app/mcp/your-server-id")
Expand Down Expand Up @@ -117,37 +117,164 @@ async def main():
asyncio.run(main())
```

## Multiple MCP Servers

Adapters natively support connecting to multiple MCP servers at once. Use `MCPServerConfig` to configure each server, then pass them as a list.

### MCPServerConfig

```python
from mcphero import MCPServerConfig

config = MCPServerConfig(
url="https://api.mcphero.app/mcp/your-server-id", # required
name="weather", # optional, auto-derived from URL if omitted
timeout=30.0, # optional, default 30s
headers={ # optional, auth headers for the server
"Authorization": "Bearer your-token",
},
init_mode="auto", # "auto" | "on_fail" | "none"
tool_prefix="wx", # optional, prefix for tool names from this server
)
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `url` | `str` | *required* | HTTP endpoint of the MCP server |
| `name` | `str \| None` | derived from URL | Identifier for the server (e.g. last path segment) |
| `timeout` | `float` | `30.0` | Request timeout in seconds |
| `headers` | `dict[str, str] \| None` | `None` | Headers sent with every request (useful for auth) |
| `init_mode` | `"auto" \| "on_fail" \| "none"` | `"auto"` | When to run MCP initialization handshake |
| `tool_prefix` | `str \| None` | `None` | Prefix applied to all tool names from this server |

**`init_mode` options:**
- `"auto"` - initialize the connection before every request (default, safest)
- `"on_fail"` - skip initialization, but retry with initialization if a request fails
- `"none"` - never initialize (for servers that don't require it)

### Multi-Server Example

```python
import asyncio
from openai import OpenAI
from mcphero import MCPToolAdapterOpenAI, MCPServerConfig

async def main():
adapter = MCPToolAdapterOpenAI([
MCPServerConfig(
url="https://api.mcphero.app/mcp/weather",
name="weather",
headers={"Authorization": "Bearer weather-token"},
),
MCPServerConfig(
url="https://api.mcphero.app/mcp/calendar",
name="calendar",
headers={"Authorization": "Bearer calendar-token"},
),
])

client = OpenAI()

# Tools from ALL servers are fetched in parallel and merged
tools = await adapter.get_tool_definitions()

messages = [{"role": "user", "content": "What's the weather today and what's on my calendar?"}]
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools,
)

# Tool calls are automatically routed to the correct server
if response.choices[0].message.tool_calls:
results = await adapter.process_tool_calls(
response.choices[0].message.tool_calls
)
messages.append(response.choices[0].message)
messages.extend(results)

final_response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools,
)
print(final_response.choices[0].message.content)

asyncio.run(main())
```

### Tool Name Collisions

When multiple servers expose tools with the same name, the adapter auto-prefixes them with the server name to avoid collisions:

```python
# Both servers have a "search" tool
adapter = MCPToolAdapterOpenAI([
MCPServerConfig(url="https://example.com/mcp/weather", name="weather"),
MCPServerConfig(url="https://example.com/mcp/calendar", name="calendar"),
])

tools = await adapter.get_tool_definitions()
# "search" becomes "weather__search" and "calendar__search"
```

You can control this behavior:

```python
# Custom separator
adapter = MCPToolAdapterOpenAI(configs, prefix_separator="-")
# "weather-search", "calendar-search"

# Disable auto-prefixing (will raise on collision)
adapter = MCPToolAdapterOpenAI(configs, auto_prefix_on_collision=False)

# Manual prefix via config (always applied, regardless of collisions)
MCPServerConfig(url="...", tool_prefix="wx")
# "wx__search"
```

## API Reference

### MCPToolAdapterOpenAI

```python
from mcphero.adapters.openai import MCPToolAdapterOpenAI
from mcphero import MCPToolAdapterOpenAI, MCPServerConfig

# Single server (URL string)
adapter = MCPToolAdapterOpenAI("https://api.mcphero.app/mcp/your-server-id")

# Single server (config)
adapter = MCPToolAdapterOpenAI(
base_url="https://api.mcphero.app/mcp/your-server-id",
timeout=30.0, # optional
headers={"Authorization": "Bearer ..."}, # optional
MCPServerConfig(
url="https://api.mcphero.app/mcp/your-server-id",
headers={"Authorization": "Bearer ..."},
)
)

# Multiple servers
adapter = MCPToolAdapterOpenAI([
MCPServerConfig(url="https://server-a.com/mcp", name="a"),
MCPServerConfig(url="https://server-b.com/mcp", name="b"),
])
```

#### Methods

| Method | Returns | Description |
|--------|---------|-------------|
| `get_tool_definitions()` | `list[ChatCompletionToolParam]` | Fetch tools from MCP server as OpenAI tool schemas |
| `get_tool_definitions()` | `list[ChatCompletionToolParam]` | Fetch tools from MCP server(s) as OpenAI tool schemas |
| `process_tool_calls(tool_calls, return_errors=True)` | `list[ChatCompletionToolMessageParam]` | Execute tool calls and return results for the conversation |
| `discover_tools()` | `list[MCPToolDefinition]` | Low-level: discover tools with routing metadata |
| `call_tool(name, arguments)` | `JsonRpcResponse` | Low-level: call a single tool by name |
| `initialize_all()` | `dict[str, JsonRpcResponse \| Exception]` | Pre-initialize all server connections |

### MCPToolAdapterGemini

```python
from mcphero.adapters.gemini import MCPToolAdapterGemini
from mcphero import MCPToolAdapterGemini, MCPServerConfig

adapter = MCPToolAdapterGemini(
base_url="https://api.mcphero.app/mcp/your-server-id",
timeout=30.0, # optional
headers={"Authorization": "Bearer ..."}, # optional
)
# Same constructor options as OpenAI adapter
adapter = MCPToolAdapterGemini("https://api.mcphero.app/mcp/your-server-id")
```

#### Methods
Expand All @@ -158,6 +285,8 @@ adapter = MCPToolAdapterGemini(
| `get_tool()` | `types.Tool` | Fetch tools as a Gemini Tool object |
| `process_function_calls(function_calls, return_errors=True)` | `list[types.Content]` | Execute function calls and return Content objects |
| `process_function_calls_as_parts(function_calls, return_errors=True)` | `list[types.Part]` | Execute function calls and return Part objects |
| `discover_tools()` | `list[MCPToolDefinition]` | Low-level: discover tools with routing metadata |
| `call_tool(name, arguments)` | `JsonRpcResponse` | Low-level: call a single tool by name |

## Error Handling

Expand Down
2 changes: 2 additions & 0 deletions mcphero/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from mcphero.adapters.openai import MCPToolAdapterOpenAI
from mcphero.adapters.base_adapter import MCPServerConfig

__all__ = [
"MCPToolAdapterOpenAI",
"MCPServerConfig",
"MCPToolAdapterGemini", # pyright: ignore[reportUnsupportedDunderAll]
]

Expand Down
Loading