Revert "🪓 langchain-arcade" (#760)

Reverts ArcadeAI/arcade-mcp#759

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Adds a new LangChain/LangGraph integration layer that wraps and
executes Arcade tools, including authorization and error/interrupt
handling; bugs here could impact tool execution semantics for adopters.
Changes are mostly additive and scoped to `contrib/langchain`.
> 
> **Overview**
> Re-introduces a standalone `contrib/langchain` Python package
(`langchain-arcade`) to expose Arcade tools as LangChain
`StructuredTool`s.
> 
> Adds sync/async `ToolManager` implementations plus utilities to
generate Pydantic arg schemas from `ToolDefinition`, optionally rewrite
tool names (underscores vs dots), and handle authorization via LangGraph
`NodeInterrupt` or structured error responses.
> 
> Includes packaging/dev scaffolding (`pyproject.toml`, `tox.ini`,
`Makefile`, `.gitignore`, `LICENSE`, `README`) and a comprehensive test
suite covering manager behaviors and auth flows.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
abd23b6d954470cb1e7376158468c0e59cdc7d7a. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
This commit is contained in:
Mateo Torres 2026-02-02 21:31:26 +00:00 committed by GitHub
parent 2dc810673d
commit 44563fce5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 2432 additions and 0 deletions

175
contrib/langchain/.gitignore vendored Normal file
View file

@ -0,0 +1,175 @@
.DS_Store
credentials.yaml
docker/credentials.yaml
*.lock
# example data
examples/data
scratch
docs/source
# From https://raw.githubusercontent.com/github/gitignore/main/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
# 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/#use-with-ide
.pdm.toml
# 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/

21
contrib/langchain/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025, Arcade AI
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,47 @@
.PHONY: help
help:
@echo "🛠️ github Commands:\n"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.PHONY: install
install: ## Install the uv environment and install all packages with dependencies
@echo "🚀 Creating virtual environment and installing all packages using uv"
@uv sync --active --all-extras --no-sources
@uv run pre-commit install
@echo "✅ All packages and dependencies installed via uv"
.PHONY: build
build: clean-build ## Build wheel file using uv
@echo "🚀 Creating wheel file"
uv build
.PHONY: clean-build
clean-build: ## clean build artifacts
@echo "🗑️ Cleaning dist directory"
rm -rf dist
.PHONY: test
test: ## Test the code with pytest
@echo "🚀 Testing code: Running pytest"
@uv run pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
.PHONY: coverage
coverage: ## Generate coverage report
@echo "coverage report"
coverage report
@echo "Generating coverage report"
coverage html
.PHONY: bump-version
bump-version: ## Bump the version in the pyproject.toml file by a patch version
@echo "🚀 Bumping version in pyproject.toml"
uv version --bump patch
.PHONY: check
check: ## Run code quality tools.
@echo "🚀 Linting code: Running pre-commit"
@uv run pre-commit run -a
@echo "🚀 Static type checking: Running mypy"
@uv run mypy --config-file=pyproject.toml

175
contrib/langchain/README.md Normal file
View file

@ -0,0 +1,175 @@
<h3 align="center">
<a name="readme-top"></a>
<img
src="https://docs.arcade.dev/images/logo/arcade-logo.png"
>
</h3>
<div align="center">
<h3>Arcade Langchain Integration</h3>
<a href="https://github.com/arcadeai/langchain-arcade/blob/main/LICENSE">
<img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License">
</a>
<a href="https://pepy.tech/project/langchain-arcade">
<img src="https://static.pepy.tech/badge/langchain-arcade" alt="Downloads">
<a href="https://pypi.org/project/langchain-arcade/">
<img src="https://img.shields.io/pypi/v/langchain-arcade.svg" alt="PyPI">
</a>
</a>
</div>
<p align="center">
<a href="https://docs.arcade.dev" target="_blank">Arcade Documentation</a>
<a href="https://docs.arcade.dev/en/resources/integrations" target="_blank">Servers</a>
<a href="https://github.com/ArcadeAI/arcade-py" target="_blank">Python Client</a>
<a href="https://github.com/ArcadeAI/arcade-js" target="_blank">JavaScript Client</a>
</p>
## Overview
`langchain-arcade` allows you to use Arcade tools in your LangChain and LangGraph applications. This integration provides a simple way to access Arcade's extensive toolkit ecosystem, including tools for search, email, document processing, and more.
## Installation
```bash
pip install langchain-arcade
```
## Basic Usage
### 1. Initialize the Tool Manager
The `ToolManager` is the main entry point for working with Arcade tools in LangChain:
```python
import os
from langchain_arcade import ToolManager
# Initialize with your API key
manager = ToolManager(api_key=os.environ["ARCADE_API_KEY"])
# Initialize with specific tools or toolkits
tools = manager.init_tools(
tools=["Web.ScrapeUrl"], # Individual tools
toolkits=["Search"] # All tools from a toolkit
)
# Convert to LangChain tools
langchain_tools = manager.to_langchain()
```
### 2. Use with LangGraph
```bash
pip install langgraph
```
Here's a simple example of using Arcade tools with LangGraph:
```python
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import MemorySaver
from langgraph.prebuilt import create_react_agent
# Create a LangGraph agent
model = ChatOpenAI(model="gpt-4o")
memory = MemorySaver()
graph = create_react_agent(model, tools, checkpointer=memory)
config = {"configurable": {"thread_id": "1", "user_id": "user@example.com"}}
user_input = {"messages": [("user", "List my important emails")]}
for chunk in graph.stream(user_input, config, stream_mode="values"):
print(chunk["messages"][-1].content)
```
## Using Tools with Authorization in LangGraph
Many Arcade tools require user authorization. Here's how to handle it:
### 1. Using with prebuilt agents
```python
import os
from langchain_arcade import ToolManager
from langchain_openai import ChatOpenAI
from langgraph.prebuilt import create_react_agent
# Initialize tools
manager = ToolManager(api_key=os.environ["ARCADE_API_KEY"])
manager.init_tools(toolkits=["Github"])
tools = manager.to_langchain(use_interrupts=True)
# Create agent
model = ChatOpenAI(model="gpt-4o")
graph = create_react_agent(model, tools)
# Run the agent with the "user_id" field in the config
# IMPORTANT the "user_id" field is required for tools that require user authorization
config = {"configurable": {"user_id": "user@lgexample.com"}}
user_input = {"messages": [("user", "Star the arcadeai/arcade-mcp repository on GitHub")]}
for chunk in graph.stream(user_input, config, debug=True):
if chunk.get("__interrupt__"):
# print the authorization url
print(chunk["__interrupt__"][0].value)
# visit the URL to authorize the tool
# once you have authorized the tool, you can run again and the agent will continue
elif chunk.get("agent"):
print(chunk["agent"]["messages"][-1].content)
# see the functional example for continuing the agent after authorization
# and for handling authorization errors gracefully
```
See the Functional examples in the [examples directory](https://github.com/ArcadeAI/arcade-mcp/tree/main/examples/langchain) that continue the agent after authorization and handle authorization errors gracefully.
### Async Support
For asynchronous applications, use `AsyncToolManager`:
```python
import asyncio
from langchain_arcade import AsyncToolManager
async def main():
manager = AsyncToolManager(api_key=os.environ["ARCADE_API_KEY"])
await manager.init_tools(toolkits=["Google"])
tools = await manager.to_langchain()
# Use tools with async LangChain/LangGraph components
asyncio.run(main())
```
## Tool Authorization Flow
Many Arcade tools require user authorization. This can be handled in many ways but the `ToolManager` provides a simple flow that can be used with prebuilt agents and also the functional API. The typical flow is:
1. Attempt to use a tool that requires authorization
2. Check the state for interrupts from the `NodeInterrupt` exception (or Command)
3. Call `manager.authorize(tool_name, user_id)` to get an authorization URL
4. Present the URL to the user
5. Call `manager.wait_for_auth(auth_response.id)` to wait for completion
6. Resume the agent execution
## Available Toolkits
Arcade provides many toolkits including:
- `Search`: Google search, Bing search
- `Google`: Gmail, Google Drive, Google Calendar
- `Web`: Crawling, scraping, etc
- `Github`: Repository operations
- `Slack`: Sending messages to Slack
- `Linkedin`: Posting to Linkedin
- `X`: Posting and reading tweets on X
- And many more
For a complete list, see the [Arcade Toolkits documentation](https://docs.arcade.dev/en/resources/integrations).
## More Examples
For more examples, see the [examples directory](https://github.com/ArcadeAI/arcade-mcp/tree/main/examples/langchain).

View file

@ -0,0 +1,7 @@
from .manager import ArcadeToolManager, AsyncToolManager, ToolManager
__all__ = [
"ArcadeToolManager", # Deprecated
"AsyncToolManager",
"ToolManager",
]

View file

@ -0,0 +1,313 @@
from typing import Any, Callable, Union
from arcadepy import NOT_GIVEN, Arcade, AsyncArcade
from arcadepy.types import ExecuteToolResponse, ToolDefinition
from langchain_core.runnables import RunnableConfig
from langchain_core.tools import StructuredTool
from pydantic import BaseModel, Field, create_model
# Check if LangGraph is enabled
LANGGRAPH_ENABLED = True
try:
from langgraph.errors import NodeInterrupt
except ImportError:
LANGGRAPH_ENABLED = False
# Mapping of Arcade value types to Python types
TYPE_MAPPING = {
"string": str,
"number": float,
"integer": int,
"boolean": bool,
"array": list,
"json": dict,
}
def get_python_type(val_type: str) -> Any:
"""Map Arcade value types to Python types.
Args:
val_type: The value type as a string.
Returns:
Corresponding Python type.
"""
_type = TYPE_MAPPING.get(val_type)
if _type is None:
raise ValueError(f"Invalid value type: {val_type}")
return _type
def tool_definition_to_pydantic_model(tool_def: ToolDefinition) -> type[BaseModel]:
"""Convert a ToolDefinition's inputs into a Pydantic BaseModel.
Args:
tool_def: The ToolDefinition object to convert.
Returns:
A Pydantic BaseModel class representing the tool's input schema.
"""
try:
fields: dict[str, Any] = {}
for param in tool_def.input.parameters or []:
param_type = get_python_type(param.value_schema.val_type)
if param_type == list and param.value_schema.inner_val_type: # noqa: E721
inner_type: type[Any] = get_python_type(
param.value_schema.inner_val_type
)
param_type = list[inner_type] # type: ignore[valid-type]
param_description = param.description or "No description provided."
default = ... if param.required else None
fields[param.name] = (
param_type,
Field(default=default, description=param_description),
)
return create_model(f"{tool_def.name}Args", **fields)
except ValueError as e:
raise ValueError(
f"Error converting {tool_def.name} parameters into pydantic model for langchain: {e}"
)
def process_tool_execution_response(
execute_response: ExecuteToolResponse, tool_name: str, langgraph: bool
) -> Any:
"""Process the response from tool execution and handle errors appropriately.
Args:
execute_response: The response from tool execution
tool_name: The name of the tool that was executed
langgraph: Whether LangGraph-specific behavior is enabled
Returns:
The output value on success, or error details on failure
"""
if execute_response.success and execute_response.output is not None:
return execute_response.output.value
# Extract detailed error information
error_details = {
"error": "Unknown error occurred",
"tool": tool_name,
}
if (
execute_response.output is not None
and execute_response.output.error is not None
):
error = execute_response.output.error
error_message = (
str(error.message) if hasattr(error, "message") else "Unknown error"
)
error_details["error"] = error_message
# Add all non-None optional error fields to the details
if (
hasattr(error, "additional_prompt_content")
and error.additional_prompt_content is not None
):
error_details["additional_prompt_content"] = error.additional_prompt_content
if hasattr(error, "can_retry") and error.can_retry is not None:
error_details["can_retry"] = str(error.can_retry)
if hasattr(error, "developer_message") and error.developer_message is not None:
error_details["developer_message"] = str(error.developer_message)
if hasattr(error, "retry_after_ms") and error.retry_after_ms is not None:
error_details["retry_after_ms"] = str(error.retry_after_ms)
if langgraph:
raise NodeInterrupt(error_details)
return error_details
def create_tool_function(
client: Arcade,
tool_name: str,
tool_def: ToolDefinition,
args_schema: type[BaseModel],
langgraph: bool = False,
) -> Callable:
"""Create a callable function to execute an Arcade tool.
Args:
client: The Arcade client instance.
tool_name: The name of the tool to wrap.
tool_def: The ToolDefinition of the tool to wrap.
args_schema: The Pydantic model representing the tool's arguments.
langgraph: Whether to enable LangGraph-specific behavior.
Returns:
A callable function that executes the tool.
"""
if langgraph and not LANGGRAPH_ENABLED:
raise ImportError(
"LangGraph is not installed. Please install it to use this feature."
)
requires_authorization = (
tool_def.requirements is not None
and tool_def.requirements.authorization is not None
)
def tool_function(config: RunnableConfig, **kwargs: Any) -> Any:
"""Execute the Arcade tool with the given parameters.
Args:
config: RunnableConfig containing execution context.
**kwargs: Tool input arguments.
Returns:
The output from the tool execution.
"""
user_id = config.get("configurable", {}).get("user_id") if config else None
if requires_authorization:
if user_id is None:
error_message = f"user_id is required to run {tool_name}"
if langgraph:
raise NodeInterrupt(error_message)
return {"error": error_message}
# Authorize the user for the tool
auth_response = client.tools.authorize(tool_name=tool_name, user_id=user_id)
if auth_response.status != "completed":
auth_message = (
f"Please use the following link to authorize: {auth_response.url}"
)
if langgraph:
raise NodeInterrupt(auth_message)
return {"error": auth_message}
# Execute the tool with provided inputs
execute_response = client.tools.execute(
tool_name=tool_name,
input=kwargs,
user_id=user_id if user_id is not None else NOT_GIVEN,
)
return process_tool_execution_response(execute_response, tool_name, langgraph)
return tool_function
def wrap_arcade_tool(
client: Union[Arcade, AsyncArcade],
tool_name: str,
tool_def: ToolDefinition,
langgraph: bool = False,
) -> StructuredTool:
"""Wrap an Arcade `ToolDefinition` as a LangChain `StructuredTool`.
Args:
client: The Arcade client instance.
tool_name: The name of the tool to wrap.
tool_def: The ToolDefinition object to wrap.
langgraph: Whether to enable LangGraph-specific behavior.
Returns:
A StructuredTool instance representing the Arcade tool.
"""
description = tool_def.description or "No description provided."
# Create a Pydantic model for the tool's input arguments
args_schema = tool_definition_to_pydantic_model(tool_def)
# Create the action function
if isinstance(client, Arcade):
action_func = create_tool_function(
client=client,
tool_name=tool_name,
tool_def=tool_def,
args_schema=args_schema,
langgraph=langgraph,
)
else:
# Use async tool function for AsyncArcade client
action_func = create_async_tool_function(
client=client,
tool_name=tool_name,
tool_def=tool_def,
args_schema=args_schema,
langgraph=langgraph,
)
# Create the StructuredTool instance
return StructuredTool.from_function(
func=action_func,
name=tool_name,
description=description,
args_schema=args_schema,
inject_kwargs={"user_id"},
)
def create_async_tool_function(
client: AsyncArcade,
tool_name: str,
tool_def: ToolDefinition,
args_schema: type[BaseModel],
langgraph: bool = False,
) -> Callable:
"""Create an async callable function to execute an Arcade tool.
Args:
client: The AsyncArcade client instance.
tool_name: The name of the tool to wrap.
tool_def: The ToolDefinition of the tool to wrap.
args_schema: The Pydantic model representing the tool's arguments.
langgraph: Whether to enable LangGraph-specific behavior.
Returns:
An async callable function that executes the tool.
"""
if langgraph and not LANGGRAPH_ENABLED:
raise ImportError(
"LangGraph is not installed. Please install it to use this feature."
)
requires_authorization = (
tool_def.requirements is not None
and tool_def.requirements.authorization is not None
)
async def tool_function(config: RunnableConfig, **kwargs: Any) -> Any:
"""Run the Arcade tool with the given parameters.
Args:
config: RunnableConfig containing execution context.
**kwargs: Tool input arguments.
Returns:
The output from the tool execution.
"""
user_id = config.get("configurable", {}).get("user_id") if config else None
if requires_authorization:
if user_id is None:
error_message = f"user_id is required to run {tool_name}"
if langgraph:
raise NodeInterrupt(error_message)
return {"error": error_message}
# Authorize the user for the tool
auth_response = await client.tools.authorize(
tool_name=tool_name, user_id=user_id
)
if auth_response.status != "completed":
auth_message = (
f"Please use the following link to authorize: {auth_response.url}"
)
if langgraph:
raise NodeInterrupt(auth_message)
return {"error": auth_message}
# Execute the tool with provided inputs
execute_response = await client.tools.execute(
tool_name=tool_name,
input=kwargs,
user_id=user_id if user_id is not None else NOT_GIVEN,
)
return process_tool_execution_response(execute_response, tool_name, langgraph)
return tool_function

View file

@ -0,0 +1,848 @@
import os
import warnings
from collections.abc import Iterator
from typing import Any, Optional, Union
from arcadepy import NOT_GIVEN, Arcade, AsyncArcade
from arcadepy.types import ToolDefinition
from arcadepy.types.shared import AuthorizationResponse
from langchain_core.tools import StructuredTool
from langchain_arcade._utilities import wrap_arcade_tool
ClientType = Union[Arcade, AsyncArcade]
class LangChainToolManager:
"""
Base tool manager for LangChain framework.
Provides a common interface for both synchronous and asynchronous tool managers.
This class handles the storage and retrieval of tool definitions and provides
common functionality used by both synchronous and asynchronous implementations.
"""
def __init__(self) -> None:
self._tools: dict[str, ToolDefinition] = {}
@property
def tools(self) -> list[str]:
"""
Get the list of tools by name in the manager.
Returns:
A list of tool names (strings) currently stored in the manager.
"""
return list(self._tools.keys())
def __len__(self) -> int:
"""Return the number of tools in the manager."""
return len(self._tools)
def _get_client_config(self, **kwargs: Any) -> dict[str, Any]:
"""
Get the client configurations from environment variables and kwargs.
If api_key or base_url are in the kwargs, they will be used.
Otherwise, the environment variables ARCADE_API_KEY and ARCADE_BASE_URL will be used.
If both are provided, the kwargs will take precedence.
Args:
**kwargs: Keyword arguments that may contain api_key and base_url.
Returns:
A dictionary of client configuration parameters.
"""
client_kwargs = {
"api_key": kwargs.get("api_key", os.getenv("ARCADE_API_KEY")),
}
base_url = kwargs.get("base_url", os.getenv("ARCADE_BASE_URL"))
if base_url:
client_kwargs["base_url"] = base_url
return client_kwargs
def _get_tool_definition(self, tool_name: str) -> ToolDefinition:
"""
Get a tool definition by name, raising an error if not found.
Args:
tool_name: The name of the tool to retrieve.
Returns:
The ToolDefinition for the specified tool.
Raises:
ValueError: If the tool is not found in the manager.
"""
try:
return self._tools[tool_name]
except KeyError:
raise ValueError(f"Tool '{tool_name}' not found in this manager instance")
def __getitem__(self, tool_name: str) -> ToolDefinition:
"""
Get a tool definition by name using dictionary-like access.
Args:
tool_name: The name of the tool to retrieve.
Returns:
The ToolDefinition for the specified tool.
Raises:
ValueError: If the tool is not found in the manager.
"""
return self._get_tool_definition(tool_name)
def requires_auth(self, tool_name: str) -> bool:
"""
Check if a tool requires authorization.
Args:
tool_name: The name of the tool to check.
Returns:
True if the tool requires authorization, False otherwise.
"""
tool_def = self._get_tool_definition(tool_name)
if tool_def.requirements is None:
return False
return tool_def.requirements.authorization is not None
class ToolManager(LangChainToolManager):
"""
Synchronous Arcade tool manager for LangChain framework.
This class wraps Arcade tools as LangChain StructuredTool objects for integration
with synchronous operations.
Example:
>>> manager = ToolManager(api_key="your-api-key")
>>> # Initialize with specific tools and toolkits
>>> manager.init_tools(tools=["Search.SearchGoogle"], toolkits=["Weather"])
>>> # Get tools as LangChain StructuredTools
>>> langchain_tools = manager.to_langchain()
>>> # Handle authorization for tools that require it
>>> if manager.requires_auth("Search.SearchGoogle"):
>>> auth_response = manager.authorize("Search.SearchGoogle", "user_123")
>>> manager.wait_for_auth(auth_response.id)
"""
def __init__(self, client: Optional[Arcade] = None, **kwargs: Any) -> None:
"""
Initialize the ToolManager.
Example:
>>> manager = ToolManager(api_key="your-api-key")
>>> # or with an existing client
>>> client = Arcade(api_key="your-api-key")
>>> manager = ToolManager(client=client)
Args:
client: Optional Arcade client instance. If not provided, one will be created.
**kwargs: Additional keyword arguments to pass to the Arcade client if creating one.
Common options include api_key and base_url.
"""
super().__init__()
if client is None:
client_kwargs = self._get_client_config(**kwargs)
client = Arcade(**client_kwargs)
self._client = client
@property
def definitions(self) -> list[ToolDefinition]:
"""
Get the list of tool definitions in the manager.
Returns:
A list of ToolDefinition objects currently stored in the manager.
"""
return list(self._tools.values())
def __iter__(self) -> Iterator[tuple[str, ToolDefinition]]:
"""
Iterate over the tools in the manager as (name, definition) pairs.
Returns:
Iterator over (tool_name, tool_definition) tuples.
"""
yield from self._tools.items()
def to_langchain(
self, use_interrupts: bool = True, use_underscores: bool = True
) -> list[StructuredTool]:
"""
Get the tools in the manager as LangChain StructuredTool objects.
Args:
use_interrupts: Whether to use interrupts for the tool. This is useful
for LangGraph workflows where you need to handle tool
authorization through state transitions.
use_underscores: Whether to use underscores for the tool name instead of periods.
For example, "Search_SearchGoogle" vs "Search.SearchGoogle".
Some model providers like OpenAI work better with underscores.
Returns:
List of StructuredTool instances ready to use with LangChain.
"""
tool_map = _create_tool_map(self.definitions, use_underscores=use_underscores)
return [
wrap_arcade_tool(
self._client, tool_name, definition, langgraph=use_interrupts
)
for tool_name, definition in tool_map.items()
]
def init_tools(
self,
tools: Optional[list[str]] = None,
toolkits: Optional[list[str]] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
raise_on_empty: bool = True,
) -> list[StructuredTool]:
"""
Initialize the tools in the manager and return them as LangChain tools.
This will clear any existing tools in the manager and replace them with
the new tools specified by the tools and toolkits parameters.
Note: In version 2.0+, this method returns a list of StructuredTool objects.
In earlier versions, it returned None.
Example:
>>> manager = ToolManager(api_key="your-api-key")
>>> langchain_tools = manager.init_tools(tools=["Search.SearchGoogle"])
>>> # Use these tools with a LangChain chain or agent
>>> agent = Agent(tools=langchain_tools, llm=llm)
Args:
tools: Optional list of specific tool names to include (e.g., "Search.SearchGoogle").
toolkits: Optional list of toolkit names to include all tools from (e.g., "Search").
limit: Optional limit on the number of tools to retrieve per request.
offset: Optional offset for paginated requests.
raise_on_empty: Whether to raise an error if no tools or toolkits are provided.
Returns:
List of StructuredTool instances ready to use with LangChain.
Raises:
ValueError: If no tools or toolkits are provided and raise_on_empty is True.
"""
tools_list = self._retrieve_tool_definitions(
tools, toolkits, raise_on_empty, limit, offset
)
self._tools = _create_tool_map(tools_list)
return self.to_langchain()
def authorize(self, tool_name: str, user_id: str) -> AuthorizationResponse:
"""
Authorize a user for a specific tool.
Example:
>>> manager = ToolManager(api_key="your-api-key")
>>> manager.init_tools(tools=["Gmail.SendEmail"])
>>> auth_response = manager.authorize("Gmail.SendEmail", "user_123")
>>> # auth_response.auth_url contains the URL for the user to authorize
Args:
tool_name: The name of the tool to authorize.
user_id: The user ID to authorize. This should be a unique identifier for the user.
Returns:
AuthorizationResponse containing authorization details, including the auth_url
that should be presented to the user to complete authorization.
"""
return self._client.tools.authorize(tool_name=tool_name, user_id=user_id)
def is_authorized(self, authorization_id: str) -> bool:
"""
Check if a tool authorization is complete.
Example:
>>> manager = ToolManager(api_key="your-api-key")
>>> auth_response = manager.authorize("Gmail.SendEmail", "user_123")
>>> # After user completes authorization
>>> is_complete = manager.is_authorized(auth_response.id)
Args:
authorization_id: The authorization ID to check. This can be the full AuthorizationResponse
object or just the ID string.
Returns:
True if the authorization is completed, False otherwise.
"""
# Handle case where entire AuthorizationResponse object is passed
if hasattr(authorization_id, "id"):
authorization_id = authorization_id.id
response = self._client.auth.status(id=authorization_id)
if response:
return response.status == "completed"
return False
def wait_for_auth(self, authorization_id: str) -> AuthorizationResponse:
"""
Wait for a tool authorization to complete. This method blocks until
the authorization is complete or fails.
Example:
>>> manager = ToolManager(api_key="your-api-key")
>>> auth_response = manager.authorize("Gmail.SendEmail", "user_123")
>>> # Share auth_response.auth_url with the user
>>> # Wait for the user to complete authorization
>>> completed_auth = manager.wait_for_auth(auth_response.id)
Args:
authorization_id: The authorization ID to wait for. This can be the full
AuthorizationResponse object or just the ID string.
Returns:
AuthorizationResponse with the completed authorization details.
"""
# Handle case where entire AuthorizationResponse object is passed
if hasattr(authorization_id, "id"):
authorization_id = authorization_id.id
return self._client.auth.wait_for_completion(authorization_id)
def _retrieve_tool_definitions(
self,
tools: Optional[list[str]] = None,
toolkits: Optional[list[str]] = None,
raise_on_empty: bool = True,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> list[ToolDefinition]:
"""
Retrieve tool definitions from the Arcade client, accounting for pagination.
Args:
tools: Optional list of specific tool names to include.
toolkits: Optional list of toolkit names to include all tools from.
raise_on_empty: Whether to raise an error if no tools or toolkits are provided.
limit: Optional limit on the number of tools to retrieve per request.
offset: Optional offset for paginated requests.
Returns:
List of ToolDefinition instances.
Raises:
ValueError: If no tools or toolkits are provided and raise_on_empty is True.
"""
all_tools: list[ToolDefinition] = []
# If no specific tools or toolkits are requested, raise an error.
if not tools and not toolkits:
if raise_on_empty:
raise ValueError(
"No tools or toolkits provided to retrieve tool definitions."
)
return []
# Retrieve individual tools if specified
if tools:
for tool_id in tools:
single_tool = self._client.tools.get(name=tool_id)
all_tools.append(single_tool)
# Retrieve tools from specified toolkits
if toolkits:
for tk in toolkits:
# Convert None to NOT_GIVEN for Stainless client
paginated_tools = self._client.tools.list(
toolkit=tk,
limit=limit if limit is not None else NOT_GIVEN,
offset=offset if offset is not None else NOT_GIVEN,
)
all_tools.extend(paginated_tools)
return all_tools
def add_tool(self, tool_name: str) -> None:
"""
Add a single tool to the manager by name.
Unlike init_tools(), this method preserves existing tools in the manager
and only adds the specified tool.
Example:
>>> manager = ToolManager(api_key="your-api-key")
>>> manager.add_tool("Gmail.SendEmail")
>>> manager.add_tool("Search.SearchGoogle")
>>> # Get all tools including newly added ones
>>> all_tools = manager.to_langchain()
Args:
tool_name: The fully qualified name of the tool to add (e.g., "Search.SearchGoogle")
Raises:
ValueError: If the tool cannot be found
"""
tool = self._client.tools.get(name=tool_name)
self._tools.update(_create_tool_map([tool]))
def add_toolkit(
self,
toolkit_name: str,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> None:
"""
Add all tools from a specific toolkit to the manager.
Unlike init_tools(), this method preserves existing tools in the manager
and only adds the tools from the specified toolkit.
Example:
>>> manager = ToolManager(api_key="your-api-key")
>>> manager.add_toolkit("Gmail")
>>> manager.add_toolkit("Search")
>>> # Get all tools including newly added ones
>>> all_tools = manager.to_langchain()
Args:
toolkit_name: The name of the toolkit to add (e.g., "Search")
limit: Optional limit on the number of tools to retrieve per request
offset: Optional offset for paginated requests
Raises:
ValueError: If the toolkit cannot be found or has no tools
"""
tools = self._client.tools.list(
toolkit=toolkit_name,
limit=NOT_GIVEN if limit is None else limit,
offset=NOT_GIVEN if offset is None else offset,
)
for tool in tools:
self._tools.update(_create_tool_map([tool]))
def get_tools(
self,
tools: Optional[list[str]] = None,
toolkits: Optional[list[str]] = None,
langgraph: bool = True,
) -> list[StructuredTool]:
"""
DEPRECATED: Return the tools in the manager as LangChain StructuredTool objects.
This method is deprecated and will be removed in a future major version.
Please use `init_tools()` to initialize tools and `to_langchain()` to convert them.
Args:
tools: Optional list of tool names to include.
toolkits: Optional list of toolkits to include.
langgraph: Whether to use LangGraph-specific behavior
such as NodeInterrupts for auth.
Returns:
List of StructuredTool instances.
"""
warnings.warn(
"get_tools() is deprecated and will be removed in the next major version. "
"Please use init_tools() to initialize tools and to_langchain() to convert them.",
DeprecationWarning,
stacklevel=2,
)
# Support existing usage pattern
if tools or toolkits:
self.init_tools(tools=tools, toolkits=toolkits)
return self.to_langchain(use_interrupts=langgraph)
class ArcadeToolManager(ToolManager):
"""
Deprecated alias for ToolManager.
ArcadeToolManager is deprecated and will be removed in the next major version.
Please use ToolManager instead.
"""
def __init__(self, *args: Any, **kwargs: Any) -> None:
warnings.warn(
"ArcadeToolManager is deprecated and will be removed in the next major version. "
"Please use ToolManager instead.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)
class AsyncToolManager(LangChainToolManager):
"""
Async version of Arcade tool manager for LangChain framework.
This class wraps Arcade tools as LangChain StructuredTool objects for integration
with asynchronous operations.
Example:
>>> manager = AsyncToolManager(api_key="your-api-key")
>>> # Initialize with specific tools and toolkits
>>> await manager.init_tools(tools=["Search.SearchGoogle"], toolkits=["Weather"])
>>> # Get tools as LangChain StructuredTools
>>> langchain_tools = await manager.to_langchain()
>>> # Handle authorization for tools that require it
>>> if manager.requires_auth("Search.SearchGoogle"):
>>> auth_response = await manager.authorize("Search.SearchGoogle", "user_123")
>>> await manager.wait_for_auth(auth_response.id)
"""
def __init__(
self,
client: Optional[AsyncArcade] = None,
**kwargs: Any,
) -> None:
"""
Initialize the AsyncToolManager.
Example:
>>> manager = AsyncToolManager(api_key="your-api-key")
>>> # or with an existing client
>>> client = AsyncArcade(api_key="your-api-key")
>>> manager = AsyncToolManager(client=client)
Args:
client: Optional AsyncArcade client instance. If not provided, one will be created.
**kwargs: Additional keyword arguments to pass to the AsyncArcade client if creating one.
Common options include api_key and base_url.
"""
super().__init__()
if not client:
client_kwargs = self._get_client_config(**kwargs)
client = AsyncArcade(**client_kwargs)
self._client = client
@property
def definitions(self) -> list[ToolDefinition]:
"""
Get the list of tool definitions in the manager.
Returns:
A list of ToolDefinition objects currently stored in the manager.
"""
return list(self._tools.values())
def __iter__(self) -> Iterator[tuple[str, ToolDefinition]]:
"""
Iterate over the tools in the manager as (name, definition) pairs.
Returns:
Iterator over (tool_name, tool_definition) tuples.
"""
yield from self._tools.items()
async def init_tools(
self,
tools: Optional[list[str]] = None,
toolkits: Optional[list[str]] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
raise_on_empty: bool = True,
) -> list[StructuredTool]:
"""
Initialize the tools in the manager asynchronously and return them as LangChain tools.
This will clear any existing tools in the manager and replace them with
the new tools specified by the tools and toolkits parameters.
Example:
>>> manager = AsyncToolManager(api_key="your-api-key")
>>> langchain_tools = await manager.init_tools(tools=["Search.SearchGoogle"])
>>> # Use these tools with a LangChain chain or agent
>>> agent = Agent(tools=langchain_tools, llm=llm)
Args:
tools: Optional list of specific tool names to include (e.g., "Search.SearchGoogle").
toolkits: Optional list of toolkit names to include all tools from (e.g., "Search").
limit: Optional limit on the number of tools to retrieve per request.
offset: Optional offset for paginated requests.
raise_on_empty: Whether to raise an error if no tools or toolkits are provided.
Returns:
List of StructuredTool instances ready to use with LangChain.
Raises:
ValueError: If no tools or toolkits are provided and raise_on_empty is True.
"""
tools_list = await self._retrieve_tool_definitions(
tools, toolkits, raise_on_empty, limit, offset
)
self._tools.update(_create_tool_map(tools_list))
return await self.to_langchain()
async def to_langchain(
self, use_interrupts: bool = True, use_underscores: bool = True
) -> list[StructuredTool]:
"""
Get the tools in the manager as LangChain StructuredTool objects asynchronously.
Args:
use_interrupts: Whether to use interrupts for the tool. This is useful
for LangGraph workflows where you need to handle tool
authorization through state transitions.
use_underscores: Whether to use underscores for the tool name instead of periods.
For example, "Search_SearchGoogle" vs "Search.SearchGoogle".
Some model providers like OpenAI work better with underscores.
Returns:
List of StructuredTool instances ready to use with LangChain.
"""
tool_map = _create_tool_map(self.definitions, use_underscores=use_underscores)
return [
wrap_arcade_tool(
self._client, tool_name, definition, langgraph=use_interrupts
)
for tool_name, definition in tool_map.items()
]
async def authorize(self, tool_name: str, user_id: str) -> AuthorizationResponse:
"""
Authorize a user for a tool.
Example:
>>> manager = AsyncToolManager(api_key="your-api-key")
>>> await manager.init_tools(tools=["Gmail.SendEmail"])
>>> auth_response = await manager.authorize("Gmail.SendEmail", "user_123")
>>> # auth_response.auth_url contains the URL for the user to authorize
Args:
tool_name: The name of the tool to authorize.
user_id: The user ID to authorize. This should be a unique identifier for the user.
Returns:
AuthorizationResponse containing authorization details, including the auth_url
that should be presented to the user to complete authorization.
"""
return await self._client.tools.authorize(tool_name=tool_name, user_id=user_id)
async def is_authorized(self, authorization_id: str) -> bool:
"""
Check if a tool authorization is complete.
Example:
>>> manager = AsyncToolManager(api_key="your-api-key")
>>> auth_response = await manager.authorize("Gmail.SendEmail", "user_123")
>>> # After user completes authorization
>>> is_complete = await manager.is_authorized(auth_response.id)
Args:
authorization_id: The authorization ID to check. This can be the full AuthorizationResponse
object or just the ID string.
Returns:
True if the authorization is completed, False otherwise.
"""
# Handle case where entire AuthorizationResponse object is passed
if hasattr(authorization_id, "id"):
authorization_id = authorization_id.id
auth_status = await self._client.auth.status(id=authorization_id)
return auth_status.status == "completed"
async def wait_for_auth(self, authorization_id: str) -> AuthorizationResponse:
"""
Wait for a tool authorization to complete. This method blocks until
the authorization is complete or fails.
Example:
>>> manager = AsyncToolManager(api_key="your-api-key")
>>> auth_response = await manager.authorize("Gmail.SendEmail", "user_123")
>>> # Share auth_response.auth_url with the user
>>> # Wait for the user to complete authorization
>>> completed_auth = await manager.wait_for_auth(auth_response.id)
Args:
authorization_id: The authorization ID to wait for. This can be the full
AuthorizationResponse object or just the ID string.
Returns:
AuthorizationResponse with the completed authorization details.
"""
# Handle case where entire AuthorizationResponse object is passed
if hasattr(authorization_id, "id"):
authorization_id = authorization_id.id
return await self._client.auth.wait_for_completion(authorization_id)
async def _retrieve_tool_definitions(
self,
tools: Optional[list[str]] = None,
toolkits: Optional[list[str]] = None,
raise_on_empty: bool = True,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> list[ToolDefinition]:
"""
Retrieve tool definitions asynchronously from the Arcade client, accounting for pagination.
Args:
tools: Optional list of specific tool names to include.
toolkits: Optional list of toolkit names to include all tools from.
raise_on_empty: Whether to raise an error if no tools or toolkits are provided.
limit: Optional limit on the number of tools to retrieve per request.
offset: Optional offset for paginated requests.
Returns:
List of ToolDefinition instances.
Raises:
ValueError: If no tools or toolkits are provided and raise_on_empty is True.
"""
all_tools: list[ToolDefinition] = []
# If no specific tools or toolkits are requested, raise an error.
if not tools and not toolkits:
if raise_on_empty:
raise ValueError(
"No tools or toolkits provided to retrieve tool definitions."
)
return []
# First, gather single tools if the user specifically requested them.
if tools:
for tool_id in tools:
# ToolsResource.get(...) returns a single ToolDefinition.
single_tool = await self._client.tools.get(name=tool_id)
all_tools.append(single_tool)
# Next, gather tool definitions from any requested toolkits.
if toolkits:
for tk in toolkits:
# Convert None to NOT_GIVEN for Stainless client
paginated_tools = await self._client.tools.list(
toolkit=tk,
limit=NOT_GIVEN if limit is None else limit,
offset=NOT_GIVEN if offset is None else offset,
)
async for tool in paginated_tools:
all_tools.append(tool)
return all_tools
async def add_tool(self, tool_name: str) -> None:
"""
Add a single tool to the manager by name.
Unlike init_tools(), this method preserves existing tools in the manager
and only adds the specified tool.
Example:
>>> manager = AsyncToolManager(api_key="your-api-key")
>>> await manager.add_tool("Gmail.SendEmail")
>>> await manager.add_tool("Search.SearchGoogle")
>>> # Get all tools including newly added ones
>>> all_tools = await manager.to_langchain()
Args:
tool_name: The fully qualified name of the tool to add (e.g., "Search.SearchGoogle")
Raises:
ValueError: If the tool cannot be found
"""
tool = await self._client.tools.get(name=tool_name)
self._tools.update(_create_tool_map([tool]))
async def add_toolkit(
self,
toolkit_name: str,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> None:
"""
Add all tools from a specific toolkit to the manager.
Unlike init_tools(), this method preserves existing tools in the manager
and only adds the tools from the specified toolkit.
Example:
>>> manager = AsyncToolManager(api_key="your-api-key")
>>> await manager.add_toolkit("Gmail")
>>> await manager.add_toolkit("Search")
>>> # Get all tools including newly added ones
>>> all_tools = await manager.to_langchain()
Args:
toolkit_name: The name of the toolkit to add (e.g., "Search")
limit: Optional limit on the number of tools to retrieve per request
offset: Optional offset for paginated requests
Raises:
ValueError: If the toolkit cannot be found or has no tools
"""
paginated_tools = await self._client.tools.list(
toolkit=toolkit_name,
limit=NOT_GIVEN if limit is None else limit,
offset=NOT_GIVEN if offset is None else offset,
)
async for tool in paginated_tools:
self._tools.update(_create_tool_map([tool]))
async def get_tools(
self,
tools: Optional[list[str]] = None,
toolkits: Optional[list[str]] = None,
langgraph: bool = True,
) -> list[StructuredTool]:
"""
DEPRECATED: Return the tools in the manager as LangChain StructuredTool objects.
This method is deprecated and will be removed in a future major version.
Please use `init_tools()` to initialize tools and `to_langchain()` to convert them.
Args:
tools: Optional list of tool names to include.
toolkits: Optional list of toolkits to include.
langgraph: Whether to use LangGraph-specific behavior
such as NodeInterrupts for auth.
Returns:
List of StructuredTool instances.
"""
warnings.warn(
"get_tools() is deprecated and will be removed in the next major version. "
"Please use init_tools() to initialize tools and to_langchain() to convert them.",
DeprecationWarning,
stacklevel=2,
)
# Support existing usage pattern
if tools or toolkits:
return await self.init_tools(tools=tools, toolkits=toolkits)
return []
def _create_tool_map(
tools: list[ToolDefinition],
use_underscores: bool = True,
) -> dict[str, ToolDefinition]:
"""
Build a dictionary that maps the "full_tool_name" to the tool definition.
Args:
tools: List of ToolDefinition objects to map.
use_underscores: Whether to use underscores instead of periods in tool names.
For example, "Search_SearchGoogle" vs "Search.SearchGoogle".
Returns:
Dictionary mapping tool names to tool definitions.
Note:
This is a temporary solution to support the naming convention of certain model providers
like OpenAI, which work better with underscores in tool names.
"""
tool_map: dict[str, ToolDefinition] = {}
for tool in tools:
# Ensure toolkit name and tool name are not None before creating the key
toolkit_name = tool.toolkit.name if tool.toolkit and tool.toolkit.name else None
if toolkit_name and tool.name:
if use_underscores:
tool_name = f"{toolkit_name}_{tool.name}"
else:
tool_name = f"{toolkit_name}.{tool.name}"
tool_map[tool_name] = tool
return tool_map

View file

@ -0,0 +1,59 @@
[build-system]
requires = [ "hatchling",]
build-backend = "hatchling.build"
[project]
name = "langchain-arcade"
version = "1.4.5"
description = "An integration package connecting Arcade and Langchain/LangGraph"
readme = "README.md"
repository = "https://github.com/arcadeai/arcade-mcp/tree/main/contrib/langchain"
license = "MIT"
requires-python = ">=3.10"
dependencies = [
"arcadepy>=1.7.0",
"langchain-core>=0.3.80,<0.4",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3.0,<8.4.0",
"pytest-cov>=4.0.0,<4.1.0",
"pytest-mock>=3.11.1,<3.12.0",
"pytest-asyncio>=0.24.0,<0.25.0",
"mypy>=1.5.1,<1.6.0",
"pre-commit>=3.4.0,<3.5.0",
"ruff>=0.7.4,<0.8.0",
"langgraph>=0.3.23,<0.4"
]
[tool.mypy]
files = ["langchain_arcade"]
python_version = "3.10"
disallow_untyped_defs = "True"
disallow_any_unimported = "True"
no_implicit_optional = "True"
check_untyped_defs = "True"
warn_return_any = "True"
warn_unused_ignores = "True"
show_error_codes = "True"
ignore_missing_imports = "True"
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.coverage.run]
branch = true
source = ["langchain_arcade"]
[tool.coverage.report]
skip_empty = true
[tool.ruff.lint]
ignore = ["C901"]
[tool.hatch.build.targets.wheel]
packages = [ "langchain_arcade",]

View file

@ -0,0 +1,33 @@
import os
import pytest
from arcadepy import Arcade
@pytest.fixture(scope="session")
def arcade_base_url():
"""
Retrieve the ARCADE_BASE_URL from the environment, falling back to a default
if not found.
"""
return os.getenv("ARCADE_BASE_URL", "http://localhost:9099")
@pytest.fixture(scope="session")
def arcade_api_key():
"""
Retrieve the ARCADE_API_KEY from the environment, falling back to a default
if not found.
"""
return os.getenv("ARCADE_API_KEY", "test_api_key")
@pytest.fixture(scope="session")
def arcade_client(arcade_base_url, arcade_api_key):
"""
Creates a single Arcade client instance for use in all tests.
Any method calls on this client can be patched/mocked within the tests.
"""
client = Arcade(api_key=arcade_api_key, base_url=arcade_base_url)
yield client
# Teardown logic would go here if necessary

View file

@ -0,0 +1,738 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from arcadepy import NOT_GIVEN
from arcadepy.pagination import AsyncOffsetPage, SyncOffsetPage
from arcadepy.types import ToolDefinition
from arcadepy.types.shared import AuthorizationResponse
from langchain_arcade.manager import ArcadeToolManager, AsyncToolManager, ToolManager
@pytest.fixture
def mock_arcade_client():
"""
A fixture to mock the Arcade client object for testing the ToolManager.
This mocks all relevant methods used by the manager, including:
- tools.get
- tools.list
- tools.authorize
- auth.status
- auth.wait_for_completion
"""
mock_client = MagicMock()
# Mock the "tools" sub-client
mock_client.tools.get = MagicMock()
mock_client.tools.list = MagicMock()
mock_client.tools.authorize = MagicMock()
# Mock the "auth" sub-client
mock_client.auth.status = MagicMock()
mock_client.auth.wait_for_completion = MagicMock()
return mock_client
@pytest.fixture
def async_mock_arcade_client():
"""
A fixture to mock the Arcade client object for testing the AsyncToolManager.
"""
mock_client = AsyncMock()
mock_client.tools.get = AsyncMock()
mock_client.tools.list = AsyncMock()
mock_client.tools.authorize = AsyncMock()
mock_client.auth.status = AsyncMock()
mock_client.auth.wait_for_completion = AsyncMock()
return mock_client
@pytest.fixture
def manager(mock_arcade_client):
"""
A fixture that creates a ToolManager with the mocked Arcade client.
"""
return ToolManager(client=mock_arcade_client)
@pytest.fixture
def async_manager(async_mock_arcade_client):
"""
A fixture that creates an AsyncToolManager with the mocked Arcade client.
"""
return AsyncToolManager(client=async_mock_arcade_client)
@pytest.fixture(params=[("sync", False), ("async", True)])
def manager_fixture(request, manager, async_manager):
"""
A parameterized fixture that returns a tuple with:
- The appropriate manager (sync or async)
- A boolean indicating if it's async
- The appropriate mock client
"""
param_name, is_async = request.param
if is_async:
return async_manager, True
else:
return manager, False
@pytest.fixture
def make_tool():
"""
A factory fixture for creating a valid ToolDefinition with a given
fully qualified name. Because the underlying ToolDefinition model
expects "toolkit" to be a dictionary with at least one field (for example "slug"),
and "requirements.authorization" to be a valid dictionary if present, we set them up
accordingly.
"""
def _make_tool(fully_qualified_name="GoogleSearch_Search", **kwargs):
# Split on the first dot to derive a 'toolkit' slug and a tool 'name'
if "." in fully_qualified_name:
raw_toolkit, raw_tool_name = fully_qualified_name.split(".", 1)
elif "_" in fully_qualified_name:
# Convert from "_" to "." to match the expected format of tool name when
# using Langchain models for LLM inference.
raw_toolkit, raw_tool_name = fully_qualified_name.split("_", 1)
else:
raw_toolkit, raw_tool_name = fully_qualified_name, fully_qualified_name
# Provide a default toolkit dict unless one already exists in kwargs
toolkit = kwargs.pop("toolkit", {"name": raw_toolkit})
# Provide a default input
# arcadepy.types.ToolDefinition expects "input" to be a valid structure (dict).
tool_input = kwargs.pop("input", {"parameters": []})
# Convert MagicMock-based requirements (with authorization) to an appropriate dict,
# or use what's passed. If none is passed, default to None.
requirements = kwargs.pop("requirements", None)
if requirements is not None and not isinstance(requirements, dict):
# If it's e.g. a MagicMock(authorization="xyz"), convert it to a dict
req_auth = getattr(requirements, "authorization", None)
# If the test expects an authorization presence, represent it as a dict
# that Pydantic can parse
if req_auth is not None:
requirements = {"authorization": {"type": req_auth}}
else:
requirements = {"authorization": None}
# Provide a default description if none is supplied
description = kwargs.pop("description", "Mock tool for testing")
# Build the pydantic fields
data = {
"fully_qualified_name": fully_qualified_name,
"qualified_name": fully_qualified_name,
"name": raw_tool_name,
"toolkit": toolkit,
"input": tool_input,
"description": description,
"requirements": requirements,
}
data.update(kwargs) # merge any extras
return ToolDefinition(**data)
return _make_tool
async def maybe_await(obj, is_async):
"""Helper to handle both sync and async return values"""
if is_async:
return await obj
return obj
@pytest.mark.asyncio
async def test_init_tools_parameterized(
manager_fixture, mock_arcade_client, async_mock_arcade_client, make_tool
):
"""
Test that init_tools clears any existing tools and retrieves new ones
from either an explicit list of tools or an entire toolkit.
"""
# Arrange
manager, is_async = manager_fixture
client = async_mock_arcade_client if is_async else mock_arcade_client
mock_tool = make_tool("GoogleSearch_Search")
client.tools.get.return_value = mock_tool
page_cls = AsyncOffsetPage if is_async else SyncOffsetPage
client.tools.list.return_value = page_cls(items=[mock_tool])
# Act
result = await maybe_await(
manager.init_tools(tools=["GoogleSearch_Search"]), is_async
)
# Assert
assert "GoogleSearch_Search" in manager.tools
assert manager._tools["GoogleSearch_Search"] == mock_tool
client.tools.get.assert_called_once_with(name="GoogleSearch_Search")
# Verify the result is a list of StructuredTool objects
assert len(result) == 1
@pytest.mark.asyncio
async def test_to_langchain_parameterized(
manager_fixture, mock_arcade_client, async_mock_arcade_client, make_tool
):
"""
Test that to_langchain returns the tools as StructuredTool objects.
"""
# Arrange
manager, is_async = manager_fixture
mock_tool = make_tool("GoogleSearch_Search")
manager._tools = {"GoogleSearch_Search": mock_tool}
# Act - with default parameters
result = await maybe_await(manager.to_langchain(), is_async)
# Assert
assert len(result) == 1
assert result[0].name == "GoogleSearch_Search"
# Act - with underscores=False
result = await maybe_await(manager.to_langchain(use_underscores=False), is_async)
# Assert
assert len(result) == 1
assert result[0].name == "GoogleSearch.Search"
@pytest.mark.asyncio
async def test_deprecated_get_tools_parameterized(
manager_fixture, mock_arcade_client, async_mock_arcade_client, make_tool
):
"""
Test that the deprecated get_tools method still works but issues a warning.
"""
# Arrange
manager, is_async = manager_fixture
client = async_mock_arcade_client if is_async else mock_arcade_client
mock_tool = make_tool("GoogleSearch_Search")
client.tools.get.return_value = mock_tool
manager._tools = {} # Ensure no tools are already loaded
# Act - Check for deprecation warning
with pytest.warns(DeprecationWarning):
result = await maybe_await(
manager.get_tools(tools=["GoogleSearch_Search"]), is_async
)
# Assert - Method should still work
assert len(result) == 1
assert "GoogleSearch_Search" in manager.tools
client.tools.get.assert_called_once_with(name="GoogleSearch_Search")
@pytest.mark.asyncio
async def test_add_tool_parameterized(
manager_fixture, mock_arcade_client, async_mock_arcade_client, make_tool
):
"""
Test that add_tool adds a single tool to the manager without clearing existing tools.
"""
# Arrange
manager, is_async = manager_fixture
client = async_mock_arcade_client if is_async else mock_arcade_client
# Set up two different mock tools
mock_tool_google = make_tool("GoogleSearch_Search")
mock_tool_bing = make_tool("BingSearch_Search")
# First tool already exists in manager
manager._tools = {"GoogleSearch_Search": mock_tool_google}
# Second tool will be added
client.tools.get.return_value = mock_tool_bing
# Act
await maybe_await(manager.add_tool("BingSearch_Search"), is_async)
# Assert - Both tools should now be in the manager
assert "GoogleSearch_Search" in manager.tools
assert "BingSearch_Search" in manager.tools
assert len(manager.tools) == 2
client.tools.get.assert_called_once_with(name="BingSearch_Search")
@pytest.mark.asyncio
async def test_add_toolkit_parameterized(
manager_fixture, mock_arcade_client, async_mock_arcade_client, make_tool
):
"""
Test that add_toolkit adds all tools from a toolkit without clearing existing tools.
"""
# Arrange
manager, is_async = manager_fixture
client = async_mock_arcade_client if is_async else mock_arcade_client
# Create a tool that's already in the manager
mock_tool_send_email = make_tool("Gmail_SendEmail")
manager._tools = {"Gmail_SendEmail": mock_tool_send_email}
# Create tools to be added from the toolkit
mock_tool_list_emails = make_tool("Gmail_ListEmails")
mock_tool_trash_email = make_tool("Gmail_TrashEmail")
# Mock the response for toolkit listing
page_cls = AsyncOffsetPage if is_async else SyncOffsetPage
client.tools.list.return_value = page_cls(
items=[mock_tool_list_emails, mock_tool_trash_email]
)
# Act
await maybe_await(manager.add_toolkit("Search"), is_async)
# Assert - All tools should now be in the manager
assert len(manager.tools) == 3
assert "Gmail_SendEmail" in manager.tools
assert "Gmail_ListEmails" in manager.tools
assert "Gmail_TrashEmail" in manager.tools
client.tools.list.assert_called_once_with(
toolkit="Search", limit=NOT_GIVEN, offset=NOT_GIVEN
)
@pytest.mark.asyncio
async def test_is_authorized_with_response_object_parameterized(
manager_fixture, mock_arcade_client, async_mock_arcade_client
):
"""
Test the is_authorized method accepting both authorization ID string and AuthorizationResponse.
"""
# Arrange
manager, is_async = manager_fixture
client = async_mock_arcade_client if is_async else mock_arcade_client
mock_type = AsyncMock if is_async else MagicMock
client.auth.status.return_value = mock_type(status="completed")
# Create an auth response object
auth_response = AuthorizationResponse(
id="auth_abc", status="pending", tool_fully_qualified_name="GoogleSearch_Search"
)
# Act - Test with string ID
status_result1 = await maybe_await(manager.is_authorized("auth_abc"), is_async)
# Act - Test with response object
status_result2 = await maybe_await(manager.is_authorized(auth_response), is_async)
# Assert
assert status_result1 is True
assert status_result2 is True
client.auth.status.assert_any_call(id="auth_abc")
client.auth.status.assert_any_call(
id="auth_abc"
) # Should be called with the same ID both times
@pytest.mark.asyncio
async def test_wait_for_auth_with_response_object_parameterized(
manager_fixture, mock_arcade_client, async_mock_arcade_client
):
"""
Test the wait_for_auth method accepting both authorization ID string and AuthorizationResponse.
"""
# Arrange
manager, is_async = manager_fixture
client = async_mock_arcade_client if is_async else mock_arcade_client
completed_response = AuthorizationResponse(
id="auth_abc",
status="completed",
tool_fully_qualified_name="GoogleSearch_Search",
)
client.auth.wait_for_completion.return_value = completed_response
# Create an auth response object
auth_response = AuthorizationResponse(
id="auth_abc", status="pending", tool_fully_qualified_name="GoogleSearch_Search"
)
# Act - Test with string ID
result1 = await maybe_await(manager.wait_for_auth("auth_abc"), is_async)
# Act - Test with response object
result2 = await maybe_await(manager.wait_for_auth(auth_response), is_async)
# Assert
assert result1 == completed_response
assert result2 == completed_response
client.auth.wait_for_completion.assert_any_call("auth_abc")
client.auth.wait_for_completion.assert_any_call(
"auth_abc"
) # Should be called with the same ID both times
@pytest.mark.asyncio
async def test_get_tools_no_init_parameterized(
manager_fixture, mock_arcade_client, async_mock_arcade_client, make_tool
):
"""
Test that the deprecated get_tools method without previous initialization
issues a warning and fetches tools.
"""
# Arrange
manager, is_async = manager_fixture
client = async_mock_arcade_client if is_async else mock_arcade_client
mock_tool = make_tool("GoogleSearch_Search")
page_cls = AsyncOffsetPage if is_async else SyncOffsetPage
client.tools.list.return_value = page_cls(items=[mock_tool])
# Act - Check for deprecation warning
with pytest.warns(DeprecationWarning):
tools = await maybe_await(
manager.get_tools(), is_async
) # No param means manager calls list
# Assert
assert len(tools) == 0
assert "GoogleSearch_Search" not in manager.tools
@pytest.mark.asyncio
async def test_get_tools_with_explicit_parameterized(
manager_fixture, mock_arcade_client, async_mock_arcade_client, make_tool
):
"""
Test that the deprecated get_tools method with explicitly specified tools
issues a warning and fetches the requested tools.
"""
# Arrange
manager, is_async = manager_fixture
client = async_mock_arcade_client if is_async else mock_arcade_client
mock_tool_google = make_tool("GoogleSearch_Search")
mock_tool_bing = make_tool("BingSearch_Search")
client.tools.get.side_effect = [mock_tool_google, mock_tool_bing]
# Act - Check for deprecation warning
with pytest.warns(DeprecationWarning):
retrieved_tools = await maybe_await(
manager.get_tools(tools=["GoogleSearch_Search", "BingSearch_Search"]),
is_async,
)
# Assert
assert len(retrieved_tools) == 2
assert set(manager.tools) == {"GoogleSearch_Search", "BingSearch_Search"}
client.tools.get.assert_any_call(name="GoogleSearch_Search")
client.tools.get.assert_any_call(name="BingSearch_Search")
def test_arcade_tool_manager_deprecation_warning():
"""
Test that the ArcadeToolManager class issues a deprecation warning.
"""
# Act - Check for deprecation warning
with pytest.warns(DeprecationWarning) as warnings_record:
ArcadeToolManager(client=MagicMock())
# Assert
assert any(
"ArcadeToolManager is deprecated" in str(w.message) for w in warnings_record
)
@pytest.mark.asyncio
async def test_authorize_parameterized(
manager_fixture, mock_arcade_client, async_mock_arcade_client
):
"""
Test the authorize method to ensure it calls the Arcade client's
tools.authorize method correctly.
"""
# Arrange
manager, is_async = manager_fixture
client = async_mock_arcade_client if is_async else mock_arcade_client
auth_response = AuthorizationResponse(
id="auth_123", status="pending", tool_fully_qualified_name="GoogleSearch_Search"
)
client.tools.authorize.return_value = auth_response
# Act
response = await maybe_await(
manager.authorize(tool_name="GoogleSearch_Search", user_id="user_123"), is_async
)
# Assert
assert response.id == "auth_123"
assert response.status == "pending"
client.tools.authorize.assert_called_once_with(
tool_name="GoogleSearch_Search", user_id="user_123"
)
def test_requires_auth_true(manager, make_tool):
"""
Test the requires_auth method returning True if
the stored tool definition's requirements contain an authorization entry.
"""
# Arrange
tool_name = "GoogleSearch_Search"
# Pass a MagicMock with 'authorization' to ensure it gets converted
mock_tool_def = make_tool(
tool_name, requirements=MagicMock(authorization="some_required_auth")
)
manager._tools[tool_name] = mock_tool_def
# Act
result = manager.requires_auth(tool_name)
# Assert
assert result is True
def test_requires_auth_false(manager, make_tool):
"""
Test the requires_auth method returning False if authorization
is not required in the tool definition.
"""
# Arrange
tool_name = "GoogleSearch_Search"
mock_tool_def = make_tool(tool_name, requirements=MagicMock(authorization=None))
manager._tools[tool_name] = mock_tool_def
# Act
result = manager.requires_auth(tool_name)
# Assert
assert result is False
def test_get_tool_definition_existing(manager, make_tool):
"""
Test the internal _get_tool_definition method retrieving
an existing tool definition by name.
"""
# Arrange
tool_name = "GoogleSearch_Search"
mock_tool_def = make_tool(tool_name)
manager._tools[tool_name] = mock_tool_def
# Act
definition = manager._get_tool_definition(tool_name)
# Assert
assert definition == mock_tool_def
def test_get_tool_definition_missing(manager):
"""
Test the internal _get_tool_definition method raising a ValueError
if the tool is not in the manager.
"""
# Act & Assert
with pytest.raises(ValueError) as excinfo:
manager._get_tool_definition("Nonexistent.Tool")
assert "Tool 'Nonexistent.Tool' not found" in str(excinfo.value)
def test_retrieve_tool_definitions_tools_only(manager, mock_arcade_client, make_tool):
"""
Test the internal _retrieve_tool_definitions method by specifying tools only.
"""
# Arrange
mock_tool = make_tool("GoogleSearch_Search")
mock_arcade_client.tools.get.return_value = mock_tool
# Act
results = manager._retrieve_tool_definitions(
tools=["GoogleSearch_Search"], toolkits=None
)
# Assert
assert len(results) == 1
assert results[0].fully_qualified_name == "GoogleSearch_Search"
mock_arcade_client.tools.get.assert_called_once_with(name="GoogleSearch_Search")
def test_retrieve_tool_definitions_toolkits_only(
manager, mock_arcade_client, make_tool
):
"""
Test the internal _retrieve_tool_definitions method by specifying toolkits.
"""
# Arrange
mock_tool = make_tool("Search_SearchBing")
mock_arcade_client.tools.list.return_value = SyncOffsetPage(items=[mock_tool])
# Act
results = manager._retrieve_tool_definitions(tools=None, toolkits=["Search"])
# Assert
assert len(results) == 1
assert results[0].fully_qualified_name == "Search_SearchBing"
mock_arcade_client.tools.list.assert_called_once_with(
toolkit="Search", limit=NOT_GIVEN, offset=NOT_GIVEN
)
def test_retrieve_tool_definitions_raise_on_empty(manager):
"""
Test that _retrieve_tool_definitions raises ValueError when no tools or toolkits
are provided and raise_on_empty is True.
"""
# Act & Assert
with pytest.raises(ValueError) as excinfo:
manager._retrieve_tool_definitions(
tools=None, toolkits=None, raise_on_empty=True
)
assert "No tools or toolkits provided" in str(excinfo.value)
def test_retrieve_tool_definitions_empty_no_raise(manager):
"""
Test that _retrieve_tool_definitions returns empty list when no tools or toolkits
are provided and raise_on_empty is False.
"""
# Act
results = manager._retrieve_tool_definitions(
tools=None, toolkits=None, raise_on_empty=False
)
# Assert
assert results == []
@pytest.mark.asyncio
async def test_retrieve_tool_definitions_with_limit_offset_parameterized(
manager_fixture, mock_arcade_client, async_mock_arcade_client, make_tool
):
"""
Test that _retrieve_tool_definitions respects limit and offset parameters.
"""
# Arrange
manager, is_async = manager_fixture
client = async_mock_arcade_client if is_async else mock_arcade_client
mock_tool = make_tool("Search_SearchGoogle")
page_cls = AsyncOffsetPage if is_async else SyncOffsetPage
client.tools.list.return_value = page_cls(items=[mock_tool])
# Act
if is_async:
results = await manager._retrieve_tool_definitions(
toolkits=["Search"], limit=10, offset=5
)
else:
results = manager._retrieve_tool_definitions(
toolkits=["Search"], limit=10, offset=5
)
# Assert
assert len(results) > 0
client.tools.list.assert_called_once_with(toolkit="Search", limit=10, offset=5)
def test_get_client_config_with_kwargs():
"""
Test that _get_client_config prioritizes kwargs over environment variables.
"""
# Arrange
manager = ToolManager(client=MagicMock()) # Client won't be used here
# Act
with patch.dict(
"os.environ", {"ARCADE_API_KEY": "env_key", "ARCADE_BASE_URL": "env_url"}
):
result = manager._get_client_config(api_key="kwarg_key", base_url="kwarg_url")
# Assert
assert result["api_key"] == "kwarg_key"
assert result["base_url"] == "kwarg_url"
def test_get_client_config_with_env_vars():
"""
Test that _get_client_config falls back to environment variables when kwargs not provided.
"""
# Arrange
manager = ToolManager(client=MagicMock()) # Client won't be used here
# Act
with patch.dict(
"os.environ", {"ARCADE_API_KEY": "env_key", "ARCADE_BASE_URL": "env_url"}
):
result = manager._get_client_config()
# Assert
assert result["api_key"] == "env_key"
assert result["base_url"] == "env_url"
def test_getitem_access(manager, make_tool):
"""
Test that __getitem__ allows dictionary-style access to tools.
"""
# Arrange
tool_name = "Search_SearchGoogle"
mock_tool_def = make_tool(tool_name)
manager._tools[tool_name] = mock_tool_def
# Act
definition = manager[tool_name]
# Assert
assert definition == mock_tool_def
def test_getitem_missing(manager):
"""
Test that __getitem__ raises ValueError for missing tools.
"""
# Act & Assert
with pytest.raises(ValueError) as excinfo:
_ = manager["Nonexistent.Tool"]
assert "Tool 'Nonexistent.Tool' not found" in str(excinfo.value)
def test_create_tool_map_with_underscores(make_tool):
"""
Test the _create_tool_map function with use_underscores=True.
"""
# Arrange
from langchain_arcade.manager import _create_tool_map
tool1 = make_tool("GoogleSearch.Search")
tool2 = make_tool("Gmail.SendEmail")
# Act
result = _create_tool_map([tool1, tool2], use_underscores=True)
# Assert
assert "GoogleSearch_Search" in result
assert "Gmail_SendEmail" in result
assert len(result) == 2
def test_create_tool_map_with_dots(make_tool):
"""
Test the _create_tool_map function with use_underscores=False.
"""
# Arrange
from langchain_arcade.manager import _create_tool_map
tool1 = make_tool("GoogleSearch.Search")
tool2 = make_tool("Gmail.SendEmail")
# Act
result = _create_tool_map([tool1, tool2], use_underscores=False)
# Assert
assert "GoogleSearch.Search" in result
assert "Gmail.SendEmail" in result
assert len(result) == 2

16
contrib/langchain/tox.ini Normal file
View file

@ -0,0 +1,16 @@
[tox]
skipsdist = true
envlist = py310, py311, py312
[gh-actions]
python =
3.10: py310
3.11: py311
3.12: py312
[testenv]
passenv = PYTHON_VERSION
allowlist_externals = uv
commands =
uv sync --active --all-extras
uv pytest --doctest-modules tests --cov --cov-config=pyproject.toml --cov-report=xml