chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:34 +08:00
commit 0549b088a4
2405 changed files with 810255 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
from .auth_configs import AuthConfigs
from .connected_accounts import ConnectedAccounts
from .custom_tool import ExperimentalToolkit
from .custom_tool_types import (
CustomTool,
RegisteredCustomTool,
RegisteredCustomToolkit,
SessionContext,
)
from .experimental import ExperimentalAPI
from .mcp import MCP
from .tool_router import ToolRouter
from .tool_router_constants import SESSION_PRESET_DIRECT_TOOLS
from .tool_router_session import ToolRouterSession
from .tool_router_session_delete import ToolRouterSessionDeleteResponse
from .tool_router_session_files import RemoteFile, ToolRouterSessionFilesMount
from .toolkits import Toolkits
from .tools import Tools
from .triggers import Triggers
from .webhook_events import (
ConnectionExpiredEvent,
ConnectionState,
ConnectionStatusEnum,
SingleConnectedAccountDetailedResponse,
WebhookConnectionMetadata,
WebhookEvent,
WebhookEventType,
is_connection_expired_event,
)
__all__ = [
"AuthConfigs",
"ConnectedAccounts",
"ConnectionExpiredEvent",
"ConnectionState",
"ConnectionStatusEnum",
"CustomTool",
"ExperimentalAPI",
"ExperimentalToolkit",
"MCP",
"RegisteredCustomTool",
"RegisteredCustomToolkit",
"RemoteFile",
"SessionContext",
"SESSION_PRESET_DIRECT_TOOLS",
"SingleConnectedAccountDetailedResponse",
"ToolRouter",
"ToolRouterSession",
"ToolRouterSessionDeleteResponse",
"ToolRouterSessionFilesMount",
"Toolkits",
"Tools",
"Triggers",
"WebhookConnectionMetadata",
"WebhookEvent",
"WebhookEventType",
"is_connection_expired_event",
]
File diff suppressed because it is too large Load Diff
+714
View File
@@ -0,0 +1,714 @@
from __future__ import annotations
import functools
import typing as t
import typing_extensions as te
if t.TYPE_CHECKING:
from .tools import Tool, ToolExecutionResponse, tool_execute_params
# TODO: Maybe use `te.Unpack` in tools.execute?
class ToolExecuteParams(te.TypedDict):
allow_tracing: te.NotRequired[t.Optional[bool]]
arguments: t.Dict[str, t.Optional[t.Any]]
connected_account_id: te.NotRequired[str]
custom_auth_params: te.NotRequired["tool_execute_params.CustomAuthParams"]
custom_connection_data: te.NotRequired["tool_execute_params.CustomConnectionData"]
entity_id: te.NotRequired[str]
text: te.NotRequired[str]
user_id: te.NotRequired[str]
version: te.NotRequired[str]
dangerously_skip_version_check: te.NotRequired[t.Optional[bool]]
ModifierInOut = t.Union["ToolExecuteParams", "ToolExecutionResponse", "Tool"]
class BeforeExecute(t.Protocol):
"""
A modifier that is called before the tool is executed.
"""
def __call__(
self,
tool: str,
toolkit: str,
params: ToolExecuteParams,
) -> ToolExecuteParams: ...
class AfterExecute(t.Protocol):
"""
A modifier that is called after the tool is executed.
"""
def __call__(
self,
tool: str,
toolkit: str,
response: ToolExecutionResponse,
) -> ToolExecutionResponse: ...
class SchemaModifier(t.Protocol):
"""
A modifier that is called to modify the schema of the tool.
"""
def __call__(
self,
tool: str,
toolkit: str,
schema: "Tool",
) -> "Tool": ...
class BeforeFileUploadCallable(t.Protocol):
"""Legacy positional form of the ``before_file_upload`` hook.
``(path, tool, toolkit) -> str | bool``. Still supported for back-compat,
but new code should use :class:`BeforeFileUploadContextCallable` so it can
discriminate local paths from URLs via ``context["source"]``.
"""
def __call__(
self,
path: str,
tool: str,
toolkit: str,
) -> t.Union[str, bool]: ...
class BeforeFileUploadContext(te.TypedDict):
"""Context passed to the new-form ``before_file_upload`` hook.
- ``path``: the local filesystem path for ``source="path"``, or the URL
string for ``source="url"``.
- ``source``: discriminator — ``"path"`` for local paths, ``"url"`` for
``http(s)://...`` inputs. Mirrors the TypeScript SDK's ``source`` field
(TS additionally emits ``"file"`` for ``File`` objects; Python has no
equivalent runtime type).
- ``tool`` / ``toolkit``: slugs of the tool being executed.
"""
path: str
source: te.Literal["path", "url"]
tool: str
toolkit: str
class BeforeFileUploadContextCallable(t.Protocol):
"""Preferred form of the ``before_file_upload`` hook.
Takes a single :class:`BeforeFileUploadContext` argument and returns either
a new path/URL string, or ``False`` to abort the upload.
"""
def __call__(
self,
context: BeforeFileUploadContext,
) -> t.Union[str, bool]: ...
BeforeFileUploadLike = t.Union[
BeforeFileUploadCallable,
BeforeFileUploadContextCallable,
]
"""Either form of ``before_file_upload``. Adapted internally."""
def _count_positional_params(fn: t.Callable) -> int:
"""Return the number of positional (or positional-or-keyword) params, or
-1 if the signature can't be introspected (e.g. builtins)."""
import inspect
try:
sig = inspect.signature(fn)
except (TypeError, ValueError):
return -1
return sum(
1
for p in sig.parameters.values()
if p.kind
in (
inspect.Parameter.POSITIONAL_ONLY,
inspect.Parameter.POSITIONAL_OR_KEYWORD,
)
)
def _adapt_before_file_upload(
hook: BeforeFileUploadLike,
) -> BeforeFileUploadContextCallable:
"""Normalise a user-supplied hook to the context-object form.
A hook declared with exactly 3 positional parameters is treated as the
legacy ``(path, tool, toolkit)`` form; anything else (typically a single
positional ``context`` parameter) is treated as the new form.
"""
if _count_positional_params(hook) == 3:
legacy = t.cast(BeforeFileUploadCallable, hook)
def wrap(context: BeforeFileUploadContext) -> t.Union[str, bool]:
return legacy(context["path"], context["tool"], context["toolkit"])
return wrap
return t.cast(BeforeFileUploadContextCallable, hook)
ModifierSlug: t.TypeAlias = str
AfterExecuteModifierL: t.TypeAlias = t.Literal["after_execute"]
BeforeExecuteModifierL: t.TypeAlias = t.Literal["before_execute"]
SchemaModifierL: t.TypeAlias = t.Literal["schema"]
BeforeFileUploadModifierL: t.TypeAlias = t.Literal["before_file_upload"]
class Modifier:
def __init__(
self,
modifier: t.Optional[
AfterExecute
| BeforeExecute
| SchemaModifier
| BeforeExecuteMeta
| AfterExecuteMeta
| BeforeFileUploadCallable
| BeforeFileUploadContextCallable
],
type_: (
AfterExecuteModifierL
| BeforeExecuteModifierL
| SchemaModifierL
| AfterExecuteMetaModifierL
| BeforeExecuteMetaModifierL
| BeforeFileUploadModifierL
),
tools: t.List[str],
toolkits: t.List[str],
) -> None:
self.modifier = modifier
self.tools = tools
self.type = type_
self.toolkits = toolkits
def apply(
self,
toolkit: str,
tool: str,
data: ModifierInOut,
modifer_type: str,
) -> ModifierInOut:
if self.modifier is None:
raise ValueError("Modifier is not provided")
# If no tools or toolkits are provided, apply the modifier to all tools
if (
self.type == modifer_type
and len(self.tools) == 0
and len(self.toolkits) == 0
):
return self.modifier(tool, toolkit, data) # type: ignore
# If the modifier is not the same type, or the slug is not in the tools or
# toolkits, return the data as is
if (
self.type != modifer_type
or tool not in self.tools
and toolkit not in self.toolkits
):
return data
# Apply the modifier to the data
return self.modifier(tool, toolkit, data) # type: ignore
@t.overload
def after_execute(
modifier: t.Optional[AfterExecute],
) -> Modifier: ...
@t.overload
def after_execute(
*,
tools: t.Optional[t.List[str]] = None,
toolkits: t.Optional[t.List[str]] = None,
) -> t.Callable[[AfterExecute], Modifier]: ...
def after_execute(
modifier: t.Optional[AfterExecute] = None,
*,
tools: t.Optional[t.List[str]] = None,
toolkits: t.Optional[t.List[str]] = None,
) -> Modifier | t.Callable[[AfterExecute], Modifier]:
if modifier is not None:
return Modifier(
modifier=modifier,
type_="after_execute",
tools=tools or [],
toolkits=toolkits or [],
)
if tools is not None or toolkits is not None:
return t.cast(
t.Callable[[AfterExecute], Modifier],
functools.partial(
after_execute,
tools=tools or [],
toolkits=toolkits or [],
),
)
raise ValueError("Either tools or toolkits must be provided")
@t.overload
def before_execute(modifier: t.Optional[BeforeExecute]) -> Modifier: ...
@t.overload
def before_execute(
*,
tools: t.Optional[t.List[str]] = None,
toolkits: t.Optional[t.List[str]] = None,
) -> t.Callable[[BeforeExecute], Modifier]: ...
def before_execute(
modifier: t.Optional[BeforeExecute] = None,
*,
tools: t.Optional[t.List[str]] = None,
toolkits: t.Optional[t.List[str]] = None,
) -> Modifier | t.Callable[[BeforeExecute], Modifier]:
if modifier is not None:
return Modifier(
modifier=modifier,
type_="before_execute",
tools=tools or [],
toolkits=toolkits or [],
)
if tools is not None or toolkits is not None:
return t.cast(
t.Callable[[BeforeExecute], Modifier],
functools.partial(
before_execute,
tools=tools or [],
toolkits=toolkits or [],
),
)
raise ValueError("Either tools or toolkits must be provided")
@t.overload
def before_file_upload(modifier: t.Optional[BeforeFileUploadLike]) -> Modifier: ...
@t.overload
def before_file_upload(
*,
tools: t.Optional[t.List[str]] = None,
toolkits: t.Optional[t.List[str]] = None,
) -> t.Callable[[BeforeFileUploadLike], Modifier]: ...
def before_file_upload(
modifier: t.Optional[BeforeFileUploadLike] = None,
*,
tools: t.Optional[t.List[str]] = None,
toolkits: t.Optional[t.List[str]] = None,
) -> Modifier | t.Callable[[BeforeFileUploadLike], Modifier]:
"""
Build a ``Modifier`` for the file-upload hook (same scoping pattern as
:func:`before_execute`).
Your callable may take **either**:
- a single ``context`` argument (:class:`BeforeFileUploadContext`) — the
preferred form, exposes ``context["source"]`` (``"path"`` or ``"url"``),
or
- three positional arguments ``(path, tool, toolkit)`` — legacy form, kept
for back-compat.
Return a new path/URL string to substitute, or ``False`` to abort the
upload (raises :class:`~composio.exceptions.FileUploadAbortedError`).
Pass the returned ``Modifier`` in ``modifiers=[...]`` on
:meth:`composio.core.models.tools.Tools.execute` or ``tools.get``. Multiple
such modifiers are composed in list order.
"""
if modifier is not None:
return Modifier(
modifier=modifier,
type_="before_file_upload",
tools=tools or [],
toolkits=toolkits or [],
)
if tools is not None or toolkits is not None:
return t.cast(
t.Callable[[BeforeFileUploadLike], Modifier],
functools.partial(
before_file_upload,
tools=tools or [],
toolkits=toolkits or [],
),
)
raise ValueError("Either tools or toolkits must be provided")
@t.overload
def schema_modifier(modifier: t.Optional[SchemaModifier]) -> Modifier: ...
@t.overload
def schema_modifier(
*,
tools: t.Optional[t.List[str]] = None,
toolkits: t.Optional[t.List[str]] = None,
) -> t.Callable[[SchemaModifier], Modifier]: ...
def schema_modifier(
modifier: t.Optional[SchemaModifier] = None,
*,
tools: t.Optional[t.List[str]] = None,
toolkits: t.Optional[t.List[str]] = None,
) -> Modifier | t.Callable[[SchemaModifier], Modifier]:
if modifier is not None:
return Modifier(
modifier=modifier,
type_="schema",
tools=tools or [],
toolkits=toolkits or [],
)
if tools is not None or toolkits is not None:
return t.cast(
t.Callable[[SchemaModifier], Modifier],
functools.partial(
schema_modifier,
tools=tools or [],
toolkits=toolkits or [],
),
)
raise ValueError("Either tools or toolkits must be provided")
@t.overload
def before_execute_meta(modifier: t.Optional[BeforeExecuteMeta]) -> Modifier: ...
@t.overload
def before_execute_meta(
*,
tools: t.Optional[t.List[str]] = None,
toolkits: t.Optional[t.List[str]] = None,
) -> t.Callable[[BeforeExecuteMeta], Modifier]: ...
def before_execute_meta(
modifier: t.Optional[BeforeExecuteMeta] = None,
*,
tools: t.Optional[t.List[str]] = None,
toolkits: t.Optional[t.List[str]] = None,
) -> Modifier | t.Callable[[BeforeExecuteMeta], Modifier]:
if modifier is not None:
return Modifier(
modifier=modifier,
type_="before_execute_meta",
tools=tools or [],
toolkits=toolkits or [],
)
if tools is not None or toolkits is not None:
return t.cast(
t.Callable[[BeforeExecuteMeta], Modifier],
functools.partial(
before_execute_meta,
tools=tools or [],
toolkits=toolkits or [],
),
)
raise ValueError("Either tools or toolkits must be provided")
@t.overload
def after_execute_meta(modifier: t.Optional[AfterExecuteMeta]) -> Modifier: ...
@t.overload
def after_execute_meta(
*,
tools: t.Optional[t.List[str]] = None,
toolkits: t.Optional[t.List[str]] = None,
) -> t.Callable[[AfterExecuteMeta], Modifier]: ...
def after_execute_meta(
modifier: t.Optional[AfterExecuteMeta] = None,
*,
tools: t.Optional[t.List[str]] = None,
toolkits: t.Optional[t.List[str]] = None,
) -> Modifier | t.Callable[[AfterExecuteMeta], Modifier]:
if modifier is not None:
return Modifier(
modifier=modifier,
type_="after_execute_meta",
tools=tools or [],
toolkits=toolkits or [],
)
if tools is not None or toolkits is not None:
return t.cast(
t.Callable[[AfterExecuteMeta], Modifier],
functools.partial(
after_execute_meta,
tools=tools or [],
toolkits=toolkits or [],
),
)
raise ValueError("Either tools or toolkits must be provided")
Modifiers = t.List[Modifier]
def merge_before_file_upload(
modifiers: t.Optional[Modifiers],
tool: str,
toolkit: str,
) -> t.Optional[BeforeFileUploadContextCallable]:
"""Compose ``before_file_upload``-type :class:`Modifier`\\ s for this *tool* / *toolkit*.
Scoping matches :class:`Modifier` (empty ``tools`` and ``toolkits`` = all tools).
Each user-supplied hook is adapted to the context form by
:func:`_adapt_before_file_upload`, so legacy 3-arg callables keep working
while new-form callables receive the full :class:`BeforeFileUploadContext`
(including ``source``).
"""
to_chain = [
m
for m in (modifiers or [])
if m.type == "before_file_upload" and m.modifier is not None
]
if not to_chain:
return None
def _applies(m: Modifier) -> bool:
if len(m.tools) == 0 and len(m.toolkits) == 0:
return True
return tool in m.tools or toolkit in m.toolkits
adapted_chain = [
(m, _adapt_before_file_upload(t.cast(BeforeFileUploadLike, m.modifier)))
for m in to_chain
]
def combined(context: BeforeFileUploadContext) -> t.Union[str, bool]:
p: str = context["path"]
for m, hook in adapted_chain:
if not _applies(m):
continue
# Preserve source/tool/toolkit while forwarding the (possibly
# rewritten) path to the next hook.
next_ctx: BeforeFileUploadContext = {
"path": p,
"source": context["source"],
"tool": context["tool"],
"toolkit": context["toolkit"],
}
out = hook(next_ctx)
if out is False:
return False
if isinstance(out, str):
p = out
return p
return combined
@t.overload
def apply_modifier_by_type(
modifiers: Modifiers,
toolkit: str,
tool: str,
*,
type: BeforeExecuteModifierL,
request: ToolExecuteParams,
) -> ToolExecuteParams: ...
@t.overload
def apply_modifier_by_type(
modifiers: Modifiers,
toolkit: str,
tool: str,
*,
type: AfterExecuteModifierL,
response: "ToolExecutionResponse",
) -> "ToolExecutionResponse": ...
@t.overload
def apply_modifier_by_type(
modifiers: Modifiers,
toolkit: str,
tool: str,
*,
type: t.Literal["schema"],
schema: "Tool",
) -> "Tool": ...
@t.overload
def apply_modifier_by_type(
modifiers: Modifiers,
toolkit: str,
tool: str,
*,
type: BeforeExecuteMetaModifierL,
session_id: str,
params: t.Dict[str, t.Any],
) -> t.Dict[str, t.Any]: ...
@t.overload
def apply_modifier_by_type(
modifiers: Modifiers,
toolkit: str,
tool: str,
*,
type: AfterExecuteMetaModifierL,
session_id: str,
response: "ToolExecutionResponse",
) -> "ToolExecutionResponse": ...
def apply_modifier_by_type(
modifiers: Modifiers,
toolkit: str,
tool: str,
*,
type: t.Literal[
"before_execute",
"after_execute",
"schema",
"before_execute_meta",
"after_execute_meta",
],
schema: t.Optional["Tool"] = None,
request: t.Optional["ToolExecuteParams"] = None,
response: t.Optional["ToolExecutionResponse"] = None,
session_id: t.Optional[str] = None,
params: t.Optional[t.Dict[str, t.Any]] = None,
) -> t.Union[ModifierInOut, t.Dict[str, t.Any]]:
"""Apply a modifier to a tool."""
# For meta modifiers, we handle them differently
if type in ("before_execute_meta", "after_execute_meta"):
if session_id is None:
raise ValueError("session_id is required for meta modifiers")
if type == "before_execute_meta":
if params is None:
raise ValueError("params is required for before_execute_meta")
result_params: t.Dict[str, t.Any] = params
for modifier in modifiers:
if modifier.type == type:
# Check if modifier should be applied
should_apply = (
(len(modifier.tools) == 0 and len(modifier.toolkits) == 0)
or tool in modifier.tools
or toolkit in modifier.toolkits
)
if should_apply and modifier.modifier is not None:
result_params = t.cast(BeforeExecuteMeta, modifier.modifier)(
tool, toolkit, session_id, result_params
)
return result_params
else: # after_execute_meta
if response is None:
raise ValueError("response is required for after_execute_meta")
result_response: "ToolExecutionResponse" = response
for modifier in modifiers:
if modifier.type == type:
# Check if modifier should be applied
should_apply = (
(len(modifier.tools) == 0 and len(modifier.toolkits) == 0)
or tool in modifier.tools
or toolkit in modifier.toolkits
)
if should_apply and modifier.modifier is not None:
result_response = t.cast(AfterExecuteMeta, modifier.modifier)(
tool, toolkit, session_id, result_response
)
return result_response
# For regular modifiers
result: ModifierInOut
if schema is not None:
result = schema
elif request is not None:
result = request
elif response is not None:
result = response
else:
raise ValueError("No data provided")
for modifier in modifiers:
result = modifier.apply(
toolkit=toolkit,
tool=tool,
data=result,
modifer_type=type,
)
return result
class BeforeExecuteMeta(t.Protocol):
"""
A modifier that is called before the meta tool is executed in a session context.
"""
def __call__(
self,
tool: str,
toolkit: str,
session_id: str,
params: t.Dict[str, t.Any],
) -> t.Dict[str, t.Any]: ...
class AfterExecuteMeta(t.Protocol):
"""
A modifier that is called after the meta tool is executed in a session context.
"""
def __call__(
self,
tool: str,
toolkit: str,
session_id: str,
response: ToolExecutionResponse,
) -> ToolExecutionResponse: ...
AfterExecuteMetaModifierL: t.TypeAlias = t.Literal["after_execute_meta"]
BeforeExecuteMetaModifierL: t.TypeAlias = t.Literal["before_execute_meta"]
class ToolOptions(te.TypedDict):
modify_schema: te.NotRequired[
t.Dict[ModifierSlug, AfterExecute | BeforeExecute | SchemaModifier]
]
+170
View File
@@ -0,0 +1,170 @@
import atexit
import functools
import queue as q
import threading as tr
import time
import typing as t
import httpx
import typing_extensions as te
TELEMETRY_URL = "https://telemetry.composio.dev/v1"
METRIC_ENDPOINT = f"{TELEMETRY_URL}/metrics/invocations"
ERROR_ENDPOINT = f"{TELEMETRY_URL}/errors"
class ErrorData(te.TypedDict):
name: str
"The name of the error"
code: te.NotRequired[str]
"The code of the error"
errorId: te.NotRequired[str]
"The error ID of the error"
message: te.NotRequired[str]
"The message of the error"
stack: te.NotRequired[str]
"The stack trace of the error"
class SourceData(te.TypedDict):
host: te.NotRequired[str]
"The name of the source/host"
service: te.NotRequired[te.Literal["sdk", "apollo", "hermes", "thermos"]]
"The service of the source"
language: te.NotRequired[te.Literal["python", "typescript", "go", "rust"]]
"The language of the function that was invoked"
version: te.NotRequired[str]
"The version of the source"
platform: te.NotRequired[str]
"The platform of the source"
environment: te.NotRequired[
te.Literal["development", "production", "ci", "staging", "test"]
]
"The environment of the source, eg: development, production, ci etc"
class Metadata(te.TypedDict):
projectId: te.NotRequired[str]
"The project ID of the source"
provider: te.NotRequired[str]
"The provider used in the source"
class TelemetryData(te.TypedDict):
functionName: str
"The name of the function that was invoked"
durationMs: te.NotRequired[float]
"The duration of the function invocation in milliseconds"
timestamp: te.NotRequired[float]
"The timestamp of the function invocation in epoch seconds"
props: te.NotRequired[t.Dict]
"The properties of the function invocation"
source: te.NotRequired[SourceData]
"""Source of the metric"""
metadata: te.NotRequired[Metadata]
"""Runtime metadata"""
error: te.NotRequired[ErrorData]
"""Error data."""
EventType: t.TypeAlias = t.Literal["metric", "error"]
Event = t.Tuple[EventType, TelemetryData]
EventQueue: t.TypeAlias = q.Queue[Event]
_queue: t.Optional[EventQueue] = None
_event: t.Optional[tr.Event] = None
_thread: t.Optional[tr.Thread] = None
def _setup():
global _queue, _event, _thread
if _queue is None:
_queue = q.Queue[Event]()
if _event is None:
_event = tr.Event()
if _thread is None:
_thread = tr.Thread(
target=_thread_loop,
kwargs={
"queue": _queue,
"event": _event,
},
daemon=True,
)
_thread.start()
atexit.register(
functools.partial(
_teardown,
queue=_queue,
event=_event,
thread=_thread,
)
)
return _queue, _event, _thread
def _teardown(queue: EventQueue, event: tr.Event, thread: tr.Thread):
# Wait max 2 seconds for queue to empty
deadline = time.time() + 2.0
while queue.qsize() and time.time() < deadline:
time.sleep(0.1)
event.set()
# Join with timeout to prevent infinite waiting
thread.join(timeout=3.0)
def _push(event: Event):
try:
_ = (
httpx.post(
url=METRIC_ENDPOINT,
json=[event[1]],
timeout=2.0, # 2 second timeout to prevent hanging
)
if event[0] == "metric"
else httpx.post(
url=ERROR_ENDPOINT,
json=event[1],
timeout=2.0, # 2 second timeout to prevent hanging
)
)
except (httpx.TimeoutException, httpx.HTTPError, Exception):
# Silently fail - telemetry shouldn't break the application
pass
def _thread_loop(queue: EventQueue, event: tr.Event):
while not event.is_set():
try:
_push(queue.get(timeout=0.1))
except q.Empty:
continue
def push_event(event: Event):
q, _, _ = _setup()
q.put(event)
def create_event(type: EventType, **payload: te.Unpack[TelemetryData]) -> Event:
return type, payload
+145
View File
@@ -0,0 +1,145 @@
from __future__ import annotations
import typing as t
import typing_extensions as te
from composio.client.types import (
auth_config_create_params,
auth_config_create_response,
auth_config_list_params,
auth_config_list_response,
auth_config_retrieve_response,
auth_config_update_params,
)
from composio.core.models.base import Resource
class AuthConfigs(Resource):
"""
Manage authentication configurations.
"""
def list(
self,
**query: te.Unpack[auth_config_list_params.AuthConfigListParams],
) -> auth_config_list_response.AuthConfigListResponse:
"""
Lists authentication configurations based on provided filter criteria.
"""
return self._client.auth_configs.list(**query)
@t.overload
def create(
self, toolkit: str, options: auth_config_create_params.AuthConfigUnionMember1
) -> auth_config_create_response.AuthConfig: ...
@t.overload
def create(
self, toolkit: str, options: auth_config_create_params.AuthConfigUnionMember0
) -> auth_config_create_response.AuthConfig: ...
def create(
self, toolkit: str, options: auth_config_create_params.AuthConfig
) -> auth_config_create_response.AuthConfig:
"""
Create a new auth config
:param toolkit: The toolkit to create the auth config for.
:param options: The options to create the auth config with.
:return: The created auth config.
"""
return self._client.auth_configs.create(
toolkit={"slug": toolkit}, auth_config=options
).auth_config
def get(
self, nanoid: str
) -> auth_config_retrieve_response.AuthConfigRetrieveResponse:
"""
Retrieves a specific authentication configuration by its ID
:param nanoid: The ID of the auth config to retrieve.
:return: The retrieved auth config.
"""
return self._client.auth_configs.retrieve(nanoid)
@t.overload
def update(
self, nanoid: str, *, options: auth_config_update_params.Variant0
) -> t.Dict: ...
@t.overload
def update(
self, nanoid: str, *, options: auth_config_update_params.Variant1
) -> t.Dict: ...
# FIXME: what type is this response, in ts, it's AuthConfigUpdateResponse
def update(
self, nanoid: str, *, options: auth_config_update_params.AuthConfigUpdateParams
) -> t.Dict:
"""
Updates an existing authentication configuration.
This method allows you to modify properties of an auth config such as credentials,
scopes, or tool restrictions. The update type (custom or default) determines which
fields can be updated.
:param nanoid: The ID of the auth config to update.
:param options: The options to update the auth config with.
:return: The updated auth config.
"""
return t.cast(
t.Dict,
self._client.auth_configs.update(
nanoid=nanoid,
type=options["type"], # type: ignore
credentials=options.get("credentials", self._client.not_given),
is_enabled_for_tool_router=options.get(
"is_enabled_for_tool_router", self._client.not_given
),
tool_access_config=options.get(
"tool_access_config", self._client.not_given
),
),
)
def delete(self, nanoid: str) -> t.Dict:
"""
Deletes an existing authentication configuration.
:param nanoid: The ID of the auth config to delete.
:return: The deleted auth config.
"""
return t.cast(t.Dict, self._client.auth_configs.delete(nanoid))
def __update_status(
self,
nanoid: str,
status: t.Literal["ENABLED", "DISABLED"],
) -> t.Dict:
return t.cast(
t.Dict,
self._client.auth_configs.update_status(
status,
nanoid=nanoid,
),
)
def enable(self, nanoid: str) -> t.Dict:
"""
Enables an existing authentication configuration.
:param nanoid: The ID of the auth config to enable.
:return: The enabled auth config.
"""
return self.__update_status(nanoid, "ENABLED")
def disable(self, nanoid: str) -> t.Dict:
"""
Disables an existing authentication configuration.
:param nanoid: The ID of the auth config to disable.
:return: The disabled auth config.
"""
return self.__update_status(nanoid, "DISABLED")
+91
View File
@@ -0,0 +1,91 @@
"""
Base resource class for representing resources in the composio client.
"""
import contextvars
import functools
import os
import time
import traceback
import typing as t
from composio.__version__ import __version__
from composio.client import HttpClient
from composio.utils.logging import WithLogger
from ._telemetry import Event, create_event, push_event
PayloadT = t.TypeVar("PayloadT", bound=dict)
allow_tracking = contextvars.ContextVar[bool]("allow_tracking", default=True)
_environment = os.getenv("ENVIRONMENT", "development")
def trace_method(method: t.Callable, name: str) -> t.Callable:
"""Wrap a method to log the call."""
# Check if the method is a class method
if getattr(method, "__self__", None) is not None:
return method
@functools.wraps(method)
def trace_wrapper(self, *args: t.Any, **kwargs: t.Any) -> t.Any:
if not allow_tracking.get():
return method(self, *args, **kwargs)
event: t.Optional[Event] = None
start_time = time.time()
event = create_event(
type="metric",
functionName=name,
timestamp=time.time(),
props={},
source={
"environment": _environment, # type: ignore
"language": "python",
"service": "sdk",
"version": __version__,
},
metadata={
"provider": self._client.provider,
},
)
try:
return method(self, *args, **kwargs)
except Exception as e:
_, payload = event
payload["error"] = {
"name": e.__class__.__name__,
"message": str(e),
"stack": traceback.format_exc(),
}
event = ("error", payload)
raise e
finally:
if event is not None:
event[1]["durationMs"] = (time.time() - start_time) * 1000
push_event(event=event)
trace_wrapper.__name__ = method.__name__
return trace_wrapper
class ResourceMeta(type):
"""Meta class for resource classes."""
def __init__(cls, name, bases, attrs):
for attr in attrs:
if attr.startswith("_") or not callable(getattr(cls, attr)):
continue
setattr(cls, attr, trace_method(getattr(cls, attr), f"{name}.{attr}"))
class Resource(WithLogger, metaclass=ResourceMeta):
"""Base resource class for composio client."""
def sanitize_payload(self, payload: PayloadT) -> PayloadT:
return {k: v for k, v in payload.items()} # type: ignore
def __init__(self, client: HttpClient):
super().__init__()
self._client = client
@@ -0,0 +1,746 @@
from __future__ import annotations
import functools
import logging
import time
import typing as t
import warnings
import typing_extensions as te
from composio_client import BadRequestError, omit
from composio import exceptions
from composio.client import HttpClient
from composio.client.types import (
connected_account_create_params,
connected_account_patch_params,
connected_account_patch_response,
connected_account_retrieve_response,
connected_account_update_status_response,
link_create_params,
)
from .base import Resource
from .experimental import ACL_ONLY_FOR_SHARED_ERROR_FRAGMENT
logger = logging.getLogger(__name__)
# Mirrors TS `ConnectionRequest.ts:terminalErrorStates`. INACTIVE is excluded
# on purpose — it can recover to ACTIVE.
_TERMINAL_CONNECTION_STATES: t.FrozenSet[str] = frozenset(
{"FAILED", "EXPIRED", "REVOKED"}
)
# One-time-per-process guard so long-running services don't spam the deprecation
# warning on every initiate() call.
_legacy_initiate_warning_emitted = False
class ConnectionRequest(Resource):
"""
A connection request.
This class is used to manage connection requests.
"""
DEFAULT_WAIT_TIMEOUT = 60.0 # Seconds
def __init__(
self,
id: str,
status: str,
redirect_url: t.Optional[str],
client: HttpClient,
):
"""
Initialize the connection request.
:param id: The ID of the connection request.
:param status: The status of the connection request.
:param redirect_url: The redirect URL of the connection request.
:param client: The client to use for the connection request.
"""
super().__init__(client)
self.id = id
self.status = status
self.redirect_url = redirect_url
def wait_for_connection(
self,
timeout: t.Optional[float] = None,
) -> connected_account_retrieve_response.ConnectedAccountRetrieveResponse:
"""
Wait for the connection to be established.
:param timeout: The timeout to wait for the connection to be established.
:return: Connected account object.
"""
timeout = self.DEFAULT_WAIT_TIMEOUT if timeout is None else timeout
deadline = time.time() + timeout
while deadline > time.time():
connection = self._client.connected_accounts.retrieve(nanoid=self.id)
self.status = connection.status
if self.status == "ACTIVE":
return connection
if self.status in _TERMINAL_CONNECTION_STATES:
raise exceptions.SDKError(
message=(
f"Connection {self.id} entered terminal state "
f"{self.status!r} before becoming active"
),
)
time.sleep(1)
raise exceptions.ComposioSDKTimeoutError(
message=f"Timeout while waiting for connection {self.id} to be active",
)
@classmethod
def from_id(cls, id: str, client: HttpClient) -> te.Self:
return cls(
id=id,
status=client.connected_accounts.retrieve(nanoid=id).status,
redirect_url=None,
client=client,
)
class AuthScheme:
"""
Collection of auth scheme helpers.
"""
def oauth1(
self, options: connected_account_create_params.ConnectionStateUnionMember0Val
) -> connected_account_create_params.ConnectionState:
"""
Create a new connected account using OAuth 1.0.
When both ``oauth_token`` and ``oauth_token_secret`` are provided,
status defaults to ACTIVE (token import). When either is omitted,
status defaults to INITIALIZING (redirect-based OAuth flow).
Pass an explicit ``status`` in options to override.
"""
has_tokens = bool(
options.get("oauth_token") # type: ignore[union-attr]
) and bool(
options.get("oauth_token_secret") # type: ignore[union-attr]
)
status = "ACTIVE" if has_tokens else "INITIALIZING"
return {
"auth_scheme": "OAUTH1",
"val": t.cast(
connected_account_create_params.ConnectionStateUnionMember0Val,
{
"status": status,
**options,
},
),
}
def oauth2(
self, options: connected_account_create_params.ConnectionStateUnionMember1Val
) -> connected_account_create_params.ConnectionState:
"""
Create a new connected account using OAuth 2.0.
When ``access_token`` is provided, status defaults to ACTIVE
(token import). When omitted, status defaults to INITIALIZING
(redirect-based OAuth flow). Pass an explicit ``status`` in
options to override.
"""
has_token = bool(options.get("access_token")) # type: ignore[union-attr]
status = "ACTIVE" if has_token else "INITIALIZING"
return {
"auth_scheme": "OAUTH2",
"val": t.cast(
connected_account_create_params.ConnectionStateUnionMember1Val,
{
"status": status,
**options,
},
),
}
def composio_link(
self, options: connected_account_create_params.ConnectionStateUnionMember2Val
) -> connected_account_create_params.ConnectionState:
"""
Create a new connected account using Composio Link.
"""
return t.cast(
connected_account_create_params.ConnectionState,
{
"auth_scheme": "COMPOSIO_LINK",
"val": t.cast(
connected_account_create_params.ConnectionStateUnionMember2Val,
{
"status": "INITIALIZING",
**options,
},
),
},
)
def api_key(
self, options: connected_account_create_params.ConnectionStateUnionMember3Val
) -> connected_account_create_params.ConnectionState:
"""
Create a new connected account using an API key.
"""
return t.cast(
connected_account_create_params.ConnectionState,
{
"auth_scheme": "API_KEY",
"val": t.cast(
connected_account_create_params.ConnectionStateUnionMember3Val,
{
"status": "ACTIVE",
**options,
},
),
},
)
def basic(
self, options: connected_account_create_params.ConnectionStateUnionMember4Val
) -> connected_account_create_params.ConnectionState:
"""
Create a new connected account using basic auth.
"""
return t.cast(
connected_account_create_params.ConnectionState,
{
"auth_scheme": "BASIC",
"val": t.cast(
connected_account_create_params.ConnectionStateUnionMember4Val,
{
"status": "ACTIVE",
**options,
},
),
},
)
def bearer_token(
self, options: connected_account_create_params.ConnectionStateUnionMember5Val
) -> connected_account_create_params.ConnectionState:
"""
Create a new connected account using a bearer token.
"""
return t.cast(
connected_account_create_params.ConnectionState,
{
"auth_scheme": "BEARER_TOKEN",
"val": t.cast(
connected_account_create_params.ConnectionStateUnionMember5Val,
{
"status": "ACTIVE",
**options,
},
),
},
)
def google_service_account(
self, options: connected_account_create_params.ConnectionStateUnionMember6Val
) -> connected_account_create_params.ConnectionState:
"""
Create a new connected account using a Google service account.
"""
return t.cast(
connected_account_create_params.ConnectionState,
{
"auth_scheme": "GOOGLE_SERVICE_ACCOUNT",
"val": t.cast(
connected_account_create_params.ConnectionStateUnionMember6Val,
{
"status": "ACTIVE",
**options,
},
),
},
)
def no_auth(
self, options: connected_account_create_params.ConnectionStateUnionMember7Val
) -> connected_account_create_params.ConnectionState:
"""
Create a new connected account using no auth.
"""
return {
"auth_scheme": "NO_AUTH",
"val": t.cast(
connected_account_create_params.ConnectionStateUnionMember7Val,
{
"status": "ACTIVE",
**options,
},
),
}
def calcom_auth(
self, options: connected_account_create_params.ConnectionStateUnionMember8Val
) -> connected_account_create_params.ConnectionState:
"""
Create a new connected account using Cal.com auth.
"""
return {
"auth_scheme": "CALCOM_AUTH",
"val": t.cast(
connected_account_create_params.ConnectionStateUnionMember8Val,
{
"status": "ACTIVE",
**options,
},
),
}
def billcom_auth(
self, options: connected_account_create_params.ConnectionStateUnionMember9Val
) -> connected_account_create_params.ConnectionState:
"""
Create a new connected account using Bill.com auth.
"""
return t.cast(
connected_account_create_params.ConnectionState,
{
"auth_scheme": "BILLCOM_AUTH",
"val": t.cast(
connected_account_create_params.ConnectionStateUnionMember9Val,
{
"status": "ACTIVE",
**options,
},
),
},
)
def basic_with_jwt(
self, options: connected_account_create_params.ConnectionStateUnionMember10Val
) -> connected_account_create_params.ConnectionState:
"""
Create a new connected account using basic auth with JWT.
"""
return t.cast(
connected_account_create_params.ConnectionState,
{
"auth_scheme": "BASIC_WITH_JWT",
"val": t.cast(
connected_account_create_params.ConnectionStateUnionMember10Val,
{
"status": "ACTIVE",
**options,
},
),
},
)
class ConnectedAccounts:
"""
Manage connected accounts.
This class is used to manage connected accounts in the Composio SDK.
These are used to authenticate with third-party services.
"""
enable: t.Callable[
[str],
connected_account_update_status_response.ConnectedAccountUpdateStatusResponse,
]
"""Enable a connected account."""
disable: t.Callable[
[str],
connected_account_update_status_response.ConnectedAccountUpdateStatusResponse,
]
"""Disable a connected account."""
def __init__(self, client: HttpClient):
"""
Initialize the connected accounts resource.
:param client: The client to use for the connected accounts resource.
"""
self._client = client
self.get = self._client.connected_accounts.retrieve
self.list = self._client.connected_accounts.list
self.delete = self._client.connected_accounts.delete
self.update_status = self._client.connected_accounts.update_status
self.refresh = self._client.connected_accounts.refresh
self.enable = functools.partial(
self._client.connected_accounts.update_status,
enabled=True,
)
self.disable = functools.partial(
self._client.connected_accounts.update_status,
enabled=False,
)
def update(
self,
nanoid: str,
*,
alias: t.Optional[str] = None,
connection: t.Optional[connected_account_patch_params.Connection] = None,
) -> connected_account_patch_response.ConnectedAccountPatchResponse:
"""
Update a connected account's alias and/or credentials.
:param nanoid: The connected account ID (ca_xxx).
:param alias: Human-readable alias. Pass an empty string to clear.
Must be unique per entity and toolkit within the project.
:param connection: Credential update with authScheme and val fields.
:return: Response with ``id``, ``status``, and ``success``.
Example:
# Set an alias
composio.connected_accounts.update('ca_abc123', alias='work-gmail')
# Clear an alias
composio.connected_accounts.update('ca_abc123', alias='')
"""
return self._client.connected_accounts.patch(
nanoid,
alias=alias if alias is not None else omit,
connection=connection if connection is not None else omit,
)
def update_acl(
self,
nanoid: str,
*,
allow_all_users: t.Optional[bool] = None,
allowed_user_ids: t.Optional[t.List[str]] = None,
not_allowed_user_ids: t.Optional[t.List[str]] = None,
) -> connected_account_patch_response.ConnectedAccountPatchResponse:
"""
Update the per-user ACL on a SHARED connected account. Experimental —
shape may change in future releases.
Only valid on SHARED connections; raises
``ComposioAclOnlyForSharedError`` on a PRIVATE connection. Omit a
parameter to leave it unchanged; pass an empty list to clear an
allow/deny list. At least one parameter must be provided.
:param nanoid: The connected account ID (``ca_xxx``).
:param allow_all_users: When True, any ``user_id`` may use this
SHARED connection (subject to the deny list).
:param allowed_user_ids: Explicit list of allowed ``user_id`` strings.
Pass ``[]`` to clear.
:param not_allowed_user_ids: Explicit deny list (wins over allow on
conflict). Pass ``[]`` to clear — note that clearing the deny
list silently re-grants access to previously-blocked users.
:return: Response with ``id``, ``status``, and ``success``.
Example:
composio.connected_accounts.update_acl(
'ca_abc',
allow_all_users=True,
not_allowed_user_ids=['user_bob'],
)
"""
if (
allow_all_users is None
and allowed_user_ids is None
and not_allowed_user_ids is None
):
raise exceptions.ValidationError(
"update_acl requires at least one of allow_all_users, "
"allowed_user_ids, or not_allowed_user_ids"
)
acl: t.Dict[str, t.Any] = {}
if allow_all_users is not None:
acl["allow_all_users"] = allow_all_users
if allowed_user_ids is not None:
acl["allowed_user_ids"] = allowed_user_ids
if not_allowed_user_ids is not None:
acl["not_allowed_user_ids"] = not_allowed_user_ids
try:
return self._client.connected_accounts.patch(
nanoid,
experimental={
"acl_config_for_shared": t.cast(
connected_account_patch_params.ExperimentalACLConfigForShared,
acl,
),
},
)
except BadRequestError as error:
message = str(error)
if ACL_ONLY_FOR_SHARED_ERROR_FRAGMENT in message:
raise exceptions.ComposioAclOnlyForSharedError(message) from error
raise
def initiate(
self,
user_id: str,
auth_config_id: str,
*,
callback_url: t.Optional[str] = None,
allow_multiple: bool = False,
config: t.Optional[connected_account_create_params.ConnectionState] = None,
alias: t.Optional[str] = None,
) -> ConnectionRequest:
"""
Compound function to create a new connected account. This function creates
a new connected account and returns a connection request.
Users can then wait for the connection to be established using the
``wait_for_connection`` method.
.. deprecated::
For Composio-managed (default) auth configs on redirectable OAuth
schemes (OAuth1, OAuth2, DCR_OAUTH), the legacy endpoint this
method wraps is being retired: **2026-05-08** for new
organizations and **2026-07-03** for all remaining organizations.
After your org's cutover, this method will raise
:class:`composio.exceptions.ComposioLegacyConnectedAccountsEndpointRetiredError`
for that specific combination.
Use :meth:`ConnectedAccounts.link` for Composio-managed OAuth — it
works for every redirectable scheme regardless of whether the
auth config is Composio-managed or custom, and the return shape
is the same.
Custom auth configs (your own OAuth app) and non-OAuth schemes
(API key, bearer token, basic auth) are unaffected and continue
to work on ``initiate()``. See
https://docs.composio.dev/docs/changelog/2026/04/24
:param user_id: The user ID to create the connected account for.
:param auth_config_id: The auth config ID to create the connected account for.
:param callback_url: Callback URL to use for OAuth apps.
:param config: The configuration to create the connected account with.
:param allow_multiple: Whether to allow multiple connected accounts for the same user and auth config.
:param alias: Optional human-readable alias for the account. Must be unique per userId and toolkit within the project.
:return: The connection request.
"""
# Check if there are multiple connected accounts for the authConfig of the user
connected_accounts = self.list(
user_ids=[user_id], auth_config_ids=[auth_config_id], statuses=["ACTIVE"]
)
if connected_accounts.items and not allow_multiple:
raise exceptions.ComposioMultipleConnectedAccountsError(
f"Multiple connected accounts found for user {user_id} in auth config {auth_config_id}. "
"Please use the allow_multiple option to allow multiple connected accounts."
)
elif connected_accounts.items:
logger.warning(
"[Warn:AllowMultiple] Multiple connected accounts found for user %s in auth config %s",
user_id,
auth_config_id,
)
connection: dict[str, t.Any] = {"user_id": user_id}
if callback_url is not None:
connection["callback_url"] = callback_url
if config is not None:
connection["state"] = config
if alias is not None:
connection["alias"] = alias
# Use `with_raw_response.create` so we can read the SEC-339
# `Deprecation` header (RFC 9745) the apollo retiring branch sets —
# that header is emitted only when the auth config is Composio-managed
# AND on a redirectable OAuth scheme, so it's the canonical signal
# that this caller needs to migrate. Custom auth configs and non-OAuth
# schemes never see the header, eliminating the false-positive warning
# that an auth_scheme-only check produced for link()-unaffected callers.
deprecation_header: t.Optional[str] = None
try:
ca_client = self._client.connected_accounts
raw_create = getattr(
getattr(ca_client, "with_raw_response", None), "create", None
)
if callable(raw_create):
raw = raw_create(
auth_config={"id": auth_config_id},
connection=t.cast(
connected_account_create_params.Connection, connection
),
)
response = raw.parse() if callable(getattr(raw, "parse", None)) else raw
headers = getattr(raw, "headers", None)
if headers is not None and hasattr(headers, "get"):
value = headers.get("Deprecation") or headers.get("deprecation")
if isinstance(value, str):
deprecation_header = value
else:
# Test mocks may not stub `with_raw_response`. Fall back to
# the parsed-only path; the deprecation gate stays off (no
# header to read).
response = ca_client.create(
auth_config={"id": auth_config_id},
connection=t.cast(
connected_account_create_params.Connection, connection
),
)
except BadRequestError as error:
# When the server has flipped this org to the retired path, the
# legacy endpoint returns 400 with a stable migration message.
# Surface it as a typed error so callers get an actionable hint
# instead of a generic BadRequestError.
message = str(error)
if (
"no longer supported" in message
and "/api/v3/connected_accounts/link" in message
):
raise exceptions.ComposioLegacyConnectedAccountsEndpointRetiredError(
message
) from error
raise
# Warn once per process when apollo flags this response as on the
# retiring path. Header presence is a 1:1 signal — custom auth
# configs and non-OAuth schemes get a clean response and stay silent,
# fixing the false-positive that auth_scheme-based detection
# produced.
global _legacy_initiate_warning_emitted
if not _legacy_initiate_warning_emitted and deprecation_header:
_legacy_initiate_warning_emitted = True
warnings.warn(
"composio.connected_accounts.initiate() will stop working "
"for this auth config on or before 2026-07-03 (see Sunset "
"header on the response). Switch to "
"composio.connected_accounts.link() — same return shape, "
"same allow_multiple semantics. "
"https://docs.composio.dev/docs/changelog/2026/04/24",
DeprecationWarning,
stacklevel=2,
)
return ConnectionRequest(
id=response.id,
status=response.connection_data.val.status,
redirect_url=getattr(response.connection_data.val, "redirect_url", None),
client=self._client,
)
def link(
self,
user_id: str,
auth_config_id: str,
*,
callback_url: t.Optional[str] = None,
alias: t.Optional[str] = None,
allow_multiple: bool = False,
experimental: t.Optional[link_create_params.Experimental] = None,
) -> ConnectionRequest:
"""
Create a Composio Connect Link for a user to connect their account to a given auth config.
This method will return an external link which you can use for the user to connect their account.
:param user_id: The external user ID to create the connected account for.
:param auth_config_id: The auth config ID to create the connected account for.
:param callback_url: The URL to redirect the user to post connecting their account.
:param alias: Optional human-readable alias for the connection. Must be unique
per userId and toolkit within the project.
:param allow_multiple: Whether to allow multiple connected accounts for the same
user and auth config. When False (default), raises
``ComposioMultipleConnectedAccountsError`` if the user already has an
``ACTIVE`` connection on this auth config. Pair with ``alias`` and a
session-level ``multi_account`` config to disambiguate at execution time.
:param experimental: Experimental options for this connection. Pass an
``Experimental`` dict with ``account_type`` and/or
``acl_config_for_shared`` to create a SHARED connection with a
per-user ACL. Experimental — shape may change in future releases.
:return: Connection request object.
Example:
# Create a connection request and redirect the user to the redirect url
connection_request = composio.connected_accounts.link('user_123', 'auth_config_123')
redirect_url = connection_request.redirect_url
print(f"Visit: {redirect_url} to authenticate your account")
# Wait for the connection to be established
connected_account = connection_request.wait_for_connection()
Example with callback URL:
# Create a connection request with callback URL
connection_request = composio.connected_accounts.link(
'user_123',
'auth_config_123',
callback_url='https://your-app.com/callback'
)
redirect_url = connection_request.redirect_url
print(f"Visit: {redirect_url} to authenticate your account")
# Wait for the connection to be established
connected_account = composio.connected_accounts.wait_for_connection(connection_request.id)
Example creating a SHARED connection with an ACL (experimental):
connection_request = composio.connected_accounts.link(
'user_creator',
'auth_config_123',
experimental={
'account_type': 'SHARED',
'acl_config_for_shared': {
'allow_all_users': True,
'not_allowed_user_ids': ['user_bob'],
},
},
)
"""
# Mirror ``initiate()``: guard against silently creating extra
# connections on the same auth config.
connected_accounts = self.list(
user_ids=[user_id], auth_config_ids=[auth_config_id], statuses=["ACTIVE"]
)
if connected_accounts.items and not allow_multiple:
raise exceptions.ComposioMultipleConnectedAccountsError(
f"Multiple connected accounts found for user {user_id} in auth config {auth_config_id}. "
"Please use the allow_multiple option to allow multiple connected accounts."
)
elif connected_accounts.items:
logger.warning(
"[Warn:AllowMultiple] Multiple connected accounts found for user %s in auth config %s",
user_id,
auth_config_id,
)
try:
response = self._client.link.create(
auth_config_id=auth_config_id,
user_id=user_id,
callback_url=callback_url if callback_url is not None else omit,
alias=alias if alias is not None else omit,
experimental=experimental if experimental is not None else omit,
)
except BadRequestError as error:
# The server rejects ACL on PRIVATE connections — surface that
# as a typed error so callers can ``except`` instead of grepping
# messages.
message = str(error)
if ACL_ONLY_FOR_SHARED_ERROR_FRAGMENT in message:
raise exceptions.ComposioAclOnlyForSharedError(message) from error
raise
return ConnectionRequest(
id=response.connected_account_id,
status="INITIATED",
redirect_url=getattr(response, "redirect_url", None),
client=self._client,
)
def wait_for_connection(
self,
id: str,
timeout: t.Optional[float] = None,
) -> connected_account_retrieve_response.ConnectedAccountRetrieveResponse:
"""
Wait for connected account with given ID to be active
"""
return ConnectionRequest.from_id(
id=id,
client=self._client,
).wait_for_connection(
timeout=timeout,
)
auth_scheme = AuthScheme()
+788
View File
@@ -0,0 +1,788 @@
"""Custom tools and toolkits for tool router sessions.
Decorator API for defining custom tools that run in-process alongside
remote Composio tools. Accessed via ``composio.experimental``.
Usage::
from pydantic import BaseModel, Field
from composio import Composio
composio = Composio()
class GrepInput(BaseModel):
pattern: str = Field(description="Pattern to search for")
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
\"\"\"Search for a pattern in local files.\"\"\"
return {"matches": []}
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS",
name="Dev Tools",
description="Local dev utilities",
)
@dev_tools.tool()
def search_code(input: GrepInput, ctx):
\"\"\"Search developer resources.\"\"\"
return {"results": []}
session = composio.create(
user_id="default",
experimental={
"custom_tools": [grep],
"custom_toolkits": [dev_tools],
},
)
"""
from __future__ import annotations
import asyncio
import inspect
import typing as t
from pydantic import BaseModel
from composio.exceptions import ValidationError
from .custom_tool_types import (
LOCAL_TOOL_PREFIX,
MAX_SLUG_LENGTH,
SLUG_REGEX,
CustomTool,
CustomToolExecuteFn,
CustomToolkitWireDefinition,
CustomToolsMap,
CustomToolsMapEntry,
CustomToolWireDefinition,
)
from .tool_router_constants import PRELOAD_TOOLS_ALL
if t.TYPE_CHECKING:
from composio_client.types.tool_router.session_attach_response import (
Experimental as SessionAttachResponseExperimental,
)
from composio_client.types.tool_router.session_create_response import (
Experimental as SessionCreateResponseExperimental,
)
from composio_client.types.tool_router.session_retrieve_response import (
Experimental as SessionRetrieveResponseExperimental,
)
# ────────────────────────────────────────────────────────────────
# Slug validation helpers
# ────────────────────────────────────────────────────────────────
def _validate_slug(slug: str, context: str) -> str:
"""Validate a custom tool or toolkit slug."""
if not slug:
raise ValidationError(f"{context}: slug is required")
if not SLUG_REGEX.match(slug):
raise ValidationError(
f"{context}: slug must only contain alphanumeric characters, "
f"underscores, and hyphens"
)
upper = slug.upper()
if upper.startswith("LOCAL_"):
raise ValidationError(
f'{context}: slug must not start with "LOCAL_"'
f"this prefix is reserved for internal routing."
)
if upper.startswith("COMPOSIO_"):
raise ValidationError(
f'{context}: slug must not start with "COMPOSIO_"'
f"this prefix is reserved for Composio meta tools."
)
return slug
def _compute_final_slug_length(tool_slug: str, toolkit_slug: t.Optional[str]) -> int:
"""Compute the final slug length: LOCAL_[TOOLKIT_]SLUG."""
length = len(LOCAL_TOOL_PREFIX) + len(tool_slug)
if toolkit_slug:
length += len(toolkit_slug) + 1 # +1 for underscore separator
return length
def _validate_slug_length(
tool_slug: str, toolkit_slug: t.Optional[str], context: str
) -> None:
"""Validate that the final slug won't exceed the max length."""
final_length = _compute_final_slug_length(tool_slug, toolkit_slug)
if final_length > MAX_SLUG_LENGTH:
prefix = LOCAL_TOOL_PREFIX + (
f"{toolkit_slug.upper()}_" if toolkit_slug else ""
)
available = MAX_SLUG_LENGTH - len(prefix)
raise ValidationError(
f'{context}: slug "{tool_slug}" is too long. '
f'With prefix "{prefix}", the final slug would be {final_length} '
f"characters (max {MAX_SLUG_LENGTH}). "
f"Shorten the slug to at most {available} characters."
)
def _build_final_slug(tool_slug: str, toolkit_slug: t.Optional[str] = None) -> str:
"""Build the final slug: LOCAL_[TOOLKIT_]SLUG."""
upper = tool_slug.upper()
if toolkit_slug:
return f"{LOCAL_TOOL_PREFIX}{toolkit_slug.upper()}_{upper}"
return f"{LOCAL_TOOL_PREFIX}{upper}"
def _get_input_json_schema(model: t.Type[BaseModel]) -> t.Dict[str, t.Any]:
"""Convert a Pydantic model class to a JSON Schema dict suitable for the backend."""
full_schema = model.model_json_schema()
schema: t.Dict[str, t.Any] = {"type": "object"}
if "properties" in full_schema:
schema["properties"] = full_schema["properties"]
if "required" in full_schema:
schema["required"] = full_schema["required"]
if "$defs" in full_schema:
schema["$defs"] = full_schema["$defs"]
return schema
# ────────────────────────────────────────────────────────────────
# Internal tool creation (used by decorator API)
# ────────────────────────────────────────────────────────────────
def _create_tool(
slug: str,
*,
name: str,
description: str,
input_params: t.Type[BaseModel],
execute: CustomToolExecuteFn,
extends_toolkit: t.Optional[str] = None,
output_params: t.Optional[t.Type[BaseModel]] = None,
preload: t.Optional[bool] = None,
) -> CustomTool:
"""Internal: create and validate a CustomTool."""
context = "experimental.tool"
_validate_slug(slug, context)
if not name:
raise ValidationError(f"{context}: name is required")
if not description:
raise ValidationError(f"{context}: description is required")
if not isinstance(input_params, type) or not issubclass(input_params, BaseModel):
raise ValidationError(
f"{context}: input_params must be a Pydantic BaseModel subclass. "
f"Tool input parameters are always an object with named properties."
)
try:
from pydantic import RootModel
if issubclass(input_params, RootModel):
raise ValidationError(
f"{context}: input_params must be a regular BaseModel with named fields, "
f"not a RootModel. Tool input parameters are always an object with "
f"named properties."
)
except ImportError:
pass
if not callable(execute):
raise ValidationError(f"{context}: execute must be a callable")
if asyncio.iscoroutinefunction(execute):
raise ValidationError(
f"{context}: execute must be a synchronous function, not async. "
f"The Composio Python SDK is synchronous — use a regular "
f"'def fn(input, ctx)' instead of 'async def'."
)
_validate_slug_length(slug, extends_toolkit, context)
input_schema = _get_input_json_schema(input_params)
output_schema: t.Optional[t.Dict[str, t.Any]] = None
if output_params is not None:
if not isinstance(output_params, type) or not issubclass(
output_params, BaseModel
):
raise ValidationError(
f"{context}: output_params must be a Pydantic BaseModel subclass"
)
output_schema = output_params.model_json_schema()
return CustomTool(
slug=slug,
name=name,
description=description,
extends_toolkit=extends_toolkit,
input_schema=input_schema,
output_schema=output_schema,
input_params=input_params,
execute=execute,
preload=preload,
)
def _get_caller_locals(depth: int = 2) -> t.Optional[t.Mapping[str, t.Any]]:
"""Best-effort lookup of a caller frame's locals."""
frame = inspect.currentframe()
try:
caller = frame
for _ in range(depth):
caller = caller.f_back if caller is not None else None
return caller.f_locals if caller is not None else None
finally:
del frame
def _resolve_function_annotations(
fn: t.Callable[..., t.Any],
*,
localns: t.Optional[t.Mapping[str, t.Any]] = None,
) -> t.Dict[str, t.Any]:
"""Resolve annotations, including postponed string annotations when possible."""
try:
return t.get_type_hints(
fn,
globalns=getattr(fn, "__globals__", {}),
localns=dict(localns) if localns is not None else None,
include_extras=True,
)
except Exception:
return {}
def _infer_tool_from_function(
fn: t.Callable[..., t.Any],
*,
slug: t.Optional[str] = None,
name: t.Optional[str] = None,
description: t.Optional[str] = None,
extends_toolkit: t.Optional[str] = None,
output_params: t.Optional[t.Type[BaseModel]] = None,
preload: t.Optional[bool] = None,
annotation_locals: t.Optional[t.Mapping[str, t.Any]] = None,
) -> CustomTool:
"""Create a CustomTool by inferring metadata from a decorated function.
- slug: from ``fn.__name__.upper()``
- name: from ``fn.__name__`` humanized
- description: from ``fn.__doc__``
- input_params: from the first parameter with a BaseModel type annotation
"""
# Infer slug
actual_slug = slug or fn.__name__.upper()
actual_name = name or fn.__name__.replace("_", " ").title()
actual_description = description or inspect.cleandoc(fn.__doc__ or "")
if not actual_description:
raise ValidationError(
f"experimental.tool: description is required. "
f'Add a docstring to "{fn.__name__}" or pass description=...'
)
# Validate and infer function signature.
# Accepted shapes (consistent with TS CustomToolExecuteFn):
# (input: BaseModel) — no session context needed
# (input: BaseModel, ctx) — with session context
# The first param MUST be annotated with a BaseModel subclass.
# Reject async before wrapping (wrapper would hide it from _create_tool)
if asyncio.iscoroutinefunction(fn):
raise ValidationError(
f'experimental.tool: "{fn.__name__}" is async. '
f"The Composio Python SDK is synchronous — use a regular "
f"'def {fn.__name__}(input, ctx)' instead of 'async def'."
)
sig = inspect.signature(fn)
params = list(sig.parameters.values())
resolved_annotations = _resolve_function_annotations(
fn,
localns=annotation_locals,
)
if not params:
raise ValidationError(
f'experimental.tool: "{fn.__name__}" must accept at least one parameter '
f"annotated with a Pydantic BaseModel subclass, e.g. "
f"def {fn.__name__}(input: MyInput, ctx): ..."
)
if len(params) > 2:
raise ValidationError(
f'experimental.tool: "{fn.__name__}" accepts {len(params)} parameters, '
f"but custom tools accept at most 2: (input: BaseModel, ctx). "
f"Extra parameters will not be populated at runtime."
)
# First param must be the input model
first = params[0]
first_annotation = resolved_annotations.get(first.name, first.annotation)
if first_annotation is inspect.Parameter.empty or not (
isinstance(first_annotation, type) and issubclass(first_annotation, BaseModel)
):
raise ValidationError(
f'experimental.tool: first parameter of "{fn.__name__}" must be annotated '
f"with a Pydantic BaseModel subclass. Got: {first_annotation!r}. "
f"Expected: def {fn.__name__}(input: MyInput, ctx): ..."
)
input_params: t.Type[BaseModel] = first_annotation
# Wrap function to match CustomToolExecuteFn: (input, ctx) -> dict
if len(params) == 1:
def execute(input: t.Any, ctx: t.Any) -> t.Dict[str, t.Any]:
return fn(input)
else:
execute = fn
return _create_tool(
slug=actual_slug,
name=actual_name,
description=actual_description,
input_params=input_params,
execute=execute,
extends_toolkit=extends_toolkit,
output_params=output_params,
preload=preload,
)
# ────────────────────────────────────────────────────────────────
# ExperimentalToolkit — custom toolkit with .tool() decorator
# ────────────────────────────────────────────────────────────────
class ExperimentalToolkit:
"""Custom toolkit that groups related tools under one namespace.
Use ``@toolkit.tool()`` to add tools. Pass the toolkit to
``composio.create(experimental={"custom_toolkits": [toolkit]})``.
Tools added to a toolkit must NOT use ``extends_toolkit`` — they
inherit the toolkit identity instead.
Example::
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS",
name="Dev Tools",
description="Local dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
\"\"\"Search developer resources.\"\"\"
return {"results": []}
"""
def __init__(
self,
*,
slug: str,
name: str,
description: str,
preload: t.Optional[bool] = None,
) -> None:
context = "experimental.Toolkit"
_validate_slug(slug, context)
if not name:
raise ValidationError(f"{context}: name is required")
if not description:
raise ValidationError(f"{context}: description is required")
self.slug = slug
self.name = name
self.description = description
self.preload = preload
self._tools: t.List[CustomTool] = []
@property
def tools(self) -> t.Tuple[CustomTool, ...]:
return tuple(self._tools)
@t.overload
def tool(self, fn: t.Callable[..., t.Any], /) -> CustomTool: ...
@t.overload
def tool(
self,
*,
slug: t.Optional[str] = None,
name: t.Optional[str] = None,
description: t.Optional[str] = None,
output_params: t.Optional[t.Type[BaseModel]] = None,
preload: t.Optional[bool] = None,
) -> t.Callable[[t.Callable[..., t.Any]], CustomTool]: ...
def tool(
self,
fn: t.Optional[t.Callable[..., t.Any]] = None,
*,
slug: t.Optional[str] = None,
name: t.Optional[str] = None,
description: t.Optional[str] = None,
output_params: t.Optional[t.Type[BaseModel]] = None,
preload: t.Optional[bool] = None,
) -> t.Union[CustomTool, t.Callable[[t.Callable[..., t.Any]], CustomTool]]:
"""Decorator to add a tool to this toolkit.
Infers slug, name, description, and input_params from the function
if not explicitly provided.
"""
def decorator(f: t.Callable[..., t.Any]) -> CustomTool:
annotation_locals = _get_caller_locals()
custom_tool = _infer_tool_from_function(
f,
slug=slug,
name=name,
description=description,
output_params=output_params,
preload=preload,
annotation_locals=annotation_locals,
# No extends_toolkit for toolkit tools
)
_validate_slug_length(
custom_tool.slug, self.slug, f'experimental.Toolkit("{self.slug}").tool'
)
self._tools.append(custom_tool)
return custom_tool
if fn is not None:
custom_tool = _infer_tool_from_function(
fn,
slug=slug,
name=name,
description=description,
output_params=output_params,
preload=preload,
annotation_locals=_get_caller_locals(),
)
_validate_slug_length(
custom_tool.slug,
self.slug,
f'experimental.Toolkit("{self.slug}").tool',
)
self._tools.append(custom_tool)
return custom_tool
return decorator
# ────────────────────────────────────────────────────────────────
# Serialization (for backend API payload)
# ────────────────────────────────────────────────────────────────
def _serialized_preload_value(
preload: t.Optional[bool],
inherited_preload: t.Optional[bool],
default_preload: bool,
) -> t.Optional[bool]:
inherited_or_default = (
inherited_preload if inherited_preload is not None else default_preload
)
if preload is not None:
return preload if preload or inherited_or_default else None
if inherited_preload is not None:
return inherited_preload if inherited_preload or default_preload else None
return True if default_preload else None
def serialize_custom_tools(
tools: t.List[CustomTool],
*,
default_preload: bool = False,
) -> t.List[CustomToolWireDefinition]:
"""Serialize custom tools into the format expected by the backend."""
result: t.List[CustomToolWireDefinition] = []
for tool in tools:
entry: CustomToolWireDefinition = {
"slug": tool.slug,
"name": tool.name,
"description": tool.description,
"input_schema": tool.input_schema,
}
if tool.output_schema:
entry["output_schema"] = tool.output_schema
if tool.extends_toolkit:
entry["extends_toolkit"] = tool.extends_toolkit
preload = _serialized_preload_value(
tool.preload, inherited_preload=None, default_preload=default_preload
)
if preload is not None:
entry["preload"] = preload
result.append(entry)
return result
def serialize_custom_toolkits(
toolkits: t.Sequence[ExperimentalToolkit],
*,
default_preload: bool = False,
) -> t.List[CustomToolkitWireDefinition]:
"""Serialize custom toolkits into the format expected by the backend."""
result: t.List[CustomToolkitWireDefinition] = []
for tk in toolkits:
toolkit_tools: t.List[CustomToolWireDefinition] = []
for tool in tk.tools:
entry: CustomToolWireDefinition = {
"slug": tool.slug,
"name": tool.name,
"description": tool.description,
"input_schema": tool.input_schema,
}
if tool.output_schema:
entry["output_schema"] = tool.output_schema
preload = _serialized_preload_value(
tool.preload,
inherited_preload=tk.preload,
default_preload=default_preload,
)
if preload is not None:
entry["preload"] = preload
toolkit_tools.append(entry)
toolkit_entry: CustomToolkitWireDefinition = {
"slug": tk.slug,
"name": tk.name,
"description": tk.description,
"tools": toolkit_tools,
}
preload = _serialized_preload_value(
tk.preload, inherited_preload=None, default_preload=default_preload
)
if preload is not None:
toolkit_entry["preload"] = preload
result.append(toolkit_entry)
return result
# ────────────────────────────────────────────────────────────────
# Routing map builders
# ────────────────────────────────────────────────────────────────
def build_custom_tools_map(
tools: t.List[CustomTool],
toolkits: t.Optional[t.List[ExperimentalToolkit]] = None,
) -> CustomToolsMap:
"""Build a CustomToolsMap from custom tools and toolkits."""
by_final_slug: t.Dict[str, CustomToolsMapEntry] = {}
by_original_slug: t.Dict[str, CustomToolsMapEntry] = {}
def add_entry(
handle: CustomTool, final_slug: str, toolkit: t.Optional[str]
) -> None:
original_slug = handle.slug.upper()
# Custom tool slugs are matched case-insensitively across local and response maps.
final_slug_key = final_slug.upper()
if len(final_slug) > MAX_SLUG_LENGTH:
raise ValidationError(
f'Custom tool slug "{handle.slug}" produces final slug '
f'"{final_slug}" which exceeds {MAX_SLUG_LENGTH} characters.'
)
if final_slug_key in by_final_slug:
raise ValidationError(
f'Custom tool slug collision: "{final_slug}" is already registered.'
)
if original_slug in by_original_slug:
existing = by_original_slug[original_slug]
raise ValidationError(
f'Custom tool slug collision: original slug "{handle.slug}" '
f"maps to multiple final slugs. "
f'"{existing.final_slug}" and "{final_slug}" both resolve '
f'from "{original_slug}".'
)
entry = CustomToolsMapEntry(
handle=handle, final_slug=final_slug, toolkit=toolkit
)
by_final_slug[final_slug_key] = entry
by_original_slug[original_slug] = entry
# Process standalone tools
for handle in tools:
add_entry(
handle,
_build_final_slug(handle.slug, handle.extends_toolkit),
handle.extends_toolkit,
)
# Process toolkit tools
if toolkits:
for tk in toolkits:
for handle in tk.tools:
add_entry(handle, _build_final_slug(handle.slug, tk.slug), tk.slug)
return CustomToolsMap(
by_final_slug=by_final_slug,
by_original_slug=by_original_slug,
toolkits=list(toolkits) if toolkits else None,
tools=list(tools) if tools else None,
)
def build_custom_tools_map_from_response(
tools: t.List[CustomTool],
toolkits: t.Optional[t.List[ExperimentalToolkit]],
experimental: t.Optional[
t.Union[
"SessionAttachResponseExperimental",
"SessionCreateResponseExperimental",
"SessionRetrieveResponseExperimental",
]
],
) -> CustomToolsMap:
"""Build a CustomToolsMap using the slug/original_slug mapping from the backend response."""
by_final_slug: t.Dict[str, CustomToolsMapEntry] = {}
by_original_slug: t.Dict[str, CustomToolsMapEntry] = {}
# Build lookup from original slug -> handle + toolkit
handles_by_original: t.Dict[str, t.Tuple[CustomTool, t.Optional[str]]] = {}
for handle in tools:
key = handle.slug.upper()
if key in handles_by_original:
raise ValidationError(
f'Duplicate custom tool slug "{handle.slug}"'
f"each tool must have a unique slug across all custom tools and toolkits."
)
handles_by_original[key] = (handle, handle.extends_toolkit)
if toolkits:
for tk in toolkits:
for handle in tk.tools:
key = handle.slug.upper()
if key in handles_by_original:
raise ValidationError(
f'Duplicate custom tool slug "{handle.slug}"'
f"each tool must have a unique slug across all custom tools and toolkits."
)
handles_by_original[key] = (handle, tk.slug)
def add_entry(
final_slug: str, original_slug: str, toolkit: t.Optional[str]
) -> None:
match = handles_by_original.get(original_slug.upper())
if not match:
return
handle, default_toolkit = match
resolved_toolkit = toolkit if toolkit is not None else default_toolkit
entry = CustomToolsMapEntry(
handle=handle, final_slug=final_slug, toolkit=resolved_toolkit
)
by_final_slug[final_slug.upper()] = entry
by_original_slug[original_slug.upper()] = entry
if experimental and experimental.custom_tools:
for ct in experimental.custom_tools:
add_entry(ct.slug, ct.original_slug, ct.extends_toolkit)
if experimental and experimental.custom_toolkits:
for ctk in experimental.custom_toolkits:
for ctk_tool in ctk.tools:
add_entry(ctk_tool.slug, ctk_tool.original_slug, ctk.slug)
return CustomToolsMap(
by_final_slug=by_final_slug,
by_original_slug=by_original_slug,
toolkits=list(toolkits) if toolkits else None,
tools=list(tools) if tools else None,
)
def find_custom_tool_map_entry_by_final_slug(
custom_tools_map: t.Optional[CustomToolsMap],
slug: str,
) -> t.Optional[CustomToolsMapEntry]:
"""Find a custom tool entry by final slug only."""
if custom_tools_map is None:
return None
return custom_tools_map.by_final_slug.get(slug.upper())
def assert_no_custom_tool_slugs_in_preload(
preload_tools: t.Union[t.Sequence[str], t.Literal["all"], None],
custom_tools_map: t.Optional[CustomToolsMap],
) -> None:
"""Reject legacy top-level preload of custom tool slugs."""
if preload_tools is None or preload_tools == PRELOAD_TOOLS_ALL:
return
if isinstance(preload_tools, str):
raise ValidationError(
'preload.tools must be a list of Composio tool slugs or "all". '
"Set preload=True on the SDK custom tool or custom toolkit "
"definition to expose custom tools directly."
)
custom_preload_slugs = []
for slug in preload_tools:
normalized = slug.upper()
if normalized.startswith(LOCAL_TOOL_PREFIX) or (
# Top-level preload.tools is only for Composio-managed tool slugs.
# Custom tools use preload=True on their SDK definitions instead.
custom_tools_map is not None
and (
normalized in custom_tools_map.by_original_slug
or normalized in custom_tools_map.by_final_slug
)
):
custom_preload_slugs.append(slug)
if custom_preload_slugs:
raise ValidationError(
"Custom tool slugs are not supported in preload.tools: "
f"{', '.join(custom_preload_slugs)}. Set preload=True on the SDK "
"custom tool or custom toolkit definition instead."
)
def get_preloaded_custom_tool_slugs(
custom_tools_map: t.Optional[CustomToolsMap],
*,
default_preload: bool = False,
) -> t.List[str]:
"""Return final custom tool slugs selected locally for preload."""
if custom_tools_map is None:
return []
seen: t.Set[str] = set()
custom_tool_slugs: t.List[str] = []
for entry in custom_tools_map.by_final_slug.values():
toolkit = next(
(
tk
for tk in custom_tools_map.toolkits or []
if entry.toolkit and tk.slug.lower() == entry.toolkit.lower()
),
None,
)
should_preload = (
entry.handle.preload
if entry.handle.preload is not None
else toolkit.preload
if toolkit is not None and toolkit.preload is not None
else default_preload
)
if not should_preload:
continue
final_slug_key = entry.final_slug.upper()
if final_slug_key in seen:
continue
seen.add(final_slug_key)
custom_tool_slugs.append(entry.final_slug)
return custom_tool_slugs
@@ -0,0 +1,79 @@
"""Standalone functions for custom tool lookup and execution.
Extracted for reuse in SessionContextImpl (sibling routing).
Security invariant (CWE-639 / SEC-365): on this code path the trusted
``user_id`` arrives via ``SessionContext`` — never via ``arguments``. The
``arguments`` dict is LLM-supplied and is validated through the tool's
Pydantic ``input_params`` model, which discards unknown fields, so an
attempt to smuggle ``user_id`` (or any other identity-bearing key) inside
``arguments`` cannot reach the tool's execute function or auth lookup.
Keep this property when modifying ``execute_custom_tool``.
"""
from __future__ import annotations
import typing as t
from pydantic import ValidationError as PydanticValidationError
from .custom_tool_types import (
CustomToolsMap,
CustomToolsMapEntry,
SessionContext,
)
from .tools import ToolExecutionResponse
def find_custom_tool(
map_: t.Optional[CustomToolsMap],
slug: str,
) -> t.Optional[CustomToolsMapEntry]:
"""Find a custom tool entry by slug.
Checks both the final slug map (LOCAL_X — agent/LLM path)
and original slug map (X — programmatic path). Case-insensitive.
"""
if map_ is None:
return None
upper = slug.upper()
return map_.by_final_slug.get(upper) or map_.by_original_slug.get(upper)
def execute_custom_tool(
entry: CustomToolsMapEntry,
arguments: t.Dict[str, t.Any],
session_context: SessionContext,
) -> ToolExecutionResponse:
"""Execute a custom tool in-process.
Validates input via the Pydantic model, calls the user's execute function,
and wraps the result into the standard response format.
"""
handle = entry.handle
# Validate and transform input using the Pydantic model.
# This applies defaults, coercions, and validators.
try:
validated = handle.input_params.model_validate(arguments)
except PydanticValidationError as e:
return {
"data": {},
"error": f"Input validation failed: {e}",
"successful": False,
}
try:
# User's execute returns data directly — we wrap into {data, error, successful}
data = handle.execute(validated, session_context)
return {
"data": data if data is not None else {},
"error": None,
"successful": True,
}
except Exception as e:
return {
"data": {},
"error": str(e),
"successful": False,
}
@@ -0,0 +1,170 @@
"""Type definitions for custom tools in tool router sessions.
Mirrors the TypeScript types in ts/packages/core/src/types/customTool.types.ts
"""
from __future__ import annotations
import re
import typing as t
from dataclasses import dataclass, field
import typing_extensions as te
from composio_client.types.tool_router import session_create_params
from pydantic import BaseModel
from composio_client.types.tool_router.session_execute_response import (
SessionExecuteResponse,
)
from composio_client.types.tool_router.session_proxy_execute_response import (
SessionProxyExecuteResponse,
)
# ────────────────────────────────────────────────────────────────
# Constants
# ────────────────────────────────────────────────────────────────
LOCAL_TOOL_PREFIX = "LOCAL_"
MAX_SLUG_LENGTH = 60
SLUG_REGEX = re.compile(r"^[A-Za-z0-9_-]+$")
# ────────────────────────────────────────────────────────────────
# Execute function type
# ────────────────────────────────────────────────────────────────
CustomToolExecuteFn = t.Callable[
[t.Any, "SessionContext"],
t.Dict[str, t.Any],
]
"""
Execute function for custom tools.
Signature: (input: BaseModel, ctx: SessionContext) -> dict
Just return the result data, or raise an error. The SDK wraps it internally
into {data, error, successful}.
"""
# ────────────────────────────────────────────────────────────────
# SessionContext protocol
# ────────────────────────────────────────────────────────────────
class SessionContext(te.Protocol):
"""Session context injected into custom tool execute functions at runtime.
Provides identity context and methods to call other tools or proxy API requests.
"""
@property
def user_id(self) -> str: ...
def execute(
self,
tool_slug: str,
arguments: t.Dict[str, t.Any],
) -> SessionExecuteResponse:
"""Execute any Composio tool from within a custom tool.
Returns the same response model as ``session.execute()``.
"""
...
def proxy_execute(
self,
*,
toolkit: str,
endpoint: str,
method: t.Literal["GET", "POST", "PUT", "DELETE", "PATCH"],
body: t.Any = None,
parameters: t.Optional[t.List[t.Dict[str, t.Any]]] = None,
) -> SessionProxyExecuteResponse:
"""Proxy API calls through Composio's auth layer.
Returns the same response model as ``session.proxy_execute()``.
"""
...
# ────────────────────────────────────────────────────────────────
# Custom tool / toolkit definitions (returned by factory functions)
# ────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class CustomTool:
"""Custom tool definition returned from ``@composio.experimental.tool()``.
Pass to ``composio.create(user_id, experimental={"custom_tools": [...]})``
to bind to a session.
"""
slug: str
name: str
description: str
input_schema: t.Dict[str, t.Any]
input_params: t.Type[BaseModel]
execute: CustomToolExecuteFn
extends_toolkit: t.Optional[str] = None
output_schema: t.Optional[t.Dict[str, t.Any]] = None
preload: t.Optional[bool] = None
CustomToolWireDefinition = session_create_params.ExperimentalCustomTool
CustomToolkitWireDefinition = session_create_params.ExperimentalCustomToolkit
class InlineCustomToolsWirePayload(te.TypedDict, total=False):
custom_tools: t.List[CustomToolWireDefinition]
custom_toolkits: t.List[CustomToolkitWireDefinition]
# ────────────────────────────────────────────────────────────────
# Internal routing map types
# ────────────────────────────────────────────────────────────────
@dataclass
class CustomToolsMapEntry:
"""Entry in the per-session custom tools routing map."""
handle: CustomTool
final_slug: str
toolkit: t.Optional[str] = None
@dataclass
class CustomToolsMap:
"""Lookup maps used by ToolRouterSession for routing custom tools."""
by_final_slug: t.Dict[str, CustomToolsMapEntry] = field(default_factory=dict)
by_original_slug: t.Dict[str, CustomToolsMapEntry] = field(default_factory=dict)
toolkits: t.Optional[t.List[t.Any]] = None
tools: t.Optional[t.List["CustomTool"]] = None
# ────────────────────────────────────────────────────────────────
# Registered types (returned by session.custom_tools() / .custom_toolkits())
# ────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class RegisteredCustomTool:
"""A custom tool as registered in a session, with its final resolved slug."""
slug: str
name: str
description: str
input_schema: t.Dict[str, t.Any]
toolkit: t.Optional[str] = None
output_schema: t.Optional[t.Dict[str, t.Any]] = None
@dataclass(frozen=True)
class RegisteredCustomToolkit:
"""A custom toolkit as registered in a session, with final slugs on nested tools."""
slug: str
name: str
description: str
tools: t.List[RegisteredCustomTool]
+187
View File
@@ -0,0 +1,187 @@
"""The ``composio.experimental`` namespace.
Houses experimental SDK surfaces whose shape may change in future
releases. Two flavours live here today:
- Decorators for in-process custom tools and toolkits
(``composio.experimental.tool`` / ``composio.experimental.Toolkit``).
Implementation details for these still live in :mod:`custom_tool`;
this module just exposes them on the namespace.
- Experimental SDK methods that take a Composio client
(``composio.experimental.update_acl``).
Anything new on the ``composio.experimental`` namespace should land here,
not on the underlying model modules.
"""
from __future__ import annotations
import typing as t
from pydantic import BaseModel
from composio.client import HttpClient
from composio.client.types import connected_account_patch_response
from .custom_tool import (
CustomTool,
ExperimentalToolkit,
_get_caller_locals,
_infer_tool_from_function,
)
# Server-side 400 message the API uses to reject ACL writes against a
# PRIVATE connection. Substring-matched in `update_acl` here and in the
# sibling `link()` / `authorize()` call sites — kept as a single constant
# so a server-side message tweak only requires one edit.
ACL_ONLY_FOR_SHARED_ERROR_FRAGMENT = "acl_config_for_shared is only valid on SHARED"
class ExperimentalAPI:
"""Experimental APIs accessed via ``composio.experimental``.
Provides decorators for creating custom tools and toolkits that run
in-process alongside remote Composio tools, plus experimental SDK
methods whose shape may change in future releases.
"""
Toolkit = ExperimentalToolkit
def __init__(self, client: t.Optional[HttpClient] = None) -> None:
self._client = client
def update_acl(
self,
nanoid: str,
*,
allow_all_users: t.Optional[bool] = None,
allowed_user_ids: t.Optional[t.List[str]] = None,
not_allowed_user_ids: t.Optional[t.List[str]] = None,
) -> connected_account_patch_response.ConnectedAccountPatchResponse:
"""
Update the per-user ACL on a SHARED connected account. Experimental —
shape may change in future releases.
Only valid on SHARED connections; raises
``ComposioAclOnlyForSharedError`` on a PRIVATE connection. Omit a
parameter to leave it unchanged; pass an empty list to clear an
allow/deny list. At least one parameter must be provided.
:param nanoid: The connected account ID (``ca_xxx``).
:param allow_all_users: When True, any ``user_id`` may use this
SHARED connection (subject to the deny list).
:param allowed_user_ids: Explicit list of allowed ``user_id`` strings.
Pass ``[]`` to clear.
:param not_allowed_user_ids: Explicit deny list (wins over allow on
conflict). Pass ``[]`` to clear — note that clearing the deny
list silently re-grants access to previously-blocked users.
:return: Response with ``id``, ``status``, and ``success``.
.. deprecated::
Use :meth:`composio.connected_accounts.update_acl` instead — ACL
updates graduated onto the ``connected_accounts`` model. This
experimental alias is kept only for backwards compatibility and
delegates to it. Prefer the ``connected_accounts`` model; do not
generate new code against this alias.
Example:
composio.connected_accounts.update_acl(
'ca_abc',
allow_all_users=True,
not_allowed_user_ids=['user_bob'],
)
"""
from composio import exceptions
from .connected_accounts import ConnectedAccounts
if self._client is None:
raise exceptions.ValidationError(
"update_acl requires a Composio client. Access it via "
"composio.connected_accounts.update_acl(...)."
)
return ConnectedAccounts(client=self._client).update_acl(
nanoid,
allow_all_users=allow_all_users,
allowed_user_ids=allowed_user_ids,
not_allowed_user_ids=not_allowed_user_ids,
)
@t.overload
def tool(self, fn: t.Callable[..., t.Any], /) -> CustomTool: ...
@t.overload
def tool(
self,
*,
slug: t.Optional[str] = None,
name: t.Optional[str] = None,
description: t.Optional[str] = None,
extends_toolkit: t.Optional[str] = None,
output_params: t.Optional[t.Type[BaseModel]] = None,
preload: t.Optional[bool] = None,
) -> t.Callable[[t.Callable[..., t.Any]], CustomTool]: ...
def tool(
self,
fn: t.Optional[t.Callable[..., t.Any]] = None,
*,
slug: t.Optional[str] = None,
name: t.Optional[str] = None,
description: t.Optional[str] = None,
extends_toolkit: t.Optional[str] = None,
output_params: t.Optional[t.Type[BaseModel]] = None,
preload: t.Optional[bool] = None,
) -> t.Union[CustomTool, t.Callable[[t.Callable[..., t.Any]], CustomTool]]:
"""Decorator to create a custom tool from a function.
Infers slug, name, description, and input_params from the function.
Override any with explicit keyword arguments.
Examples::
# Bare decorator — no parens
@composio.experimental.tool
def grep(input: GrepInput, ctx):
\"\"\"Search for a pattern.\"\"\"
return {"matches": []}
# With parens — no args
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
\"\"\"Search for a pattern.\"\"\"
return {"matches": []}
# With extends_toolkit — inherits auth
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
\"\"\"Create a Gmail draft.\"\"\"
return ctx.proxy_execute(toolkit="gmail", ...)
"""
def decorator(f: t.Callable[..., t.Any]) -> CustomTool:
annotation_locals = _get_caller_locals()
return _infer_tool_from_function(
f,
slug=slug,
name=name,
description=description,
extends_toolkit=extends_toolkit,
output_params=output_params,
preload=preload,
annotation_locals=annotation_locals,
)
if fn is not None:
return _infer_tool_from_function(
fn,
slug=slug,
name=name,
description=description,
extends_toolkit=extends_toolkit,
output_params=output_params,
preload=preload,
annotation_locals=_get_caller_locals(),
)
return decorator
@@ -0,0 +1,42 @@
from __future__ import annotations
import typing as t
from composio_client import Omit, omit
from composio_client.types.tool_router import (
session_attach_params,
session_execute_params,
session_search_params,
)
from composio.core.models.custom_tool_types import InlineCustomToolsWirePayload
def inline_custom_tools_attach_experimental(
payload: t.Optional[InlineCustomToolsWirePayload],
) -> t.Union[session_attach_params.Experimental, Omit]:
if payload is None:
return omit
# Stainless generates endpoint-specific experimental types with the same custom
# definition shape, so this helper centralizes the structural cast.
return t.cast(session_attach_params.Experimental, payload)
def inline_custom_tools_execute_experimental(
payload: t.Optional[InlineCustomToolsWirePayload],
) -> t.Union[session_execute_params.Experimental, Omit]:
if payload is None:
return omit
# Stainless generates endpoint-specific experimental types with the same custom
# definition shape, so this helper centralizes the structural cast.
return t.cast(session_execute_params.Experimental, payload)
def inline_custom_tools_search_experimental(
payload: t.Optional[InlineCustomToolsWirePayload],
) -> t.Union[session_search_params.Experimental, Omit]:
if payload is None:
return omit
# Stainless generates endpoint-specific experimental types with the same custom
# definition shape, so this helper centralizes the structural cast.
return t.cast(session_search_params.Experimental, payload)
+28
View File
@@ -0,0 +1,28 @@
from composio_client import BaseModel
from composio.core.models.base import Resource
INTERNAL_SDK_REALTIME_CREDENTIALS_ENDPOINT = "/api/v3/internal/sdk/realtime/credentials"
class SDKRealtimeCredentialsResponse(BaseModel):
pusher_key: str
project_id: str
pusher_cluster: str
class Internal(Resource):
"""
Internal resource for getting internal SDK realtime credentials.
"""
def get_sdk_realtime_credentials(self) -> SDKRealtimeCredentialsResponse:
"""
Get the SDK realtime credentials.
:return: The SDK realtime credentials.
"""
return self._client.get(
path=INTERNAL_SDK_REALTIME_CREDENTIALS_ENDPOINT,
cast_to=SDKRealtimeCredentialsResponse,
)
+500
View File
@@ -0,0 +1,500 @@
"""
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 = 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,
custom_tools=custom_tools,
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:
update_params["custom_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
@@ -0,0 +1,175 @@
"""Session context implementation injected into custom tool execute functions.
One instance is created per session and shared across all custom tool invocations,
including sibling routing (tool A calling tool B without hitting the network).
"""
from __future__ import annotations
import typing as t
from composio_client import omit
from composio_client.types.tool_router.session_proxy_execute_params import Parameter
from composio_client.types.tool_router.session_execute_response import (
SessionExecuteResponse,
)
from composio_client.types.tool_router.session_proxy_execute_response import (
SessionProxyExecuteResponse,
)
from composio.client import HttpClient
from composio.core.models.custom_tool_execution import (
execute_custom_tool,
find_custom_tool,
)
from composio.core.models.custom_tool_types import (
CustomToolsMap,
InlineCustomToolsWirePayload,
)
from composio.core.models.inline_custom_tools_payload import (
inline_custom_tools_execute_experimental,
)
from composio.core.models.tools import _serialize_arguments
from composio.exceptions import ValidationError
_VALID_METHODS = frozenset({"GET", "POST", "PUT", "DELETE", "PATCH"})
_VALID_PARAM_TYPES = frozenset({"header", "query"})
def proxy_execute_impl(
client: HttpClient,
session_id: str,
*,
toolkit: str,
endpoint: str,
method: t.Literal["GET", "POST", "PUT", "DELETE", "PATCH"],
body: t.Any = None,
parameters: t.Optional[t.List[t.Dict[str, t.Any]]] = None,
) -> SessionProxyExecuteResponse:
"""Shared proxy execute implementation used by SessionContextImpl and ToolRouterSession."""
# Client-side validation (matches TS SessionProxyExecuteParamsSchema)
if not toolkit:
raise ValidationError("proxy_execute: toolkit is required")
if not endpoint:
raise ValidationError("proxy_execute: endpoint is required")
if method not in _VALID_METHODS:
raise ValidationError(
f"proxy_execute: method must be one of {sorted(_VALID_METHODS)}, got {method!r}"
)
# Transform and validate parameters
api_params: t.List[Parameter] = []
if parameters:
for i, p in enumerate(parameters):
if not isinstance(p, dict) or "name" not in p or "value" not in p:
raise ValidationError(
f"proxy_execute: parameters[{i}] must be a dict with 'name' and 'value' keys"
)
param_type = p.get("in", p.get("type", "header"))
if param_type not in _VALID_PARAM_TYPES:
raise ValidationError(
f"proxy_execute: parameters[{i}].type must be 'header' or 'query', "
f"got {param_type!r}"
)
api_params.append(
Parameter(
name=p["name"],
type=param_type, # type: ignore[typeddict-item]
value=str(p["value"]),
)
)
return client.tool_router.session.proxy_execute(
session_id=session_id,
toolkit_slug=toolkit,
endpoint=endpoint,
method=method,
body=body if body is not None else omit,
parameters=api_params if api_params else omit,
)
class SessionContextImpl:
"""Concrete implementation of SessionContext.
One instance is created per session (singleton) and shared across
all custom tool invocations. When ``custom_tools_map`` is provided,
``execute()`` checks local tools first before falling back to the
backend API (sibling routing).
"""
def __init__(
self,
client: HttpClient,
user_id: str,
session_id: str,
custom_tools_map: t.Optional[CustomToolsMap] = None,
inline_custom_tools_payload: t.Optional[InlineCustomToolsWirePayload] = None,
) -> None:
self._client = client
self._user_id = user_id
self._session_id = session_id
self._custom_tools_map = custom_tools_map
self._inline_custom_tools_payload = inline_custom_tools_payload
@property
def user_id(self) -> str:
"""The user ID for the current session."""
return self._user_id
def execute(
self,
tool_slug: str,
arguments: t.Dict[str, t.Any],
) -> SessionExecuteResponse:
"""Execute any tool from within a custom tool.
Routes to sibling local tools in-process when available,
otherwise delegates to the backend API.
Returns the same response model as ``session.execute()``.
"""
# Try local tool first (sibling routing)
entry = find_custom_tool(self._custom_tools_map, tool_slug)
if entry:
result = execute_custom_tool(entry, arguments, self)
return SessionExecuteResponse(
data=result["data"],
error=result["error"],
log_id="",
)
# Serialize any Pydantic model instances before sending to remote API
serialized = _serialize_arguments(arguments)
return self._client.tool_router.session.execute(
session_id=self._session_id,
tool_slug=tool_slug,
arguments=serialized,
experimental=inline_custom_tools_execute_experimental(
self._inline_custom_tools_payload
),
)
def proxy_execute(
self,
*,
toolkit: str,
endpoint: str,
method: t.Literal["GET", "POST", "PUT", "DELETE", "PATCH"],
body: t.Any = None,
parameters: t.Optional[t.List[t.Dict[str, t.Any]]] = None,
) -> SessionProxyExecuteResponse:
"""Proxy API calls through Composio's auth layer.
Returns the same response model as ``session.proxy_execute()``.
"""
return proxy_execute_impl(
self._client,
self._session_id,
toolkit=toolkit,
endpoint=endpoint,
method=method,
body=body,
parameters=parameters,
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,4 @@
import typing as t
SESSION_PRESET_DIRECT_TOOLS: t.Literal["direct_tools"] = "direct_tools"
PRELOAD_TOOLS_ALL: t.Literal["all"] = "all"
@@ -0,0 +1,915 @@
"""
ToolRouterSession class for managing a single tool router session.
Provides methods for tools, authorize, toolkits, search, execute, and files.
When custom tools are bound to the session, execution is routed: local tools
run in-process, remote tools are sent to the backend.
"""
from __future__ import annotations
import typing as t
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from composio_client import BadRequestError, Omit, omit
from composio_client._types import SequenceNotStr
from composio_client.types.tool_list_response import (
ItemDeprecated,
ItemDeprecatedToolkit,
ItemToolkit,
)
from composio_client.types.tool_router import session_link_params, session_patch_params
from composio_client.types.tool_router.session_execute_response import (
SessionExecuteResponse,
)
from composio_client.types.tool_router.session_proxy_execute_response import (
SessionProxyExecuteResponse,
)
from composio_client.types.tool_router.session_search_response import (
SessionSearchResponse,
)
from composio import exceptions
from composio.client import HttpClient
from composio.client.types import Tool
from composio.core.models._modifiers import Modifiers, apply_modifier_by_type
from composio.core.models.connected_accounts import ConnectionRequest
from composio.core.models.custom_tool import find_custom_tool_map_entry_by_final_slug
from composio.core.models.custom_tool_execution import (
execute_custom_tool,
find_custom_tool,
)
from composio.core.models.custom_tool_types import (
CustomToolsMap,
CustomToolsMapEntry,
InlineCustomToolsWirePayload,
RegisteredCustomTool,
RegisteredCustomToolkit,
)
from composio.core.models.experimental import ACL_ONLY_FOR_SHARED_ERROR_FRAGMENT
from composio.core.models.inline_custom_tools_payload import (
inline_custom_tools_execute_experimental,
inline_custom_tools_search_experimental,
)
from composio.core.models.session_context import SessionContextImpl, proxy_execute_impl
from composio.core.models.tool_router_session_delete import (
ToolRouterSessionDeleteResponse,
delete_tool_router_session,
)
from composio.core.models.tools import ToolExecuteParams, ToolExecutionResponse
from composio.core.provider import TTool, TToolCollection
from composio.core.provider.base import BaseProvider
if t.TYPE_CHECKING:
from composio.core.models.tool_router import (
ToolkitConnectionsDetails,
ToolRouterMCPServerConfig,
ToolRouterSessionExperimental,
)
COMPOSIO_MULTI_EXECUTE_TOOL = "COMPOSIO_MULTI_EXECUTE_TOOL"
DIRECT_CUSTOM_TOOL_DESCRIPTION_PREFIX = (
"[Direct tool - call directly, no search needed beforehand.]"
)
MAX_PARALLEL_WORKERS = 5
@dataclass
class ToolRouterSessionPreloadConfig:
"""Preloaded tools configured for a tool router session."""
tools: t.Union[t.List[str], t.Literal["all"]]
class ToolRouterSession(t.Generic[TTool, TToolCollection]):
"""
A Composio session — the object returned by ``composio.create(...)`` /
``composio.use(...)``. Use it to fetch session-scoped tools, authorize
toolkits, search, and execute tools.
Generic Parameters:
TTool: The individual tool type returned by the provider.
TToolCollection: The collection type returned by tools().
The hosted MCP endpoint (``session.mcp``) exists at runtime on every
session, but is only surfaced in the type when you opt in with
``create(..., mcp=True)`` / ``use(..., mcp=True)``, which returns a
:class:`ToolRouterSessionWithMcp`. By default agents use native tools via
:meth:`tools`. See https://docs.composio.dev/docs/sessions-via-mcp
Attributes:
session_id: Unique session identifier
experimental: Experimental features (files, assistive prompt, etc.)
"""
#: Unique session identifier.
session_id: str
#: Experimental capabilities available on this session.
experimental: "ToolRouterSessionExperimental"
def __init__(
self,
*,
client: HttpClient,
provider: t.Optional[BaseProvider[t.Any, t.Any]],
dangerously_allow_auto_upload_download_files: bool,
sensitive_file_upload_protection: bool = True,
file_upload_path_deny_segments: t.Optional[t.Sequence[str]] = None,
file_upload_dirs: t.Union[t.Sequence[str], t.Literal[False], None] = None,
session_id: str,
mcp: t.Any,
experimental: "ToolRouterSessionExperimental",
custom_tools_map: t.Optional[CustomToolsMap] = None,
user_id: t.Optional[str] = None,
preload: t.Optional[ToolRouterSessionPreloadConfig] = None,
preloaded_custom_tool_slugs: t.Optional[t.List[str]] = None,
inline_custom_tools_payload: t.Optional[InlineCustomToolsWirePayload] = None,
) -> None:
self._client = client
self._provider = provider
self._auto_upload_download_files = dangerously_allow_auto_upload_download_files
self._sensitive_file_upload_protection = sensitive_file_upload_protection
self._file_upload_path_deny_segments = file_upload_path_deny_segments
self._file_upload_dirs = file_upload_dirs
self.session_id = session_id
# The MCP endpoint exists on every session at runtime (kept for
# backwards compatibility), but is only typed via
# ToolRouterSessionWithMcp. Assign through setattr so type checkers do
# not surface `mcp` on the base class — MCP is an explicit opt-in.
setattr(self, "mcp", mcp)
self.experimental = experimental
self.preload = preload or ToolRouterSessionPreloadConfig(tools=[])
self._custom_tools_map = custom_tools_map
self._user_id = user_id
self._preloaded_custom_tool_slugs = preloaded_custom_tool_slugs or []
self._inline_custom_tools_payload = inline_custom_tools_payload
# Create singleton session context if custom tools are bound
self._session_context: t.Optional[SessionContextImpl] = None
if custom_tools_map and user_id:
self._session_context = SessionContextImpl(
client=client,
user_id=user_id,
session_id=session_id,
custom_tools_map=custom_tools_map,
inline_custom_tools_payload=inline_custom_tools_payload,
)
def _has_custom_tools(self) -> bool:
"""Check if this session has any custom tools bound."""
if self._custom_tools_map is None:
return False
return len(self._custom_tools_map.by_final_slug) > 0
def _tool_router_backend_execute(
self,
tools_model: t.Any,
modifiers: t.Optional["Modifiers"] = None,
) -> t.Callable[..., t.Any]:
"""Backend execute wrapper with this session's file-upload settings."""
return tools_model._wrap_execute_tool_for_tool_router(
session_id=self.session_id,
modifiers=modifiers,
inline_custom_tools_payload=self._inline_custom_tools_payload,
)
def tools(self, modifiers: t.Optional["Modifiers"] = None) -> TToolCollection:
"""
Get provider-wrapped tools for execution with your AI framework.
Returns tools configured for this session, wrapped in the format expected
by your AI provider (OpenAI, Anthropic, LangChain, etc.).
When custom tools are bound to the session, execution of
COMPOSIO_MULTI_EXECUTE_TOOL is intercepted: local tools are executed
in-process, remote tools are sent to the backend.
"""
from composio.core.models.tools import Tools as ToolsModel
from composio.core.provider import AgenticProvider, NonAgenticProvider
if self._provider is None:
raise ValueError(
"Provider is required for tool router. "
"Please initialize ToolRouter with a provider."
)
tools_model = ToolsModel(
client=self._client,
provider=self._provider,
dangerously_allow_auto_upload_download_files=self._auto_upload_download_files,
sensitive_file_upload_protection=self._sensitive_file_upload_protection,
file_upload_path_deny_segments=self._file_upload_path_deny_segments,
file_upload_dirs=self._file_upload_dirs,
)
router_tools = tools_model.get_raw_tool_router_meta_tools(
session_id=self.session_id,
modifiers=modifiers,
)
router_tools = self._add_preloaded_custom_tools(router_tools, modifiers)
for tool in router_tools:
tool.input_parameters = (
tools_model._file_helper.enhance_schema_descriptions(
schema=tool.input_parameters,
)
)
if issubclass(type(self._provider), NonAgenticProvider):
return t.cast(
TToolCollection,
t.cast(
NonAgenticProvider[TTool, TToolCollection], self._provider
).wrap_tools(tools=router_tools),
)
# For agentic providers: if custom tools are bound, create a routing
# execute function that intercepts COMPOSIO_MULTI_EXECUTE_TOOL
if self._has_custom_tools():
execute_fn = self._create_routing_execute_fn(tools_model, modifiers)
else:
execute_fn = self._tool_router_backend_execute(
tools_model, modifiers=modifiers
)
return t.cast(
TToolCollection,
t.cast(AgenticProvider[TTool, TToolCollection], self._provider).wrap_tools(
tools=router_tools,
execute_tool=execute_fn,
),
)
def _add_preloaded_custom_tools(
self,
tools: t.List[Tool],
modifiers: t.Optional["Modifiers"],
) -> t.List[Tool]:
custom_tools = self._get_preloaded_custom_tool_schemas(modifiers)
if not custom_tools:
return tools
existing_slugs = {tool.slug.upper() for tool in tools}
appended_tools = [
tool for tool in custom_tools if tool.slug.upper() not in existing_slugs
]
if not appended_tools:
return tools
return [*tools, *appended_tools]
def _get_preloaded_custom_tool_schemas(
self,
modifiers: t.Optional["Modifiers"],
) -> t.List[Tool]:
if not self._custom_tools_map or not self._preloaded_custom_tool_slugs:
return []
tools: t.List[Tool] = []
for slug in self._preloaded_custom_tool_slugs:
entry = find_custom_tool_map_entry_by_final_slug(
self._custom_tools_map,
slug,
)
if entry is None:
continue
tool = self._custom_tool_entry_to_tool(entry)
if modifiers is not None:
tool = t.cast(
Tool,
apply_modifier_by_type(
modifiers=modifiers,
toolkit=tool.toolkit.slug,
tool=tool.slug,
type="schema",
schema=tool,
),
)
tools.append(tool)
return tools
def _custom_tool_entry_to_tool(self, entry: CustomToolsMapEntry) -> Tool:
toolkit_slug = entry.toolkit or "custom"
toolkit_name = (
self._custom_toolkit_name(toolkit_slug) or entry.toolkit or "Custom"
)
return Tool(
available_versions=[],
deprecated=ItemDeprecated(
available_versions=[],
displayName=entry.handle.name,
is_deprecated=False,
toolkit=ItemDeprecatedToolkit(logo=""),
version="latest",
),
description=(
f"{DIRECT_CUSTOM_TOOL_DESCRIPTION_PREFIX}\n{entry.handle.description}"
),
input_parameters=entry.handle.input_schema,
is_deprecated=False,
name=entry.handle.name,
no_auth=entry.handle.extends_toolkit is None,
output_parameters=entry.handle.output_schema or {},
scopes=[],
slug=entry.final_slug,
tags=[],
toolkit=ItemToolkit(logo="", name=toolkit_name, slug=toolkit_slug),
version="latest",
)
def _custom_toolkit_name(self, toolkit_slug: str) -> t.Optional[str]:
if self._custom_tools_map is None:
return None
for toolkit in self._custom_tools_map.toolkits or []:
if toolkit.slug.lower() == toolkit_slug.lower():
return toolkit.name
return None
def _create_routing_execute_fn(
self,
tools_model: t.Any,
modifiers: t.Optional["Modifiers"],
) -> t.Callable[..., t.Any]:
"""Create an execute function that routes local/remote tools.
Applies before_execute/after_execute modifiers around the overall
COMPOSIO_MULTI_EXECUTE_TOOL call, consistent with the standard path.
"""
backend_execute = self._tool_router_backend_execute(
tools_model, modifiers=modifiers
)
def routing_execute(slug: str, arguments: t.Dict) -> t.Dict:
if slug == COMPOSIO_MULTI_EXECUTE_TOOL:
# Apply before_execute modifiers
processed_arguments = arguments
if modifiers is not None:
type_before: t.Literal["before_execute"] = "before_execute"
params: ToolExecuteParams = {"arguments": arguments}
modified = apply_modifier_by_type(
modifiers=modifiers,
toolkit="composio",
tool=slug,
type=type_before,
request=params,
)
processed_arguments = modified.get("arguments", arguments)
result = self._route_multi_execute(processed_arguments, tools_model)
# Apply after_execute modifiers
if modifiers is not None:
type_after: t.Literal["after_execute"] = "after_execute"
result = t.cast(
t.Dict[str, t.Any],
apply_modifier_by_type(
modifiers=modifiers,
toolkit="composio",
tool=slug,
type=type_after,
response=t.cast(ToolExecutionResponse, result),
),
)
return result
entry = find_custom_tool(self._custom_tools_map, slug)
if entry:
return t.cast(
t.Dict[str, t.Any],
execute_custom_tool(
entry,
arguments,
t.cast(SessionContextImpl, self._session_context),
),
)
# Non-multi-execute meta tools always go to backend
return backend_execute(slug, arguments)
return routing_execute
def _parse_tool_item(self, item: t.Any) -> t.Dict[str, t.Any]:
"""Parse an individual tool item from COMPOSIO_MULTI_EXECUTE_TOOL's tools array."""
if not isinstance(item, dict):
return {"tool_slug": "", "arguments": {}}
return {
"tool_slug": str(item.get("tool_slug", "")),
"arguments": item.get("arguments", {}),
}
def _route_multi_execute(
self,
input_args: t.Dict[str, t.Any],
tools_model: t.Any,
) -> t.Dict[str, t.Any]:
"""Route a COMPOSIO_MULTI_EXECUTE_TOOL call.
Splits the tools[] array into local and remote, executes each
appropriately, and merges results: remotes first, locals appended
(matches TS — remote results may have workbench index references).
Modifiers are NOT applied here — the caller (routing_execute)
handles before_execute/after_execute to avoid double application.
"""
tool_items = input_args.get("tools")
if not isinstance(tool_items, list) or len(tool_items) == 0:
# Fallback: send to backend as-is (no modifiers — caller handles them)
return self._tool_router_backend_execute(tools_model)(
COMPOSIO_MULTI_EXECUTE_TOOL,
input_args,
)
parsed = [self._parse_tool_item(item) for item in tool_items]
# Partition into local (with resolved entry) and remote
local_items: t.List[t.Tuple[int, CustomToolsMapEntry]] = []
remote_indices: t.List[int] = []
for i, p in enumerate(parsed):
entry = find_custom_tool(self._custom_tools_map, p["tool_slug"])
if entry:
local_items.append((i, entry))
else:
remote_indices.append(i)
# All remote — just forward entire payload (no modifiers — caller handles them)
if not local_items:
return self._tool_router_backend_execute(tools_model)(
COMPOSIO_MULTI_EXECUTE_TOOL,
input_args,
)
ctx = self._session_context
assert ctx is not None
# Determine worker count (capped at MAX_PARALLEL_WORKERS)
num_tasks = len(local_items) + (1 if remote_indices else 0)
num_workers = min(MAX_PARALLEL_WORKERS, num_tasks)
with ThreadPoolExecutor(max_workers=num_workers) as pool:
# Submit local tool executions
local_futures = []
for idx, entry in local_items:
future = pool.submit(
execute_custom_tool,
entry,
parsed[idx]["arguments"],
ctx,
)
local_futures.append((idx, future))
# Submit remote batch (single call) if any
# No modifiers here — the outer routing_execute handles them
remote_future = None
if remote_indices:
remote_tool_items = [tool_items[i] for i in remote_indices]
remote_input = {**input_args, "tools": remote_tool_items}
execute_fn = self._tool_router_backend_execute(tools_model)
remote_future = pool.submit(
execute_fn,
COMPOSIO_MULTI_EXECUTE_TOOL,
remote_input,
)
# Gather local results
local_results: t.List[t.Tuple[int, ToolExecutionResponse]] = []
for idx, future in local_futures:
local_results.append((idx, future.result()))
# Gather remote result
remote_result: t.Optional[t.Dict[str, t.Any]] = None
if remote_future:
remote_result = remote_future.result()
# If only one local tool and no remote, return unwrapped
if not remote_indices and len(local_results) == 1:
return t.cast(t.Dict[str, t.Any], local_results[0][1])
# Build local result entries matching backend format
local_entries = []
for idx, result in local_results:
local_entry: t.Dict[str, t.Any] = {
"response": {
"successful": result["successful"],
"data": result["data"],
},
"tool_slug": parsed[idx]["tool_slug"],
}
if result.get("error"):
local_entry["response"]["error"] = result["error"]
local_entry["error"] = result["error"]
local_entries.append(local_entry)
# Merge: remotes first, locals appended (matches TS behavior —
# remote results may have workbench index references)
remote_data_raw = (remote_result or {}).get("data")
remote_data = remote_data_raw if isinstance(remote_data_raw, dict) else {}
remote_results_list = (
remote_data.get("results", [])
if isinstance(remote_data.get("results"), list)
else []
)
all_results = [
{**entry, "index": i}
for i, entry in enumerate([*remote_results_list, *local_entries])
]
failed = sum(1 for r in all_results if r.get("error"))
merged_data = {**remote_data, "results": all_results}
if local_entries and any(
key in remote_data
for key in ("total_count", "success_count", "error_count")
):
merged_data["total_count"] = len(all_results)
merged_data["success_count"] = len(all_results) - failed
merged_data["error_count"] = failed
remote_error = (
str(remote_result.get("error"))
if remote_result and remote_result.get("error") is not None
else None
)
has_any_error = any(r.get("error") for _, r in local_results) or bool(
remote_error
)
error_message = None
if has_any_error:
error_message = (
remote_error
if remote_error is not None and failed == 0
else f"{failed} out of {len(all_results)} tools failed"
)
return {
"data": merged_data,
"error": error_message,
"successful": not has_any_error,
}
def authorize(
self,
toolkit: str,
*,
callback_url: t.Optional[str] = None,
alias: t.Optional[str] = None,
experimental: t.Optional[session_link_params.Experimental] = None,
) -> ConnectionRequest:
"""
Authorize a toolkit for the user and get a connection request.
Initiates the OAuth flow and returns a ConnectionRequest with redirect URL.
:param alias: Human-readable alias for the connection. Must be unique
per userId and toolkit within the project.
:param experimental: Experimental options for this connection. Pass an
``Experimental`` dict with ``account_type`` and/or
``acl_config_for_shared`` to create a SHARED connection with a
per-user ACL. Experimental — shape may change in future releases.
"""
try:
response = self._client.tool_router.session.link(
session_id=self.session_id,
toolkit=toolkit,
callback_url=callback_url if callback_url else omit,
alias=alias if alias is not None else omit,
experimental=experimental if experimental is not None else omit,
)
except BadRequestError as error:
# The server rejects ACL on PRIVATE connections — surface that
# as a typed error mirroring ``composio.connected_accounts.link()``.
message = str(error)
if ACL_ONLY_FOR_SHARED_ERROR_FRAGMENT in message:
raise exceptions.ComposioAclOnlyForSharedError(message) from error
raise
return ConnectionRequest(
id=response.connected_account_id,
redirect_url=response.redirect_url,
status="INITIATED",
client=self._client,
)
def toolkits(
self,
*,
toolkits: t.Optional[t.List[str]] = None,
next_cursor: t.Optional[str] = None,
limit: t.Optional[int] = None,
is_connected: t.Optional[bool] = None,
search: t.Optional[str] = None,
) -> ToolkitConnectionsDetails:
"""
Get toolkit connection states for the session.
"""
from composio.core.models.tool_router import (
ToolkitConnectedAccount,
ToolkitConnection,
ToolkitConnectionAuthConfig,
ToolkitConnectionsDetails,
ToolkitConnectionState,
)
toolkits_params: t.Dict[str, t.Any] = {}
if next_cursor is not None:
toolkits_params["cursor"] = next_cursor
if limit is not None:
toolkits_params["limit"] = limit
if toolkits is not None:
toolkits_params["toolkits"] = toolkits
if is_connected is not None:
toolkits_params["is_connected"] = is_connected
if search is not None:
toolkits_params["search"] = search
result = self._client.tool_router.session.toolkits(
session_id=self.session_id,
**toolkits_params,
)
toolkit_states: t.List[ToolkitConnectionState] = []
for item in result.items:
connected_account = item.connected_account
auth_config: t.Optional[ToolkitConnectionAuthConfig] = None
connected_acc: t.Optional[ToolkitConnectedAccount] = None
if connected_account:
if connected_account.auth_config:
auth_config = ToolkitConnectionAuthConfig(
id=connected_account.auth_config.id,
mode=connected_account.auth_config.auth_scheme,
is_composio_managed=connected_account.auth_config.is_composio_managed,
)
connected_acc = ToolkitConnectedAccount(
id=connected_account.id,
status=connected_account.status,
)
connection = (
None
if item.is_no_auth
else ToolkitConnection(
is_active=(
connected_account.status == "ACTIVE"
if connected_account
else False
),
auth_config=auth_config,
connected_account=connected_acc,
)
)
toolkit_state = ToolkitConnectionState(
slug=item.slug,
name=item.name,
logo=item.meta.logo if item.meta else None,
is_no_auth=item.is_no_auth if item.is_no_auth else False,
connection=connection,
)
toolkit_states.append(toolkit_state)
return ToolkitConnectionsDetails(
items=toolkit_states,
next_cursor=result.next_cursor,
total_pages=int(result.total_pages),
)
def search(
self,
*,
query: str,
model: t.Optional[str] = None,
) -> SessionSearchResponse:
"""
Search for tools by semantic use case.
Returns relevant tools for the given query with schemas and guidance.
"""
return self._client.tool_router.session.search(
session_id=self.session_id,
queries=[{"use_case": query}],
model=model if model else omit,
experimental=inline_custom_tools_search_experimental(
self._inline_custom_tools_payload
),
)
def execute(
self,
tool_slug: str,
*,
arguments: t.Optional[t.Dict[str, t.Any]] = None,
account: t.Optional[str] = None,
) -> SessionExecuteResponse:
"""
Execute a tool within the session.
For custom tools, accepts the original slug (e.g. "GREP") or the
full slug (e.g. "LOCAL_GREP"). Custom tools are executed in-process;
remote tools are sent to the Composio backend.
:param account: Account ID or alias for direct app tool execution in
multi-account sessions. Helper/meta tools either ignore this
top-level field or define their own account-selection fields.
Both paths return a ``SessionExecuteResponse`` with ``data``,
``error``, and ``log_id`` attributes.
"""
from composio_client.types.tool_router.session_execute_response import (
SessionExecuteResponse,
)
# Check if this is a local tool (by original or final slug)
entry = find_custom_tool(self._custom_tools_map, tool_slug)
if entry and self._session_context:
result = execute_custom_tool(entry, arguments or {}, self._session_context)
return SessionExecuteResponse(
data=result["data"],
error=result["error"],
log_id="",
)
return self._client.tool_router.session.execute(
session_id=self.session_id,
tool_slug=tool_slug,
arguments=arguments if arguments is not None else omit,
account=account if account is not None else omit,
experimental=inline_custom_tools_execute_experimental(
self._inline_custom_tools_payload
),
)
def custom_tools(
self, *, toolkit: t.Optional[str] = None
) -> t.List[RegisteredCustomTool]:
"""List all custom tools registered in this session.
Returns tools with their final slugs, schemas, and resolved toolkit.
:param toolkit: Filter by toolkit slug (e.g. 'gmail', 'DEV_TOOLS')
:returns: Array of registered custom tools
"""
if not self._custom_tools_map:
return []
entries = list(self._custom_tools_map.by_final_slug.values())
if toolkit:
entries = [
e for e in entries if e.toolkit and e.toolkit.lower() == toolkit.lower()
]
return [
RegisteredCustomTool(
slug=entry.final_slug,
name=entry.handle.name,
description=entry.handle.description,
toolkit=entry.toolkit,
input_schema=entry.handle.input_schema,
output_schema=entry.handle.output_schema,
)
for entry in entries
]
def custom_toolkits(self) -> t.List[RegisteredCustomToolkit]:
"""List all custom toolkits registered in this session.
Returns toolkits with their tools showing final slugs.
"""
if not self._custom_tools_map or not self._custom_tools_map.toolkits:
return []
result = []
for tk in self._custom_tools_map.toolkits:
tools = []
for tool in tk.tools:
entry = self._custom_tools_map.by_original_slug.get(tool.slug.upper())
tools.append(
RegisteredCustomTool(
slug=entry.final_slug if entry else tool.slug,
name=tool.name,
description=tool.description,
toolkit=tk.slug,
input_schema=tool.input_schema,
output_schema=tool.output_schema,
)
)
result.append(
RegisteredCustomToolkit(
slug=tk.slug,
name=tk.name,
description=tk.description,
tools=tools,
)
)
return result
def proxy_execute(
self,
*,
toolkit: str,
endpoint: str,
method: t.Literal["GET", "POST", "PUT", "DELETE", "PATCH"],
body: t.Any = None,
parameters: t.Optional[t.List[t.Dict[str, t.Any]]] = None,
) -> SessionProxyExecuteResponse:
"""Proxy an API call through Composio's auth layer.
:param toolkit: Composio toolkit slug (e.g. 'gmail', 'github')
:param endpoint: API endpoint URL
:param method: HTTP method
:param body: Request body (for POST, PUT, PATCH)
:param parameters: Query/header parameters
:returns: Proxied API response
"""
return proxy_execute_impl(
self._client,
self.session_id,
toolkit=toolkit,
endpoint=endpoint,
method=method,
body=body,
parameters=parameters,
)
def update(
self,
*,
toolkits: t.Union[session_patch_params.Toolkits, "Omit"] = omit,
tools: t.Union[t.Dict[str, session_patch_params.Tools], "Omit"] = omit,
tags: t.Union[session_patch_params.Tags, "Omit"] = omit,
auth_configs: t.Union[t.Dict[str, str], "Omit"] = omit,
connected_accounts: t.Union[
t.Optional[t.Dict[str, SequenceNotStr[str]]], "Omit"
] = omit,
manage_connections: t.Union[
t.Optional[session_patch_params.ManageConnections], "Omit"
] = omit,
sandbox: t.Union[t.Optional[session_patch_params.Workbench], "Omit"] = omit,
workbench: t.Union[t.Optional[session_patch_params.Workbench], "Omit"] = omit,
multi_account: t.Union[
t.Optional[session_patch_params.MultiAccount], "Omit"
] = omit,
preload: t.Union[session_patch_params.Preload, "Omit"] = omit,
) -> None:
"""Partially update the session configuration.
Only the fields provided will be changed; omitted fields are preserved.
Mutates this session's ``preload`` in-place.
Pass ``None`` for ``manage_connections``, ``sandbox``/``workbench``, or
``multi_account`` to clear the stored value.
``workbench`` is a backwards-compatible alias for ``sandbox``. Prefer
``sandbox`` in new code.
All parameters use the same types as the Stainless-generated
``client.tool_router.session.patch()`` method.
"""
from composio.core.models.tool_router import _session_preload_config
if sandbox is not omit and workbench is not omit:
raise exceptions.InvalidParams(
"Pass either `sandbox` or `workbench`, not both. "
"`workbench` is a backwards-compatible alias for `sandbox`."
)
workbench_payload = sandbox if sandbox is not omit else workbench
response = self._client.tool_router.session.patch(
session_id=self.session_id,
toolkits=toolkits,
tools=tools,
tags=tags,
auth_configs=auth_configs,
connected_accounts=connected_accounts,
manage_connections=manage_connections,
workbench=workbench_payload,
multi_account=multi_account,
preload=preload,
)
self.preload = _session_preload_config(response.config.preload)
def delete(self) -> ToolRouterSessionDeleteResponse:
"""
Delete this session.
Deleted sessions immediately stop being retrievable or executable. An
already-deleted session surfaces the backend 404.
"""
return delete_tool_router_session(self._client, self.session_id)
class ToolRouterSessionWithMcp(ToolRouterSession[TTool, TToolCollection]):
"""A :class:`ToolRouterSession` whose hosted MCP endpoint is exposed.
Returned by ``create(..., mcp=True)`` / ``use(..., mcp=True)``. The ``mcp``
attribute is populated by the base ``__init__`` at runtime; this subclass
only surfaces it in the type. See
https://docs.composio.dev/docs/sessions-via-mcp
"""
#: Hosted MCP server configuration (url + auth headers) for this session.
#: This is the recommended MCP path, gated behind ``mcp=True``.
#: See https://docs.composio.dev/docs/sessions-via-mcp
mcp: "ToolRouterMCPServerConfig"
@@ -0,0 +1,39 @@
"""Tool router session deletion helper."""
from __future__ import annotations
import typing as t
from urllib.parse import quote
import typing_extensions as te
from composio.client import HttpClient
from composio.exceptions import ValidationError
class ToolRouterSessionDeleteResponse(te.TypedDict):
"""Response returned when a tool router session is deleted."""
session_id: str
deleted: t.Literal[True]
def delete_tool_router_session(
client: HttpClient,
session_id: str,
) -> ToolRouterSessionDeleteResponse:
"""Delete a tool router session by ID."""
response = client.delete(
f"/api/v3.1/tool_router/session/{quote(session_id, safe='')}",
cast_to=t.Dict[str, t.Any],
)
response_session_id = response.get("session_id")
deleted = response.get("deleted")
if not isinstance(response_session_id, str) or deleted is not True:
raise ValidationError("Invalid tool router session delete response")
return {
"session_id": response_session_id,
"deleted": True,
}
@@ -0,0 +1,351 @@
"""
Tool router session files mount - list, upload, download, delete files
in the session's virtual filesystem.
"""
from __future__ import annotations
import typing as t
from pathlib import Path
from urllib.parse import unquote, urlparse
import requests
from composio_client import omit
from composio_client.types.tool_router.session.file_create_download_url_response import (
FileCreateDownloadURLResponse,
)
from composio_client.types.tool_router.session.file_delete_response import (
FileDeleteResponse,
)
from composio_client.types.tool_router.session.file_list_response import (
FileListResponse,
)
from composio.client import HttpClient
from composio.exceptions import RemoteFileDownloadError, ValidationError
from composio.utils.mimetypes import get_extension_from_mime_type
from composio.utils.uuid import generate_short_id
DEFAULT_TOOL_ROUTER_SESSION_FILES_MOUNT_ID = "files"
COMPOSIO_DIR = ".composio"
TEMP_FILES_DIRECTORY_NAME = "files"
_MAX_RESPONSE_SIZE = 100 * 1024 * 1024 # 100 MB
_CONNECT_TIMEOUT = 5
_READ_TIMEOUT = 60
def _is_url(value: str) -> bool:
"""Check if a string is a valid HTTP/HTTPS URL."""
try:
parsed = urlparse(value)
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
except Exception:
return False
def _fetch_from_url(url: str) -> t.Tuple[bytes, str]:
"""Fetch file content from URL. Returns (content, mimetype)."""
try:
response = requests.get(
url,
stream=True,
allow_redirects=False,
timeout=(_CONNECT_TIMEOUT, _READ_TIMEOUT),
)
except requests.exceptions.RequestException as e:
raise ValidationError(f"Failed to fetch file from URL: {e}") from e
if response.status_code in (301, 302, 303, 307, 308):
response.close()
raise ValidationError(
"URL returned redirect. Please provide a direct URL to the file."
)
if not response.ok:
response.close()
raise ValidationError(
f"Failed to fetch file from URL: {response.status_code} {response.reason}"
)
content_length = response.headers.get("Content-Length")
if content_length and int(content_length) > _MAX_RESPONSE_SIZE:
response.close()
raise ValidationError(
f"File size ({int(content_length)} bytes) exceeds maximum allowed "
f"size ({_MAX_RESPONSE_SIZE} bytes)"
)
chunks: t.List[bytes] = []
total_bytes = 0
for chunk in response.iter_content(chunk_size=8192):
if chunk:
total_bytes += len(chunk)
if total_bytes > _MAX_RESPONSE_SIZE:
response.close()
raise ValidationError("Response size exceeds maximum allowed size")
chunks.append(chunk)
response.close()
mimetype = response.headers.get("content-type", "application/octet-stream")
mimetype = mimetype.split(";")[0].strip()
return b"".join(chunks), mimetype
class RemoteFile:
"""Represents a file stored in a tool router session's file mount.
Provides methods to fetch, save, and work with the file content.
"""
def __init__(
self,
*,
expires_at: str,
mount_relative_path: str,
sandbox_mount_prefix: str,
download_url: str,
) -> None:
self.expires_at = expires_at
self.mount_relative_path = mount_relative_path
self.sandbox_mount_prefix = sandbox_mount_prefix
self.download_url = download_url
@property
def filename(self) -> str:
"""Filename extracted from the mount path (e.g. 'report.pdf' from 'output/report.pdf')."""
return Path(self.mount_relative_path).name
def buffer(self) -> bytes:
"""Fetch the file content as bytes."""
try:
response = requests.get(
self.download_url,
timeout=(_CONNECT_TIMEOUT, _READ_TIMEOUT),
)
except requests.exceptions.RequestException as e:
raise RemoteFileDownloadError(
f"Failed to download file: {type(e).__name__}",
download_url=self.download_url,
mount_relative_path=self.mount_relative_path,
filename=self.filename,
) from e
if not response.ok:
raise RemoteFileDownloadError(
f"Failed to download file: {response.status_code} {response.reason}",
status_code=response.status_code,
status_text=response.reason,
download_url=self.download_url,
mount_relative_path=self.mount_relative_path,
filename=self.filename,
)
return response.content
def text(self) -> str:
"""Fetch the file content as UTF-8 text."""
return self.buffer().decode("utf-8")
def save(self, path: t.Optional[t.Union[str, Path]] = None) -> str:
"""Download and save the file to the local filesystem.
Returns the absolute path where the file was saved.
If path is omitted, saves to ~/.composio/files/ using the filename.
"""
content = self.buffer()
save_path: Path
if path is not None:
save_path = Path(path)
else:
# SEC-316 defense-in-depth: `self.filename` is `Path(mount_relative_path).name`,
# so directory components are already stripped — but verify the resolved
# default-location path stays inside the cache dir to reject residual `..`
# cases (e.g. ``mount_relative_path == ".."`` keeps ``filename == ".."``).
default_dir = Path.home() / COMPOSIO_DIR / TEMP_FILES_DIRECTORY_NAME
save_path = default_dir / self.filename
if not save_path.resolve().is_relative_to(default_dir.resolve()):
raise ValidationError(
f"Path traversal detected: filename {self.filename!r} resolves "
"outside the intended output directory."
)
save_path.parent.mkdir(parents=True, exist_ok=True)
save_path.write_bytes(content)
return str(save_path.resolve())
@classmethod
def from_api_response(cls, data: FileCreateDownloadURLResponse) -> "RemoteFile":
"""Create RemoteFile from API response."""
return cls(
expires_at=data.expires_at,
mount_relative_path=data.mount_relative_path,
sandbox_mount_prefix=data.sandbox_mount_prefix,
download_url=data.download_url,
)
class ToolRouterSessionFilesMount:
"""File mount for a tool router session - list, upload, download, delete."""
def __init__(self, client: HttpClient, session_id: str) -> None:
self._client = client
self._session_id = session_id
def list(
self,
*,
path: t.Optional[str] = None,
mount_id: str = DEFAULT_TOOL_ROUTER_SESSION_FILES_MOUNT_ID,
cursor: t.Optional[str] = None,
limit: t.Optional[int] = None,
) -> FileListResponse:
"""List files and directories at the specified path on the session's file mount.
Args:
path: Directory path to list. Use '/' or omit for root.
mount_id: ID of the file mount. Defaults to 'files'.
cursor: Pagination cursor from previous response's next_cursor.
limit: Max files per page (1-500).
Returns:
FileListResponse with items and next_cursor.
"""
raw_path = path or ""
if raw_path in ("", "/"):
mount_relative_prefix_arg: t.Any = omit
else:
prefix_str = raw_path[1:] if raw_path.startswith("/") else raw_path
mount_relative_prefix_arg = prefix_str if prefix_str else omit
return self._client.tool_router.session.files.list(
mount_id,
session_id=self._session_id,
mount_relative_prefix=mount_relative_prefix_arg,
cursor=cursor if cursor else omit,
limit=int(limit) if limit is not None else omit,
)
def _normalize_upload_input(
self,
input: t.Union[str, Path, bytes, bytearray],
*,
remote_path: t.Optional[str] = None,
mimetype: t.Optional[str] = None,
) -> t.Tuple[bytes, str, str]:
"""Normalize upload input to (content, remote_path, mimetype)."""
if isinstance(input, (str, Path)):
path_str = str(input)
if _is_url(path_str):
content, mime = _fetch_from_url(path_str)
parsed = urlparse(path_str)
pathname = unquote(parsed.path)
segments = [s for s in pathname.split("/") if s]
filename = segments[-1] if segments else ""
if not filename or "." not in filename:
ext = get_extension_from_mime_type(mime)
filename = f"{generate_short_id()}.{ext}"
rpath = remote_path or filename
return content, rpath, mime
# Local file
p = Path(path_str)
if not p.exists():
raise ValidationError(f"File not found: {p}")
if not p.is_file():
raise ValidationError(f"Not a file: {p}")
content = p.read_bytes()
mime = mimetype or "application/octet-stream"
rpath = remote_path or p.name
return content, rpath, mime
if isinstance(input, (bytes, bytearray)):
buffer = bytes(input)
if not mimetype and not remote_path:
raise ValidationError(
"When passing a buffer, either mimetype or remote_path (filename) "
"is required. Example: files.upload(buffer, remote_path='data.json', "
"mimetype='application/json')"
)
mime = mimetype or "application/octet-stream"
ext = get_extension_from_mime_type(mime)
rpath = remote_path or f"upload-{generate_short_id()}.{ext}"
return buffer, rpath, mime
raise ValidationError(
f"Unsupported input type: {type(input)}. "
"Use str (path/URL), Path, bytes, or bytearray."
)
def upload(
self,
input: t.Union[str, Path, bytes, bytearray],
*,
remote_path: t.Optional[str] = None,
mimetype: t.Optional[str] = None,
mount_id: str = DEFAULT_TOOL_ROUTER_SESSION_FILES_MOUNT_ID,
) -> RemoteFile:
"""Upload a file to the session's file mount.
Accepts a file path (local or URL), or raw bytes."""
content, rpath, mime = self._normalize_upload_input(
input, remote_path=remote_path, mimetype=mimetype
)
create_resp = self._client.tool_router.session.files.create_upload_url(
mount_id,
session_id=self._session_id,
mount_relative_path=rpath,
mimetype=mime,
)
try:
upload_resp = requests.put(
create_resp.upload_url,
data=content,
headers={"Content-Type": mime},
timeout=(_CONNECT_TIMEOUT, _READ_TIMEOUT),
)
except requests.exceptions.RequestException as e:
raise ValidationError(f"Failed to upload file: {type(e).__name__}") from e
if not upload_resp.ok:
raise ValidationError(
f"Failed to upload file: {upload_resp.status_code} {upload_resp.reason}"
)
download_resp = self._client.tool_router.session.files.create_download_url(
mount_id,
session_id=self._session_id,
mount_relative_path=create_resp.mount_relative_path,
)
return RemoteFile.from_api_response(download_resp)
def download(
self,
file_path: str,
*,
mount_id: str = DEFAULT_TOOL_ROUTER_SESSION_FILES_MOUNT_ID,
) -> RemoteFile:
"""Download a file from the session's file mount.
Returns a RemoteFile with download_url, buffer(), text(), save() methods."""
resp = self._client.tool_router.session.files.create_download_url(
mount_id,
session_id=self._session_id,
mount_relative_path=file_path,
)
return RemoteFile.from_api_response(resp)
def delete(
self,
remote_path: str,
*,
mount_id: str = DEFAULT_TOOL_ROUTER_SESSION_FILES_MOUNT_ID,
) -> FileDeleteResponse:
"""Delete a file or directory at the specified path on the session's file mount."""
return self._client.tool_router.session.files.delete(
mount_id,
session_id=self._session_id,
mount_relative_path=remote_path,
)
+187
View File
@@ -0,0 +1,187 @@
from __future__ import annotations
import typing as t
from composio import exceptions
from composio.client import HttpClient
from composio.client.types import (
AuthSchemeL,
toolkit_list_params,
toolkit_list_response,
toolkit_retrieve_response,
)
from composio.core.models.connected_accounts import ConnectedAccounts
from composio.utils.pydantic import none_to_omit
from .base import Resource
AuthFieldsT: t.TypeAlias = t.List[
toolkit_retrieve_response.AuthConfigDetailFieldsConnectedAccountInitiationRequired
| toolkit_retrieve_response.AuthConfigDetailFieldsConnectedAccountInitiationOptional
| toolkit_retrieve_response.AuthConfigDetailFieldsAuthConfigCreationRequired
| toolkit_retrieve_response.AuthConfigDetailFieldsAuthConfigCreationOptional
]
class Toolkits(Resource):
"""
Toolkits are a collectiono of tools that can be used to perform various tasks.
They're conceptualized as a set of tools. Ex: Github toolkit can perform
Github actions via its collection of tools. This is a replacement of the
`apps` concept in the earlier versions of the SDK.
"""
connected_accounts: ConnectedAccounts
def __init__(self, client: HttpClient):
super().__init__(client)
self.connected_accounts = ConnectedAccounts(client)
def list(
self,
*,
category: t.Optional[str] = None,
cursor: t.Optional[str] = None,
limit: t.Optional[float] = None,
sort_by: t.Optional[t.Literal["usage", "alphabetically"]] = None,
managed_by: t.Optional[t.Literal["composio", "all", "project"]] = None,
) -> toolkit_list_response.ToolkitListResponse:
"""List all toolkits."""
return self._client.toolkits.list(
category=none_to_omit(category),
cursor=none_to_omit(cursor),
limit=none_to_omit(limit),
managed_by=none_to_omit(managed_by),
sort_by=none_to_omit(sort_by),
)
@t.overload
def get(self) -> t.List[toolkit_list_response.Item]:
"""Get all toolkits."""
@t.overload
def get(self, slug: str) -> toolkit_retrieve_response.ToolkitRetrieveResponse:
"""Get a toolkit by slug."""
@t.overload
def get(
self,
*,
query: toolkit_list_params.ToolkitListParams,
) -> t.List[toolkit_list_response.Item]:
"""Get a list of toolkits by query."""
def get(
self,
slug: t.Optional[str] = None,
*,
query: t.Optional[toolkit_list_params.ToolkitListParams] = None,
) -> t.Union[
toolkit_retrieve_response.ToolkitRetrieveResponse,
t.List[toolkit_list_response.Item],
]:
if slug is not None:
return self._client.toolkits.retrieve(slug=slug)
return self._client.toolkits.list(**(query or {})).items
def list_categories(self):
"""List all categories of toolkits."""
return self._client.toolkits.retrieve_categories().items
def _get_auth_config_id(self, toolkit: str) -> str:
"""Get the auth config ID for a toolkit."""
auth_configs = self._client.auth_configs.list(toolkit_slug=toolkit)
if len(auth_configs.items) > 0:
(auth_config, *_) = sorted(
auth_configs.items,
key=lambda x: t.cast(str, x.created_at),
reverse=True,
)
return auth_config.id
return self._client.auth_configs.create(
toolkit={"slug": toolkit},
auth_config={
"type": "use_composio_managed_auth",
"tool_access_config": {
"tools_for_connected_account_creation": [],
},
},
).auth_config.id
def authorize(self, *, user_id: str, toolkit: str):
"""
Authorize a user to a toolkit
If auth config is not found, it will be created using composio managed auth.
:param user_id: The ID of the user to authorize.
:param toolkit: The slug of the toolkit to authorize.
:return: The connection request.
"""
return self.connected_accounts.initiate(
user_id=user_id,
auth_config_id=self._get_auth_config_id(
toolkit=toolkit,
),
)
def get_connected_account_initiation_fields(
self,
toolkit: str,
auth_scheme: AuthSchemeL,
required_only: bool = False,
) -> AuthFieldsT:
"""
Get the required property for a given toolkit and auth scheme.
"""
details = self._client.toolkits.retrieve(slug=toolkit).auth_config_details or []
for auth_detail in details:
if auth_detail.mode != auth_scheme:
continue
if required_only:
return t.cast(
AuthFieldsT,
auth_detail.fields.connected_account_initiation.required,
)
return t.cast(
AuthFieldsT,
auth_detail.fields.connected_account_initiation.required
+ auth_detail.fields.connected_account_initiation.optional,
)
raise exceptions.InvalidParams(
f"auth config details not found with {toolkit=} and {auth_scheme=}"
)
def get_auth_config_creation_fields(
self,
toolkit: str,
auth_scheme: AuthSchemeL,
required_only: bool = False,
) -> AuthFieldsT:
"""
Get the required property for a given toolkit and auth scheme.
"""
info = self._client.toolkits.retrieve(slug=toolkit)
for auth_detail in info.auth_config_details or []:
if auth_detail.mode != auth_scheme:
continue
if required_only:
return t.cast(
AuthFieldsT,
auth_detail.fields.auth_config_creation.required,
)
return t.cast(
AuthFieldsT,
auth_detail.fields.auth_config_creation.required
+ auth_detail.fields.auth_config_creation.optional,
)
raise exceptions.InvalidParams(
f"auth config details not found with {toolkit=} and {auth_scheme=}"
)
+770
View File
@@ -0,0 +1,770 @@
from __future__ import annotations
import functools
import typing as t
from pathlib import Path
import typing_extensions as te
from pydantic import BaseModel as PydanticBaseModel
from composio_client import omit
from composio.client import HttpClient
from composio.client.types import (
Tool,
tool_execute_params,
tool_proxy_params,
tool_proxy_response,
)
from composio.core.models._files import FileHelper
from composio.core.models.base import Resource
from composio.core.models.custom_tool_types import InlineCustomToolsWirePayload
from composio.core.models.inline_custom_tools_payload import (
inline_custom_tools_execute_experimental,
)
from composio.core.provider import TTool, TToolCollection
from composio.core.provider.agentic import AgenticProvider, AgenticProviderExecuteFn
from composio.core.provider.base import ExecuteToolFn
from composio.core.provider.base import BaseProvider
from composio.core.provider.none_agentic import NonAgenticProvider
from composio.core.types import ToolkitVersionParam
from composio.exceptions import InvalidParams, ToolVersionRequiredError
from composio.utils.pydantic import none_to_omit
from composio.utils.toolkit_version import get_toolkit_version
from composio.utils.upload_dir_allowlist import resolve_effective_upload_allowlist
from ._modifiers import (
Modifiers,
ToolExecuteParams,
after_execute,
apply_modifier_by_type,
before_execute,
before_file_upload,
merge_before_file_upload,
schema_modifier,
)
TOOL_ROUTER_SESSION_TOOLS_PAGE_LIMIT = 500
def _needs_serialization(obj: t.Any) -> bool:
"""Check if an object contains any Pydantic model instances."""
if isinstance(obj, PydanticBaseModel):
return True
if isinstance(obj, dict):
return any(_needs_serialization(v) for v in obj.values())
if isinstance(obj, list):
return any(_needs_serialization(item) for item in obj)
return False
def _serialize_value(obj: t.Any) -> t.Any:
"""Recursively convert Pydantic models to JSON-safe primitives."""
if isinstance(obj, PydanticBaseModel):
return obj.model_dump(mode="json")
if isinstance(obj, dict):
return {k: _serialize_value(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_serialize_value(item) for item in obj]
return obj
def _serialize_arguments(arguments: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]:
"""Serialize any Pydantic model instances in tool arguments to JSON-safe dicts.
Prevents JSON serialization errors when arguments contain complex types
(e.g., RootModel from discriminated union schemas). Returns the original
dict unchanged when no Pydantic models are present.
"""
if not _needs_serialization(arguments):
return arguments
return {k: _serialize_value(v) for k, v in arguments.items()}
class ToolExecutionResponse(te.TypedDict):
data: t.Dict
error: t.Optional[str]
successful: bool
class Tools(Resource, t.Generic[TTool, TToolCollection]):
"""
Tools class definition
This class is used to manage tools in the Composio SDK.
It provides methods to list, get, and execute tools.
Generic Parameters:
TTool: The individual tool type returned by the provider (e.g., ChatCompletionToolParam for OpenAI).
TToolCollection: The collection type returned by get() (e.g., list[ChatCompletionToolParam]).
The return type of get() is automatically inferred from the provider's generic parameters.
This works for both built-in providers (OpenAI, Anthropic, etc.) and custom providers.
"""
provider: BaseProvider[TTool, TToolCollection]
def __init__(
self,
client: HttpClient,
provider: BaseProvider[TTool, TToolCollection],
file_download_dir: t.Optional[str] = None,
toolkit_versions: t.Optional[ToolkitVersionParam] = None,
dangerously_allow_auto_upload_download_files: bool = False,
sensitive_file_upload_protection: bool = True,
file_upload_path_deny_segments: t.Optional[t.Sequence[str]] = None,
file_upload_dirs: t.Union[t.Sequence[str], t.Literal[False], None] = None,
):
"""
Initialize the tools resource.
:param client: The client to use for the tools resource.
:param provider: The provider to use for the tools resource.
:param file_download_dir: Output directory for downloadable files
:param toolkit_versions: The versions of the toolkits to use. Defaults to 'latest' if not provided.
:param dangerously_allow_auto_upload_download_files: Opt-in for automatic file upload/download. Defaults to False.
:param sensitive_file_upload_protection: When True, block local paths on the built-in sensitive-path denylist before upload.
:param file_upload_path_deny_segments: Extra path segment names to merge with the built-in denylist.
:param file_upload_dirs: Allowlist of directories from which local files may
be auto-uploaded. Only consulted when
``dangerously_allow_auto_upload_download_files=True``. See Composio docs
for details.
"""
self._auto_upload_download_files = bool(
dangerously_allow_auto_upload_download_files
)
# Resolve allowlist once, but only if auto-upload is enabled. When it's
# disabled there's no auto-upload code path, so we pass ``None`` and
# FileHelper won't run the allowlist check either way. Manual APIs (if
# ever added in the future) should also pass ``None``.
resolved_allowlist: t.Optional[t.List[Path]] = (
resolve_effective_upload_allowlist(file_upload_dirs)
if self._auto_upload_download_files
else None
)
self._client = client
self._tool_schemas: t.Dict[str, Tool] = {}
self._file_helper = FileHelper(
client=self._client,
outdir=file_download_dir,
sensitive_file_upload_protection=sensitive_file_upload_protection,
file_upload_path_deny_segments=file_upload_path_deny_segments,
file_upload_allowlist=resolved_allowlist,
)
self._toolkit_versions = toolkit_versions
self.provider = provider
self.provider.set_execute_tool_fn(
t.cast(
ExecuteToolFn,
functools.partial(
self.execute,
# Dangerously skip version check for provider controlled tool execution
dangerously_skip_version_check=True,
),
),
)
def get_raw_composio_tool_by_slug(self, slug: str) -> Tool:
"""
Returns schema for the given tool slug.
"""
return t.cast(
Tool,
self._client.tools.retrieve(
tool_slug=slug,
toolkit_versions=none_to_omit(self._toolkit_versions),
),
)
def get_raw_composio_tools(
self,
tools: t.Optional[list[str]] = None,
search: t.Optional[str] = None,
toolkits: t.Optional[list[str]] = None,
scopes: t.Optional[t.List[str]] = None,
limit: t.Optional[int] = None,
) -> list[Tool]:
"""
Get a list of tool schemas based on the provided filters.
"""
if tools is None and search is None and toolkits is None:
raise InvalidParams(
"Either `tools`, `search`, or `toolkits` must be provided"
)
tools_list = []
if tools is not None:
if len(tools):
tools_list.extend(
self._client.tools.list(
tool_slugs=",".join(tools),
toolkit_versions=none_to_omit(self._toolkit_versions),
).items
)
# Search tools by toolkit slugs and search term
if toolkits is not None or search is not None:
tools_list.extend(
self._client.tools.list(
toolkit_slug=none_to_omit(",".join(toolkits) if toolkits else None),
search=none_to_omit(search),
scopes=scopes,
limit=limit,
toolkit_versions=none_to_omit(self._toolkit_versions),
).items
)
return tools_list
def get_raw_tool_router_meta_tools(
self,
session_id: str,
modifiers: t.Optional["Modifiers"] = None,
) -> list[Tool]:
"""
Fetches the tools exposed by a tool router session.
This method fetches helper/meta tools and any preloaded app tools from the
Composio API and transforms them to the expected format. It provides access
to the underlying tool data without provider-specific wrapping.
:param session_id: The session ID to get the meta tools for
:param modifiers: Optional modifiers to apply to the tool schemas
:return: The list of meta tools
Example:
```python
from composio import Composio
composio = Composio()
tools_model = composio.tools
# Get meta tools for a session
meta_tools = tools_model.get_raw_tool_router_meta_tools("session_123")
print(meta_tools)
# Get meta tools with schema modifiers
from composio.core.models import schema_modifier
@schema_modifier
def modify_schema(tool: str, toolkit: str, schema):
# Customize the schema
schema.description = f"Modified: {schema.description}"
return schema
meta_tools = tools_model.get_raw_tool_router_meta_tools(
"session_123",
modifiers=[modify_schema]
)
```
"""
# Fetch session tools from the API
tools_list: t.List[Tool] = []
cursor: t.Optional[str] = None
while True:
tools_response = self._client.tool_router.session.tools(
session_id=session_id,
cursor=none_to_omit(cursor),
limit=TOOL_ROUTER_SESSION_TOOLS_PAGE_LIMIT,
)
# Cast to Tool type - session.tools returns compatible Item type from different response schema
tools_list.extend(t.cast(Tool, item) for item in tools_response.items)
cursor = getattr(tools_response, "next_cursor", None)
if not cursor:
break
# Apply schema modifiers if provided
if modifiers is not None:
from composio.core.models._modifiers import apply_modifier_by_type
tools_list = [
t.cast(
Tool,
apply_modifier_by_type(
modifiers=modifiers,
toolkit=tool.toolkit.slug,
tool=tool.slug,
type="schema",
schema=tool,
),
)
for tool in tools_list
]
self._tool_schemas.update(
{tool.slug: tool.model_copy(deep=True) for tool in tools_list}
)
return tools_list
def _get(
self,
user_id: str,
tools: t.Optional[list[str]] = None,
search: t.Optional[str] = None,
toolkits: t.Optional[list[str]] = None,
scopes: t.Optional[t.List[str]] = None,
modifiers: t.Optional[Modifiers] = None,
limit: t.Optional[int] = None,
) -> TToolCollection:
"""Get a list of tools based on the provided filters."""
tools_list = self.get_raw_composio_tools(
tools=tools,
search=search,
toolkits=toolkits,
scopes=scopes,
limit=limit,
)
if modifiers is not None:
tools_list = [
apply_modifier_by_type(
modifiers=modifiers,
toolkit=tool.toolkit.slug,
tool=tool.slug,
type="schema",
schema=tool,
)
for tool in tools_list
]
self._tool_schemas.update(
{tool.slug: tool.model_copy(deep=True) for tool in tools_list}
)
# Always enhance schema descriptions (type hints and required notes)
# regardless of dangerously_allow_auto_upload_download_files
for tool in tools_list:
tool.input_parameters = self._file_helper.enhance_schema_descriptions(
schema=tool.input_parameters,
)
# Only process file_uploadable schemas when the caller opted in via
# `dangerously_allow_auto_upload_download_files=True`.
if self._auto_upload_download_files:
for tool in tools_list:
tool.input_parameters = (
self._file_helper.process_file_uploadable_schema(
schema=tool.input_parameters,
)
)
if issubclass(type(self.provider), NonAgenticProvider):
return t.cast(
TToolCollection,
t.cast(
NonAgenticProvider[TTool, TToolCollection], self.provider
).wrap_tools(tools=tools_list),
)
return t.cast(
TToolCollection,
t.cast(AgenticProvider[TTool, TToolCollection], self.provider).wrap_tools(
tools=tools_list,
execute_tool=self._wrap_execute_tool(
user_id=user_id,
modifiers=modifiers,
),
),
)
def get(
self,
user_id: str,
*,
slug: t.Optional[str] = None,
tools: t.Optional[list[str]] = None,
search: t.Optional[str] = None,
toolkits: t.Optional[list[str]] = None,
scopes: t.Optional[t.List[str]] = None,
modifiers: t.Optional[Modifiers] = None,
limit: t.Optional[int] = None,
) -> TToolCollection:
"""
Get a tool or list of tools based on the provided arguments.
The return type is automatically inferred based on the provider's generic parameters.
For example:
- OpenAIProvider -> list[ChatCompletionToolParam]
- AnthropicProvider -> list[ToolParam]
- CustomProvider[MyTool, list[MyTool]] -> list[MyTool]
:param user_id: The user ID to get tools for.
:param slug: Get a single tool by slug.
:param tools: Get tools by a list of tool slugs.
:param search: Search tools by search term.
:param toolkits: Get tools from specific toolkits.
:param scopes: Filter by scopes.
:param modifiers: Optional modifiers to apply.
:param limit: Limit the number of tools returned.
:return: Provider-specific tool collection (TToolCollection).
"""
if slug is not None:
return self._get(
user_id=user_id,
tools=[slug],
modifiers=modifiers,
)
return self._get(
user_id=user_id,
tools=tools,
search=search,
toolkits=toolkits,
scopes=scopes,
modifiers=modifiers,
limit=limit,
)
def _wrap_execute_tool(
self,
modifiers: t.Optional[Modifiers] = None,
user_id: t.Optional[str] = None,
) -> AgenticProviderExecuteFn:
"""Wrap the execute tool function"""
return t.cast(
AgenticProviderExecuteFn,
functools.partial(
self.execute,
modifiers=modifiers,
user_id=user_id,
# Dangerously skip version check for agentic tool execution via providers
# This can be safe because most agentic flows users fetch latest version and then execute the tool
dangerously_skip_version_check=True,
),
)
def _wrap_execute_tool_for_tool_router(
self,
session_id: str,
modifiers: t.Optional[Modifiers] = None,
inline_custom_tools_payload: t.Optional[InlineCustomToolsWirePayload] = None,
) -> AgenticProviderExecuteFn:
"""
Create an execute function for tool router session tools.
This method creates a function that executes tools within a tool router session context.
It uses the session's execute endpoint which handles authentication and connection
management automatically.
:param session_id: The session ID
:param modifiers: Optional modifiers to apply before and after execution
:return: Execute function for tool router
"""
def execute_tool_fn(slug: str, arguments: t.Dict) -> t.Dict:
"""
Execute a tool in the tool router session.
This function is used by agentic providers to execute tools within
a tool router session context. It uses the session's execute
endpoint which handles authentication and connection management
automatically.
:param slug: The tool slug to execute
:param arguments: The tool arguments
:return: Tool execution response
"""
tool = self._tool_schemas.get(slug)
if tool is None:
tool = t.cast(
Tool,
self._client.tools.retrieve(
tool_slug=slug,
toolkit_versions=none_to_omit(self._toolkit_versions),
),
)
self._tool_schemas[slug] = tool
if self._auto_upload_download_files:
meta_tk = tool.toolkit.slug if tool.toolkit else "composio"
bfu = merge_before_file_upload(
modifiers,
tool=slug,
toolkit=meta_tk,
)
arguments = self._file_helper.substitute_file_uploads(
tool=tool,
request=arguments,
before_file_upload=bfu,
)
toolkit_slug = tool.toolkit.slug if tool.toolkit else "composio"
# Apply before_execute modifiers
processed_arguments = arguments
if modifiers is not None:
params: ToolExecuteParams = {
"arguments": arguments,
}
type_before: t.Literal["before_execute"] = "before_execute"
modified_params = apply_modifier_by_type(
modifiers=modifiers,
toolkit=toolkit_slug,
tool=slug,
type=type_before,
request=params,
)
processed_arguments = modified_params.get("arguments", arguments)
# Serialize any Pydantic model instances before sending to the API
processed_arguments = _serialize_arguments(processed_arguments)
response = self._client.tool_router.session.execute(
session_id=session_id,
tool_slug=slug,
arguments=processed_arguments,
# Provider-wrapped session tools are agentic calls, so they opt into
# direct tool offload when the backend session workbench allows it.
enable_auto_workbench_offload=True,
experimental=inline_custom_tools_execute_experimental(
inline_custom_tools_payload
),
)
# Convert response to standard format
result: ToolExecutionResponse = {
"data": response.data if hasattr(response, "data") else {},
"error": response.error if hasattr(response, "error") else None,
"successful": not (hasattr(response, "error") and response.error),
}
# Apply after_execute modifiers
if modifiers is not None:
type_after: t.Literal["after_execute"] = "after_execute"
result = apply_modifier_by_type(
modifiers=modifiers,
toolkit=toolkit_slug,
tool=slug,
type=type_after,
response=result,
)
return t.cast(t.Dict, result)
return t.cast(AgenticProviderExecuteFn, execute_tool_fn)
def _execute_tool(
self,
slug: str,
arguments: t.Dict,
connected_account_id: t.Optional[str] = None,
custom_auth_params: t.Optional[tool_execute_params.CustomAuthParams] = None,
custom_connection_data: t.Optional[
tool_execute_params.CustomConnectionData
] = None,
user_id: t.Optional[str] = None,
text: t.Optional[str] = None,
version: t.Optional[str] = None,
dangerously_skip_version_check: t.Optional[bool] = None,
) -> ToolExecutionResponse:
"""Execute a tool"""
# Serialize any Pydantic model instances in arguments to plain dicts
# before sending to the API (fixes PLEN-1514: RootModel not JSON serializable)
arguments = _serialize_arguments(arguments)
# Get the tool to determine its toolkit
tool = self.get_raw_composio_tool_by_slug(slug)
# If version is not explicitly provided, resolve it from instance-level toolkit versions
# This matches the TypeScript behavior - always resolve version when None
if version is None:
toolkit_slug = tool.toolkit.slug if tool.toolkit else "unknown"
# Use instance-level toolkit versions configuration
version = get_toolkit_version(toolkit_slug, self._toolkit_versions)
# Check if the version is 'latest' and dangerously_skip_version_check is not True
# If so, raise an error to prevent unexpected behavior
if version == "latest" and not dangerously_skip_version_check:
raise ToolVersionRequiredError()
return t.cast(
ToolExecutionResponse,
# Disable retries: tool execution is a non-idempotent write, and a
# silent retry after a read timeout can duplicate the side effect.
self._client.without_retries.tools.execute(
tool_slug=slug,
arguments=arguments,
connected_account_id=none_to_omit(connected_account_id),
custom_auth_params=none_to_omit(custom_auth_params),
custom_connection_data=none_to_omit(custom_connection_data),
user_id=none_to_omit(user_id),
text=none_to_omit(text),
version=none_to_omit(version),
).model_dump(
exclude={
"log_id",
"session_info",
}
),
)
def execute(
self,
slug: str,
arguments: t.Dict,
*,
connected_account_id: t.Optional[str] = None,
custom_auth_params: t.Optional[tool_execute_params.CustomAuthParams] = None,
custom_connection_data: t.Optional[
tool_execute_params.CustomConnectionData
] = None,
user_id: t.Optional[str] = None,
text: t.Optional[str] = None,
version: t.Optional[str] = None,
dangerously_skip_version_check: t.Optional[bool] = None,
modifiers: t.Optional[Modifiers] = None,
) -> ToolExecutionResponse:
"""
Execute a tool with the provided parameters.
This method calls the Composio API to execute the tool and returns the response.
:param slug: The slug of the tool to execute.
:param arguments: The arguments to pass to the tool.
:param connected_account_id: The ID of the connected account to use for the tool.
:param custom_auth_params: The custom auth params to use for the tool.
:param custom_connection_data: The custom connection data to use for the tool, takes priority over custom_auth_params.
:param user_id: The ID of the user to execute the tool for.
:param text: The text to pass to the tool.
:param version: The version of the tool to execute (overrides the SDK-level toolkit versions for this execution).
:param dangerously_skip_version_check: Skip the version check for 'latest' version. This might cause unexpected behavior when new versions are released.
:param modifiers: The modifiers to apply to the tool (include ``@before_file_upload`` for file path hooks).
:return: The response from the tool.
"""
tool = self._tool_schemas.get(slug)
if tool is None:
tool = t.cast(
Tool,
self._client.tools.retrieve(
tool_slug=slug,
toolkit_versions=none_to_omit(self._toolkit_versions),
),
)
self._tool_schemas[slug] = tool
if self._auto_upload_download_files:
tk = tool.toolkit.slug if tool.toolkit else "unknown"
bfu = merge_before_file_upload(
modifiers,
tool=slug,
toolkit=tk,
)
arguments = self._file_helper.substitute_file_uploads(
tool=tool,
request=arguments,
before_file_upload=bfu,
)
if modifiers is not None:
type_before_exec: t.Literal["before_execute"] = "before_execute"
request_params: ToolExecuteParams = {
"arguments": arguments,
}
if connected_account_id is not None:
request_params["connected_account_id"] = connected_account_id
if custom_auth_params is not None:
request_params["custom_auth_params"] = custom_auth_params
if custom_connection_data is not None:
request_params["custom_connection_data"] = custom_connection_data
if version is not None:
request_params["version"] = version
if text is not None:
request_params["text"] = text
if user_id is not None:
request_params["user_id"] = user_id
if dangerously_skip_version_check is not None:
request_params["dangerously_skip_version_check"] = (
dangerously_skip_version_check
)
processed_params = apply_modifier_by_type(
modifiers=modifiers,
toolkit=tool.toolkit.slug,
tool=slug,
type=type_before_exec,
request=request_params,
)
connected_account_id = processed_params.get(
"connected_account_id", connected_account_id
)
custom_auth_params = processed_params.get(
"custom_auth_params", custom_auth_params
)
custom_connection_data = processed_params.get(
"custom_connection_data", custom_connection_data
)
text = processed_params.get("text", text)
version = processed_params.get("version", version)
user_id = processed_params.get("user_id", user_id)
arguments = processed_params["arguments"]
dangerously_skip_version_check = processed_params.get(
"dangerously_skip_version_check", dangerously_skip_version_check
)
response = self._execute_tool(
slug=slug,
arguments=arguments,
connected_account_id=connected_account_id,
custom_auth_params=custom_auth_params,
custom_connection_data=custom_connection_data,
user_id=user_id,
text=text,
version=version,
dangerously_skip_version_check=dangerously_skip_version_check,
)
if self._auto_upload_download_files:
response = self._file_helper.substitute_file_downloads(
tool=tool,
response=response,
)
if modifiers is not None:
response = apply_modifier_by_type(
modifiers=modifiers,
toolkit=tool.toolkit.slug,
tool=slug,
type="after_execute",
response=response,
)
return response
def proxy(
self,
endpoint: str,
method: t.Literal["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"],
body: t.Optional[object] = None,
connected_account_id: t.Optional[str] = None,
parameters: t.Optional[t.List[tool_proxy_params.Parameter]] = None,
custom_connection_data: t.Optional[
tool_proxy_params.CustomConnectionData
] = None,
) -> tool_proxy_response.ToolProxyResponse:
"""Proxy a tool call to the Composio API"""
# Disable retries: a proxied call is a non-idempotent write, and a silent
# retry after a read timeout can duplicate the side effect.
return self._client.without_retries.tools.proxy(
endpoint=endpoint,
method=method,
body=body if body is not None else omit,
connected_account_id=connected_account_id
if connected_account_id is not None
else omit,
parameters=parameters if parameters is not None else omit,
custom_connection_data=custom_connection_data
if custom_connection_data is not None
else omit,
)
__all__ = [
"Tools",
"ToolExecuteParams",
"ToolExecutionResponse",
"Modifiers",
"after_execute",
"before_execute",
"before_file_upload",
"schema_modifier",
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,191 @@
"""
Webhook event types for typed event handling.
This module provides TypedDict definitions for specific webhook event types,
enabling type-safe handling of events like connection expiration.
"""
from __future__ import annotations
import typing as t
from enum import Enum
import typing_extensions as te
class WebhookEventType(str, Enum):
"""Known webhook event types."""
CONNECTION_EXPIRED = "composio.connected_account.expired"
TRIGGER_MESSAGE = "composio.trigger.message"
class ConnectionStatusEnum(str, Enum):
"""Connection status values."""
INITIALIZING = "INITIALIZING"
INITIATED = "INITIATED"
ACTIVE = "ACTIVE"
FAILED = "FAILED"
EXPIRED = "EXPIRED"
INACTIVE = "INACTIVE"
REVOKED = "REVOKED"
# =============================================================================
# CONNECTED ACCOUNT DATA (matches GET /api/v3/connected_accounts/{id})
# =============================================================================
class ConnectedAccountToolkit(te.TypedDict):
"""Toolkit information in connected account response."""
slug: str
class ConnectedAccountAuthConfigDeprecated(te.TypedDict, total=False):
"""Deprecated auth config fields."""
uuid: str
class ConnectedAccountAuthConfig(te.TypedDict, total=False):
"""Auth config details in connected account response."""
id: te.Required[str]
auth_scheme: te.Required[str]
is_composio_managed: te.Required[bool]
is_disabled: te.Required[bool]
deprecated: ConnectedAccountAuthConfigDeprecated
class ConnectionStateVal(te.TypedDict, total=False):
"""Connection state value - varies by auth scheme."""
status: str
# OAuth2 fields
access_token: str
refresh_token: t.Optional[str]
token_type: str
expires_in: t.Union[int, str, None]
scope: t.Union[str, t.List[str], None]
id_token: str
code_verifier: str
callback_url: str
# OAuth1 fields
oauth_token: str
oauth_token_secret: str
# API Key fields
api_key: str
generic_api_key: str
# Bearer Token fields
token: str
# Basic auth fields
username: str
password: str
class ConnectionState(te.TypedDict):
"""Connection state data discriminated by auth scheme."""
authScheme: str
val: ConnectionStateVal
class ConnectedAccountDeprecated(te.TypedDict, total=False):
"""Deprecated connected account fields."""
labels: t.List[str]
uuid: str
class SingleConnectedAccountDetailedResponse(te.TypedDict, total=False):
"""
Connected account data matching GET /api/v3/connected_accounts/{id} response.
This is used in webhook payloads for connection lifecycle events.
It intentionally does NOT reuse composio_client's ConnectedAccountRetrieveResponse
because webhook payloads arrive in raw snake_case format, while the SDK client
transforms responses to a different shape. This TypedDict validates the raw
webhook payload directly without any transformation layer.
See Also:
composio_client.types.connected_account_retrieve_response for the SDK version.
"""
toolkit: te.Required[ConnectedAccountToolkit]
auth_config: te.Required[ConnectedAccountAuthConfig]
id: te.Required[str]
user_id: te.Required[str]
status: te.Required[str] # ConnectionStatusEnum value
created_at: te.Required[str]
updated_at: te.Required[str]
state: te.Required[ConnectionState]
data: te.Required[t.Dict[str, t.Any]]
params: te.Required[t.Dict[str, t.Any]]
status_reason: te.Required[t.Optional[str]]
is_disabled: te.Required[bool]
test_request_endpoint: str
deprecated: ConnectedAccountDeprecated
# =============================================================================
# CONNECTION EXPIRED WEBHOOK EVENT
# =============================================================================
class WebhookConnectionMetadata(te.TypedDict):
"""Webhook metadata for connection events."""
project_id: str
org_id: str
class ConnectionExpiredEvent(te.TypedDict):
"""
Connection expired webhook event payload.
Emitted when a connected account expires due to authentication refresh failure.
Example:
>>> from composio.core.models.webhook_events import is_connection_expired_event
>>>
>>> def handle_webhook(payload: dict) -> None:
... if is_connection_expired_event(payload):
... print(f"Connection {payload['data']['id']} expired")
... print(f"Toolkit: {payload['data']['toolkit']['slug']}")
... print(f"User: {payload['data']['user_id']}")
"""
id: str # Unique message ID (e.g., "msg_847cdfcd-d219-4f18-a6dd-91acd42ca94a")
timestamp: str # ISO-8601 timestamp
type: t.Literal["composio.connected_account.expired"]
data: SingleConnectedAccountDetailedResponse
metadata: WebhookConnectionMetadata
# Type alias for non-trigger webhook events with specific typed schemas.
# Trigger events (composio.trigger.message) are handled through the
# TriggerEvent type in triggers.py via the trigger subscription system.
WebhookEvent = t.Union[ConnectionExpiredEvent]
def is_connection_expired_event(
payload: t.Dict[str, t.Any],
) -> t.TypeGuard[ConnectionExpiredEvent]:
"""
Check if a webhook payload is a connection expired event.
:param payload: The webhook payload to check
:return: True if this is a connection expired event
Example:
>>> from composio.core.models.webhook_events import is_connection_expired_event
>>>
>>> if is_connection_expired_event(payload):
... handle_connection_expired(payload) # payload is narrowed to ConnectionExpiredEvent
"""
return (
isinstance(payload, dict)
and payload.get("type") == WebhookEventType.CONNECTION_EXPIRED.value
)