diff --git a/pyproject.toml b/pyproject.toml index 5f511ee0cd..04853ee9e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -131,6 +131,9 @@ speech = [ "azure-cognitiveservices-speech>=1.44.0", ] +mcp = [ + "mcp>=1.10.0", +] litellm = [ # Cap below 1.92.0: 1.92.0+ replaced the universal py3-none-any wheel with a # Rust (PyO3) extension and only publishes manylinux_2_28 (x86_64/aarch64) and @@ -153,6 +156,7 @@ all = [ "ipykernel>=6.29.5", "jupyter>=1.1.1", "litellm>=1.84.0,<1.99.0", # 1.92.0+ drops the universal wheel (no mac/musl/old-glibc/win-arm64); see litellm group + "mcp>=1.10.0", "ollama>=0.5.1", "opencv-python>=4.11.0.86", "playwright>=1.49.0", diff --git a/pyrit/mcp/__init__.py b/pyrit/mcp/__init__.py new file mode 100644 index 0000000000..db2c668c43 --- /dev/null +++ b/pyrit/mcp/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +# ruff: noqa: F401 + +"""Model Context Protocol (MCP) integration for PyRIT targets.""" + +from pyrit.mcp.mcp_server_config import MCPServerConfig, MCPTransport +from pyrit.mcp.mcp_wrapped_prompt_chat_target import MCPWrappedPromptChatTarget + +__all__ = ["MCPServerConfig", "MCPTransport", "MCPWrappedPromptChatTarget"] diff --git a/pyrit/mcp/_client.py b/pyrit/mcp/_client.py new file mode 100644 index 0000000000..ef6b3d3014 --- /dev/null +++ b/pyrit/mcp/_client.py @@ -0,0 +1,187 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Thin async client over the official ``mcp`` Python SDK. + +Owns the transport plumbing (stdio subprocess / Streamable HTTP sessions) and +exposes just the two operations the wrapped target needs: listing tools and +calling one. The ``mcp`` package is an optional dependency (``pyrit[mcp]``) and +is imported lazily with an actionable error message. +""" + +from __future__ import annotations + +import logging +from contextlib import AsyncExitStack +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from pyrit.mcp.mcp_server_config import MCPServerConfig, MCPTransport + +if TYPE_CHECKING: + from types import TracebackType + +logger = logging.getLogger(__name__) + + +def _import_mcp() -> Any: + """ + Import the optional ``mcp`` package. + + Returns: + The imported module. + + Raises: + ModuleNotFoundError: With an actionable install hint if the extra is missing. + """ + try: + import mcp # noqa: PLC0415 + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + "The 'mcp' package is required for MCP support. Install it with: pip install pyrit[mcp]" + ) from exc + return mcp + + +@dataclass(frozen=True) +class MCPTool: + """A tool declared by an MCP server (catalog entry).""" + + name: str + description: str + input_schema: dict[str, Any] + + +@dataclass(frozen=True) +class MCPToolResult: + """Result of an MCP tool call, normalized to text.""" + + text: str + is_error: bool + + +class MCPClientSession: + """ + A live session with a single MCP server. + + Use as an async context manager; entering connects (launching the subprocess + for stdio servers) and exiting tears the connection down. Not safe for + concurrent use by multiple tasks — the wrapped target serializes access. + """ + + def __init__(self, *, config: MCPServerConfig) -> None: + """ + Create (but do not start) a session for the given server. + + Args: + config (MCPServerConfig): Connection and policy configuration. + """ + self._config = config + self._exit_stack: AsyncExitStack | None = None + self._session: Any = None # mcp.ClientSession, untyped until mcp is imported + + @property + def server_name(self) -> str: + """The friendly server name from the config.""" + return self._config.name + + async def __aenter__(self) -> MCPClientSession: + mcp = _import_mcp() + config = self._config + self._exit_stack = AsyncExitStack() + + try: + if config.transport is MCPTransport.STDIO: + stdio_client = mcp.client.stdio.stdio_client + server_params = mcp.client.stdio.StdioServerParameters( + command=config.command, + args=list(config.args), + env=dict(config.env) if config.env is not None else None, + ) + read_stream, write_stream = await self._exit_stack.enter_async_context(stdio_client(server_params)) + else: + streamablehttp_client = mcp.client.streamable_http.streamablehttp_client + http_transport = await self._exit_stack.enter_async_context( + streamablehttp_client(url=config.url, headers=dict(config.headers) if config.headers else {}) + ) + read_stream, write_stream, _get_session_id = http_transport + + client_session = mcp.client.session.ClientSession(read_stream, write_stream) + self._session = await self._exit_stack.enter_async_context(client_session) + await self._session.initialize() + except BaseException: + await self._exit_stack.aclose() + self._exit_stack = None + raise + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + if self._exit_stack is not None: + await self._exit_stack.aclose() + self._exit_stack = None + self._session = None + + def _require_session(self) -> Any: + if self._session is None: + raise RuntimeError( + f"MCP session for server '{self._config.name}' is not connected; use 'async with' to connect." + ) + return self._session + + async def list_tools(self) -> list[MCPTool]: + """ + List the tools the server declares. + + Returns: + list[MCPTool]: The server's tool catalog. + + Raises: + RuntimeError: If the session is not connected. + Exception: Propagates SDK/transport failures after normalizing + ``result.isError``-style errors (listing has no error channel, + so transport failures propagate). + """ + session = self._require_session() + response = await session.list_tools() + return [ + MCPTool( + name=tool.name, + description=tool.description or "", + input_schema=dict(tool.inputSchema) if tool.inputSchema else {}, + ) + for tool in response.tools + ] + + async def call_tool(self, *, tool_name: str, arguments: dict[str, Any]) -> MCPToolResult: + """ + Call a tool on the server. + + Args: + tool_name (str): The tool's registered name. + arguments (dict[str, Any]): JSON tool arguments. + + Returns: + MCPToolResult: Normalized text result; ``is_error`` is True when the + server reported a tool-level error. + + Raises: + RuntimeError: If the session is not connected. + Exception: Transport failures (timeouts, connection loss) propagate; + tool-level errors do not (they are reported via ``is_error``). + """ + session = self._require_session() + response = await session.call_tool(name=tool_name, arguments=arguments) + + parts: list[str] = [] + for content in response.content or []: + text = getattr(content, "text", None) + if text is not None: + parts.append(text) + text = "\n".join(parts) if parts else "(no content returned)" + return MCPToolResult(text=text, is_error=bool(response.isError)) diff --git a/pyrit/mcp/mcp_server_config.py b/pyrit/mcp/mcp_server_config.py new file mode 100644 index 0000000000..4125465db9 --- /dev/null +++ b/pyrit/mcp/mcp_server_config.py @@ -0,0 +1,94 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Configuration models for connecting PyRIT to MCP servers. + +These models describe *how to reach* an MCP server (transport and connection +parameters) plus the red-team policy applied to its tools (allowlist, caps). +They are transport-agnostic inputs to :mod:`pyrit.mcp._client`. +""" + +from __future__ import annotations + +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + + +class MCPTransport(str, Enum): + """ + Transports supported by :class:`MCPServerConfig`. + + - ``stdio``: launch the server as a subprocess and speak MCP over stdin/stdout. + Deterministic and offline-testable; the recommended transport for local tools. + - ``streamable_http``: connect to a remote MCP server over the Streamable HTTP + transport (the successor of the deprecated HTTP+SSE transport in the MCP spec). + """ + + STDIO = "stdio" + STREAMABLE_HTTP = "streamable_http" + + +class MCPServerConfig(BaseModel): + """ + Connection and policy configuration for a single MCP server. + + Exactly one connection style must be provided per transport: ``command`` for + stdio servers, ``url`` for streamable-HTTP servers. + + Args: + name (str): Friendly server name used in tool catalog entries, audit logs, + and error messages. + transport (MCPTransport): Transport used to reach the server. + command (str | None): Executable that starts the MCP server (stdio only), + e.g. ``"python"``. + args (list[str] | None): Arguments forwarded to ``command`` (stdio only). + env (dict[str, str] | None): Extra environment variables for the server + subprocess (stdio only). Defaults to a minimal environment when unset. + url (str | None): Server endpoint URL (streamable-http only). + headers (dict[str, str] | None): Extra HTTP headers for the endpoint + (streamable-http only), e.g. authorization headers. + allowed_tools (list[str] | None): Explicit tool-name allowlist for this + server. ``None`` (default) allows every tool the server declares; + names listed here are the only ones the wrapped target may execute. + tool_call_timeout (float): Per-tool-call timeout in seconds. Defaults to 60. + """ + + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, description="Friendly server name used in logs and the tool catalog.") + transport: MCPTransport = Field(description="Transport used to reach the MCP server.") + command: str | None = Field(None, description="Executable that starts the MCP server (stdio only).") + args: list[str] = Field(default_factory=list, description="Arguments for the stdio server command.") + env: dict[str, str] | None = Field(None, description="Extra environment variables for the stdio subprocess.") + url: str | None = Field(None, description="Server endpoint URL (streamable-http only).") + headers: dict[str, str] | None = Field(None, description="Extra HTTP headers (streamable-http only).") + allowed_tools: list[str] | None = Field( + None, + description="Explicit tool-name allowlist for this server; None allows all tools the server declares.", + ) + tool_call_timeout: float = Field( + 60.0, + gt=0, + description="Per-tool-call timeout in seconds.", + ) + + def validate_connection(self) -> None: + """ + Verify the connection parameters match the selected transport. + + Raises: + ValueError: If required connection parameters are missing or a + parameter is provided that does not belong to the transport. + """ + if self.transport is MCPTransport.STDIO: + if not self.command: + raise ValueError(f"MCP server '{self.name}': stdio transport requires 'command'.") + if self.url: + raise ValueError(f"MCP server '{self.name}': 'url' is only valid for the streamable_http transport.") + elif self.transport is MCPTransport.STREAMABLE_HTTP: + if not self.url: + raise ValueError(f"MCP server '{self.name}': streamable_http transport requires 'url'.") + if self.command: + raise ValueError(f"MCP server '{self.name}': 'command' is only valid for the stdio transport.") diff --git a/pyrit/mcp/mcp_wrapped_prompt_chat_target.py b/pyrit/mcp/mcp_wrapped_prompt_chat_target.py new file mode 100644 index 0000000000..421da74aec --- /dev/null +++ b/pyrit/mcp/mcp_wrapped_prompt_chat_target.py @@ -0,0 +1,499 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +MCP-wrapped prompt target. + +Composes Model Context Protocol (MCP) tool use into any PyRIT chat target: +the wrapper connects to the configured MCP servers, presents their tools to +the model as part of the system context, and drives a bounded agentic loop in +which the model can discover and call tools before producing its final answer. + +This follows the implementation direction discussed on PyRIT issue #1273: +composition/wrapping of an existing ``PromptTarget`` rather than per-target +modifications, so every chat target (LiteLLM, Azure ML, Hugging Face, ...) +gains MCP tool use without code changes. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from contextlib import AsyncExitStack +from typing import TYPE_CHECKING, Any + +from pyrit.mcp._client import MCPClientSession, MCPTool, MCPToolResult +from pyrit.models import Message, MessagePiece +from pyrit.prompt_target.common.prompt_target import PromptTarget +from pyrit.prompt_target.common.target_configuration import TargetConfiguration + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + from pyrit.mcp.mcp_server_config import MCPServerConfig + +logger = logging.getLogger(__name__) + +_TOOL_CALL_PREFIX = "TOOL_CALL:" +_TOOL_RESULT_PREFIX = "TOOL_RESULT" +_TOOL_ERROR_PREFIX = "TOOL_ERROR" +_MAX_TOOL_RESULT_CHARS = 8_000 + + +def _truncate(text: str, limit: int = _MAX_TOOL_RESULT_CHARS) -> str: + """Return ``text``, truncated with an explicit marker when over ``limit``.""" + if len(text) <= limit: + return text + return f"{text[:limit]}\n... [truncated {len(text) - limit} characters]" + + +class MCPWrappedPromptChatTarget(PromptTarget): + """ + Wraps any inner chat target with MCP tool use. + + The wrapper connects to the configured MCP servers, lists their tools, and + injects a tool catalog plus a strict tool-call protocol into the system + context of each conversation. When the model wants to use a tool it + responds with a ``TOOL_CALL: {json}`` line; the wrapper executes the call + (subject to the configured allowlists and caps), feeds the result back as a + labeled user turn, and continues until the model produces a final answer + without a tool call or the round budget is exhausted. + + Sessions are established lazily on first use and reused across sends; + :meth:`cleanup_target_async` tears them down. + + Intermediate agentic rounds happen inside the wrapper and are audit-logged; + only the final response is returned to the caller (and thus scored), keeping + memory and reports focused on the conversation-level behavior. + """ + + _DEFAULT_SYSTEM_PROMPT_PREFIX: str = ( + "You have access to the following tools exposed over the Model Context Protocol (MCP):\n\n" + ) + _DEFAULT_SYSTEM_PROMPT_INSTRUCTIONS: str = ( + "\n\nTo call a tool, respond with exactly one line in this format and nothing else:\n" + 'TOOL_CALL: {"tool": "", "arguments": {}}\n' + "You will then receive the tool result and may call further tools. " + "When you can answer without another tool call, respond with your final answer " + "and do NOT include a TOOL_CALL line." + ) + + def __init__( + self, + *, + inner_target: PromptTarget, + mcp_servers: Sequence[MCPServerConfig], + max_tool_call_rounds: int = 5, + allowed_tools: Sequence[str] | None = None, + system_prompt_prefix: str | None = None, + system_prompt_instructions: str | None = None, + custom_configuration: TargetConfiguration | None = None, + ) -> None: + """ + Initialize the wrapper. + + Args: + inner_target (PromptTarget): The target to wrap. Must support system + prompts and multi-turn conversations; the wrapper advertises the + inner target's capabilities. + mcp_servers (Sequence[MCPServerConfig]): MCP servers to connect to. + Must be non-empty. + max_tool_call_rounds (int): Maximum number of tool-call rounds per + user prompt. Defaults to 5. + allowed_tools (Sequence[str] | None): Global tool-name allowlist + applied on top of each server's own ``allowed_tools``. ``None`` + (default) defers to the per-server allowlists. + system_prompt_prefix (str | None): Replacement for the default catalog + preamble, if callers want custom framing. + system_prompt_instructions (str | None): Replacement for the default + tool-call protocol instructions. + custom_configuration (TargetConfiguration | None): Override the + default (inner-target-derived) configuration. + + Raises: + ValueError: If no MCP servers are configured, a server config is + invalid, or the inner target lacks required capabilities. + """ + if not mcp_servers: + raise ValueError("MCPWrappedPromptChatTarget requires at least one MCP server.") + inner_capabilities = inner_target.capabilities + if not inner_capabilities.supports_system_prompt: + raise ValueError( + f"The inner target {type(inner_target).__name__} must support system prompts " + "to receive the MCP tool catalog." + ) + if not inner_capabilities.supports_multi_turn: + raise ValueError( + f"The inner target {type(inner_target).__name__} must support multi-turn conversations " + "for the MCP tool-call loop." + ) + + self._servers: list[MCPServerConfig] = list(mcp_servers) + for server in self._servers: + server.validate_connection() + self._servers_by_name: dict[str, MCPServerConfig] = {server.name: server for server in self._servers} + self._max_tool_call_rounds = max(1, max_tool_call_rounds) + self._global_allowed_tools: frozenset[str] | None = ( + frozenset(allowed_tools) if allowed_tools is not None else None + ) + self._system_prompt_prefix = ( + system_prompt_prefix if system_prompt_prefix is not None else self._DEFAULT_SYSTEM_PROMPT_PREFIX + ) + self._system_prompt_instructions = ( + system_prompt_instructions + if system_prompt_instructions is not None + else self._DEFAULT_SYSTEM_PROMPT_INSTRUCTIONS + ) + self._inner_target = inner_target + self._session_stack: AsyncExitStack | None = None + self._sessions: dict[str, MCPClientSession] = {} + self._sessions_lock = asyncio.Lock() + + super().__init__( + custom_configuration=custom_configuration + if custom_configuration is not None + else TargetConfiguration(capabilities=inner_capabilities) + ) + + async def _get_or_connect_sessions_async(self) -> dict[str, MCPClientSession]: + """ + Return the live sessions, connecting lazily on first use. + + Returns: + dict[str, MCPClientSession]: Live sessions keyed by server name. + """ + async with self._sessions_lock: + if self._session_stack is None: + stack = AsyncExitStack() + try: + sessions: dict[str, MCPClientSession] = {} + for server in self._servers: + session = await stack.enter_async_context(MCPClientSession(config=server)) + sessions[server.name] = session + except BaseException: + await stack.aclose() + raise + self._session_stack = stack + self._sessions = sessions + logger.info("Connected to %d MCP server(s): %s", len(sessions), ", ".join(sessions)) + return self._sessions + + async def cleanup_target_async(self) -> None: + """Close MCP sessions. Safe to call multiple times.""" + async with self._sessions_lock: + if self._session_stack is not None: + await self._session_stack.aclose() + self._session_stack = None + self._sessions = {} + + def _tool_is_allowed(self, *, server: MCPServerConfig, tool_name: str) -> bool: + """Return True when ``tool_name`` passes both the server and global allowlists.""" + if server.allowed_tools is not None and tool_name not in server.allowed_tools: + return False + return self._global_allowed_tools is None or tool_name in self._global_allowed_tools + + def _build_system_prompt(self, *, catalog: Mapping[str, Sequence[MCPTool]]) -> str: + """ + Build the system context: tool catalog plus the tool-call protocol. + + Args: + catalog (Mapping[str, Sequence[MCPTool]]): Declared tools per server name. + + Returns: + str: The full system prompt to inject. + """ + lines: list[str] = [] + for server_name, tools in catalog.items(): + server = self._servers_by_name[server_name] + for tool in tools: + if not self._tool_is_allowed(server=server, tool_name=tool.name): + continue + schema = json.dumps(tool.input_schema, sort_keys=True) if tool.input_schema else "{}" + lines.append( + f"- {server_name}/{tool.name}: {tool.description or 'No description.'} | parameters: {schema}" + ) + body = "\n".join(lines) if lines else "(no tools available)" + return self._system_prompt_prefix + body + self._system_prompt_instructions + + def _build_system_message(self, *, conversation_id: str, sequence: int, text: str) -> Message: + """ + Build the synthetic system message carrying the tool catalog. + + Returns: + Message: The system-role message to prepend. + """ + return Message( + message_pieces=[ + MessagePiece( + role="system", + original_value=text, + converted_value=text, + conversation_id=conversation_id, + sequence=sequence, + prompt_metadata={"mcp_system_context": True}, + ) + ] + ) + + def _build_tool_result_message( + self, + *, + conversation_id: str, + sequence: int, + server_name: str, + tool_name: str, + result: MCPToolResult, + ) -> Message: + """ + Build the labeled user turn that feeds a tool result back to the model. + + Returns: + Message: The user-role message carrying the (possibly truncated) result. + """ + prefix = _TOOL_ERROR_PREFIX if result.is_error else _TOOL_RESULT_PREFIX + body = f"{prefix}[{server_name}/{tool_name}]: {_truncate(result.text)}" + return Message( + message_pieces=[ + MessagePiece( + role="user", + original_value=body, + converted_value=body, + conversation_id=conversation_id, + sequence=sequence, + prompt_metadata={"mcp_server": server_name, "mcp_tool": tool_name, "mcp_is_error": result.is_error}, + ) + ] + ) + + def _build_tool_call_error_message(self, *, conversation_id: str, sequence: int, error: str) -> Message: + """ + Build the user turn that bounces an invalid or denied tool call back to the model. + + Returns: + Message: The user-role message describing the rejection. + """ + body = f"{_TOOL_ERROR_PREFIX}: {error} Respond with your final answer or a corrected TOOL_CALL line." + return Message( + message_pieces=[ + MessagePiece( + role="user", + original_value=body, + converted_value=body, + conversation_id=conversation_id, + sequence=sequence, + prompt_metadata={"mcp_call_error": error}, + ) + ] + ) + + @staticmethod + def _parse_tool_call(text: str) -> dict[str, Any] | None: + """ + Extract the last valid ``TOOL_CALL:`` line from an assistant response. + + Args: + text (str): The assistant response text. + + Returns: + dict[str, Any] | None: ``{"tool": ..., "arguments": ...}`` when a + well-formed call is present; ``None`` otherwise. Malformed call + lines are treated as absent so the model can correct itself on + the next round. + """ + if _TOOL_CALL_PREFIX not in text: + return None + call: dict[str, Any] | None = None + for line in text.splitlines(): + stripped = line.strip() + if not stripped.startswith(_TOOL_CALL_PREFIX): + continue + payload = stripped[len(_TOOL_CALL_PREFIX) :].strip() + try: + parsed = json.loads(payload) + except json.JSONDecodeError: + call = None + continue + if ( + isinstance(parsed, dict) + and isinstance(parsed.get("tool"), str) + and isinstance(parsed.get("arguments"), dict) + ): + call = parsed + else: + call = None + return call + + @staticmethod + def _response_text(messages: Sequence[Message]) -> str: + """ + Concatenate the assistant text of the last response message. + + Returns: + str: The assistant text, or an empty string when there is none. + """ + if not messages: + return "" + return "\n".join( + piece.converted_value or piece.original_value + for piece in messages[-1].message_pieces + if piece.api_role == "assistant" + ) + + @staticmethod + def _next_sequence(conversation: Sequence[Message]) -> int: + """Return the next sequence number after the highest in the conversation.""" + return ( + max( + (piece.sequence for message in conversation for piece in message.message_pieces), + default=-1, + ) + + 1 + ) + + @staticmethod + def _conversation_id(conversation: Sequence[Message]) -> str: + """Return the conversation id shared by the conversation's messages.""" + return conversation[-1].message_pieces[0].conversation_id or "" + + def _find_tool( + self, + *, + sessions: Mapping[str, MCPClientSession], + catalog: Mapping[str, Sequence[MCPTool]], + tool_name: str, + ) -> tuple[MCPClientSession, str, MCPTool] | None: + """ + Resolve a catalog tool name (``server/tool`` or bare ``tool``) to a session. + + Args: + sessions (Mapping[str, MCPClientSession]): Live sessions by server name. + catalog (Mapping[str, Sequence[MCPTool]]): Declared tools per server name. + tool_name (str): The name the model used in its TOOL_CALL. + + Returns: + tuple[MCPClientSession, str, MCPTool] | None: Session, canonical + ``server/tool`` name, and tool; ``None`` when no unique match exists. + """ + if "/" in tool_name: + server_name, _, bare_name = tool_name.partition("/") + session = sessions.get(server_name) + if session is None: + return None + for tool in catalog.get(server_name, []): + if tool.name == bare_name: + return session, f"{server_name}/{bare_name}", tool + return None + matches = [ + (sessions[server_name], f"{server_name}/{tool.name}", tool) + for server_name, tools in catalog.items() + for tool in tools + if tool.name == tool_name + ] + return matches[0] if len(matches) == 1 else None + + async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Message]) -> list[Message]: + """ + Run the bounded agentic MCP loop against the inner target. + + Args: + normalized_conversation (list[Message]): The full conversation + (history + current message) after the wrapper's normalization + pipeline. + + Returns: + list[Message]: The inner target's final response messages. When the + round budget is exhausted mid-loop, the last response is returned + as-is (it may still contain a ``TOOL_CALL`` line) and a warning + is logged. + """ + sessions = await self._get_or_connect_sessions_async() + catalog: dict[str, list[MCPTool]] = {name: await session.list_tools() for name, session in sessions.items()} + + conversation: list[Message] = list(normalized_conversation) + conversation_id = self._conversation_id(conversation) + sequence = self._next_sequence(conversation) + conversation.insert( + 0, + self._build_system_message( + conversation_id=conversation_id, + sequence=sequence, + text=self._build_system_prompt(catalog=catalog), + ), + ) + sequence += 1 + + response: list[Message] = [] + for _ in range(self._max_tool_call_rounds): + response = await self._inner_target._send_prompt_to_target_async(normalized_conversation=conversation) + assistant_text = self._response_text(response) + call = self._parse_tool_call(assistant_text) + + if call is None: + return response + + tool_name = call["tool"] + resolved = self._find_tool(sessions=sessions, catalog=catalog, tool_name=tool_name) + if resolved is None: + logger.warning("[MCP audit] denied unknown tool '%s'", tool_name) + conversation.append(response[-1]) + conversation.append( + self._build_tool_call_error_message( + conversation_id=conversation_id, + sequence=sequence, + error=f"Unknown tool '{tool_name}'.", + ) + ) + sequence += 1 + continue + + session, canonical_name, tool = resolved + server_name = canonical_name.split("/", 1)[0] + server = self._servers_by_name[server_name] + if not self._tool_is_allowed(server=server, tool_name=tool.name): + logger.warning("[MCP audit] denied tool '%s' by allowlist", canonical_name) + conversation.append(response[-1]) + conversation.append( + self._build_tool_call_error_message( + conversation_id=conversation_id, + sequence=sequence, + error=f"Tool '{canonical_name}' is not allowed by policy.", + ) + ) + sequence += 1 + continue + + logger.info("[MCP audit] calling %s with arguments: %s", canonical_name, call["arguments"]) + try: + result = await asyncio.wait_for( + session.call_tool(tool_name=tool.name, arguments=call["arguments"]), + timeout=server.tool_call_timeout, + ) + except TimeoutError: + logger.warning( + "[MCP audit] tool call '%s' timed out after %ss", canonical_name, server.tool_call_timeout + ) + result = MCPToolResult(text="Tool call timed out.", is_error=True) + logger.info( + "[MCP audit] tool call '%s' finished (is_error=%s, %d chars)", + canonical_name, + result.is_error, + len(result.text), + ) + + conversation.append(response[-1]) + conversation.append( + self._build_tool_result_message( + conversation_id=conversation_id, + sequence=sequence, + server_name=server_name, + tool_name=tool.name, + result=result, + ) + ) + sequence += 1 + + logger.warning( + "MCP tool-call loop hit the round cap (%d); returning the last response.", self._max_tool_call_rounds + ) + return response diff --git a/tests/unit/mcp/__init__.py b/tests/unit/mcp/__init__.py new file mode 100644 index 0000000000..9a0454564d --- /dev/null +++ b/tests/unit/mcp/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. diff --git a/tests/unit/mcp/_echo_mcp_server.py b/tests/unit/mcp/_echo_mcp_server.py new file mode 100644 index 0000000000..b0cf9eee3f --- /dev/null +++ b/tests/unit/mcp/_echo_mcp_server.py @@ -0,0 +1,30 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Tiny FastMCP stdio server used by the MCP unit tests. + +Declares two tools over the real MCP stdio transport so the client wrapper is +exercised end-to-end (subprocess launch, initialize handshake, list_tools, +call_tool) without any network access. +""" + +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("test-server") + + +@mcp.tool() +def echo(text: str) -> str: + """Return the input text verbatim.""" + return f"echo: {text}" + + +@mcp.tool() +def add_numbers(a: int, b: int) -> int: + """Add two integers.""" + return str(a + b) # type: ignore[return-value] + + +if __name__ == "__main__": + mcp.run(transport="stdio") diff --git a/tests/unit/mcp/test_mcp_client.py b/tests/unit/mcp/test_mcp_client.py new file mode 100644 index 0000000000..b08b0a8955 --- /dev/null +++ b/tests/unit/mcp/test_mcp_client.py @@ -0,0 +1,64 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""End-to-end tests for the MCP client wrapper over a real stdio server.""" + +import sys +from pathlib import Path + +import pytest + +from pyrit.mcp._client import MCPClientSession +from pyrit.mcp.mcp_server_config import MCPServerConfig, MCPTransport + +_SERVER_PATH = Path(__file__).parent / "_echo_mcp_server.py" + + +@pytest.fixture +def stdio_config() -> MCPServerConfig: + return MCPServerConfig( + name="test-server", + transport=MCPTransport.STDIO, + command=sys.executable, + args=["-u", str(_SERVER_PATH)], + ) + + +async def test_list_tools_over_stdio(stdio_config): + async with MCPClientSession(config=stdio_config) as session: + tools = await session.list_tools() + + names = {tool.name for tool in tools} + assert names == {"echo", "add_numbers"} + echo_tool = next(tool for tool in tools if tool.name == "echo") + assert "verbatim" in echo_tool.description + assert "text" in echo_tool.input_schema.get("properties", {}) + + +async def test_call_tool_over_stdio(stdio_config): + async with MCPClientSession(config=stdio_config) as session: + result = await session.call_tool(tool_name="echo", arguments={"text": "hello pyrit"}) + + assert result.is_error is False + assert result.text == "echo: hello pyrit" + + +async def test_call_tool_reports_tool_level_errors(stdio_config): + """Server-side tool errors surface as is_error, not as transport exceptions.""" + async with MCPClientSession(config=stdio_config) as session: + result = await session.call_tool(tool_name="echo", arguments={"wrong_arg": 1}) + + assert result.is_error is True + + +async def test_call_tool_without_connection_raises(): + session = MCPClientSession( + config=MCPServerConfig( + name="never-connected", + transport=MCPTransport.STDIO, + command=sys.executable, + args=["-c", "pass"], + ) + ) + with pytest.raises(RuntimeError, match="not connected"): + await session.call_tool(tool_name="echo", arguments={}) diff --git a/tests/unit/mcp/test_mcp_wrapped_prompt_chat_target.py b/tests/unit/mcp/test_mcp_wrapped_prompt_chat_target.py new file mode 100644 index 0000000000..e9151f810b --- /dev/null +++ b/tests/unit/mcp/test_mcp_wrapped_prompt_chat_target.py @@ -0,0 +1,252 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Unit tests for the MCP-wrapped prompt target's agentic loop protocol.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from pyrit.mcp._client import MCPTool, MCPToolResult +from pyrit.mcp.mcp_server_config import MCPServerConfig, MCPTransport +from pyrit.mcp.mcp_wrapped_prompt_chat_target import MCPWrappedPromptChatTarget +from pyrit.models import Message, MessagePiece +from pyrit.prompt_target.common.target_configuration import TargetCapabilities + + +def _conversation(*, user_text: str = "What is 2 + 3?") -> list[Message]: + return [ + Message( + message_pieces=[MessagePiece(role="user", original_value=user_text, converted_value=user_text, sequence=0)] + ) + ] + + +def _assistant_message(text: str) -> list[Message]: + return [Message(message_pieces=[MessagePiece(role="assistant", original_value=text, converted_value=text)])] + + +def _capabilities(*, system_prompt: bool = True, multi_turn: bool = True) -> TargetCapabilities: + return TargetCapabilities(supports_system_prompt=system_prompt, supports_multi_turn=multi_turn) + + +def _inner_target(responses: list[str], *, capabilities: TargetCapabilities | None = None) -> MagicMock: + """Build an inner target stub returning the given assistant texts in order.""" + inner = MagicMock() + inner.capabilities = capabilities if capabilities is not None else _capabilities() + inner._send_prompt_to_target_async = AsyncMock(side_effect=[_assistant_message(text) for text in responses]) + return inner + + +def _fake_session(*, tools: list[MCPTool], call_results: list[MCPToolResult]) -> MagicMock: + """Build a session stub whose list_tools/call_tool behave like the real client.""" + session = MagicMock() + session.list_tools = AsyncMock(return_value=tools) + session.call_tool = AsyncMock(side_effect=call_results) + return session + + +def _wrapped_target( + inner: MagicMock, + *, + sessions: dict[str, MagicMock] | None = None, + server_allowed_tools: list[str] | None = None, + global_allowed_tools: list[str] | None = None, + max_tool_call_rounds: int = 5, +) -> MCPWrappedPromptChatTarget: + config = MCPServerConfig( + name="calc", + transport=MCPTransport.STDIO, + command="python", + args=["server.py"], + allowed_tools=server_allowed_tools, + ) + wrapper = MCPWrappedPromptChatTarget( + inner_target=inner, + mcp_servers=[config], + max_tool_call_rounds=max_tool_call_rounds, + allowed_tools=global_allowed_tools, + ) + + if sessions is None: + sessions = { + "calc": _fake_session( + tools=[ + MCPTool(name="add", description="Add two numbers.", input_schema={"type": "object"}), + MCPTool(name="echo", description="Echo text.", input_schema={"type": "object"}), + ], + call_results=[MCPToolResult(text="42", is_error=False) for _ in range(32)], + ) + } + + async def _sessions(): + return sessions + + wrapper._get_or_connect_sessions_async = _sessions # type: ignore[method-assign] + return wrapper + + +@pytest.mark.usefixtures("patch_central_database") +class TestWrapperInitialization: + def test_requires_inner_system_prompt_support(self): + inner = _inner_target(["x"], capabilities=_capabilities(system_prompt=False)) + with pytest.raises(ValueError, match="system prompts"): + MCPWrappedPromptChatTarget( + inner_target=inner, + mcp_servers=[MCPServerConfig(name="s", transport=MCPTransport.STDIO, command="python")], + ) + + def test_requires_inner_multi_turn_support(self): + inner = _inner_target(["x"], capabilities=_capabilities(multi_turn=False)) + with pytest.raises(ValueError, match="multi-turn"): + MCPWrappedPromptChatTarget( + inner_target=inner, + mcp_servers=[MCPServerConfig(name="s", transport=MCPTransport.STDIO, command="python")], + ) + + def test_requires_at_least_one_server(self): + inner = _inner_target(["x"]) + with pytest.raises(ValueError, match="at least one MCP server"): + MCPWrappedPromptChatTarget(inner_target=inner, mcp_servers=[]) + + def test_invalid_server_connection_params_rejected(self): + inner = _inner_target(["x"]) + with pytest.raises(ValueError, match="stdio transport requires"): + MCPWrappedPromptChatTarget( + inner_target=inner, + mcp_servers=[MCPServerConfig(name="s", transport=MCPTransport.STDIO)], + ) + + def test_inner_capabilities_are_advertised(self): + inner = _inner_target(["x"]) + wrapper = _wrapped_target(inner) + assert wrapper.capabilities.supports_system_prompt is True + assert wrapper.capabilities.supports_multi_turn is True + + +class TestToolCallParsing: + def test_parses_last_valid_call(self): + text = 'thinking...\nTOOL_CALL: {"tool": "add", "arguments": {"a": 2, "b": 3}}' + assert MCPWrappedPromptChatTarget._parse_tool_call(text) == {"tool": "add", "arguments": {"a": 2, "b": 3}} + + def test_returns_none_without_call(self): + assert MCPWrappedPromptChatTarget._parse_tool_call("final answer: 5") is None + + def test_malformed_json_is_rejected(self): + assert MCPWrappedPromptChatTarget._parse_tool_call('TOOL_CALL: {"tool": ') is None + + def test_missing_arguments_is_rejected(self): + assert MCPWrappedPromptChatTarget._parse_tool_call('TOOL_CALL: {"tool": "add"}') is None + + +@pytest.mark.usefixtures("patch_central_database") +class TestAgenticLoop: + async def test_final_answer_without_tool_call(self): + inner = _inner_target(["The answer is 5."]) + wrapper = _wrapped_target(inner) + + response = await wrapper._send_prompt_to_target_async(normalized_conversation=_conversation()) + + assert response[-1].message_pieces[0].original_value == "The answer is 5." + assert inner._send_prompt_to_target_async.await_count == 1 + + async def test_system_context_injected_once_with_catalog(self): + inner = _inner_target(["final"]) + wrapper = _wrapped_target(inner) + + await wrapper._send_prompt_to_target_async(normalized_conversation=_conversation()) + + first_conversation = inner._send_prompt_to_target_async.await_args_list[0].kwargs["normalized_conversation"] + system_pieces = [piece for m in first_conversation for piece in m.message_pieces if piece.role == "system"] + assert len(system_pieces) == 1 + assert "calc/add" in system_pieces[0].original_value + assert "TOOL_CALL:" in system_pieces[0].original_value + # The original user message is preserved after the injected system context. + assert first_conversation[1].message_pieces[0].original_value == "What is 2 + 3?" + + async def test_tool_call_executes_and_result_fed_back(self): + inner = _inner_target( + [ + 'TOOL_CALL: {"tool": "add", "arguments": {"a": 2, "b": 3}}', + "The answer is 5.", + ] + ) + session = _fake_session( + tools=[MCPTool(name="add", description="Add two numbers.", input_schema={"type": "object"})], + call_results=[MCPToolResult(text="5", is_error=False)], + ) + wrapper = _wrapped_target(inner, sessions={"calc": session}) + + response = await wrapper._send_prompt_to_target_async(normalized_conversation=_conversation()) + + assert response[-1].message_pieces[0].original_value == "The answer is 5." + assert inner._send_prompt_to_target_async.await_count == 2 + assert session.call_tool.await_count == 1 + assert session.call_tool.await_args.kwargs == {"tool_name": "add", "arguments": {"a": 2, "b": 3}} + second_conversation = inner._send_prompt_to_target_async.await_args_list[1].kwargs["normalized_conversation"] + tool_result_texts = [ + piece.original_value + for m in second_conversation + for piece in m.message_pieces + if piece.original_value.startswith("TOOL_RESULT") + ] + assert tool_result_texts == ["TOOL_RESULT[calc/add]: 5"] + + async def test_unknown_tool_is_denied_not_executed(self): + inner = _inner_target( + [ + 'TOOL_CALL: {"tool": "does_not_exist", "arguments": {}}', + "Final answer.", + ] + ) + wrapper = _wrapped_target(inner) + + response = await wrapper._send_prompt_to_target_async(normalized_conversation=_conversation()) + + assert response[-1].message_pieces[0].original_value == "Final answer." + second_conversation = inner._send_prompt_to_target_async.await_args_list[1].kwargs["normalized_conversation"] + denial = [ + piece for m in second_conversation for piece in m.message_pieces if "Unknown tool" in piece.original_value + ] + assert len(denial) == 1 + + async def test_disallowed_tool_is_denied(self): + inner = _inner_target( + [ + 'TOOL_CALL: {"tool": "echo", "arguments": {"text": "x"}}', + "Final answer.", + ] + ) + wrapper = _wrapped_target(inner, server_allowed_tools=["add"]) + + response = await wrapper._send_prompt_to_target_async(normalized_conversation=_conversation()) + + assert response[-1].message_pieces[0].original_value == "Final answer." + second_conversation = inner._send_prompt_to_target_async.await_args_list[1].kwargs["normalized_conversation"] + denial = [ + piece + for m in second_conversation + for piece in m.message_pieces + if "not allowed by policy" in piece.original_value + ] + assert len(denial) == 1 + + async def test_round_cap_returns_last_response(self): + inner = _inner_target(['TOOL_CALL: {"tool": "add", "arguments": {"a": 1, "b": 2}}'] * 3) + wrapper = _wrapped_target(inner, max_tool_call_rounds=2) + + response = await wrapper._send_prompt_to_target_async(normalized_conversation=_conversation()) + + assert inner._send_prompt_to_target_async.await_count == 2 + assert "TOOL_CALL" in response[-1].message_pieces[0].original_value + + async def test_cleanup_closes_sessions(self): + inner = _inner_target(["final"]) + wrapper = _wrapped_target(inner) + + await wrapper._get_or_connect_sessions_async() + await wrapper.cleanup_target_async() + + sessions = await wrapper._get_or_connect_sessions_async() + assert sessions # reconnected lazily after cleanup + await wrapper.cleanup_target_async() diff --git a/uv.lock b/uv.lock index f4faac5362..d86855e0c8 100644 --- a/uv.lock +++ b/uv.lock @@ -2243,6 +2243,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, +] + [[package]] name = "httptools" version = "0.7.1" @@ -2306,6 +2319,32 @@ http2 = [ { name = "h2" }, ] +[[package]] +name = "httpx2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + [[package]] name = "huggingface-hub" version = "1.13.0" @@ -3419,6 +3458,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, ] +[[package]] +name = "mcp" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/6e/21fb8e5d579dbe21d96ea4d5034200d46d8bdf2261053b5bd041f3c2f612/mcp-2.1.1.tar.gz", hash = "sha256:50b7ba1ebbe117008ea7bdd288234043e69c20b403d6851d19661e6d431a75ef", size = 3984589, upload-time = "2026-08-25T16:14:02.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/af/8644cc5fa26a59afd2df2e98eeb19e72926887fa4b7441aba4ff661140db/mcp-2.1.1-py3-none-any.whl", hash = "sha256:1c6c31c5d6471c58db76af3af8af67f46d11d01f0a59077d0a308cbdb3d3e915", size = 357912, upload-time = "2026-08-25T16:13:59.024Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/dd/1c4417dc0b722c23a1669032d5f044e41170fe5d4773b488a50fcce98c32/mcp_types-2.1.1.tar.gz", hash = "sha256:77dcbe48fba73cca71a673f2646a5f037a017b7a0a07ac89cec1113028890eda", size = 66674, upload-time = "2026-08-25T16:14:03.861Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/d0/242e63c510f4a17381f55b1549a3f94f5687a0595984febd2b6f87a687a0/mcp_types-2.1.1-py3-none-any.whl", hash = "sha256:26f9f7f03f2a5730717a5b98e2ab7eb640ac352d05a00cdc725c311864778295", size = 69656, upload-time = "2026-08-25T16:14:00.667Z" }, +] + [[package]] name = "mdit-py-plugins" version = "0.5.0" @@ -5351,6 +5428,7 @@ all = [ { name = "ipykernel" }, { name = "jupyter" }, { name = "litellm" }, + { name = "mcp" }, { name = "ollama" }, { name = "opencv-python" }, { name = "playwright" }, @@ -5376,6 +5454,9 @@ huggingface = [ litellm = [ { name = "litellm" }, ] +mcp = [ + { name = "mcp" }, +] opencv = [ { name = "opencv-python" }, ] @@ -5448,6 +5529,8 @@ requires-dist = [ { name = "jupyter", marker = "extra == 'all'", specifier = ">=1.1.1" }, { name = "litellm", marker = "extra == 'all'", specifier = ">=1.84.0,<1.99.0" }, { name = "litellm", marker = "extra == 'litellm'", specifier = ">=1.84.0,<1.99.0" }, + { name = "mcp", marker = "extra == 'all'", specifier = ">=1.10.0" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.10.0" }, { name = "numpy", marker = "python_full_version < '3.14'", specifier = ">=1.26.0" }, { name = "numpy", marker = "python_full_version >= '3.14'", specifier = ">=2.3.0" }, { name = "ollama", marker = "extra == 'all'", specifier = ">=0.5.1" }, @@ -5489,7 +5572,7 @@ requires-dist = [ { name = "uvicorn", extras = ["standard"], specifier = ">=0.32.0" }, { name = "websockets", specifier = ">=14.0" }, ] -provides-extras = ["huggingface", "gcg", "playwright", "fairness-bias", "opencv", "speech", "litellm", "all"] +provides-extras = ["huggingface", "gcg", "playwright", "fairness-bias", "opencv", "speech", "mcp", "litellm", "all"] [package.metadata.requires-dev] dev = [ @@ -5644,6 +5727,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "pytz" version = "2025.2" @@ -5653,6 +5745,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e", size = 6361387, upload-time = "2026-06-04T07:49:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db", size = 6926780, upload-time = "2026-06-04T07:49:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd", size = 4307203, upload-time = "2026-06-04T07:49:18.993Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, +] + [[package]] name = "pywinpty" version = "3.0.2" @@ -6710,6 +6824,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/ae/57d1d7af907e20c077e113e0e4976f87b82c0a415403d99284a262229dd0/srsly-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:d822083fe26ec6728bd8c273ac121fc4ab3864a0fdf0cf0ff3efb188fcd209ed", size = 650229, upload-time = "2026-03-23T11:56:46.148Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/54/6767bb789b2f2fed6e0f953df949cd39dc263a384c1b65a95232598621d6/sse_starlette-3.4.11.tar.gz", hash = "sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade", size = 34972, upload-time = "2026-09-05T12:11:04.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/6a/2ba3ed4a69babf3afdddf7d8314a48d87562c0a442206bbc2a1b50d5efc0/sse_starlette-3.4.11-py3-none-any.whl", hash = "sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453", size = 17122, upload-time = "2026-09-05T12:11:03.195Z" }, +] + [[package]] name = "stack-data" version = "0.6.3" @@ -7151,6 +7278,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f0/ac/229b7d4589d2e5937310e72c6d46e89599d16a4a12b479ffa1499fee8eb8/triton-3.7.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10ba85fa2cca4a2fbdeb36bf1cb082f2c252bda55bf9fccd74f65ec5bc647e68", size = 197824404, upload-time = "2026-06-17T19:53:42.772Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "ty" version = "0.0.75"