Files
Alberto Schiabel d4d306073c fix(sdk): save tool-only MCP updates and connection-management intent (#4512)
This PR:

- closes
[PLEN-3890](https://linear.app/composio/issue/PLEN-3890/fix-mcp-lifecycle-update-configuration-defects)

- fixes `composio.mcp.update()` dropping parts of the requested
configuration: tool-only updates (`allowedTools` without `toolkits`)
sent no tools field at all, and updates with toolkits sent the
create-time `custom_tools` alias, which the update endpoint never reads
- inverts `manuallyManageConnections` into `managed_auth_via_composio`
on update, so `manuallyManageConnections: true` no longer stores
"Composio manages auth" (create and generate already inverted it)
- builds the update body sparsely: each provided field is sent
independently, omitted fields are left out entirely instead of being
sent as `undefined`
- keeps the auth config of a `{ toolkit, authConfigId }` toolkit entry
in `create()` and `update()` instead of discarding it via an `else if`
(Python already extracted both)
- sends `allowed_tools` instead of the deprecated `custom_tools` alias
from `create()` too, in both SDKs; Python previously raised `TypeError`
(masked as `ValidationError`) for the removed `custom_tools` kwarg
against the pinned `composio-client` 1.43.0
- adds regression coverage for both SDKs and a changeset for
`@composio/core`

## Context

Found by the September SDK + Composio client hackathon (Area 5, MCP
lifecycle), where the two worst frictions were "successful SDK updates
that do not save the requested configuration" and reversed manual
connection management. The hackathon's third Area 5 finding — PATCH
accepting nonexistent tool slugs — is an Apollo-side defect, fixed
separately in ComposioHQ/platform#13015. `MCP.ts` and the Python
`mcp.py` are identical between `next` and `main`, so this merges cleanly
to the beta channel afterwards.
2026-09-21 21:37:22 +04:00

504 lines
19 KiB
Python

"""
MCP (Model Control Protocol) module for Composio SDK.
This module provides MCP server operations
"""
from __future__ import annotations
import typing as t
import typing_extensions as te
from composio_client.types.mcp.custom_create_response import CustomCreateResponse
from composio.client import HttpClient
from composio.core.models.base import Resource
from composio.exceptions import ValidationError
from composio.utils.pydantic import none_to_omit
# Data Types (matching TypeScript specification)
class ConfigToolkit(te.TypedDict, total=False):
"""Toolkit configuration for an MCP server.
Defined locally rather than imported from ``composio_client``: the generated
client removed ``types.tool_router_create_session_params.ConfigToolkit`` in
1.41.0 when the tool-router session schema was redesigned. The MCP surface
only needs the toolkit slug and optional auth config, so we keep our own
minimal shape and stay decoupled from tool-router regen churn.
"""
toolkit: te.Required[str]
"""Toolkit identifier (e.g., gmail, slack, github)."""
auth_config_id: str
"""Specific auth configuration ID for this toolkit."""
class MCPCreateResponse(CustomCreateResponse):
"""MCP Create Response with generate method (extends CustomCreateResponse)."""
generate: t.Callable[[str, t.Optional[bool]], "MCPServerInstance"]
class MCPServerInstance(te.TypedDict):
"""MCP Server Instance data structure (matching TypeScript implementation)."""
id: str
name: str
type: str
url: str # User-specific connection URL
user_id: str # Associated user ID
allowed_tools: t.List[str] # Available tools for the user
auth_configs: t.List[str] # Associated auth configurations
class MCPItem(te.TypedDict):
"""Complete MCP server information."""
id: str # Unique server identifier
name: str # Human-readable server name
allowed_tools: t.List[str] # Array of enabled tool identifiers
auth_config_ids: t.List[str] # Array of auth configuration IDs
toolkits: t.List[str] # Array of toolkit names
commands: t.Dict[str, str] # Setup commands for different clients
mcp_url: str # Server connection URL
toolkit_icons: t.Dict[str, str] # Map of toolkit icons
server_instance_count: int # Number of active instances
created_at: te.NotRequired[t.Optional[str]]
updated_at: te.NotRequired[t.Optional[str]]
class MCPListResponse(te.TypedDict):
"""Paginated list response."""
items: t.List[t.Any] # Array of MCP server objects (raw API response)
current_page: int # Current page number
total_pages: int # Total number of pages
def _add_generate_method(
response: CustomCreateResponse, mcp_instance: "MCP"
) -> MCPCreateResponse:
"""Add generate method to CustomCreateResponse object."""
def generate(
user_id: str, manually_manage_connections: t.Optional[bool] = None
) -> MCPServerInstance:
"""
Generate server instance for this MCP configuration.
Matches TypeScript server.generate(userId) method.
:param user_id: External user ID from your database
:param manually_manage_connections: Whether to manually manage connections (optional)
:return: MCP server instance
"""
return mcp_instance.generate(user_id, response.id, manually_manage_connections)
# Add the generate method to the response object and cast to MCPCreateResponse
response.generate = generate # type: ignore
return t.cast(MCPCreateResponse, response)
class MCP(Resource):
"""
MCP (Model Control Protocol) class.
Provides enhanced MCP server operations
This matches the TypeScript ExperimentalMCP class functionality.
.. deprecated::
Use a session's MCP endpoint instead:
``composio.create(user_id, mcp=True)`` returns a session that exposes
``session.mcp.url`` / ``session.mcp.headers``. MCP is now opt-in per
session; this standalone ``composio.mcp`` server-management API is kept
only for backwards compatibility. Prefer the session MCP endpoint; do
not generate new code against ``composio.mcp``.
See https://docs.composio.dev/docs/sessions-via-mcp
"""
def __init__(self, client: HttpClient):
"""
Initialize MCP instance.
:param client: HTTP client for API calls
"""
super().__init__(client)
def create(
self,
name: str,
toolkits: t.List[t.Union[ConfigToolkit, str]],
manually_manage_connections: bool = False,
allowed_tools: t.Optional[t.List[str]] = None,
) -> MCPCreateResponse:
"""
Create a new MCP server configuration with specified toolkits and authentication settings.
:param name: Unique name for the MCP configuration
:param toolkits: List of toolkit configurations. Can be either:
- MCPToolkitConfig objects with detailed configuration
- Strings representing toolkit names (for simple cases)
:param manually_manage_connections: Whether to manually manage account connections (default: False)
:param allowed_tools: List of specific tools to enable across all toolkits (default: None for all tools)
:return: Created server details with generate method
Examples:
>>> # Using toolkit configuration objects with auth
>>> server = composio.experimental.mcp.create(
... 'personal-mcp-server',
... toolkits=[
... {
... 'toolkit': 'github',
... 'auth_config_id': 'ac_xyz',
... },
... {
... 'toolkit': 'slack',
... 'auth_config_id': 'ac_abc',
... },
... ],
... allowed_tools=['GITHUB_CREATE_ISSUE', 'GITHUB_LIST_REPOS', 'SLACK_SEND_MESSAGE'],
... manually_manage_connections=False
... )
>>>
>>> # Using simple toolkit names (most common usage)
>>> server = composio.experimental.mcp.create(
... 'simple-mcp-server',
... toolkits=['composio_search', 'text_to_pdf'],
... allowed_tools=['COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH', 'TEXT_TO_PDF_CONVERT_TEXT_TO_PDF']
... )
>>>
>>> # Using all tools from toolkits (default behavior)
>>> server = composio.experimental.mcp.create(
... 'all-tools-server',
... toolkits=['composio_search', 'text_to_pdf']
... # allowed_tools=None means all tools from these toolkits
... )
>>>
>>> # Get server instance for a user
>>> mcp = server.generate('user_12345')
"""
if not toolkits:
raise ValidationError("At least one toolkit configuration is required")
try:
# Normalize toolkits to MCPToolkitConfig format
normalized_toolkit_configs: t.List[ConfigToolkit] = []
for toolkit in toolkits:
if isinstance(toolkit, str):
# Convert string to MCPToolkitConfig
normalized_toolkit_configs.append(ConfigToolkit(toolkit=toolkit))
else:
# Already MCPToolkitConfig, use as-is
normalized_toolkit_configs.append(toolkit)
# Extract toolkits and prepare for API call
toolkit_configs = normalized_toolkit_configs
# Get unique toolkits and auth config IDs
toolkit_names = []
auth_config_ids = []
for toolkit_config in toolkit_configs:
if (
"toolkit" in toolkit_config
and toolkit_config["toolkit"] not in toolkit_names
):
toolkit_names.append(toolkit_config["toolkit"])
if (
"auth_config_id" in toolkit_config
and toolkit_config["auth_config_id"] not in auth_config_ids
):
auth_config_ids.append(toolkit_config["auth_config_id"])
# Use the allowed_tools parameter instead of individual toolkit configs
# (`custom_tools` is the deprecated create-time alias)
allowed_tools_param = none_to_omit(allowed_tools)
# Use the custom MCP create endpoint
response = self._client.mcp.custom.create(
name=name,
toolkits=toolkit_names,
auth_config_ids=auth_config_ids,
allowed_tools=allowed_tools_param,
managed_auth_via_composio=not manually_manage_connections,
)
# Return response with generate method (matching TypeScript behavior)
return _add_generate_method(response, self)
except Exception as e:
raise ValidationError("Failed to create MCP server") from e
def list(
self,
page_no: t.Optional[int] = None,
limit: t.Optional[int] = None,
toolkits: t.Optional[str] = None,
auth_config_ids: t.Optional[str] = None,
name: t.Optional[str] = None,
order_by: t.Optional[te.Literal["created_at", "updated_at"]] = None,
order_direction: t.Optional[te.Literal["asc", "desc"]] = None,
) -> MCPListResponse:
"""
List MCP servers with optional filtering and pagination.
:param page_no: Page number for pagination (default: 1)
:param limit: Maximum items per page (default: 10)
:param toolkits: Filter by toolkit name (single string)
:param auth_config_ids: Filter by auth configuration ID (single string)
:param name: Filter by server name (partial match)
:param order_by: Order by field ('created_at' or 'updated_at')
:param order_direction: Order direction ('asc' or 'desc')
:return: Paginated list of MCP servers
Examples:
>>> # List all servers
>>> all_servers = composio.experimental.mcp.list()
>>>
>>> # List with pagination
>>> paged_servers = composio.experimental.mcp.list(page_no=2, limit=5)
>>>
>>> # Filter by toolkit
>>> github_servers = composio.experimental.mcp.list(toolkits='github', name='personal')
"""
try:
# Use the MCP list endpoint with filters
response = self._client.mcp.list(
page_no=page_no,
limit=limit,
toolkits=none_to_omit(toolkits),
auth_config_ids=none_to_omit(auth_config_ids),
name=none_to_omit(name),
order_by=none_to_omit(order_by),
order_direction=none_to_omit(order_direction),
)
items = (
response.items if hasattr(response, "items") and response.items else []
)
return MCPListResponse(
items=items,
current_page=getattr(response, "current_page", page_no or 1),
total_pages=getattr(response, "total_pages", 1),
)
except Exception as e:
raise ValidationError("Failed to list MCP servers") from e
def get(self, server_id: str):
"""
Retrieve detailed information about a specific MCP server/config.
:param server_id: The unique identifier of the MCP server/config
:return: Complete MCP server information
Example:
>>> server = composio.experimental.mcp.get('mcp_12345')
>>>
>>> print(server['name']) # "My Personal MCP Server"
>>> print(server['allowed_tools']) # ["GITHUB_CREATE_ISSUE", "SLACK_SEND_MESSAGE"]
>>> print(server['toolkits']) # ["github", "slack"]
>>> print(server['server_instance_count']) # 3
"""
try:
response = self._client.mcp.retrieve(server_id)
return response
except Exception as e:
raise ValidationError(f"Failed to retrieve MCP server {server_id}") from e
def update(
self,
server_id: str,
name: t.Optional[str] = None,
toolkits: t.Optional[t.List[t.Union[ConfigToolkit, str]]] = None,
manually_manage_connections: t.Optional[bool] = None,
allowed_tools: t.Optional[t.List[str]] = None,
):
"""
Update an existing MCP server configuration.
:param server_id: The unique identifier of the MCP server to update
:param name: Optional new name for the MCP server
:param toolkits: Optional list of toolkit configurations (strings or objects)
:param manually_manage_connections: Optional flag for connection management
:param allowed_tools: Optional list of specific tools to enable across all toolkits
:return: Updated MCP server information
Examples:
>>> # Update server name only
>>> updated_server = composio.experimental.mcp.update(
... 'mcp_12345',
... name='My Updated MCP Server'
... )
>>>
>>> # Update toolkits and tools
>>> server_with_new_tools = composio.experimental.mcp.update(
... 'mcp_12345',
... toolkits=['github', 'slack'],
... allowed_tools=['GITHUB_CREATE_ISSUE', 'SLACK_SEND_MESSAGE']
... )
>>>
>>> # Update with auth configs
>>> server_with_auth = composio.experimental.mcp.update(
... 'mcp_12345',
... toolkits=[
... {'toolkit': 'github', 'auth_config_id': 'auth_abc123'},
... {'toolkit': 'slack', 'auth_config_id': 'auth_def456'}
... ],
... allowed_tools=['GITHUB_CREATE_ISSUE', 'SLACK_SEND_MESSAGE'],
... manually_manage_connections=False
... )
"""
try:
update_params: t.Dict[str, t.Any] = {}
if name is not None:
update_params["name"] = name
if toolkits is not None:
# Normalize toolkits to ConfigToolkit format (same as create method)
normalized_toolkit_configs: t.List[ConfigToolkit] = []
for toolkit in toolkits:
if isinstance(toolkit, str):
# Convert string to ConfigToolkit
normalized_toolkit_configs.append(
ConfigToolkit(toolkit=toolkit)
)
else:
# Already ConfigToolkit, use as-is
normalized_toolkit_configs.append(toolkit)
# Extract toolkits and prepare for API call
toolkit_names = []
auth_config_ids = []
for toolkit_config in normalized_toolkit_configs:
if (
"toolkit" in toolkit_config
and toolkit_config["toolkit"] not in toolkit_names
):
toolkit_names.append(toolkit_config["toolkit"])
if (
"auth_config_id" in toolkit_config
and toolkit_config["auth_config_id"] not in auth_config_ids
):
auth_config_ids.append(toolkit_config["auth_config_id"])
update_params["toolkits"] = toolkit_names
update_params["auth_config_ids"] = auth_config_ids
if allowed_tools is not None:
# The update endpoint only reads `allowed_tools`; the create-time
# `custom_tools` alias is deprecated and ignored on PATCH.
update_params["allowed_tools"] = allowed_tools
if manually_manage_connections is not None:
update_params[
"managed_auth_via_composio"
] = not manually_manage_connections
# Use the MCP update endpoint
response = self._client.mcp.update(server_id, **update_params)
return response
except Exception as e:
raise ValidationError(f"Failed to update MCP server {server_id}") from e
def delete(self, server_id: str) -> t.Dict[str, t.Any]:
"""
Permanently delete an MCP server configuration.
:param server_id: The unique identifier of the MCP server to delete
:return: Deletion result
Example:
>>> # Delete a server
>>> result = composio.experimental.mcp.delete('mcp_12345')
>>>
>>> if result['deleted']:
... print(f"Server {result['id']} has been successfully deleted")
>>> else:
... print(f"Failed to delete server {result['id']}")
"""
try:
response = self._client.mcp.delete(server_id)
return {
"id": server_id,
"deleted": getattr(response, "deleted", True),
}
except Exception as e:
raise ValidationError(f"Failed to delete MCP server {server_id}") from e
def generate(
self,
user_id: str,
mcp_config_id: str,
manually_manage_connections: t.Optional[bool] = None,
) -> MCPServerInstance:
"""
Get server URLs for an existing MCP server.
This matches the TypeScript implementation exactly.
:param user_id: External user ID from your database
:param mcp_config_id: MCP configuration ID
:param manually_manage_connections: Whether to manually manage connections (optional)
:return: MCP server instance
Example:
>>> mcp = composio.experimental.mcp.generate(
... 'user_12345',
... 'mcp_67890',
... manually_manage_connections=False
... )
>>>
>>> print(mcp['url']) # Server URL for the user
>>> print(mcp['allowed_tools']) # Available tools
"""
try:
# Get server details first (matching TS: this.client.mcp.retrieve)
server_details = self._client.mcp.retrieve(mcp_config_id)
# Generate server URLs (matching TS: this.client.mcp.generate.url)
url_response = self._client.mcp.generate.url(
mcp_server_id=mcp_config_id,
user_ids=[user_id],
managed_auth_by_composio=False if manually_manage_connections else True,
)
# Get the generated URL (matching TS: urlResponse.user_ids_url[0])
if hasattr(url_response, "user_ids_url") and url_response.user_ids_url:
server_url = url_response.user_ids_url[0]
else:
raise ValidationError("No server URL generated")
# Create structured server instance (matching TS MCPServerInstance)
server_instance: MCPServerInstance = {
"id": server_details.id,
"name": server_details.name,
"type": "streamable_http",
"url": server_url,
"user_id": user_id,
"allowed_tools": getattr(server_details, "allowed_tools", []),
"auth_configs": getattr(server_details, "auth_config_ids", []),
}
return server_instance
except Exception as e:
if isinstance(e, ValidationError):
raise
raise ValidationError("Failed to parse MCP server instance") from e