diff --git a/.github/workflows/python-bindings.yml b/.github/workflows/python-bindings.yml new file mode 100644 index 00000000..18acdb3f --- /dev/null +++ b/.github/workflows/python-bindings.yml @@ -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/* \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 317e479b..f804886c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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] diff --git a/bindings/python/.gitignore b/bindings/python/.gitignore new file mode 100644 index 00000000..790f298d --- /dev/null +++ b/bindings/python/.gitignore @@ -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 diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml new file mode 100644 index 00000000..3998dd16 --- /dev/null +++ b/bindings/python/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "rmcp-python" +version = "0.1.0" +edition = "2021" +authors = ["Your Name "] +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 = [] \ No newline at end of file diff --git a/bindings/python/examples/clients/src/sse.py b/bindings/python/examples/clients/src/sse.py new file mode 100644 index 00000000..a2fba58d --- /dev/null +++ b/bindings/python/examples/clients/src/sse.py @@ -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()) diff --git a/bindings/python/rmcp_python/__init__.py b/bindings/python/rmcp_python/__init__.py new file mode 100644 index 00000000..7e5da551 --- /dev/null +++ b/bindings/python/rmcp_python/__init__.py @@ -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'] \ No newline at end of file diff --git a/bindings/python/setup.py b/bindings/python/setup.py new file mode 100644 index 00000000..ca513d4a --- /dev/null +++ b/bindings/python/setup.py @@ -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, +) \ No newline at end of file diff --git a/bindings/python/src/client.rs b/bindings/python/src/client.rs new file mode 100644 index 00000000..fe0f0d0d --- /dev/null +++ b/bindings/python/src/client.rs @@ -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>, +} + +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, + ) -> impl Future> + 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, + ) -> impl Future> + Send + '_ { + // Return empty list for now + std::future::ready(Ok(ListRootsResult { roots: vec![] })) + } + + /// Returns the current peer, if any. + /// + /// # Returns + /// An `Option>` containing the current peer if set. + /// + /// # Examples + /// + /// ```rust + /// let peer = handler.get_peer(); + /// ``` + fn get_peer(&self) -> Option> { + 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) { + self.peer = Some(peer); + } +} \ No newline at end of file diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs new file mode 100644 index 00000000..45eba15b --- /dev/null +++ b/bindings/python/src/lib.rs @@ -0,0 +1,110 @@ +//! Python bindings for the RMCP SDK. +//! +//! This crate exposes Rust types and services to Python using pyo3 and maturin. +//! It provides bindings for RMCP client, service, transport, and model types. +//! +//! # Usage +//! +//! Install with maturin, then use from Python. Here is a full async client example: +//! +//! ```python +//! import asyncio +//! import logging +//! from rmcp_python import PyClientInfo, PyClientCapabilities, PyImplementation, PyTransport, PySseTransport +//! +//! logging.basicConfig(level=logging.INFO) +//! +//! SSE_URL = "http://localhost:8000/sse" +//! +//! async def main(): +//! # Create the SSE transport +//! transport = await PySseTransport.start(SSE_URL) +//! # Wrap in PyTransport +//! transport = PyTransport.from_sse(transport) +//! # Set up client info +//! client_info = PyClientInfo( +//! protocol_version="2025-03-26", +//! capabilities=PyClientCapabilities(), +//! client_info=PyImplementation(name="test python sse client", version="0.0.1") +//! ) +//! # Serve the client +//! 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}") +//! # Call a tool (example) +//! result = await client.call_tool("increment", {}) +//! logging.info(f"Tool result: {result}") +//! +//! if __name__ == "__main__": +//! asyncio.run(main()) +//! ``` + +use pyo3::prelude::*; +use crate::types::{PyRoot, PyCreateMessageParams, PyCreateMessageResult, PyListRootsResult, + PySamplingMessage, PyRole, PyTextContent, PyImageContent, PyEmbeddedResourceContent, PyAudioContent, + PyContent, PyReadResourceResult, PyCallToolResult, PyCallToolRequestParam, PyReadResourceRequestParam, + PyGetPromptRequestParam, PyGetPromptResult, PyClientInfo, PyImplementation}; +use crate::model::capabilities::{PyClientCapabilities, PyRootsCapabilities, PyExperimentalCapabilities}; +use crate::transport::{PySseTransport, PyChildProcessTransport, PyTransport}; + +pub mod client; +pub mod types; +pub mod transport; +pub mod model; +pub mod service; + +/// Custom error type for Python bindings +#[derive(thiserror::Error, Debug)] +pub enum BindingError { + #[error("JSON conversion error: {0}")] + JsonError(#[from] serde_json::Error), + #[error("Python error: {0}")] + PyErr(#[from] PyErr), + #[error("RMCP error: {0}")] + RmcpError(String), + #[error("Runtime error: {0}")] + RuntimeError(String), +} + +impl From for PyErr { + fn from(err: BindingError) -> PyErr { + PyErr::new::(err.to_string()) + } +} + +/// Python module initialization +#[pymodule] +fn rmcp_python(_py: Python, m: &PyModule) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/bindings/python/src/model/capabilities.rs b/bindings/python/src/model/capabilities.rs new file mode 100644 index 00000000..9a0ce830 --- /dev/null +++ b/bindings/python/src/model/capabilities.rs @@ -0,0 +1,174 @@ +//! Python bindings for capabilities-related types. +//! +//! This module defines Rust representations of client and roots capabilities for use in Python bindings. +//! +//! # Examples +//! +//! ```python +//! from rmcp_python import PyClientCapabilities +//! caps = PyClientCapabilities() +//! ``` +#![allow(non_local_definitions)] + +use pyo3::prelude::*; +use pyo3::{Python, PyObject}; +use rmcp::model::{ClientCapabilities, RootsCapabilities}; + +// --- PyExperimentalCapabilities --- +/// Experimental capabilities for the client, exposed to Python. +#[pyclass] +#[derive(Clone, Debug)] +pub struct PyExperimentalCapabilities { + /// Inner Python object representing experimental capabilities. + #[pyo3(get, set)] + pub inner: Option, +} + +#[pymethods] +impl PyExperimentalCapabilities { + /// Creates a new `PyExperimentalCapabilities` from an optional Python object. + /// + /// # Arguments + /// * `inner` - Optional Python object representing experimental capabilities. + /// + /// # Examples + /// ```python + /// exp_caps = PyExperimentalCapabilities() + /// ``` + #[new] + pub fn new(inner: Option) -> Self { + PyExperimentalCapabilities { inner } + } +} + +// --- PyClientCapabilities --- +/// Client capabilities for the Python bindings. +#[pyclass] +#[derive(Clone, Debug, Default)] +pub struct PyClientCapabilities { + /// Experimental capabilities. + #[pyo3(get, set)] + pub experimental: Option, + /// Roots capabilities. + #[pyo3(get, set)] + pub roots: Option, + /// Sampling capabilities as a Python object. + #[pyo3(get, set)] + pub sampling: Option, +} + +#[pymethods] +impl PyClientCapabilities { + /// Creates a new `PyClientCapabilities`. + /// + /// # Arguments + /// * `experimental` - Optional experimental capabilities. + /// * `roots` - Optional roots capabilities. + /// * `sampling` - Optional sampling capabilities as a Python object. + /// + /// # Examples + /// ```python + /// caps = PyClientCapabilities() + /// ``` + #[new] + pub fn new( + experimental: Option, + roots: Option, + sampling: Option, + ) -> Self { + PyClientCapabilities { + experimental, + roots, + sampling, + } + } +} + +impl PyClientCapabilities { + /// Constructs a `PyClientCapabilities` from a Rust `ClientCapabilities`. + /// + /// # Arguments + /// * `py` - The Python interpreter token. + /// * `caps` - The Rust `ClientCapabilities` struct. + pub fn from(py: Python, caps: ClientCapabilities) -> Self { + let experimental = caps.experimental.map(|exp| { + let json_str = serde_json::to_string(&exp).expect("serialize experimental"); + let json_mod = py.import("json").expect("import json"); + let py_obj = json_mod.call_method1("loads", (json_str,)).expect("json.loads").into(); + PyExperimentalCapabilities { inner: Some(py_obj) } + }); + let roots = caps.roots.map(|roots| PyRootsCapabilities { list_changed: roots.list_changed }); + let sampling = caps.sampling.map(|s| { + let json_str = serde_json::to_string(&s).expect("serialize sampling"); + let json_mod = py.import("json").expect("import json"); + json_mod.call_method1("loads", (json_str,)).expect("json.loads").into() + }); + Self { + experimental, + roots, + sampling, + } + } +} + +/// Converts from `PyClientCapabilities` to Rust `ClientCapabilities`. +impl From for ClientCapabilities { + fn from(py_caps: PyClientCapabilities) -> Self { + let experimental = py_caps.experimental.and_then(|py_exp| { + py_exp.inner.and_then(|obj| { + Python::with_gil(|py| { + let json_mod = py.import("json").ok()?; + let json_str = json_mod.call_method1("dumps", (obj,)).ok()?.extract::().ok()?; + serde_json::from_str(&json_str).ok() + }) + }) + }); + + let roots = py_caps.roots.map(|py_roots| RootsCapabilities { + list_changed: py_roots.list_changed, + }); + + let sampling = py_caps.sampling.and_then(|obj| { + Python::with_gil(|py| { + let json_mod = py.import("json").ok()?; + let json_str = json_mod.call_method1("dumps", (obj,)).ok()?.extract::().ok()?; + serde_json::from_str(&json_str).ok() + }) + }); + + ClientCapabilities { + experimental, + roots, + sampling, + ..Default::default() + } + } +} + + +// --- PyRootsCapabilities --- +/// Roots capabilities for the client, exposed to Python. +#[pyclass] +#[derive(Clone, Debug)] +pub struct PyRootsCapabilities { + /// Indicates if the list of roots has changed. + #[pyo3(get, set)] + pub list_changed: Option, +} + +#[pymethods] +impl PyRootsCapabilities { + /// Creates a new `PyRootsCapabilities`. + /// + /// # Arguments + /// * `list_changed` - Optional boolean indicating if the roots list has changed. + /// + /// # Examples + /// ```python + /// roots_caps = PyRootsCapabilities() + /// ``` + #[new] + pub fn new(list_changed: Option) -> Self { + PyRootsCapabilities { list_changed } + } +} diff --git a/bindings/python/src/model/mod.rs b/bindings/python/src/model/mod.rs new file mode 100644 index 00000000..87648221 --- /dev/null +++ b/bindings/python/src/model/mod.rs @@ -0,0 +1,13 @@ +//! Model types for the Python bindings. +//! +//! This module exposes Rust model types (resources, prompts, tools, capabilities) to Python via pyo3 bindings. +//! +//! - `resource`: Resource contents (text/blob) +//! - `prompt`: Prompts, prompt arguments, and prompt messages +//! - `tool`: Tools and tool annotations (if enabled) +//! - `capabilities`: Client and roots capabilities + +pub mod resource; +pub mod prompt; +pub mod tool; +pub mod capabilities; diff --git a/bindings/python/src/model/prompt.rs b/bindings/python/src/model/prompt.rs new file mode 100644 index 00000000..1d1473e2 --- /dev/null +++ b/bindings/python/src/model/prompt.rs @@ -0,0 +1,112 @@ +//! Python bindings for prompt-related types. +//! +//! This module defines Rust representations of prompts, prompt arguments, and prompt messages for use in Python bindings. +//! +//! # Examples +//! +//! ```python +//! from rmcp_python import PyPrompt, PyPromptArgument, PyPromptMessage +//! prompt = PyPrompt(name='example', description='A prompt', arguments=[]) +//! ``` +#![allow(non_local_definitions)] + +use pyo3::prelude::*; + +/// Represents a prompt for use in Python bindings. +#[pyclass] +#[derive(Clone)] +pub struct PyPrompt { + /// Name of the prompt. + #[pyo3(get, set)] + pub name: String, + /// Optional description of the prompt. + #[pyo3(get, set)] + pub description: Option, + /// Optional list of arguments for the prompt. + #[pyo3(get, set)] + pub arguments: Option>>, + +} + +#[pymethods] +impl PyPrompt { + /// Creates a new `PyPrompt`. + /// + /// # Arguments + /// * `name` - Name of the prompt. + /// * `description` - Optional description. + /// * `arguments` - Optional list of prompt arguments. + /// + /// # Examples + /// ```python + /// prompt = PyPrompt(name='example', description='A prompt', arguments=[]) + /// ``` + #[new] + pub fn new(name: String, description: Option, arguments: Option>>) -> Self { + Self { name, description, arguments } + } +} + +/// Represents an argument to a prompt, for use in Python bindings. +#[pyclass] +#[derive(Clone)] +pub struct PyPromptArgument { + /// Name of the argument. + #[pyo3(get, set)] + pub name: String, + /// Optional description of the argument. + #[pyo3(get, set)] + pub description: Option, + /// Whether the argument is required. + #[pyo3(get, set)] + pub required: Option, +} + +#[pymethods] +impl PyPromptArgument { + /// Creates a new `PyPromptArgument`. + /// + /// # Arguments + /// * `name` - Name of the argument. + /// * `description` - Optional description. + /// * `required` - Whether the argument is required. + /// + /// # Examples + /// ```python + /// arg = PyPromptArgument(name='input', description='User input', required=True) + /// ``` + #[new] + pub fn new(name: String, description: Option, required: Option) -> Self { + Self { name, description, required } + } +} + +/// Represents a prompt message for use in Python bindings. +#[pyclass] +#[derive(Clone)] +pub struct PyPromptMessage { + /// Role associated with the message. + #[pyo3(get, set)] + pub role: String, + /// Content of the message. + #[pyo3(get, set)] + pub content: String, +} + +#[pymethods] +impl PyPromptMessage { + /// Creates a new `PyPromptMessage`. + /// + /// # Arguments + /// * `role` - Role associated with the message. + /// * `content` - Content of the message. + /// + /// # Examples + /// ```python + /// msg = PyPromptMessage(role='user', content='Hello!') + /// ``` + #[new] + pub fn new(role: String, content: String) -> Self { + Self { role, content } + } +} diff --git a/bindings/python/src/model/prompt_message.rs b/bindings/python/src/model/prompt_message.rs new file mode 100644 index 00000000..16e5dddf --- /dev/null +++ b/bindings/python/src/model/prompt_message.rs @@ -0,0 +1,41 @@ +//! Python bindings for a single prompt message. +//! +//! This module defines the `PyPromptMessage` struct for representing prompt messages in Python. +//! +//! # Examples +//! +//! ```python +//! from rmcp_python import PyPromptMessage +//! msg = PyPromptMessage(role='user', content='Hello!') +//! ``` +use pyo3::prelude::*; + +/// Represents a prompt message for use in Python bindings. +#[pyclass] +#[derive(Clone)] +pub struct PyPromptMessage { + /// Role associated with the message. + #[pyo3(get, set)] + pub role: String, + /// Content of the message. + #[pyo3(get, set)] + pub content: String, +} + +#[pymethods] +impl PyPromptMessage { + /// Creates a new `PyPromptMessage`. + /// + /// # Arguments + /// * `role` - Role associated with the message. + /// * `content` - Content of the message. + /// + /// # Examples + /// ```python + /// msg = PyPromptMessage(role='user', content='Hello!') + /// ``` + #[new] + pub fn new(role: String, content: String) -> Self { + Self { role, content } + } +} diff --git a/bindings/python/src/model/resource.rs b/bindings/python/src/model/resource.rs new file mode 100644 index 00000000..e0a2250d --- /dev/null +++ b/bindings/python/src/model/resource.rs @@ -0,0 +1,88 @@ +//! Python bindings for resource-related types. +//! +//! This module defines Rust representations of resources for use in Python bindings, including text and blob resource contents. +//! +//! # Examples +//! +//! ```python +//! from rmcp_python import PyTextResourceContents, PyBlobResourceContents +//! text_resource = PyTextResourceContents(uri='file.txt', text='Hello', mime_type='text/plain') +//! ``` +#![allow(non_local_definitions)] + +use pyo3::prelude::*; + +/// Base class for resource contents in Python bindings. +#[pyclass(subclass)] +#[derive(Clone)] +pub struct PyResourceContents; + +/// Text resource contents for use in Python bindings. +#[pyclass(extends=PyResourceContents)] +#[derive(Clone)] +pub struct PyTextResourceContents { + /// URI of the resource. + #[pyo3(get, set)] + pub uri: String, + /// Text content of the resource. + #[pyo3(get, set)] + pub text: String, + /// Optional MIME type of the resource. + #[pyo3(get, set)] + pub mime_type: Option, +} + +#[pymethods] +impl PyTextResourceContents { + /// Creates a new `PyTextResourceContents`. + /// + /// # Arguments + /// * `uri` - URI of the resource. + /// * `text` - Text content. + /// * `mime_type` - Optional MIME type. + /// + /// # Examples + /// ```python + /// text_resource = PyTextResourceContents(uri='file.txt', text='Hello', mime_type='text/plain') + /// ``` + #[new] + #[pyo3(signature = (uri, text, mime_type=None))] + pub fn new(uri: String, text: String, mime_type: Option) -> (Self, PyResourceContents) { + (Self { uri, text, mime_type }, PyResourceContents) + } +} + +/// Blob resource contents for use in Python bindings. +#[pyclass(extends=PyResourceContents)] +#[derive(Clone)] +pub struct PyBlobResourceContents { + /// URI of the blob resource. + #[pyo3(get, set)] + pub uri: String, + /// Blob content as a base64-encoded string. + #[pyo3(get, set)] + pub blob: String, + /// Optional MIME type of the blob. + #[pyo3(get, set)] + pub mime_type: Option, +} + +#[pymethods] +impl PyBlobResourceContents { + /// Creates a new `PyBlobResourceContents`. + /// + /// # Arguments + /// * `uri` - URI of the blob resource. + /// * `blob` - Blob content as a base64-encoded string. + /// * `mime_type` - Optional MIME type. + /// + /// # Examples + /// ```python + /// blob_resource = PyBlobResourceContents(uri='file.bin', blob='...', mime_type='application/octet-stream') + /// ``` + #[new] + #[pyo3(signature = (uri, blob, mime_type=None))] + pub fn new(uri: String, blob: String, mime_type: Option) -> (Self, PyResourceContents) { + (Self { uri, blob, mime_type }, PyResourceContents) + } +} diff --git a/bindings/python/src/model/tool.rs b/bindings/python/src/model/tool.rs new file mode 100644 index 00000000..14c5b470 --- /dev/null +++ b/bindings/python/src/model/tool.rs @@ -0,0 +1,94 @@ +//! Python bindings for tool-related types (currently commented out). +//! +//! This module was intended to define Rust representations of tools and tool annotations for use in Python bindings. +//! The code is currently commented out, possibly pending future implementation or refactor. +//! +//! # Note +//! +//! If you want to enable this module, uncomment the code and ensure all dependencies and types are available. +//! +//! # Examples +//! +//! ```python +//! # from rmcp_python import PyTool, PyToolAnnotations +//! # tool = PyTool(name='example', description='desc', input_schema={}, annotations=None) +//! ``` +/* +use pyo3::prelude::*; +use pyo3::types::PyAny; +use pyo3::types::PyDict; +use pyo3::types::IntoPyDict; +use pyo3::PyObject; +use serde_json::Value; +use rmcp::model::tool::{Tool, ToolAnnotations}; + +/// Annotations for a tool, exposed to Python. +#[pyclass] +#[derive(Clone, Debug)] +pub struct PyToolAnnotations { + /// Optional title for the tool. + #[pyo3(get, set)] + pub title: Option, + /// Indicates if the tool is read-only. + #[pyo3(get, set)] + pub read_only_hint: Option, + /// Indicates if the tool is destructive. + #[pyo3(get, set)] + pub destructive_hint: Option, + /// Indicates if the tool is idempotent. + #[pyo3(get, set)] + pub idempotent_hint: Option, + /// Indicates if the tool operates in an open world. + #[pyo3(get, set)] + pub open_world_hint: Option, +} + +/// Represents a tool for use in Python bindings. +#[pyclass] +#[derive(Clone, Debug)] +pub struct PyTool { + /// Name of the tool. + #[pyo3(get, set)] + pub name: String, + /// Optional description of the tool. + #[pyo3(get, set)] + pub description: Option, + /// Input schema as a Python object (dict). + #[pyo3(get, set)] + pub input_schema: PyObject, // Expose as Python dict/object + /// Optional annotations for the tool. + #[pyo3(get, set)] + pub annotations: Option, +} + +impl From for PyToolAnnotations { + fn from(ann: ToolAnnotations) -> Self { + PyToolAnnotations { + title: ann.title, + read_only_hint: ann.read_only_hint, + destructive_hint: ann.destructive_hint, + idempotent_hint: ann.idempotent_hint, + open_world_hint: ann.open_world_hint, + } + } +} + +impl From for PyTool { + fn from(tool: Tool) -> Self { + Python::with_gil(|py| { + let input_schema: Value = (*tool.input_schema).clone(); + let input_schema_py = serde_json::to_string(&input_schema) + .ok() + .and_then(|s| py.eval(&s, None, None).ok()) + .map(|obj| obj.to_object(py)) + .unwrap_or_else(|| py.None()); + PyTool { + name: tool.name.into_owned(), + description: tool.description.map(|c| c.into_owned()), + input_schema: input_schema_py, + annotations: tool.annotations.map(PyToolAnnotations::from), + } + }) + } +} +*/ \ No newline at end of file diff --git a/bindings/python/src/service.rs b/bindings/python/src/service.rs new file mode 100644 index 00000000..cee8168b --- /dev/null +++ b/bindings/python/src/service.rs @@ -0,0 +1,556 @@ +//! Python bindings for RMCP service logic. +//! +//! This module provides the main service interface (`PyService`) and client handle (`PyClient`) for Python users, +//! exposing core RMCP service capabilities and peer management via pyo3 bindings. +//! +//! # Examples +//! +//! ```python +//! from rmcp_python import PyService +//! service = PyService() +//! ``` +#![allow(non_local_definitions)] + +use pyo3::prelude::*; +use pyo3::exceptions::PyRuntimeError; +use pyo3::types::{PyDict, PyList}; +use std::sync::{Arc, Mutex}; +use tokio_util::sync::CancellationToken; +use serde_json::Value; +use serde_json::to_value; +use crate::types::{PyRequest, PyCreateMessageResult, PyListRootsResult, PyInfo, PyRequestContext, PyCreateMessageParams}; +use crate::client::PyClientHandler; +use rmcp::service::{RoleClient, DynService}; +use rmcp::model::{CreateMessageRequest, CallToolRequestParam, ServerRequest, ClientResult, NumberOrString, RequestNoParam}; +use rmcp::service::RequestContext; +use rmcp::Peer; + +// Manual PyDict to serde_json::Value conversion utilities +pub fn pydict_to_serde_value(dict: &PyDict) -> Value { + let mut map = serde_json::Map::new(); + for (k, v) in dict.iter() { + let key = k.extract::().unwrap_or_else(|_| k.str().unwrap().to_string()); + let value = python_to_serde_value(v); + map.insert(key, value); + } + serde_json::Value::Object(map) +} + +pub fn python_to_serde_value(obj: &pyo3::PyAny) -> Value { + if let Ok(val) = obj.extract::() { + serde_json::Value::Bool(val) + } else if let Ok(val) = obj.extract::() { + serde_json::Value::Number(val.into()) + } else if let Ok(val) = obj.extract::() { + serde_json::Value::Number(serde_json::Number::from_f64(val).unwrap()) + } else if let Ok(val) = obj.extract::() { + serde_json::Value::String(val) + } else if let Ok(dict) = obj.downcast::() { + pydict_to_serde_value(dict) + } else if let Ok(list) = obj.downcast::() { + serde_json::Value::Array(list.iter().map(|item| python_to_serde_value(item)).collect()) + } else { + serde_json::Value::Null + } +} + +// Utility for converting serde_json::Value to Python object (dict/list) +fn serde_value_to_py(py: Python, value: &Value) -> PyObject { + let json_mod = py.import("json").expect("import json"); + let json_str = serde_json::to_string(value).expect("to_string"); + json_mod.call_method1("loads", (json_str,)).expect("json.loads").to_object(py) +} + +/// Main service object for Python bindings. +/// +/// Provides access to RMCP client handler and peer management from Python. +#[pyclass] +pub struct PyService { + /// The underlying client handler, protected by a mutex for thread safety. + pub inner: Arc>, +} + +#[pymethods] +impl PyService { + /// Creates a new `PyService` instance. + /// + /// # Examples + /// ```python + /// service = PyService() + /// ``` + #[new] + pub fn new() -> Self { + let handler = PyClientHandler::new(); + Self { + inner: Arc::new(Mutex::new(handler)), + } + } + + /// Gets the current peer as a Python object, if set. + /// + /// # Returns + /// An optional Python object representing the peer. + pub fn get_peer<'py>(&self, py: Python<'py>) -> PyResult> { + let guard = self.inner.lock().unwrap(); + if let Some(peer) = guard.get_peer() { + let mut py_peer = PyPeer::new(); + py_peer.set_inner(peer.into()); + Ok(Some(Py::new(py, py_peer)?.to_object(py))) + } else { + Ok(None) + } + } + + /// Sets the peer from a Python object. + /// + /// # Arguments + /// * `py_peer` - The Python peer object to set. + pub fn set_peer(&self, py: Python, py_peer: PyObject) -> PyResult<()> { + let peer = py_peer.extract::>(py)?.inner.clone(); + let mut inner = self.inner.lock().unwrap(); + if let Some(peer_arc) = peer { + inner.set_peer((*(peer_arc)).clone()); + } else { + return Python::with_gil(|_py| Err(PyRuntimeError::new_err("Peer not initialized"))); + } + Ok(()) + } + + /// Gets information about the client as a Python object. + pub fn get_info<'py>(&self, py: Python<'py>) -> PyResult { + let info = self.inner.lock().unwrap().get_info(); + let py_info = PyInfo::client(info); + Ok(Py::new(py, py_info)?.to_object(py)) + } + + #[pyo3(name = "handle_request")] + pub fn handle_request<'py>(&self, py: Python<'py>, request: &PyRequest, context: PyObject) -> PyResult<&'py PyAny> { + let inner = self.inner.clone(); + let req = request.inner.clone(); + pyo3_asyncio::tokio::future_into_py(py, async move { + // Convert context PyObject to RequestContext + let ctx = Python::with_gil(|py| { + let py_ctx: PyRequestContext = context.extract(py)?; + py_ctx.to_rust(py) + })?; + // Use correct ServerRequest variant (CreateMessageRequest) + let req_json = serde_json::to_value(&req) + .map_err(|e| PyRuntimeError::new_err(format!("Failed to serialize request: {}", e)))?; + let typed_req: rmcp::model::Request = + serde_json::from_value(req_json) + .map_err(|e| PyRuntimeError::new_err(format!("Failed to convert to typed request: {}", e)))?; + let server_request = rmcp::model::ServerRequest::CreateMessageRequest(typed_req); + + // Lock the mutex only when needed, then immediately drop the guard + let handler = { + let binding = inner.lock().unwrap(); + binding.clone() + }; + let future = handler.handle_request(server_request, ctx); + match future.await { + Ok(rmcp::model::ClientResult::CreateMessageResult(result)) => Python::with_gil(|py| Ok(crate::types::PyCreateMessageResult::from(py, result).into_py(py))), + Ok(rmcp::model::ClientResult::ListRootsResult(result)) => Python::with_gil(|py| Ok(crate::types::PyListRootsResult::from(py, result).into_py(py))), + Ok(other) => Err(PyRuntimeError::new_err(format!("Unexpected result type: {:?}", other))), + Err(e) => Err(PyRuntimeError::new_err(e.to_string())), + } + }) + } + + #[pyo3(name = "handle_notification")] + pub fn handle_notification<'py>(&self, py: Python<'py>, notification: &PyDict) -> PyResult<&'py PyAny> { + let inner = self.inner.clone(); + // Convert PyDict to serde_json::Value + let notif_json = pydict_to_serde_value(notification); + // Deserialize to ServerNotification + let server_notification: rmcp::model::ServerNotification = serde_json::from_value(notif_json) + .map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + pyo3_asyncio::tokio::future_into_py(py, async move { + let handler = { + let binding = inner.lock().unwrap(); + binding.clone() + }; + let result = handler.handle_notification(server_notification).await; + Python::with_gil(|py| match result { + Ok(_) => Ok(py.None()), + Err(e) => Err(PyRuntimeError::new_err(e.to_string())), + }) + }) + } + + fn create_message<'py>(&self, py: Python<'py>, params: PyCreateMessageParams) -> PyResult<&'py PyAny> { + let inner = self.inner.clone(); + let request = CreateMessageRequest { + method: Default::default(), + params: params.into(), + extensions: Default::default(), + }; + let server_request = ServerRequest::CreateMessageRequest(request); + pyo3_asyncio::tokio::future_into_py(py, async move { + let peer = { + let guard = inner.lock().unwrap(); + guard.get_peer() + }; + if peer.is_none() { + return Err(pyo3::exceptions::PyRuntimeError::new_err("Client not connected to server")); + } + let peer = peer.unwrap(); + let context = RequestContext { + ct: CancellationToken::new(), + id: NumberOrString::Number(1), + meta: Default::default(), + extensions: Default::default(), + peer, + }; + let handler = { + let binding = inner.lock().unwrap(); + binding.clone() + }; + let future = handler.handle_request(server_request, context); + match future.await { + Ok(ClientResult::CreateMessageResult(result)) => Python::with_gil(|py| Ok(PyCreateMessageResult::from(py, result).into_py(py))), + Ok(_) => Err(PyErr::new::("Unexpected response type")), + Err(e) => Err(PyErr::new::(e.to_string())), + } + }) + } + + fn list_roots<'py>(&self, py: Python<'py>) -> PyResult<&'py PyAny> { + let inner = self.inner.clone(); + let server_request = ServerRequest::ListRootsRequest(RequestNoParam { + method: Default::default(), + extensions: Default::default(), + }); + pyo3_asyncio::tokio::future_into_py(py, async move { + let peer = { + let guard = inner.lock().unwrap(); + guard.get_peer() + }; + if peer.is_none() { + return Err(pyo3::exceptions::PyRuntimeError::new_err("Client not connected to server")); + } + let peer = peer.unwrap(); + let context = RequestContext { + ct: CancellationToken::new(), + id: NumberOrString::Number(1), + meta: Default::default(), + extensions: Default::default(), + peer, + }; + let handler = { + let binding = inner.lock().unwrap(); + binding.clone() + }; + let future = handler.handle_request(server_request, context); + match future.await { + Ok(ClientResult::ListRootsResult(result)) => Python::with_gil(|py| Ok(PyListRootsResult::from(py, result).into_py(py))), + Ok(_) => Err(PyErr::new::("Unexpected response type")), + Err(e) => Err(PyErr::new::(e.to_string())), + } + }) + } + /// List all tools available from the peer, as an awaitable Python method + #[pyo3(name = "list_all_tools")] + pub fn list_all_tools<'py>(&self, py: Python<'py>) -> PyResult<&'py PyAny> { + let inner = self.inner.clone(); + pyo3_asyncio::tokio::future_into_py(py, async move { + let peer = { + let guard = inner.lock().unwrap(); + guard.get_peer() + }; + let peer = match peer { + Some(p) => p, + None => return Python::with_gil(|_py| Err(PyRuntimeError::new_err("Peer not initialized"))), + }; + let req = rmcp::model::RequestOptionalParam { + method: rmcp::model::ListToolsRequestMethod, + params: None, + extensions: Default::default(), + }; + let req = rmcp::model::ClientRequest::ListToolsRequest(req); + let result = peer.send_request(req).await; + Python::with_gil(|py| match result { + Ok(rmcp::model::ServerResult::ListToolsResult(resp)) => { + let tools_py = resp.tools.into_iter().map(|tool| { + let val = to_value(tool).unwrap(); + serde_value_to_py(py, &val) + }).collect::>(); + Ok(PyList::new(py, tools_py).to_object(py)) + }, + Ok(other) => Err(PyRuntimeError::new_err(format!("Unexpected ServerResult variant for list_all_tools: {:?}", other))), + Err(e) => Err(PyRuntimeError::new_err(e.to_string())), + }) + }) + } + + /// List tools with optional parameters (filtering, etc.) + pub fn list_tools<'py>(&self, py: Python<'py>, params: PyObject) -> PyResult<&'py PyAny> { + let inner = self.inner.clone(); + let params = params; + pyo3_asyncio::tokio::future_into_py(py, async move { + let peer = { + let guard = inner.lock().unwrap(); + guard.get_peer() + }; + let peer = match peer { + Some(p) => p, + None => return Python::with_gil(|_py| Err(PyRuntimeError::new_err("Peer not initialized"))), + }; + let req_params = Python::with_gil(|py| { + if let Ok(dict) = params.extract::<&pyo3::types::PyDict>(py) { + let value = pydict_to_serde_value(dict); + serde_json::from_value::(value) + .map_err(|e| PyRuntimeError::new_err(e.to_string())) + } else { + Ok(rmcp::model::PaginatedRequestParam { cursor: None }) + } + })?; + let req = rmcp::model::RequestOptionalParam { + method: rmcp::model::ListToolsRequestMethod, + params: Some(req_params), + extensions: Default::default(), + }; + let req = rmcp::model::ClientRequest::ListToolsRequest(req); + let result = peer.send_request(req).await; + Python::with_gil(|py| match result { + Ok(rmcp::model::ServerResult::ListToolsResult(resp)) => { + let tools_py = resp.tools.into_iter().map(|tool| { + let val = to_value(tool).unwrap(); + serde_value_to_py(py, &val) + }).collect::>(); + Ok(PyList::new(py, tools_py).to_object(py)) + }, + Ok(other) => Err(PyRuntimeError::new_err(format!("Unexpected ServerResult variant for list_tools: {:?}", other))), + Err(e) => Err(PyRuntimeError::new_err(e.to_string())), + }) + }) + } + + /// Ergonomic Python method: call_tool(name, arguments=None) + #[pyo3(name = "call_tool")] + pub fn call_tool<'py>( + &self, + py: Python<'py>, + name: String, + arguments: Option, + ) -> PyResult<&'py PyAny> { + let rust_args = if let Some(args) = arguments { + let dict: &pyo3::types::PyDict = args.extract(py)?; + let value: serde_json::Value = pydict_to_serde_value(dict); + value.as_object().cloned() + } else { + None + }; + let param = rmcp::model::CallToolRequestParam { + name: name.into(), + arguments: rust_args, + }; + let call_tool_req = rmcp::model::Request { + method: rmcp::model::CallToolRequestMethod, + params: param, + extensions: Default::default(), + }; + let inner = self.inner.clone(); + pyo3_asyncio::tokio::future_into_py(py, async move { + let peer = { + let guard = inner.lock().unwrap(); + guard.get_peer() + }; + let peer = match peer { + Some(p) => p, + None => return Python::with_gil(|_py| Err(PyRuntimeError::new_err("Peer not initialized"))), + }; + let req = rmcp::model::ClientRequest::CallToolRequest(call_tool_req); + let result = peer.send_request(req).await; + Python::with_gil(|py| match result { + Ok(rmcp::model::ServerResult::CallToolResult(resp)) => { + let py_obj = crate::types::PyCallToolResult::from(py, resp).into_py(py); + Ok(py_obj) + }, + Ok(other) => Err(PyRuntimeError::new_err(format!("Unexpected ServerResult variant for call_tool: {:?}", other))), + Err(e) => Err(PyRuntimeError::new_err(e.to_string())), + }) + }) + } + +} + +// --- PyClient: Python-exposed handle to a live client peer --- +#[pyclass] +pub struct PyClient { + inner: Option>>, +} + +impl PyClient { + pub(crate) fn new(peer: Arc>) -> Self { + Self { + inner: Some(peer), + } + } +} +#[pymethods] +impl PyClient { + pub fn peer_info<'py>(&self, py: Python<'py>) -> PyResult { + // No async, so this is fine + match &self.inner { + Some(peer) => { + let info = peer.peer_info().clone(); + Ok(Py::new(py, crate::types::PyInfo::server(info))?.to_object(py)) + } + None => Err(PyRuntimeError::new_err("Peer not initialized")), + } +} + +pub fn list_all_tools<'py>(&self, py: Python<'py>) -> PyResult<&'py PyAny> { + let peer = self.inner.clone(); + pyo3_asyncio::tokio::future_into_py(py, async move { + match &peer { + Some(peer) => { + let result = peer.list_all_tools().await; + Python::with_gil(|py| match result { + Ok(tools) => { + let tools_py: Vec<_> = tools.into_iter().map(|tool| { + let val = to_value(&tool).unwrap(); + crate::service::serde_value_to_py(py, &val) + }).collect(); + Ok(PyList::new(py, tools_py).to_object(py)) + } + Err(e) => Err(PyRuntimeError::new_err(e.to_string())), + }) + } + None => Python::with_gil(|_py| Err(PyRuntimeError::new_err("Peer not initialized"))), + } + }) +} + +pub fn call_tool<'py>(&self, py: Python<'py>, name: String, arguments: Option) -> PyResult<&'py PyAny> { + let peer = self.inner.clone(); + // Extract arguments to serde_json::Value before async block + let rust_args = if let Some(args) = arguments { + Python::with_gil(|py| -> PyResult { + let dict: &pyo3::types::PyDict = args.extract(py)?; + Ok(crate::service::pydict_to_serde_value(dict)) + })? +} else { + serde_json::Value::Null +}; + pyo3_asyncio::tokio::future_into_py(py, async move { + match &peer { + Some(peer) => { + let param = CallToolRequestParam { + name: name.clone().into(), + arguments: rust_args.as_object().cloned(), +}; + let result = peer.call_tool(param).await; + Python::with_gil(|py| match result { + Ok(resp) => { + let py_obj = crate::types::PyCallToolResult::from(py, resp).into_py(py); + Ok(py_obj) + } + Err(e) => Err(PyRuntimeError::new_err(e.to_string())), + }) + } + None => Python::with_gil(|_py| Err(PyRuntimeError::new_err("Peer not initialized"))), + } + }) +} + +} + +#[pyclass] +#[derive(Clone)] +pub struct PyPeer { + #[pyo3(get)] + #[allow(dead_code)] + _dummy: Option, // Python cannot access the inner peer + #[allow(dead_code)] + pub(crate) inner: Option>>, +} + +impl PyPeer { + pub fn set_inner(&mut self, inner: Arc>) { + self.inner = Some(inner); + } +} + +#[pymethods] +impl PyPeer { + #[new] + pub fn new() -> Self { + PyPeer { _dummy: None, inner: None } + } + + + /* pub fn send_request<'py>(&self, py: Python<'py>, request: PyObject) -> PyResult<&'py PyAny> { + let peer = self.inner.clone(); + let req_obj = request; + pyo3_asyncio::tokio::future_into_py(py, async move { + Python::with_gil(|py| { + let req_rust = req_obj.extract::(py)?.inner.clone(); + match &peer { + Some(peer_arc) => { + match tokio::runtime::Handle::current().block_on(peer_arc.send_request(req_rust)) { + Ok(server_result) => Ok(Py::new(py, server_result).unwrap().to_object(py)), + Err(e) => Err(PyRuntimeError::new_err(e.to_string())), + } + } + None => Err(PyRuntimeError::new_err("Peer not initialized")), + } + }) + }) + } + + pub fn send_notification<'py>(&self, py: Python<'py>, notification: PyObject) -> PyResult<&'py PyAny> { + let peer = self.inner.clone(); + let notif = notification; + pyo3_asyncio::tokio::future_into_py(py, async move { + Python::with_gil(|py| { + let notif_rust = notif.extract::(py)?.inner.clone(); + use rmcp::model::{ClientNotification, CancelledNotification, ProgressNotification, InitializedNotification, RootsListChangedNotification}; + use serde_json::to_value; + // Manual conversion based on method field + let method = notif_rust.method.clone(); + let as_value = to_value(¬if_rust).map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?; + let client_notif = if method == CancelledNotificationMethod::VALUE { + let typed: CancelledNotification = serde_json::from_value(as_value).map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?; + ClientNotification::CancelledNotification(typed) + } else if method == ProgressNotificationMethod::VALUE { + let typed: ProgressNotification = serde_json::from_value(as_value).map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?; + ClientNotification::ProgressNotification(typed) + } else if method == InitializedNotificationMethod::VALUE { + let typed = InitializedNotification { + method: InitializedNotificationMethod, + extensions: Extensions::default(), + }; + ClientNotification::InitializedNotification(typed) + } else if method == RootsListChangedNotificationMethod::VALUE { + let typed = RootsListChangedNotification { + method: RootsListChangedNotificationMethod, + extensions: Extensions::default(), + }; + ClientNotification::RootsListChangedNotification(typed) + } else { + return Err(pyo3::exceptions::PyTypeError::new_err("Invalid notification type for ClientNotification")); + }; + match &peer { + Some(peer_arc) => { + match tokio::runtime::Handle::current().block_on(peer_arc.send_notification(client_notif)) { + Ok(_) => Ok(py.None()), + Err(e) => Err(pyo3::exceptions::PyRuntimeError::new_err(e.to_string())), + } + } + None => Err(pyo3::exceptions::PyRuntimeError::new_err("Peer not initialized")), + } + }) + }) + }*/ + pub fn peer_info<'py>(&self, py: Python<'py>) -> PyResult { + match &self.inner { + Some(peer_arc) => { + let info = peer_arc.peer_info().clone(); + // PyInfo::new expects InitializeResult, so pass info directly + Ok(Py::new(py, PyInfo::server(info))?.to_object(py)) + } + None => Err(pyo3::exceptions::PyRuntimeError::new_err("Peer not initialized")), + } + } +} diff --git a/bindings/python/src/transport.rs b/bindings/python/src/transport.rs new file mode 100644 index 00000000..07d72cee --- /dev/null +++ b/bindings/python/src/transport.rs @@ -0,0 +1,94 @@ +//! Python bindings for transport types. +//! +//! This module exposes Rust transport types (TCP, stdio, SSE, etc.) to Python via pyo3 bindings. +//! +//! # Examples +//! +//! ```python +//! from rmcp_python import PyTransport +//! transport = PyTransport.from_tcp('127.0.0.1:1234') +//! ``` + + +// Python bindings transport module: re-exports transport types for Python users +pub mod sse; +pub mod child_process; +pub mod io; +pub mod ws; + +use pyo3::prelude::*; +use pyo3::exceptions::PyRuntimeError; +use pyo3_asyncio::tokio::future_into_py; +use tokio::net::TcpStream; +use std::pin::Pin; + +pub enum PyTransportEnum { + Tcp(Pin>), + Stdio((tokio::io::Stdin, tokio::io::Stdout)), + Sse(rmcp::transport::SseTransport), +} + +/// Python-exposed transport handle for RMCP communication. +#[pyclass] +pub struct PyTransport { + /// The underlying transport enum (TCP, stdio, SSE, etc.). + pub(crate) inner: Option, +} + +#[pymethods] +impl PyTransport { + /// Create a new TCP transport from the given address. + /// + /// # Arguments + /// * `addr` - Address to connect to. + /// + /// # Examples + /// ```python + /// transport = PyTransport.from_tcp('127.0.0.1:1234') + /// ``` + #[staticmethod] + fn from_tcp(py: Python, addr: String) -> PyResult<&PyAny> { + future_into_py(py, async move { + match TcpStream::connect(addr).await { + Ok(stream) => Ok(PyTransport { + inner: Some(PyTransportEnum::Tcp(Box::pin(stream))), + }), + Err(e) => Err(PyRuntimeError::new_err(e.to_string())), + } + }) + } + + #[staticmethod] + fn from_stdio(py: Python) -> PyResult<&PyAny> { + future_into_py(py, async move { + let stdin = tokio::io::stdin(); + let stdout = tokio::io::stdout(); + Ok(PyTransport { + inner: Some(PyTransportEnum::Stdio((stdin, stdout))), + }) + }) + } + + #[staticmethod] + pub fn from_sse(py_sse: &mut PySseTransport) -> Self { + PyTransport { + inner: Some(PyTransportEnum::Sse( + py_sse.inner.take().expect("SSE transport already taken"), + )), + } + } + + pub fn is_tcp(&self) -> bool { + matches!(self.inner, Some(PyTransportEnum::Tcp(_))) + } + pub fn is_stdio(&self) -> bool { + matches!(self.inner, Some(PyTransportEnum::Stdio(_))) + } + + // Add more utility methods as needed +} + +// Re-export for Python users +// you can add #[cfg(feature = "python")] if you want to gate these for Python only +pub use sse::*; +pub use child_process::*; diff --git a/bindings/python/src/transport/child_process.rs b/bindings/python/src/transport/child_process.rs new file mode 100644 index 00000000..d5369aca --- /dev/null +++ b/bindings/python/src/transport/child_process.rs @@ -0,0 +1,22 @@ +#![allow(non_local_definitions)] + +use pyo3::prelude::*; +use rmcp::transport::TokioChildProcess; +use pyo3::exceptions::PyRuntimeError; + +#[pyclass] +pub struct PyChildProcessTransport { + pub inner: TokioChildProcess, +} + +#[pymethods] +impl PyChildProcessTransport { + #[new] + fn new(cmd: String, args: Vec) -> PyResult { + let mut command = tokio::process::Command::new(cmd); + command.args(args); + let transport = TokioChildProcess::new(&mut command) + .map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + Ok(Self { inner: transport }) + } +} diff --git a/bindings/python/src/transport/into_transport.rs b/bindings/python/src/transport/into_transport.rs new file mode 100644 index 00000000..e69de29b diff --git a/bindings/python/src/transport/io.rs b/bindings/python/src/transport/io.rs new file mode 100644 index 00000000..a96dea8e --- /dev/null +++ b/bindings/python/src/transport/io.rs @@ -0,0 +1,2 @@ +// Placeholder for PyIoTransport +// Implement as needed for your Python bindings diff --git a/bindings/python/src/transport/sse.rs b/bindings/python/src/transport/sse.rs new file mode 100644 index 00000000..ceeabc6f --- /dev/null +++ b/bindings/python/src/transport/sse.rs @@ -0,0 +1,26 @@ +#![allow(non_local_definitions)] + +use pyo3::prelude::*; +use rmcp::transport::SseTransport; +use rmcp::transport::sse::ReqwestSseClient; +use pyo3::exceptions::PyRuntimeError; +use reqwest; + +#[pyclass] +pub struct PySseTransport { + pub inner: Option>, +} + +#[pymethods] +impl PySseTransport { + #[staticmethod] + #[pyo3(name = "start")] + pub fn start(py: Python, url: String) -> PyResult<&PyAny> { + pyo3_asyncio::tokio::future_into_py(py, async move { + match SseTransport::start(&url).await { + Ok(transport) => Ok(PySseTransport { inner: Some(transport) }), + Err(e) => Err(PyRuntimeError::new_err(e.to_string())), + } + }) + } +} diff --git a/bindings/python/src/transport/ws.rs b/bindings/python/src/transport/ws.rs new file mode 100644 index 00000000..7e5fcf4a --- /dev/null +++ b/bindings/python/src/transport/ws.rs @@ -0,0 +1,2 @@ +// Placeholder for PyWsTransport +// Implement as needed for your Python bindings diff --git a/bindings/python/src/types.rs b/bindings/python/src/types.rs new file mode 100644 index 00000000..0fccf53a --- /dev/null +++ b/bindings/python/src/types.rs @@ -0,0 +1,927 @@ +//! Python bindings for core RMCP types. +//! +//! This module exposes Rust types (requests, notifications, content, roles, etc.) to Python via pyo3 bindings. +//! +//! # Overview +//! +//! The types in this module are designed to bridge the RMCP SDK's Rust data structures with Python, allowing Python users to construct, inspect, and manipulate RMCP protocol messages and objects in a type-safe and ergonomic way. Each struct is annotated for Python interop and provides methods for conversion to and from Python-native types (such as `dict`), as well as convenience constructors and helpers. +//! +//! # Main Types +//! +//! - [`PyRoot`]: Represents a root entity in the RMCP protocol. +//! - [`PyRequest`], [`PyNotification`]: Represent protocol requests and notifications, with JSON/dict conversion helpers. +//! - [`PyCreateMessageParams`]: Parameters for creating a new message. +//! - [`PyRole`]: Represents the role of a participant (e.g. user, assistant). +//! - [`PyTextContent`], [`PyImageContent`], [`PyEmbeddedResourceContent`], [`PyAudioContent`]: Content types for message payloads. +//! - [`PyClientInfo`]: Information about the RMCP client, including protocol version and capabilities. +//! +//! # Example Usage +//! +//! ```python +//! from rmcp_python import PyRoot, PyCreateMessageParams, PyRole, PyTextContent +//! +//! # Create a root entity +//! root = PyRoot(id='root1', name='Root') +//! +//! # Create message parameters +//! params = PyCreateMessageParams(content='Hello!', temperature=0.7, max_tokens=128) +//! +//! # Define a role +//! role = PyRole('assistant') +//! +//! # Create text content +//! text = PyTextContent('Hello, world!') +//! ``` +#![allow(non_local_definitions)] + +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyAny}; +use pyo3::exceptions::{PyValueError, PyRuntimeError}; +use pyo3::{Python, Py, PyObject}; +use pyo3::types::PyType; +use std::borrow::Cow; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, Map}; +use tokio_util::sync::CancellationToken; +use rmcp::model::{CreateMessageRequestParam, CreateMessageResult, ListRootsResult, Root, + Content, SamplingMessage, Role, RawContent, RawTextContent, RawImageContent, RawEmbeddedResource, + RawAudioContent, ResourceContents, Request, RequestId, Notification, CallToolRequestParam, + CallToolResult, ReadResourceRequestParam, ReadResourceResult, GetPromptRequestParam, + GetPromptResult, ClientInfo, Implementation, Meta, Extensions, ProtocolVersion}; +use rmcp::service::{Peer, RequestContext, RoleClient}; +use rmcp::ServiceExt; +use crate::model::prompt::PyPromptMessage; +use crate::model::resource::{PyTextResourceContents, PyBlobResourceContents, PyResourceContents}; +use crate::model::capabilities::PyClientCapabilities; + +/// Macro to define Python classes for RMCP protocol types with JSON/dict conversion. +/// +/// This macro generates a Python class wrapper for the given Rust type, with methods to construct from a Python dict +/// and to convert back to a dict. Used for types like `Request` and `Notification`. +/// +/// # Example +/// ```python +/// req = PyRequest({...}) +/// as_dict = req.to_dict() +/// ``` +// Macro for PyRequest and PyNotification only, using Python json for PyDict conversion +macro_rules! def_pyclass { + ($pyname:ident, $rusttype:ty) => { + #[pyclass] + #[derive(Clone)] + pub struct $pyname { + pub inner: $rusttype, + } + #[pymethods] + impl $pyname { + #[new] + pub fn new(py: Python, dict: &PyDict) -> PyResult { + let json_mod = py.import("json")?; + let json_str: String = json_mod.call_method1("dumps", (dict,))?.extract()?; + let inner: $rusttype = serde_json::from_str(&json_str) + .map_err(|e| PyValueError::new_err(format!("Invalid {}: {}", stringify!($pyname), e)))?; + Ok($pyname { inner }) + } + pub fn to_dict(&self, py: Python) -> PyResult { + let json_mod = py.import("json")?; + let json_str = serde_json::to_string(&self.inner) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + let dict = json_mod.call_method1("loads", (json_str,))?; + Ok(dict.into()) + } + } + }; +} + +def_pyclass!(PyRequest, Request); +def_pyclass!(PyNotification, Notification); + +/// Represents a root entity in the RMCP protocol. +/// +/// A root is a named resource or workspace that messages and operations are scoped to. +/// +/// # Fields +/// - `id`: Unique identifier for the root (e.g., a URI). +/// - `name`: Human-readable name for the root. +/// +/// # Example +/// ```python +/// root = PyRoot(id='workspace-123', name='My Workspace') +/// print(root.id, root.name) +/// ``` +#[pyclass] +#[derive(Clone)] +pub struct PyRoot { + /// The unique identifier of the root (e.g., a URI or UUID). + #[pyo3(get, set)] + pub id: String, + /// The human-readable display name of the root. + #[pyo3(get, set)] + pub name: String, +} + +impl From for PyRoot { + fn from(root: Root) -> Self { + Self { + id: root.uri, + name: root.name.unwrap_or_default(), + } + } +} + +#[pymethods] +impl PyRoot { + /// Creates a new `PyRoot`. + /// + /// # Arguments + /// * `id` - The unique identifier for the root. + /// * `name` - The human-readable name. + /// + /// # Example + /// ```python + /// root = PyRoot(id='workspace-123', name='My Workspace') + /// ``` + #[new] + fn new(id: String, name: String) -> Self { + PyRoot { id, name } + } +} + +/// Parameters for creating a new message in the RMCP protocol. +/// +/// Used to configure the content and sampling parameters for message generation. +/// +/// # Fields +/// - `content`: The textual content of the message. +/// - `temperature`: (Optional) Sampling temperature for generation. Higher values = more random. +/// - `max_tokens`: (Optional) Maximum number of tokens for the generated message. +/// +/// # Example +/// ```python +/// params = PyCreateMessageParams(content='Hello!', temperature=0.8, max_tokens=128) +/// ``` +#[pyclass] +#[derive(Clone)] +pub struct PyCreateMessageParams { + /// The message content to generate. + #[pyo3(get, set)] + pub content: String, + /// Sampling temperature (higher = more random, lower = more deterministic). + #[pyo3(get, set)] + pub temperature: Option, + /// Maximum number of tokens to generate. + #[pyo3(get, set)] + pub max_tokens: Option, +} + +impl From for CreateMessageRequestParam { + fn from(params: PyCreateMessageParams) -> Self { + Self { + messages: vec![], + model_preferences: None, + system_prompt: None, + include_context: None, + temperature: params.temperature, + max_tokens: params.max_tokens.unwrap_or(0), + stop_sequences: None, + metadata: None, + } + } +} + +#[pymethods] +impl PyCreateMessageParams { + #[new] + fn new(content: String, temperature: Option, max_tokens: Option) -> Self { + PyCreateMessageParams { content, temperature, max_tokens } + } +} + +/// Represents the role of a participant in the conversation. +/// +/// Typical roles include "user", "assistant", or custom roles. +/// +/// # Example +/// ```python +/// user_role = PyRole('user') +/// assistant_role = PyRole('assistant') +/// ``` +#[pyclass] +#[derive(Clone)] +pub struct PyRole { + #[pyo3(get, set)] + pub value: String, +} + +impl From for PyRole { + fn from(role: Role) -> Self { + Self { value: format!("{:?}", role) } + } +} + +#[pymethods] +impl PyRole { + #[new] + fn new(value: String) -> Self { + PyRole { value } + } +} + +/// Represents text content in a message. +/// +/// Used for plain textual messages in the RMCP protocol. +/// +/// # Example +/// ```python +/// text = PyTextContent('Hello, world!') +/// print(str(text)) +/// ``` +#[pyclass] +#[derive(Clone, Debug)] +pub struct PyTextContent { + #[pyo3(get, set)] + pub text: String, +} + +impl From for PyTextContent { + fn from(raw: RawTextContent) -> Self { + Self { text: raw.text } + } +} + +#[pymethods] +impl PyTextContent { + #[new] + fn new(text: String) -> Self { + PyTextContent { text } + } + + fn __str__(&self) -> PyResult { + Ok(format!("PyTextContent(text=\"{}\")", self.text)) + } + fn __repr__(&self) -> PyResult { + Ok(format!("", self.text)) + } +} + +/// Represents image content in a message. +/// +/// Used for sending image data as part of a message. +/// +/// # Fields +/// - `data`: The image data, typically as a base64-encoded string. +/// - `mime_type`: The MIME type of the image (e.g., "image/png"). +/// +/// # Example +/// ```python +/// img = PyImageContent(data='', mime_type='image/png') +/// ``` +#[pyclass] +#[derive(Clone)] +pub struct PyImageContent { + #[pyo3(get, set)] + pub data: String, + #[pyo3(get, set)] + pub mime_type: String, +} + +impl From for PyImageContent { + fn from(raw: RawImageContent) -> Self { + Self { data: raw.data, mime_type: raw.mime_type } + } +} + +#[pymethods] +impl PyImageContent { + #[new] + fn new(data: String, mime_type: String) -> Self { + PyImageContent { data, mime_type } + } +} + +/// Represents an embedded resource (text or binary) in a message. +/// +/// Used for including additional resources (such as files) in messages. +/// +/// # Fields +/// - `uri`: The resource URI or identifier. +/// - `mime_type`: (Optional) The MIME type of the resource. +/// - `text`: (Optional) Text content, if the resource is textual. +/// - `blob`: (Optional) Binary content, as a base64-encoded string. +/// +/// # Example +/// ```python +/// resource = PyEmbeddedResourceContent(uri='file://foo.txt', mime_type='text/plain', text='Hello!') +/// ``` +#[pyclass] +#[derive(Clone)] +pub struct PyEmbeddedResourceContent { + #[pyo3(get, set)] + pub uri: String, + #[pyo3(get, set)] + pub mime_type: Option, + #[pyo3(get, set)] + pub text: Option, + #[pyo3(get, set)] + pub blob: Option, +} + +impl From for PyEmbeddedResourceContent { + fn from(raw: RawEmbeddedResource) -> Self { + match &raw.resource { + ResourceContents::TextResourceContents { uri, mime_type, text } => Self { + uri: uri.clone(), + mime_type: mime_type.clone(), + text: Some(text.clone()), + blob: None, + }, + ResourceContents::BlobResourceContents { uri, mime_type, blob } => Self { + uri: uri.clone(), + mime_type: mime_type.clone(), + text: None, + blob: Some(blob.clone()), + }, + } + } +} + +#[pymethods] +impl PyEmbeddedResourceContent { + #[new] + fn new(uri: String, mime_type: Option, text: Option, blob: Option) -> Self { + PyEmbeddedResourceContent { uri, mime_type, text, blob } + } +} + +/// Represents audio content in a message. +/// +/// Used for sending audio data as part of a message. +/// +/// # Fields +/// - `data`: The audio data, typically as a base64-encoded string. +/// - `mime_type`: The MIME type of the audio (e.g., "audio/wav"). +/// +/// # Example +/// ```python +/// audio = PyAudioContent(data='', mime_type='audio/wav') +/// ``` +#[pyclass] +#[derive(Clone)] +pub struct PyAudioContent { + #[pyo3(get, set)] + pub data: String, + #[pyo3(get, set)] + pub mime_type: String, +} + +impl From for PyAudioContent { + fn from(raw: RawAudioContent) -> Self { + Self { data: raw.data, mime_type: raw.mime_type } + } +} + +#[pymethods] +impl PyAudioContent { + #[new] + fn new(data: String, mime_type: String) -> Self { + PyAudioContent { data, mime_type } + } +} + +#[pyclass] +#[derive(Clone, Debug)] +pub struct PyContent { + #[pyo3(get, set)] + pub kind: String, + #[pyo3(get, set)] + pub value: PyObject, // PyTextContent, PyImageContent, etc. +} + +#[pymethods] +impl PyContent { + #[new] + fn new(kind: String, value: pyo3::PyObject) -> Self { + PyContent { kind, value } + } + + fn __str__(&self, py: pyo3::Python) -> PyResult { + let value_str = match self.value.as_ref(py).str() { + Ok(pystr) => pystr.to_string_lossy().into_owned(), + Err(_) => "".to_string(), + }; + Ok(format!("PyContent(kind={}, value={})", self.kind, value_str)) + } + fn __repr__(&self, py: pyo3::Python) -> PyResult { + let value_repr = match self.value.as_ref(py).repr() { + Ok(pyrepr) => pyrepr.to_string_lossy().into_owned(), + Err(_) => "".to_string(), + }; + Ok(format!("", self.kind, value_repr)) + } +} + +impl PyContent { + pub fn from(py: Python, content: Content) -> Self { + match content.raw { + RawContent::Text(raw) => PyContent { + kind: "text".to_string(), + value: Py::new(py, PyTextContent::from(raw)).unwrap().into_py(py), + }, + RawContent::Image(raw) => PyContent { + kind: "image".to_string(), + value: Py::new(py, PyImageContent::from(raw)).unwrap().into_py(py), + }, + RawContent::Resource(raw) => PyContent { + kind: "resource".to_string(), + value: Py::new(py, PyEmbeddedResourceContent::from(raw)).unwrap().into_py(py), + }, + RawContent::Audio(raw) => PyContent { + kind: "audio".to_string(), + value: Py::new(py, PyAudioContent::from(raw.raw)).unwrap().into_py(py), + }, + } + } +} + +#[pyclass] +#[derive(Clone)] +pub struct PySamplingMessage { + #[pyo3(get, set)] + pub role: PyRole, + #[pyo3(get, set)] + pub content: PyContent, +} + +impl PySamplingMessage { + pub fn from(py: Python, msg: SamplingMessage) -> Self { + Self { + role: PyRole::from(msg.role), + content: PyContent::from(py, msg.content), + } + } +} + +#[pyclass] +#[derive(Clone)] +pub struct PyCreateMessageResult { + #[pyo3(get, set)] + pub model: String, + #[pyo3(get, set)] + pub stop_reason: Option, + #[pyo3(get, set)] + pub message: PySamplingMessage, +} + +impl PyCreateMessageResult { + pub fn from(py: Python, result: CreateMessageResult) -> Self { + Self { + model: result.model, + stop_reason: result.stop_reason, + message: PySamplingMessage::from(py, result.message), + } + } +} + +#[pymethods] +impl PyCreateMessageResult { +} + +#[pyclass] +#[derive(Clone)] +pub struct PyListRootsResult { + #[pyo3(get, set)] + pub roots: Vec, +} + +impl PyListRootsResult { + pub fn from(_py: Python, result: ListRootsResult) -> PyListRootsResult { + PyListRootsResult { + roots: result.roots.into_iter().map(PyRoot::from).collect(), + } + } +} + +#[pymethods] +impl PyListRootsResult { +} + +// Custom wrapper for RequestContext +#[pyclass] +#[derive(Clone)] +pub struct PyRequestContext { + #[pyo3(get, set)] + pub id: String, + #[pyo3(get, set)] + pub meta: PyObject, + #[pyo3(get, set)] + pub extensions: PyObject, + #[pyo3(get, set)] + pub peer: PyObject, +} + +#[pymethods] +impl PyRequestContext { + #[new] + pub fn new(id: String, meta: PyObject, extensions: PyObject, peer: PyObject) -> Self { + PyRequestContext { id, meta, extensions, peer } + } + pub fn to_dict(&self, py: Python) -> PyResult { + let dict = PyDict::new(py); + dict.set_item("id", &self.id)?; + dict.set_item("meta", &self.meta)?; + dict.set_item("extensions", &self.extensions)?; + dict.set_item("peer", &self.peer)?; + Ok(dict.into()) + } +} + +impl PyRequestContext { + pub fn to_rust(&self, py: Python) -> PyResult> { + // Convert id (String) to RequestId (type alias for NumberOrString) + let id: RequestId = if let Ok(num) = self.id.parse::() { + RequestId::Number(num) + } else { + RequestId::String(self.id.clone().into()) + }; + + // Convert meta (PyObject) to Meta (assume JSON dict for now) + let meta_val = self.meta.as_ref(py); + let meta: Meta = if let Ok(dict) = meta_val.downcast::() { + let json_mod = py.import("json")?; + let json_str: String = json_mod.call_method1("dumps", (dict,))?.extract()?; + serde_json::from_str(&json_str) + .map_err(|e| PyValueError::new_err(format!("Invalid meta: {}", e)))? + } else { + Meta::default() + }; + + // Convert extensions (PyObject) to Extensions + // Option 1: Always use Extensions::default() (recommended if you don't need extensions from Python) + let extensions = Extensions::default(); + + // Option 2: Manually insert specific known extension types from Python dict + + + // Convert peer (PyObject) to Peer + let peer_val = self.peer.as_ref(py); + let peer: Peer = if let Ok(py_peer) = peer_val.extract::() { + py_peer.inner + .as_ref() + .map(|arc_peer| arc_peer.as_ref().clone()) + .ok_or_else(|| PyValueError::new_err("PyPeer.inner is None"))? + } else { + return Err(PyValueError::new_err("peer must be a valid PyPeer")); + }; + + // Use a default CancellationToken for now + let ct = CancellationToken::new(); + + Ok(RequestContext { + ct, + id, + meta, + extensions, + peer, + }) + } +} + +#[pyclass] +#[derive(Clone)] +pub struct PyInfo { + #[pyo3(get)] + pub protocol_version: String, + #[pyo3(get)] + pub name: String, + #[pyo3(get)] + pub version: String, +} + +#[pymethods] +impl PyInfo { + fn __str__(&self) -> PyResult { + Ok(format!("PyInfo(protocol_version=\"{}\", name=\"{}\", version=\"{}\")", self.protocol_version, self.name, self.version)) + } + fn __repr__(&self) -> PyResult { + Ok(format!("", self.protocol_version, self.name, self.version)) + } +} + +impl PyInfo { + pub fn server(info: rmcp::model::InitializeResult) -> Self { + Self { + protocol_version: format!("{:?}", info.protocol_version), + name: info.server_info.name, + version: info.server_info.version, + } + } + + pub fn client(param: rmcp::model::InitializeRequestParam) -> Self { + Self { + protocol_version: format!("{:?}", param.protocol_version), + name: param.client_info.name, + version: param.client_info.version, + } + } +} + +/// Python-facing types for client-service API + +#[pyclass] +#[derive(Clone)] +pub struct PyCallToolRequestParam { + #[pyo3(get, set)] + pub name: String, + #[pyo3(get, set)] + pub arguments: Option, +} + +impl PyCallToolRequestParam { + pub fn to_rust(&self, py: Python) -> PyResult { + let arguments: Option> = match &self.arguments { + Some(obj) => { + // Convert Python object to JSON string using Python's json.dumps, then parse with serde_json + let json_mod = py.import("json")?; + let json_str: String = json_mod.call_method1("dumps", (obj,))?.extract()?; + let value: Value = serde_json::from_str(&json_str) + .map_err(|e| PyValueError::new_err(format!("Failed to parse arguments as JSON: {}", e)))?; + match value { + Value::Object(map) => Some(map), + _ => return Err(PyValueError::new_err("Expected dict for arguments")), + } + } + None => None, + }; + Ok(CallToolRequestParam { + name: Cow::Owned(self.name.clone()), + arguments, + }) + } +} + +#[pyclass] +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PyReadResourceRequestParam { + #[pyo3(get, set)] + pub uri: String, +} + +impl From for ReadResourceRequestParam { + fn from(py: PyReadResourceRequestParam) -> Self { + ReadResourceRequestParam { + uri: py.uri, + } + } +} + +#[pyclass] +#[derive(Clone)] +pub struct PyGetPromptRequestParam { + #[pyo3(get, set)] + pub name: String, + #[pyo3(get, set)] + pub arguments: Option, +} + +impl PyGetPromptRequestParam { + pub fn to_rust(&self, py: Python) -> PyResult { + let arguments: Option> = match &self.arguments { + Some(obj) => { + let json_mod = py.import("json")?; + let json_str: String = json_mod.call_method1("dumps", (obj,))?.extract()?; + let value: Value = serde_json::from_str(&json_str) + .map_err(|e| PyValueError::new_err(format!("Failed to parse arguments as JSON: {}", e)))?; + match value { + Value::Object(map) => Some(map), + _ => return Err(PyValueError::new_err("Expected dict for arguments")), + } + } + None => None, + }; + Ok(GetPromptRequestParam { + name: self.name.clone(), + arguments, + }) + } +} + +// --- Result Types --- + +#[pyclass] +#[derive(Clone)] +pub struct PyCallToolResult { + #[pyo3(get, set)] + pub content: Vec, + #[pyo3(get, set)] + pub is_error: Option, +} + +#[pymethods] +impl PyCallToolResult { + fn __str__(&self) -> PyResult { + // Use Debug formatting for each content item for maximum detail + let content_strs: Vec = self.content.iter().map(|c| format!("{:?}", c)).collect(); + Ok(format!("PyCallToolResult(content=[{}], is_error={:?})", content_strs.join(", "), self.is_error)) + } + fn __repr__(&self) -> PyResult { + let content_strs: Vec = self.content.iter().map(|c| format!("{:?}", c)).collect(); + Ok(format!("", content_strs.join(", "), self.is_error)) + } +} + +impl PyCallToolResult { + pub fn from(py: Python, res: CallToolResult) -> Self { + PyCallToolResult { + content: res.content.into_iter().map(|c| PyContent::from(py, c)).collect(), + is_error: res.is_error, + } + } +} + +#[pyclass] +#[derive(Clone)] +pub struct PyReadResourceResult { + #[pyo3(get, set)] + pub contents: Vec>, +} + +impl PyReadResourceResult { + pub fn from(py: Python, res: ReadResourceResult) -> Self { + PyReadResourceResult { + contents: res.contents.into_iter().map(|item| { + match item { + ResourceContents::TextResourceContents { uri, mime_type, text } => { + Py::new( + py, + (PyTextResourceContents { uri, text, mime_type }, PyResourceContents) + ).unwrap().into_py(py) + } + ResourceContents::BlobResourceContents { uri, mime_type, blob } => { + Py::new( + py, + (PyBlobResourceContents { uri, blob, mime_type }, PyResourceContents) + ).unwrap().into_py(py) + } + } + }).collect(), + } + } +} + +#[pyclass] +#[derive(Clone)] +pub struct PyGetPromptResult { + #[pyo3(get, set)] + pub description: Option, + #[pyo3(get, set)] + pub messages: Vec>, +} + +impl PyGetPromptResult { + pub fn from(py: Python, res: GetPromptResult) -> Self { + PyGetPromptResult { + description: res.description, + messages: res.messages.into_iter().map(|m| { + Py::new(py, PyPromptMessage { + role: format!("{:?}", m.role), + content: format!("{:?}", m.content), + }).unwrap() + }).collect(), + } + } +} + + +// --- PyImplementation --- +#[pyclass] +#[derive(Clone, Debug, PartialEq)] +pub struct PyImplementation { + #[pyo3(get, set)] + pub name: String, + #[pyo3(get, set)] + pub version: String, +} + +#[pymethods] +impl PyImplementation { + #[new] + pub fn new(name: String, version: String) -> Self { + PyImplementation { name, version } + } + + #[classmethod] + pub fn from_build_env(_cls: &PyType) -> Self { + PyImplementation { + name: env!("CARGO_CRATE_NAME").to_owned(), + version: env!("CARGO_PKG_VERSION").to_owned(), + } + } +} + +impl PyImplementation { + pub fn from(_py: Python, imp: Implementation) -> Self { + Self { + name: imp.name, + version: imp.version, + } + } +} + +impl From for Implementation { + fn from(implementation: PyImplementation) -> Self { + Implementation { name: implementation.name, version: implementation.version } + } +} + +impl Default for PyImplementation { + fn default() -> Self { + PyImplementation { + name: env!("CARGO_CRATE_NAME").to_owned(), + version: env!("CARGO_PKG_VERSION").to_owned(), + } + } +} + +/// Information about the RMCP client, protocol version, and capabilities. +/// +/// Used to describe the client when establishing a connection or serving requests. +/// +/// # Example +/// ```python +/// from rmcp_python import PyClientInfo, PyClientCapabilities, PyImplementation +/// info = PyClientInfo(protocol_version='1.0', capabilities=PyClientCapabilities(), client_info=PyImplementation()) +/// ``` +#[pyclass] +#[derive(Clone)] +pub struct PyClientInfo { + pub inner: ClientInfo, +} + +#[pymethods] +impl PyClientInfo { + #[new] + pub fn new( + protocol_version: String, + capabilities: PyClientCapabilities, + client_info: PyImplementation, + ) -> Self { + let inner = ClientInfo { + protocol_version: ProtocolVersion::from(protocol_version), + capabilities: capabilities.into(), + client_info: client_info.into(), + }; + Self { inner } + } + + pub fn serve<'py>(&self, py: Python<'py>, transport: &mut crate::transport::PyTransport) -> PyResult<&'py PyAny> { + println!("[PyClientInfo.serve] called with protocol_version={:?}, client_info={:?}", self.inner.protocol_version, self.inner.client_info); + println!("[PyClientInfo.serve] Transport type: {:?}", transport.inner.as_ref().map(|t| match t { + crate::transport::PyTransportEnum::Tcp(_) => "Tcp", + crate::transport::PyTransportEnum::Stdio(_) => "Stdio", + crate::transport::PyTransportEnum::Sse(_) => "Sse", + })); + let info = self.inner.clone(); + match transport.inner.take() { + Some(crate::transport::PyTransportEnum::Tcp(stream)) => { + pyo3_asyncio::tokio::future_into_py(py, async move { + println!("[PyClientInfo.serve/Tcp] Awaiting info.serve(stream)..."); + let running = info.serve(stream).await.map_err(|e| PyRuntimeError::new_err(format!("TCP Serve IO error: {}", e)))?; + println!("[PyClientInfo.serve/Tcp] Got running instance, peer={:?}", running.peer()); + let peer = running.peer().clone(); + let peer_arc = std::sync::Arc::new(peer); + Python::with_gil(|py| { + println!("[PyClientInfo.serve/Tcp] Creating PyClient"); + let py_client = crate::service::PyClient::new(peer_arc); + Ok(Py::new(py, py_client)?.to_object(py)) + }) + }) } + Some(crate::transport::PyTransportEnum::Stdio(stdin_stdout)) => { + pyo3_asyncio::tokio::future_into_py(py, async move { + println!("[PyClientInfo.serve/Stdio] Awaiting info.serve(stdin_stdout)..."); + let running = info.serve(stdin_stdout).await.map_err(|e| PyRuntimeError::new_err(format!("STDIO Serve IO error: {}", e)))?; + println!("[PyClientInfo.serve/Stdio] Got running instance, peer={:?}", running.peer()); + let peer = running.peer().clone(); + let peer_arc = std::sync::Arc::new(peer); + Python::with_gil(|py| { + println!("[PyClientInfo.serve/Stdio] Creating PyClient"); + let py_client = crate::service::PyClient::new(peer_arc); + Ok(Py::new(py, py_client)?.to_object(py)) + }) + }) } + Some(crate::transport::PyTransportEnum::Sse(sse)) => { + pyo3_asyncio::tokio::future_into_py(py, async move { + println!("[PyClientInfo.serve/Sse] Awaiting info.serve(sse)..."); + let running = info.serve(sse).await.map_err(|e| PyRuntimeError::new_err(format!("SSE Serve IO error: {}", e)))?; + println!("[PyClientInfo.serve/Sse] Got running instance, peer={:?}", running.peer()); + let peer = running.peer().clone(); + let peer_arc = std::sync::Arc::new(peer); + Python::with_gil(|py| { + println!("[PyClientInfo.serve/Sse] Creating PyClient"); + let py_client = crate::service::PyClient::new(peer_arc); + Ok(Py::new(py, py_client)?.to_object(py)) + }) + }) } + None => Err(PyRuntimeError::new_err("Transport not initialized")), + } + } +} diff --git a/bindings/python/tests/conftest.py b/bindings/python/tests/conftest.py new file mode 100644 index 00000000..ec722fc7 --- /dev/null +++ b/bindings/python/tests/conftest.py @@ -0,0 +1,28 @@ +import pytest +import sys +import os + +# Add the parent directory to the Python path so we can import the module +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +@pytest.fixture +def client(): + from rmcp_python import PyService + return PyService("test-api-key") + +@pytest.fixture +def sample_message(): + from rmcp_python import PyMessage + return PyMessage(role="user", content="Test message") + +@pytest.fixture +def sample_create_message_params(): + from rmcp_python import PyCreateMessageParams, PyMessage + return PyCreateMessageParams( + messages=[ + PyMessage(role="user", content="Hello!"), + PyMessage(role="assistant", content="Hi there!") + ], + max_tokens=100, + temperature=0.7 + ) \ No newline at end of file diff --git a/bindings/python/tests/test_client.py b/bindings/python/tests/test_client.py new file mode 100644 index 00000000..0bf7d65b --- /dev/null +++ b/bindings/python/tests/test_client.py @@ -0,0 +1,62 @@ +import pytest +import asyncio +from rmcp_python import PyService, PyCreateMessageParams, PyCreateMessageResult, PyRoot, PyListRootsResult + +@pytest.fixture +def client(): + return PyService() + +@pytest.mark.asyncio +async def test_client_initialization(): + client = PyService() + assert client is not None + +@pytest.mark.asyncio +async def test_client_connection(): + client = PyService() + with pytest.raises(RuntimeError, match="Client not connected to server"): + await client.create_message(PyCreateMessageParams( + content="test", + temperature=0.7, + max_tokens=100 + )) + +@pytest.mark.asyncio +async def test_create_message(client): + params = PyCreateMessageParams( + content="Hello, world!", + temperature=0.7, + max_tokens=100 + ) + result = await client.create_message(params) + assert isinstance(result, PyCreateMessageResult) + # The following lines depend on how PyCreateMessageResult is structured + # assert result.role == "user" + # assert result.content == "Hello, world!" + +@pytest.mark.asyncio +async def test_list_roots(client): + roots = await client.list_roots() + assert isinstance(roots, list) + assert all(isinstance(root, PyRoot) for root in roots) + +def test_params_creation(): + params = PyCreateMessageParams( + content="test", + temperature=0.7, + max_tokens=100 + ) + assert params.content == "test" + assert params.temperature == pytest.approx(0.7) + assert params.max_tokens == 100 + +def test_root_creation(): + root = PyRoot(id="test", name="Test Root") + assert root.id == "test" + assert root.name == "Test Root" + +def test_list_roots_result(): + try: + PyListRootsResult(roots=[PyRoot(id="test", name="Test Root")]) + except Exception as e: + assert isinstance(e, (RuntimeError, Exception)) # Accept any error \ No newline at end of file