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
+186
View File
@@ -0,0 +1,186 @@
############################
## Project Specific Items ##
############################
try.ipynb
refactor_debug.ipynb
temp
.vscode
#############################################
## From @Github/gitignore/Python.gitignore ##
#############################################
# Byte-compiled / optimized / DLL files
#
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
+3
View File
@@ -0,0 +1,3 @@
[submodule "client"]
path = client
url = git@github.com:composiohq/composio-base-py
+42
View File
@@ -0,0 +1,42 @@
# AGENTS.md
Python SDK guidance for AI agents.
## Scope
`python/` contains the Python SDK, provider packages, tests, nox sessions, release scripts, and Python docs.
## Skill Routing
- Use `python-sdk` for core SDK code under `python/composio/`.
- Use `python-providers` for `python/providers/*`.
- Use `python-testing` for Ruff, mypy, pytest, nox, and Makefile verification.
- Use `python-release` for build, bump, and publishing workflow changes.
- Use `cross-sdk-parity` when matching TypeScript SDK behavior.
## Setup
Run from `python/`:
```bash
make env
source .venv/bin/activate
```
## Commands
```bash
make fmt
make chk
make tst
make snt
make type_inference
make build
```
## Rules
- Use Ruff for formatting/linting and mypy for type checks.
- Add pytest coverage for behavior changes.
- Keep provider-specific changes under the relevant `python/providers/<provider>/` package.
- When bumping `composio-client`, update `python/pyproject.toml`, `python/setup.py`, and root `uv.lock` together.
+148
View File
@@ -0,0 +1,148 @@
# Changelog
All notable changes to the Composio Python SDK will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> Versions between `0.8.11` and `0.13.0` were released without CHANGELOG entries. See the [Git commit log](https://github.com/ComposioHQ/composio/commits/next/python) for changes in that window.
## [0.17.1] - 2026-06-28
### Added
- **Tool Router session deletion**: delete sessions from Python via `composio.sessions.delete(session_id)` or `session.delete()`, matching the TypeScript SDK surface.
### Changed
- Bumped the Python SDK and provider packages from `0.17.0` to `0.17.1`.
## [0.17.0] - 2026-06-26
### Added
- **`triggers.parse()`**: parse and optionally verify an incoming webhook request, mirroring the TypeScript SDK. Pass `verify_secret` to validate the signature; omit it to skip verification explicitly. A present-but-empty `verify_secret` (e.g. an unset `COMPOSIO_WEBHOOK_SECRET`) raises rather than silently skipping verification.
- **`triggers.set_webhook_subscription()`**: create or update the project webhook subscription from the Python SDK.
- **`connected_accounts.update_acl()`**: the experimental shared-connection ACL patch helper graduated to `connected_accounts`; `experimental.update_acl()` remains as a deprecated alias.
### Changed
- **`composio.sessions` is the canonical sessions entrypoint**: use `composio.sessions.create(...)` (or the `composio.create` / `composio.use` shortcuts). `composio.tool_router` is now a deprecated alias for the same object.
- **MCP is now opt-in for sessions**: `composio.create()` / `composio.use()` return native-tool sessions by default and select the MCP-typed session only when `mcp=True`. The runtime object is unchanged.
- **Prefer `sandbox` for session code-execution config**: the `sandbox` key is now preferred; the existing `workbench` key is still accepted as an alias.
- Bumped the Python SDK and provider packages from `0.16.0` to `0.17.0`.
## [0.16.0] - 2026-06-25
### Fixed
- **Auto file upload/download behind `$ref`/`$defs`**: tool schemas that express their file fields through a `$ref`/`$defs` indirection are now resolved before file handling, so `file_uploadable` inputs and `file_downloadable` outputs reachable only through a reference are no longer silently skipped when `dangerously_allow_auto_upload_download_files=True`. `GMAIL_GET_ATTACHMENT` (whose `file_downloadable` flag sits two `$ref` hops deep) is the canonical case. Adds a cycle-safe, depth-capped `dereference_json_schema` utility with Draft 2020-12 sibling-keyword merge, mirroring the TypeScript SDK's `dereferenceJsonSchema`.
- **No retries on non-idempotent tool writes**: `tools.execute()` and `tools.proxy()` are non-idempotent POST writes, but the Stainless-generated client retried them by default (on read timeouts, 429s, and 5xx), which could duplicate side effects — e.g. send an email twice. Both paths now route through a cached `client.without_retries` clone (`max_retries=0`); idempotent reads and lists keep the default retries. This is an interim fix ahead of backend idempotency keys.
- **Bounded timeouts on file transfers**: session file uploads/downloads and the presigned S3 `PUT`/`GET` paths now use bounded `(connect, read)` timeouts, and timeout or request failures surface as the SDK's typed file errors instead of hanging.
- **Provider-visible tool schema aliasing**: centralized aliasing via `alias_tool_input_schema` / `restore_tool_arguments`, applied across the Anthropic, Claude Agent SDK, Gemini, Google ADK, and OpenAI Agents providers. Aliases invalid Python parameter names (e.g. `$top`), preserves the existing `from``from_rs` style, avoids leading-underscore aliases so Pydantic-backed providers can build models, caps aliases at 64 characters for Anthropic-style validators, and dereferences internal `$ref`/`$defs` before aliasing. Original backend argument names are restored before execution.
- **OpenAI Agents schema mutation**: the tool schema is now deep-copied before its `examples`/`default` fields are stripped, so the source schema is no longer mutated in place.
### Changed
- Bumped the `composio-client` dependency from `1.39.0``1.41.0`.
- Bumped the Python SDK and provider packages from `0.15.0` to `0.16.0`.
- Refreshed Python core and provider dependency ranges, plus root and provider uv locks, to the newest compatible versions.
## [0.15.0] - 2026-06-16
### Fixed
- Forwarded `user_id` from `triggers.create(..., user_id=...)` into the trigger upsert request via `extra_body`, so 2FA-enabled trigger projects can verify the pinned connected account belongs to the caller.
### Changed
- Bumped the Python SDK and provider packages from `0.14.0` to `0.15.0`.
## [0.14.0] - 2026-06-05
### Removed
- Removed the legacy `composio.tools.custom_tool` registry path, including `core.models.custom_tools`, `ExecuteRequestFn`, the old fallback execution wiring, the old security tests, and the legacy custom-tools example.
### Changed
- Kept custom tools on the 2026 tool-router surface: `composio.experimental.tool()`, `composio.experimental.Toolkit`, inline execution, preload/attach/use flows, and `session.custom_tools()`.
- Bumped the Python SDK and provider packages from `0.13.1` to `0.14.0`.
## [0.13.1] - 2026-05-13
### Added
- **SHARED connected accounts (experimental)**: Connections can now be marked SHARED at create time and reached from a tool-router session by other `user_id`s when explicitly pinned and authorised via a per-user ACL. Mirrors the TypeScript SDK's `@composio/core@0.10.0` release.
- `composio.connected_accounts.link(..., experimental={"account_type": "SHARED", "acl_config_for_shared": {...}})` to create.
- `composio.experimental.update_acl(nanoid, allow_all_users=..., allowed_user_ids=[...], not_allowed_user_ids=[...])` to update the per-user ACL on an existing SHARED connection. PATCH semantics: omit a field to leave it unchanged; pass `[]` to clear an allow/deny list.
- `session.authorize(toolkit, experimental={...})` for the same flow inside a tool-router session.
- `composio.connected_accounts.list(account_type='PRIVATE' | 'SHARED' | 'ALL')` filter.
- New typed errors: `ComposioAclOnlyForSharedError`, `ComposioSharedAccessDeniedError`, `ComposioSharedConnectionNotAccessibleError`.
- **Experimental — shape may change in future releases.** Pinning a SHARED connection in a session config and direct execute by `connected_account_id` are unchanged.
- **`composio.experimental` namespace**: new module housing experimental SDK methods that take a client (`update_acl`) alongside the existing decorators (`tool`, `Toolkit`).
### Fixed
- **`initiate()` deprecation warning false-positives**: the legacy `POST /api/v3/connected_accounts` deprecation warning is now gated on the response `Deprecation` HTTP header (RFC 9745) rather than the `auth_scheme` of the request. Custom auth configs and non-OAuth schemes no longer trigger a spurious warning. The legacy endpoint's retired-path 400 is also surfaced as a typed `ComposioLegacyConnectedAccountsEndpointRetiredError`. See https://docs.composio.dev/docs/changelog/2026/04/24.
- **Schema converter nullability**: `null`-only `anyOf` blocks now preserve nullability instead of dropping to a bare `str` fallback.
### Changed
- Bumped `composio-client` dependency from `1.36.0``1.39.0` so the generated typed client carries the `Experimental` TypedDicts for the SHARED-connection surface.
## [0.8.11] - 2025-09-10
### Added
- **Composio Connect Link Support**: New link() method for creating external authentication links
- Added `link()` method to ConnectedAccounts class for generating user authentication links
- Support for callback URL redirection after authentication
- Enhanced user experience with external link-based authentication flow
- Includes comprehensive documentation and usage examples
### Fixed
- **Documentation**: Fixed typo in connected account creation function docstring
- Corrected "coneected" to "connected" in function documentation
## [0.8.10] - 2024-12-19
### Added
- **LlamaIndex Provider**: New LlamaIndex provider for enhanced AI framework integration
- Complete provider implementation with demo and documentation (`python/providers/llamaindex/`)
- Support for LlamaIndex-specific tool execution patterns
- Includes provider.py, demo script, and proper packaging setup
### Changed
- **Schema Parsing**: Improved OpenAPI schema parsing with default fallback to 'any' type for invalid schemas
- Enhanced `python/composio/utils/openapi.py` to handle malformed schemas gracefully
- More robust type inference for edge cases where schema type is missing or invalid
### Fixed
- **Type Checking**: Fixed type checking issues in core validation logic
- Updated `python/composio/core/models/toolkits.py` for better type safety
- Added proper type annotations and validation
- **Sentinel Value Checks**: Fixed sentinel value validation in core models
- Enhanced validation in `python/composio/core/models/toolkits.py`
- Improved handling in `python/composio/core/models/tools.py` and `python/composio/core/models/triggers.py`
- Added proper sentinel value checks in `python/composio/types.py`
- **Build System**: Updated noxfile.py for improved development workflow
## [0.8.9] - 2024-11-XX
### Added
- Initial stable release with core functionality
- Support for multiple AI frameworks (OpenAI, Anthropic, Google, etc.)
- Comprehensive tool execution capabilities
- Authentication and connected account management
- File upload and download support
- Webhook and trigger system
- Multi-provider support architecture
### Features
- Tool discovery and execution
- Connected account management
- Authentication configuration
- File handling and processing
- Webhook integration
- Trigger system for automated workflows
- Support for custom tools and integrations
---
## Version History
For detailed commit history and technical changes, see the [Git commit log](https://github.com/composio/composio/commits/main).
## Support
For questions, issues, or contributions, please visit:
- [GitHub Repository](https://github.com/composio/composio)
- [Documentation](https://docs.composio.dev)
- [Community Discord](https://discord.gg/composio)
+82
View File
@@ -0,0 +1,82 @@
.PHONY: clean-build sync provider create-provider env fmt chk snt tst type_inference dead-code bump build format check sanity test
clean-build:
@rm -rf dist/ build/
@for provider in $(shell ls -d providers/*); do\
rm -rf build $$provider/dist $$provider/build;\
done
sync:
@uv sync
@uv pip install -e .
provider:
@for provider in $(shell ls -d providers/*); do\
uv pip install $$provider;\
done
create-provider:
@if [ -z "$(name)" ]; then\
echo "Please provide a provider name: make create-provider name=<provider-name> [agentic=true] [output=<directory>]";\
exit 1;\
fi
@ARGS="$(name)";\
if [ "$(agentic)" = "true" ]; then\
ARGS="$$ARGS --agentic";\
fi;\
if [ ! -z "$(output)" ]; then\
ARGS="$$ARGS --output-dir $(output)";\
fi;\
bash scripts/create-provider.sh $$ARGS
env:
@echo "* creating new environment"
@if [ -z "$$VIRTUAL_ENV" ];\
then\
uv venv --seed --prompt composio --python 3.12;\
uv sync;\
uv sync --dev;\
make provider;\
uv pip install -e .;\
echo "* enter virtual environment with all development dependencies now";\
else\
uv sync;\
uv pip install -e .;\
echo "* already in a virtual environment (exit first ('deactivate') to create a new environment)";\
fi
@echo "* run 'source .venv/bin/activate' to enter the development environment."
fmt:
@nox -s fmt
chk:
@nox -s chk
dead-code:
@nox -s dead_code
snt:
@nox -s snt
tst:
@nox -s tst
type_inference:
@nox -s type_inference
# Friendly aliases for the short session names above (e.g. `make test` == `make tst`).
format: fmt
check: chk
sanity: snt
test: tst
bump: clean-build
@uv run python scripts/bump.py
build: clean-build
@./.venv/bin/python -m build
@for provider in $(shell ls -d providers/*); do\
./.venv/bin/python -m build $$provider;\
cp $$provider/dist/* dist/;\
done
+141
View File
@@ -0,0 +1,141 @@
<p align="center">
<a href="https://composio.dev">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://brand.composio.dev/logos/Logomark-White.svg">
<img alt="Composio logo" src="https://brand.composio.dev/logos/Logomark-Black.svg" width="96">
</picture>
</a>
</p>
# Composio Python SDK
Composio gives your AI agents 1000+ pre-authenticated toolkits, per-user sessions, authentication, triggers, and a sandbox. This package is the Python SDK.
- [Documentation](https://docs.composio.dev)
- [Quickstart](https://docs.composio.dev/docs/quickstart)
- [Dashboard](https://dashboard.composio.dev) (grab your `COMPOSIO_API_KEY` from [Settings](https://dashboard.composio.dev/settings))
## Install
Requires Python 3.10+.
```bash
pip install composio
```
## Quickstart
Create a session for one of your users and hand its tools to your agent:
```python
from composio import Composio
composio = Composio() # reads COMPOSIO_API_KEY, or pass api_key=...
session = composio.create(user_id="user_123")
tools = session.tools() # OpenAI-format tool definitions by default
```
By default a session gets meta tools that discover, authenticate, and execute app tools at runtime, so you don't load hundreds of tool definitions into context. Store `session.session_id` and reuse the session across turns:
```python
session = composio.use(session_id)
```
See [how Composio works](https://docs.composio.dev/docs/how-composio-works) for sessions and meta tools, and [configuring sessions](https://docs.composio.dev/docs/configuring-sessions) for restricting `toolkits`, `tools`, `auth_configs`, and `connected_accounts` on `composio.create()`.
## Use with an agent framework
Provider packages adapt `session.tools()` to your framework's native tool format. With [OpenAI Agents](https://github.com/ComposioHQ/composio/tree/next/python/providers/openai_agents):
```bash
pip install composio composio-openai-agents openai-agents
```
```python
from composio import Composio
from composio_openai_agents import OpenAIAgentsProvider
from agents import Agent, Runner
composio = Composio(provider=OpenAIAgentsProvider())
session = composio.create(user_id="user_123")
tools = session.tools()
agent = Agent(
name="Personal Assistant",
instructions="You are a helpful assistant. Use Composio tools to take action.",
tools=tools,
)
result = Runner.run_sync(starting_agent=agent, input="Summarize my emails from today")
print(result.final_output)
```
Other Python providers: [`composio-openai`](https://github.com/ComposioHQ/composio/tree/next/python/providers/openai), [`composio-anthropic`](https://github.com/ComposioHQ/composio/tree/next/python/providers/anthropic), [`composio-claude-agent-sdk`](https://github.com/ComposioHQ/composio/tree/next/python/providers/claude_agent_sdk), [`composio-langchain`](https://github.com/ComposioHQ/composio/tree/next/python/providers/langchain), [`composio-langgraph`](https://github.com/ComposioHQ/composio/tree/next/python/providers/langgraph), [`composio-llamaindex`](https://github.com/ComposioHQ/composio/tree/next/python/providers/llamaindex), [`composio-crewai`](https://github.com/ComposioHQ/composio/tree/next/python/providers/crewai), [`composio-autogen`](https://github.com/ComposioHQ/composio/tree/next/python/providers/autogen), [`composio-gemini`](https://github.com/ComposioHQ/composio/tree/next/python/providers/gemini), [`composio-google`](https://github.com/ComposioHQ/composio/tree/next/python/providers/google), [`composio-google-adk`](https://github.com/ComposioHQ/composio/tree/next/python/providers/google_adk). Don't see yours? [Build a custom provider](https://docs.composio.dev/docs/providers/custom-providers).
## MCP
Every session also exposes a hosted MCP endpoint. Pass `mcp=True` and point Claude, Cursor, or any MCP client at it:
```python
from composio import Composio
composio = Composio()
session = composio.create(user_id="user_123", mcp=True)
print(session.mcp.url) # MCP endpoint for this session
print(session.mcp.headers) # auth headers for the endpoint
```
See [sessions via MCP](https://docs.composio.dev/docs/sessions-via-mcp).
## Authentication
Sessions manage connections for you by default; the agent walks the user through OAuth with the session's meta tools. To drive the flow yourself, authorize a toolkit from the session:
```python
connection_request = session.authorize("gmail")
print(connection_request.redirect_url) # send the user here to approve access
connection_request.wait_for_connection()
```
See [authentication](https://docs.composio.dev/docs/authentication) for auth configs, custom OAuth apps, and connection lifecycle.
## Triggers
Subscribe to events from connected apps (new email, new commit, and so on) and react to them:
```python
from composio import Composio
composio = Composio()
trigger = composio.triggers.create(
slug="GITHUB_COMMIT_EVENT",
user_id="user_123",
trigger_config={"owner": "composiohq", "repo": "composio"},
)
subscription = composio.triggers.subscribe()
@subscription.handle(trigger_id=trigger.trigger_id)
def handle_event(data):
print("Event received:", data)
subscription.wait_forever()
```
`subscribe()` streams events over a WebSocket for local development. In production, register a webhook URL and parse deliveries with `composio.triggers.parse()`. See [setting up triggers](https://docs.composio.dev/docs/setting-up-triggers/creating-triggers).
## Development
This package lives in the [Composio SDK monorepo](https://github.com/ComposioHQ/composio) under `python/`. See the [contribution guidelines](https://github.com/ComposioHQ/composio/blob/next/CONTRIBUTING.md) to get set up.
## Support
- [Documentation](https://docs.composio.dev)
- [Python SDK reference](https://docs.composio.dev/reference/sdk-reference/python)
- [Discord community](https://discord.gg/composio)
- [Open an issue](https://github.com/ComposioHQ/composio/issues)
- [support@composio.dev](mailto:support@composio.dev)
+57
View File
@@ -0,0 +1,57 @@
from .__version__ import __version__
from .core.models.custom_tool import ExperimentalToolkit
from .core.models.custom_tool_types import (
CustomTool,
SessionContext,
)
from .core.models.tool_router_constants import SESSION_PRESET_DIRECT_TOOLS
from .core.models.tool_router_session_files import RemoteFile
from .core.models.tools import (
after_execute,
before_execute,
before_file_upload,
schema_modifier,
)
from .core.models.webhook_events import (
ConnectionExpiredEvent,
ConnectionState,
ConnectionStatusEnum,
SingleConnectedAccountDetailedResponse,
WebhookConnectionMetadata,
WebhookEvent,
WebhookEventType,
is_connection_expired_event,
)
from .core.types import (
ToolkitLatestVersion,
ToolkitVersion,
ToolkitVersionParam,
ToolkitVersions,
)
from .sdk import Composio
__all__ = (
"Composio",
"CustomTool",
"ExperimentalToolkit",
"RemoteFile",
"SessionContext",
"SESSION_PRESET_DIRECT_TOOLS",
"ConnectionExpiredEvent",
"ConnectionState",
"ConnectionStatusEnum",
"SingleConnectedAccountDetailedResponse",
"WebhookConnectionMetadata",
"WebhookEvent",
"WebhookEventType",
"after_execute",
"before_execute",
"before_file_upload",
"is_connection_expired_event",
"schema_modifier",
"__version__",
"ToolkitLatestVersion",
"ToolkitVersion",
"ToolkitVersions",
"ToolkitVersionParam",
)
+1
View File
@@ -0,0 +1 @@
__version__ = "0.17.1"
+258
View File
@@ -0,0 +1,258 @@
"""
This module is a light wrapper around the auto-generated composio client.
"""
import contextvars
import os
import platform
import typing as t
from importlib.metadata import version
from uuid import uuid4
import typing_extensions as te
from composio_client import (
DEFAULT_MAX_RETRIES,
NOT_GIVEN,
APIError,
NotGiven,
_base_client,
)
from composio_client import Composio as BaseComposio
from httpx import URL, Client, Request, Timeout
from composio.utils.logging import WithLogger
ComposioAPIError = APIError
APIEnvironment = te.Literal["production", "staging", "local"]
def _get_python_implementation() -> str:
"""
Get the Python implementation name.
Returns:
String identifier for Python implementation (CPYTHON, PYPY, JYTHON, IRONPYTHON, etc.)
"""
impl = platform.python_implementation().upper()
return impl
def _detect_runtime_environment() -> str:
"""
Detect the runtime environment where the code is executing.
Returns a string identifier for the environment.
"""
# Check for Google Colab
try:
import google.colab # type: ignore # noqa: F401
return "GOOGLE_COLAB"
except ImportError:
pass
# Check for Jupyter/IPython
try:
shell = get_ipython().__class__.__name__ # type: ignore # noqa: F821
if shell == "ZMQInteractiveShell":
return "JUPYTER_NOTEBOOK"
elif shell == "TerminalInteractiveShell":
return "IPYTHON"
except NameError:
pass
# Check for AWS Lambda
if os.environ.get("AWS_LAMBDA_FUNCTION_NAME"):
return "AWS_LAMBDA"
# Check for Google Cloud Functions
if os.environ.get("FUNCTION_NAME") or os.environ.get("K_SERVICE"):
return "GOOGLE_CLOUD_FUNCTION"
# Check for Azure Functions
if os.environ.get("FUNCTIONS_WORKER_RUNTIME"):
return "AZURE_FUNCTION"
# Check for Kaggle
if os.environ.get("KAGGLE_KERNEL_RUN_TYPE"):
return "KAGGLE"
# Check for Replit
if os.environ.get("REPL_ID") or os.environ.get("REPLIT_DB_URL"):
return "REPLIT"
# Check for GitHub Actions
if os.environ.get("GITHUB_ACTIONS"):
return "GITHUB_ACTIONS"
# Check for GitLab CI
if os.environ.get("GITLAB_CI"):
return "GITLAB_CI"
# Check for CircleCI
if os.environ.get("CIRCLECI"):
return "CIRCLECI"
# Check for Jenkins
if os.environ.get("JENKINS_HOME"):
return "JENKINS"
# Check for Docker
if os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv"):
return "DOCKER"
# Check if running in a container (generic)
try:
with open("/proc/1/cgroup", "r") as f:
if "docker" in f.read() or "containerd" in f.read():
return "CONTAINER"
except (FileNotFoundError, PermissionError):
pass
# Default to LOCAL for development environments
return "LOCAL"
class RequestContext(te.TypedDict):
id: te.NotRequired[t.Optional[str]]
provider: str
# TODO: Rename `Composio` to `HttpClient` in stainless generator
class HttpClient(BaseComposio, WithLogger):
"""
Wrapper around the auto-generated composio client.
"""
request_ctx: contextvars.ContextVar[RequestContext]
not_given = NOT_GIVEN
# Detect once at class initialization
_runtime_env: str = (
f"{_detect_runtime_environment()}_{_get_python_implementation()}"
)
def __init__(
self,
*,
provider: str,
api_key: t.Optional[str] = None,
environment: te.Union[NotGiven, APIEnvironment] = "production",
base_url: t.Optional[t.Union[str, URL, NotGiven]] = NOT_GIVEN,
timeout: t.Optional[t.Union[float, Timeout, NotGiven]] = NOT_GIVEN,
max_retries: int = DEFAULT_MAX_RETRIES,
default_headers: t.Optional[t.Mapping[str, str]] = None,
default_query: t.Optional[t.Mapping[str, object]] = None,
http_client: t.Optional[Client] = None,
_strict_response_validation: bool = False,
) -> None:
"""
Initialize the client.
:param provider: The provider to use for the client.
:param api_key: The API key to use for the client.
:param environment: The environment to use for the client.
:param base_url: The base URL to use for the client.
:param timeout: The timeout to use for the client.
:param max_retries: The maximum number of retries to use for the client.
:param default_headers: The default headers to use for the client.
:param default_query: The default query parameters to use for the client.
:param http_client: The HTTP client to use for the client.
"""
WithLogger.__init__(self)
BaseComposio.__init__(
self,
api_key=api_key,
environment=environment,
base_url=base_url,
timeout=timeout,
max_retries=max_retries,
default_headers=default_headers,
default_query=default_query,
http_client=http_client,
_strict_response_validation=_strict_response_validation,
)
# TOFIX: Verbosity wrapper impl
_base_client.log = self._logger # type: ignore
self.provider = provider
self.request_ctx = contextvars.ContextVar[RequestContext](
"request_ctx",
default={
"id": None,
"provider": provider,
},
)
# Lazily-built sibling client with retries disabled; see `without_retries`.
self._without_retries: t.Optional[te.Self] = None
def copy( # type: ignore[override]
self,
*,
_extra_kwargs: t.Mapping[str, t.Any] = {},
**kwargs: t.Any,
) -> te.Self:
"""
Clone the client, re-injecting the required ``provider`` keyword.
The Stainless-generated ``copy`` rebuilds the client via
``self.__class__(...)`` without passing ``provider``, which this subclass
requires — so the inherited ``copy``/``with_options`` raise ``TypeError``.
Threading ``provider`` through ``_extra_kwargs`` makes them work again
(e.g. ``with_options(max_retries=0)``).
"""
return super().copy( # type: ignore[misc]
_extra_kwargs={
"provider": self.provider,
# The generated `copy` does not re-pass `_strict_response_validation`,
# so without this the clone would silently fall back to the default
# (False) even when the original had it enabled — keeping the sibling
# a faithful copy that differs from the parent only in `max_retries`.
"_strict_response_validation": self._strict_response_validation,
**_extra_kwargs,
},
**kwargs,
)
# Re-alias `with_options` to this override. The base class binds
# `with_options = copy` at class-definition time, so without this it would
# still resolve to the base `copy` and miss the `provider` re-injection.
with_options = copy
@property
def without_retries(self) -> te.Self:
"""
A cached sibling client that never retries requests.
Used for non-idempotent writes (``tools.execute`` / ``tools.proxy``),
where a silent retry after a read timeout can duplicate a side effect
(e.g. send an email twice). Reads keep the default retry behaviour.
Scope: only ``tools.execute`` / ``tools.proxy`` route through this today.
Other non-idempotent writes (``auth_configs.create`` / ``update`` /
``delete``, ``mcp.update`` / ``delete``, ``connected_accounts.delete`` /
``refresh``, ``link.create``) keep the default retries — most are
naturally idempotent on retry, and the durable fix is backend-honoured
idempotency keys.
The sibling is cached rather than rebuilt per call so a fresh client is
not constructed on every execute/proxy (the hottest path); its options
never change, so one per client suffices.
"""
if self._without_retries is None:
self._without_retries = self.with_options(max_retries=0)
return self._without_retries
def _prepare_request(self, request: Request) -> None:
"""
Request interceptor to inject request id, provider, and SDK version.
"""
ctx = self.request_ctx.get()
request.headers["x-request-id"] = ctx.get("id") or uuid4().hex
request.headers["x-framework"] = ctx["provider"]
request.headers["x-source"] = "PYTHON_SDK"
request.headers["x-runtime"] = HttpClient._runtime_env
try:
request.headers["x-sdk-version"] = version("composio")
except Exception:
request.headers["x-sdk-version"] = "unknown"
+97
View File
@@ -0,0 +1,97 @@
"""
This module is a light wrapper around the auto-generated composio client types.
"""
import typing as t
from composio_client import NotGiven
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,
connected_account_create_params,
connected_account_create_response,
connected_account_list_params,
connected_account_list_response,
connected_account_patch_params,
connected_account_patch_response,
connected_account_retrieve_response,
connected_account_update_status_response,
link_create_params,
tool_execute_params,
tool_execute_response,
tool_list_response,
tool_proxy_params,
tool_proxy_response,
toolkit_list_params,
toolkit_list_response,
toolkit_retrieve_response,
trigger_instance_upsert_response,
)
Tool: t.TypeAlias = tool_list_response.Item
ToolkitMinimal: t.TypeAlias = tool_list_response.ItemToolkit
AuthConfig: t.TypeAlias = connected_account_create_params.AuthConfig
Oauth1L: t.TypeAlias = t.Literal["OAUTH1"]
Oauth2L: t.TypeAlias = t.Literal["OAUTH2"]
ApiKeyL: t.TypeAlias = t.Literal["API_KEY"]
BasicL: t.TypeAlias = t.Literal["BASIC"]
NoAuthL: t.TypeAlias = t.Literal["NO_AUTH"]
SnowflakeL: t.TypeAlias = t.Literal["SNOWFLAKE"]
CalcomAuthL: t.TypeAlias = t.Literal["CALCOM_AUTH"]
BearerTokenL: t.TypeAlias = t.Literal["BEARER_TOKEN"]
BillcomAuthL: t.TypeAlias = t.Literal["BILLCOM_AUTH"]
ComposioLinkL: t.TypeAlias = t.Literal["COMPOSIO_LINK"]
BasicWithJwtL: t.TypeAlias = t.Literal["BASIC_WITH_JWT"]
GoogleServiceAccountL: t.TypeAlias = t.Literal["GOOGLE_SERVICE_ACCOUNT"]
AuthSchemeL: t.TypeAlias = t.Literal[
Oauth1L,
Oauth2L,
ApiKeyL,
BasicL,
NoAuthL,
SnowflakeL,
CalcomAuthL,
BearerTokenL,
BillcomAuthL,
ComposioLinkL,
BasicWithJwtL,
GoogleServiceAccountL,
]
__all__ = (
"auth_config_create_params",
"auth_config_create_response",
"auth_config_list_params",
"auth_config_list_response",
"auth_config_retrieve_response",
"auth_config_update_params",
"connected_account_create_params",
"connected_account_create_response",
"connected_account_list_params",
"connected_account_list_response",
"connected_account_patch_params",
"connected_account_patch_response",
"connected_account_retrieve_response",
"connected_account_update_status_response",
"link_create_params",
"trigger_instance_upsert_response",
"tool_execute_params",
"tool_execute_response",
"tool_list_response",
"tool_proxy_params",
"tool_proxy_response",
"toolkit_list_params",
"toolkit_list_response",
"toolkit_retrieve_response",
"Tool",
"ToolkitMinimal",
"AuthConfig",
"NotGiven",
"AuthSchemeL",
)
View File
+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
)
+13
View File
@@ -0,0 +1,13 @@
from __future__ import annotations
from .agentic import AgenticProvider, AgenticProviderExecuteFn
from .base import TTool, TToolCollection
from .none_agentic import NonAgenticProvider
__all__ = [
"TTool",
"TToolCollection",
"AgenticProvider",
"NonAgenticProvider",
"AgenticProviderExecuteFn",
]
+152
View File
@@ -0,0 +1,152 @@
"""
OpenAI provider implementation.
"""
from __future__ import annotations
import json
import time
import typing as t
from openai import Client
from openai.types.beta.thread import Thread
from openai.types.beta.threads.run import Run
from openai.types.chat.chat_completion import ChatCompletion
from openai.types.chat.chat_completion_message_tool_call import (
ChatCompletionMessageToolCall,
)
from openai.types.chat.chat_completion_tool_param import ChatCompletionToolParam
from openai.types.shared_params.function_definition import FunctionDefinition
from openai.types.shared_params.function_parameters import FunctionParameters
from composio.core.provider import NonAgenticProvider
from composio.types import Modifiers, Tool, ToolExecutionResponse
from composio.utils.shared import normalize_tool_arguments
OpenAITool: t.TypeAlias = ChatCompletionToolParam
OpenAIToolCollection: t.TypeAlias = t.List[OpenAITool]
class OpenAIProvider(
NonAgenticProvider[OpenAITool, OpenAIToolCollection], name="openai"
):
"""OpenAIProvider class definition"""
def wrap_tool(self, tool: Tool) -> OpenAITool:
return ChatCompletionToolParam(
function=FunctionDefinition(
name=tool.slug,
description=tool.description,
parameters=t.cast(FunctionParameters, tool.input_parameters),
strict=None,
),
type="function",
)
def wrap_tools(self, tools: t.Sequence[Tool]) -> OpenAIToolCollection:
return [self.wrap_tool(tool) for tool in tools]
def execute_tool_call(
self,
user_id: str,
tool_call: ChatCompletionMessageToolCall,
modifiers: t.Optional[Modifiers] = None,
) -> ToolExecutionResponse:
"""Execute a tool call.
:param tool_call: Tool call metadata.
:param user_id: User ID to use for executing the function call.
:return: Object containing output data from the tool call.
"""
# OpenAI always serializes tool arguments as a JSON string; normalize
# tolerates empty / object-shaped payloads too (issue #2406).
return self.execute_tool(
slug=tool_call.function.name,
arguments=normalize_tool_arguments(tool_call.function.arguments),
modifiers=modifiers,
user_id=user_id,
)
def handle_tool_calls(
self,
user_id: str,
response: ChatCompletion,
modifiers: t.Optional[Modifiers] = None,
) -> t.List[ToolExecutionResponse]:
"""
Handle tool calls from OpenAI chat completion object.
:param response: Chat completion object from
openai.OpenAI.chat.completions.create function call
:param user_id: User ID to use for executing the function call.
:return: A list of output objects from the function calls.
"""
outputs = []
# Only the first choice is actionable: its tool results feed back into a
# single assistant turn. With n > 1, iterating every choice would run each
# tool call once per choice and orphan the tool_call_ids belonging to the
# alternative completions.
choice = response.choices[0] if response.choices else None
# A single assistant message can carry several tool calls (parallel tool
# calls, on by default); each one needs its own tool result.
if choice is not None and choice.message.tool_calls is not None:
for tool_call in choice.message.tool_calls:
outputs.append(
self.execute_tool_call(
user_id=user_id,
tool_call=t.cast(ChatCompletionMessageToolCall, tool_call),
modifiers=modifiers,
)
)
return outputs
def handle_assistant_tool_calls(
self,
user_id: str,
run: Run,
) -> t.List:
"""Wait and handle assistant function calls"""
tool_outputs: list[dict] = []
if run.required_action is None:
return tool_outputs
for tool_call in run.required_action.submit_tool_outputs.tool_calls:
tool_outputs.append(
{
"tool_call_id": tool_call.id,
"output": json.dumps(
self.execute_tool_call(
tool_call=t.cast(ChatCompletionMessageToolCall, tool_call),
user_id=user_id,
)
),
}
)
return tool_outputs
def wait_and_handle_assistant_tool_calls(
self,
user_id: str,
client: Client,
run: Run,
thread: Thread,
) -> Run:
"""Wait and handle assistant function calls"""
while run.status in ("queued", "in_progress", "requires_action"):
if run.status != "requires_action":
run = client.beta.threads.runs.retrieve(
thread_id=thread.id,
run_id=run.id,
)
time.sleep(0.5)
continue
run = client.beta.threads.runs.submit_tool_outputs(
thread_id=thread.id,
run_id=run.id,
tool_outputs=self.handle_assistant_tool_calls(
run=run,
user_id=user_id,
),
)
return run
@@ -0,0 +1,90 @@
"""
OpenAI Responses API provider implementation.
"""
from __future__ import annotations
import typing as t
from openai.types.responses.response import Response
from openai.types.responses.response_output_item import ResponseFunctionToolCall
from composio.core.provider import NonAgenticProvider
from composio.types import Modifiers, Tool, ToolExecutionResponse
from composio.utils.shared import normalize_tool_arguments
# Responses API uses a flattened tool structure
ResponsesTool = t.Dict[str, t.Any]
ResponsesToolCollection = t.List[ResponsesTool]
class OpenAIResponsesProvider(
NonAgenticProvider[ResponsesTool, ResponsesToolCollection], name="openai_responses"
):
"""OpenAI Responses API Provider class definition."""
def wrap_tool(self, tool: Tool) -> ResponsesTool:
"""Wrap a tool for the Responses API format."""
return {
"type": "function",
"name": tool.slug,
"description": tool.description,
"parameters": tool.input_parameters,
}
def wrap_tools(self, tools: t.Sequence[Tool]) -> ResponsesToolCollection:
"""Wrap multiple tools for the Responses API format."""
return [self.wrap_tool(tool) for tool in tools]
def execute_tool_call(
self,
user_id: str,
tool_call: t.Union[ResponseFunctionToolCall],
modifiers: t.Optional[Modifiers] = None,
) -> ToolExecutionResponse:
"""Execute a tool call from the Responses API.
:param tool_call: Tool call metadata from Responses API.
:param user_id: User ID to use for executing the function call.
:param modifiers: Optional modifiers for tool execution.
:return: Object containing output data from the tool call.
"""
# OpenAI always serializes tool arguments as a JSON string; normalize
# tolerates empty / object-shaped payloads too (issue #2406).
slug = tool_call.name
arguments = normalize_tool_arguments(tool_call.arguments)
return self.execute_tool(
slug=slug,
arguments=arguments,
modifiers=modifiers,
user_id=user_id,
)
def handle_tool_calls(
self,
user_id: str,
response: Response,
modifiers: t.Optional[Modifiers] = None,
) -> t.List[ToolExecutionResponse]:
"""
Handle tool calls from OpenAI Responses API.
:param response: Response object from openai.OpenAI.beta.responses.create
:param user_id: User ID to use for executing the function call.
:param modifiers: Optional modifiers for tool execution
:return: List[ToolExecutionResponse] with tool execution results
"""
outputs = []
if response.output:
for item in response.output:
if isinstance(item, ResponseFunctionToolCall):
result = self.execute_tool_call(
user_id=user_id,
tool_call=item,
modifiers=modifiers,
)
outputs.append(result)
return outputs
+48
View File
@@ -0,0 +1,48 @@
from __future__ import annotations
import typing as t
from composio.client.types import Tool
from composio.core.provider.base import BaseProvider, TTool, TToolCollection
class AgenticProviderExecuteFn(t.Protocol):
def __call__(
self,
slug: str,
arguments: t.Dict,
) -> t.Dict:
"""
Execute a wrapped tool by slug, passing an arbitrary input dict.
Returns a dict with the following keys:
- data: The data returned by the tool.
- error: The error returned by the tool.
- successful: Whether the tool was successful.
"""
...
class AgenticProvider(BaseProvider[TTool, TToolCollection]):
"""
Base class for all agentic providers. This class is not meant to be used
directly but rather to be extended by concrete provider implementations.
"""
def __init_subclass__(cls, name: str) -> None:
cls.name = name
def wrap_tool(
self,
tool: Tool,
execute_tool: AgenticProviderExecuteFn,
) -> TTool:
"""Wrap a tool in the provider-specific format"""
raise NotImplementedError
def wrap_tools(
self,
tools: t.Sequence[Tool],
execute_tool: AgenticProviderExecuteFn,
) -> TToolCollection:
"""Wrap a list of tools in the provider-specific format"""
raise NotImplementedError
+73
View File
@@ -0,0 +1,73 @@
"""
BaseProvider module
Defines the barebones provider metaclass that needs to be subclassed for every provider.
"""
from __future__ import annotations
import typing as t
import typing_extensions as te
if t.TYPE_CHECKING:
from composio.core.models.tools import Modifiers, ToolExecutionResponse
TTool = t.TypeVar("TTool")
TToolCollection = t.TypeVar("TToolCollection")
class ExecuteToolFn(t.Protocol):
def __call__(
self,
slug: str,
arguments: t.Dict,
*,
modifiers: t.Optional[Modifiers] = None,
user_id: t.Optional[str] = None,
) -> ToolExecutionResponse:
"""
Execute a wrapped tool by slug, passing an arbitrary input dict.
This function is used by the providers to execute tools for the helper methods.
Returns a dict with the following keys:
- data: The data returned by the tool.
- error: The error returned by the tool.
- successful: Whether the tool was successful.
"""
...
class SchemaConfig(te.TypedDict):
skip_defaults: te.NotRequired[bool]
class BaseProviderConfig(te.TypedDict):
schema_config: te.NotRequired[SchemaConfig]
class BaseProvider(t.Generic[TTool, TToolCollection]):
"""
BaseProvider class
All providers should inherit from this class and implement `wrap_tools` so that
they can be used with the core Composio class.
"""
name: str
"""Name of the provider"""
__schema_skip_defaults__ = False
execute_tool: ExecuteToolFn
"""
The function to execute a tool for the provider's helper methods.
This is automatically injected by the core SDK.
"""
def __init__(self, **kwargs: t.Unpack[BaseProviderConfig]) -> None:
self.skip_default = kwargs.get("schema_config", {}).get(
"skip_defaults", self.__schema_skip_defaults__
)
def set_execute_tool_fn(self, execute_tool_fn: ExecuteToolFn) -> None:
self.execute_tool = execute_tool_fn
@@ -0,0 +1,31 @@
from __future__ import annotations
import typing as t
from composio.client.types import Tool
from composio.core.provider.base import BaseProvider, TTool, TToolCollection
class NonAgenticProvider(BaseProvider[TTool, TToolCollection]):
"""
Base class for all non-agentic providers, such as `openai` This class is not
meant to be used directly, but rather to be extended by concrete implementations
This version doesn't have the execute_tool_fn for `wrap_tool` and `wrap_tools`
"""
def __init_subclass__(cls, name: str) -> None:
cls.name = name
def wrap_tool(
self,
tool: Tool,
) -> TTool:
"""Wrap a tool in the provider-specific format"""
raise NotImplementedError
def wrap_tools(
self,
tools: t.Sequence[Tool],
) -> TToolCollection:
"""Wrap a list of tools in the provider-specific format"""
raise NotImplementedError
+20
View File
@@ -0,0 +1,20 @@
"""Core types for Composio SDK."""
import typing as t
import typing_extensions as te
# Tool versioning types
ToolkitLatestVersion = te.Literal["latest"]
ToolkitVersion = t.Union[
ToolkitLatestVersion, str
] # Can be "latest" or any version string like "20250906_01"
ToolkitVersions = t.Dict[str, ToolkitVersion]
ToolkitVersionParam = t.Union[ToolkitVersions, ToolkitLatestVersion, None]
__all__ = [
"ToolkitLatestVersion",
"ToolkitVersion",
"ToolkitVersions",
"ToolkitVersionParam",
]
+456
View File
@@ -0,0 +1,456 @@
"""
Composio exceptions.
"""
import difflib
import typing as t
ENV_COMPOSIO_API_KEY = "COMPOSIO_API_KEY"
class ComposioError(Exception):
"""Base composio SDK error."""
def __init__(
self,
message: str,
*args: t.Any,
delegate: bool = False,
) -> None:
"""
Initialize Composio SDK error.
:param message: Error message
:param delegate: Whether to delegate the error message to the log
collection server or not
"""
super().__init__(message, *args)
self.message = message
self.delegate = delegate
class NotFoundError(ComposioError):
pass
class HTTPError(ComposioError):
"""
Exception class for HTTP API errors.
"""
def __init__(
self,
message: str,
status_code: int,
*args: t.Any,
delegate: bool = False,
) -> None:
"""
Initialize HTTPError class.
:param message: Content from the API response
:param status_code: HTTP response status code
:param delegate: Whether to delegate the error message to the log
collection server or not
"""
super().__init__(message, *args, delegate=delegate)
self.status_code = status_code
class ComposioClientError(ComposioError):
"""
Exception class for Composio client errors.
"""
class SDKError(ComposioError):
pass
class ProcessorError(SDKError):
pass
class EnumError(ComposioError):
pass
class ValidationError(ComposioError):
pass
class JSONSchemaRefResolutionError(ValidationError):
"""Raised when an internal JSON Schema ``$ref`` cannot be inlined.
Covers malformed pointers, missing ``$defs``/``definitions`` targets
(strict mode only), and ``$ref`` chains or node nesting that exceed the
safety caps in :func:`composio.utils.json_schema.dereference_json_schema`.
"""
def __init__(
self,
message: str,
*args: t.Any,
meta: t.Optional[t.Dict[str, t.Any]] = None,
delegate: bool = False,
) -> None:
super().__init__(message, *args, delegate=delegate)
self.meta = meta or {}
class ToolkitError(ComposioError):
pass
class EntityIDError(ComposioError):
pass
class PluginError(ComposioError):
pass
class InvalidParams(ComposioError):
pass
class FileError(ComposioError):
pass
class ComposioSDKTimeoutError(ComposioError, TimeoutError):
pass
class SDKFileNotFoundError(ComposioError, FileNotFoundError):
pass
class LockFileError(ComposioError):
pass
class VersionError(ComposioError):
pass
class InvalidLockFile(LockFileError):
pass
class InvalidVersionString(EnumError):
pass
class VersionSelectionError(LockFileError, VersionError):
def __init__(
self,
action: str,
requested: str,
locked: str,
delegate: bool = False,
) -> None:
self.action = action
self.requested = requested
self.locked = locked
super().__init__(
message=(
f"Error selecting version for action: {action!r}, "
f"requested: {requested!r}, locked: {locked!r}"
),
delegate=delegate,
)
class InvalidEnum(EnumError):
pass
class EnumStringNotFound(EnumError):
"""Raise when user provides invalid enum string."""
def __init__(self, value: str, enum: str, possible_values: t.List[str]) -> None:
error_message = f"Invalid value `{value}` for enum class `{enum}`"
matches = difflib.get_close_matches(value, possible_values, n=1)
if matches:
(match,) = matches
error_message += f". Did you mean {match!r}?"
super().__init__(message=error_message)
class EnumMetadataNotFound(EnumError):
pass
class ErrorUploadingFile(FileError):
pass
class SensitiveFilePathBlockedError(FileError):
"""Raised when a local file path is refused before upload (sensitive directory or credential-like name)."""
class FileUploadPathNotAllowedError(FileError):
"""
Raised when automatic file upload during tool execution is attempted from a
path outside the configured ``file_upload_dirs`` allowlist.
Only fires for auto-upload (enabled via
``dangerously_allow_auto_upload_download_files=True``). Manual upload APIs
are not subject to this check.
"""
class FileUploadAbortedError(FileError):
"""Raised when a ``before_file_upload`` hook returns ``False``."""
class ErrorDownloadingFile(FileError):
pass
class RemoteFileDownloadError(FileError):
"""Raised when fetching a remote file from a tool router session mount fails.
Includes HTTP status, URL, and file path context for debugging.
"""
def __init__(
self,
message: str = "Failed to download remote file",
*,
status_code: t.Optional[int] = None,
status_text: t.Optional[str] = None,
download_url: t.Optional[str] = None,
mount_relative_path: t.Optional[str] = None,
filename: t.Optional[str] = None,
**kwargs: t.Any,
) -> None:
super().__init__(message, **kwargs)
self.status_code = status_code
self.status_text = status_text
self.download_url = download_url
self.mount_relative_path = mount_relative_path
self.filename = filename
class ResponseTooLargeError(FileError):
"""Raised when a response exceeds the maximum allowed size."""
pass
class TriggerError(ToolkitError):
pass
class WebhookSignatureVerificationError(TriggerError):
"""Raised when webhook signature verification fails."""
pass
class WebhookPayloadError(TriggerError):
"""Raised when webhook payload is invalid."""
pass
class TriggerTypeNotFound(TriggerError, NotFoundError):
"""Raised when a trigger type cannot be found for the given slug.
Mirrors the TypeScript SDK's ``ComposioTriggerTypeNotFoundError``.
"""
pass
class ActionError(ToolkitError):
pass
class TriggerSubscriptionError(TriggerError, ComposioClientError):
pass
class InvalidTriggerFilters(TriggerSubscriptionError):
pass
class ApiKeyError(ComposioClientError):
pass
class ApiKeyNotProvidedError(ApiKeyError, NotFoundError):
"""Raise when API key is required but not provided."""
def __init__(self) -> None:
super().__init__(
message=(
"API Key not provided, either provide API key "
f"or export it as `{ENV_COMPOSIO_API_KEY}` "
"or run `composio login`"
),
)
class ResourceError(ComposioClientError):
pass
class NoItemsFound(ResourceError):
"""
Exception class for empty collection values.
"""
class ErrorFetchingResource(ResourceError):
pass
class SchemaError(ToolkitError):
pass
class InvalidSchemaError(SchemaError, TypeError):
pass
class InvalidEntityIdError(EntityIDError, ValueError):
pass
class IntegrationError(ResourceError):
pass
class ConnectedAccountError(ResourceError):
pass
class ConnectedAccountNotFoundError(NotFoundError, ConnectedAccountError):
pass
class InvalidConnectedAccount(ValidationError, ConnectedAccountError):
pass
class ComposioMultipleConnectedAccountsError(ConnectedAccountError):
"""Raised when multiple connected accounts are found for a user and auth config."""
pass
class ComposioLegacyConnectedAccountsEndpointRetiredError(ConnectedAccountError):
"""Raised by ``composio.connected_accounts.initiate()`` when the legacy
``POST /api/v3/connected_accounts`` endpoint rejects a Composio-managed
OAuth (OAuth1, OAuth2, DCR_OAUTH) auth-config request.
Cutover dates: 2026-05-08 (new orgs), 2026-07-03 (all remaining orgs).
Migrate to ``composio.connected_accounts.link()`` — same return shape,
works for every redirectable scheme regardless of whether the auth
config is Composio-managed or custom.
See: https://docs.composio.dev/docs/changelog/2026/04/24
"""
pass
class ComposioAclOnlyForSharedError(ConnectedAccountError):
"""Raised when ACL fields (``allow_all_users``, ``allowed_user_ids``,
``not_allowed_user_ids``) are sent on a ``PRIVATE`` connection. ACL
is only meaningful for ``SHARED`` connections.
Fix: use ``account_type='SHARED'``, or omit the ACL fields when
creating/updating a PRIVATE connection.
"""
pass
class ComposioSharedAccessDeniedError(ConnectedAccountError):
"""Raised when a tool execution attempts to use a SHARED connected
account but the requesting ``user_id`` is not allowed by the
connection's ACL.
Surfaces when a SHARED connection is reached directly (e.g. via
``composio.tools.execute(slug, connected_account_id=...)``) without
going through a tool-router session.
Fix: ask the connection's creator to grant access via
``composio.experimental.update_acl()`` — set ``allow_all_users``,
add the ``user_id`` to ``allowed_user_ids``, or remove it from
``not_allowed_user_ids``.
"""
pass
class ComposioSharedConnectionNotAccessibleError(ConnectedAccountError):
"""Raised by ``tool_router.session.create()`` / ``session.patch()``
when the session's ``user_id`` cannot use a pinned SHARED connection.
Raised at session-create time so the session never enters a state
that fails mid-execution.
Fix: grant the session user access via
``composio.experimental.update_acl()`` on the pinned connection,
or pin a different connection the user can use.
"""
pass
class ErrorProcessingToolExecutionRequest(PluginError):
pass
class DescopeAuthError(ComposioError):
pass
class DescopeConfigError(ComposioError):
pass
class InvalidExecuteFunctionError(ComposioError):
pass
class ToolNotFoundError(ComposioError):
pass
class InvalidModifier(ComposioError):
pass
class ExecuteToolFnNotSetError(ComposioError):
pass
class ToolVersionRequiredError(ComposioError):
"""Raised when toolkit version is not specified for manual tool execution."""
def __init__(self) -> None:
super().__init__(
message=(
"Toolkit version not specified. For manual execution of the tool "
"please pass a specific toolkit version.\n\n"
"Possible fixes:\n"
"1. Pass the toolkit version as a parameter to the execute function "
'("latest" is not supported in manual execution)\n'
"2. Set the toolkit versions in the Composio config "
"(toolkit_versions={'<toolkit-slug>': '<toolkit-version>'})\n"
"3. Set the toolkit version in the environment variable "
"(COMPOSIO_TOOLKIT_VERSION_<TOOLKIT_SLUG>)\n"
"4. Set dangerously_skip_version_check to True "
"(this might cause unexpected behavior when new versions of the tools are released)"
),
)
class UsageError(ComposioError):
pass
@@ -0,0 +1,36 @@
# Integration Tests
Integration tests for Composio SDK functionality.
## Setup
```bash
export COMPOSIO_API_KEY="your_api_key_here"
```
## Running Tests
```bash
# From project root
cd /path/to/composio
python -m pytest python/composio/integration_test/ -v
# From python directory
cd /path/to/composio/python
pytest composio/integration_test/ -v
# Using uv
cd /path/to/composio/python
uv run pytest composio/integration_test/ -v
```
## Test Files
- **`test_mcp.py`** - MCP (Model Context Protocol) functionality tests
- **`test_tool_router.py`** - ToolRouter experimental feature tests
## Requirements
- Python 3.12+
- pytest
- Valid Composio API key
@@ -0,0 +1,8 @@
"""
Integration test package for Composio SDK.
This package contains comprehensive integration tests for various Composio features,
including experimental MCP functionality.
"""
__all__ = []
@@ -0,0 +1,121 @@
"""
Pytest configuration and shared fixtures for Composio integration tests.
"""
import os
import sys
from pathlib import Path
import pytest
# Add the python directory to the path for imports
python_dir = Path(__file__).parent.parent.parent
sys.path.insert(0, str(python_dir))
# Test configuration
API_KEY = os.getenv("COMPOSIO_API_KEY")
if not API_KEY:
pytest.skip(
"COMPOSIO_API_KEY environment variable not set", allow_module_level=True
)
@pytest.fixture(scope="session", autouse=True)
def setup_environment():
"""Set up the test environment for all tests."""
os.environ["COMPOSIO_API_KEY"] = API_KEY
yield
@pytest.fixture(scope="session")
def composio_client():
"""Provide a Composio client instance for all tests."""
from composio import Composio
return Composio()
@pytest.fixture(scope="session")
def auth_configs(composio_client):
"""Get available auth configurations for testing."""
try:
configs_response = composio_client.auth_configs.list()
# Debug what we get
print(f"Raw response type: {type(configs_response)}")
# Handle different response formats
if hasattr(configs_response, "items"):
# Direct access to items
items = configs_response.items
print(f"Found items directly: {len(items) if items else 0}")
return list(items) if items else []
elif hasattr(configs_response, "__iter__"):
# If it's being converted to an iterable of tuples, find the items tuple
items_tuple = None
for item in configs_response:
if isinstance(item, tuple) and len(item) == 2 and item[0] == "items":
items_tuple = item[1]
break
if items_tuple:
print(f"Found items in tuple: {len(items_tuple)}")
return list(items_tuple)
return []
except Exception as e:
print(f"Exception in auth_configs fixture: {e}")
import traceback
traceback.print_exc()
return []
@pytest.fixture
def sample_mcp_config_data():
"""Provide sample data for MCP configuration testing."""
import time
return {
"name": f"pytest_test_{int(time.time())}",
"server_config": [
{"auth_config_id": "test_auth_id", "allowed_tools": ["GMAIL_FETCH_EMAILS"]}
],
"options": {"is_chat_auth": True},
}
@pytest.fixture
def test_user_id():
"""Provide a consistent test user ID."""
return "pytest_integration_user_123"
# Track created resources for cleanup
_created_mcp_servers = []
@pytest.fixture
def mcp_server_cleanup(composio_client):
"""Fixture to track and cleanup MCP servers created during tests."""
created_servers = []
yield created_servers
# Cleanup after test
for server_id in created_servers:
try:
composio_client.mcp.delete(server_id)
print(f"✅ Cleaned up MCP server: {server_id}")
except Exception as e:
print(f"⚠️ Failed to cleanup MCP server {server_id}: {e}")
def pytest_configure(config):
"""Configure pytest with custom settings."""
# Add timeout marker
config.addinivalue_line(
"markers", "timeout: mark test to run with a specific timeout"
)
@@ -0,0 +1,16 @@
[tool:pytest]
# Simple pytest configuration for Composio integration tests
testpaths = .
python_files = test_*.py
python_classes = Test*
python_functions = test_*
# Add timeout and other options
# --timeout=120: Each test gets max 2 minutes
# --timeout-method=thread: Use thread-based timeout (works cross-platform)
addopts = -v --tb=short --timeout=120 --timeout-method=thread
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
integration: marks tests as integration tests
@@ -0,0 +1,643 @@
"""
Comprehensive integration tests for Composio MCP functionality.
This module provides comprehensive pytest-based tests for the MCP (Model Context Protocol) functionality,
combining both structured pytest tests and direct execution tests for non-auth toolkits.
Usage:
export COMPOSIO_API_KEY="your_api_key_here"
pytest python/composio/integration_test/test_mcp.py -v
"""
import os
import uuid
import pytest
import requests
from composio import Composio
from composio.exceptions import ValidationError
# Test configuration
API_KEY = os.getenv("COMPOSIO_API_KEY")
TEST_CONFIG_PREFIX = "pytest_integration_test"
if not API_KEY:
pytest.fail("COMPOSIO_API_KEY environment variable not set", pytrace=False)
def generate_unique_name(prefix: str = "pytest") -> str:
"""Generate a unique test name using UUID to avoid collisions."""
# Use first 8 characters of UUID for readability while maintaining uniqueness
unique_id = str(uuid.uuid4())[:8]
return f"{prefix}-{unique_id}"
@pytest.fixture
def composio_client():
"""Fixture providing Composio client instance."""
return Composio(api_key=API_KEY)
@pytest.fixture
def test_mcp_config_data():
"""Fixture providing test data for MCP config creation."""
return {
"name": generate_unique_name("pytest-data"),
"toolkits": ["composio_search", "text_to_pdf"],
"allowed_tools": [
"COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH",
"TEXT_TO_PDF_CONVERT_TEXT_TO_PDF",
],
"manually_manage_connections": False,
}
class TestMCPStructure:
"""Test the basic structure and availability of MCP features."""
def test_mcp_namespace_exists(self, composio_client):
"""Test that mcp namespace exists at top level."""
assert hasattr(composio_client, "mcp"), "Missing mcp namespace"
@pytest.mark.parametrize(
"method_name", ["create", "list", "get", "update", "delete", "generate"]
)
def test_mcp_methods_available(self, composio_client, method_name):
"""Test that all required MCP methods are available."""
assert hasattr(composio_client.mcp, method_name), (
f"Missing method: {method_name}"
)
class TestMCPOperations:
"""Test MCP CRUD operations."""
def test_list_mcp_configs(self, composio_client):
"""Test listing MCP configurations."""
try:
configs = composio_client.mcp.list()
assert isinstance(configs, dict), "list() should return a dictionary"
assert "items" in configs, "Response should contain 'items' key"
assert "current_page" in configs, (
"Response should contain 'current_page' key"
)
assert "total_pages" in configs, "Response should contain 'total_pages' key"
assert isinstance(configs["items"], list), "items should be a list"
except Exception as e:
# List might fail if no configs exist or API issues, but method should exist
assert "Failed to list MCP servers" in str(e) or "list" in str(e).lower()
def test_list_with_pagination(self, composio_client):
"""Test listing with pagination parameters."""
try:
configs = composio_client.mcp.list(page_no=1, limit=5)
assert isinstance(configs, dict), (
"Paginated list should return a dictionary"
)
assert "items" in configs, "Response should contain 'items'"
except Exception as e:
# Expected to fail with current API implementation
assert "Failed to list MCP servers" in str(e)
def test_list_with_filters(self, composio_client):
"""Test listing with filter parameters."""
try:
# Test toolkit filter with non-auth toolkits
configs_search = composio_client.mcp.list(toolkits="composio_search")
assert configs_search["items"] is None or isinstance(
configs_search["items"], list
)
# Test name filter
configs_name = composio_client.mcp.list(name="test")
assert configs_name["items"] is None or isinstance(
configs_name["items"], list
)
except Exception as e:
# Expected to fail with current API implementation
assert "Failed to list MCP servers" in str(e)
def test_create_mcp_config(self, composio_client):
"""Test creating MCP configuration with new API using non-auth toolkits."""
# Server name must be ≤30 chars and only contain letters, numbers, spaces, hyphens
test_name = generate_unique_name("pytest-create")
mcp_config = composio_client.mcp.create(
test_name,
toolkits=["composio_search", "text_to_pdf"],
allowed_tools=[
"COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH",
"TEXT_TO_PDF_CONVERT_TEXT_TO_PDF",
],
manually_manage_connections=False,
)
# Basic response validation
assert mcp_config.id
assert mcp_config.name == test_name
assert callable(mcp_config.generate)
assert mcp_config.allowed_tools == [
"COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH",
"TEXT_TO_PDF_CONVERT_TEXT_TO_PDF",
]
assert mcp_config.commands.claude
assert mcp_config.commands.cursor
assert mcp_config.commands.windsurf
# Test the generate method
try:
server_instance = mcp_config.generate("test_user_123")
assert isinstance(server_instance, dict), (
"generate should return a dictionary"
)
assert "id" in server_instance, "Server instance should have id"
assert "url" in server_instance, "Server instance should have url"
assert "type" in server_instance, "Server instance should have type"
assert server_instance["type"] == "streamable_http", (
"Server type should be streamable_http"
)
assert isinstance(server_instance["url"], str), "URL should be string"
assert len(server_instance["url"]) > 0, "URL should not be empty"
except Exception as e:
print(f"Generate method failed (may be expected): {e}")
def test_get_nonexistent_config(self, composio_client):
"""Test getting a non-existent configuration."""
with pytest.raises(ValidationError):
composio_client.mcp.get("nonexistent_config_id")
def test_create_with_empty_toolkits(self, composio_client):
"""Test creating with empty toolkit configuration."""
with pytest.raises(ValidationError):
composio_client.mcp.create(
"test_empty",
toolkits=[], # Empty toolkits
)
def test_generate_method_directly(self, composio_client):
"""Test the generate method directly on MCP class using non-auth toolkits."""
# First create a config
test_name = generate_unique_name("pytest-generate")
mcp_config = composio_client.mcp.create(
test_name,
toolkits=["composio_search", "text_to_pdf"],
allowed_tools=[
"COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH",
"TEXT_TO_PDF_CONVERT_TEXT_TO_PDF",
],
manually_manage_connections=False,
)
# Test generate method directly
try:
server_instance = composio_client.mcp.generate(
"test_user_direct_123",
mcp_config.id,
{"manually_manage_connections": False},
)
assert isinstance(server_instance, dict), (
"generate should return a dictionary"
)
assert server_instance["id"] == mcp_config.id, (
"Instance ID should match config ID"
)
assert server_instance["user_id"] == "test_user_direct_123", (
"User ID should match"
)
assert server_instance["type"] == "streamable_http", (
"Type should be streamable_http"
)
assert "url" in server_instance, "Should have URL"
except Exception as e:
print(f"Direct generate failed (may be expected): {e}")
def test_create_with_string_toolkits(self, composio_client):
"""Test creating MCP configuration using simple string toolkit names."""
test_name = generate_unique_name("pytest-strings")
# Test with simple string toolkit names
mcp_config = composio_client.mcp.create(
test_name,
toolkits=["composio_search", "text_to_pdf"],
manually_manage_connections=False,
)
# Basic validation
assert mcp_config.id
assert mcp_config.name == test_name
assert callable(mcp_config.generate)
def test_create_with_mixed_toolkits(self, composio_client):
"""Test creating MCP configuration with mixed string and object formats."""
test_name = generate_unique_name("pytest-mixed")
# Test with mixed formats (string and object with auth_config_id)
mcp_config = composio_client.mcp.create(
test_name,
toolkits=[
"composio_search", # String format
{
"toolkit": "text_to_pdf",
# No auth_config_id needed for non-auth toolkit
}, # Object format
],
allowed_tools=[
"COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH",
"TEXT_TO_PDF_CONVERT_TEXT_TO_PDF",
],
manually_manage_connections=False,
)
# Basic validation
assert mcp_config.id
assert mcp_config.name == test_name
assert callable(mcp_config.generate)
def test_create_response_structure(self, composio_client):
"""Test that create response has all required fields."""
test_name = generate_unique_name("pytest-structure")
mcp_config = composio_client.mcp.create(
test_name,
toolkits=["composio_search", "text_to_pdf"],
allowed_tools=[
"COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH",
"TEXT_TO_PDF_CONVERT_TEXT_TO_PDF",
],
manually_manage_connections=False,
)
# Basic structure validation
assert mcp_config.id
assert mcp_config.name == test_name
assert mcp_config.allowed_tools == [
"COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH",
"TEXT_TO_PDF_CONVERT_TEXT_TO_PDF",
]
assert mcp_config.auth_config_ids == []
assert mcp_config.mcp_url
assert mcp_config.commands.claude
assert mcp_config.commands.cursor
assert mcp_config.commands.windsurf
assert callable(mcp_config.generate)
class TestMCPErrorHandling:
"""Test error handling and edge cases."""
@pytest.mark.parametrize(
"invalid_config_id", ["", "invalid_id", "mcp_000000", "nonexistent"]
)
def test_invalid_config_ids(self, composio_client, invalid_config_id):
"""Test various invalid configuration IDs."""
with pytest.raises(ValidationError):
composio_client.mcp.get(invalid_config_id)
def test_generate_with_invalid_params(self, composio_client):
"""Test generate method with invalid parameters."""
with pytest.raises(ValidationError):
composio_client.mcp.generate("", "invalid_config_id")
def test_create_with_invalid_toolkit_config(self, composio_client):
"""Test create with invalid toolkit configuration."""
# Test with empty toolkit config - should fail during API call
try:
result = composio_client.mcp.create(
"test",
toolkits=[
{
# Empty toolkit config - will fail at API level
}
],
)
# If it somehow succeeds, that's unexpected but not necessarily wrong
print(f"Create succeeded with empty config: {result.id}")
except ValidationError as e:
# Expected - should fail with validation error
assert "Failed to create MCP server" in str(e)
except Exception as e:
# Also acceptable - might fail for other API reasons
print(f"Create failed with: {type(e).__name__}: {e}")
class TestMCPNoAuthToolkits:
"""Test MCP functionality with non-authentication toolkits."""
def test_mcp_with_no_auth_toolkits(self, composio_client):
"""Test MCP with toolkits that don't require authentication."""
print("🔧 Testing MCP with Non-Auth Toolkits")
print("=" * 50)
# Create MCP server with non-auth toolkits
server_name = generate_unique_name("no-auth-test")
print(f"🚀 Creating MCP server: {server_name}")
mcp_server = composio_client.mcp.create(
server_name,
toolkits=["composio_search", "text_to_pdf"],
allowed_tools=[
"COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH",
"TEXT_TO_PDF_CONVERT_TEXT_TO_PDF",
],
manually_manage_connections=False,
)
print("✅ MCP server created successfully!")
print(f" Server ID: {mcp_server.id}")
print(f" Server Name: {mcp_server.name}")
print(f" Toolkits: {getattr(mcp_server, 'toolkits', 'N/A')}")
# Generate server instance for a test user
test_user_id = "test_user_no_auth_123"
print(f"\n🔗 Generating server instance for user: {test_user_id}")
server_instance = mcp_server.generate(test_user_id)
print("✅ Server instance generated successfully!")
print(f" Instance ID: {server_instance['id']}")
print(f" Instance Type: {server_instance['type']}")
print(f" Instance URL: {server_instance['url']}")
print(f" User ID: {server_instance['user_id']}")
print(f" Allowed Tools: {server_instance['allowed_tools']}")
print(f" Auth Configs: {server_instance['auth_configs']}")
# Test direct generate method as well
print("\n🔄 Testing direct generate method...")
direct_instance = composio_client.mcp.generate(
test_user_id + "_direct",
mcp_server.id,
{"manually_manage_connections": False},
)
print("✅ Direct generate method successful!")
print(f" Direct Instance URL: {direct_instance['url']}")
print(f" Direct Instance User ID: {direct_instance['user_id']}")
# Test URL connectivity (basic check)
print("\n🌐 Testing MCP URL connectivity...")
mcp_url = server_instance["url"]
try:
# Just verify the URL is valid and endpoint exists
# Don't try to read the stream as SSE endpoints can hang indefinitely
headers = {
"Accept": "text/event-stream, application/json, */*",
"Cache-Control": "no-cache",
"User-Agent": "Composio-Python-MCP-Test",
}
# Use HEAD request if supported, otherwise quick GET with immediate close
response = requests.head(mcp_url, headers=headers, timeout=3)
if response.status_code == 405: # Method not allowed for HEAD
# Try GET but immediately close without reading stream
response = requests.get(
mcp_url, headers=headers, timeout=3, stream=True
)
response.close() # Close immediately without reading
print("✅ MCP URL is accessible!")
print(f" Status Code: {response.status_code}")
print(f" URL: {mcp_url[:50]}...")
except requests.exceptions.Timeout:
print("⚠️ MCP URL timeout (normal for SSE endpoints)")
except Exception as e:
print(f"⚠️ MCP URL test failed: {e}")
print("\n📊 Test Summary:")
print(f" ✅ MCP Server Created: {mcp_server.id}")
print(f" ✅ Server Instance Generated: {server_instance['type']}")
print(" ✅ Direct Generate Method: Working")
print(f" ✅ Available Tools: {len(server_instance['allowed_tools'])} tools")
print(
f" ✅ No Auth Required: {len(server_instance['auth_configs'])} auth configs"
)
# Assertions for pytest
assert mcp_server.id is not None
assert mcp_server.name == server_name
assert server_instance["type"] == "streamable_http"
assert server_instance["user_id"] == test_user_id
assert len(server_instance["allowed_tools"]) > 0
assert len(server_instance["auth_configs"]) == 0 # No auth required
def test_mcp_with_string_toolkits(self, composio_client):
"""Test MCP server creation using simple string toolkit names."""
print("🧪 Testing MCP with string toolkit names...")
# Create MCP server with string toolkit names (simplified API)
server_name = generate_unique_name("string-test")
print(f"🚀 Creating MCP server with strings: {server_name}")
mcp_server = composio_client.mcp.create(
server_name,
toolkits=["composio_search", "text_to_pdf"], # Simple strings
manually_manage_connections=False,
)
print("✅ MCP server created successfully with string toolkits!")
print(f" Server ID: {mcp_server.id}")
print(f" Server Name: {mcp_server.name}")
# Test generate method
user_id = f"string_user_{str(uuid.uuid4())[:8]}"
server_instance = mcp_server.generate(user_id)
print("✅ Server instance generated successfully!")
print(f" Instance URL: {server_instance['url']}")
print(f" User ID: {server_instance['user_id']}")
print(f" Server Type: {server_instance['type']}")
# Basic validation
assert server_instance["user_id"] == user_id
assert server_instance["type"] == "streamable_http"
assert "url" in server_instance
assert len(server_instance["url"]) > 0
print("🎉 String toolkit test completed successfully!")
class TestMCPRealWorldScenarios:
"""Test real-world usage scenarios."""
def test_full_workflow_with_no_auth_toolkits(self, composio_client):
"""Test complete workflow: create -> generate -> use with non-auth toolkits."""
# Server name must be ≤30 chars and only contain letters, numbers, spaces, hyphens
test_name = generate_unique_name("pytest-work")
# Step 1: Create MCP config
mcp_config = composio_client.mcp.create(
test_name,
toolkits=["composio_search", "text_to_pdf"],
allowed_tools=[
"COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH",
"TEXT_TO_PDF_CONVERT_TEXT_TO_PDF",
],
manually_manage_connections=False,
)
assert mcp_config.id is not None
assert mcp_config.name == test_name
# Step 2: Generate server instance
try:
server_instance = mcp_config.generate("workflow_user_123")
# Step 3: Verify server instance
assert server_instance["id"] == mcp_config.id
assert server_instance["user_id"] == "workflow_user_123"
assert server_instance["type"] == "streamable_http"
assert isinstance(server_instance["url"], str)
assert len(server_instance["url"]) > 0
print("✅ Full workflow successful!")
print(f" Config ID: {mcp_config.id}")
print(f" Server URL: {server_instance['url'][:50]}...")
except Exception as e:
print(f"Workflow generate step failed (may be expected): {e}")
def test_api_compatibility_with_typescript(self, composio_client):
"""Test that Python API matches TypeScript patterns."""
# Check method availability (matching TypeScript)
mcp = composio_client.mcp
# CRUD operations
assert hasattr(mcp, "create"), "Missing create method"
assert hasattr(mcp, "list"), "Missing list method"
assert hasattr(mcp, "get"), "Missing get method"
assert hasattr(mcp, "update"), "Missing update method"
assert hasattr(mcp, "delete"), "Missing delete method"
# Instance generation
assert hasattr(mcp, "generate"), "Missing generate method"
print("✅ API compatibility verified with TypeScript patterns")
@pytest.mark.skip(
reason="MCP update bug with 'custom_tools' argument - TypeError in McpResource.update()"
)
def test_full_crud_cycle(self, composio_client):
"""Test complete CRUD cycle: create -> get -> update -> get with assertions at each step."""
test_name = generate_unique_name("pytest-crud")
# Step 1: CREATE MCP server
print(f"🏗️ Step 1: Creating MCP server '{test_name}'")
mcp_server = composio_client.mcp.create(
test_name,
toolkits=["composio_search"],
allowed_tools=["COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH"],
manually_manage_connections=False,
)
# Assertions after CREATE
assert mcp_server.id is not None, "Created server should have ID"
assert mcp_server.name == test_name, f"Server name should be {test_name}"
assert mcp_server.allowed_tools == ["COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH"], (
"Should have correct allowed tools"
)
assert len(mcp_server.auth_config_ids) == 0, (
"Should have no auth configs for non-auth toolkits"
)
assert callable(mcp_server.generate), "Should have generate method"
print(f"✅ CREATE: Server created with ID {mcp_server.id}")
# Step 2: GET MCP server
print(f"📖 Step 2: Getting MCP server '{mcp_server.id}'")
retrieved_server = composio_client.mcp.get(mcp_server.id)
# Assertions after GET
assert retrieved_server.id == mcp_server.id, (
"Retrieved ID should match created ID"
)
assert retrieved_server.name == test_name, "Retrieved name should match"
print("✅ GET: Successfully retrieved server")
# Step 3: UPDATE MCP server
updated_name = f"{test_name}-updated"
print(
f"🔄 Step 3: Updating MCP server to '{updated_name}' with additional toolkit"
)
composio_client.mcp.update(
mcp_server.id,
name=updated_name,
toolkits=["composio_search", "text_to_pdf"],
allowed_tools=[
"COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH",
"TEXT_TO_PDF_CONVERT_TEXT_TO_PDF",
],
)
print("✅ UPDATE: Server updated successfully")
# Step 4: GET updated MCP server
print("📖 Step 4: Getting updated MCP server")
final_server = composio_client.mcp.get(mcp_server.id)
# Assertions after UPDATE and final GET
assert final_server.id == mcp_server.id, "ID should remain the same"
# Verify that updates took effect
assert final_server.name == updated_name, (
f"Name should be updated to {updated_name}"
)
# Check that toolkits were updated (should now include both composio_search and text_to_pdf)
expected_toolkits = ["composio_search", "text_to_pdf"]
actual_toolkits = getattr(final_server, "toolkits", [])
for toolkit in expected_toolkits:
assert toolkit in actual_toolkits, (
f"Updated toolkits should include {toolkit}"
)
# Check that allowed_tools were updated
expected_tools = [
"COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH",
"TEXT_TO_PDF_CONVERT_TEXT_TO_PDF",
]
actual_tools = getattr(final_server, "allowed_tools", [])
for tool in expected_tools:
assert tool in actual_tools, f"Updated allowed_tools should include {tool}"
print("✅ FINAL GET: Retrieved updated server with correct changes")
print(f" Updated name: {final_server.name}")
print(f" Updated toolkits: {actual_toolkits}")
print(f" Updated allowed_tools: {actual_tools}")
print(f"🎉 CRUD cycle test completed for server {mcp_server.id}")
# Direct execution support for backward compatibility
def main():
"""Main test function for direct execution."""
print("🎯 MCP Integration Tests")
print("Testing MCP functionality with non-auth toolkits")
print()
# Initialize Composio client
composio = Composio()
# Run the no-auth toolkit test directly
try:
# Create test instance
test_instance = TestMCPNoAuthToolkits()
test_instance.test_mcp_with_no_auth_toolkits(composio)
print("\n🎉 All tests passed! MCP is working with non-auth toolkits.")
return True
except Exception as e:
print(f"\n💥 Tests failed: {e}")
import traceback
traceback.print_exc()
return False
if __name__ == "__main__":
success = main()
exit(0 if success else 1)
View File
+241
View File
@@ -0,0 +1,241 @@
from __future__ import annotations
import os
import typing as t
import typing_extensions as te
from composio import exceptions
from composio.client import DEFAULT_MAX_RETRIES, APIEnvironment, HttpClient
from composio.core.models import (
AuthConfigs,
ConnectedAccounts,
Toolkits,
ToolRouter,
Tools,
Triggers,
)
from composio.core.models.base import allow_tracking
from composio.core.models.mcp import MCP
from composio.core.provider import TTool, TToolCollection
from composio.core.provider._openai import (
OpenAIProvider,
OpenAITool,
OpenAIToolCollection,
)
from composio.core.provider.base import BaseProvider
from composio.core.types import ToolkitVersionParam
from composio.utils.logging import WithLogger
from composio.utils.toolkit_version import get_toolkit_versions
_DEFAULT_PROVIDER = OpenAIProvider()
class SDKConfig(te.TypedDict):
environment: te.NotRequired[APIEnvironment]
api_key: te.NotRequired[str]
base_url: te.NotRequired[str]
timeout: te.NotRequired[int]
max_retries: te.NotRequired[int]
allow_tracking: te.NotRequired[bool]
file_download_dir: te.NotRequired[str]
toolkit_versions: te.NotRequired[ToolkitVersionParam]
dangerously_allow_auto_upload_download_files: te.NotRequired[bool]
sensitive_file_upload_protection: te.NotRequired[bool]
file_upload_path_deny_segments: te.NotRequired[t.Sequence[str]]
file_upload_dirs: te.NotRequired[t.Union[t.Sequence[str], t.Literal[False]]]
class Composio(t.Generic[TTool, TToolCollection], WithLogger):
"""
Composio SDK for Python.
Generic parameters:
TTool: The individual tool type returned by the provider (e.g., ChatCompletionToolParam for OpenAI).
TToolCollection: The collection type returned by get_tools (e.g., list[ChatCompletionToolParam]).
The generic types are automatically inferred from the provider passed to __init__.
When no provider is passed, defaults to OpenAI types.
Examples:
# Implicit type inference - recommended
composio = Composio(provider=OpenAIProvider()) # Composio[OpenAITool, list[OpenAITool]]
composio = Composio(provider=AnthropicProvider()) # Composio[ToolParam, list[ToolParam]]
composio = Composio() # Composio[OpenAITool, list[OpenAITool]] (default)
# Custom provider - types are inferred automatically
composio = Composio(provider=MyCustomProvider()) # Composio[MyTool, list[MyTool]]
"""
tools: "Tools[TTool, TToolCollection]"
@t.overload
def __init__(
self: "Composio[OpenAITool, OpenAIToolCollection]",
provider: None = None,
**kwargs: te.Unpack[SDKConfig],
) -> None:
"""Initialize with default OpenAI provider."""
...
@t.overload
def __init__(
self,
provider: BaseProvider[TTool, TToolCollection],
**kwargs: te.Unpack[SDKConfig],
) -> None:
"""Initialize with an explicit provider. Types are inferred from the provider."""
...
def __init__(
self,
provider: t.Optional[BaseProvider[t.Any, t.Any]] = None,
**kwargs: te.Unpack[SDKConfig],
) -> None:
"""
Initialize the Composio SDK.
:param provider: The provider to use for the SDK. Defaults to OpenAIProvider.
:param environment: The environment to use for the SDK.
:param api_key: The API key to use for the SDK.
:param base_url: The base URL to use for the SDK.
:param timeout: The timeout to use for the SDK.
:param max_retries: The maximum number of retries to use for the SDK.
:param toolkit_versions: A dictionary mapping toolkit names to specific versions:
- A dictionary mapping toolkit names to specific versions
- A string (e.g., 'latest', '20250906_01') to use the same version for all toolkits
- None or omitted to use 'latest' as default
:param dangerously_allow_auto_upload_download_files: Opt-in for automatic file
upload and download during tool execution. Defaults to False.
:param sensitive_file_upload_protection: When True, block local paths on the built-in sensitive-path denylist before upload. Defaults to True.
:param file_upload_path_deny_segments: Extra path segment names merged with the built-in denylist.
:param file_upload_dirs: Allowlist of directories from which the SDK is allowed
to read local files during **automatic** file upload (the flow gated by
``dangerously_allow_auto_upload_download_files=True``).
- ``None`` (default) -> ``[~/.composio/temp]``.
- ``False`` -> reject every local path during auto-upload. URLs and
in-memory bytes still work because they aren't path-checked.
- ``Sequence[str]`` (non-empty) -> use as the allowlist. A file is accepted
iff its symlink-resolved absolute path is inside one of these directories
on a path-component boundary (``/tmp/foo`` allows ``/tmp/foo/bar`` but
NOT ``/tmp/foo-bar``).
- ``[]`` -> behaves like ``False`` (kept as an alias; prefer ``False``).
- Providing a value REPLACES the default. Include ``~/.composio/temp`` in
your list if you want the default staging dir to keep working.
- On Windows, entries are compared case-insensitively.
"""
WithLogger.__init__(self)
api_key = kwargs.get("api_key", os.environ.get("COMPOSIO_API_KEY"))
if not api_key:
raise exceptions.ApiKeyNotProvidedError()
# Use default provider if none provided
# Cast to BaseProvider[TTool, TToolCollection] for type consistency
actual_provider: BaseProvider[TTool, TToolCollection] = t.cast(
BaseProvider[TTool, TToolCollection],
provider if provider is not None else _DEFAULT_PROVIDER,
)
# Process toolkit versions with environment variable support
toolkit_versions = get_toolkit_versions(kwargs.get("toolkit_versions"))
allow_tracking.set(kwargs.get("allow_tracking", True))
self._client = HttpClient(
environment=kwargs.get("environment", "production"),
provider=actual_provider.name,
api_key=api_key,
base_url=kwargs.get("base_url") or os.environ.get("COMPOSIO_BASE_URL"),
timeout=kwargs.get("timeout"),
max_retries=kwargs.get("max_retries", DEFAULT_MAX_RETRIES),
)
self.provider = actual_provider
sensitive_file_upload_protection: bool = kwargs.get(
"sensitive_file_upload_protection", True
)
file_upload_path_deny_segments: t.Optional[t.Sequence[str]] = kwargs.get(
"file_upload_path_deny_segments"
)
file_upload_dirs: t.Union[t.Sequence[str], t.Literal[False], None] = kwargs.get(
"file_upload_dirs"
)
self.tools = Tools(
client=self._client,
provider=actual_provider,
file_download_dir=kwargs.get("file_download_dir"),
toolkit_versions=toolkit_versions,
dangerously_allow_auto_upload_download_files=kwargs.get(
"dangerously_allow_auto_upload_download_files", False
),
sensitive_file_upload_protection=sensitive_file_upload_protection,
file_upload_path_deny_segments=file_upload_path_deny_segments,
file_upload_dirs=file_upload_dirs,
)
self.toolkits = Toolkits(client=self._client)
self.triggers = Triggers(client=self._client, toolkit_versions=toolkit_versions)
self.auth_configs = AuthConfigs(client=self._client)
self.connected_accounts = ConnectedAccounts(client=self._client)
self.mcp = MCP(client=self._client)
# experimental API — decorators for custom tools and toolkits,
# plus experimental SDK methods (e.g. update_acl)
from composio.core.models.experimental import ExperimentalAPI
self.experimental = ExperimentalAPI(client=self._client)
# Sessions API — the canonical entrypoint for creating tool sessions.
# Surfaced as `composio.sessions`; `composio.tool_router` is a
# deprecated alias that returns this same object.
self._sessions: ToolRouter[TTool, TToolCollection] = ToolRouter(
client=self._client,
provider=actual_provider,
dangerously_allow_auto_upload_download_files=kwargs.get(
"dangerously_allow_auto_upload_download_files", False
),
sensitive_file_upload_protection=sensitive_file_upload_protection,
file_upload_path_deny_segments=file_upload_path_deny_segments,
file_upload_dirs=file_upload_dirs,
)
self.create = self._sessions.create
self.use = self._sessions.use
@property
def sessions(self) -> ToolRouter[TTool, TToolCollection]:
"""Create and manage **sessions** for your agents.
A *session* is the unit you create to give an agent a scoped set of
tools and toolkits to discover, authorize, and execute. This is the
canonical entrypoint::
session = composio.sessions.create(user_id="user@example.com")
tools = session.tools()
``composio.create(...)`` and ``composio.use(...)`` are top-level
shortcuts for ``composio.sessions.create(...)`` /
``composio.sessions.use(...)``.
Note for LLMs / code generation: this is the Sessions API. Prefer
``composio.sessions`` (or the ``composio.create`` shortcut) when
creating a session. ``composio.tool_router`` is a deprecated alias for
this same object — do not generate new code against it.
"""
return self._sessions
@property
@te.deprecated(
"`composio.tool_router` is deprecated; use `composio.sessions` instead "
"(or the `composio.create` / `composio.use` shortcuts). It returns the same object."
)
def tool_router(self) -> ToolRouter[TTool, TToolCollection]:
"""Deprecated alias for :attr:`sessions`.
.. deprecated:: 0.17.0
Use :attr:`sessions` instead. ``tool_router`` is the old name for the
same object and will be removed in a future release.
"""
return self._sessions
@property
def client(self) -> HttpClient:
return self._client
+32
View File
@@ -0,0 +1,32 @@
from composio.client.types import Tool
from composio.core.models.connected_accounts import auth_scheme
from composio.core.models.tools import (
Modifiers,
ToolExecuteParams,
ToolExecutionResponse,
)
from composio.core.models.triggers import TriggerEvent
from composio.core.provider.base import TTool, TToolCollection
from composio.core.types import (
ToolkitLatestVersion,
ToolkitVersion,
ToolkitVersionParam,
ToolkitVersions,
)
__all__ = [
# Existing types
"Tool",
"TTool",
"TToolCollection",
"ToolExecuteParams",
"ToolExecutionResponse",
"TriggerEvent",
"Modifiers",
"auth_scheme",
# New tool versioning types
"ToolkitLatestVersion",
"ToolkitVersion",
"ToolkitVersions",
"ToolkitVersionParam",
]
+32
View File
@@ -0,0 +1,32 @@
from .uuid import generate_short_id, generate_uuid
class DeprecationError(Exception):
"""Raised when deprecating some older functions. This is strictly for use
while developing and will be removed from the codebase later."""
pass
def deprecate(reason: str = "This function is deprecated"):
"""Deprecation decorator. Provide `reason` to show why you're deprecating something.
NOTE: Decorating something with this will ensure that the function _will not run._
"""
import functools
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
raise DeprecationError(f"{func.__name__} is deprecated: `{reason}`")
return wrapper
return decorator
__all__ = [
"DeprecationError",
"deprecate",
"generate_short_id",
"generate_uuid",
]
+257
View File
@@ -0,0 +1,257 @@
"""Inline internal JSON Schema ``$ref`` pointers.
Python counterpart of the TypeScript SDK's ``dereferenceJsonSchema``
(``ts/packages/core/src/utils/jsonSchema.ts``). Hand-rolled schema walkers in
the SDK (e.g. the file-upload/download substitution in
:mod:`composio.core.models._files`) recurse through ``properties``/``anyOf``/
``oneOf``/``allOf``/``items`` but do not dereference ``$ref``. The Composio API
emits tool parameter schemas with ``$ref``/``$defs`` indirection (``GMAIL_GET_
ATTACHMENT`` is the canonical shape), so a ``file_uploadable``/``file_
downloadable`` flag reachable only through a reference is silently missed.
Resolving references *once* at the boundary — rather than teaching every walker
to dereference — keeps the walkers reference-agnostic and gives all of them
``$ref`` support for free, including keywords they never special-cased
(``additionalProperties``, ``patternProperties``, ``prefixItems``, ``not``, …),
because :func:`dereference_json_schema` walks containers reflectively.
"""
from __future__ import annotations
import typing as t
from composio.exceptions import JSONSchemaRefResolutionError
from composio.utils.logging import get as get_logger
logger = get_logger()
# A ``$ref`` chain longer than this, or object nesting deeper than this, is
# treated as pathological (cyclic data, a billion-laughs-style schema) and
# raises rather than exhausting the interpreter's recursion limit. The caps
# fire in both strict and lenient modes — leniency applies to *unresolvable*
# refs, not to runaway expansion.
MAX_REF_CHAIN_DEPTH = 100
MAX_NODE_DEPTH = 512
# In-band hint attached to the sentinel when lenient mode stands in for an
# unresolvable ``$ref``. Makes the degradation visible to an LLM reading the
# schema instead of leaving it a bare permissive object. Overridden by a
# caller-provided ``description`` sibling (Draft 2020-12 sibling-keyword merge).
UNRESOLVED_REF_DESCRIPTION = (
"Schema shape unresolved at the source — validate loosely. "
"See https://github.com/ComposioHQ/composio/issues/3307."
)
# Strategy when an internal ``$ref`` cannot be resolved.
# - ``"throw"`` (default): raise ``JSONSchemaRefResolutionError``. Right for
# first-party / custom-tool schemas where a dangling ``$ref`` is a bug.
# - ``"sentinel"``: replace the node with a permissive object. Right for
# schemas from an upstream service the caller cannot edit (the Composio API
# ships some ``output_parameters`` with a ``$ref`` into ``#/$defs/...`` but no
# ``$defs`` block — see https://github.com/ComposioHQ/composio/issues/3307).
UnresolvedRefStrategy = t.Literal["throw", "sentinel"]
UnresolvedRefReason = t.Literal["missing-target", "malformed-pointer"]
# Callback invoked once per replaced node in ``"sentinel"`` mode.
OnReplace = t.Callable[[str, "UnresolvedRefReason"], None]
def _cycle_break_sentinel() -> t.Dict[str, t.Any]:
"""A fresh permissive object used to break cycles / stand in for danglers."""
return {"type": "object", "additionalProperties": True}
def _is_mapping(value: t.Any) -> bool:
return isinstance(value, dict)
def _decode_pointer_segment(segment: str) -> str:
# Order matters: ``~01`` must decode to ``~1`` (literal), not ``/``.
return segment.replace("~1", "/").replace("~0", "~")
class _Resolution(t.NamedTuple):
ok: bool
value: t.Any = None
reason: t.Optional[UnresolvedRefReason] = None
failed_at: t.Optional[str] = None
def _try_resolve_pointer(root: t.Dict[str, t.Any], pointer: str) -> _Resolution:
"""Walk a local JSON Pointer (``#/a/b/0``) against ``root``.
Returns a tagged result so the caller can distinguish a malformed pointer
from a structurally valid pointer whose target is absent.
"""
if pointer in ("#", ""):
return _Resolution(ok=True, value=root)
if not pointer.startswith("#/"):
return _Resolution(ok=False, reason="malformed-pointer")
cursor: t.Any = root
for raw_segment in pointer[2:].split("/"):
segment = _decode_pointer_segment(raw_segment)
if isinstance(cursor, list):
try:
index = int(segment)
except ValueError:
return _Resolution(ok=False, reason="missing-target", failed_at=segment)
if index < 0 or index >= len(cursor):
return _Resolution(ok=False, reason="missing-target", failed_at=segment)
cursor = cursor[index]
elif isinstance(cursor, dict):
if segment not in cursor:
return _Resolution(ok=False, reason="missing-target", failed_at=segment)
cursor = cursor[segment]
else:
return _Resolution(ok=False, reason="missing-target", failed_at=segment)
return _Resolution(ok=True, value=cursor)
def _raise_resolution_error(pointer: str, resolution: _Resolution) -> t.NoReturn:
if resolution.reason == "malformed-pointer":
raise JSONSchemaRefResolutionError(
f"Unsupported $ref pointer: {pointer}",
meta={"ref": pointer},
)
meta: t.Dict[str, t.Any] = {"ref": pointer}
if resolution.failed_at is not None:
meta["failed_at"] = resolution.failed_at
raise JSONSchemaRefResolutionError(
f"Cannot resolve $ref {pointer}",
meta=meta,
)
def dereference_json_schema(
schema: t.Any,
*,
on_unresolved: UnresolvedRefStrategy = "throw",
on_replace: t.Optional[OnReplace] = None,
) -> t.Any:
"""Inline internal ``$ref`` pointers (``#/$defs/...`` and legacy
``#/definitions/...``), returning a new schema; the input is never mutated.
External refs (``http://``, ``https://``, …) are left untouched (and logged
once for audit, since a downstream resolver fetching them could enable SSRF
or local-file disclosure). Cycles — both ``$ref`` cycles and JS-object-style
identity cycles — are broken with a permissive ``{"type": "object",
"additionalProperties": True}`` sentinel. ``$defs``/``definitions`` are
stripped from the returned root once everything is inlined.
:param on_unresolved: see :data:`UnresolvedRefStrategy`.
:param on_replace: invoked once per replaced node in ``"sentinel"`` mode,
with the original pointer and the reason it could not be resolved.
:raises JSONSchemaRefResolutionError: on malformed pointers or missing
targets (strict mode only), or chains/nesting past the safety caps
(both modes).
"""
if not _is_mapping(schema):
return schema
root = t.cast(t.Dict[str, t.Any], schema)
# Identity-based cycle guard for the *current* walk path. Python doesn't
# suffer JS-style prototype pollution, so unlike the TS port there's no
# need to filter ``__proto__``/``constructor`` keys while cloning.
visiting: t.Set[int] = set()
# ``walk`` uses explicit loops (not comprehensions) so each level of schema
# nesting costs exactly one Python stack frame. That keeps ``MAX_NODE_DEPTH``
# the binding limit under the interpreter's default recursion ceiling, so a
# pathologically deep schema fails with our typed error rather than a bare
# ``RecursionError`` (which is also caught at the call site as a backstop).
def walk(
node: t.Any,
visited_refs: t.FrozenSet[str],
chain_depth: int,
node_depth: int,
) -> t.Any:
if node_depth >= MAX_NODE_DEPTH:
raise JSONSchemaRefResolutionError(
f"JSON Schema node depth exceeded cap ({MAX_NODE_DEPTH})"
)
if isinstance(node, list):
node_id = id(node)
if node_id in visiting:
return _cycle_break_sentinel()
visiting.add(node_id)
try:
cloned_list: t.List[t.Any] = []
for item in node:
cloned_list.append(
walk(item, visited_refs, chain_depth, node_depth + 1)
)
return cloned_list
finally:
visiting.discard(node_id)
if not _is_mapping(node):
return node
node_id = id(node)
if node_id in visiting:
return _cycle_break_sentinel()
visiting.add(node_id)
try:
ref = node.get("$ref")
ref = ref if isinstance(ref, str) else None
# External refs and non-``$ref`` nodes both pass through the same
# reflective clone path.
if ref is None or not ref.startswith("#"):
if ref is not None:
logger.warning("Leaving external $ref untouched: %s", ref)
cloned: t.Dict[str, t.Any] = {}
for key, value in node.items():
cloned[key] = walk(value, visited_refs, chain_depth, node_depth + 1)
return cloned
if chain_depth >= MAX_REF_CHAIN_DEPTH:
raise JSONSchemaRefResolutionError(
f"JSON Schema $ref chain exceeded depth cap "
f"({MAX_REF_CHAIN_DEPTH}): {ref}",
meta={"ref": ref},
)
if ref in visited_refs:
return _cycle_break_sentinel()
resolution = _try_resolve_pointer(root, ref)
if resolution.ok:
target: t.Any = resolution.value
elif on_unresolved == "sentinel":
if on_replace is not None and resolution.reason is not None:
on_replace(ref, resolution.reason)
target = {
**_cycle_break_sentinel(),
"description": UNRESOLVED_REF_DESCRIPTION,
}
else:
_raise_resolution_error(ref, resolution)
next_refs = visited_refs | {ref}
resolved = walk(target, next_refs, chain_depth + 1, node_depth + 1)
# Shallow-merge sibling keywords next to ``$ref`` (Draft 2020-12:
# siblings win on collision). Draft 7 ignores them, but the Composio
# tool surface admits both, so we honor siblings for safety.
siblings = {key: value for key, value in node.items() if key != "$ref"}
if not siblings or not _is_mapping(resolved):
return resolved
merged = dict(resolved)
for key, value in siblings.items():
merged[key] = walk(value, visited_refs, chain_depth, node_depth + 1)
return merged
finally:
visiting.discard(node_id)
try:
out = walk(root, frozenset(), 0, 0)
except RecursionError as exc: # pragma: no cover - depth cap normally fires first
raise JSONSchemaRefResolutionError(
"JSON Schema nesting too deep to dereference"
) from exc
if _is_mapping(out):
out.pop("$defs", None)
out.pop("definitions", None)
return out
+169
View File
@@ -0,0 +1,169 @@
"""
Logging utilities.
"""
import logging
import os
import typing as t
from enum import Enum
ENV_COMPOSIO_LOGGING_LEVEL = "COMPOSIO_LOGGING_LEVEL"
_DEFAULT_FORMAT = "[%(asctime)s][%(levelname)s] %(message)s"
_DEFAULT_LOGGER_NAME = "composio"
_LOG_VERBOSITY = int(os.environ.get("COMPOSIO_LOG_VERBOSITY", 0))
_LOG_LINE_SIZE_BY_VERBOSITY = {
0: 256,
1: 512,
2: 1024,
3: -1,
}
_logger: t.Optional[logging.Logger] = None
"""Global logger object for composio."""
_logger_wrapper: t.Optional["_VerbosityWrapper"] = None
"""Global logger object wrapped with verbosity setting for composio."""
class LogLevel(Enum):
"""Logging level."""
CRITICAL = "critical"
FATAL = "fatal"
ERROR = "error"
WARNING = "warning"
WARN = "warn"
INFO = "info"
DEBUG = "debug"
NOTSET = "notset"
_LEVELS: t.Dict[LogLevel, int] = {
LogLevel.CRITICAL: logging.CRITICAL,
LogLevel.FATAL: logging.FATAL,
LogLevel.ERROR: logging.ERROR,
LogLevel.WARNING: logging.WARNING,
LogLevel.WARN: logging.WARN,
LogLevel.INFO: logging.INFO,
LogLevel.DEBUG: logging.DEBUG,
LogLevel.NOTSET: logging.NOTSET,
}
class _VerbosityWrapper:
def __init__(
self,
logger: logging.Logger,
verbosity_level: t.Optional[int] = None,
) -> None:
self.logger = logger
self.level = logger.level
self.setup(verbosity_level=verbosity_level)
self.verbosity = min(verbosity_level or _LOG_VERBOSITY, 3)
self.size = _LOG_LINE_SIZE_BY_VERBOSITY[self.verbosity]
def setup(self, verbosity_level: t.Optional[int] = None) -> None:
if verbosity_level is None:
return
self.verbosity = verbosity_level
self.size = _LOG_LINE_SIZE_BY_VERBOSITY[self.verbosity]
def _trim(self, msg) -> str:
msg = str(msg)
if self.size == -1:
return msg
if len(msg) < self.size:
return msg
return msg[: self.size] + "..."
def info(self, msg, *args, **kwargs):
self.logger.info(self._trim(msg), *args, **kwargs)
def debug(self, msg, *args, **kwargs):
self.logger.debug(self._trim(msg), *args, **kwargs)
def warning(self, msg, *args, **kwargs):
self.logger.warning(self._trim(msg), *args, **kwargs)
def error(self, msg, *args, **kwargs):
self.logger.error(msg, *args, **kwargs)
def isEnabledFor(self, level: int):
return self.logger.isEnabledFor(level=level)
def _parse_log_level_from_env(default: int) -> int:
"""Parse log level from environment."""
level = os.environ.get(ENV_COMPOSIO_LOGGING_LEVEL)
if level is None:
return default
try:
return _LEVELS[LogLevel(level.lower())]
except (ValueError, KeyError):
return default
def setup(level: LogLevel = LogLevel.INFO, log_format: str = _DEFAULT_FORMAT) -> None:
"""Setup logging config."""
global _logger_wrapper
if _logger_wrapper is None:
_logger_wrapper = get(name=_DEFAULT_LOGGER_NAME)
logging.basicConfig(format=log_format)
_logger_wrapper.logger.setLevel(_LEVELS[level])
def get(
name: t.Optional[str] = None,
level: LogLevel = LogLevel.INFO,
log_format: str = _DEFAULT_FORMAT,
verbosity_level: t.Optional[int] = None,
) -> _VerbosityWrapper:
"""Set up the logger."""
global _logger, _logger_wrapper
if _logger_wrapper is not None:
return t.cast(_VerbosityWrapper, _logger_wrapper)
# Setup logging format.
logging.basicConfig(format=log_format)
# Create logger
_logger = logging.getLogger(name or _DEFAULT_LOGGER_NAME)
_logger.setLevel(_parse_log_level_from_env(default=_LEVELS[level]))
_logger_wrapper = _VerbosityWrapper(_logger, verbosity_level=verbosity_level)
return _logger_wrapper
class WithLogger:
"""Interface to endow subclasses with a logger."""
def __init__(
self,
logger: t.Optional[logging.Logger] = None,
logger_name: str = _DEFAULT_LOGGER_NAME,
logging_level: LogLevel = LogLevel.INFO,
verbosity_level: t.Optional[int] = None,
) -> None:
"""
Initialize the logger.
:param logger: the logger object.
:param logger_name: the default logger name, if a logger is not provided.
"""
self._logger = (
_VerbosityWrapper(logger, verbosity_level=verbosity_level)
if logger is not None
else get(name=logger_name, level=logging_level)
)
self._logger.setup(verbosity_level=verbosity_level)
self._logging_level = logging._levelToName[self._logger.level]
@property
def logger(self) -> logging.Logger:
"""Get the component logger."""
return t.cast(logging.Logger, self._logger)
+576
View File
@@ -0,0 +1,576 @@
import typing as t
from pathlib import Path
_default = "application/octet-stream"
_types = {
".3dm": "x-world/x-3dmf",
".3dmf": "x-world/x-3dmf",
".7z": "application/x-7z-compressed",
".a": "application/octet-stream",
".aab": "application/x-authorware-bin",
".aam": "application/x-authorware-map",
".aas": "application/x-authorware-seg",
".abc": "text/vnd.abc",
".acgi": "text/html",
".afl": "video/animaflex",
".ai": "application/postscript",
".aif": "audio/x-aiff",
".aifc": "audio/x-aiff",
".aiff": "audio/x-aiff",
".aim": "application/x-aim",
".aip": "text/x-audiosoft-intra",
".ani": "application/x-navi-animation",
".aos": "application/x-nokia-9000-communicator-add-on-software",
".aps": "application/mime",
".arc": "application/octet-stream",
".arj": "application/octet-stream",
".art": "image/x-jg",
".asf": "video/x-ms-asf",
".asm": "text/x-asm",
".asp": "text/asp",
".asx": "video/x-ms-asf-plugin",
".au": "audio/basic",
".avi": "video/x-msvideo",
".avs": "video/avs-video",
".bcpio": "application/x-bcpio",
".bin": "application/octet-stream",
".bm": "image/bmp",
".bmp": "image/x-ms-bmp",
".boo": "application/book",
".book": "application/book",
".boz": "application/x-bzip2",
".bsh": "application/x-bsh",
".bz": "application/x-bzip",
".bz2": "application/x-bzip2",
".c": "text/plain",
".c+": "text/plain",
".cat": "application/vnd.ms-pki.seccat",
".cc": "text/x-c",
".ccad": "application/clariscad",
".cco": "application/x-cocoa",
".cdf": "application/x-netcdf",
".cer": "application/x-x509-ca-cert",
".cha": "application/x-chat",
".chat": "application/x-chat",
".class": "application/x-java-class",
".com": "text/plain",
".conf": "text/plain",
".cpio": "application/x-cpio",
".cpp": "text/x-c",
".cpt": "application/x-cpt",
".crl": "application/pkix-crl",
".crt": "application/x-x509-user-cert",
".csh": "application/x-csh",
".css": "text/css",
".csv": "text/csv",
".cxx": "text/plain",
".dcr": "application/x-director",
".deepv": "application/x-deepv",
".def": "text/plain",
".der": "application/x-x509-ca-cert",
".dif": "video/x-dv",
".dir": "application/x-director",
".dl": "video/x-dl",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".dot": "application/msword",
".dp": "application/commonground",
".drw": "application/drafting",
".dump": "application/octet-stream",
".dv": "video/x-dv",
".dvi": "application/x-dvi",
".dwf": "model/vnd.dwf",
".dwg": "image/x-dwg",
".dxf": "image/x-dwg",
".dxr": "application/x-director",
".el": "text/x-script.elisp",
".elc": "application/x-elc",
".env": "application/x-envoy",
".eot": "application/vnd.ms-fontobject",
".eps": "application/postscript",
".es": "application/x-esrehber",
".etx": "text/x-setext",
".evy": "application/x-envoy",
".exe": "application/octet-stream",
".f": "text/x-fortran",
".f77": "text/x-fortran",
".f90": "text/x-fortran",
".fdf": "application/vnd.fdf",
".fif": "image/fif",
".flac": "audio/flac",
".fli": "video/x-fli",
".flo": "image/florian",
".flx": "text/vnd.fmi.flexstor",
".fmf": "video/x-atomic3d-feature",
".for": "text/x-fortran",
".fpx": "image/vnd.net-fpx",
".frl": "application/freeloader",
".funk": "audio/make",
".g": "text/plain",
".g3": "image/g3fax",
".gif": "image/gif",
".gl": "video/x-gl",
".gsd": "audio/x-gsm",
".gsm": "audio/x-gsm",
".gsp": "application/x-gsp",
".gss": "application/x-gss",
".gtar": "application/x-gtar",
".gz": "application/x-gzip",
".gzip": "multipart/x-gzip",
".h": "text/plain",
".hdf": "application/x-hdf",
".help": "application/x-helpfile",
".hgl": "application/vnd.hp-hpgl",
".hh": "text/x-h",
".hlb": "text/x-script",
".hlp": "application/x-winhelp",
".hpg": "application/vnd.hp-hpgl",
".hpgl": "application/vnd.hp-hpgl",
".hqx": "application/x-mac-binhex40",
".hta": "application/hta",
".htc": "text/x-component",
".htm": "text/html",
".html": "text/html",
".htmls": "text/html",
".htt": "text/webviewhtml",
".htx": "text/html",
".ice": "x-conference/x-cooltalk",
".ico": "image/vnd.microsoft.icon",
".ics": "text/calendar",
".idc": "text/plain",
".ief": "image/ief",
".iefs": "image/ief",
".iges": "model/iges",
".igs": "model/iges",
".ima": "application/x-ima",
".imap": "application/x-httpd-imap",
".inf": "application/inf",
".ins": "application/x-internett-signup",
".ip": "application/x-ip2",
".isu": "video/x-isvideo",
".it": "audio/it",
".iv": "application/x-inventor",
".ivr": "i-world/i-vrml",
".ivy": "application/x-livescreen",
".jam": "audio/x-jam",
".jav": "text/x-java-source",
".java": "text/x-java-source",
".jcm": "application/x-java-commerce",
".jfif": "image/pjpeg",
".jfif-bnl": "image/jpeg",
".jpe": "image/jpeg",
".jpeg": "image/jpeg",
".jpg": "image/jpg",
".jps": "image/x-jps",
".js": "application/javascript",
".json": "application/json",
".jut": "image/jutvision",
".kar": "music/x-karaoke",
".ksh": "text/plain",
".la": "audio/x-nspaudio",
".lam": "audio/x-liveaudio",
".latex": "application/x-latex",
".lha": "application/x-lha",
".lhx": "application/octet-stream",
".list": "text/plain",
".lma": "audio/x-nspaudio",
".log": "text/plain",
".lsp": "text/x-script.lisp",
".lst": "text/plain",
".lsx": "text/x-la-asf",
".ltx": "application/x-latex",
".lzh": "application/x-lzh",
".lzx": "application/x-lzx",
".m": "text/x-m",
".m1v": "video/mpeg",
".m2a": "audio/mpeg",
".m2v": "video/mpeg",
".m3u": "application/vnd.apple.mpegurl",
".man": "application/x-troff-man",
".map": "application/x-navimap",
".mar": "text/plain",
".mbd": "application/mbedlet",
".mc": "application/x-magic-cap-package-1.0",
".mcd": "application/x-mathcad",
".mcf": "text/mcf",
".mcp": "application/netmc",
".me": "application/x-troff-me",
".mht": "message/rfc822",
".mhtml": "message/rfc822",
".mid": "audio/midi",
".midi": "audio/midi",
".mif": "application/x-mif",
".mime": "www/mime",
".mjf": "audio/x-vnd.audioexplosion.mjuicemediafile",
".mjpg": "video/x-motion-jpeg",
".mka": "audio/x-matroska",
".mkv": "video/x-matroska",
".mm": "application/x-meme",
".mme": "application/base64",
".mod": "audio/x-mod",
".moov": "video/quicktime",
".mov": "video/quicktime",
".movie": "video/x-sgi-movie",
".mp2": "audio/mpeg",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".mpa": "video/mpeg",
".mpc": "application/x-project",
".mpe": "video/mpeg",
".mpeg": "video/mpeg",
".mpg": "video/mpeg",
".mpga": "audio/mpeg",
".mpp": "application/vnd.ms-project",
".mpt": "application/x-project",
".mpv": "application/x-project",
".mpx": "application/x-project",
".mrc": "application/marc",
".ms": "application/x-troff-ms",
".mv": "video/x-sgi-movie",
".my": "audio/make",
".mzz": "application/x-vnd.audioexplosion.mzz",
".nap": "image/naplps",
".naplps": "image/naplps",
".nc": "application/x-netcdf",
".ncm": "application/vnd.nokia.configuration-message",
".nif": "image/x-niff",
".niff": "image/x-niff",
".nix": "application/x-mix-transfer",
".nsc": "application/x-conference",
".nvd": "application/x-navidoc",
".o": "application/octet-stream",
".oda": "application/oda",
".ogg": "video/ogg",
".omc": "application/x-omc",
".omcd": "application/x-omcdatamaker",
".omcr": "application/x-omcregerator",
".otf": "font/otf",
".p": "text/x-pascal",
".p10": "application/x-pkcs10",
".p12": "application/x-pkcs12",
".p7a": "application/x-pkcs7-signature",
".p7c": "application/pkcs7-mime",
".p7m": "application/x-pkcs7-mime",
".p7r": "application/x-pkcs7-certreqresp",
".p7s": "application/pkcs7-signature",
".part": "application/pro_eng",
".pas": "text/pascal",
".pbm": "image/x-portable-bitmap",
".pcl": "application/x-pcl",
".pct": "image/pict",
".pcx": "image/x-pcx",
".pdb": "chemical/x-pdb",
".pdf": "application/pdf",
".pfunk": "audio/make.my.funk",
".pgm": "image/x-portable-graymap",
".pic": "image/pict",
".pict": "image/pict",
".pkg": "application/x-newton-compatible-pkg",
".pko": "application/vnd.ms-pki.pko",
".pl": "text/plain",
".plx": "application/x-pixclscript",
".pm": "text/x-script.perl-module",
".pm4": "application/x-pagemaker",
".pm5": "application/x-pagemaker",
".png": "image/png",
".pnm": "image/x-portable-anymap",
".pot": "application/vnd.ms-powerpoint",
".pov": "model/x-pov",
".ppa": "application/vnd.ms-powerpoint",
".ppm": "image/x-portable-pixmap",
".pps": "application/vnd.ms-powerpoint",
".ppt": "application/vnd.ms-powerpoint",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".ppz": "application/mspowerpoint",
".pre": "application/x-freelance",
".prt": "application/pro_eng",
".ps": "application/postscript",
".psd": "application/octet-stream",
".pvu": "paleovu/x-pv",
".pwz": "application/vnd.ms-powerpoint",
".py": "text/x-python",
".pyc": "application/x-python-code",
".qcp": "audio/vnd.qcelp",
".qd3": "x-world/x-3dmf",
".qd3d": "x-world/x-3dmf",
".qif": "image/x-quicktime",
".qt": "video/quicktime",
".qtc": "video/x-qtc",
".qti": "image/x-quicktime",
".qtif": "image/x-quicktime",
".ra": "audio/x-pn-realaudio",
".ram": "application/x-pn-realaudio",
".ras": "image/x-cmu-raster",
".rast": "image/cmu-raster",
".rar": "application/vnd.rar",
".rexx": "text/x-script.rexx",
".rf": "image/vnd.rn-realflash",
".rgb": "image/x-rgb",
".rm": "audio/x-pn-realaudio",
".rmi": "audio/mid",
".rmm": "audio/x-pn-realaudio",
".rmp": "audio/x-pn-realaudio-plugin",
".rng": "application/vnd.nokia.ringing-tone",
".rnx": "application/vnd.rn-realplayer",
".roff": "application/x-troff",
".rp": "image/vnd.rn-realpix",
".rpm": "audio/x-pn-realaudio-plugin",
".rt": "text/vnd.rn-realtext",
".rtf": "application/rtf",
".rtx": "text/richtext",
".rv": "video/vnd.rn-realvideo",
".s": "text/x-asm",
".s3m": "audio/s3m",
".saveme": "application/octet-stream",
".sbk": "application/x-tbook",
".scm": "video/x-scm",
".sdml": "text/plain",
".sdp": "application/x-sdp",
".sdr": "application/sounder",
".sea": "application/x-sea",
".set": "application/set",
".sgm": "text/x-sgml",
".sgml": "text/x-sgml",
".sh": "application/x-sh",
".shar": "application/x-shar",
".shtml": "text/x-server-parsed-html",
".sid": "audio/x-psid",
".sit": "application/x-stuffit",
".skd": "application/x-koan",
".skm": "application/x-koan",
".skp": "application/x-koan",
".skt": "application/x-koan",
".sl": "application/x-seelogo",
".smi": "application/smil",
".smil": "application/smil",
".snd": "audio/basic",
".sol": "application/solids",
".spc": "text/x-speech",
".spl": "application/futuresplash",
".spr": "application/x-sprite",
".sprite": "application/x-sprite",
".src": "application/x-wais-source",
".ssi": "text/x-server-parsed-html",
".ssm": "application/streamingmedia",
".sst": "application/vnd.ms-pki.certstore",
".step": "application/step",
".stl": "application/x-navistyle",
".stp": "application/step",
".sv4cpio": "application/x-sv4cpio",
".sv4crc": "application/x-sv4crc",
".svf": "image/x-dwg",
".svg": "image/svg+xml",
".svr": "x-world/x-svr",
".swf": "application/x-shockwave-flash",
".t": "application/x-troff",
".talk": "text/x-speech",
".tar": "application/x-tar",
".tbk": "application/x-tbook",
".tcl": "application/x-tcl",
".tcsh": "text/x-script.tcsh",
".tex": "application/x-tex",
".texi": "application/x-texinfo",
".texinfo": "application/x-texinfo",
".text": "text/plain",
".tgz": "application/x-compressed",
".tif": "image/tiff",
".tiff": "image/tiff",
".tr": "application/x-troff",
".ts": "video/mp2t",
".tsi": "audio/tsp-audio",
".tsp": "audio/tsplayer",
".tsv": "text/tab-separated-values",
".turbot": "image/florian",
".txt": "text/plain",
".uil": "text/x-uil",
".uni": "text/uri-list",
".unis": "text/uri-list",
".unv": "application/i-deas",
".uri": "text/uri-list",
".uris": "text/uri-list",
".ustar": "application/x-ustar",
".uu": "text/x-uuencode",
".uue": "text/x-uuencode",
".vcd": "application/x-cdlink",
".vcs": "text/x-vcalendar",
".vda": "application/vda",
".vdo": "video/vdo",
".vew": "application/groupwise",
".viv": "video/vnd.vivo",
".vivo": "video/vnd.vivo",
".vmd": "application/vocaltec-media-desc",
".vmf": "application/vocaltec-media-file",
".voc": "audio/x-voc",
".vos": "video/vosaic",
".vox": "audio/voxware",
".vqe": "audio/x-twinvq-plugin",
".vqf": "audio/x-twinvq",
".vql": "audio/x-twinvq-plugin",
".vrml": "x-world/x-vrml",
".vrt": "x-world/x-vrt",
".vsd": "application/x-visio",
".vst": "application/x-visio",
".vsw": "application/x-visio",
".w60": "application/wordperfect6.0",
".w61": "application/wordperfect6.1",
".w6w": "application/msword",
".wav": "audio/x-wav",
".wb1": "application/x-qpro",
".wbmp": "image/vnd.wap.wbmp",
".web": "application/vnd.xara",
".webm": "video/webm",
".webp": "image/webp",
".wiz": "application/msword",
".wk1": "application/x-123",
".wmf": "windows/metafile",
".wml": "text/vnd.wap.wml",
".wmlc": "application/vnd.wap.wmlc",
".wmls": "text/vnd.wap.wmlscript",
".wmlsc": "application/vnd.wap.wmlscriptc",
".word": "application/msword",
".woff": "font/woff",
".woff2": "font/woff2",
".wp": "application/wordperfect",
".wp5": "application/wordperfect6.0",
".wp6": "application/wordperfect",
".wpd": "application/x-wpwin",
".wq1": "application/x-lotus",
".wri": "application/x-wri",
".wrl": "x-world/x-vrml",
".wrz": "x-world/x-vrml",
".wsc": "text/scriplet",
".wsrc": "application/x-wais-source",
".wtk": "application/x-wintalk",
".xbm": "image/x-xbitmap",
".xdr": "video/x-amt-demorun",
".xgz": "xgl/drawing",
".xif": "image/vnd.xiff",
".xl": "application/excel",
".xla": "application/x-msexcel",
".xlb": "application/vnd.ms-excel",
".xlc": "application/x-excel",
".xld": "application/x-excel",
".xlk": "application/x-excel",
".xll": "application/x-excel",
".xlm": "application/x-excel",
".xls": "application/vnd.ms-excel",
".xlt": "application/x-excel",
".xlv": "application/x-excel",
".xlw": "application/x-msexcel",
".xm": "audio/xm",
".xml": "text/xml",
".xmz": "xgl/movie",
".xpix": "application/x-vnd.ls-xpix",
".xpm": "image/x-xpixmap",
".x-ng": "image/png",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".xsr": "video/x-amt-showrun",
".xwd": "image/x-xwindowdump",
".xyz": "chemical/x-pdb",
".yaml": "application/x-yaml",
".yml": "application/x-yaml",
".z": "application/x-compressed",
".zip": "application/zip",
".zoo": "application/octet-stream",
".zsh": "text/x-script.zsh",
".mjs": "application/javascript",
".webmanifest": "application/manifest+json",
".dll": "application/octet-stream",
".obj": "application/octet-stream",
".so": "application/octet-stream",
".m3u8": "application/vnd.apple.mpegurl",
".wasm": "application/wasm",
".h5": "application/x-hdf5",
".pfx": "application/x-pkcs12",
".pyo": "application/x-python-code",
".xsl": "application/xml",
".rdf": "application/xml",
".wsdl": "application/xml",
".xpdl": "application/xml",
".3gp": "audio/3gpp",
".3gpp": "audio/3gpp",
".3g2": "audio/3gpp2",
".3gpp2": "audio/3gpp2",
".aac": "audio/aac",
".adts": "audio/aac",
".loas": "audio/aac",
".ass": "audio/aac",
".opus": "audio/opus",
".heic": "image/heic",
".heif": "image/heif",
".eml": "message/rfc822",
".nws": "message/rfc822",
".bat": "text/plain",
".vcf": "text/x-vcard",
".xul": "text/xul",
}
def guess(file: t.Union[str, Path]) -> str:
return _types.get(Path(file).suffix, _default)
# MIME type -> extension (no leading dot). Used when deriving filenames from
# content-type headers (e.g. for URLs without path segments).
_MIME_TO_EXT: t.Dict[str, str] = {
"text/plain": "txt",
"text/html": "html",
"text/css": "css",
"text/javascript": "js",
"application/json": "json",
"application/xml": "xml",
"application/pdf": "pdf",
"application/zip": "zip",
"application/x-zip-compressed": "zip",
"application/gzip": "gz",
"application/octet-stream": "bin",
"application/x-tar": "tar",
"application/msword": "doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
"application/vnd.ms-excel": "xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
"application/vnd.ms-powerpoint": "ppt",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/svg+xml": "svg",
"image/webp": "webp",
"image/bmp": "bmp",
"image/tiff": "tiff",
"audio/mpeg": "mp3",
"audio/wav": "wav",
"audio/ogg": "ogg",
"video/mp4": "mp4",
"video/mpeg": "mpeg",
"video/quicktime": "mov",
"video/x-msvideo": "avi",
"video/webm": "webm",
}
def get_extension_from_mime_type(mime_type: str) -> str:
"""Map MIME type to file extension (no leading dot).
Used when deriving filenames from content-type headers
(e.g. for URLs without path segments).
"""
clean = mime_type.split(";")[0].lower().strip()
if clean in _MIME_TO_EXT:
return _MIME_TO_EXT[clean]
parts = clean.split("/")
if len(parts) == 2:
subtype = parts[1].lower()
if "+" in subtype:
plus_parts = subtype.split("+")
suffix = plus_parts[-1]
known_prefixes = ("svg", "atom", "rss")
if plus_parts[0] in known_prefixes:
return plus_parts[0]
structured_suffixes = ("json", "xml", "yaml", "zip", "gzip")
if suffix in structured_suffixes:
return suffix
return suffix
return subtype or "txt"
return "bin"
+116
View File
@@ -0,0 +1,116 @@
"""OpenAPI helpers."""
import inspect
import typing as t
from composio.exceptions import InvalidSchemaError
OPENAPI_TO_PYTHON = {
"null": None,
"number": float,
"integer": int,
"boolean": bool,
"string": str,
}
# pylint: disable=unused-argument
def _handle_object_type(schema: t.Dict) -> t.Type:
# Nested objects are not supported ATM
return t.Dict[str, t.Any]
def _handle_array_type(schema: t.Dict) -> t.Any:
# This discards the nested objects
items_type = schema.get("items", {}).get("type")
if items_type is None:
return t.List[t.Any]
SubT = _type_to_parameter(schema=schema.get("items", {}))
return t.List[SubT] # type: ignore
def _handle_enum_type(schema: t.Dict) -> t.Any:
return t.Literal[tuple(schema["enum"])]
def _type_to_parameter(schema: t.Dict[str, t.Any]) -> t.Any:
if "enum" in schema:
return _handle_enum_type(schema=schema)
p_type = schema.get("type")
if p_type in OPENAPI_TO_PYTHON:
return OPENAPI_TO_PYTHON[p_type]
if p_type == "object":
return _handle_object_type(schema=schema)
if p_type == "array":
return _handle_array_type(schema=schema)
if p_type is None:
# No type specified (e.g. a combiner option that is description-only or
# an explicit Any), mirroring the top-level fallback below.
return t.Any
raise InvalidSchemaError(f"Invalid property type {p_type}: {schema!r}")
def _handle_composite_type(schemas: t.List[t.Dict]) -> t.Any:
if not schemas:
# An empty oneOf/anyOf has no options to union; fall back to Any.
return t.Any
return t.Union[tuple(map(_type_to_parameter, schemas))]
def _one_of_to_parameter(schema: t.Dict[str, t.Any]) -> t.Any:
return _handle_composite_type(schemas=schema["oneOf"])
def _any_of_to_parameter(schema: t.Dict[str, t.Any]) -> t.Any:
return _handle_composite_type(schemas=schema["anyOf"])
def _all_of_to_parameter(schema: t.Dict[str, t.Any]) -> t.Type:
composite = {}
for subschema in schema["allOf"]:
composite.update(subschema)
return _type_to_parameter(schema=composite)
def function_signature_from_jsonschema(
schema: t.Dict[str, t.Any],
skip_default: bool = False,
) -> t.List[inspect.Parameter]:
"""Convert json schema to a list of parameters (`inspect.Parameter`)."""
parameters = []
required = set(schema.get("required", []))
for p_name, p_schema in schema.get("properties", {}).items():
if "oneOf" in p_schema:
p_type = _one_of_to_parameter(schema=p_schema)
elif "anyOf" in p_schema:
p_type = _any_of_to_parameter(schema=p_schema)
elif "allOf" in p_schema:
p_type = _all_of_to_parameter(schema=p_schema)
elif "type" in p_schema:
p_type = _type_to_parameter(schema=p_schema)
else:
# Handle cases where no type is specified (e.g., Pydantic's Any type)
# This typically happens when using typing.Any in Pydantic models,
# which intentionally omits the 'type' field in JSON schema
p_type = t.Any
p_val = p_schema.get("default", None)
if p_name in required or p_schema.get("required", False) or skip_default:
p_val = inspect.Parameter.empty
parameters.append(
inspect.Parameter(
name=p_name,
annotation=p_type,
default=p_val,
kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,
)
)
return parameters
+44
View File
@@ -0,0 +1,44 @@
import typing as t
import pydantic
from composio_client import omit
def none_to_omit(value: t.Optional[t.Any]) -> t.Any:
"""Convert None to omit for composio_client API calls.
This utility function helps convert Python's None values to composio_client's
omit sentinel value, which tells the API to exclude the parameter entirely.
Args:
value: Any value that might be None
Returns:
The original value if not None, otherwise omit
Example:
>>> none_to_omit("hello")
"hello"
>>> none_to_omit(None)
<omit>
>>> none_to_omit(42)
42
"""
return omit if value is None else value
def parse_pydantic_error(e: pydantic.ValidationError) -> str:
"""Parse pydantic validation error."""
message = "Invalid request data provided"
missing = []
others = [""]
for error in e.errors():
param = ".".join(map(str, error["loc"]))
if error["type"] == "missing":
missing.append(param)
continue
others.append(error["msg"] + f" on parameter `{param}`")
if len(missing) > 0:
message += f"\n- Following fields are missing: {set(missing)}"
message += "\n- ".join(others)
return message
+288
View File
@@ -0,0 +1,288 @@
"""
JSON Schema to Pydantic type conversion using json-schema-to-pydantic library.
This module provides a wrapper around json-schema-to-pydantic that maintains
backwards compatibility with the existing json_schema_to_pydantic_type() API.
The library handles most JSON Schema features correctly, but doesn't support
boolean schemas (true/false values that are valid in JSON Schema draft-06+).
We handle those by pre-filtering them before passing to the library.
"""
import typing as t
from functools import reduce
from json_schema_to_pydantic import (
CombinerError,
SchemaError,
create_model as create_model_from_schema,
)
from composio.utils.logging import get as get_logger
logger = get_logger(__name__)
# Type mapping for simple cases where we don't need full model creation
PYDANTIC_TYPE_TO_PYTHON_TYPE = {
"string": str,
"integer": int,
"number": float,
"boolean": bool,
"array": t.List,
"object": t.Dict,
"null": t.Optional[t.Any],
}
CONTAINER_TYPE = ("array", "object")
# Should be deprecated,
# required values will always be provided by users
# Non-required values are nullable(None) if default value not provided.
FALLBACK_VALUES = {
"string": "",
"number": 0.0,
"integer": 0,
"boolean": False,
"object": {},
"array": [],
"null": None,
}
def _filter_boolean_schemas(
schema: t.Union[t.Dict[str, t.Any], bool, t.List],
) -> t.Union[t.Dict[str, t.Any], t.Any, None]:
"""
Pre-filter boolean schemas from anyOf/allOf/oneOf arrays.
JSON Schema draft-06+ allows `true` and `false` as valid schemas:
- `true` means "accept any value" (equivalent to {})
- `false` means "reject all values" (equivalent to {"not": {}})
The json-schema-to-pydantic library doesn't handle these, so we:
- Replace `true` with {} (empty schema, accepts anything)
- Filter out `false` (rejects everything, so no point including it)
Returns None if the schema is a standalone `false` boolean.
"""
if isinstance(schema, bool):
if schema:
# true -> empty schema (accepts any value)
return {}
else:
# false -> reject all, return None to filter out
return None
if isinstance(schema, list):
# Filter list items (e.g., for anyOf arrays)
filtered = []
for item in schema:
result = _filter_boolean_schemas(item)
if result is not None:
filtered.append(result)
return filtered if filtered else None
if not isinstance(schema, dict):
return schema
# Make a copy to avoid mutating the original
result = {}
for key, value in schema.items():
if key in ("anyOf", "allOf", "oneOf"):
# Filter boolean schemas from combiner arrays
filtered_value = _filter_boolean_schemas(value)
if filtered_value is None or (
isinstance(filtered_value, list) and len(filtered_value) == 0
):
# All schemas were false, skip this combiner
continue
result[key] = filtered_value
elif key == "items" and isinstance(value, (dict, bool)):
# Handle array items schema
filtered_items = _filter_boolean_schemas(value)
if filtered_items is not None:
result[key] = filtered_items
elif key == "properties" and isinstance(value, dict):
# Recursively filter property schemas
filtered_props = {}
for prop_name, prop_schema in value.items():
filtered_prop = _filter_boolean_schemas(prop_schema)
if filtered_prop is not None:
filtered_props[prop_name] = filtered_prop
result[key] = filtered_props
elif key in ("$defs", "definitions") and isinstance(value, dict):
# Recursively filter definitions
filtered_defs = {}
for def_name, def_schema in value.items():
filtered_def = _filter_boolean_schemas(def_schema)
if filtered_def is not None:
filtered_defs[def_name] = filtered_def
result[key] = filtered_defs
else:
result[key] = value
return result
def json_schema_to_pydantic_type(
json_schema: t.Union[t.Dict[str, t.Any], bool],
) -> t.Union[t.Type, t.Optional[t.Any]]:
"""
Converts a JSON schema type to a Pydantic type.
Uses json-schema-to-pydantic for complex schemas (anyOf, allOf, oneOf),
falls back to simple type mapping for primitive types.
:param json_schema: The JSON schema to convert (can be dict or boolean).
:return: A Pydantic type.
"""
# Handle boolean schemas (JSON Schema draft-06+)
if isinstance(json_schema, bool):
if json_schema:
return t.Any # true schema accepts any value
else:
return None # false schema - will be filtered out in union processing
# Pre-filter boolean schemas from combiners
filtered_schema = _filter_boolean_schemas(json_schema)
if filtered_schema is None:
return str # Fallback if all schemas were false
# Handle simple primitive types without complex combiners
if _is_simple_primitive(filtered_schema):
return _convert_simple_type(filtered_schema)
# Use library for complex schemas (anyOf, allOf, oneOf, nested objects)
return _convert_with_library(filtered_schema)
def _is_simple_primitive(schema: t.Dict[str, t.Any]) -> bool:
"""Check if schema is a simple primitive without combiners."""
has_combiners = any(k in schema for k in ("anyOf", "allOf", "oneOf"))
has_properties = "properties" in schema
schema_type = schema.get("type")
return (
not has_combiners
and not has_properties
and schema_type in PYDANTIC_TYPE_TO_PYTHON_TYPE
and schema_type not in CONTAINER_TYPE
)
def _convert_simple_type(schema: t.Dict[str, t.Any]) -> t.Type[t.Any]:
"""Convert simple primitive types directly."""
type_ = schema.get("type", "string")
return t.cast(t.Type[t.Any], PYDANTIC_TYPE_TO_PYTHON_TYPE.get(type_, str))
def _convert_with_library(
schema: t.Dict[str, t.Any],
) -> t.Union[t.Type, t.Any]:
"""Use json-schema-to-pydantic for complex schema conversion."""
try:
# Handle top-level combiner without type (e.g., {"anyOf": [...]})
if (
any(k in schema for k in ("anyOf", "allOf", "oneOf"))
and "type" not in schema
):
return _handle_toplevel_combiner(schema)
# For object schemas, create model directly
if schema.get("type") == "object":
if "title" not in schema:
schema = {**schema, "title": "GeneratedModel"}
return create_model_from_schema(
schema,
allow_undefined_array_items=True,
allow_undefined_type=True,
)
# For array schemas
if schema.get("type") == "array":
items = schema.get("items")
if items:
item_type = json_schema_to_pydantic_type(items)
return t.List[t.cast(t.Type, item_type)] # type: ignore
return t.List
# Fallback to simple type
return _convert_simple_type(schema)
except (SchemaError, CombinerError) as e:
logger.debug(f"Library schema conversion failed: {e}, falling back to string")
return str
except Exception as e:
logger.debug(
f"Unexpected error in schema conversion: {e}, falling back to string"
)
return str
def _handle_toplevel_combiner(
schema: t.Dict[str, t.Any],
) -> t.Union[t.Type, t.Any]:
"""
Handle top-level combiner schemas (anyOf, allOf, oneOf without "type").
The library can handle these directly - it returns the appropriate type.
"""
try:
# Try direct conversion - library handles anyOf/oneOf/allOf at top level
result = create_model_from_schema(
schema,
allow_undefined_array_items=True,
allow_undefined_type=True,
)
if result is type(None):
return t.Optional[t.Any]
# If result is a type (like a Union or Optional), return it directly
# If result is a model class, return it
return result
except (SchemaError, CombinerError):
pass
except Exception:
pass
# Fallback: manually build union type for anyOf/oneOf
if "anyOf" in schema or "oneOf" in schema:
options = schema.get("anyOf", schema.get("oneOf", []))
return _build_union_from_options(options)
# Fallback: use first option for allOf
if "allOf" in schema and schema["allOf"]:
return json_schema_to_pydantic_type(schema["allOf"][0])
return t.Any
def _build_union_from_options(options: t.List[t.Dict[str, t.Any]]) -> t.Type:
"""Build a Union type from a list of schema options."""
pydantic_types = []
null_type = PYDANTIC_TYPE_TO_PYTHON_TYPE.get("null")
has_null = False
for option in options:
ptype = json_schema_to_pydantic_type(option)
if ptype is None:
continue
if ptype == null_type or ptype is type(None):
has_null = True
continue
pydantic_types.append(ptype)
if len(pydantic_types) == 0:
return t.Optional[t.Any] if has_null else str # type: ignore
if len(pydantic_types) == 1:
base_type = pydantic_types[0]
if has_null:
return t.Optional[t.cast(t.Type, base_type)] # type: ignore
return base_type
# Build union
cast_types = [t.cast(t.Type, ptype) for ptype in pydantic_types]
union_type = reduce(lambda a, b: t.Union[a, b], cast_types) # type: ignore
if has_null:
return t.Optional[union_type] # type: ignore
return union_type
@@ -0,0 +1,93 @@
"""Block accidental upload of local files from well-known secret/credential locations."""
from __future__ import annotations
import re
import typing as t
from pathlib import Path
# Path components that indicate a sensitive directory anywhere in a resolved local path.
BUILTIN_FILE_UPLOAD_PATH_DENY_SEGMENTS: t.Tuple[str, ...] = (
".ssh",
".aws",
".azure",
".gnupg",
".kube",
".docker",
".claude", # may contain API keys and project context read by assistants
".password-store",
"keychains",
)
_SECRET_LIKE_BASENAME = re.compile(r"^(\.env(\.|$)|\.netrc$|\.pgpass$)", re.IGNORECASE)
_DEFAULT_PRIVATE_KEY_BASENAME = re.compile(
r"^id_(rsa|ed25519|ecdsa|dsa|ecdsa_sk)(\.old)?$", re.IGNORECASE
)
def _normalize_path_segments(file_path: t.Union[str, Path]) -> t.List[str]:
p = Path(file_path).expanduser()
try:
resolved = p.resolve()
except OSError:
resolved = p
parts = [part for part in resolved.as_posix().split("/") if part]
if parts and parts[0].endswith(":"):
# Windows path: "C:/Users/..." -> drop drive letter segment
parts = parts[1:]
return parts
def _get_block_reason(
file_path: t.Union[str, Path],
additional_deny_segments: t.Optional[t.Sequence[str]] = None,
) -> t.Optional[str]:
extra = [s.strip() for s in (additional_deny_segments or []) if s and s.strip()]
deny: t.Set[str] = {s.lower() for s in BUILTIN_FILE_UPLOAD_PATH_DENY_SEGMENTS}
deny.update(s.lower() for s in extra)
segments = _normalize_path_segments(file_path)
# Case-insensitive segment match: lower each path component once per check.
for seg, key in zip(segments, (s.lower() for s in segments), strict=True):
if key in deny:
return f'path segment "{seg}" is in the sensitive file upload denylist'
if not segments:
return None
basename = segments[-1]
if _SECRET_LIKE_BASENAME.search(basename) or _DEFAULT_PRIVATE_KEY_BASENAME.match(
basename
):
return (
f'file name "{basename}" looks like a credential, env, or private key file'
)
if basename.lower() == "credentials":
return 'file name "credentials" is often used for cloud/API credential stores'
return None
def is_blocked_sensitive_file_upload_path(
file_path: t.Union[str, Path],
additional_deny_segments: t.Optional[t.Sequence[str]] = None,
) -> bool:
return _get_block_reason(file_path, additional_deny_segments) is not None
def assert_safe_local_file_upload_path(
file_path: t.Union[str, Path],
*,
enabled: bool = True,
additional_deny_segments: t.Optional[t.Sequence[str]] = None,
) -> None:
"""Raise SensitiveFilePathBlockedError if *file_path* matches the denylist."""
if not enabled:
return
reason = _get_block_reason(file_path, additional_deny_segments)
if reason:
from composio.exceptions import SensitiveFilePathBlockedError
raise SensitiveFilePathBlockedError(
f"Refusing to upload: {reason}. "
"Set sensitive_file_upload_protection=False on Composio if you must "
"(not recommended), or use a copy outside sensitive locations."
)
+672
View File
@@ -0,0 +1,672 @@
"""
Shared utils.
"""
import copy
import dataclasses
import hashlib
import json
import keyword
import typing as t
import uuid
from functools import reduce
from inspect import Parameter
from pydantic import BaseModel, Field, create_model
from pydantic.fields import FieldInfo
from composio.exceptions import InvalidParams, InvalidSchemaError
from composio.utils.json_schema import dereference_json_schema
from composio.utils.logging import get as get_logger
from composio.utils.schema_converter import (
CONTAINER_TYPE,
FALLBACK_VALUES,
PYDANTIC_TYPE_TO_PYTHON_TYPE,
json_schema_to_pydantic_type,
)
logger = get_logger(__name__)
# Re-export for backward compatibility
__all__ = [
"json_schema_to_pydantic_type",
"PYDANTIC_TYPE_TO_PYTHON_TYPE",
"CONTAINER_TYPE",
"FALLBACK_VALUES",
"json_schema_to_pydantic_field",
"json_schema_to_fields_dict",
"json_schema_to_model",
"pydantic_model_from_param_schema",
"get_signature_format_from_schema_params",
"get_pydantic_signature_format_from_schema_params",
"generate_request_id",
"ToolSchemaAliases",
"alias_tool_input_schema",
"restore_tool_arguments",
"substitute_reserved_python_keywords",
"reinstate_reserved_python_keywords",
"normalize_tool_arguments",
]
reserved_names = ["validate"]
_OBJ_MARKER = "-_object_-"
_ARR_MARKER = "-_array_-"
_MAX_PROVIDER_ALIAS_LENGTH = 64
def normalize_tool_arguments(arguments: t.Any) -> t.Dict[str, t.Any]:
"""Coerce model-supplied tool arguments into a dict.
Models (and some MCP transports) occasionally emit tool-call arguments as a
JSON string instead of a dict, which breaks execution with errors such as
``tool_use.input: Input should be a valid dictionary``. This is the single
coercion every provider routes through so behaviour is identical everywhere.
See https://github.com/ComposioHQ/composio/issues/2406.
- ``None`` becomes ``{}`` (some models send no arguments for no-arg tools).
- A dict is returned unchanged.
- A string is JSON-parsed; an empty / whitespace-only string becomes ``{}``.
- Anything that does not resolve to a dict (lists, primitives, unparseable
strings, JSON that parses to a non-object) raises :class:`InvalidParams`.
:param arguments: Raw arguments as received from the model / framework.
:return: The normalized arguments as a dict.
:raises InvalidParams: If the arguments cannot be resolved to a dict.
"""
if arguments is None:
return {}
if isinstance(arguments, str):
stripped = arguments.strip()
if not stripped:
return {}
try:
parsed = json.loads(stripped)
except json.JSONDecodeError as e:
raise InvalidParams(
f"Tool arguments were provided as a string that is not valid JSON: {e}"
) from e
return _as_dict(parsed)
return _as_dict(arguments)
def _as_dict(value: t.Any) -> t.Dict[str, t.Any]:
if isinstance(value, dict):
return value
raise InvalidParams(
f"Tool arguments must resolve to an object, received {type(value).__name__}"
)
def _make_safe_name(name: str) -> str:
"""Append ``_rs`` to a Python keyword so it can be used as a parameter name."""
return f"{name}_rs"
def _make_python_identifier(name: str) -> str:
if keyword.iskeyword(name):
return _make_safe_name(name)
safe = "".join(
char if (char.isascii() and (char.isalnum() or char == "_")) else "_"
for char in name
)
if not safe:
safe = "param"
if safe[0].isdigit() or safe[0] == "_":
safe = f"param_{safe.lstrip('_') or 'value'}"
if keyword.iskeyword(safe):
safe = _make_safe_name(safe)
if len(safe) > _MAX_PROVIDER_ALIAS_LENGTH:
hash_suffix = hashlib.sha256(name.encode()).hexdigest()[:8]
safe = (
f"{safe[: _MAX_PROVIDER_ALIAS_LENGTH - len(hash_suffix) - 1]}_{hash_suffix}"
)
return safe
@dataclasses.dataclass(frozen=True)
class ToolSchemaAliases:
"""Provider-visible schema plus mapping back to backend argument names."""
schema: t.Dict[str, t.Any]
aliases: t.Dict[str, t.Any]
def restore_arguments(self, arguments: dict) -> dict:
return restore_tool_arguments(arguments, self.aliases)
def alias_tool_input_schema(schema: t.Dict) -> ToolSchemaAliases:
"""Alias tool input schema keys so they are valid Python parameter names.
Returns a :class:`ToolSchemaAliases` object containing a deep-copied schema
for provider/framework exposure and a reverse alias map for restoring model
arguments before calling the backend executor.
Python keywords keep the historical ``_rs`` suffix (``from`` becomes
``from_rs``). Other names that cannot be used as Python identifiers are
converted to Pydantic-safe identifiers and long aliases are capped at 64
characters so the same provider-facing schema is accepted by
Anthropic-style tool schema validators. Internal JSON Schema references are
inlined before aliasing so referenced object properties are exposed through
the same safe names. If two properties would expose the same alias, an
:class:`InvalidSchemaError` is raised instead of guessing.
"""
aliased_schema = t.cast(
t.Dict[str, t.Any],
dereference_json_schema(
copy.deepcopy(schema),
on_unresolved="sentinel",
),
)
schema_params, aliases = _alias_schema_properties(aliased_schema)
return ToolSchemaAliases(schema=schema_params, aliases=aliases)
def _alias_schema_properties(schema: t.Dict[str, t.Any]) -> t.Tuple[dict, dict]:
properties = schema.get("properties")
if not isinstance(properties, dict):
return schema, {}
aliases: t.Dict[str, t.Any] = {}
aliased_properties: t.Dict[str, t.Any] = {}
for original_name, property_schema in properties.items():
safe_name = _make_python_identifier(original_name)
if safe_name in aliased_properties:
raise InvalidSchemaError(
"Tool input schema property names produce a duplicate Python "
f"parameter alias {safe_name!r}"
)
nested_object_aliases: t.Dict[str, t.Any] = {}
nested_array_aliases: t.Dict[str, t.Any] = {}
if isinstance(property_schema, dict):
property_schema, nested_object_aliases = _alias_nested_object_schema(
property_schema
)
property_schema, nested_array_aliases = _alias_nested_array_schema(
property_schema
)
aliased_properties[safe_name] = property_schema
if safe_name != original_name or nested_object_aliases or nested_array_aliases:
aliases[safe_name] = original_name
if nested_object_aliases:
aliases[f"{safe_name}{_OBJ_MARKER}"] = nested_object_aliases
if nested_array_aliases:
aliases[f"{safe_name}{_ARR_MARKER}"] = nested_array_aliases
schema["properties"] = aliased_properties
if aliases and "required" in schema:
reverse = {
original: safe
for safe, original in aliases.items()
if not safe.endswith(_OBJ_MARKER) and not safe.endswith(_ARR_MARKER)
}
schema["required"] = [reverse.get(r, r) for r in schema["required"]]
return schema, aliases
def _alias_nested_object_schema(
property_schema: t.Dict[str, t.Any],
) -> t.Tuple[dict, dict]:
if not isinstance(property_schema.get("properties"), dict):
return property_schema, {}
return _alias_schema_properties(property_schema)
def _alias_nested_array_schema(
property_schema: t.Dict[str, t.Any],
) -> t.Tuple[dict, dict]:
items_schema = property_schema.get("items")
if not isinstance(items_schema, dict):
return property_schema, {}
aliased_items, aliases = _alias_schema_properties(items_schema)
if aliases:
property_schema["items"] = aliased_items
return property_schema, aliases
def restore_tool_arguments(request: dict, aliases: dict) -> dict:
"""Restore provider-visible argument aliases back to backend schema names.
Modifies *request* in-place and returns it.
"""
alias_keys = [
key
for key in aliases
if not key.endswith(_OBJ_MARKER) and not key.endswith(_ARR_MARKER)
]
for clean_key in sorted(alias_keys, reverse=True):
if clean_key not in request:
continue
original_value = request.pop(clean_key)
object_aliases = aliases.get(f"{clean_key}{_OBJ_MARKER}", {})
array_aliases = aliases.get(f"{clean_key}{_ARR_MARKER}", {})
if object_aliases and isinstance(original_value, dict):
original_value = restore_tool_arguments(
request=original_value,
aliases=object_aliases,
)
if array_aliases and isinstance(original_value, list):
original_value = [
restore_tool_arguments(item, array_aliases)
if isinstance(item, dict)
else item
for item in original_value
]
request[aliases.get(clean_key, clean_key)] = original_value
return request
def substitute_reserved_python_keywords(
schema: t.Dict,
) -> t.Tuple[dict, dict]:
"""Replace unsafe JSON schema property names with Python parameter aliases.
Backward-compatible wrapper around :func:`alias_tool_input_schema`.
"""
aliased = alias_tool_input_schema(schema=schema)
return aliased.schema, aliased.aliases
def reinstate_reserved_python_keywords(
request: dict,
keywords: dict,
) -> dict:
"""Reverse the substitution performed by :func:`substitute_reserved_python_keywords`.
Modifies *request* **in-place** and returns it.
"""
return restore_tool_arguments(request=request, aliases=keywords)
def _coerce_default_value(
default: t.Any,
json_schema: t.Dict[str, t.Any],
) -> t.Any:
"""
Coerce a default value to match the expected type from JSON schema.
Handles common mismatches where string defaults should be boolean/int/float.
This fixes issues where API returns stringified defaults like "true" instead of true.
Coercion precedence: boolean > integer > float. This means values like "1" and "0"
become booleans when both bool and int are expected types.
:param default: The default value from the JSON schema.
:param json_schema: The JSON schema property definition.
:return: The coerced default value, or original if no coercion possible.
"""
if default is None or not isinstance(default, str):
return default
# Collect expected types from schema
expected_types: t.Set[t.Any] = set()
if "type" in json_schema:
py_type = PYDANTIC_TYPE_TO_PYTHON_TYPE.get(json_schema["type"])
if py_type is not None:
expected_types.add(py_type)
for combiner in ("anyOf", "oneOf", "allOf"):
for option in json_schema.get(combiner, []):
if isinstance(option, dict):
option_type = option.get("type")
if isinstance(option_type, str):
py_type = PYDANTIC_TYPE_TO_PYTHON_TYPE.get(option_type)
if py_type is not None:
expected_types.add(py_type)
# If string is expected, no coercion needed
if str in expected_types:
return default
# Boolean coercion (takes precedence over int for "1"/"0")
if bool in expected_types:
lower_default = default.lower()
if lower_default in ("true", "yes", "1"):
return True
if lower_default in ("false", "no", "0"):
return False
# Integer coercion
if int in expected_types:
try:
return int(default)
except ValueError:
pass
# Float coercion
if float in expected_types:
try:
return float(default)
except ValueError:
pass
return default
def json_schema_to_pydantic_field(
name: str,
json_schema: t.Dict[str, t.Any],
required: t.List[str],
skip_default: bool = False,
) -> t.Tuple[str, t.Type, FieldInfo]:
"""
Converts a JSON schema property to a Pydantic field definition.
:param name: The field name.
:param json_schema: The JSON schema property.
:param required: List of required properties.
:return: A Pydantic field definition.
"""
description = json_schema.get("description")
if "oneOf" in json_schema:
description = " | ".join(
[option.get("description", "") for option in json_schema["oneOf"]]
)
description = f"Any of the following options(separated by |): {description}"
examples = json_schema.get("examples", [])
default = json_schema.get("default")
# Coerce default value to match expected type from schema
if default is not None:
default = _coerce_default_value(default, json_schema)
# Check if the field name is a reserved Pydantic name
original_name = name
if name in reserved_names:
name = f"{name}_"
alias = original_name
else:
alias = None
field = {
"description": description,
"examples": examples,
"alias": alias,
}
if not skip_default:
field["default"] = ... if original_name in required else default
return (
name,
t.cast(
t.Type,
json_schema_to_pydantic_type(
json_schema=json_schema,
),
),
Field(**field), # type: ignore
)
def json_schema_to_fields_dict(json_schema: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]:
"""
Converts a JSON schema to a dictionary of param name, and a tuple of type & Field.
:param json_schema: The JSON schema to convert.
:return: dict<str, tuple<<class 'type'>, Field>>
Example Output:
```python
{
'owner': (<class 'str'>, FieldInfo(default=Ellipsis, description='The account owner of the repository.', extra={'examples': ([],)})),
'repo': (<class 'str'>, FieldInfo(default=Ellipsis, description='The name of the repository without the `.git` extension.', extra={'examples': ([],)}))}
}
```
"""
field_definitions = {}
for name, prop in json_schema.get("properties", {}).items():
updated_name, pydantic_type, pydantic_field = json_schema_to_pydantic_field(
name, prop, json_schema.get("required", [])
)
field_definitions[updated_name] = (pydantic_type, pydantic_field)
return field_definitions # type: ignore
def json_schema_to_model(
json_schema: t.Dict[str, t.Any],
skip_default: bool = False,
) -> t.Type[BaseModel]:
"""
Converts a JSON schema to a Pydantic BaseModel class.
:param json_schema: The JSON schema to convert.
:param skip_default: Skip the default values when building field object
:return: Pydantic `BaseModel` type
"""
model_name = json_schema.get("title")
field_definitions = {}
for name, prop in json_schema.get("properties", {}).items():
updated_name, pydantic_type, pydantic_field = json_schema_to_pydantic_field(
name,
prop,
json_schema.get("required", []),
skip_default=skip_default,
)
field_definitions[updated_name] = (pydantic_type, pydantic_field)
return create_model(model_name, **field_definitions) # type: ignore
def pydantic_model_from_param_schema(param_schema: t.Dict) -> t.Type:
"""
Dynamically creates a Pydantic model from a schema dictionary.
:param param_schema: Schema with 'title', 'properties', and optionally 'required' keys.
:return: A Pydantic model class for the defined schema.
:raised ValueError: Invalid 'type' for property or recursive model creation.
Note: Requires global `schema_type_python_type_dict` for type mapping and
`fallback_values` for default values.
"""
required_fields = {}
optional_fields = {}
if "title" not in param_schema:
raise ValueError(f"Missing 'title' in param_schema: {param_schema}")
param_title = str(param_schema["title"]).replace(" ", "")
required_props = param_schema.get("required", [])
if param_schema.get("type") == "array":
# print("param_schema inside array - ", param_schema)
item_schema = param_schema.get("items")
if item_schema:
ItemType = t.cast(
t.Type,
json_schema_to_pydantic_type(
json_schema=item_schema,
),
)
return t.List[ItemType] # type: ignore
return t.List
for prop_name, prop_info in param_schema.get("properties", {}).items():
prop_type = prop_info.get("type")
prop_title = prop_info.get("title", prop_name).replace(" ", "")
prop_default = prop_info.get("default", FALLBACK_VALUES.get(prop_type))
if (
prop_type is not None
and prop_type in PYDANTIC_TYPE_TO_PYTHON_TYPE
and prop_type not in CONTAINER_TYPE
):
signature_prop_type = PYDANTIC_TYPE_TO_PYTHON_TYPE[prop_type]
elif prop_type is None:
# Schema uses anyOf/allOf/oneOf/$ref instead of a top-level "type" key.
# Delegate to json_schema_to_pydantic_type which handles all combiners.
signature_prop_type = t.cast(
t.Type,
json_schema_to_pydantic_type(json_schema=prop_info),
)
else:
signature_prop_type = pydantic_model_from_param_schema(prop_info)
field_kwargs = {
"description": prop_info.get(
"description", prop_info.get("desc", prop_title)
),
}
# Add alias if the field name is a reserved Pydantic name
if prop_name in reserved_names:
field_kwargs["alias"] = prop_name
field_kwargs["title"] = f"{prop_name}_"
else:
field_kwargs["title"] = prop_title
if prop_name in required_props:
required_fields[prop_name] = (
signature_prop_type,
Field(..., **field_kwargs),
)
else:
optional_fields[prop_name] = (
signature_prop_type,
Field(default=prop_default, **field_kwargs),
)
if not required_fields and not optional_fields:
return t.Dict
return create_model( # type: ignore
param_title,
**required_fields,
**optional_fields,
)
def get_signature_format_from_schema_params(
schema_params: t.Dict,
skip_default: bool = False,
) -> t.List[Parameter]:
"""
Get function parameters signature(with pydantic field definition as default values)
from schema parameters. Works like:
def demo_function(
owner: str,
repo: str),
)
:param schema_params: A dictionary object containing schema params, with keys [properties, required etc.].
:return: List of required and optional parameters
Output Format:
[
<Parameter "owner: str">,
<Parameter "repo: str">
]
"""
default_parameters = []
none_default_parameters = []
required_params = schema_params.get("required", [])
schema_params_object = schema_params.get("properties", {})
for param_name, param_schema in schema_params_object.items():
param_type = param_schema.get("type", None)
param_oneOf = param_schema.get("oneOf", None)
param_anyOf = param_schema.get("anyOf", None)
param_allOf = param_schema.get("allOf", None)
if param_allOf is not None and len(param_allOf) == 1:
param_type = param_allOf[0].get("type", None)
if param_oneOf is not None or param_anyOf is not None:
param_types = [ptype.get("type") for ptype in (param_oneOf or param_anyOf)]
# Map each option to a Python type, falling back to t.Any for options
# that are missing a "type" key or use an unrecognized type, then build
# a Union for any count of members (no 1/2/3-member cap).
mapped_types: t.List[t.Any] = [
PYDANTIC_TYPE_TO_PYTHON_TYPE.get(ptype, t.Any) for ptype in param_types
]
if len(mapped_types) == 1:
annotation = mapped_types[0]
else:
annotation = reduce(lambda a, b: t.Union[a, b], mapped_types)
param_default = param_schema.get("default", "")
elif param_type in PYDANTIC_TYPE_TO_PYTHON_TYPE:
annotation = PYDANTIC_TYPE_TO_PYTHON_TYPE[param_type]
param_default = param_schema.get("default", FALLBACK_VALUES[param_type])
else:
annotation = pydantic_model_from_param_schema(param_schema)
if param_type is None or param_type == "null":
param_default = None
else:
param_default = param_schema.get("default", FALLBACK_VALUES[param_type])
default = param_default
required = param_schema.get("required", False) or param_name in required_params
if required:
default = Parameter.empty
if skip_default:
default = Parameter.empty
parameter = Parameter(
name=param_name,
kind=Parameter.POSITIONAL_OR_KEYWORD,
annotation=annotation,
default=default,
)
if required:
default_parameters.append(parameter)
continue
none_default_parameters.append(parameter)
return default_parameters + none_default_parameters
def get_pydantic_signature_format_from_schema_params(
schema_params: t.Dict,
skip_default: bool = False,
) -> t.List[Parameter]:
"""
Get function parameters signature(with pydantic field definition as default values)
from schema parameters. Works like:
def demo_function(
owner: str=Field(..., description='The account owner of the repository.'),
repo: str=Field(..., description='The name of the repository without the `.git` extension.'),
)
:param schema_params: A dictionary object containing schema params, with keys [properties, required etc.].
:return: List of required and optional parameters
Example Output Format:
```python
[
<Parameter "owner: str = FieldInfo(
default=Ellipsis,
description='The account owner of the repository.',
extra={'examples': ([],)})">,
<Parameter "repo: str = FieldInfo(
default=Ellipsis,
description='The name of the repository without the `.git` extension.',
extra={'examples': ([],)})">
]
```
"""
all_parameters = []
field_definitions = json_schema_to_fields_dict(schema_params)
for param_name, (param_dtype, parame_field) in field_definitions.items():
param = Parameter(
name=param_name,
kind=Parameter.POSITIONAL_OR_KEYWORD,
annotation=param_dtype,
default=Parameter.empty if skip_default else parame_field.default,
)
all_parameters.append(param)
return all_parameters
def generate_request_id() -> str:
"""Generate a unique request ID."""
return str(uuid.uuid4())
+99
View File
@@ -0,0 +1,99 @@
"""
Utilities for handling toolkit versions.
"""
import os
import typing as t
from composio.core.types import ToolkitVersion, ToolkitVersionParam, ToolkitVersions
def normalize_toolkit_slug(toolkit_slug: str) -> str:
"""
Canonicalizes a toolkit slug into the form used as a version-map key.
Toolkit slugs are matched case-insensitively. This is the single source of
truth for that rule: every write into a version map (env vars, user-supplied
dicts) and every read out of one MUST go through this helper so the two sides
can never drift apart and silently miss a configured pin.
Kept intentionally equivalent to the TypeScript SDK's ``normalizeToolkitSlug``
(see ts/packages/core/src/utils/toolkitVersion.ts).
:param toolkit_slug: The slug/name of the toolkit, in any casing
:return: The normalized (lowercase) slug used as a version-map key
"""
return toolkit_slug.lower()
def get_toolkit_version(
toolkit_slug: str, toolkit_versions: t.Optional[ToolkitVersionParam] = None
) -> ToolkitVersion:
"""
Gets the version for a specific toolkit based on the provided toolkit versions configuration.
:param toolkit_slug: The slug/name of the toolkit to get the version for
:param toolkit_versions: Optional toolkit versions configuration (string for global version
or dict mapping toolkit slugs to versions)
:return: The toolkit version to use - either the specific version from config, or 'latest' as fallback
"""
# If toolkit_versions is a string, use it as a global version for all toolkits
if isinstance(toolkit_versions, str):
return toolkit_versions
# If toolkit_versions is a dict mapping, look up the specific toolkit version.
# The map is keyed by normalized slugs, so normalize the lookup too
# (see normalize_toolkit_slug for why).
if isinstance(toolkit_versions, dict) and len(toolkit_versions) > 0:
return toolkit_versions.get(normalize_toolkit_slug(toolkit_slug), "latest")
# Else use 'latest'
return "latest"
def get_toolkit_versions(
default_versions: t.Optional[ToolkitVersionParam] = None,
) -> ToolkitVersionParam:
"""
Gets toolkit versions configuration by merging environment variables, user-provided defaults, and fallbacks.
Priority order:
1. If default_versions is a string, use it as a global version for all toolkits
2. User-provided toolkit version mappings (default_versions dict)
3. Environment variables (COMPOSIO_TOOLKIT_VERSION_<TOOLKIT_NAME>)
4. Fallback to 'latest' if no versions are configured
:param default_versions: Optional default versions configuration (string for global version or dict mapping toolkit names to versions)
:return: Toolkit versions configuration - either a string for global version or dict mapping toolkit names to versions
"""
# If already set by user as a string, use it as global version for all toolkits
if isinstance(default_versions, str):
return default_versions
# Check if there are envs similar to COMPOSIO_TOOLKIT_VERSION_GITHUB then extract the toolkit name
toolkit_versions_from_env: ToolkitVersions = {}
for key, value in os.environ.items():
if key.startswith("COMPOSIO_TOOLKIT_VERSION_"):
toolkit_name = key.replace("COMPOSIO_TOOLKIT_VERSION_", "")
toolkit_versions_from_env[normalize_toolkit_slug(toolkit_name)] = value
# Normalize keys via normalize_toolkit_slug (the same helper the lookup uses);
# user-provided values override env.
user_provided_toolkit_versions: ToolkitVersions = {}
if default_versions and isinstance(default_versions, dict):
user_provided_toolkit_versions = {
normalize_toolkit_slug(key): value
for key, value in default_versions.items()
}
# Final toolkit versions
toolkit_versions = {
**toolkit_versions_from_env,
**user_provided_toolkit_versions,
}
# If the toolkit_versions are empty, use 'latest'
if len(toolkit_versions) == 0:
return "latest"
return toolkit_versions
@@ -0,0 +1,239 @@
"""Allowlist enforcement for automatic file upload during tool execution.
This is only consulted when
``dangerously_allow_auto_upload_download_files=True``. Manual upload APIs are
not subject to the allowlist (parity with the TypeScript SDK).
- A local path is accepted iff its symlink-resolved absolute path is inside one
of the allowed directories on a path-component boundary. ``/tmp/foo`` allows
``/tmp/foo/bar`` but NOT ``/tmp/foo-bar``.
- URLs never hit this check.
- User-provided ``file_upload_dirs`` fully REPLACES the default
``[~/.composio/temp]``.
- On Windows, path comparisons are case-insensitive.
"""
from __future__ import annotations
import os
import sys
import typing as t
from pathlib import Path
from composio.exceptions import (
FileUploadPathNotAllowedError,
SDKFileNotFoundError,
)
UPLOAD_TEMP_DIRECTORY_NAME = "temp"
def get_default_upload_dir() -> t.Optional[Path]:
"""Absolute path of the default upload staging directory
(``<home>/.composio/temp``), or None when a home directory cannot be
determined."""
try:
home = Path.home()
except (RuntimeError, OSError):
return None
return (home / ".composio" / UPLOAD_TEMP_DIRECTORY_NAME).resolve()
def ensure_dir_exists(p: Path) -> None:
"""Best-effort directory creation. Allowlist enforcement still works if
creation fails (e.g. read-only filesystem)."""
try:
p.mkdir(parents=True, exist_ok=True)
except OSError:
pass
FileUploadDirs = t.Union[t.Sequence[str], t.Literal[False], None]
"""User-facing type for ``file_upload_dirs``.
- ``None`` (default) -> use ``[<home>/.composio/temp]``.
- ``False`` -> reject every local path during auto-upload (URLs / bytes still
work).
- ``[]`` -> same as ``False`` (kept as an alias; prefer ``False``).
- ``Sequence[str]`` (non-empty) -> allowlist replacing the default.
"""
def resolve_effective_upload_allowlist(
user_dirs: FileUploadDirs,
) -> t.List[Path]:
"""Resolve the effective allowlist.
- ``None`` -> ``[<default>]`` when available, else ``[]`` (fail-closed).
- ``False`` -> ``[]`` (explicit "reject all local paths"). URLs and
in-memory bytes still work because they aren't path-checked.
- ``Sequence[str]`` (including ``[]``) REPLACES the default and is returned
verbatim after ``~``-expansion and ``resolve()``. ``[]`` is treated as
equivalent to ``False``.
- Empty / blank / non-string entries are skipped.
- Duplicates are removed (case-insensitive on Windows).
"""
if user_dirs is None:
default = get_default_upload_dir()
return [default] if default is not None else []
if user_dirs is False:
return []
seen: t.Set[str] = set()
out: t.List[Path] = []
for raw in user_dirs:
if not isinstance(raw, (str, os.PathLike)):
continue
s = str(raw).strip()
if not s:
continue
abs_path = Path(s).expanduser().resolve()
key = str(abs_path).lower() if sys.platform == "win32" else str(abs_path)
if key in seen:
continue
seen.add(key)
out.append(abs_path)
return out
def _is_inside_dir(child: Path, parent: Path) -> bool:
"""True iff ``child`` equals ``parent`` or is nested inside on a component
boundary. Assumes both are absolute and normalized."""
try:
child_str = str(child)
parent_str = str(parent)
if sys.platform == "win32":
child_str = child_str.lower()
parent_str = parent_str.lower()
if child_str == parent_str:
return True
sep = os.sep
parent_with_sep = parent_str if parent_str.endswith(sep) else parent_str + sep
return child_str.startswith(parent_with_sep)
except OSError:
return False
def _format_allowlist(dirs: t.Sequence[Path]) -> str:
if not dirs:
return " (none configured — all local paths are blocked)"
return "\n".join(f" - {d}" for d in dirs)
def _build_help_footer(allowlist: t.Sequence[Path]) -> str:
return "\n".join(
[
"",
"Allowed upload directories (file_upload_dirs):",
_format_allowlist(allowlist),
"",
"Fix one of the following:",
"",
" 1. Recommended — move the file under an allowed directory, or add your",
" directory to the allowlist:",
" Composio(",
" file_upload_dirs=['/abs/path/to/uploads', '~/.composio/temp'],",
" )",
" Note: user-provided `file_upload_dirs` REPLACES the default",
" `~/.composio/temp`. Include it explicitly if you want staged uploads",
" to keep working.",
"",
" 2. Pass the file by URL (http://... / https://...) instead of a",
" filesystem path. URLs are never path-checked.",
"",
" 3. (Dangerous) Bypass the allowlist entirely by opting into automatic",
" file upload/download from any readable path:",
" Composio(dangerously_allow_auto_upload_download_files=True, ...)",
" WARNING: This lets a prompt-injected or misbehaving tool read ANY",
" file the process can access. Only enable it in trusted, sandboxed",
" environments. The built-in sensitive-path denylist (.ssh, .aws,",
" .env, private SSH keys, etc.) still applies unless you also set",
" `sensitive_file_upload_protection=False`.",
]
)
def assert_path_inside_upload_dirs(
file_path: t.Union[str, Path],
allowlist: t.Sequence[Path],
) -> None:
"""Raise an elaborate error if the path is missing or outside the allowlist.
:raises SDKFileNotFoundError: when ``file_path`` does not exist on disk.
:raises FileUploadPathNotAllowedError: when resolved path is outside every
entry of ``allowlist`` (including when allowlist is empty).
"""
attempted = str(file_path)
abs_path = Path(attempted).expanduser()
try:
abs_path = abs_path.resolve(strict=False)
except OSError:
pass
if not abs_path.exists():
cwd = Path.cwd()
parent = abs_path.parent
parent_exists = parent.exists()
raise SDKFileNotFoundError(
"\n".join(
[
f'Refusing to auto-upload "{attempted}": the file does not exist on disk.',
"",
f"Path attempted: {attempted}",
f"Resolved to: {abs_path}",
f"Process cwd: {cwd}",
f"Parent exists: {'yes (' + str(parent) + ')' if parent_exists else 'no (' + str(parent) + ')'}",
"",
"Common causes:",
" - Typo in the filename passed to the tool.",
" - Relative path resolved against the wrong working directory",
" (relative paths use os.getcwd() at the moment of upload).",
" - File was deleted between the tool being called and the upload starting.",
"",
_build_help_footer(allowlist),
]
)
)
try:
real_path = abs_path.resolve(strict=True)
except OSError:
real_path = abs_path
if not allowlist:
raise FileUploadPathNotAllowedError(
"\n".join(
[
f'Refusing to auto-upload "{attempted}": no upload directories are configured.',
"",
f"Path attempted: {attempted}",
f"Resolved to: {real_path}",
"",
"Automatic file upload during tool execution is locked down by default",
"to prevent a prompt-injected tool from exfiltrating server files",
"(source code, .env, SSH keys, etc.).",
_build_help_footer(allowlist),
]
)
)
for dir_entry in allowlist:
try:
real_dir = Path(dir_entry).expanduser().resolve(strict=False)
except OSError:
real_dir = Path(dir_entry)
if _is_inside_dir(real_path, real_dir):
return
raise FileUploadPathNotAllowedError(
"\n".join(
[
f'Refusing to auto-upload "{attempted}": resolved path is not inside any',
"directory in the configured `file_upload_dirs` allowlist.",
"",
f"Path attempted: {attempted}",
f"Resolved to: {real_path}",
_build_help_footer(allowlist),
]
)
)
+17
View File
@@ -0,0 +1,17 @@
"""UUID utility functions for Composio SDK."""
import uuid
def generate_uuid() -> str:
"""Generate a random UUID v4 string."""
return str(uuid.uuid4())
def generate_short_id() -> str:
"""
Generate a short ID (8 characters) from a UUID.
Returns the first 8 characters of a UUID with dashes removed.
"""
return generate_uuid()[:8].replace("-", "")
+7
View File
@@ -0,0 +1,7 @@
comment:
layout: "diff, flags, files"
behavior: default
require_changes: false
require_base: false
require_head: true
hide_project_coverage: false
+81
View File
@@ -0,0 +1,81 @@
[mypy]
; Exclude directories and files from type checking
; - External provider type inference tests require those provider packages to be installed
; The core test_type_inference.py and test_type_inference_custom_provider.py can be checked
exclude = ^(apps/composio/|utils/|\.nox/|.*/setup\.py|.*/build/|examples/|.*demo.*|tests/test_type_inference_(?!custom_provider).*)
strict_optional=True
disable_error_code=var-annotated
; Only add packages which does not have type-stub package
[mypy-pysher.*]
ignore_missing_imports=true
[mypy-crewai.*]
ignore_missing_imports=true
[mypy-vertexai.*]
ignore_missing_imports=true
[mypy-google.adk.*]
ignore_missing_imports=true
[mypy-google.genai.*]
ignore_missing_imports=true
[mypy-proto.marshal.collections.maps]
ignore_missing_imports=true
[mypy-autogen.*]
ignore_missing_imports=true
[mypy-autogen_core.*]
ignore_missing_imports=true
[mypy-composio_gemini.*]
ignore_missing_imports=true
[mypy-claude_agent_sdk.*]
ignore_missing_imports=true
[mypy-mcp.*]
ignore_missing_imports=true
[mypy-llama_index.*]
ignore_missing_imports=true
[mypy-agents.*]
ignore_missing_imports=true
; Composio provider packages (may not be installed)
[mypy-composio_anthropic.*]
ignore_missing_imports=true
[mypy-composio_langchain.*]
ignore_missing_imports=true
[mypy-composio_langgraph.*]
ignore_missing_imports=true
[mypy-composio_llamaindex.*]
ignore_missing_imports=true
[mypy-composio_openai_agents.*]
ignore_missing_imports=true
[mypy-composio_crewai.*]
ignore_missing_imports=true
[mypy-composio_autogen.*]
ignore_missing_imports=true
[mypy-composio_google.*]
ignore_missing_imports=true
[mypy-composio_google_adk.*]
ignore_missing_imports=true
[mypy-composio_claude_agent_sdk.*]
ignore_missing_imports=true
[mypy-json_schema_to_pydantic.*]
ignore_missing_imports=true
+8
View File
@@ -0,0 +1,8 @@
[pytest]
addopts = --strict-markers
markers =
core: marks tests that check only the core functionality
openai: marks tests that check the OpenAIProvider
langchain: marks tests that check the LangChainProvider
agno: marks tests that check the AgnoProvider
gemini: marks tests that check the GeminiProvider
+6
View File
@@ -0,0 +1,6 @@
# Same as Black.
line-length = 88
indent-width = 4
[lint]
ignore = [ "E741" ]
+17
View File
@@ -0,0 +1,17 @@
# Vulture allowlist — confirmed false positives.
#
# Vulture (see the `dead_code` nox session) cannot see some usages: symbols
# referenced only through `__all__`, dynamic attribute access, framework entry
# points, or `TYPE_CHECKING`-only re-exports look "unused" to it. List such
# confirmed-intentional names here so they stop showing up in the report.
#
# The idiom is a *bare reference* to the name (a load), one per line — that is
# what marks it "used". Keep a comment explaining why each entry is a false
# positive, and prune entries when the underlying symbol is deleted.
# Re-exported for downstream typing via `__all__` in
# composio/core/models/custom_tool.py (TYPE_CHECKING-only imports, so vulture
# does not connect the __all__ string to the import binding).
SessionAttachResponseExperimental # noqa: B018, F821
SessionCreateResponseExperimental # noqa: B018, F821
SessionRetrieveResponseExperimental # noqa: B018, F821
View File
+23
View File
@@ -0,0 +1,23 @@
# Development Docs
## Setup
Install [pipenv](https://pipenv.pypa.io), then run `make env` to create a new environment.
**NOTE**: You can use `make env` to create a new environment and clean your environment up every time.
## Plugins
The providers are in the `plugins/` folder, and these are namespaced under `composio_PROVIDER`.
## Testing
This project uses `tox` to run tests since the optional plugins could conflict with each other.
You do not need to install this separately if you've installed the dev dependencies by running
`make env`. Here are the commands to test the different providers:
1. `tox -r -e core` - Tests the core package
2. `tox -r -e openai` - Tests the openai plugin
3. `tox -r -e langchain` - Tests the langchain plugin
+12
View File
@@ -0,0 +1,12 @@
# Creating python package release
1. Decide which packages you want to publish
2. Run `python scripts/bump.py`
3. This will prompt you with the different packages and the next version, select next version or skip for given package
4. Create a release PR
5. Merge and publish a github release
**NOTES**
* Since the development is active on `next` branch, only select this branch when creating github release
* While the development is active, only create release-candidate by selecting `pre` when prompted for next version in `bump.py`
+844
View File
@@ -0,0 +1,844 @@
# Tool Router
The Tool Router provides a powerful way to create isolated MCP (Model Context Protocol) sessions for users with scoped access to toolkits and tools. It enables dynamic configuration of which tools are available within a session and manages authentication for multiple toolkits.
## Overview
Tool Router allows you to:
- Create isolated sessions with specific toolkit configurations
- Manage authentication flows for users across multiple toolkits
- Access tools via an MCP-compatible server URL
- Query toolkit connection states
- Integrate with multiple AI frameworks (OpenAI, Anthropic, LangChain, LlamaIndex, CrewAI)
> **Note:** When using MCP clients to connect to Tool Router, you don't need to pass a provider to the Composio constructor. A provider is only required when using `session.tools()` to get framework-specific tool objects.
## Installation
```bash
pip install composio
```
## Quick Start
### Using MCP (Recommended)
When using Tool Router with MCP clients, you don't need to pass a provider:
```python
from composio import Composio
composio = Composio()
# Create a session for a user with access to Gmail tools
session = composio.tool_router.create(
user_id='user_123',
toolkits=['gmail']
)
# Use the MCP URL with any MCP-compatible client
print(session.mcp.url)
```
### Using Framework-Specific Tools
If you want to use `session.tools()` to get tools formatted for a specific AI framework, pass the appropriate provider:
```python
from composio import Composio
from composio_openai import OpenAIProvider
composio = Composio(provider=OpenAIProvider())
# Create a session for a user with access to Gmail tools
session = composio.tool_router.create(
user_id='user_123',
toolkits=['gmail']
)
# Get the tools formatted for OpenAI
tools = session.tools()
```
## Creating Sessions
### Basic Session Creation
Use `composio.tool_router.create()` to create a new Tool Router session:
```python
from composio import Composio
composio = Composio()
# Create a session with Gmail access
session = composio.tool_router.create(
user_id='user_123',
toolkits=['gmail']
)
print(f"Session ID: {session.session_id}")
print(f"MCP URL: {session.mcp.url}")
```
### Using an Existing Session
If you have an existing session ID, you can retrieve it using `composio.tool_router.use()`:
```python
session = composio.tool_router.use('existing_session_id')
# Access session properties
print(session.session_id)
print(session.mcp.url)
```
## Configuration Options
The session creation accepts the following configuration options:
### `toolkits`
Specify which toolkits to enable or disable in the session.
```python
# Simple list of toolkit slugs to enable
session = composio.tool_router.create(
user_id='user_123',
toolkits=['gmail', 'slack', 'github']
)
# Explicit enabled configuration
session = composio.tool_router.create(
user_id='user_123',
toolkits={'enable': ['gmail', 'slack']}
)
# Disable specific toolkits (enable all others)
session = composio.tool_router.create(
user_id='user_123',
toolkits={'disable': ['calendar']}
)
```
### `tools`
Fine-grained control over which tools are available within toolkits. This is a dictionary where keys are toolkit slugs and values specify tool configuration for that toolkit.
```python
session = composio.tool_router.create(
user_id='user_123',
toolkits=['gmail', 'github', 'slack'],
tools={
# List shorthand - enables only these tools
'gmail': ['GMAIL_FETCH_EMAILS', 'GMAIL_SEND_EMAIL'],
# Explicit enable configuration
'github': {'enable': ['GITHUB_CREATE_ISSUE', 'GITHUB_LIST_ISSUES']},
# Explicit disable configuration
'slack': {'disable': ['SLACK_DELETE_MESSAGE']},
# Filter by MCP tags (readOnlyHint, destructiveHint, idempotentHint, openWorldHint)
'linear': {'tags': ['readOnlyHint', 'idempotentHint']}
}
)
```
### `tags`
Global MCP tags to filter tools by across all toolkits. Only tools matching these tags will be available. Toolkit-level tags (specified in `tools`) override this global setting.
```python
session = composio.tool_router.create(
user_id='user_123',
toolkits=['gmail', 'github', 'slack'],
tags=['readOnlyHint', 'idempotentHint'] # Only show read-only and idempotent tools
)
```
Available tag values:
- `'readOnlyHint'`: Tools that only read data
- `'destructiveHint'`: Tools that modify or delete data
- `'idempotentHint'`: Tools that can be safely retried
- `'openWorldHint'`: Tools that operate in an open world context
### `auth_configs`
Map toolkits to specific authentication configurations:
```python
session = composio.tool_router.create(
user_id='user_123',
toolkits=['gmail', 'github'],
auth_configs={
'gmail': 'ac_gmail_work',
'github': 'ac_github_personal'
}
)
```
### `connected_accounts`
Map toolkits to specific connected account IDs:
```python
session = composio.tool_router.create(
user_id='user_123',
toolkits=['gmail'],
connected_accounts={
'gmail': 'ca_abc123'
}
)
```
### `manage_connections`
Control how connections are managed within the session:
```python
# Boolean: enable/disable automatic connection management
session = composio.tool_router.create(
user_id='user_123',
toolkits=['gmail'],
manage_connections=True # default
)
# Dict: fine-grained control
session = composio.tool_router.create(
user_id='user_123',
toolkits=['gmail'],
manage_connections={
'enable': True, # Whether to use tools to manage connections
'callback_url': 'https://your-app.com/auth/callback', # Optional OAuth callback URL
'wait_for_connections': True # NEW in v0.10.5: Wait for connections to complete
}
)
```
#### Wait for Connections
The `wait_for_connections` property (new in v0.10.5) allows the tool router session to wait for users to complete authentication before proceeding to the next step. When set to `True`, the session will block execution until all required connections are established.
```python
session = composio.tool_router.create(
user_id='user_123',
toolkits=['gmail', 'slack'],
manage_connections={
'enable': True,
'callback_url': 'https://your-app.com/auth/callback',
'wait_for_connections': True # Session waits for connections to complete
}
)
```
### `workbench`
Configure workbench behavior:
```python
# Disable workbench entirely — no code execution tools in the session
session = composio.tool_router.create(
user_id='user_123',
toolkits=['gmail'],
workbench={
'enable': False
}
)
# Fine-tune workbench settings
session = composio.tool_router.create(
user_id='user_123',
toolkits=['gmail'],
workbench={
'enable': True, # default
'enable_proxy_execution': False, # Whether to allow proxy execute calls in workbench
'auto_offload_threshold': 300 # Character threshold for auto-offloading large responses to the workbench
}
)
```
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enable` | `bool` | `True` | Enables/disables the workbench entirely. When `False`, `COMPOSIO_REMOTE_WORKBENCH` and `COMPOSIO_REMOTE_BASH_TOOL` are excluded from the session. |
| `enable_proxy_execution` | `bool` | `True` | Controls proxy API execution in the workbench. |
| `auto_offload_threshold` | `int` | auto | Character threshold for auto-offloading large responses to the workbench. |
## Session Properties and Methods
A Tool Router session provides the following properties and methods:
### `session_id`
The unique identifier for this session.
```python
print(session.session_id)
```
### `mcp`
The MCP server configuration for this session, including authentication headers.
```python
print(session.mcp.url) # The URL to connect to
print(session.mcp.type) # ToolRouterMCPServerType.HTTP or ToolRouterMCPServerType.SSE
print(session.mcp.headers) # Authentication headers (includes x-api-key)
```
### `tools(modifiers=None)`
Retrieve the tools available in the session, formatted for your AI framework.
```python
# Basic usage
tools = session.tools()
# With session-specific modifiers (new in v0.10.5)
from composio.core.models import before_execute_meta, after_execute_meta
@before_execute_meta
def before_modifier(tool, toolkit, session_id, params):
print(f"[Session: {session_id}] Executing {tool} from {toolkit}")
# Add custom logging, validation, or parameter transformation
return params
@after_execute_meta
def after_modifier(tool, toolkit, session_id, response):
print(f"[Session: {session_id}] Completed {tool}")
# Transform results, add telemetry, or handle errors
return response
tools = session.tools(modifiers=[before_modifier, after_modifier])
```
#### Session-Specific Modifiers
Tool Router sessions now support enhanced modifiers (introduced in v0.10.5) that include session context:
- **`@before_execute_meta`**: Modify parameters before execution, with access to session ID
- **`@after_execute_meta`**: Transform results after execution, with access to session ID
- **`@modify_schema_meta`**: Customize tool schemas before they're sent to the AI model
These modifiers provide better context for session-based tool execution, allowing you to track which session is executing which tools.
### Meta Tools
Tool Router provides meta tools for managing connections and session state. You can access these directly using the `get_raw_tool_router_meta_tools` method (introduced in v0.10.5):
```python
from composio import Composio
from composio.core.models import modify_schema_meta
composio = Composio()
# Define schema modifier
@modify_schema_meta
def schema_modifier(tool, toolkit, schema):
# Customize meta tool schemas
print(f"Modifying schema for {tool}")
return schema
# Get raw meta tools for a session
meta_tools = composio.tools.get_raw_tool_router_meta_tools(
session_id='session_123',
modifiers=[schema_modifier]
)
print(f"Available meta tools: {[t.name for t in meta_tools]}")
```
Meta tools allow you to:
- Authorize new toolkit connections within a session
- Query toolkit connection states
- Manage session configuration
This method is useful when you need direct access to the underlying meta tools without creating a full session object.
### `authorize(toolkit, *, callback_url=None)`
Initiate an authorization flow for a toolkit.
```python
# Start authorization for Gmail
connection_request = session.authorize('gmail')
print(connection_request.redirect_url) # URL to redirect user for auth
# Wait for the user to complete authorization
connected_account = connection_request.wait_for_connection()
print(f"Connected: {connected_account}")
# With custom callback URL
connection_request = session.authorize(
'gmail',
callback_url='https://your-app.com/auth/callback'
)
```
### `toolkits(...)`
Query the connection state of toolkits in the session.
```python
# Get all toolkits
result = session.toolkits()
for toolkit in result.items:
print(f"{toolkit.name} ({toolkit.slug})")
if toolkit.connection:
print(f" Connected: {toolkit.connection.is_active}")
if toolkit.connection.connected_account:
print(f" Account ID: {toolkit.connection.connected_account.id}")
print(f" Status: {toolkit.connection.connected_account.status}")
# With pagination
result = session.toolkits(limit=10, next_cursor='cursor_abc')
# Filter by specific toolkits
result = session.toolkits(toolkits=['gmail', 'slack'])
# Filter by connection status
connected_toolkits = session.toolkits(is_connected=True)
disconnected_toolkits = session.toolkits(is_connected=False)
# Combine all options
result = session.toolkits(
toolkits=['gmail', 'github'],
limit=5,
is_connected=True
)
```
## Framework Integrations
### OpenAI
```python
from openai import OpenAI
from composio import Composio
from composio_openai import OpenAIProvider
composio = Composio(provider=OpenAIProvider())
client = OpenAI()
session = composio.tool_router.create(
user_id='user_123',
toolkits=['gmail']
)
tools = session.tools()
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Find my last email from gmail"}],
tools=tools,
)
print(response.choices[0].message)
```
### Anthropic
```python
from anthropic import Anthropic
from composio import Composio
from composio_anthropic import AnthropicProvider
composio = Composio(provider=AnthropicProvider())
client = Anthropic()
session = composio.tool_router.create(
user_id='user_123',
toolkits=['gmail']
)
tools = session.tools()
response = client.messages.create(
model="claude-3-opus-20240229",
max_tokens=1024,
messages=[{"role": "user", "content": "Find my last email from gmail"}],
tools=tools,
)
print(response.content)
```
### LangChain
```python
from langchain_openai import ChatOpenAI
from langchain.agents import create_tool_calling_agent, AgentExecutor
from langchain_core.prompts import ChatPromptTemplate
from composio import Composio
from composio_langchain import LangChainProvider
composio = Composio(provider=LangChainProvider())
session = composio.tool_router.create(
user_id='user_123',
toolkits=['gmail']
)
tools = session.tools()
llm = ChatOpenAI(model="gpt-4")
prompt = ChatPromptTemplate.from_messages([
("system", "You are a helpful assistant."),
("human", "{input}"),
("placeholder", "{agent_scratchpad}"),
])
agent = create_tool_calling_agent(llm, tools, prompt)
agent_executor = AgentExecutor(agent=agent, tools=tools)
result = agent_executor.invoke({"input": "Find my last email from gmail"})
print(result)
```
### Using MCP URL Directly
You can also use the MCP URL directly with any MCP-compatible client:
```python
from composio import Composio
composio = Composio()
session = composio.tool_router.create(
user_id='user_123',
toolkits=['gmail'],
manage_connections=True
)
# Use with any MCP client
mcp_url = session.mcp.url
mcp_headers = session.mcp.headers # Pre-configured authentication headers
print(f"MCP URL: {mcp_url}")
print(f"MCP Type: {session.mcp.type}") # 'http' or 'sse'
print(f"MCP Headers: {mcp_headers}") # {'x-api-key': 'your-api-key'}
```
## Authorization Flow
When a user needs to connect a toolkit, use the `authorize()` method:
```python
from composio import Composio
composio = Composio()
session = composio.tool_router.create(
user_id='user_123',
toolkits=['gmail']
)
# Initiate authorization for Gmail
connection_request = session.authorize('gmail')
# Log the redirect URL for the user
print(f"Redirect URL: {connection_request.redirect_url}")
# Wait for the user to complete the authorization (with timeout)
connected_account = connection_request.wait_for_connection(timeout=300)
print(f"Connected Account: {connected_account}")
```
You can also provide a custom callback URL:
```python
connection_request = session.authorize(
'gmail',
callback_url='https://your-app.com/auth/callback'
)
```
## Querying Toolkit States
Use the `toolkits()` method to check connection states:
```python
from composio import Composio
composio = Composio()
session = composio.tool_router.create(user_id='user_123')
# Get all toolkits
result = session.toolkits()
for toolkit in result.items:
status = "Connected" if toolkit.connection and toolkit.connection.is_active else "Not connected"
print(f"{toolkit.name}: {status}")
# Get only connected toolkits
connected = session.toolkits(is_connected=True)
print(f"You have {len(connected.items)} connected toolkits")
# Get only disconnected toolkits (need authorization)
disconnected = session.toolkits(is_connected=False)
for toolkit in disconnected.items:
print(f"Please connect: {toolkit.name}")
# Pagination example
result = session.toolkits(limit=10)
all_toolkits = list(result.items)
while result.next_cursor:
result = session.toolkits(limit=10, next_cursor=result.next_cursor)
all_toolkits.extend(result.items)
```
The response structure:
```python
{
'items': [
{
'slug': 'gmail',
'name': 'Gmail',
'logo': 'https://...',
'is_no_auth': False,
'connection': {
'is_active': True,
'auth_config': {
'id': 'ac_xxx',
'mode': 'OAUTH2',
'is_composio_managed': True
},
'connected_account': {
'id': 'ca_xxx',
'status': 'ACTIVE'
}
}
}
],
'next_cursor': 'cursor_abc',
'total_pages': 1
}
```
## Best Practices
1. **User Isolation**: Create separate sessions per user to ensure proper isolation of connections and tools.
2. **Toolkit Selection**: Only enable toolkits that are necessary for the use case to maintain security and reduce complexity.
3. **Connection Management**: Use `manage_connections=True` (default) for interactive applications where users can be prompted to connect accounts.
4. **Tag Filtering**: Use global `tags` to restrict tools to safe operations (e.g., `['readOnlyHint']`) or toolkit-specific tags in the `tools` configuration for fine-grained control.
5. **Session Reuse**: Store and reuse `session_id` to maintain user sessions across requests.
```python
import redis
# Store session ID after creation
session = composio.tool_router.create(user_id='user_123', toolkits=['gmail'])
redis_client.set(f"session:user_123", session.session_id)
# Retrieve existing session
session_id = redis_client.get(f"session:user_123")
if session_id:
session = composio.tool_router.use(session_id)
```
## Type Reference
### ToolRouterSession
```python
@dataclass
class ToolRouterSession:
session_id: str
mcp: ToolRouterMCPServerConfig
tools: Callable[[Optional[Modifiers]], Any]
authorize: Callable[..., ConnectionRequest]
toolkits: Callable[..., ToolkitConnectionsDetails]
```
### ToolRouterMCPServerConfig
```python
class ToolRouterMCPServerType(str, Enum):
HTTP = "http"
SSE = "sse"
@dataclass
class ToolRouterMCPServerConfig:
type: ToolRouterMCPServerType
url: str
headers: Optional[Dict[str, Optional[str]]] = None # Authentication headers (includes x-api-key)
```
### ToolkitConnectionState
```python
@dataclass
class ToolkitConnectionState:
slug: str
name: str
is_no_auth: bool
connection: Optional[ToolkitConnection] = None
logo: Optional[str] = None
@dataclass
class ToolkitConnection:
is_active: bool
auth_config: Optional[ToolkitConnectionAuthConfig] = None
connected_account: Optional[ToolkitConnectedAccount] = None
@dataclass
class ToolkitConnectionAuthConfig:
id: str
mode: str
is_composio_managed: bool
@dataclass
class ToolkitConnectedAccount:
id: str
status: str
```
### ToolkitConnectionsDetails
```python
@dataclass
class ToolkitConnectionsDetails:
items: List[ToolkitConnectionState]
total_pages: int
next_cursor: Optional[str] = None
```
### Configuration Types
```python
from typing import List, Dict, Union, Literal
from typing_extensions import TypedDict
# Toolkits configuration
ToolRouterToolkitsEnableConfig = TypedDict('ToolRouterToolkitsEnableConfig', {
'enable': List[str]
})
ToolRouterToolkitsDisableConfig = TypedDict('ToolRouterToolkitsDisableConfig', {
'disable': List[str]
})
toolkits: Union[
List[str], # ['gmail', 'slack']
ToolRouterToolkitsEnableConfig, # {'enable': ['gmail', 'slack']}
ToolRouterToolkitsDisableConfig # {'disable': ['calendar']}
]
# Tools configuration
ToolRouterToolsEnableConfig = TypedDict('ToolRouterToolsEnableConfig', {
'enable': List[str]
})
ToolRouterToolsDisableConfig = TypedDict('ToolRouterToolsDisableConfig', {
'disable': List[str]
})
ToolRouterToolsTagsConfig = TypedDict('ToolRouterToolsTagsConfig', {
'tags': List[Literal['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint']]
})
ToolRouterToolsConfig = Union[
List[str], # ['GMAIL_SEND_EMAIL', 'GMAIL_FETCH_EMAILS']
ToolRouterToolsEnableConfig, # {'enable': ['GMAIL_SEND_EMAIL']}
ToolRouterToolsDisableConfig, # {'disable': ['GMAIL_DELETE_EMAIL']}
ToolRouterToolsTagsConfig # {'tags': ['readOnlyHint']}
]
tools: Dict[str, ToolRouterToolsConfig] # Key is toolkit slug, value is ToolRouterToolsConfig
# Tags configuration (global)
tags: List[Literal['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint']]
# Manage connections configuration
ToolRouterManageConnectionsConfig = TypedDict('ToolRouterManageConnectionsConfig', {
'enable': bool,
'callback_url': str, # Optional
'wait_for_connections': bool # NEW in v0.10.5: Wait for connections to complete
})
manage_connections: Union[
bool,
ToolRouterManageConnectionsConfig # {'enable': True, 'callback_url': 'https://...', 'wait_for_connections': True}
]
# Workbench configuration
ToolRouterWorkbenchConfig = TypedDict('ToolRouterWorkbenchConfig', {
'enable': bool, # Enable/disable workbench entirely (default: True)
'enable_proxy_execution': bool, # Whether to allow proxy execute calls
'auto_offload_threshold': int # Character threshold for auto-offloading execution to workbench
})
workbench: ToolRouterWorkbenchConfig
```
## What's New in v0.10.5
### 1. Wait for Connections
The new `wait_for_connections` property allows sessions to wait for users to complete authentication before proceeding:
```python
session = composio.tool_router.create(
user_id='user_123',
toolkits=['gmail', 'slack'],
manage_connections={
'enable': True,
'callback_url': 'https://your-app.com/callback',
'wait_for_connections': True # NEW
}
)
```
### 2. Enhanced Session Modifiers
Session-specific modifiers now include session context, making it easier to track and manage tool execution:
```python
from composio.core.models import before_execute_meta, after_execute_meta
@before_execute_meta
def before_modifier(tool, toolkit, session_id, params):
print(f"[{session_id}] Executing {tool}")
return params
@after_execute_meta
def after_modifier(tool, toolkit, session_id, response):
print(f"[{session_id}] Completed {tool}")
return response
tools = session.tools(modifiers=[before_modifier, after_modifier])
```
### 3. Direct Meta Tools Access
New method to fetch meta tools directly from a session:
```python
from composio.core.models import modify_schema_meta
@modify_schema_meta
def schema_modifier(tool, toolkit, schema):
return schema
meta_tools = composio.tools.get_raw_tool_router_meta_tools(
session_id='session_123',
modifiers=[schema_modifier]
)
```
### 4. Performance Improvements
- Optimized tool fetching with fewer API calls
- Improved session API architecture for better reliability
- Simplified internal tool execution paths
All changes in v0.10.5 are fully backward compatible with existing code.
+78
View File
@@ -0,0 +1,78 @@
from composio import Composio
composio = Composio()
# List all auth configs
auth_configs = composio.auth_configs.list()
print(auth_configs)
# Use composio managed auth
auth_config = composio.auth_configs.create(
toolkit="github",
options={
"type": "use_composio_managed_auth",
},
)
print(auth_config)
# Use custom auth
auth_config = composio.auth_configs.create(
toolkit="notion",
options={
"name": "Notion Auth",
"type": "use_custom_auth",
"auth_scheme": "OAUTH2",
"credentials": {
"client_id": "1234567890",
"client_secret": "1234567890",
"oauth_redirect_uri": "https://backend.composio.dev/api/v3/toolkits/auth/callback",
},
},
)
print(auth_config)
# When creating an auth config, you can check for required fields
required_fields = composio.toolkits.get_auth_config_creation_fields(
toolkit="NOTION",
auth_scheme="OAUTH2",
)
print(required_fields)
# Retrieve a specific auth config
auth_config_retrieved = composio.auth_configs.get(auth_config.id)
print(auth_config_retrieved)
# Fetch required input fields for auth config
toolkit = composio.toolkits.get(slug="notion")
required_input_fields = [
field.name for field in toolkit.auth_config_details or [] if field.mode
]
print(required_input_fields)
# Update an auth config
auth_config_updated = composio.auth_configs.update(
auth_config.id,
options={
"type": "custom",
"credentials": {
"client_id": "1234567890",
"client_secret": "1234567890",
},
"tool_access_config": {
"tools_for_connected_account_creation": ["github"],
},
},
)
print(auth_config_updated)
# Enable an auth config
composio.auth_configs.enable(auth_config.id)
print("Auth config enabled")
# Disable an auth config
composio.auth_configs.disable(auth_config.id)
print("Auth config disabled")
# Delete an auth config
composio.auth_configs.delete(auth_config.id)
print("Auth config deleted")
+54
View File
@@ -0,0 +1,54 @@
from composio import Composio
from composio.types import auth_scheme
composio = Composio()
# List all connected accounts
connected_accounts = composio.connected_accounts.list()
print(connected_accounts)
# Create a new connected account (OAuth)
connection_request = composio.connected_accounts.initiate(
user_id="1234567890",
auth_config_id="1234567890",
)
print(connection_request)
# Wait for the connection to be established (OAuth)
connected_account = connection_request.wait_for_connection()
print(connected_account)
# Create a new connected account (API Key)
connection_request = composio.connected_accounts.initiate(
user_id="1234567890",
auth_config_id="1234567890",
config=auth_scheme.api_key(
options={
"api_key": "1234567890",
},
),
)
print(connection_request)
# When creating a connected account, you can check for required fields
required_fields = composio.toolkits.get_connected_account_initiation_fields(
toolkit="NOTION",
auth_scheme="API_KEY",
)
print(required_fields)
# Retrieve a specific connected account
connected_account_retrieved = composio.connected_accounts.get(connected_account.id)
print(connected_account_retrieved)
# Disable a connected account
composio.connected_accounts.disable(connected_account.id)
print("Connected account disabled")
# Enable a connected account
composio.connected_accounts.enable(connected_account.id)
print("Connected account enabled")
# Delete a connected account
composio.connected_accounts.delete(connected_account.id)
print("Connected account deleted")
+200
View File
@@ -0,0 +1,200 @@
"""
Custom Tools — local tools + proxy execute with OpenAI Agents SDK.
Shows how to create custom tools that run in-process alongside
remote Composio tools. Includes all three patterns: standalone,
extension (Gmail proxy), and custom toolkit.
Usage:
COMPOSIO_API_KEY=... OPENAI_API_KEY=... python examples/custom_tools_agent_test.py
"""
# ruff: noqa: E302, E303
import asyncio
import base64
import os
import sys
from agents import Agent, Runner
from pydantic import BaseModel, Field
from composio import Composio
from composio_openai_agents import OpenAIAgentsProvider
composio = Composio(
base_url=os.environ.get("COMPOSIO_BASE_URL"),
provider=OpenAIAgentsProvider(),
)
# ── 1. Standalone tool — slug/name/description inferred from function ──
class UserLookupInput(BaseModel):
user_id: str = Field(description="User ID (e.g. user-1)")
USERS = {
"user-1": {"name": "Alice Johnson", "email": "alice@acme.com", "role": "admin"},
"user-2": {"name": "Bob Smith", "email": "bob@acme.com", "role": "developer"},
}
@composio.experimental.tool()
def get_user(input: UserLookupInput, ctx):
"""Look up an internal user by ID. Returns name, email, and role."""
user = USERS.get(input.user_id)
if not user:
raise ValueError(f'User "{input.user_id}" not found')
return user
# ── 2. Standalone tool with overrides ──────────────────────────────────
class GreetInput(BaseModel):
name: str = Field(description="Name to greet")
style: str = Field(default="friendly", description="Greeting style")
@composio.experimental.tool(
slug="GREET_V2",
name="Greeting Generator",
description="Generate a personalized greeting message in different styles.",
)
def greet(input: GreetInput, ctx):
"""This docstring is ignored because description= is set above."""
greetings = {
"friendly": f"Hey {input.name}! Great to see you!",
"formal": f"Dear {input.name}, I hope this message finds you well.",
"casual": f"Yo {input.name}, what's up?",
}
return {"message": greetings.get(input.style, f"Hello {input.name}!")}
# ── 3. Extension tool — inherits Gmail auth via ctx.proxy_execute() ────
class DraftInput(BaseModel):
to: str = Field(description="Recipient email address")
subject: str = Field(description="Email subject")
body: str = Field(description="Email body (plain text)")
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a real Gmail draft. Appears in the user's drafts folder."""
raw_msg = (
f"To: {input.to}\r\nSubject: {input.subject}\r\n"
f"Content-Type: text/plain; charset=UTF-8\r\n\r\n{input.body}"
)
raw = base64.urlsafe_b64encode(raw_msg.encode()).decode().rstrip("=")
res = ctx.proxy_execute(
toolkit="gmail",
endpoint="https://gmail.googleapis.com/gmail/v1/users/me/drafts",
method="POST",
body={"message": {"raw": raw}},
)
if res["status"] != 200:
raise RuntimeError(f"Gmail API error {res['status']}")
data = res["data"]
return {"draft_id": data["id"], "to": input.to, "subject": input.subject}
# ── 4. Custom toolkit — groups related tools under one namespace ───────
role_manager = composio.experimental.Toolkit(
slug="ROLE_MANAGER",
name="Role Manager",
description="Manage user roles in the system",
)
class SetRoleInput(BaseModel):
user_id: str = Field(description="User ID")
role: str = Field(description="New role (admin, developer, viewer)")
@role_manager.tool()
def set_role(input: SetRoleInput, ctx):
"""Set a user's role. Returns confirmation."""
return {"user_id": input.user_id, "role": input.role, "updated": True}
@role_manager.tool(name="List All Roles")
def list_roles(input: UserLookupInput, ctx):
"""List available roles for a user."""
return {"roles": ["admin", "developer", "viewer"], "current": "admin"}
# ── Agent ──────────────────────────────────────────────────────────────
async def run_test(prompt, test_name):
print(f"\n{'=' * 60}")
print(f"TEST: {test_name}")
print(f"{'=' * 60}")
session = composio.create(
user_id="default",
toolkits=["gmail", "weathermap"],
manage_connections=True,
experimental={
"custom_tools": [get_user, greet, create_draft],
"custom_toolkits": [role_manager],
},
)
tools = session.tools()
agent = Agent(
name="Assistant",
instructions=(
"You are a helpful assistant. Use Composio tools to execute tasks. "
"In MULTI_EXECUTE, always pass arguments inside the arguments field."
),
model="gpt-4.1-mini",
tools=tools,
)
print(f"> {prompt}\n")
try:
result = await Runner.run(agent, prompt, max_turns=25)
print(f"\nAgent: {result.final_output}")
return True
except Exception as e:
print(f"\nERROR: {e}")
return False
async def main():
tests = [
("Look up user-2", "Standalone (inferred slug/name)"),
("Generate a formal greeting for Alice", "Standalone (overridden slug/name)"),
("Set user-1 role to developer", "Toolkit tool"),
("Look up user-1 and set their role to viewer", "Mixed local tools"),
("What is the weather in Tokyo?", "Remote (weathermap)"),
(
'Draft an email to bob@acme.com with subject "Test" and body "Hello!"',
"Gmail proxy",
),
(
"Look up user-1, draft them an email saying hi, include weather in SF",
"Mixed all",
),
]
results = []
for prompt, name in tests:
ok = await run_test(prompt, name)
results.append((name, ok))
print(f"\n{'=' * 60}")
print("RESULTS")
print(f"{'=' * 60}")
for name, ok in results:
print(f" [{'PASS' if ok else 'FAIL'}] {name}")
print(f"\n{sum(1 for _, ok in results if ok)}/{len(results)} passed")
if __name__ == "__main__":
if not os.environ.get("OPENAI_API_KEY"):
print("Error: OPENAI_API_KEY env var required")
sys.exit(1)
asyncio.run(main())
@@ -0,0 +1,189 @@
"""
Advanced examples demonstrating the ToolRouter with all configuration options.
This example shows various ways to configure tool router sessions with
different parameters matching the TypeScript implementation.
"""
from composio import Composio
# Initialize Composio SDK
composio = Composio()
def example_with_specific_toolkits():
"""Create a session with specific toolkits enabled."""
print("=== Specific Toolkits Example ===")
session = composio.tool_router.create(
user_id="user_toolkit", toolkits=["github", "slack", "linear"]
)
print(f"Session ID: {session.session_id}")
print("Available toolkits: github, slack, linear")
return session
def example_with_disabled_toolkits():
"""Create a session with specific toolkits disabled."""
print("\n=== Disabled Toolkits Example ===")
session = composio.tool_router.create(
user_id="user_disabled", toolkits={"disabled": ["linear", "jira"]}
)
print(f"Session ID: {session.session_id}")
print("Disabled toolkits: linear, jira")
return session
def example_with_connection_management_config():
"""Create a session with connection management and callback URL."""
print("\n=== Connection Management with Callback Example ===")
session = composio.tool_router.create(
user_id="user_callback",
manage_connections={
"enabled": True,
"callback_uri": "https://myapp.com/oauth/callback",
},
)
print(f"Session ID: {session.session_id}")
print("Connection management enabled with custom callback")
return session
def example_with_auth_configs():
"""Create a session with specific auth configs for toolkits."""
print("\n=== Auth Configs Example ===")
session = composio.tool_router.create(
user_id="user_auth",
toolkits=["github", "slack"],
auth_configs={"github": "ac_github_123", "slack": "ac_slack_456"},
)
print(f"Session ID: {session.session_id}")
print("Auth configs: github → ac_github_123, slack → ac_slack_456")
return session
def example_with_connected_accounts():
"""Create a session with pre-configured connected accounts."""
print("\n=== Connected Accounts Example ===")
session = composio.tool_router.create(
user_id="user_connected",
toolkits=["github", "slack"],
connected_accounts={"github": "ca_github_789", "slack": "ca_slack_012"},
)
print(f"Session ID: {session.session_id}")
print("Connected accounts: github → ca_github_789, slack → ca_slack_012")
return session
def example_with_all_parameters():
"""Create a session with all parameters configured."""
print("\n=== All Parameters Example ===")
session = composio.tool_router.create(
user_id="user_complete",
toolkits=["github", "slack", "notion"],
manage_connections={
"enabled": True,
"callback_uri": "https://myapp.com/callback",
},
auth_configs={
"github": "ac_github_xyz",
"slack": "ac_slack_abc",
"notion": "ac_notion_def",
},
connected_accounts={"github": "ca_github_111", "slack": "ca_slack_222"},
)
print(f"✓ Session ID: {session.session_id}")
print("✓ Toolkits: github, slack, notion")
print("✓ Connection management: enabled with callback")
print("✓ Auth configs: configured for 3 toolkits")
print("✓ Connected accounts: 2 pre-configured")
return session
def example_minimal_vs_maximal():
"""Compare minimal and maximal configurations."""
print("\n=== Minimal vs Maximal Example ===")
# Minimal - just user ID
minimal_session = composio.tool_router.create(user_id="user_minimal")
print(f"Minimal session: {minimal_session.session_id}")
# Maximal - all options
maximal_session = composio.tool_router.create(
user_id="user_maximal",
toolkits=["github", "slack"],
manage_connections={"enabled": True, "callback_uri": "https://app.com/cb"},
auth_configs={"github": "ac_1", "slack": "ac_2"},
connected_accounts={"github": "ca_1"},
)
print(f"Maximal session: {maximal_session.session_id}")
return minimal_session, maximal_session
def example_type_safe_configuration():
"""Demonstrate type-safe configuration using TypedDict."""
print("\n=== Type-Safe Configuration Example ===")
from composio.core.models.tool_router import (
ToolRouterManageConnectionsConfig,
ToolRouterToolkitsDisabledConfig,
)
# Type-safe toolkit config
toolkit_config: ToolRouterToolkitsDisabledConfig = {"disabled": ["linear", "asana"]}
# Type-safe connection management config
connection_config: ToolRouterManageConnectionsConfig = {
"enabled": True,
"callback_uri": "https://secure.app.com/oauth",
"infer_scopes_from_tools": False,
}
session = composio.tool_router.create(
user_id="user_typesafe",
toolkits=toolkit_config,
manage_connections=connection_config,
)
print(f"Session ID: {session.session_id}")
print("✓ Type-safe configuration applied")
return session
if __name__ == "__main__":
"""Run all advanced examples."""
print("Composio ToolRouter Advanced Examples\n")
print("=" * 60)
try:
example_with_specific_toolkits()
example_with_disabled_toolkits()
example_with_connection_management_config()
example_with_auth_configs()
example_with_connected_accounts()
example_with_all_parameters()
example_minimal_vs_maximal()
example_type_safe_configuration()
print("\n" + "=" * 60)
print("✓ All advanced examples completed successfully!")
except Exception as e:
print(f"\n✗ Error running examples: {e}")
import traceback
traceback.print_exc()
@@ -0,0 +1,187 @@
"""
Example demonstrating the use of ToolRouter in Composio SDK.
This example shows how to create a tool router session for a user,
get provider-wrapped tools, and authorize toolkits.
"""
from composio import Composio
# Initialize Composio SDK
composio = Composio()
# Example 1: Create a basic tool router session
def basic_session_example():
"""Create a basic tool router session."""
print("=== Basic Session Example ===")
user_id = "user_123"
# Create a tool router session
session = composio.tool_router.create(user_id=user_id)
print(f"Session ID: {session.session_id}")
print(f"MCP Server Type: {session.mcp.type}")
print(f"MCP Server URL: {session.mcp.url}")
# Get tools wrapped for the provider
tools = session.tools()
print(
f"Number of tools available: {len(tools) if isinstance(tools, list) else 'N/A'}"
)
return session
# Example 2: Create a session with connection management
def session_with_connections_example():
"""Create a session with connection management enabled."""
print("\n=== Session with Connection Management ===")
user_id = "user_456"
# Create a tool router session with connection management
session = composio.tool_router.create(user_id=user_id, manage_connections=True)
print(f"Session ID: {session.session_id}")
print("Connection management enabled")
# Get tools (now includes COMPOSIO_MANAGE_CONNECTIONS)
tools = session.tools()
print(
f"Number of tools available: {len(tools) if isinstance(tools, list) else 'N/A'}"
)
return session
# Example 3: Authorize a toolkit
def authorize_toolkit_example():
"""Demonstrate toolkit authorization."""
print("\n=== Authorize Toolkit Example ===")
user_id = "user_789"
# Create a session
session = composio.tool_router.create(user_id=user_id)
# Authorize GitHub toolkit for the user
try:
connection_request = session.authorize("github")
print(f"Connection Request ID: {connection_request.id}")
print(f"Connection Status: {connection_request.status}")
if connection_request.redirect_url:
print(f"Redirect URL: {connection_request.redirect_url}")
print("User should visit this URL to complete authorization")
return connection_request
except Exception as e:
print(f"Error authorizing toolkit: {e}")
return None
# Example 4: Get toolkit connection states
def get_toolkits_example():
"""Get toolkit connection states for a session."""
print("\n=== Get Toolkits Example ===")
user_id = "user_101"
# Create a session
session = composio.tool_router.create(user_id=user_id)
# Get toolkit connection states
toolkits = session.toolkits()
print(f"Current toolkits: {toolkits}")
return toolkits
# Example 5: Full workflow with advanced configuration
def full_workflow_example():
"""Complete workflow with session, tools, and authorization."""
print("\n=== Full Workflow Example ===")
user_id = "user_full"
# 1. Create session with multiple configurations
session = composio.tool_router.create(
user_id=user_id,
toolkits=["github", "slack"],
manage_connections=True,
auth_configs={"github": "ac_demo_123"},
)
print(f"✓ Created session: {session.session_id}")
# 2. Get tools for the session
tools = session.tools()
print(f"✓ Retrieved {len(tools) if isinstance(tools, list) else 'N/A'} tools")
# 3. Check toolkits
toolkits_result = session.toolkits()
print(f"✓ Current toolkits: {len(toolkits_result.items)} toolkit(s)")
# 4. Authorize a toolkit if needed
try:
connection_request = session.authorize("slack")
print("✓ Initiated authorization for Slack")
if connection_request.redirect_url:
print(f" → Redirect URL: {connection_request.redirect_url}")
except Exception as e:
print(f"✗ Authorization error: {e}")
return session
# Example 6: Using with custom modifiers
def session_with_modifiers_example():
"""Create a session and use tools with custom modifiers."""
print("\n=== Session with Modifiers Example ===")
user_id = "user_modifiers"
# Create session
session = composio.tool_router.create(user_id=user_id)
# Get tools with custom modifiers
modifiers = {
# Add any custom modifiers here
# Example: timeout, custom headers, etc.
}
tools = session.tools(modifiers=modifiers if modifiers else None)
print(
f"✓ Retrieved {len(tools) if isinstance(tools, list) else 'N/A'} tools with modifiers"
)
return session
if __name__ == "__main__":
"""Run all examples."""
print("Composio ToolRouter Examples\n")
print("=" * 50)
# Run examples
try:
basic_session_example()
session_with_connections_example()
authorize_toolkit_example()
get_toolkits_example()
full_workflow_example()
session_with_modifiers_example()
print("\n" + "=" * 50)
print("✓ All examples completed successfully!")
except Exception as e:
print(f"\n✗ Error running examples: {e}")
import traceback
traceback.print_exc()
+26
View File
@@ -0,0 +1,26 @@
from fastapi import FastAPI
from fastapi.responses import RedirectResponse
from composio import Composio
# Create a FastAPI app
app = FastAPI()
# Create a Composio client
composio = Composio()
@app.get("/authorize/{toolkit}")
def authorize_app(toolkit: str):
# retrieve the user id from your app
user_id = ""
# retrieve the auth config id from your app
auth_config_id = ""
# initiate the connection request
connection_request = composio.connected_accounts.initiate(
user_id=user_id,
auth_config_id=auth_config_id,
)
return RedirectResponse(url=connection_request.redirect_url) # type: ignore
+34
View File
@@ -0,0 +1,34 @@
import asyncio
from langchain_mcp_adapters.client import MultiServerMCPClient
from langgraph.prebuilt import create_react_agent
from composio import Composio
composio = Composio()
mcp_config = composio.mcp.create(
name="langchain-slack-mcp",
toolkits=[{"toolkit": "slack", "auth_config_id": "<auth-config-id>"}],
)
mcp_server = mcp_config.generate(user_id="<user-id>")
client = MultiServerMCPClient(
{
"composio": {
"url": mcp_server["url"],
"transport": "streamable_http",
}
}
)
async def langchain_mcp(message: str):
tools = await client.get_tools()
agent = create_react_agent("openai:gpt-4.1", tools)
response = await agent.ainvoke({"messages": message})
return response
mcp_response = asyncio.run(langchain_mcp("Show me 20 most used slack channels"))
+106
View File
@@ -0,0 +1,106 @@
import os
from composio import (
Composio,
after_execute,
before_execute,
before_file_upload,
schema_modifier,
)
from composio.types import Tool, ToolExecuteParams, ToolExecutionResponse
composio = Composio()
@before_execute(tools=["HACKERNEWS_GET_USER"])
def before_execute_modifier(
tool: str,
toolkit: str,
params: ToolExecuteParams,
) -> ToolExecuteParams:
# Perform modifications on the request
print("before_execute_modifier", tool, toolkit)
return params
@before_file_upload(tools=["HACKERNEWS_GET_USER"])
def rewrite_upload_path(path: str, tool: str, toolkit: str) -> str:
"""Optional: same pattern as before_execute; use for per-tool file path policy."""
return path
@after_execute(tools=["HACKERNEWS_GET_USER"])
def after_execute_modifier(
tool: str,
toolkit: str,
response: ToolExecutionResponse,
) -> ToolExecutionResponse:
return {
**response,
"data": {
"karama": response["data"]["karama"],
},
}
# execute tool
response = composio.tools.execute(
user_id="default",
slug="HACKERNEWS_GET_USER",
arguments={"username": "pg"},
modifiers=[
before_execute_modifier,
rewrite_upload_path,
after_execute_modifier,
],
)
print(response)
@schema_modifier(tools=["HACKERNEWS_GET_USER"])
def modify_schema(
tool: str,
toolkit: str,
schema: Tool,
) -> Tool:
# Perform modifications on the schema
print("modify_schema", tool, toolkit)
return schema
tools = composio.tools.get(
user_id="default",
slug="HACKERNEWS_GET_USER",
modifiers=[modify_schema],
)
print(tools)
@before_execute(toolkits=["NOTION"])
def add_custom_auth(
tool: str,
toolkit: str,
params: ToolExecuteParams,
) -> ToolExecuteParams:
if params["custom_auth_params"] is None:
params["custom_auth_params"] = {"parameters": []}
params["custom_auth_params"]["parameters"].append( # type: ignore
{
"name": "x-api-key",
"value": os.getenv("NOTION_API_KEY"),
"in": "header",
}
)
return params
result = composio.tools.execute(
user_id="default",
slug="NOTION_GET_DATABASES",
arguments={},
modifiers=[
add_custom_auth,
],
)
print(result)
+47
View File
@@ -0,0 +1,47 @@
"""
Tool Router - Authorization Example
This example demonstrates how to authorize a toolkit connection
within a Tool Router session.
"""
import os
from composio import Composio
# Initialize Composio
# Set COMPOSIO_API_KEY environment variable or pass api_key parameter
api_key = os.environ.get("COMPOSIO_API_KEY")
if not api_key:
print("Error: COMPOSIO_API_KEY environment variable not set")
print("Please set it using: export COMPOSIO_API_KEY='your_api_key'")
exit(1)
composio = Composio(api_key=api_key)
# Create a tool router session
session = composio.create(
user_id="user_123",
toolkits=["github", "gmail"],
)
print(f"Session created: {session.session_id}")
# Authorize a toolkit connection
# This initiates the OAuth flow and returns a connection request
connection_request = session.authorize("github")
print(f"\nConnection Request ID: {connection_request.id}")
print(f"Status: {connection_request.status}")
print(f"Redirect URL: {connection_request.redirect_url}")
print("\nPlease visit the URL above to authorize the connection.")
# Wait for the user to complete the authorization
# This will poll the API until the connection is active or timeout occurs
try:
connected_account = connection_request.wait_for_connection()
print("\n✅ Connection successful!")
print(f"Connected Account ID: {connected_account.id}")
print(f"Status: {connected_account.status}")
except Exception as e:
print(f"\n❌ Connection failed: {e}")
@@ -0,0 +1,33 @@
import asyncio
from claude_agent_sdk import ClaudeAgentOptions, query
from composio_claude_agent_sdk import ClaudeAgentSDKProvider
from composio import Composio
composio = Composio(provider=ClaudeAgentSDKProvider())
session = composio.create(
user_id="user_123",
)
async def main():
options = ClaudeAgentOptions(
system_prompt="You are an expert Python developer",
permission_mode="bypassPermissions",
mcp_servers={
"composio": {
"type": session.mcp.type,
"url": session.mcp.url,
"headers": session.mcp.headers,
}
},
)
async for message in query(
prompt="Fetch my last email and summarize it.", options=options
):
print(message)
asyncio.run(main())
@@ -0,0 +1,32 @@
from crewai import Agent, Crew, Task
from crewai.mcp import MCPServerHTTP
from composio import Composio
composio = Composio()
session = composio.create(
user_id="user_123",
)
agent = Agent(
role="Gmail agent",
goal="helps with gmail related queries",
backstory="You are a helpful assistant that can use the tools provided to you.",
mcps=[
MCPServerHTTP(
url=session.mcp.url,
headers=session.mcp.headers,
)
],
)
# Define task
task = Task(
description=("Find the last email and summarize it."),
expected_output="A summary of the last email including sender, subject, and key points.",
agent=agent,
)
my_crew = Crew(agents=[agent], tasks=[task])
result = my_crew.kickoff()
print(result)
@@ -0,0 +1,95 @@
"""
Tool Router direct_tools session preset with OpenAI Agents.
SESSION_PRESET_DIRECT_TOOLS is a shortcut for sessions where the full allowed
tool set is known upfront. It loads all tools allowed by the filters directly
into session.tools() and the MCP tool list.
Usage:
COMPOSIO_API_KEY=... OPENAI_API_KEY=... python examples/tool_router/direct_tools_preset.py
"""
import os
from agents import Agent, Runner
from composio_openai_agents import OpenAIAgentsProvider
from pydantic import BaseModel, Field
from composio import Composio, SESSION_PRESET_DIRECT_TOOLS
def require_env(name: str) -> str:
value = os.environ.get(name)
if not value:
raise RuntimeError(f"Set {name} before running this example.")
return value
composio = Composio(
api_key=require_env("COMPOSIO_API_KEY"),
base_url=os.environ.get("COMPOSIO_BASE_URL"),
provider=OpenAIAgentsProvider(),
)
require_env("OPENAI_API_KEY")
class UserNoteInput(BaseModel):
username: str = Field(description="Hacker News username, for example pg")
hn_research = composio.experimental.Toolkit(
slug="HN_RESEARCH",
name="Hacker News research",
description="Internal research notes for Hacker News users.",
)
@hn_research.tool(
slug="GET_USER_NOTE",
name="Get Hacker News user note",
description="Return an internal research note for a Hacker News username.",
)
def get_user_note(input: UserNoteInput, ctx):
username = input.username.lower()
return {
"username": input.username,
"note": (
"Paul Graham; YC co-founder and essayist."
if username == "pg"
else f"No curated internal note for {input.username}."
),
}
session = composio.create(
user_id="direct-tools-example-user",
session_preset=SESSION_PRESET_DIRECT_TOOLS,
toolkits=["hackernews"],
tools={"hackernews": {"enable": ["HACKERNEWS_GET_USER"]}},
experimental={
"custom_toolkits": [hn_research],
},
)
tools = session.tools()
tool_names = [tool.name for tool in tools]
assert "HACKERNEWS_GET_USER" in tool_names
assert "LOCAL_HN_RESEARCH_GET_USER_NOTE" in tool_names
assert "COMPOSIO_SEARCH_TOOLS" not in tool_names
print("Direct tools exposed to the agent:")
for tool in tools:
print(f"- {tool.name}")
agent = Agent(
name="Direct Tools Demo Agent",
instructions="Use the provided tools to perform the task.",
model=os.environ.get("OPENAI_MODEL", "gpt-5.5"),
tools=tools,
)
result = Runner.run_sync(
agent,
'Look up user "pg" on Hacker News and include any internal research note.',
)
print(result.final_output)
+85
View File
@@ -0,0 +1,85 @@
"""
Example demonstrating the Tool Router session files API.
Shows how to list, upload, download, and delete files in a tool router
session's virtual filesystem. Also demonstrates search and execute.
Requires COMPOSIO_API_KEY and OPENAI_API_KEY to be set.
"""
import tempfile
from pathlib import Path
from composio import Composio
from composio_openai import OpenAIProvider
def main():
composio = Composio(provider=OpenAIProvider())
# Create a session
print("Creating tool router session...")
session = composio.tool_router.create(user_id="demo_files_user")
print(f" Session ID: {session.session_id}")
# Upload a file (from bytes)
print("\nUploading file (bytes)...")
remote = session.experimental.files.upload(
b'{"hello": "world"}',
remote_path="test_data.json",
mimetype="application/json",
)
print(f" Uploaded to: {remote.mount_relative_path}")
# List files
print("\nListing files...")
result = session.experimental.files.list(path="/")
print(f" Items: {len(result.items)}")
for item in result.items:
print(f" - {item.mount_relative_path} ({item.size} bytes)")
# Download the file
print("\nDownloading file...")
downloaded = session.experimental.files.download(remote.mount_relative_path)
content = downloaded.text()
print(f" Content: {content[:80]}...")
# Upload from local file
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
f.write("Hello from local file")
local_path = f.name
try:
print("\nUploading from local path...")
remote2 = session.experimental.files.upload(local_path)
print(f" Uploaded to: {remote2.mount_relative_path}")
finally:
Path(local_path).unlink(missing_ok=True)
# List again
print("\nListing files (after 2nd upload)...")
result2 = session.experimental.files.list(path="/")
print(f" Items: {len(result2.items)}")
for item in result2.items:
print(f" - {item.mount_relative_path} ({item.size} bytes)")
# Delete
print("\nDeleting test files...")
session.experimental.files.delete(remote.mount_relative_path)
session.experimental.files.delete(remote2.mount_relative_path)
print(" Deleted.")
# Search for tools
print("\nSearching for tools...")
search_result = session.search(query="send email")
print(f" Success: {search_result.success}, Results: {len(search_result.results)}")
if search_result.results:
r = search_result.results[0]
print(
f" First result: {r.primary_tool_slugs[:3] if r.primary_tool_slugs else []}"
)
print("\nAll files API operations succeeded.")
if __name__ == "__main__":
main()
@@ -0,0 +1,48 @@
import asyncio
from langchain.agents import create_agent
from langchain_mcp_adapters.client import MultiServerMCPClient
from langchain_openai.chat_models import ChatOpenAI
from composio import Composio
composio = Composio()
session = composio.create(
user_id="user_123",
)
async def main():
try:
mcp_client = MultiServerMCPClient(
{
"composio": {
"transport": "streamable_http",
"url": session.mcp.url,
"headers": session.mcp.headers,
}
}
)
tools = await mcp_client.get_tools()
agent = create_agent(
tools=tools,
model=ChatOpenAI(model="gpt-4o"),
)
result = await agent.ainvoke(
{
"messages": [
{"role": "user", "content": "Fetch my last email and summarize?"}
]
}
)
print(result)
except Exception as e:
print(e)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,33 @@
from agents import Agent, HostedMCPTool, Runner
from composio import Composio
composio = Composio()
session = composio.create(
user_id="user_123",
)
print(session.mcp)
composio_mcp = HostedMCPTool(
tool_config={
"type": "mcp",
"server_label": "tool_router",
"server_url": session.mcp.url,
"require_approval": "never",
"headers": session.mcp.headers,
}
)
agent = Agent(
name="My Agent",
instructions="You are a helpful assistant that can use the tools provided to you.",
tools=[composio_mcp],
)
result = Runner.run_sync(
starting_agent=agent,
input="Find my last email and summarize it.",
)
print(result.final_output)
+170
View File
@@ -0,0 +1,170 @@
"""
Tool Router preload with OpenAI Agents.
Shows direct tool exposure for:
1. Composio tools via preload["tools"].
2. SDK custom tools via preload=True on the custom tool or toolkit.
3. A nested custom tool override with preload=False inside a preloaded toolkit.
Usage:
COMPOSIO_API_KEY=... OPENAI_API_KEY=... python examples/tool_router/preload.py
"""
import os
from agents import Agent, Runner
from composio_openai_agents import OpenAIAgentsProvider
from pydantic import BaseModel, Field
from composio import Composio
def require_env(name: str) -> str:
value = os.environ.get(name)
if not value:
raise RuntimeError(f"Set {name} before running this example.")
return value
class UserLookupInput(BaseModel):
user_id: str = Field(description="Internal user ID, for example user-1")
class TeamSearchInput(BaseModel):
team: str = Field(description="Team name to search for")
class AccountInput(BaseModel):
user_id: str = Field(description="Internal user ID")
INTERNAL_USERS = {
"user-1": {
"id": "user-1",
"name": "Ada Lovelace",
"email": "ada@example.com",
"team": "platform",
"plan": "enterprise",
},
"user-2": {
"id": "user-2",
"name": "Grace Hopper",
"email": "grace@example.com",
"team": "developer-tools",
"plan": "startup",
},
}
composio = Composio(
api_key=require_env("COMPOSIO_API_KEY"),
base_url=os.environ.get("COMPOSIO_BASE_URL"),
provider=OpenAIAgentsProvider(),
)
require_env("OPENAI_API_KEY")
@composio.experimental.tool(
slug="LOOKUP_INTERNAL_USER",
name="Lookup internal user",
description="Look up an internal demo user profile by user ID.",
preload=True,
)
def lookup_internal_user(input: UserLookupInput, ctx):
user = INTERNAL_USERS.get(input.user_id)
if not user:
raise ValueError(f'User "{input.user_id}" not found')
return user
@composio.experimental.tool(
slug="SEARCH_INTERNAL_USERS",
name="Search internal users",
description=(
"Search demo internal users by team. This custom tool is search-only "
"because preload is not enabled."
),
)
def search_internal_users(input: TeamSearchInput, ctx):
return {
"results": [
user for user in INTERNAL_USERS.values() if user["team"] == input.team
]
}
internal_admin = composio.experimental.Toolkit(
slug="INTERNAL_ADMIN",
name="Internal admin",
description="Demo internal administration tools.",
preload=True,
)
@internal_admin.tool(
slug="GET_ACCOUNT_HEALTH",
name="Get account health",
description="Return internal account health details for a user.",
)
def get_account_health(input: AccountInput, ctx):
return {
"user_id": input.user_id,
"health": "green",
"open_incidents": 0,
"renewal_risk": "low",
}
@internal_admin.tool(
slug="GET_ACCOUNT_AUDIT_LOG",
name="Get account audit log",
description=(
"Return internal account audit events. This overrides the toolkit "
"preload default and remains search-only."
),
preload=False,
)
def get_account_audit_log(input: AccountInput, ctx):
return {
"user_id": input.user_id,
"events": ["login", "settings_viewed"],
}
session = composio.create(
user_id="preload-example-user",
toolkits=["hackernews"],
tools={"hackernews": {"enable": ["HACKERNEWS_GET_USER"]}},
preload={"tools": ["HACKERNEWS_GET_USER"]},
manage_connections=False,
experimental={
"custom_tools": [lookup_internal_user, search_internal_users],
"custom_toolkits": [internal_admin],
},
)
tools = session.tools()
tool_names = [tool.name for tool in tools]
assert "HACKERNEWS_GET_USER" in tool_names
assert "LOCAL_LOOKUP_INTERNAL_USER" in tool_names
assert "LOCAL_INTERNAL_ADMIN_GET_ACCOUNT_HEALTH" in tool_names
assert "LOCAL_SEARCH_INTERNAL_USERS" not in tool_names
assert "LOCAL_INTERNAL_ADMIN_GET_ACCOUNT_AUDIT_LOG" not in tool_names
print("Direct tools exposed to the agent:")
for tool in tools:
print(f"- {tool.name}")
agent = Agent(
name="Preload Demo Agent",
instructions="Use the provided tools to perform the task.",
model=os.environ.get("OPENAI_MODEL", "gpt-5.5"),
tools=tools,
)
prompt = (
'Look up Hacker News user "pg", then look up internal user user-1 and '
"their account health. Summarize the useful facts."
)
result = Runner.run_sync(agent, prompt)
print(result.final_output)
@@ -0,0 +1,47 @@
"""
Tool Router - Session Update Example
Demonstrates using session.update() to modify a session's configuration
after creation — e.g. adding toolkits, changing workbench settings, or
updating preload config without creating a new session.
"""
import os
from composio import Composio
composio = Composio(base_url=os.environ.get("COMPOSIO_BASE_URL"))
# Create a session with gmail only
session = composio.create(
user_id="session-update-demo",
toolkits=["gmail"],
manage_connections=False,
)
print(f"Session created: {session.session_id}")
print(f"Preload: {session.preload}")
# Update: add github toolkit, enable workbench, and preload a tool
session.update(
toolkits={"enable": ["gmail", "github"]},
workbench={"enable": True, "sandbox_size": "medium"},
preload={"tools": ["GITHUB_CREATE_AN_ISSUE"]},
)
print("\nAfter update:")
print(f"Preload: {session.preload}")
# Update: disable workbench entirely
session.update(
workbench={"enable": False},
)
print("\nAfter disabling workbench: done")
# Update: clear manage_connections by passing None
session.update(
manage_connections=None,
)
print("\nAfter clearing manage_connections: done")
@@ -0,0 +1,73 @@
"""
Tool Router - MCP (Model Context Protocol) Example with OpenAI Agents
This example demonstrates how to use Tool Router with MCP servers and OpenAI Agents provider.
The MCP server provides a standardized way to access tools across different platforms,
and OpenAI Agents provider allows you to use these tools with OpenAI's agent framework.
"""
import asyncio
import os
from agents import Agent, HostedMCPTool, Runner
from composio_openai_agents import OpenAIAgentsProvider
from composio import Composio
async def main():
# Initialize Composio with OpenAI Agents provider
# Set COMPOSIO_API_KEY environment variable or pass api_key parameter
api_key = os.environ.get("COMPOSIO_API_KEY")
if not api_key:
print("Error: COMPOSIO_API_KEY environment variable not set")
print("Please set it using: export COMPOSIO_API_KEY='your_api_key'")
return
composio = Composio(api_key=api_key, provider=OpenAIAgentsProvider())
# Create a tool router session
session = composio.create(
user_id="user_123",
toolkits=["github", "gmail"],
)
mcpTool = HostedMCPTool(
tool_config={
"type": "mcp",
"server_label": "tool_router",
"server_url": session.mcp.url,
"require_approval": "never",
"headers": session.mcp.headers,
}
)
print(f"Session created: {session.session_id}")
print(f"MCP Server URL: {session.mcp.url}")
print(f"MCP Server Type: {session.mcp.type}")
# Create an agent with the tools from tool router
agent = Agent(
name="MCP Tool Router Agent",
instructions=(
"You are a helpful assistant that can use GitHub and Gmail tools "
"through the MCP (Model Context Protocol) server. "
"Help users with their GitHub and email tasks."
),
tools=[mcpTool],
)
# Run the agent with a sample task
print("\n--- Running Agent with Tool Router Tools ---")
result = await Runner.run(
starting_agent=agent,
input=(
"List my recent GitHub repositories and tell me about them. "
"If successful, respond with a summary of what you found."
),
)
print(f"\nAgent Result: {result.final_output}")
if __name__ == "__main__":
asyncio.run(main())
+27
View File
@@ -0,0 +1,27 @@
"""
Tool Router - Toolkits Example
This example demonstrates how to retrieve available toolkits
and their connection status in a Tool Router session.
"""
from composio import Composio
composio = Composio()
# Create a tool router session
# When manage_connections is enabled, tools for managing connections are included
session = composio.create(
user_id="pg-test-37ee710c-d5be-4775-91f2-a8e06b937d9b",
manage_connections=True,
)
print(f"Session created: {session.session_id}")
print(f"MCP Server: {session.mcp.url}")
# Get available toolkits for the session
# This returns information about all toolkits available to the user
toolkits = session.toolkits()
print("\nAvailable toolkits:")
print(toolkits)
+91
View File
@@ -0,0 +1,91 @@
"""
Tool Router - OpenAI Agents Example
This example demonstrates how to use Tool Router with OpenAI Agents framework.
OpenAI Agents provides a powerful way to build AI agents that can use tools
and execute complex workflows.
"""
import asyncio
from agents import Agent, Runner
from composio_openai_agents import OpenAIAgentsProvider
from composio import Composio, after_execute, before_execute
from composio.types import ToolExecuteParams, ToolExecutionResponse
async def main():
# Initialize Composio with OpenAI Agents provider
composio = Composio(provider=OpenAIAgentsProvider())
# Create a tool router session for a specific user
# This creates an isolated session with tools for the specified toolkits
session = composio.create(
user_id="user_123",
toolkits=["gmail"],
)
# Define logging modifiers to track tool calls
# Pass empty lists to apply to all tools
@before_execute(tools=[])
def log_before_execute(
tool: str,
toolkit: str,
params: ToolExecuteParams,
) -> ToolExecuteParams:
"""Log tool execution before it runs."""
print(f"🔧 Executing tool: {toolkit}.{tool}")
print(f" Arguments: {params.get('arguments', {})}")
return params
@after_execute(tools=[])
def log_after_execute(
tool: str,
toolkit: str,
response: ToolExecutionResponse,
) -> ToolExecutionResponse:
"""Log tool execution after it completes."""
print(f"✅ Completed tool: {toolkit}.{tool}")
if "data" in response:
print(f" Response data: {response['data']}")
return response
# Get tools wrapped for OpenAI Agents with logging modifiers
# These tools are ready to be used with the OpenAI Agents framework
tools = session.tools(modifiers=[log_before_execute, log_after_execute])
print(f"\nAvailable tools: {len(tools)}")
# Create an agent with the tools from the session
agent = Agent(
name="Gmail Assistant",
instructions=(
"You are a helpful assistant that helps users manage their "
"Gmail accounts. You can check emails, "
"send messages, and perform various actions on the Gmail platform."
),
tools=tools,
)
# Define the task
task = "Fetch my last email from gmail and summarize it"
print(f"\nTask: {task}")
print("\nAgent working...\n")
# Run the agent
result = await Runner.run(
starting_agent=agent,
input=task,
)
# Print the final output
print("\n" + "=" * 50)
print("RESULT:")
print("=" * 50)
print(result.final_output)
if __name__ == "__main__":
asyncio.run(main())
+19
View File
@@ -0,0 +1,19 @@
from composio import Composio
composio = Composio()
# List all toolkits
toolkits = composio.toolkits.get()
print(toolkits)
# Get a toolkit by slug
toolkit = composio.toolkits.get(slug="github")
print(toolkit)
# List all toolkits categories
categories = composio.toolkits.list_categories()
print(categories)
# Authorize a user to a toolkit
connection_request = composio.toolkits.authorize(user_id="123", toolkit="github")
print(connection_request)
+36
View File
@@ -0,0 +1,36 @@
from composio import Composio
composio = Composio()
# Get all tools by toolkit
tools = composio.tools.get(user_id="default", toolkits=["GITHUB"])
# Get all tools by search
tools = composio.tools.get(user_id="default", search="user")
# Get all tools by toolkit and search
tools = composio.tools.get(user_id="default", toolkits=["GITHUB"], search="star")
# execute tool
response = composio.tools.execute(
user_id="default",
slug="HACKERNEWS_GET_USER",
arguments={"username": "pg"},
version="20251023_00",
)
print(response)
# execute proxy call (github)
proxy_response = composio.tools.proxy(
endpoint="/repos/composiohq/composio/issues/1",
method="GET",
connected_account_id="ac_1234", # use connected account for github
parameters=[
{
"name": "Accept",
"value": "application/vnd.github.v3+json",
"type": "header",
},
],
)
print(proxy_response)
+95
View File
@@ -0,0 +1,95 @@
from composio import Composio
composio = Composio()
# List all triggers
triggers = composio.triggers.list()
print(triggers)
# List all active triggers
active_triggers = composio.triggers.list_active()
print(active_triggers)
# List all triggers enums
trigger_enums = composio.triggers.list_enum()
print(trigger_enums)
# Get a trigger by id
trigger = composio.triggers.get_type(slug="GITHUB_COMMIT_EVENT")
print(trigger)
# Create a trigger instance
instance = composio.triggers.create(
slug="GITHUB_COMMIT_EVENT",
connected_account_id="123",
trigger_config={
"repo": "composio",
"owner": "composiohq",
},
)
print(instance)
# Or use user ID
instance = composio.triggers.create(
slug="GITHUB_COMMIT_EVENT",
user_id="user@email.com",
trigger_config={
"repo": "composio",
"owner": "composiohq",
},
)
print(instance)
# Disable a trigger instance
disabled_instance = composio.triggers.disable(trigger_id="123")
print(disabled_instance)
# Enable a trigger instance
enabled_instance = composio.triggers.enable(trigger_id="123")
print(enabled_instance)
# Delete a trigger instance
deleted_instance = composio.triggers.delete(trigger_id="123")
print(deleted_instance)
# Verify a webhook (example in Flask)
# @app.route('/webhook', methods=['POST'])
# def webhook():
# try:
# result = composio.triggers.verify_webhook(
# id=request.headers.get('webhook-id', ''),
# payload=request.get_data(as_text=True),
# signature=request.headers.get('webhook-signature', ''),
# timestamp=request.headers.get('webhook-timestamp', ''),
# secret=os.environ['COMPOSIO_WEBHOOK_SECRET'],
# )
#
# # Result contains:
# # - version: WebhookVersion (V1, V2, or V3)
# # - payload: Normalized TriggerEvent
# # - raw_payload: Original parsed payload
# print(f"Webhook version: {result['version']}")
# print(f"Trigger: {result['payload']['trigger_slug']}")
# return 'OK', 200
# except WebhookSignatureVerificationError:
# return 'Unauthorized', 401
# Subscribe to a trigger
subscription = composio.triggers.subscribe()
# Define a callback functions
@subscription.handle(toolkit="GITHUB")
def handle_github_event(data):
print(data)
@subscription.handle(toolkit="SLACK")
def handle_slack_event(data):
print(data)
subscription.wait_forever()
+174
View File
@@ -0,0 +1,174 @@
import nox
from nox.sessions import Session
nox.options.default_venv_backend = "uv"
# Modules for both ruff and mypy
modules_for_mypy = [
"composio/",
"providers/",
"tests/",
"scripts/",
]
# Modules for ruff only (includes examples)
modules_for_ruff = [
"composio/",
"providers/",
"tests/",
"examples/",
"scripts/",
]
# Type stubs and provider libraries installed solely so mypy can resolve
# imports across `providers/` and `tests/`. These can't live in the locked
# `dev` dependency group: the provider libraries (crewai, langchain,
# llama-index, ...) would drag conflicting transitive deps into the root
# resolution. Shared test/lint tooling (ruff, pytest, fastapi, semver,
# langchain-openai) is sourced from the `dev` group in pyproject.toml via
# `--group dev`, so every package is declared in exactly one place.
type_stubs = [
"types-requests==2.33.0.20260518",
"types-protobuf==7.34.1.20260518",
"anthropic==0.111.0",
"crewai==0.134.0",
"langchain==1.3.10",
"langgraph==1.2.6",
"llama-index==0.14.22",
"openai-agents==0.17.6",
"google-cloud-aiplatform==1.158.0",
]
mypy = "mypy==2.1.0"
ruff = [
"ruff",
"--config",
"config/ruff.toml",
]
@nox.session
def fmt(session: Session):
"""Format code"""
session.install("--group", "dev")
session.run("ruff", "check", "--select", "I", "--fix", *modules_for_ruff)
session.run("ruff", "format", *modules_for_ruff)
@nox.session
def chk(session: Session):
"""Check for linter and type issues"""
session.install(".", "--group", "dev", mypy, *type_stubs)
session.run(*ruff, "check", *modules_for_ruff)
for module in modules_for_mypy:
session.run("mypy", "--config-file", "config/mypy.ini", module)
@nox.session
def fix(session: Session):
"""Fix linter issues"""
session.install("--group", "dev")
session.run(*ruff, "check", "--fix", *modules_for_ruff)
@nox.session
def tst(session: Session):
"""Run the Python unit test suite."""
session.install(".", "--group", "dev")
session.install("./providers/langchain")
session.install("./providers/autogen")
test_paths = session.posargs or ["tests/"]
session.run("pytest", *test_paths, "-v", "--tb=short")
@nox.session
def snt(session: Session):
"""Run fast sanity tests for imports and SDK initialization."""
session.install(".", "--group", "dev")
test_paths = session.posargs or ["tests/test_imports.py", "tests/test_sdk.py"]
session.run("pytest", *test_paths, "-v", "--tb=short")
@nox.session
def type_inference(session: Session):
"""Type check provider return type inference tests.
This session verifies that mypy correctly infers provider-specific return
types from `Composio.tools.get()` when using @overload signatures.
Unlike the `chk` session, this installs all provider packages so mypy can
resolve the provider types and verify the type inference works correctly.
"""
# Install core SDK, shared dev tooling, and mypy
session.install(".", "--group", "dev", mypy, *type_stubs)
# Install all provider packages for type resolution
session.install(
"./providers/anthropic",
"./providers/autogen",
"./providers/claude_agent_sdk",
"./providers/crewai",
"./providers/gemini",
"./providers/google",
"./providers/google_adk",
"./providers/langchain",
"./providers/langgraph",
"./providers/llamaindex",
"./providers/openai",
"./providers/openai_agents",
)
# Run mypy on type inference test files
# Note: explicitly listed files are checked even if they match the exclude pattern in `mypy.ini`
session.run(
"mypy",
"--config-file",
"config/mypy.ini",
"tests/test_type_inference.py",
"tests/test_type_inference_anthropic.py",
"tests/test_type_inference_autogen.py",
"tests/test_type_inference_claude_agent_sdk.py",
"tests/test_type_inference_crewai.py",
"tests/test_type_inference_gemini.py",
"tests/test_type_inference_google.py",
"tests/test_type_inference_google_adk.py",
"tests/test_type_inference_langchain.py",
"tests/test_type_inference_langgraph.py",
"tests/test_type_inference_llamaindex.py",
"tests/test_type_inference_openai_agents.py",
)
# Modules scanned for dead code (source only, no tests/examples/scripts)
modules_for_vulture = [
"composio/",
"providers/",
]
@nox.session(name="dead_code")
def dead_code(session: Session):
"""Report likely-dead code (unused functions, classes, variables).
Report-only: vulture exits 1 when it finds candidates, but we do not fail
the session on that so it never blocks CI on false positives. Vet the
output by hand; suppress confirmed false positives by adding the symbol to
``config/vulture_allowlist.py``. Ruff already covers unused imports (F401)
and unused locals (F841) in the `chk` session; vulture adds cross-module
unused functions/classes that ruff cannot see.
"""
session.install("vulture>=2.14")
session.run(
"vulture",
*modules_for_vulture,
"config/vulture_allowlist.py",
"--min-confidence",
"80",
"--exclude",
"*/build/*,*/dist/*,*/.venv/*,*/.nox/*,*/__pycache__/*",
# vulture exit codes: 0 = clean, 3 = candidates found. Report-only, so
# neither should fail the session. (1/2 are real usage/parse errors.)
success_codes=[0, 3],
)
+31
View File
@@ -0,0 +1,31 @@
# AGENTS.md
Python provider guidance.
## Scope
Each child directory is a Python provider package that adapts Composio to a framework or agent runtime.
## Skill Routing
Use `python-providers` for provider implementation and `python-testing` for nox/pytest verification. Use `cross-sdk-parity` when the provider mirrors a TypeScript package.
## Commands
Run from `python/`:
```bash
make create-provider name=<provider-name>
make create-provider name=<provider-name> agentic=true
make chk
make tst
make type_inference
```
## Rules
- Keep provider dependencies in the provider package metadata unless shared tooling needs them.
- Preserve Python naming conventions and public import paths.
- Add provider tests and type-inference coverage when public return types change.
- New providers or renamed provider packages usually need explicit entries in `python/noxfile.py`'s `type_inference` install list and checked-file list.
- Verify package metadata before release-facing changes.
+73
View File
@@ -0,0 +1,73 @@
# composio-anthropic
Adapts Composio tools to the Claude Messages API tool format and executes the tool calls Claude returns.
## Installation
```bash
pip install composio composio-anthropic anthropic
```
Set `COMPOSIO_API_KEY` (create one at https://dashboard.composio.dev/settings) and `ANTHROPIC_API_KEY` in your environment.
## Quickstart
`AnthropicProvider` is non-agentic: Claude returns `tool_use` blocks, `handle_tool_calls` executes them, and you send the results back as `tool_result` blocks.
```python
import json
import anthropic
from composio import Composio
from composio_anthropic import AnthropicProvider
composio = Composio(provider=AnthropicProvider())
client = anthropic.Anthropic()
# Create a session for your user
session = composio.create(user_id="user_123")
tools = session.tools()
messages = [
{"role": "user", "content": "Send an email to john@example.com with the subject 'Hello' and body 'Hello from Composio!'"}
]
response = client.messages.create(
model="claude-opus-4-6",
max_tokens=4096,
tools=tools,
messages=messages,
)
# Agentic loop: keep executing tool calls until the model responds with text
while response.stop_reason == "tool_use":
tool_use_blocks = [block for block in response.content if block.type == "tool_use"]
results = composio.provider.handle_tool_calls(user_id="user_123", response=response)
messages.append({"role": "assistant", "content": response.content})
messages.append({
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": tool_use_blocks[i].id, "content": json.dumps(result)}
for i, result in enumerate(results)
]
})
response = client.messages.create(
model="claude-opus-4-6",
max_tokens=4096,
tools=tools,
messages=messages,
)
# Print final response
for block in response.content:
if block.type == "text":
print(block.text)
```
`handle_tool_calls` extracts every `tool_use` block from the response, executes the matching Composio tools, and returns the raw results in order. Claude occasionally emits tool input as a JSON string instead of an object; the provider normalizes this before execution.
Building on the Claude Agent SDK instead of the Messages API? Use [`composio-claude-agent-sdk`](../claude_agent_sdk).
## Links
- Anthropic provider docs: https://docs.composio.dev/docs/providers/anthropic
- Composio docs: https://docs.composio.dev
@@ -0,0 +1,29 @@
"""
Anthropic claude demo.7
"""
import anthropic
from composio_anthropic import AnthropicProvider
from composio import Composio
# Initialize tools.
anthropic_client = anthropic.Anthropic()
composio = Composio(provider=AnthropicProvider())
# Get GitHub tools that are pre-configured
tools = composio.tools.get(user_id="default", toolkits=["GITHUB"])
# Get response from the LLM
response = anthropic_client.messages.create(
model="claude-3-opus-20240229",
max_tokens=1024,
tools=tools,
messages=[
{"role": "user", "content": "Star me composiohq/composio repo in github."},
],
)
# Execute the function calls.
result = composio.provider.handle_tool_calls(user_id="default", response=response)
print(result)
@@ -0,0 +1,3 @@
from composio_anthropic.provider import AnthropicProvider
__all__ = ("AnthropicProvider",)

Some files were not shown because too many files have changed in this diff Show More