Skip to content

Commit 213593c

Browse files
committed
feat(python bindings): add Python bindings crate, workflow, and tests under bindings/python
1 parent fe58a2a commit 213593c

24 files changed

+2680
-0
lines changed

.github/workflows/python-bindings.yml

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
name: Python Bindings CI
2+
3+
on:
4+
push:
5+
paths:
6+
- 'bindings/python/**'
7+
- 'crates/rmcp/**'
8+
- '.github/workflows/python-bindings.yml'
9+
pull_request:
10+
paths:
11+
- 'bindings/python/**'
12+
- 'crates/rmcp/**'
13+
- '.github/workflows/python-bindings.yml'
14+
15+
jobs:
16+
test:
17+
runs-on: ${{ matrix.os }}
18+
strategy:
19+
matrix:
20+
os: [ubuntu-latest, macos-latest, windows-latest]
21+
python-version: ['3.8', '3.9', '3.10', '3.11']
22+
23+
steps:
24+
- uses: actions/checkout@v3
25+
26+
- name: Set up Python ${{ matrix.python-version }}
27+
uses: actions/setup-python@v4
28+
with:
29+
python-version: ${{ matrix.python-version }}
30+
31+
- name: Install Rust toolchain
32+
uses: dtolnay/rust-toolchain@stable
33+
with:
34+
toolchain: stable
35+
components: rustfmt, clippy
36+
37+
- name: Install dependencies
38+
run: |
39+
python -m pip install --upgrade pip
40+
pip install maturin pytest pytest-asyncio
41+
42+
- name: Build and test
43+
run: |
44+
cd bindings/python
45+
maturin develop
46+
pytest tests/ -v
47+
48+
build:
49+
needs: test
50+
runs-on: ubuntu-latest
51+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
52+
53+
steps:
54+
- uses: actions/checkout@v3
55+
56+
- name: Set up Python
57+
uses: actions/setup-python@v4
58+
with:
59+
python-version: '3.11'
60+
61+
- name: Install Rust toolchain
62+
uses: dtolnay/rust-toolchain@stable
63+
with:
64+
toolchain: stable
65+
66+
- name: Install dependencies
67+
run: |
68+
python -m pip install --upgrade pip
69+
pip install maturin twine
70+
71+
- name: Build wheels
72+
run: |
73+
cd bindings/python
74+
maturin build --release --strip
75+
76+
- name: Publish to PyPI
77+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
78+
env:
79+
TWINE_USERNAME: __token__
80+
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
81+
run: |
82+
cd bindings/python
83+
twine upload target/wheels/*

bindings/python/.gitignore

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Python ignores
2+
__pycache__/
3+
*.py[cod]
4+
*.so
5+
*.pyd
6+
*.pyo
7+
*.egg-info/
8+
build/
9+
dist/
10+
.eggs/
11+
.env
12+
.venv
13+
venv/
14+
ENV/
15+
16+
# Rust ignores
17+
target/
18+
Cargo.lock

bindings/python/Cargo.toml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
[package]
2+
name = "rmcp-python"
3+
version = "0.1.0"
4+
edition = "2021"
5+
authors = ["Your Name <[email protected]>"]
6+
description = "Python bindings for the RMCP Rust SDK"
7+
license = "MIT"
8+
repository = "https://github.com/yourusername/rust-sdk"
9+
10+
[lib]
11+
name = "rmcp_python"
12+
crate-type = ["cdylib"]
13+
14+
[dependencies]
15+
rmcp = { path = "../../crates/rmcp", features = ["transport-sse", "transport-child-process", "client"] }
16+
pyo3 = { version = "0.20.3", features = ["extension-module"] }
17+
pyo3-asyncio = { version = "0.20.0", features = ["tokio-runtime"] }
18+
tokio = { version = "1.0", features = ["full"] }
19+
tokio-util = { version = "0.7", features = ["full"] }
20+
futures = "0.3"
21+
async-trait = "0.1"
22+
thiserror = "1.0"
23+
serde = { version = "1.0", features = ["derive"] }
24+
serde_json = "1.0"
25+
reqwest = "0.12"
26+
27+
[build-dependencies]
28+
pyo3-build-config = "0.20.0"
29+
30+
[features]
31+
default = ["pyo3"]
32+
pyo3 = []
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import logging
2+
import asyncio
3+
from rmcp_python import PyClientInfo, PyClientCapabilities, PyImplementation, PyTransport, PySseTransport
4+
5+
logging.basicConfig(level=logging.INFO)
6+
7+
# The SSE endpoint for the MCP server
8+
SSE_URL = "http://localhost:8000/sse"
9+
10+
async def main():
11+
# Create the SSE transport
12+
transport = await PySseTransport.start(SSE_URL)
13+
14+
# Wrap the transport in PyTransport mimics the IntoTransport of Rust
15+
transport = PyTransport.from_sse(transport)
16+
# Initialize client info similar to the Rust examples
17+
client_info = PyClientInfo(
18+
protocol_version="2025-03-26", # Use default
19+
capabilities=PyClientCapabilities(),
20+
client_info=PyImplementation(
21+
name="test python sse client",
22+
version="0.0.1",
23+
)
24+
)
25+
26+
# Serve the client using the transport (mimics client_info.serve(transport) in Rust)
27+
client = await client_info.serve(transport)
28+
29+
# Print server info
30+
server_info = client.peer_info()
31+
logging.info(f"Connected to server: {server_info}")
32+
33+
# List available tools
34+
tools = await client.list_all_tools()
35+
logging.info(f"Available tools: {tools}")
36+
37+
# Optionally, call a tool (e.g., get_value)
38+
result = await client.call_tool("increment", {})
39+
logging.info(f"Tool result: {result}")
40+
41+
if __name__ == "__main__":
42+
asyncio.run(main())
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
2+
from .rmcp_python import (
3+
PyService,
4+
PyCreateMessageParams,
5+
PyCreateMessageResult,
6+
PyRoot,
7+
PyListRootsResult,
8+
PyClientInfo,
9+
PyClientCapabilities,
10+
PyImplementation,
11+
PyTransport,
12+
PySseTransport,
13+
)
14+
15+
__all__ = ['PyService', 'PyCreateMessageParams', 'PyCreateMessageResult', 'PyRoot', 'PyListRootsResult', 'PyClientInfo', 'PyClientCapabilities', 'PyImplementation', 'PyTransport', 'PySseTransport']

bindings/python/setup.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from setuptools import setup
2+
from setuptools_rust import Binding, RustExtension
3+
4+
setup(
5+
name="rmcp-python",
6+
version="0.1.0",
7+
rust_extensions=[RustExtension("rmcp_python.rmcp_python", "Cargo.toml", binding=Binding.PyO3)],
8+
packages=["rmcp_python"],
9+
# Rust extension is not zip safe
10+
zip_safe=False,
11+
)

bindings/python/src/client.rs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
//! Python bindings client handler implementation.
2+
//!
3+
//! This module provides the `PyClientHandler` struct, which implements the `ClientHandler` trait for use in Python bindings.
4+
//! It allows sending and receiving messages, managing peers, and listing root messages in a client context.
5+
//!
6+
//! # Examples
7+
//!
8+
//! ```rust
9+
//! use bindings::python::client::PyClientHandler;
10+
//! let handler = PyClientHandler::new();
11+
//! ```
12+
#![allow(non_local_definitions)]
13+
14+
use rmcp::service::{RoleClient, RequestContext};
15+
use rmcp::ClientHandler;
16+
use rmcp::model::{CreateMessageRequestParam, SamplingMessage, Role, Content, CreateMessageResult, ListRootsResult};
17+
use std::future::Future;
18+
use rmcp::service::Peer;
19+
20+
/// A client handler for use in Python bindings.
21+
///
22+
/// This struct manages an optional peer and implements the `ClientHandler` trait.
23+
#[derive(Clone)]
24+
pub struct PyClientHandler {
25+
/// The current peer associated with this handler, if any.
26+
peer: Option<Peer<RoleClient>>,
27+
}
28+
29+
impl PyClientHandler {
30+
/// Creates a new `PyClientHandler` with no peer set.
31+
///
32+
/// # Examples
33+
///
34+
/// ```rust
35+
/// let handler = PyClientHandler::new();
36+
/// assert!(handler.get_peer().is_none());
37+
/// ```
38+
pub fn new() -> Self {
39+
Self {
40+
peer: None,
41+
}
42+
}
43+
}
44+
45+
impl ClientHandler for PyClientHandler {
46+
/// Creates a message in response to a request.
47+
///
48+
/// # Parameters
49+
/// - `_params`: The parameters for the message creation request.
50+
/// - `_context`: The request context.
51+
///
52+
/// # Returns
53+
/// A future resolving to a `CreateMessageResult` containing the created message.
54+
///
55+
/// # Examples
56+
///
57+
/// ```rust
58+
/// // Usage in async context
59+
/// // let result = handler.create_message(params, context).await;
60+
/// ```
61+
fn create_message(
62+
&self,
63+
_params: CreateMessageRequestParam,
64+
_context: RequestContext<RoleClient>,
65+
) -> impl Future<Output = Result<CreateMessageResult, rmcp::Error>> + Send + '_ {
66+
// Create a default message for now
67+
let message = SamplingMessage {
68+
role: Role::Assistant,
69+
content: Content::text("".to_string()),
70+
};
71+
let result = CreateMessageResult {
72+
model: "default-model".to_string(),
73+
stop_reason: None,
74+
message,
75+
};
76+
std::future::ready(Ok(result))
77+
}
78+
79+
/// Lists root messages for the client.
80+
///
81+
/// # Parameters
82+
/// - `_context`: The request context.
83+
///
84+
/// # Returns
85+
/// A future resolving to a `ListRootsResult` containing the list of root messages.
86+
///
87+
/// # Examples
88+
///
89+
/// ```rust
90+
/// // Usage in async context
91+
/// // let roots = handler.list_roots(context).await;
92+
/// ```
93+
fn list_roots(
94+
&self,
95+
_context: RequestContext<RoleClient>,
96+
) -> impl Future<Output = Result<ListRootsResult, rmcp::Error>> + Send + '_ {
97+
// Return empty list for now
98+
std::future::ready(Ok(ListRootsResult { roots: vec![] }))
99+
}
100+
101+
/// Returns the current peer, if any.
102+
///
103+
/// # Returns
104+
/// An `Option<Peer<RoleClient>>` containing the current peer if set.
105+
///
106+
/// # Examples
107+
///
108+
/// ```rust
109+
/// let peer = handler.get_peer();
110+
/// ```
111+
fn get_peer(&self) -> Option<Peer<RoleClient>> {
112+
self.peer.clone()
113+
}
114+
115+
/// Sets the current peer.
116+
///
117+
/// # Parameters
118+
/// - `peer`: The peer to set for this handler.
119+
///
120+
/// # Examples
121+
///
122+
/// ```rust
123+
/// handler.set_peer(peer);
124+
/// ```
125+
fn set_peer(&mut self, peer: Peer<RoleClient>) {
126+
self.peer = Some(peer);
127+
}
128+
}

0 commit comments

Comments
 (0)