diff --git a/toolkits/mastodon/.pre-commit-config.yaml b/toolkits/mastodon/.pre-commit-config.yaml
new file mode 100644
index 000000000..085074e00
--- /dev/null
+++ b/toolkits/mastodon/.pre-commit-config.yaml
@@ -0,0 +1,18 @@
+files: ^.*/mastodon/.*
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: "v4.4.0"
+ hooks:
+ - id: check-case-conflict
+ - id: check-merge-conflict
+ - id: check-toml
+ - id: check-yaml
+ - id: end-of-file-fixer
+ - id: trailing-whitespace
+
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.6.7
+ hooks:
+ - id: ruff
+ args: [--fix]
+ - id: ruff-format
diff --git a/toolkits/mastodon/.ruff.toml b/toolkits/mastodon/.ruff.toml
new file mode 100644
index 000000000..9519fe6c3
--- /dev/null
+++ b/toolkits/mastodon/.ruff.toml
@@ -0,0 +1,44 @@
+target-version = "py310"
+line-length = 100
+fix = true
+
+[lint]
+select = [
+ # flake8-2020
+ "YTT",
+ # flake8-bandit
+ "S",
+ # flake8-bugbear
+ "B",
+ # flake8-builtins
+ "A",
+ # flake8-comprehensions
+ "C4",
+ # flake8-debugger
+ "T10",
+ # flake8-simplify
+ "SIM",
+ # isort
+ "I",
+ # mccabe
+ "C90",
+ # pycodestyle
+ "E", "W",
+ # pyflakes
+ "F",
+ # pygrep-hooks
+ "PGH",
+ # pyupgrade
+ "UP",
+ # ruff
+ "RUF",
+ # tryceratops
+ "TRY",
+]
+
+[lint.per-file-ignores]
+"**/tests/*" = ["S101"]
+
+[format]
+preview = true
+skip-magic-trailing-comma = false
diff --git a/toolkits/mastodon/Makefile b/toolkits/mastodon/Makefile
new file mode 100644
index 000000000..0a8969beb
--- /dev/null
+++ b/toolkits/mastodon/Makefile
@@ -0,0 +1,55 @@
+.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
+ @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi
+ @echo "✅ All packages and dependencies installed via uv"
+
+.PHONY: install-local
+install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources
+ @echo "🚀 Creating virtual environment and installing all packages using uv"
+ @uv sync --active --all-extras
+ @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi
+ @echo "✅ All packages and dependencies installed via uv"
+
+.PHONY: build
+build: clean-build ## Build wheel file using poetry
+ @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 --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
+
+.PHONY: coverage
+coverage: ## Generate coverage report
+ @echo "coverage report"
+ @uv run --no-sources coverage report
+ @echo "Generating coverage report"
+ @uv run --no-sources 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 --no-sources --bump patch
+
+.PHONY: check
+check: ## Run code quality tools.
+ @if [ -f .pre-commit-config.yaml ]; then\
+ echo "🚀 Linting code: Running pre-commit";\
+ uv run --no-sources pre-commit run -a;\
+ fi
+ @echo "🚀 Static type checking: Running mypy"
+ @uv run --no-sources mypy --config-file=pyproject.toml
diff --git a/toolkits/mastodon/README.md b/toolkits/mastodon/README.md
new file mode 100644
index 000000000..9e00317d5
--- /dev/null
+++ b/toolkits/mastodon/README.md
@@ -0,0 +1,26 @@
+
+

+
+
+
+
+
+
+
+
+# Arcade mastodon Toolkit
+Allow the agent to interact with a Mastodon server
+## Features
+
+- The mastodon toolkit does not have any features yet.
+
+## Development
+
+Read the docs on how to create a toolkit [here](https://docs.arcade.dev/home/build-tools/create-a-toolkit)
diff --git a/toolkits/mastodon/arcade_mastodon/__init__.py b/toolkits/mastodon/arcade_mastodon/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/toolkits/mastodon/arcade_mastodon/tools/__init__.py b/toolkits/mastodon/arcade_mastodon/tools/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/toolkits/mastodon/arcade_mastodon/tools/statuses.py b/toolkits/mastodon/arcade_mastodon/tools/statuses.py
new file mode 100644
index 000000000..339c243b8
--- /dev/null
+++ b/toolkits/mastodon/arcade_mastodon/tools/statuses.py
@@ -0,0 +1,173 @@
+from typing import Annotated
+
+import httpx
+from arcade_tdk import ToolContext, tool
+from arcade_tdk.auth import OAuth2
+from arcade_tdk.errors import RetryableToolError, ToolExecutionError
+
+from arcade_mastodon.tools.users import lookup_single_user_by_username
+from arcade_mastodon.utils import get_headers, get_url, parse_status, parse_statuses
+
+
+@tool(
+ requires_auth=OAuth2(
+ id="mastodon",
+ scopes=["write:statuses"],
+ ),
+ requires_secrets=["MASTODON_SERVER_URL"],
+)
+async def post_status(
+ context: ToolContext,
+ status: Annotated[str, "The status to post"],
+) -> Annotated[dict, "The status object from Mastodon"]:
+ """
+ Post a status to Mastodon.
+ """
+
+ async with httpx.AsyncClient() as client_http:
+ response = await client_http.post(
+ get_url(context=context, endpoint="statuses"),
+ headers=get_headers(context),
+ json={"status": status},
+ )
+ response.raise_for_status()
+ return parse_status(response.json())
+
+
+@tool(
+ requires_auth=OAuth2(
+ id="mastodon",
+ scopes=["write:statuses"],
+ ),
+ requires_secrets=["MASTODON_SERVER_URL"],
+)
+async def delete_status_by_id(
+ context: ToolContext,
+ status_id: Annotated[str, "The ID of the status to delete"],
+) -> Annotated[dict, "The status object from Mastodon that was deleted"]:
+ """
+ Delete a Mastodon status by its ID.
+ """
+
+ async with httpx.AsyncClient() as client_http:
+ response = await client_http.delete(
+ get_url(context=context, endpoint=f"statuses/{status_id}"),
+ headers=get_headers(context),
+ )
+ response.raise_for_status()
+ return parse_status(response.json())
+
+
+@tool(
+ requires_auth=OAuth2(
+ id="mastodon",
+ scopes=["read:statuses"],
+ ),
+ requires_secrets=["MASTODON_SERVER_URL"],
+)
+async def lookup_status_by_id(
+ context: ToolContext,
+ status_id: Annotated[str, "The ID of the status to lookup"],
+) -> Annotated[dict, "The status object from Mastodon"]:
+ """
+ Lookup a Mastodon status by its ID.
+ """
+
+ async with httpx.AsyncClient() as client_http:
+ response = await client_http.get(
+ get_url(context=context, endpoint=f"statuses/{status_id}"),
+ headers=get_headers(context),
+ )
+ response.raise_for_status()
+ return parse_status(response.json())
+
+
+@tool(
+ requires_auth=OAuth2(
+ id="mastodon",
+ scopes=["read:statuses", "read:accounts"],
+ ),
+ requires_secrets=["MASTODON_SERVER_URL"],
+)
+async def search_recent_statuses_by_username(
+ context: ToolContext,
+ username: Annotated[str, "The username of the Mastodon account to look up."],
+ limit: Annotated[
+ int, "The maximum number of statuses to return. Default is 20, maximum is 40."
+ ] = 20,
+) -> Annotated[dict, "The statuses from Mastodon"]:
+ """
+ Search for recent statuses by a username.
+ """
+
+ account_info = await lookup_single_user_by_username(context, username)
+ if not account_info["account"]:
+ raise ToolExecutionError(
+ message=f"Account {username} not found.",
+ developer_message=f"Account {username} not found while searching for recent statuses.",
+ )
+
+ account_id = account_info["account"]["id"]
+
+ limit = max(1, min(limit, 40))
+
+ async with httpx.AsyncClient() as client_http:
+ response = await client_http.get(
+ get_url(context=context, endpoint=f"accounts/{account_id}/statuses"),
+ headers=get_headers(context),
+ params={"limit": limit},
+ )
+ response.raise_for_status()
+ return {"statuses": parse_statuses(response.json())}
+
+
+@tool(
+ requires_auth=OAuth2(
+ id="mastodon",
+ scopes=["read:statuses", "read:accounts"],
+ ),
+ requires_secrets=["MASTODON_SERVER_URL"],
+)
+async def search_recent_statuses_by_keywords(
+ context: ToolContext,
+ keywords: Annotated[list[str] | None, "The keywords to search for."] = None,
+ phrases: Annotated[list[str] | None, "The phrases to search for."] = None,
+ limit: Annotated[
+ int, "The maximum number of statuses to return. Default is 20, maximum is 40."
+ ] = 20,
+) -> Annotated[dict, "The statuses from Mastodon"]:
+ """
+ Search for recent statuses by keywords and phrases.
+ """
+
+ if not any([keywords, phrases]):
+ raise RetryableToolError(
+ message="At least one keyword or one phrase must be provided to this tool.",
+ developer_message="The LLM did not provide any keywords or phrases to"
+ " search for recent statuses.",
+ additional_prompt_content="Please provide at least one keyword or one phrase"
+ " to search for recent statuses.",
+ retry_after_ms=500,
+ )
+
+ query = "".join(f'"{phrase}"' for phrase in phrases or [])
+ if keywords:
+ query += " ".join(f"{keyword}" for keyword in keywords)
+
+ limit = max(1, min(limit, 40))
+
+ async with httpx.AsyncClient() as client_http:
+ response = await client_http.get(
+ get_url(
+ context=context,
+ endpoint="search",
+ api_version="v2",
+ ),
+ headers=get_headers(context),
+ params={
+ "q": query,
+ "limit": limit,
+ },
+ )
+ response.raise_for_status()
+ return {"statuses": parse_statuses(response.json())}
diff --git a/toolkits/mastodon/arcade_mastodon/tools/users.py b/toolkits/mastodon/arcade_mastodon/tools/users.py
new file mode 100644
index 000000000..95cfcfcb0
--- /dev/null
+++ b/toolkits/mastodon/arcade_mastodon/tools/users.py
@@ -0,0 +1,31 @@
+from typing import Annotated
+
+import httpx
+from arcade_tdk import ToolContext, tool
+from arcade_tdk.auth import OAuth2
+
+from arcade_mastodon.utils import get_headers, get_url
+
+
+@tool(
+ requires_auth=OAuth2(
+ id="mastodon",
+ scopes=["read:accounts"],
+ ),
+ requires_secrets=["MASTODON_SERVER_URL"],
+)
+async def lookup_single_user_by_username(
+ context: ToolContext,
+ username: Annotated[str, "The username of the Mastodon account to look up."],
+) -> Annotated[dict, "The account object from Mastodon"]:
+ """
+ Lookup a single Mastodon account by its username.
+ """
+
+ async with httpx.AsyncClient() as client_http:
+ response = await client_http.get(
+ get_url(context=context, endpoint=f"accounts/lookup?acct={username}"),
+ headers=get_headers(context),
+ )
+ response.raise_for_status()
+ return {"account": response.json()}
diff --git a/toolkits/mastodon/arcade_mastodon/utils.py b/toolkits/mastodon/arcade_mastodon/utils.py
new file mode 100644
index 000000000..910f17aa6
--- /dev/null
+++ b/toolkits/mastodon/arcade_mastodon/utils.py
@@ -0,0 +1,50 @@
+from typing import Any
+
+from arcade_tdk import ToolContext
+from arcade_tdk.errors import ToolExecutionError
+
+
+def get_headers(context: ToolContext) -> dict[str, str]:
+ """Build headers for Mastodon API requests."""
+ token = context.get_auth_token_or_empty()
+ if not token:
+ raise ToolExecutionError(message="No Mastodon token found")
+ return {
+ "Authorization": f"Bearer {token}",
+ "Content-Type": "application/json",
+ }
+
+
+def get_url(
+ context: ToolContext,
+ endpoint: str,
+ api_version: str = "v1",
+) -> str:
+ """Build the full URL for a Mastodon API endpoint."""
+ try:
+ return f"{context.get_secret('MASTODON_SERVER_URL')}/api/{api_version}/{endpoint}"
+ except ValueError as e:
+ raise ToolExecutionError(
+ message="MASTODON_SERVER_URL is not set in the secrets",
+ ) from e
+
+
+def parse_status(status: dict[str, Any]) -> dict[str, Any]:
+ """Filter out unnecessary fields from the status object."""
+ return {
+ "id": status["id"],
+ "url": status["url"],
+ "content": status["content"],
+ "created_at": status["created_at"],
+ "tags": status["tags"],
+ "media_attachments": status["media_attachments"],
+ "account_id": status["account"]["id"],
+ "account_username": status["account"]["username"],
+ "account_display_name": status["account"]["display_name"],
+ "favourites_count": status["favourites_count"],
+ }
+
+
+def parse_statuses(statuses: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Filter out unnecessary fields from the statuses object."""
+ return [parse_status(status) for status in statuses]
diff --git a/toolkits/mastodon/conftest.py b/toolkits/mastodon/conftest.py
new file mode 100644
index 000000000..439033c07
--- /dev/null
+++ b/toolkits/mastodon/conftest.py
@@ -0,0 +1,23 @@
+import pytest
+from arcade_tdk import ToolContext, ToolSecretItem
+
+
+@pytest.fixture
+def tool_context() -> ToolContext:
+ return ToolContext(
+ authorization={"token": "test_token"},
+ secrets=[ToolSecretItem(key="MASTODON_SERVER_URL", value="https://mastodon.social")],
+ )
+
+
+@pytest.fixture
+def httpx_mock(mocker):
+ mock_client = mocker.patch("httpx.AsyncClient", autospec=True)
+ async_mock_client = mock_client.return_value.__aenter__.return_value
+ return async_mock_client
+
+
+@pytest.fixture
+def mock_lookup_single_user_by_username(mocker):
+ mock_tool = mocker.patch("arcade_mastodon.tools.statuses.lookup_single_user_by_username")
+ return mock_tool
diff --git a/toolkits/mastodon/evals/eval_mastodon.py b/toolkits/mastodon/evals/eval_mastodon.py
new file mode 100644
index 000000000..d6d2aa3e1
--- /dev/null
+++ b/toolkits/mastodon/evals/eval_mastodon.py
@@ -0,0 +1,137 @@
+from arcade_evals import (
+ EvalRubric,
+ EvalSuite,
+ ExpectedToolCall,
+ tool_eval,
+)
+from arcade_evals.critic import BinaryCritic, NumericCritic, SimilarityCritic
+from arcade_tdk import ToolCatalog
+
+import arcade_mastodon
+from arcade_mastodon.tools.statuses import (
+ delete_status_by_id,
+ lookup_status_by_id,
+ post_status,
+ search_recent_statuses_by_keywords,
+ search_recent_statuses_by_username,
+)
+from arcade_mastodon.tools.users import lookup_single_user_by_username
+
+# Evaluation rubric
+rubric = EvalRubric(
+ fail_threshold=0.85,
+ warn_threshold=0.95,
+)
+
+
+catalog = ToolCatalog()
+catalog.add_module(arcade_mastodon)
+
+
+@tool_eval()
+def mastodon_eval_suite() -> EvalSuite:
+ suite = EvalSuite(
+ name="mastodon Tools Evaluation",
+ system_message=(
+ "You are an AI assistant with access to mastodon tools. "
+ "Use them to help the user with their tasks."
+ ),
+ catalog=catalog,
+ rubric=rubric,
+ )
+
+ suite.add_case(
+ name="Lookup a user by username",
+ user_message="Please lookup the user with the username @techblogger",
+ expected_tool_calls=[
+ ExpectedToolCall(func=lookup_single_user_by_username, args={"username": "techblogger"})
+ ],
+ rubric=rubric,
+ critics=[
+ SimilarityCritic(critic_field="username", weight=1.0),
+ ],
+ )
+
+ suite.add_case(
+ name="Posting a status",
+ user_message="Post a status to Mastodon saying 'Hello, world from an AI assistant!'",
+ expected_tool_calls=[
+ ExpectedToolCall(
+ func=post_status, args={"status": "Hello, world from an AI assistant!"}
+ )
+ ],
+ rubric=rubric,
+ critics=[
+ SimilarityCritic(critic_field="status", weight=1.0),
+ ],
+ )
+
+ suite.add_case(
+ name="Delete a status by ID",
+ user_message="Please delete the status with the ID 1234567890",
+ expected_tool_calls=[
+ ExpectedToolCall(func=delete_status_by_id, args={"status_id": "1234567890"})
+ ],
+ rubric=rubric,
+ critics=[
+ SimilarityCritic(critic_field="status_id", weight=1.0),
+ ],
+ )
+
+ suite.add_case(
+ name="Lookup a status by ID",
+ user_message="Please lookup the status with the ID 1234567890",
+ expected_tool_calls=[
+ ExpectedToolCall(func=lookup_status_by_id, args={"status_id": "1234567890"})
+ ],
+ rubric=rubric,
+ critics=[
+ SimilarityCritic(critic_field="status_id", weight=1.0),
+ ],
+ )
+
+ suite.add_case(
+ name="Search for statuses by username",
+ user_message="Show me the last 10 statuses from the user @techblogger",
+ expected_tool_calls=[
+ ExpectedToolCall(
+ func=search_recent_statuses_by_username,
+ args={"username": "techblogger", "limit": 10},
+ )
+ ],
+ rubric=rubric,
+ critics=[
+ SimilarityCritic(critic_field="username", weight=0.5),
+ NumericCritic(critic_field="limit", weight=0.5, value_range=(9, 11)),
+ ],
+ )
+
+ suite.add_case(
+ name="Search for statuses by username with default limit",
+ user_message="Show me the last statuses from the user @techblogger",
+ expected_tool_calls=[
+ ExpectedToolCall(
+ func=search_recent_statuses_by_username, args={"username": "techblogger"}
+ )
+ ],
+ rubric=rubric,
+ critics=[
+ SimilarityCritic(critic_field="username", weight=1.0),
+ ],
+ )
+
+ suite.add_case(
+ name="Search for statuses by keywords",
+ user_message="Show me the last statuses with the keywords 'mastodon' and 'ai'",
+ expected_tool_calls=[
+ ExpectedToolCall(
+ func=search_recent_statuses_by_keywords, args={"keywords": ["mastodon", "ai"]}
+ )
+ ],
+ rubric=rubric,
+ critics=[
+ BinaryCritic(critic_field="keywords", weight=1.0),
+ ],
+ )
+
+ return suite
diff --git a/toolkits/mastodon/pyproject.toml b/toolkits/mastodon/pyproject.toml
new file mode 100644
index 000000000..6172e71db
--- /dev/null
+++ b/toolkits/mastodon/pyproject.toml
@@ -0,0 +1,54 @@
+[build-system]
+requires = [ "hatchling",]
+build-backend = "hatchling.build"
+
+[project]
+name = "arcade_mastodon"
+version = "0.1.0"
+description = "Allow the agent to interact with a Mastodon server"
+requires-python = ">=3.10"
+dependencies = [
+ "arcade-tdk>=2.0.0,<3.0.0",
+]
+
+
+[project.optional-dependencies]
+dev = [
+ "arcade-ai[evals]>=2.0.5,<3.0.0",
+ "arcade-serve>=2.0.0,<3.0.0",
+ "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",
+ "tox>=4.11.1,<4.12.0",
+ "ruff>=0.7.4,<0.8.0",
+]
+
+# Use local path sources for arcade libs when working locally
+[tool.uv.sources]
+arcade-ai = { path = "../../", editable = true }
+arcade-serve = { path = "../../libs/arcade-serve/", editable = true }
+arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true }
+
+[tool.mypy]
+files = [ "arcade_mastodon/**/*.py",]
+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.report]
+skip_empty = true
+
+[tool.hatch.build.targets.wheel]
+packages = [ "arcade_mastodon",]
diff --git a/toolkits/mastodon/tests/__init__.py b/toolkits/mastodon/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/toolkits/mastodon/tests/test_statuses.py b/toolkits/mastodon/tests/test_statuses.py
new file mode 100644
index 000000000..74b9d7dc2
--- /dev/null
+++ b/toolkits/mastodon/tests/test_statuses.py
@@ -0,0 +1,327 @@
+from unittest.mock import MagicMock
+
+import httpx
+import pytest
+from arcade_tdk.errors import RetryableToolError, ToolExecutionError
+
+from arcade_mastodon.tools.statuses import (
+ delete_status_by_id,
+ lookup_status_by_id,
+ post_status,
+ search_recent_statuses_by_keywords,
+ search_recent_statuses_by_username,
+)
+
+full_status_text = """Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+Sed non interdum dolor. Pellentesque augue mauris, venenatis dapibus massa eget,
+elementum rutrum ex. Donec et purus egestas, pharetra odio rhoncus, feugiat dolor.
+Cras sollicitudin, enim sit amet sagittis consequat, metus mauris blandit neque, eu
+finibus leo velit a lacus. Curabitur a justo lorem. Integer iaculis feugiat ex sed imperdiet.
+Cras feugiat ut justo quis venenatis. Pellentesque efficitur sit amet enim quis facilisis.
+Proin malesuada mollis purus, eu tempus sem venenatis a.
+Vestibulum convallis tortor ac elementum gravida.
+Sed arcu massa, dictum ac suscipit id, pretium non nulla. Aenean lorem urna, convallis nec elit non,
+ehoncus sollicitudin eros. Vestibulum eget dolor consectetur, tempor orci quis, elementum neque.
+Aenean quis consectetur justo, vitae faucibus velit. Proin ex metus, lacinia sit amet lorem et,
+posuere facilisis nunc. Donec ut viverra sem, ac iaculis neque.
+
+Morbi blandit ante ut tellus varius fringilla. Quisque eget felis non tellus
+lobortis congue. Praesent eu malesuada est. Morbi dui dolor, vehicula non est eu,
+dapibus pellentesque sapien. In hac habitasse platea dictumst. Praesent ac est scelerisque,
+tristique magna et, viverra nunc. Nunc accumsan magna nec felis varius, ut iaculis velit dapibus.
+Aenean sit amet porttitor leo."""
+
+truncated_status_text = full_status_text[:500]
+
+
+fake_account = {
+ "acct": "testuser",
+ "avatar": "https://files.example.com/image.jpg",
+ "avatar_static": "https://files.example.com/image.jpg",
+ "bot": False,
+ "created_at": "2025-06-27T00:00:00.000Z",
+ "discoverable": True,
+ "display_name": "Test User",
+ "emojis": [],
+ "fields": [],
+ "followers_count": 0,
+ "following_count": 0,
+ "group": False,
+ "header": "https://example.com/headers/original/missing.png",
+ "header_static": "https://example.com/headers/original/missing.png",
+ "hide_collections": None,
+ "id": "0987654321",
+ "indexable": True,
+ "last_status_at": "2025-06-30",
+ "locked": False,
+ "noindex": False,
+ "note": "Test account for Arcade
",
+ "roles": [],
+ "statuses_count": 5,
+ "uri": "https://example.com/users/testuser",
+ "url": "https://example.com/@testuser",
+ "username": "testuser",
+}
+
+fake_status_response = {
+ "account": fake_account,
+ "application": {"name": "Test Application", "website": "https://example.com"},
+ "bookmarked": False,
+ "card": None,
+ "content": "This is a test status
",
+ "created_at": "2025-06-30T14:53:12.499Z",
+ "edited_at": None,
+ "emojis": [],
+ "favourited": False,
+ "favourites_count": 0,
+ "filtered": [],
+ "id": "1234567890",
+ "in_reply_to_account_id": None,
+ "in_reply_to_id": None,
+ "language": "en",
+ "media_attachments": [],
+ "mentions": [],
+ "muted": False,
+ "pinned": False,
+ "poll": None,
+ "quote": None,
+ "reblog": None,
+ "reblogged": False,
+ "reblogs_count": 0,
+ "replies_count": 0,
+ "sensitive": False,
+ "spoiler_text": "",
+ "tags": [],
+ "uri": "https://example.com/users/testuser/statuses/1234567890",
+ "url": "https://example.com/@testuser/1234567890",
+ "visibility": "public",
+}
+
+
+fake_parsed_status = {
+ "account_display_name": "Test User",
+ "account_id": "0987654321",
+ "account_username": "testuser",
+ "content": "This is a test status
",
+ "created_at": "2025-06-30T14:53:12.499Z",
+ "favourites_count": 0,
+ "id": "1234567890",
+ "media_attachments": [],
+ "tags": [],
+ "url": "https://example.com/@testuser/1234567890",
+}
+
+
+@pytest.mark.asyncio
+async def test_post_status_success(
+ tool_context,
+ httpx_mock,
+) -> None:
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = fake_status_response
+ httpx_mock.post.return_value = mock_response
+
+ status = "Hello, world!"
+ result = await post_status(
+ context=tool_context,
+ status=status,
+ )
+
+ assert result == fake_parsed_status
+ httpx_mock.post.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_delete_status_by_id_success(
+ tool_context,
+ httpx_mock,
+):
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = fake_status_response
+ httpx_mock.delete.return_value = mock_response
+
+ status_id = "1234567890"
+ result = await delete_status_by_id(
+ context=tool_context,
+ status_id=status_id,
+ )
+
+ assert result == fake_parsed_status
+ httpx_mock.delete.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_delete_status_by_id_failure(
+ tool_context,
+ httpx_mock,
+):
+ mock_response = httpx.HTTPStatusError(
+ "Internal Server Error",
+ request=MagicMock(),
+ response=MagicMock(status_code=404),
+ )
+ httpx_mock.delete.side_effect = mock_response
+
+ status_id = "1234567890"
+ with pytest.raises(ToolExecutionError):
+ await delete_status_by_id(
+ context=tool_context,
+ status_id=status_id,
+ )
+
+ httpx_mock.delete.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_search_recent_statuses_by_username_success(
+ tool_context,
+ httpx_mock,
+ mock_lookup_single_user_by_username,
+):
+ mock_lookup_single_user_by_username.return_value = {"account": fake_account}
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = [fake_status_response] * 5
+ httpx_mock.get.return_value = mock_response
+
+ username = "testuser"
+ result = await search_recent_statuses_by_username(
+ context=tool_context,
+ username=username,
+ )
+
+ assert "statuses" in result
+ assert len(result["statuses"]) == 5
+ assert result["statuses"][0]["content"] == fake_parsed_status["content"]
+ httpx_mock.get.assert_called_once()
+ mock_lookup_single_user_by_username.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_search_recent_statuses_by_username_no_statuses_found(
+ tool_context,
+ httpx_mock,
+ mock_lookup_single_user_by_username,
+):
+ mock_lookup_single_user_by_username.return_value = {"account": fake_account}
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = []
+ httpx_mock.get.return_value = mock_response
+
+ username = "testuser"
+ result = await search_recent_statuses_by_username(
+ context=tool_context,
+ username=username,
+ )
+
+ assert "statuses" in result
+ assert len(result["statuses"]) == 0
+ httpx_mock.get.assert_called_once()
+ mock_lookup_single_user_by_username.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_search_recent_statuses_by_username_failure(
+ tool_context,
+ httpx_mock,
+ mock_lookup_single_user_by_username,
+):
+ mock_lookup_single_user_by_username.return_value = {"account": None}
+ mock_response = httpx.HTTPStatusError(
+ "Internal Server Error",
+ request=MagicMock(),
+ response=MagicMock(status_code=500),
+ )
+ httpx_mock.get.side_effect = mock_response
+
+ username = "testuser"
+ with pytest.raises(ToolExecutionError):
+ await search_recent_statuses_by_username(
+ context=tool_context,
+ username=username,
+ )
+
+ httpx_mock.get.assert_not_called()
+ mock_lookup_single_user_by_username.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_search_recent_statuses_by_keywords_success(
+ tool_context,
+ httpx_mock,
+):
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = [fake_status_response] * 5
+ httpx_mock.get.return_value = mock_response
+
+ keywords = ["test", "keyword"]
+ phrases = ["test phrase"]
+ result = await search_recent_statuses_by_keywords(
+ context=tool_context,
+ keywords=keywords,
+ phrases=phrases,
+ )
+
+ assert "statuses" in result
+ assert len(result["statuses"]) == 5
+ assert result["statuses"][0]["content"] == fake_parsed_status["content"]
+ httpx_mock.get.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_search_recent_statuses_by_keywords_no_keywords_or_phrases(
+ tool_context,
+ httpx_mock,
+):
+ with pytest.raises(RetryableToolError):
+ await search_recent_statuses_by_keywords(
+ context=tool_context,
+ )
+
+ httpx_mock.get.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_lookup_status_by_id_success(
+ tool_context,
+ httpx_mock,
+):
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = fake_status_response
+ httpx_mock.get.return_value = mock_response
+
+ status_id = "1234567890"
+ result = await lookup_status_by_id(
+ context=tool_context,
+ status_id=status_id,
+ )
+
+ assert result == fake_parsed_status
+ httpx_mock.get.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_lookup_status_by_id_failure(
+ tool_context,
+ httpx_mock,
+):
+ mock_response = httpx.HTTPStatusError(
+ "Not Found",
+ request=MagicMock(),
+ response=MagicMock(status_code=404),
+ )
+ httpx_mock.get.side_effect = mock_response
+
+ status_id = "1234567890"
+ with pytest.raises(ToolExecutionError):
+ await lookup_status_by_id(
+ context=tool_context,
+ status_id=status_id,
+ )
+
+ httpx_mock.get.assert_called_once()
diff --git a/toolkits/mastodon/tests/test_users.py b/toolkits/mastodon/tests/test_users.py
new file mode 100644
index 000000000..d3702bd56
--- /dev/null
+++ b/toolkits/mastodon/tests/test_users.py
@@ -0,0 +1,91 @@
+from unittest.mock import MagicMock
+
+import httpx
+import pytest
+from arcade_tdk.errors import ToolExecutionError
+
+from arcade_mastodon.tools.users import lookup_single_user_by_username
+
+fake_account = {
+ "acct": "testuser",
+ "avatar": "https://files.example.com/image.jpg",
+ "avatar_static": "https://files.example.com/image.jpg",
+ "bot": False,
+ "created_at": "2025-06-27T00:00:00.000Z",
+ "discoverable": True,
+ "display_name": "Test User",
+ "emojis": [],
+ "fields": [],
+ "followers_count": 0,
+ "following_count": 0,
+ "group": False,
+ "header": "https://example.com/headers/original/missing.png",
+ "header_static": "https://example.com/headers/original/missing.png",
+ "hide_collections": None,
+ "id": "0987654321",
+ "indexable": True,
+ "last_status_at": "2025-06-30",
+ "locked": False,
+ "noindex": False,
+ "note": "Test account for Arcade
",
+ "roles": [],
+ "statuses_count": 5,
+ "uri": "https://example.com/users/testuser",
+ "url": "https://example.com/@testuser",
+ "username": "testuser",
+}
+
+
+@pytest.mark.asyncio
+async def test_lookup_single_user_by_username_success(tool_context, httpx_mock):
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = fake_account
+ httpx_mock.get.return_value = mock_response
+
+ username = "testuser"
+ result = await lookup_single_user_by_username(
+ context=tool_context,
+ username=username,
+ )
+
+ assert result == {"account": fake_account}
+ httpx_mock.get.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_lookup_single_user_by_username_user_not_found(tool_context, httpx_mock):
+ mock_response = httpx.HTTPStatusError(
+ "Not Found",
+ request=MagicMock(),
+ response=MagicMock(status_code=404),
+ )
+ httpx_mock.get.side_effect = mock_response
+
+ username = "testuser"
+ with pytest.raises(ToolExecutionError):
+ await lookup_single_user_by_username(
+ context=tool_context,
+ username=username,
+ )
+
+ httpx_mock.get.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_lookup_single_user_by_username_failure(tool_context, httpx_mock):
+ mock_response = httpx.HTTPStatusError(
+ "Internal Server Error",
+ request=MagicMock(),
+ response=MagicMock(status_code=500),
+ )
+ httpx_mock.get.side_effect = mock_response
+
+ username = "testuser"
+ with pytest.raises(ToolExecutionError):
+ await lookup_single_user_by_username(
+ context=tool_context,
+ username=username,
+ )
+
+ httpx_mock.get.assert_called_once()