Skip to content

Add Python bindings crate, FFI, workflow, and examples for RMCP Rust SDK #172

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
83 changes: 83 additions & 0 deletions .github/workflows/python-bindings.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
name: Python Bindings CI

on:
push:
paths:
- 'bindings/python/**'
- 'crates/rmcp/**'
- '.github/workflows/python-bindings.yml'
pull_request:
paths:
- 'bindings/python/**'
- 'crates/rmcp/**'
- '.github/workflows/python-bindings.yml'

jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ['3.8', '3.9', '3.10', '3.11']

steps:
- uses: actions/checkout@v3

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
components: rustfmt, clippy

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install maturin pytest pytest-asyncio

- name: Build and test
run: |
cd bindings/python
maturin develop
pytest tests/ -v

build:
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'

steps:
- uses: actions/checkout@v3

- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install maturin twine

- name: Build wheels
run: |
cd bindings/python
maturin build --release --strip

- name: Publish to PyPI
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
run: |
cd bindings/python
twine upload target/wheels/*
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@


[workspace]
members = ["crates/rmcp", "crates/rmcp-macros", "examples/*"]
members = [ "bindings/python","crates/rmcp", "crates/rmcp-macros", "examples/*"]
resolver = "2"

[workspace.dependencies]
Expand Down
18 changes: 18 additions & 0 deletions bindings/python/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Python ignores
__pycache__/
*.py[cod]
*.so
*.pyd
*.pyo
*.egg-info/
build/
dist/
.eggs/
.env
.venv
venv/
ENV/

# Rust ignores
target/
Cargo.lock
32 changes: 32 additions & 0 deletions bindings/python/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
[package]
name = "rmcp-python"
version = "0.1.0"
edition = "2021"
authors = ["Your Name <[email protected]>"]
description = "Python bindings for the RMCP Rust SDK"
license = "MIT"
repository = "https://github.com/yourusername/rust-sdk"

[lib]
name = "rmcp_python"
crate-type = ["cdylib"]

[dependencies]
rmcp = { path = "../../crates/rmcp", features = ["transport-sse", "transport-child-process", "client"] }
pyo3 = { version = "0.20.3", features = ["extension-module"] }
pyo3-asyncio = { version = "0.20.0", features = ["tokio-runtime"] }
tokio = { version = "1.0", features = ["full"] }
tokio-util = { version = "0.7", features = ["full"] }
futures = "0.3"
async-trait = "0.1"
thiserror = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
reqwest = "0.12"

[build-dependencies]
pyo3-build-config = "0.20.0"

[features]
default = ["pyo3"]
pyo3 = []
42 changes: 42 additions & 0 deletions bindings/python/examples/clients/src/sse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import logging
import asyncio
from rmcp_python import PyClientInfo, PyClientCapabilities, PyImplementation, PyTransport, PySseTransport

logging.basicConfig(level=logging.INFO)

# The SSE endpoint for the MCP server
SSE_URL = "http://localhost:8000/sse"

async def main():
# Create the SSE transport
transport = await PySseTransport.start(SSE_URL)

# Wrap the transport in PyTransport mimics the IntoTransport of Rust
transport = PyTransport.from_sse(transport)
# Initialize client info similar to the Rust examples
client_info = PyClientInfo(
protocol_version="2025-03-26", # Use default
capabilities=PyClientCapabilities(),
client_info=PyImplementation(
name="test python sse client",
version="0.0.1",
)
)

# Serve the client using the transport (mimics client_info.serve(transport) in Rust)
client = await client_info.serve(transport)

# Print server info
server_info = client.peer_info()
logging.info(f"Connected to server: {server_info}")

# List available tools
tools = await client.list_all_tools()
logging.info(f"Available tools: {tools}")

# Optionally, call a tool (e.g., get_value)
result = await client.call_tool("increment", {})
logging.info(f"Tool result: {result}")

if __name__ == "__main__":
asyncio.run(main())
15 changes: 15 additions & 0 deletions bindings/python/rmcp_python/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@

from .rmcp_python import (
PyService,
PyCreateMessageParams,
PyCreateMessageResult,
PyRoot,
PyListRootsResult,
PyClientInfo,
PyClientCapabilities,
PyImplementation,
PyTransport,
PySseTransport,
)

__all__ = ['PyService', 'PyCreateMessageParams', 'PyCreateMessageResult', 'PyRoot', 'PyListRootsResult', 'PyClientInfo', 'PyClientCapabilities', 'PyImplementation', 'PyTransport', 'PySseTransport']
11 changes: 11 additions & 0 deletions bindings/python/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from setuptools import setup
from setuptools_rust import Binding, RustExtension

setup(
name="rmcp-python",
version="0.1.0",
rust_extensions=[RustExtension("rmcp_python.rmcp_python", "Cargo.toml", binding=Binding.PyO3)],
packages=["rmcp_python"],
# Rust extension is not zip safe
zip_safe=False,
)
128 changes: 128 additions & 0 deletions bindings/python/src/client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//! Python bindings client handler implementation.
//!
//! This module provides the `PyClientHandler` struct, which implements the `ClientHandler` trait for use in Python bindings.
//! It allows sending and receiving messages, managing peers, and listing root messages in a client context.
//!
//! # Examples
//!
//! ```rust
//! use bindings::python::client::PyClientHandler;
//! let handler = PyClientHandler::new();
//! ```
#![allow(non_local_definitions)]

use rmcp::service::{RoleClient, RequestContext};
use rmcp::ClientHandler;
use rmcp::model::{CreateMessageRequestParam, SamplingMessage, Role, Content, CreateMessageResult, ListRootsResult};
use std::future::Future;
use rmcp::service::Peer;

/// A client handler for use in Python bindings.
///
/// This struct manages an optional peer and implements the `ClientHandler` trait.
#[derive(Clone)]
pub struct PyClientHandler {
/// The current peer associated with this handler, if any.
peer: Option<Peer<RoleClient>>,
}

impl PyClientHandler {
/// Creates a new `PyClientHandler` with no peer set.
///
/// # Examples
///
/// ```rust
/// let handler = PyClientHandler::new();
/// assert!(handler.get_peer().is_none());
/// ```
pub fn new() -> Self {
Self {
peer: None,
}
}
}

impl ClientHandler for PyClientHandler {
/// Creates a message in response to a request.
///
/// # Parameters
/// - `_params`: The parameters for the message creation request.
/// - `_context`: The request context.
///
/// # Returns
/// A future resolving to a `CreateMessageResult` containing the created message.
///
/// # Examples
///
/// ```rust
/// // Usage in async context
/// // let result = handler.create_message(params, context).await;
/// ```
fn create_message(
&self,
_params: CreateMessageRequestParam,
_context: RequestContext<RoleClient>,
) -> impl Future<Output = Result<CreateMessageResult, rmcp::Error>> + Send + '_ {
// Create a default message for now
let message = SamplingMessage {
role: Role::Assistant,
content: Content::text("".to_string()),
};
let result = CreateMessageResult {
model: "default-model".to_string(),
stop_reason: None,
message,
};
std::future::ready(Ok(result))
}

/// Lists root messages for the client.
///
/// # Parameters
/// - `_context`: The request context.
///
/// # Returns
/// A future resolving to a `ListRootsResult` containing the list of root messages.
///
/// # Examples
///
/// ```rust
/// // Usage in async context
/// // let roots = handler.list_roots(context).await;
/// ```
fn list_roots(
&self,
_context: RequestContext<RoleClient>,
) -> impl Future<Output = Result<ListRootsResult, rmcp::Error>> + Send + '_ {
// Return empty list for now
std::future::ready(Ok(ListRootsResult { roots: vec![] }))
}

/// Returns the current peer, if any.
///
/// # Returns
/// An `Option<Peer<RoleClient>>` containing the current peer if set.
///
/// # Examples
///
/// ```rust
/// let peer = handler.get_peer();
/// ```
fn get_peer(&self) -> Option<Peer<RoleClient>> {
self.peer.clone()
}

/// Sets the current peer.
///
/// # Parameters
/// - `peer`: The peer to set for this handler.
///
/// # Examples
///
/// ```rust
/// handler.set_peer(peer);
/// ```
fn set_peer(&mut self, peer: Peer<RoleClient>) {
self.peer = Some(peer);
}
}
Loading
Loading