diff --git a/examples/mcp_servers/resources/pyproject.toml b/examples/mcp_servers/resources/pyproject.toml
new file mode 100644
index 00000000..285a6416
--- /dev/null
+++ b/examples/mcp_servers/resources/pyproject.toml
@@ -0,0 +1,44 @@
+[project]
+name = "resources"
+version = "0.1.0"
+description = "Example MCP server showcasing resource features (static, file-backed, templates, annotations)"
+requires-python = ">=3.10"
+dependencies = [
+ "arcade-mcp-server>=1.19.0,<2.0.0",
+]
+
+[project.optional-dependencies]
+dev = [
+ "arcade-mcp[all]>=1.12.1,<2.0.0",
+ "pytest>=7.0.0",
+ "pytest-asyncio>=0.21.0",
+ "mypy>=1.0.0",
+ "ruff>=0.1.0",
+]
+
+# Tell Arcade.dev that this package has Arcade tools
+[project.entry-points.arcade_toolkits]
+toolkit_name = "resources"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/resources"]
+
+[tool.ruff]
+line-length = 100
+target-version = "py312"
+
+[tool.mypy]
+python_version = "3.12"
+warn_unused_configs = true
+disallow_untyped_defs = false
+
+# Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode
+# Otherwise, you will install the following packages from PyPI
+# [tool.uv.sources]
+# arcade-mcp = { path = "../../../", editable = true }
+# arcade-serve = { path = "../../../libs/arcade-serve/", editable = true }
+# arcade-mcp-server = { path = "../../../libs/arcade-mcp-server/", editable = true }
diff --git a/examples/mcp_servers/resources/src/resources/__init__.py b/examples/mcp_servers/resources/src/resources/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/examples/mcp_servers/resources/src/resources/app.html b/examples/mcp_servers/resources/src/resources/app.html
new file mode 100644
index 00000000..5d1ce41e
--- /dev/null
+++ b/examples/mcp_servers/resources/src/resources/app.html
@@ -0,0 +1,165 @@
+
+
+
+
+
+Knowledge Base — MCP App
+
+
+
+Knowledge Base — MCP App
+
+
+
+
Get Article
+
+
+
+
Select an article and click Fetch.
+
+
+
+
+
Search Articles
+
+
+
+
Enter a query and click Search.
+
+
+
+
+
diff --git a/examples/mcp_servers/resources/src/resources/data.py b/examples/mcp_servers/resources/src/resources/data.py
new file mode 100644
index 00000000..aa63ec0c
--- /dev/null
+++ b/examples/mcp_servers/resources/src/resources/data.py
@@ -0,0 +1,37 @@
+"""In-memory data for the "Company Knowledge Base" demo."""
+
+from resources.schemas import Author
+
+KB_ARTICLES: dict[str, dict[str, str]] = {
+ "getting-started": {
+ "title": "Getting Started",
+ "category": "onboarding",
+ "author": "docs-team",
+ "body": "Welcome to the company knowledge base! Start here to learn the basics.",
+ },
+ "api-guidelines": {
+ "title": "API Design Guidelines",
+ "category": "engineering",
+ "author": "platform-team",
+ "body": "All APIs must follow REST conventions and use JSON payloads.",
+ },
+ "security-policy": {
+ "title": "Security Policy",
+ "category": "compliance",
+ "author": "security-team",
+ "body": "All employees must use MFA and rotate credentials every 90 days.",
+ },
+}
+
+KB_CATEGORIES: dict[str, list[str]] = {
+ "onboarding": ["getting-started"],
+ "engineering": ["api-guidelines"],
+ "compliance": ["security-policy"],
+}
+
+# Maps short author keys to structured Author records
+TEAMS: dict[str, Author] = {
+ "docs-team": Author(name="Documentation Team", team="Developer Experience"),
+ "platform-team": Author(name="Platform Team", team="Engineering"),
+ "security-team": Author(name="Security Team", team="Compliance & Risk"),
+}
diff --git a/examples/mcp_servers/resources/src/resources/logo.png b/examples/mcp_servers/resources/src/resources/logo.png
new file mode 100644
index 00000000..2cb87f92
Binary files /dev/null and b/examples/mcp_servers/resources/src/resources/logo.png differ
diff --git a/examples/mcp_servers/resources/src/resources/schemas.py b/examples/mcp_servers/resources/src/resources/schemas.py
new file mode 100644
index 00000000..7f249332
--- /dev/null
+++ b/examples/mcp_servers/resources/src/resources/schemas.py
@@ -0,0 +1,37 @@
+"""TypedDict definitions for structured tool outputs."""
+
+from typing_extensions import TypedDict
+
+
+class Author(TypedDict):
+ """The team or person who authored an article."""
+
+ name: str
+ team: str
+
+
+class ArticleDetail(TypedDict):
+ """Full article detail with nested author information."""
+
+ slug: str
+ title: str
+ category: str
+ body: str
+ author: Author
+
+
+class SearchMatch(TypedDict):
+ """A single search hit with relevance context."""
+
+ slug: str
+ title: str
+ category: str
+ matched_field: str
+
+
+class SearchResult(TypedDict):
+ """Search results with metadata"""
+
+ query: str
+ total_matches: int
+ matches: list[SearchMatch]
diff --git a/examples/mcp_servers/resources/src/resources/server.py b/examples/mcp_servers/resources/src/resources/server.py
new file mode 100644
index 00000000..06f18cba
--- /dev/null
+++ b/examples/mcp_servers/resources/src/resources/server.py
@@ -0,0 +1,322 @@
+#!/usr/bin/env python3
+"""
+Resources Example MCP Server
+
+Comprehensive showcase of MCP resource features in arcade-mcp-server:
+
+ 1. @app.resource(uri) decorator — register a resource with a handler
+ 2. app.add_resource(uri, handler=...) — imperative registration
+ 3. app.add_text_resource(uri, text=...) — static text convenience
+ 4. app.add_file_resource(uri, path=...) — file-backed resource
+ 5. URI templates with {param} — parameterized resources
+ 6. Wildcard templates {param*} — greedy path matching
+ 7. Annotations(priority=...) — resource annotations
+ 8. meta={...} — custom metadata
+ 9. Async handlers + return types — bytes, dict, str
+10. @app.tool(meta={...}) — MCP Apps (tool-to-UI linking)
+11. Nested TypedDict tool outputs — recursive structured output schemas
+"""
+
+import asyncio
+import json
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Annotated
+
+from arcade_mcp_server import Annotations, MCPApp
+from resources.data import KB_ARTICLES, KB_CATEGORIES, TEAMS
+from resources.schemas import ArticleDetail, Author, SearchMatch, SearchResult
+
+app = MCPApp(name="resources", version="1.0.0", log_level="DEBUG")
+
+_HERE = Path(__file__).parent
+
+
+# ===========================================================================
+# 1. @app.resource decorator
+# ===========================================================================
+# The simplest way to register a resource with a handler function.
+# The handler receives the URI as its first argument and returns content.
+
+
+@app.resource(
+ "kb://articles/index",
+ name="Article Index",
+ description="List of all knowledge base articles",
+ mime_type="application/json",
+)
+def article_index(uri: str) -> str:
+ """Return a JSON index of all articles."""
+ index = [
+ {"slug": slug, "title": a["title"], "category": a["category"]}
+ for slug, a in KB_ARTICLES.items()
+ ]
+ return json.dumps(index, indent=2)
+
+
+# ===========================================================================
+# 2. app.add_resource — imperative registration
+# ===========================================================================
+# Use this when you want to register a resource without a decorator,
+# for example when the handler is defined elsewhere or generated dynamically.
+
+
+def _serve_category_list(uri: str) -> str:
+ """Return the list of categories as JSON."""
+ return json.dumps(list(KB_CATEGORIES.keys()))
+
+
+app.add_resource(
+ "kb://categories",
+ name="Categories",
+ description="List of all article categories",
+ mime_type="application/json",
+ handler=_serve_category_list,
+)
+
+
+# ===========================================================================
+# 3. app.add_text_resource — static text convenience
+# ===========================================================================
+# One-liner for resources whose content is known at registration time.
+# No handler function needed — the text is served directly.
+
+app.add_text_resource(
+ "kb://readme",
+ text="Welcome to the Company Knowledge Base.\n\nBrowse articles by category or search by slug.",
+ name="README",
+ description="Knowledge base welcome text",
+)
+
+
+# ===========================================================================
+# 4. app.add_file_resource — file-backed resource
+# ===========================================================================
+# Serves a file from disk. Text files are returned as TextResourceContents;
+# binary files (detected via UnicodeDecodeError) as BlobResourceContents.
+
+app.add_file_resource(
+ "kb://config/pyproject",
+ path=_HERE.resolve().parents[1] / "pyproject.toml",
+ name="Project Config",
+ description="The pyproject.toml for this example server",
+ mime_type="text/plain",
+)
+
+
+# ===========================================================================
+# 5. URI templates with {param}
+# ===========================================================================
+# When a URI contains {braces}, it is automatically registered as a
+# ResourceTemplate. The handler receives extracted parameters as kwargs.
+
+
+@app.resource(
+ "kb://articles/{slug}",
+ name="Article by Slug",
+ description="Retrieve a specific article by its slug",
+ mime_type="application/json",
+)
+def article_by_slug(uri: str, slug: str) -> str:
+ """Return a single article as JSON. 'slug' is extracted from the URI."""
+ article = KB_ARTICLES.get(slug)
+ if article is None:
+ return json.dumps({"error": f"Article '{slug}' not found"})
+ return json.dumps(article, indent=2)
+
+
+# Multi-parameter template — both {category} and {slug} are extracted.
+@app.resource(
+ "kb://categories/{category}/articles/{slug}",
+ name="Article by Category and Slug",
+ description="Retrieve an article scoped to a category",
+ mime_type="application/json",
+)
+def article_in_category(uri: str, category: str, slug: str) -> str:
+ """Return an article only if it belongs to the given category."""
+ if category not in KB_CATEGORIES:
+ return json.dumps({"error": f"Category '{category}' not found"})
+ if slug not in KB_CATEGORIES[category]:
+ return json.dumps({"error": f"Article '{slug}' not in category '{category}'"})
+ return json.dumps(KB_ARTICLES[slug], indent=2)
+
+
+# ===========================================================================
+# 6. Wildcard templates {param*}
+# ===========================================================================
+# The {param*} syntax matches greedily across '/' separators, useful for
+# nested paths like "guides/setup/linux".
+
+
+@app.resource(
+ "kb://docs/{path*}",
+ name="Docs Tree",
+ description="Retrieve documentation by nested path (e.g. 'guides/setup/linux')",
+ mime_type="text/plain",
+)
+def docs_by_path(uri: str, path: str) -> str:
+ """Wildcard match — 'path' captures everything including slashes."""
+ return f"You requested documentation at path: {path}\n(In a real server, this would read from a docs tree.)"
+
+
+# ===========================================================================
+# 7. Annotations
+# ===========================================================================
+# Resource annotations let clients sort, filter, or prioritize resources.
+# The Annotations model supports 'audience' and 'priority' fields.
+
+
+@app.resource(
+ "kb://announcements/pinned",
+ name="Pinned Announcement",
+ description="The current pinned company announcement",
+ mime_type="text/plain",
+ annotations=Annotations(
+ audience=["user"],
+ priority=1.0,
+ ),
+)
+def pinned_announcement(uri: str) -> str:
+ """A high-priority resource. Clients can use annotations to sort/filter."""
+ return "All-hands meeting this Friday at 3 PM."
+
+
+# ===========================================================================
+# 8. Custom metadata (meta)
+# ===========================================================================
+# Arbitrary metadata attached to a resource, visible to clients in
+# resources/list responses under the _meta field.
+
+
+@app.resource(
+ "kb://articles/api-guidelines/metadata",
+ name="API Guidelines Metadata",
+ description="Article with custom metadata tags",
+ mime_type="application/json",
+ meta={"tags": ["api", "engineering", "standards"], "version": 2, "reviewed": True},
+)
+def article_metadata(uri: str) -> str:
+ """Resource with custom _meta fields. Clients see these in resources/list."""
+ return json.dumps(KB_ARTICLES["api-guidelines"], indent=2)
+
+
+# ===========================================================================
+# 9. Async handlers + return types
+# ===========================================================================
+# Handlers can be async. Return types are automatically coerced:
+# - str → TextResourceContents
+# - bytes → BlobResourceContents (base64-encoded)
+# - dict with "text" key → TextResourceContents
+# - dict with "blob" key → BlobResourceContents
+
+# Static PNG file — demonstrates binary file-backed resources.
+app.add_file_resource(
+ "kb://branding/logo",
+ path=_HERE / "logo.png",
+ name="Company Logo",
+ description="A small 32x32 pixel-art KB logo (binary file resource)",
+ mime_type="image/png",
+)
+
+
+@app.resource(
+ "kb://status",
+ name="Server Status",
+ description="Server health status (async handler returning dict)",
+ mime_type="application/json",
+)
+async def server_status(uri: str) -> dict:
+ """Async handler returning dict with 'text' key → TextResourceContents."""
+ await asyncio.sleep(0) # simulate async I/O
+ status = {
+ "healthy": True,
+ "article_count": len(KB_ARTICLES),
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ }
+ return {"text": json.dumps(status, indent=2)}
+
+
+# ===========================================================================
+# 10. MCP Apps — tool-to-UI resource linking with nested TypedDict outputs
+# ===========================================================================
+# Tools can declare a meta dict to attach _meta extensions. For MCP Apps,
+# set meta={"ui": {"resourceUri": "..."}} to link to an interactive HTML
+# resource. MCP Apps hosts render the HTML in a sandboxed iframe and the UI
+# can call tools back on the server via postMessage JSON-RPC.
+#
+# These tools also showcase nested TypedDict return types, which
+# arcade-mcp-server recursively expands into full JSON Schema output schemas.
+#
+# See: https://modelcontextprotocol.io/extensions/apps/build
+
+APP_RESOURCE_URI = "ui://resources/mcp-app.html"
+
+
+@app.tool(meta={"ui": {"resourceUri": APP_RESOURCE_URI}})
+def get_article(
+ slug: Annotated[str, "The article slug (e.g. 'getting-started')"],
+) -> Annotated[ArticleDetail, "Full article with nested author information"]:
+ """Retrieve a knowledge-base article by slug, including author details.
+
+ Returns an ArticleDetail (nested TypedDict) with a nested Author object,
+ demonstrating recursive structured output schemas.
+ """
+ raw = KB_ARTICLES.get(slug)
+ if raw is None:
+ raise ValueError(f"Article '{slug}' not found")
+ return ArticleDetail(
+ slug=slug,
+ title=raw["title"],
+ category=raw["category"],
+ body=raw["body"],
+ author=TEAMS.get(raw["author"], Author(name=raw["author"], team="Unknown")),
+ )
+
+
+@app.tool(meta={"ui": {"resourceUri": APP_RESOURCE_URI}})
+def search_articles(
+ query: Annotated[str, "Case-insensitive search query"],
+) -> Annotated[SearchResult, "Search results with nested match details"]:
+ """Search knowledge-base articles by title, category, or body text.
+
+ Returns a SearchResult (nested TypedDict) containing a list of SearchMatch
+ objects, demonstrating recursive structured output schemas with lists.
+ """
+ q = query.lower()
+ matches: list[SearchMatch] = []
+ for slug, article in KB_ARTICLES.items():
+ for field in ("title", "category", "body"):
+ if q in article[field].lower():
+ matches.append(
+ SearchMatch(
+ slug=slug,
+ title=article["title"],
+ category=article["category"],
+ matched_field=field,
+ )
+ )
+ break # one match per article
+ return SearchResult(
+ query=query,
+ total_matches=len(matches),
+ matches=matches,
+ )
+
+
+@app.resource(APP_RESOURCE_URI, name="MCP App UI", mime_type="text/html;profile=mcp-app")
+def serve_app_ui(uri: str) -> str:
+ """Serve the MCP App HTML from the co-located app.html file."""
+ return (_HERE / "app.html").read_text(encoding="utf-8")
+
+
+# ---------------------------------------------------------------------------
+# Entry point
+# ---------------------------------------------------------------------------
+
+if __name__ == "__main__":
+ # Get transport from command line argument, default to "stdio"
+ # - "stdio" (default): Standard I/O for Claude Desktop, CLI tools, etc.
+ # - "http": HTTPS streaming for Cursor, VS Code, etc.
+ transport = sys.argv[1] if len(sys.argv) > 1 else "stdio"
+ app.run(transport=transport, host="127.0.0.1", port=8000)
diff --git a/examples/mcp_servers/tools_with_output_schema/pyproject.toml b/examples/mcp_servers/tools_with_output_schema/pyproject.toml
new file mode 100644
index 00000000..ab40fe6e
--- /dev/null
+++ b/examples/mcp_servers/tools_with_output_schema/pyproject.toml
@@ -0,0 +1,44 @@
+[project]
+name = "tools_with_output_schema"
+version = "0.1.0"
+description = "MCP Server created with Arcade.dev"
+requires-python = ">=3.10"
+dependencies = [
+ "arcade-mcp-server>=1.19.0,<2.0.0",
+]
+
+[project.optional-dependencies]
+dev = [
+ "arcade-mcp[all]>=1.12.1,<2.0.0",
+ "pytest>=7.0.0",
+ "pytest-asyncio>=0.21.0",
+ "mypy>=1.0.0",
+ "ruff>=0.1.0",
+]
+
+# Tell Arcade.dev that this package has Arcade tools
+[project.entry-points.arcade_toolkits]
+toolkit_name = "tools_with_output_schema"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/tools_with_output_schema"]
+
+[tool.ruff]
+line-length = 100
+target-version = "py312"
+
+[tool.mypy]
+python_version = "3.12"
+warn_unused_configs = true
+disallow_untyped_defs = false
+
+# Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode
+# Otherwise, you will install the following packages from PyPI
+# [tool.uv.sources]
+# arcade-mcp = { path = "../../../", editable = true }
+# arcade-serve = { path = "../../../libs/arcade-serve/", editable = true }
+# arcade-mcp-server = { path = "../../../libs/arcade-mcp-server/", editable = true }
diff --git a/examples/mcp_servers/tools_with_output_schema/src/tools_with_output_schema/__init__.py b/examples/mcp_servers/tools_with_output_schema/src/tools_with_output_schema/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/examples/mcp_servers/tools_with_output_schema/src/tools_with_output_schema/server.py b/examples/mcp_servers/tools_with_output_schema/src/tools_with_output_schema/server.py
new file mode 100644
index 00000000..9f8e8446
--- /dev/null
+++ b/examples/mcp_servers/tools_with_output_schema/src/tools_with_output_schema/server.py
@@ -0,0 +1,188 @@
+#!/usr/bin/env python3
+"""
+tools_with_output_schema MCP server
+
+Demonstrates how Arcade tools expose structured TypedDict return types as
+fully-expanded JSON Schema output schemas, so MCP clients can validate and
+display tool results without guessing the shape of the data.
+
+Tools in this server progress from simple to complex:
+ - calculate_statistics — flat TypedDict (all scalar fields)
+ - analyze_text — TypedDict with a list field
+ - get_calendar_info — TypedDict with a nested TypedDict field
+ - parse_url — TypedDict with two levels of nesting
+"""
+
+import sys
+from collections import Counter
+from datetime import datetime
+from statistics import mean, median
+from typing import Annotated
+from urllib.parse import urlparse
+
+from arcade_mcp_server import MCPApp
+from typing_extensions import TypedDict
+
+app = MCPApp(name="tools_with_output_schema", version="1.0.0", log_level="DEBUG")
+
+
+# ---------------------------------------------------------------------------
+# TypedDict definitions
+# ---------------------------------------------------------------------------
+
+
+class Statistics(TypedDict):
+ """Descriptive statistics for a list of numbers."""
+
+ count: int
+ total: float
+ mean: float
+ median: float
+ minimum: float
+ maximum: float
+
+
+class TextAnalysis(TypedDict):
+ """Basic statistics about a piece of text."""
+
+ word_count: int
+ char_count: int
+ sentence_count: int
+ top_words: list[str] # most-frequent words, descending
+
+
+class CalendarDate(TypedDict):
+ """A broken-down calendar date."""
+
+ year: int
+ month: int
+ day: int
+ weekday: str
+ is_weekend: bool
+
+
+class CalendarInfo(TypedDict):
+ """Extended information about a date, including a nested CalendarDate."""
+
+ date: CalendarDate
+ day_of_year: int
+ week_number: int
+ days_until_year_end: int
+
+
+class UrlComponents(TypedDict):
+ """The individual components of a URL."""
+
+ scheme: str
+ host: str
+ path: str
+ port: int
+ query_string: str
+
+
+class ParsedUrl(TypedDict):
+ """A fully parsed URL with a nested UrlComponents breakdown."""
+
+ components: UrlComponents
+ is_secure: bool
+ domain: str
+
+
+# ---------------------------------------------------------------------------
+# Tools
+# ---------------------------------------------------------------------------
+
+
+@app.tool
+def calculate_statistics(
+ numbers: Annotated[list[float], "The list of numbers to analyze"],
+) -> Annotated[Statistics, "Descriptive statistics for the provided numbers"]:
+ """Compute descriptive statistics (count, total, mean, median, min, max) for a list of numbers."""
+ if not numbers:
+ raise ValueError("numbers must not be empty")
+ return Statistics(
+ count=len(numbers),
+ total=sum(numbers),
+ mean=mean(numbers),
+ median=median(numbers),
+ minimum=min(numbers),
+ maximum=max(numbers),
+ )
+
+
+@app.tool
+def analyze_text(
+ text: Annotated[str, "The text to analyze"],
+ top_n: Annotated[int, "How many top words to return"] = 5,
+) -> Annotated[TextAnalysis, "Word, character, and sentence counts plus the most frequent words"]:
+ """Analyze a piece of text and return word counts, character counts, and top words."""
+ words = text.split()
+ sentences = [s for s in text.replace("?", ".").replace("!", ".").split(".") if s.strip()]
+ word_freq = Counter(w.strip(".,!?;:\"'").lower() for w in words if w.strip(".,!?;:\"'"))
+ top_words = [word for word, _ in word_freq.most_common(top_n)]
+ return TextAnalysis(
+ word_count=len(words),
+ char_count=len(text),
+ sentence_count=len(sentences),
+ top_words=top_words,
+ )
+
+
+@app.tool
+def get_calendar_info(
+ date_str: Annotated[str, "Date in YYYY-MM-DD format"],
+) -> Annotated[CalendarInfo, "Extended calendar information including a nested date breakdown"]:
+ """Parse a date string and return detailed calendar information with a nested date object."""
+ dt = datetime.strptime(date_str, "%Y-%m-%d")
+ day_of_year = dt.timetuple().tm_yday
+ week_number = dt.isocalendar()[1]
+ year_end = datetime(dt.year, 12, 31)
+ days_until_year_end = (year_end - dt).days
+ weekday_name = dt.strftime("%A")
+ return CalendarInfo(
+ date=CalendarDate(
+ year=dt.year,
+ month=dt.month,
+ day=dt.day,
+ weekday=weekday_name,
+ is_weekend=dt.weekday() >= 5,
+ ),
+ day_of_year=day_of_year,
+ week_number=week_number,
+ days_until_year_end=days_until_year_end,
+ )
+
+
+@app.tool
+def parse_url(
+ url: Annotated[str, "The URL to parse"],
+) -> Annotated[ParsedUrl, "Fully parsed URL with a nested components breakdown"]:
+ """Parse a URL and return its components as a structured object with nested fields."""
+ parsed = urlparse(url)
+ host = parsed.hostname or ""
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
+ # Strip leading 'www.' for the bare domain
+ domain = host.removeprefix("www.")
+ return ParsedUrl(
+ components=UrlComponents(
+ scheme=parsed.scheme,
+ host=host,
+ path=parsed.path or "/",
+ port=port,
+ query_string=parsed.query,
+ ),
+ is_secure=parsed.scheme == "https",
+ domain=domain,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Entry point
+# ---------------------------------------------------------------------------
+
+if __name__ == "__main__":
+ # Get transport from command line argument, default to "stdio"
+ # - "stdio" (default): Standard I/O for Claude Desktop, CLI tools, etc.
+ # - "http": HTTPS streaming for Cursor, VS Code, etc.
+ transport = sys.argv[1] if len(sys.argv) > 1 else "stdio"
+ app.run(transport=transport, host="127.0.0.1", port=8000)
diff --git a/libs/arcade-mcp-server/arcade_mcp_server/__init__.py b/libs/arcade-mcp-server/arcade_mcp_server/__init__.py
index a33d801c..c5b5b9a3 100644
--- a/libs/arcade-mcp-server/arcade_mcp_server/__init__.py
+++ b/libs/arcade-mcp-server/arcade_mcp_server/__init__.py
@@ -2,20 +2,12 @@
MCP (Model Context Protocol) support for Arcade.
This package provides:
-- MCP server implementation for serving Arcade tools
+- MCPApp: A FastAPI-like interface for building MCP servers with decorators
+- MCPServer: Lower-level server implementation for serving Arcade tools
- Multiple transport options (stdio, HTTP/SSE)
+- Tools and resources support
- Integration with Arcade workers with factory and runner functions
- Context system for tool execution with MCP methods
-
-A FastAPI-like interface for building MCP servers.
-- Add tools with decorators or explicitly
-- Run the server with a single function call
-- Supports HTTP transport only
-
-`arcade_mcp` for running stdio directly from the command line.
-- auto discovery of tools and construction of the server
-- supports stdio transport only
-- run with uv or `python -m arcade_mcp`
"""
from arcade_tdk import tool
@@ -24,19 +16,27 @@ from arcade_mcp_server.context import Context
from arcade_mcp_server.mcp_app import MCPApp
from arcade_mcp_server.server import MCPServer
from arcade_mcp_server.settings import MCPSettings
+from arcade_mcp_server.types import (
+ Annotations,
+ BlobResourceContents,
+ Resource,
+ ResourceTemplate,
+ TextResourceContents,
+)
from arcade_mcp_server.worker import create_arcade_mcp, run_arcade_mcp
__all__ = [
+ "Annotations",
+ "BlobResourceContents",
"Context",
- # FastAPI-like interface
"MCPApp",
- # MCP Server implementation
"MCPServer",
"MCPSettings",
- # Integrated Factory and Runner
+ "Resource",
+ "ResourceTemplate",
+ "TextResourceContents",
"create_arcade_mcp",
"run_arcade_mcp",
- # Re-exported from TDK functionality
"tool",
]
diff --git a/libs/arcade-mcp-server/arcade_mcp_server/convert.py b/libs/arcade-mcp-server/arcade_mcp_server/convert.py
index c9fbe2e1..b419e388 100644
--- a/libs/arcade-mcp-server/arcade_mcp_server/convert.py
+++ b/libs/arcade-mcp-server/arcade_mcp_server/convert.py
@@ -54,10 +54,13 @@ def create_mcp_tool(materialized_tool: MaterializedTool) -> MCPTool:
description = f"[DEPRECATED: {deprecation_msg}] {description}"
# Build the tool's output schema
+ # MCP spec requires outputSchema.type to be "object"
output_schema = None
if hasattr(definition, "output") and definition.output:
output_def = definition.output
if getattr(output_def, "value_schema", None):
+ # _build_value_schema_json always returns {"type": "object", ...}
+ # (it wraps non-object types in a result property internally)
output_schema = _build_value_schema_json(output_def.value_schema)
# Build MCP tool annotations from metadata behavior fields
@@ -181,39 +184,11 @@ def build_input_schema_from_definition(definition: ToolDefinition) -> dict[str,
if getattr(definition, "input", None) and getattr(definition.input, "parameters", None):
for param in definition.input.parameters:
val_schema = getattr(param, "value_schema", None)
- schema: dict[str, Any] = {
- "type": _map_type_to_json_schema_type(getattr(val_schema, "val_type", None)),
- }
+ schema = _value_schema_to_json_schema(val_schema) if val_schema else {"type": "string"}
if getattr(param, "description", None):
schema["description"] = param.description
- if val_schema and getattr(val_schema, "enum", None):
- schema["enum"] = list(val_schema.enum)
-
- if (
- val_schema
- and val_schema.val_type == "array"
- and getattr(val_schema, "inner_val_type", None)
- ):
- schema["items"] = {"type": _map_type_to_json_schema_type(val_schema.inner_val_type)}
-
- if (
- val_schema
- and val_schema.val_type == "json"
- and getattr(val_schema, "properties", None)
- ):
- schema["type"] = "object"
- schema["properties"] = {}
- for prop_name, prop_schema in val_schema.properties.items():
- schema["properties"][prop_name] = {
- "type": _map_type_to_json_schema_type(
- getattr(prop_schema, "val_type", None)
- ),
- }
- if getattr(prop_schema, "description", None):
- schema["properties"][prop_name]["description"] = prop_schema.description
-
properties[param.name] = schema
if getattr(param, "required", False):
required.append(param.name)
@@ -241,30 +216,51 @@ def _build_value_schema_json(value_schema: Any) -> dict[str, Any]:
the wrapping performed at runtime by
:func:`convert_content_to_structured_content`.
"""
- val_type = getattr(value_schema, "val_type", None)
+ inner_schema = _value_schema_to_json_schema(value_schema)
- if val_type == "json":
- schema: dict[str, Any] = {"type": "object"}
- if getattr(value_schema, "properties", None):
- schema["properties"] = {}
- for prop_name, prop_schema in value_schema.properties.items():
- schema["properties"][prop_name] = {
- "type": _map_type_to_json_schema_type(getattr(prop_schema, "val_type", None))
- }
- if getattr(prop_schema, "description", None):
- schema["properties"][prop_name]["description"] = prop_schema.description
- return schema
+ # Object return types are already top-level objects, emit directly.
+ if inner_schema.get("type") == "object":
+ return inner_schema
- inner_schema: dict[str, Any] = {
- "type": _map_type_to_json_schema_type(val_type),
- }
- if getattr(value_schema, "enum", None):
- inner_schema["enum"] = list(value_schema.enum)
- if val_type == "array" and getattr(value_schema, "inner_val_type", None):
- inner_schema["items"] = {"type": _map_type_to_json_schema_type(value_schema.inner_val_type)}
+ # Primitives/arrays must be wrapped so outputSchema.type is "object" per MCP spec.
return {
"type": "object",
"properties": {
"result": inner_schema,
},
}
+
+
+def _value_schema_to_json_schema(value_schema: Any) -> dict[str, Any]:
+ """Convert a ValueSchema to a JSON Schema dict without top-level object wrapping.
+
+ Recursively expands nested object (json) types into their sub-schemas.
+ """
+ val_type = getattr(value_schema, "val_type", None)
+
+ if val_type == "json":
+ schema: dict[str, Any] = {"type": "object"}
+ if getattr(value_schema, "enum", None):
+ schema["enum"] = list(value_schema.enum)
+ if getattr(value_schema, "properties", None):
+ schema["properties"] = {}
+ for prop_name, prop_schema in value_schema.properties.items():
+ schema["properties"][prop_name] = _value_schema_to_json_schema(prop_schema)
+ if getattr(prop_schema, "description", None):
+ schema["properties"][prop_name]["description"] = prop_schema.description
+ return schema
+
+ schema = {"type": _map_type_to_json_schema_type(val_type)}
+ if getattr(value_schema, "enum", None):
+ schema["enum"] = list(value_schema.enum)
+ if val_type == "array" and getattr(value_schema, "inner_val_type", None):
+ inner_type = value_schema.inner_val_type
+ items_schema: dict[str, Any] = {"type": _map_type_to_json_schema_type(inner_type)}
+ if inner_type == "json" and getattr(value_schema, "inner_properties", None):
+ items_schema["properties"] = {}
+ for prop_name, prop_schema in value_schema.inner_properties.items():
+ items_schema["properties"][prop_name] = _value_schema_to_json_schema(prop_schema)
+ if getattr(prop_schema, "description", None):
+ items_schema["properties"][prop_name]["description"] = prop_schema.description
+ schema["items"] = items_schema
+ return schema
diff --git a/libs/arcade-mcp-server/arcade_mcp_server/managers/resource.py b/libs/arcade-mcp-server/arcade_mcp_server/managers/resource.py
index 8cfda7a0..a2d2925c 100644
--- a/libs/arcade-mcp-server/arcade_mcp_server/managers/resource.py
+++ b/libs/arcade-mcp-server/arcade_mcp_server/managers/resource.py
@@ -6,10 +6,14 @@ Async-safe resources with registry-based storage and deterministic listing.
from __future__ import annotations
+import base64
+import contextlib
import logging
-from typing import Any, Callable
+import re
+from pathlib import Path
+from typing import Any, Callable, Literal
-from arcade_mcp_server.exceptions import NotFoundError
+from arcade_mcp_server.exceptions import NotFoundError, ResourceError
from arcade_mcp_server.managers.base import ComponentManager
from arcade_mcp_server.types import (
BlobResourceContents,
@@ -21,6 +25,73 @@ from arcade_mcp_server.types import (
logger = logging.getLogger("arcade.mcp.managers.resource")
+DuplicatePolicy = Literal["warn", "error", "replace", "ignore"]
+MultipleMatchPolicy = Literal["warn", "error", "ignore"]
+
+
+def _is_template_uri(uri: str) -> bool:
+ """Return True if *uri* contains RFC 6570-style template variables."""
+ return bool(re.search(r"\{[^}]+\}", uri))
+
+
+def _template_to_regex(template: str) -> re.Pattern[str]:
+ """Convert a URI template to a compiled regex with named groups.
+
+ ``{param}`` -> ``(?P[^/]+)``
+ ``{param*}`` -> ``(?P.+)`` (wildcard / greedy)
+ """
+ pattern = re.escape(template)
+ # Wildcard parameters first (e.g. {path*})
+ pattern = re.sub(
+ r"\\{(\w+)\\\*\\}",
+ lambda m: f"(?P<{m.group(1)}>.+)",
+ pattern,
+ )
+ # Simple parameters (e.g. {city})
+ pattern = re.sub(
+ r"\\{(\w+)\\}",
+ lambda m: f"(?P<{m.group(1)}>[^/]+)",
+ pattern,
+ )
+ return re.compile(f"^{pattern}$")
+
+
+def _template_to_sample_uri(template: str) -> str:
+ """Replace template variables with dummy values to produce a concrete sample URI.
+
+ ``{param}`` -> ``__param__``
+ ``{param*}`` -> ``__param__/nested`` (includes slash to exercise wildcard)
+ """
+ # Wildcards first
+ result = re.sub(r"\{(\w+)\*\}", r"__\1__/nested", template)
+ # Simple params
+ result = re.sub(r"\{(\w+)\}", r"__\1__", result)
+ return result
+
+
+def make_text_handler(text: str) -> Callable[[str], str]:
+ """Create a handler that returns static text."""
+
+ def handler(_uri: str) -> str:
+ return text
+
+ return handler
+
+
+def make_file_handler(path: str | Path) -> Callable[[str], str | bytes]:
+ """Create a handler that reads a file, returning text or bytes."""
+ file_path = Path(path)
+
+ def _read_file(_uri: str) -> str | bytes:
+ if not file_path.exists():
+ raise NotFoundError(f"File not found: {file_path}")
+ try:
+ return file_path.read_text(encoding="utf-8")
+ except UnicodeDecodeError:
+ return file_path.read_bytes()
+
+ return _read_file
+
class ResourceManager(ComponentManager[str, Resource]):
"""
@@ -29,10 +100,16 @@ class ResourceManager(ComponentManager[str, Resource]):
def __init__(
self,
+ duplicate_policy: DuplicatePolicy = "warn",
+ multiple_match_policy: MultipleMatchPolicy = "warn",
) -> None:
super().__init__("resource")
self._templates: dict[str, ResourceTemplate] = {}
self._resource_handlers: dict[str, Callable[[str], Any]] = {}
+ self._template_handlers: dict[str, Callable[..., Any]] = {}
+ self._template_patterns: dict[str, re.Pattern[str]] = {}
+ self.duplicate_policy: DuplicatePolicy = duplicate_policy
+ self.multiple_match_policy: MultipleMatchPolicy = multiple_match_policy
async def list_resources(self) -> list[Resource]:
return await self.registry.list()
@@ -43,21 +120,47 @@ class ResourceManager(ComponentManager[str, Resource]):
async def read_resource(self, uri: str) -> list[ResourceContents]:
handler = self._resource_handlers.get(uri)
if handler:
+ # Look up the registered resource's mimeType so we can propagate it
+ mime_type: str | None = None
+ try:
+ registered: Resource = await self.registry.get(uri)
+ mime_type = registered.mimeType
+ except KeyError:
+ pass
+
result = handler(uri)
if hasattr(result, "__await__"):
result = await result
- if isinstance(result, str):
- return [TextResourceContents(uri=uri, text=result)]
- elif isinstance(result, dict):
- if "text" in result:
- return [TextResourceContents(uri=uri, text=result["text"])]
- if "blob" in result:
- return [BlobResourceContents(uri=uri, blob=result["blob"])]
- return [ResourceContents(uri=uri)]
- elif isinstance(result, list):
- return result
- else:
- return [TextResourceContents(uri=uri, text=str(result))]
+ return self._coerce_result(uri, mime_type, result)
+
+ # Try template matching before giving up — collect all matches
+ matches: list[tuple[str, re.Match[str]]] = []
+ for tmpl_str, pattern in self._template_patterns.items():
+ match = pattern.match(uri)
+ if match:
+ matches.append((tmpl_str, match))
+
+ if matches:
+ if len(matches) > 1 and self.multiple_match_policy != "ignore":
+ matched_templates = [m[0] for m in matches]
+ message = (
+ f"URI '{uri}' matched {len(matches)} resource templates: "
+ f"{matched_templates}. Using first registered match "
+ f"'{matched_templates[0]}'."
+ )
+ if self.multiple_match_policy == "error":
+ raise ResourceError(message)
+ else: # "warn"
+ logger.warning(message)
+
+ tmpl_str, match = matches[0]
+ params = match.groupdict()
+ tmpl_handler = self._template_handlers[tmpl_str]
+ mime_type = self._templates[tmpl_str].mimeType
+ result = tmpl_handler(uri, **params)
+ if hasattr(result, "__await__"):
+ result = await result
+ return self._coerce_result(uri, mime_type, result)
try:
_ = await self.registry.get(uri)
@@ -66,9 +169,43 @@ class ResourceManager(ComponentManager[str, Resource]):
return [TextResourceContents(uri=uri, text="")] # static placeholder
+ @staticmethod
+ def _coerce_result(uri: str, mime_type: str | None, result: Any) -> list[ResourceContents]:
+ """Convert a handler return value into a list of ResourceContents."""
+ if isinstance(result, bytes):
+ blob = base64.b64encode(result).decode("ascii")
+ return [BlobResourceContents(uri=uri, mimeType=mime_type, blob=blob)]
+ elif isinstance(result, str):
+ return [TextResourceContents(uri=uri, mimeType=mime_type, text=result)]
+ elif isinstance(result, dict):
+ if "text" in result:
+ return [TextResourceContents(uri=uri, mimeType=mime_type, text=result["text"])]
+ if "blob" in result:
+ return [BlobResourceContents(uri=uri, mimeType=mime_type, blob=result["blob"])]
+ return [ResourceContents(uri=uri, mimeType=mime_type)]
+ elif isinstance(result, list):
+ return result
+ else:
+ return [TextResourceContents(uri=uri, mimeType=mime_type, text=str(result))]
+
async def add_resource(
self, resource: Resource, handler: Callable[[str], Any] | None = None
) -> None:
+ # Duplicate-detection
+ existing: Resource | None = None
+ with contextlib.suppress(KeyError):
+ existing = await self.registry.get(resource.uri)
+
+ if existing is not None:
+ if self.duplicate_policy == "error":
+ raise ResourceError(f"Resource '{resource.uri}' already registered")
+ elif self.duplicate_policy == "ignore":
+ return
+ elif self.duplicate_policy == "warn":
+ logger.warning(f"Replacing duplicate resource '{resource.uri}'")
+ # "replace" and "warn" both fall through to upsert
+ self._resource_handlers.pop(resource.uri, None)
+
await self.registry.upsert(resource.uri, resource)
if handler:
self._resource_handlers[resource.uri] = handler
@@ -88,6 +225,7 @@ class ResourceManager(ComponentManager[str, Resource]):
await self.registry.remove(uri)
except KeyError:
raise NotFoundError(f"Resource '{uri}' not found")
+ self._resource_handlers.pop(uri, None)
await self.registry.upsert(resource.uri, resource)
if handler:
self._resource_handlers[resource.uri] = handler
@@ -96,7 +234,66 @@ class ResourceManager(ComponentManager[str, Resource]):
async def add_template(self, template: ResourceTemplate) -> None:
self._templates[template.uriTemplate] = template
+ async def add_template_with_handler(
+ self, template: ResourceTemplate, handler: Callable[..., Any]
+ ) -> None:
+ """Store a template together with its handler and compiled regex.
+
+ Warns at registration time if the new template overlaps with any
+ existing template, since only the first registered match is used at
+ read time.
+ """
+ new_pattern = _template_to_regex(template.uriTemplate)
+ new_sample = _template_to_sample_uri(template.uriTemplate)
+
+ for existing_tmpl_str, existing_pattern in self._template_patterns.items():
+ existing_sample = _template_to_sample_uri(existing_tmpl_str)
+ # Check both directions: does the new pattern match an existing
+ # template's sample URI, or does an existing pattern match the
+ # new template's sample URI?
+ if existing_pattern.match(new_sample) or new_pattern.match(existing_sample):
+ logger.warning(
+ "Resource template '%s' overlaps with already-registered "
+ "template '%s'. The first registered template will take "
+ "priority when both match a URI. Consider registering more "
+ "specific templates before broader ones.",
+ template.uriTemplate,
+ existing_tmpl_str,
+ )
+
+ self._templates[template.uriTemplate] = template
+ self._template_handlers[template.uriTemplate] = handler
+ self._template_patterns[template.uriTemplate] = new_pattern
+
async def remove_template(self, uri_template: str) -> ResourceTemplate:
if uri_template not in self._templates:
raise NotFoundError(f"Resource template '{uri_template}' not found")
+ self._template_handlers.pop(uri_template, None)
+ self._template_patterns.pop(uri_template, None)
return self._templates.pop(uri_template)
+
+ async def add_text_resource(
+ self,
+ uri: str,
+ text: str,
+ *,
+ name: str | None = None,
+ description: str | None = None,
+ mime_type: str = "text/plain",
+ ) -> None:
+ """Convenience: register a static text resource."""
+ resource = Resource(uri=uri, name=name or uri, description=description, mimeType=mime_type)
+ await self.add_resource(resource, handler=make_text_handler(text))
+
+ async def add_file_resource(
+ self,
+ uri: str,
+ path: str | Path,
+ *,
+ name: str | None = None,
+ description: str | None = None,
+ mime_type: str | None = None,
+ ) -> None:
+ """Convenience: register a file-backed resource."""
+ resource = Resource(uri=uri, name=name or uri, description=description, mimeType=mime_type)
+ await self.add_resource(resource, handler=make_file_handler(path))
diff --git a/libs/arcade-mcp-server/arcade_mcp_server/managers/tool.py b/libs/arcade-mcp-server/arcade_mcp_server/managers/tool.py
index 2a100502..e313dc2f 100644
--- a/libs/arcade-mcp-server/arcade_mcp_server/managers/tool.py
+++ b/libs/arcade-mcp-server/arcade_mcp_server/managers/tool.py
@@ -6,7 +6,8 @@ Async-safe tool management with pre-converted MCPTool DTOs and executable materi
from __future__ import annotations
-from typing import TypedDict
+import logging
+from typing import Any, TypedDict
from arcade_core.catalog import MaterializedTool, ToolCatalog
@@ -15,6 +16,8 @@ from arcade_mcp_server.exceptions import NotFoundError
from arcade_mcp_server.managers.base import ComponentManager
from arcade_mcp_server.types import MCPTool
+logger = logging.getLogger("arcade.mcp.managers.tool")
+
class ManagedTool(TypedDict):
dto: MCPTool
@@ -92,3 +95,20 @@ class ToolManager(ComponentManager[Key, ManagedTool]):
if sanitized in self._sanitized_to_key:
del self._sanitized_to_key[sanitized]
return rec["materialized"]
+
+ async def apply_meta_extensions(self, extensions: dict[str, dict[str, Any]]) -> None:
+ """Merge additional _meta fields into loaded tool DTOs.
+
+ Used for MCP Apps support (_meta.ui.resourceUri) and other extensions.
+ Keys in *extensions* are tool FQNs (dotted).
+ """
+ for fqn, extra_meta in extensions.items():
+ try:
+ rec = await self.registry.get(fqn)
+ except KeyError:
+ logger.warning(f"Tool meta extension for '{fqn}' skipped: tool not found")
+ continue
+ dto = rec["dto"]
+ if dto.meta is None:
+ dto.meta = {}
+ dto.meta.update(extra_meta)
diff --git a/libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py b/libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py
index 5b1fd020..32f1f52e 100644
--- a/libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py
+++ b/libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py
@@ -21,6 +21,7 @@ from arcade_core.subprocess_utils import (
get_windows_no_window_creationflags,
graceful_terminate_process,
)
+from arcade_core.utils import snake_to_pascal_case
from arcade_tdk.auth import ToolAuthorization
from arcade_tdk.error_adapters import ErrorAdapter
from arcade_tdk.tool import tool as tool_decorator
@@ -30,10 +31,15 @@ from watchfiles import watch
from arcade_mcp_server._validation import normalize_version
from arcade_mcp_server.exceptions import ServerError
from arcade_mcp_server.logging_utils import intercept_standard_logging
+from arcade_mcp_server.managers.resource import (
+ _is_template_uri,
+ make_file_handler,
+ make_text_handler,
+)
from arcade_mcp_server.resource_server.base import ResourceServerValidator
from arcade_mcp_server.server import MCPServer
from arcade_mcp_server.settings import MCPSettings, ServerSettings, find_env_file
-from arcade_mcp_server.types import Prompt, PromptMessage, Resource
+from arcade_mcp_server.types import Annotations, Prompt, PromptMessage, Resource, ResourceTemplate
from arcade_mcp_server.usage import ServerTracker
from arcade_mcp_server.worker import create_arcade_mcp, serve_with_force_quit
@@ -116,6 +122,14 @@ class MCPApp:
self._catalog = ToolCatalog()
self._toolkit_name = name
+ # Resource collection (build-time)
+ self._initial_resources: list[
+ tuple[Resource | ResourceTemplate, Callable[..., Any] | None]
+ ] = []
+
+ # Tool _meta extensions (build-time) — keyed by tool FQN
+ self._tool_meta_extensions: dict[str, dict[str, Any]] = {}
+
# Public handle to the MCPServer (set by caller for runtime ops)
self.server: MCPServer | None = None
@@ -254,8 +268,15 @@ class MCPApp:
requires_metadata: list[str] | None = None,
adapters: list[ErrorAdapter] | None = None,
metadata: ToolMetadata | None = None,
+ meta: dict[str, Any] | None = None,
) -> Callable[P, T]:
"""Add a tool for build-time materialization (pre-server)."""
+ if meta and "arcade" in meta:
+ raise ToolDefinitionError(
+ "The 'arcade' key in meta is reserved. "
+ "Use the 'metadata' parameter (ToolMetadata) instead."
+ )
+
if not hasattr(func, "__tool_name__"):
func = tool_decorator(
func,
@@ -276,6 +297,18 @@ class MCPApp:
)
except ToolDefinitionError as e:
raise e.with_context(func.__name__) from e
+
+ # Store _meta extensions for the tool
+ if meta:
+ tool_name = snake_to_pascal_case(getattr(func, "__tool_name__", func.__name__))
+ fqn = None
+ for mat_tool in self._catalog:
+ if mat_tool.definition.name == tool_name:
+ fqn = str(mat_tool.definition.fully_qualified_name)
+ break
+ if fqn:
+ self._tool_meta_extensions[fqn] = meta
+
logger.debug(f"Added tool: {func.__name__}")
return func
@@ -285,6 +318,118 @@ class MCPApp:
module, self._toolkit_name, version=self.version, description=self.instructions
)
+ def add_resource(
+ self,
+ uri: str,
+ *,
+ name: str | None = None,
+ title: str | None = None,
+ description: str | None = None,
+ mime_type: str | None = None,
+ handler: Callable[..., Any] | None = None,
+ annotations: Annotations | None = None,
+ meta: dict[str, Any] | None = None,
+ ) -> None:
+ """Register a resource at build time (before server start).
+
+ If the URI contains ``{`` it is treated as a URI template and a
+ :class:`ResourceTemplate` is stored instead of a :class:`Resource`.
+ """
+ common_kwargs: dict[str, Any] = {
+ "name": name or uri,
+ "title": title,
+ "description": description,
+ "mimeType": mime_type,
+ "annotations": annotations,
+ }
+ if meta is not None:
+ common_kwargs["_meta"] = meta
+
+ if _is_template_uri(uri):
+ item: Resource | ResourceTemplate = ResourceTemplate(uriTemplate=uri, **common_kwargs)
+ else:
+ item = Resource(uri=uri, **common_kwargs)
+ self._initial_resources.append((item, handler))
+ logger.debug(f"Added resource: {uri}")
+
+ def resource(
+ self,
+ uri: str,
+ *,
+ name: str | None = None,
+ title: str | None = None,
+ description: str | None = None,
+ mime_type: str | None = None,
+ annotations: Annotations | None = None,
+ meta: dict[str, Any] | None = None,
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
+ """Decorator for registering a resource with a handler at build time."""
+
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
+ self.add_resource(
+ uri,
+ name=name or func.__name__,
+ title=title,
+ description=description,
+ mime_type=mime_type,
+ handler=func,
+ annotations=annotations,
+ meta=meta,
+ )
+ return func
+
+ return decorator
+
+ def add_text_resource(
+ self,
+ uri: str,
+ *,
+ text: str,
+ name: str | None = None,
+ title: str | None = None,
+ description: str | None = None,
+ mime_type: str = "text/plain",
+ ) -> None:
+ """Register a static text resource at build time."""
+ if _is_template_uri(uri):
+ raise ValueError(
+ f"Template URIs are not supported for static text resources: '{uri}'. "
+ "Use add_resource() with a handler that accepts template parameters instead."
+ )
+ self.add_resource(
+ uri,
+ name=name,
+ title=title,
+ description=description,
+ mime_type=mime_type,
+ handler=make_text_handler(text),
+ )
+
+ def add_file_resource(
+ self,
+ uri: str,
+ *,
+ path: str | Path,
+ name: str | None = None,
+ title: str | None = None,
+ description: str | None = None,
+ mime_type: str | None = None,
+ ) -> None:
+ """Register a file-backed resource at build time."""
+ if _is_template_uri(uri):
+ raise ValueError(
+ f"Template URIs are not supported for file resources: '{uri}'. "
+ "Use add_resource() with a handler that accepts template parameters instead."
+ )
+ self.add_resource(
+ uri,
+ name=name,
+ title=title,
+ description=description,
+ mime_type=mime_type,
+ handler=make_file_handler(path),
+ )
+
def tool(
self,
func: Callable[P, T] | None = None,
@@ -295,6 +440,7 @@ class MCPApp:
requires_metadata: list[str] | None = None,
adapters: list[ErrorAdapter] | None = None,
metadata: ToolMetadata | None = None,
+ meta: dict[str, Any] | None = None,
) -> Callable[[Callable[P, T]], Callable[P, T]] | Callable[P, T]:
"""Decorator for adding tools with optional parameters."""
@@ -308,6 +454,7 @@ class MCPApp:
requires_metadata=requires_metadata,
adapters=adapters,
metadata=metadata,
+ meta=meta,
)
if func is not None:
@@ -322,8 +469,10 @@ class MCPApp:
transport: TransportType = "stdio",
**kwargs: Any,
) -> None:
- if len(self._catalog) == 0:
- logger.error("No tools added to the server. Use @app.tool decorator or app.add_tool().")
+ if len(self._catalog) == 0 and len(self._initial_resources) == 0:
+ logger.error(
+ "No tools or resources added. Use @app.tool, app.add_tool(), @app.resource, or app.add_resource()."
+ )
sys.exit(1)
host, port, transport, reload = MCPApp._get_configuration_overrides(
@@ -337,7 +486,10 @@ class MCPApp:
# parent watcher has already been setup
reload = False
- logger.info(f"Starting {self._name} v{self.version} with {len(self._catalog)} tools")
+ logger.info(
+ f"Starting {self._name} v{self.version}"
+ f" with {len(self._catalog)} tools and {len(self._initial_resources)} resources"
+ )
if transport in ["http", "streamable-http", "streamable"]:
resource_server_auth_enabled = isinstance(
@@ -377,6 +529,8 @@ class MCPApp:
run_stdio_server(
catalog=self._catalog,
settings=self._mcp_settings,
+ initial_resources=self._initial_resources,
+ tool_meta_extensions=self._tool_meta_extensions,
**self.server_kwargs,
)
)
@@ -470,6 +624,8 @@ class MCPApp:
mcp_settings=self._mcp_settings,
debug=debug,
resource_server_validator=self.resource_server_validator,
+ initial_resources=self._initial_resources,
+ tool_meta_extensions=self._tool_meta_extensions,
**self.server_kwargs,
)
diff --git a/libs/arcade-mcp-server/arcade_mcp_server/server.py b/libs/arcade-mcp-server/arcade_mcp_server/server.py
index 68407a0c..0b59b7ca 100644
--- a/libs/arcade-mcp-server/arcade_mcp_server/server.py
+++ b/libs/arcade-mcp-server/arcade_mcp_server/server.py
@@ -71,6 +71,7 @@ from arcade_mcp_server.types import (
PingRequest,
ReadResourceRequest,
ReadResourceResult,
+ ResourceTemplate,
ServerCapabilities,
SetLevelRequest,
SubscribeRequest,
@@ -123,6 +124,8 @@ class MCPServer:
auth_disabled: bool = False,
arcade_api_key: str | None = None,
arcade_api_url: str | None = None,
+ initial_resources: list[tuple[Any, Callable[..., Any] | None]] | None = None,
+ tool_meta_extensions: dict[str, dict[str, Any]] | None = None,
):
"""
Initialize MCP server.
@@ -177,6 +180,11 @@ class MCPServer:
self._resource_manager = ResourceManager()
self._prompt_manager = PromptManager()
+ # Build-time resources to load on start
+ self._initial_resources = initial_resources or []
+
+ self._tool_meta_extensions = tool_meta_extensions or {}
+
# Centralized notifications
self.notification_manager = NotificationManager(self)
@@ -355,10 +363,22 @@ class MCPServer:
except Exception:
logger.exception("Failed to load tools from initial catalog")
+ # Apply _meta extensions to loaded tools
+ if self._tool_meta_extensions:
+ await self._tool_manager.apply_meta_extensions(self._tool_meta_extensions)
+
# Check for missing secrets and log warnings (only when worker routes are disabled)
await self._check_and_warn_missing_secrets()
await self._resource_manager.start()
+ for item, handler in self._initial_resources:
+ if isinstance(item, ResourceTemplate):
+ if handler is not None:
+ await self._resource_manager.add_template_with_handler(item, handler)
+ else:
+ await self._resource_manager.add_template(item)
+ else:
+ await self._resource_manager.add_resource(item, handler)
await self._prompt_manager.start()
await self.lifespan_manager.startup()
diff --git a/libs/arcade-mcp-server/arcade_mcp_server/stdio_runner.py b/libs/arcade-mcp-server/arcade_mcp_server/stdio_runner.py
index fe2d8bed..63526eda 100644
--- a/libs/arcade-mcp-server/arcade_mcp_server/stdio_runner.py
+++ b/libs/arcade-mcp-server/arcade_mcp_server/stdio_runner.py
@@ -6,6 +6,7 @@ and tool catalog initialization used by both stdio and HTTP modes.
"""
import sys
+from collections.abc import Callable
from typing import Any
from arcade_core.catalog import ToolCatalog
@@ -16,6 +17,7 @@ from loguru import logger
from arcade_mcp_server.server import MCPServer
from arcade_mcp_server.settings import MCPSettings
+from arcade_mcp_server.types import Resource, ResourceTemplate
def initialize_tool_catalog(
@@ -56,6 +58,9 @@ async def run_stdio_server(
debug: bool = False,
env_file: str | None = None,
settings: MCPSettings | None = None,
+ initial_resources: list[tuple[Resource | ResourceTemplate, Callable[..., Any] | None]]
+ | None = None,
+ tool_meta_extensions: dict[str, dict[str, Any]] | None = None,
**kwargs: Any,
) -> None:
"""Run MCP server with stdio transport."""
@@ -89,6 +94,8 @@ async def run_stdio_server(
server = MCPServer(
catalog=catalog,
settings=settings,
+ initial_resources=initial_resources,
+ tool_meta_extensions=tool_meta_extensions,
**kwargs,
)
diff --git a/libs/arcade-mcp-server/arcade_mcp_server/worker.py b/libs/arcade-mcp-server/arcade_mcp_server/worker.py
index 326b74c0..3d7ef0c1 100644
--- a/libs/arcade-mcp-server/arcade_mcp_server/worker.py
+++ b/libs/arcade-mcp-server/arcade_mcp_server/worker.py
@@ -33,6 +33,7 @@ from arcade_mcp_server.resource_server.middleware import ResourceServerMiddlewar
from arcade_mcp_server.server import MCPServer
from arcade_mcp_server.settings import MCPSettings
from arcade_mcp_server.transports.http_session_manager import HTTPSessionManager
+from arcade_mcp_server.types import Resource, ResourceTemplate
class CustomUvicornServer(uvicorn.Server):
@@ -77,6 +78,9 @@ class CustomUvicornServer(uvicorn.Server):
async def create_lifespan(
catalog: ToolCatalog,
mcp_settings: MCPSettings | None = None,
+ initial_resources: list[tuple[Resource | ResourceTemplate, Callable[..., Any] | None]]
+ | None = None,
+ tool_meta_extensions: dict[str, dict[str, Any]] | None = None,
**kwargs: Any,
) -> AsyncGenerator[dict[str, Any], None]:
"""
@@ -103,6 +107,8 @@ async def create_lifespan(
mcp_server = MCPServer(
catalog,
settings=mcp_settings,
+ initial_resources=initial_resources,
+ tool_meta_extensions=tool_meta_extensions,
**kwargs,
)
@@ -127,6 +133,9 @@ def create_arcade_mcp(
debug: bool = False,
otel_enable: bool = False,
resource_server_validator: ResourceServerValidator | None = None,
+ initial_resources: list[tuple[Resource | ResourceTemplate, Callable[..., Any] | None]]
+ | None = None,
+ tool_meta_extensions: dict[str, dict[str, Any]] | None = None,
**kwargs: Any,
) -> FastAPI:
"""
@@ -156,7 +165,13 @@ def create_arcade_mcp(
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
try:
logger.debug(f"Server lifespan startup. OTEL enabled: {otel_enable}")
- async with create_lifespan(catalog, mcp_settings, **kwargs) as components:
+ async with create_lifespan(
+ catalog,
+ mcp_settings,
+ initial_resources=initial_resources,
+ tool_meta_extensions=tool_meta_extensions,
+ **kwargs,
+ ) as components:
app.state.mcp_server = components["mcp_server"]
app.state.session_manager = components["session_manager"]
yield
diff --git a/libs/arcade-mcp-server/pyproject.toml b/libs/arcade-mcp-server/pyproject.toml
index 6c53c514..078592d2 100644
--- a/libs/arcade-mcp-server/pyproject.toml
+++ b/libs/arcade-mcp-server/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "arcade-mcp-server"
-version = "1.18.0"
+version = "1.19.0"
description = "Model Context Protocol (MCP) server framework for Arcade.dev"
readme = "README.md"
authors = [{ name = "Arcade.dev" }]
diff --git a/libs/tests/arcade_mcp_server/test_convert.py b/libs/tests/arcade_mcp_server/test_convert.py
index a571c1e5..7c561dbc 100644
--- a/libs/tests/arcade_mcp_server/test_convert.py
+++ b/libs/tests/arcade_mcp_server/test_convert.py
@@ -16,7 +16,11 @@ from arcade_core.schema import (
ValueSchema,
)
from arcade_mcp_server import tool
-from arcade_mcp_server.convert import convert_to_mcp_content, create_mcp_tool
+from arcade_mcp_server.convert import (
+ convert_content_to_structured_content,
+ convert_to_mcp_content,
+ create_mcp_tool,
+)
# Small PNG header (1x1 transparent pixel) used for byte-image param tests
PNG_BYTES = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde"
@@ -338,6 +342,50 @@ class TestCreateMCPTool:
assert param_schema["type"] == "string"
assert param_schema["enum"] == ["red", "green", "blue"]
+ def test_enum_on_json_object_parameter(self):
+ """Test that enum is preserved on json/object type parameters."""
+ tool_def = ToolDefinition(
+ name="test",
+ fully_qualified_name="Test.test",
+ description="Test",
+ toolkit=ToolkitDefinition(name="Test"),
+ input=ToolInput(
+ parameters=[
+ InputParameter(
+ name="config",
+ required=True,
+ description="Config choice",
+ value_schema=ValueSchema(
+ val_type="json",
+ enum=["preset_a", "preset_b"],
+ ),
+ )
+ ]
+ ),
+ output=ToolOutput(),
+ requirements=ToolRequirements(),
+ )
+
+ @tool
+ def f(config: Annotated[str, "Config choice"]):
+ return config
+
+ input_model, output_model = create_func_models(f)
+ meta = ToolMeta(module=f.__module__, toolkit=tool_def.toolkit.name)
+ mat_tool = MaterializedTool(
+ tool=f,
+ definition=tool_def,
+ meta=meta,
+ input_model=input_model,
+ output_model=output_model,
+ )
+
+ mcp_tool = create_mcp_tool(mat_tool)
+ param_schema = mcp_tool.inputSchema["properties"]["config"]
+
+ assert param_schema["type"] == "object"
+ assert param_schema["enum"] == ["preset_a", "preset_b"]
+
def test_no_parameters(self):
"""Test tool with no parameters."""
tool_def = ToolDefinition(
@@ -483,3 +531,132 @@ class TestCreateMCPTool:
assert schema is not None
assert schema["type"] == "object"
assert "properties" not in schema
+
+ def test_output_schema_nested_object(self):
+ """Test that nested object properties are recursively expanded in outputSchema."""
+ nested_props = {
+ "id": ValueSchema(val_type="integer"),
+ "name": ValueSchema(val_type="string"),
+ }
+ outer_props = {
+ "data": ValueSchema(val_type="json", properties=nested_props),
+ "status": ValueSchema(val_type="string"),
+ }
+ mcp_tool = self._make_tool_with_output(
+ ValueSchema(val_type="json", properties=outer_props)
+ )
+ output_schema = mcp_tool.outputSchema
+
+ assert output_schema is not None
+ assert output_schema["type"] == "object"
+ assert "data" in output_schema["properties"]
+ data_schema = output_schema["properties"]["data"]
+ assert data_schema["type"] == "object"
+ assert "properties" in data_schema
+ assert data_schema["properties"]["id"]["type"] == "integer"
+ assert data_schema["properties"]["name"]["type"] == "string"
+ assert output_schema["properties"]["status"]["type"] == "string"
+
+ def test_input_schema_nested_object(self):
+ """Test that nested object properties are recursively expanded in inputSchema."""
+ # Two levels deep: payload.info.count — requires recursion
+ deeply_nested_props = {
+ "count": ValueSchema(val_type="integer"),
+ }
+ nested_props = {
+ "id": ValueSchema(val_type="integer"),
+ "info": ValueSchema(val_type="json", properties=deeply_nested_props),
+ }
+ tool_def = ToolDefinition(
+ name="test",
+ fully_qualified_name="Test.test",
+ description="Test",
+ toolkit=ToolkitDefinition(name="Test"),
+ input=ToolInput(
+ parameters=[
+ InputParameter(
+ name="payload",
+ required=True,
+ description="Nested payload",
+ value_schema=ValueSchema(val_type="json", properties=nested_props),
+ )
+ ]
+ ),
+ output=ToolOutput(),
+ requirements=ToolRequirements(),
+ )
+
+ @tool
+ def f(payload: Annotated[str, "Nested payload"]):
+ return payload
+
+ input_model, output_model = create_func_models(f)
+ meta = ToolMeta(module=f.__module__, toolkit=tool_def.toolkit.name)
+ mat_tool = MaterializedTool(
+ tool=f,
+ definition=tool_def,
+ meta=meta,
+ input_model=input_model,
+ output_model=output_model,
+ )
+
+ mcp_tool = create_mcp_tool(mat_tool)
+ payload_schema = mcp_tool.inputSchema["properties"]["payload"]
+
+ assert payload_schema["type"] == "object"
+ assert "properties" in payload_schema
+ assert payload_schema["properties"]["id"]["type"] == "integer"
+ info_schema = payload_schema["properties"]["info"]
+ assert info_schema["type"] == "object"
+ assert "properties" in info_schema
+ assert info_schema["properties"]["count"]["type"] == "integer"
+
+ def test_output_schema_non_object_wrapped_in_object(self):
+ """Non-object output types get wrapped in {type: object, properties: {result: ...}}."""
+ mcp_tool = self._make_tool_with_output(ValueSchema(val_type="string"))
+ output_schema = mcp_tool.outputSchema
+
+ assert output_schema is not None
+ assert output_schema["type"] == "object"
+ assert "result" in output_schema["properties"]
+ assert output_schema["properties"]["result"]["type"] == "string"
+
+ def test_output_schema_array_wrapped_in_object(self):
+ """Array output types get wrapped in an object with a 'result' property."""
+ mcp_tool = self._make_tool_with_output(
+ ValueSchema(val_type="array", inner_val_type="string")
+ )
+ output_schema = mcp_tool.outputSchema
+
+ assert output_schema is not None
+ assert output_schema["type"] == "object"
+ assert output_schema["properties"]["result"]["type"] == "array"
+ assert output_schema["properties"]["result"]["items"]["type"] == "string"
+
+
+class TestConvertContentToStructuredContent:
+ """Test convert_content_to_structured_content function."""
+
+ def test_none_returns_none(self):
+ assert convert_content_to_structured_content(None) is None
+
+ def test_dict_returned_as_is(self):
+ d = {"key": "value"}
+ assert convert_content_to_structured_content(d) is d
+
+ def test_list_wrapped_in_result(self):
+ result = convert_content_to_structured_content([1, 2, 3])
+ assert result == {"result": [1, 2, 3]}
+
+ @pytest.mark.parametrize("value", ["hello", 42, 3.14, True])
+ def test_primitives_wrapped_in_result(self, value):
+ result = convert_content_to_structured_content(value)
+ assert result == {"result": value}
+
+ def test_arbitrary_object_str_wrapped(self):
+ class Custom:
+ def __str__(self):
+ return "custom-str"
+
+ result = convert_content_to_structured_content(Custom())
+ assert result == {"result": "custom-str"}
diff --git a/libs/tests/arcade_mcp_server/test_mcp_app.py b/libs/tests/arcade_mcp_server/test_mcp_app.py
index 7cb3aff9..b48cd44c 100644
--- a/libs/tests/arcade_mcp_server/test_mcp_app.py
+++ b/libs/tests/arcade_mcp_server/test_mcp_app.py
@@ -6,8 +6,7 @@ from typing import Annotated
from unittest.mock import Mock, patch
import pytest
-from arcade_core.catalog import MaterializedTool
-
+from arcade_core.catalog import MaterializedTool, ToolDefinitionError
from arcade_mcp_server import tool
from arcade_mcp_server.mcp_app import MCPApp
from arcade_mcp_server.server import MCPServer
@@ -443,6 +442,8 @@ class TestMCPApp:
mcp_settings=mcp_app._mcp_settings,
debug=False,
resource_server_validator=mcp_app.resource_server_validator,
+ initial_resources=mcp_app._initial_resources,
+ tool_meta_extensions=mcp_app._tool_meta_extensions,
)
mock_serve.assert_called_once_with(
app=mock_fastapi_app,
@@ -467,6 +468,8 @@ class TestMCPApp:
mcp_settings=mcp_app._mcp_settings,
debug=True,
resource_server_validator=mcp_app.resource_server_validator,
+ initial_resources=mcp_app._initial_resources,
+ tool_meta_extensions=mcp_app._tool_meta_extensions,
)
mock_serve.assert_called_once_with(
app=mock_fastapi_app,
@@ -742,3 +745,341 @@ class TestMCPApp:
app = MCPApp()
with pytest.raises(expected_error):
app._validate_name(name)
+
+
+class TestMCPAppResourceRegistration:
+ """Tests for build-time resource registration on MCPApp."""
+
+ def test_add_resource_stores_resource(self):
+ """Verify add_resource stores a (Resource, None) tuple in _initial_resources."""
+ app = MCPApp(name="TestApp", version="1.0.0")
+ app.add_resource("ui://app/index.html", name="App UI", mime_type="text/html")
+
+ assert len(app._initial_resources) == 1
+ resource, handler = app._initial_resources[0]
+ assert resource.uri == "ui://app/index.html"
+ assert resource.name == "App UI"
+ assert resource.mimeType == "text/html"
+ assert handler is None
+
+ def test_add_resource_with_handler(self):
+ """Verify handler is stored alongside resource."""
+ app = MCPApp(name="TestApp", version="1.0.0")
+
+ def my_handler(uri: str) -> str:
+ return "content"
+
+ app.add_resource("file:///data.json", name="Data", handler=my_handler)
+
+ assert len(app._initial_resources) == 1
+ resource, handler = app._initial_resources[0]
+ assert resource.uri == "file:///data.json"
+ assert handler is my_handler
+
+ def test_add_resource_name_defaults_to_uri(self):
+ """Name defaults to URI when omitted."""
+ app = MCPApp(name="TestApp", version="1.0.0")
+ app.add_resource("ui://app/page.html")
+
+ resource, _ = app._initial_resources[0]
+ assert resource.name == "ui://app/page.html"
+
+ def test_add_resource_with_title(self):
+ """Verify title is set on the Resource object."""
+ app = MCPApp(name="TestApp", version="1.0.0")
+ app.add_resource(
+ "ui://app/index.html",
+ name="App UI",
+ title="My Application",
+ mime_type="text/html",
+ )
+
+ resource, _ = app._initial_resources[0]
+ assert resource.title == "My Application"
+
+ def test_resource_decorator_with_title(self):
+ """Verify title is passed through from the decorator."""
+ app = MCPApp(name="TestApp", version="1.0.0")
+
+ @app.resource("ui://app/index.html", title="My App UI")
+ def serve_ui(uri: str) -> str:
+ return ""
+
+ resource, _ = app._initial_resources[0]
+ assert resource.title == "My App UI"
+
+ def test_resource_decorator_registers_resource(self):
+ """@app.resource(uri) stores (Resource, fn)."""
+ app = MCPApp(name="TestApp", version="1.0.0")
+
+ @app.resource("ui://app/index.html", mime_type="text/html")
+ def serve_ui(uri: str) -> str:
+ return ""
+
+ assert len(app._initial_resources) == 1
+ resource, handler = app._initial_resources[0]
+ assert resource.uri == "ui://app/index.html"
+ assert handler is serve_ui
+
+ def test_resource_decorator_name_defaults_to_function_name(self):
+ """Name defaults to fn.__name__ when using decorator."""
+ app = MCPApp(name="TestApp", version="1.0.0")
+
+ @app.resource("ui://app/index.html")
+ def serve_ui(uri: str) -> str:
+ return ""
+
+ resource, _ = app._initial_resources[0]
+ assert resource.name == "serve_ui"
+
+ def test_resource_decorator_preserves_function(self):
+ """Decorated function is still directly callable."""
+ app = MCPApp(name="TestApp", version="1.0.0")
+
+ @app.resource("ui://app/index.html")
+ def serve_ui(uri: str) -> str:
+ return f"content for {uri}"
+
+ assert serve_ui("test://uri") == "content for test://uri"
+
+ def test_multiple_resources_registered(self):
+ """Multiple add_resource + decorator calls accumulate."""
+ app = MCPApp(name="TestApp", version="1.0.0")
+
+ app.add_resource("file:///a.txt", name="A")
+ app.add_resource("file:///b.txt", name="B")
+
+ @app.resource("ui://app/index.html")
+ def serve_ui(uri: str) -> str:
+ return ""
+
+ assert len(app._initial_resources) == 3
+
+ def test_run_exits_when_no_tools_or_resources(self):
+ """run() exits when neither tools nor resources are registered."""
+ app = MCPApp(name="TestApp", version="1.0.0")
+ with pytest.raises(SystemExit):
+ app.run(transport="http")
+
+ def test_run_allows_resource_only_server(self):
+ """run() does not exit when only resources are registered (no tools)."""
+ app = MCPApp(name="TestApp", version="1.0.0")
+ app.add_resource("ui://app/index.html", name="App UI")
+
+ with (
+ patch("arcade_mcp_server.mcp_app.create_arcade_mcp") as mock_create,
+ patch("arcade_mcp_server.mcp_app.serve_with_force_quit"),
+ ):
+ mock_create.return_value = Mock()
+ # Should NOT raise SystemExit
+ app.run(transport="http", host="127.0.0.1", port=8000)
+
+ def test_tool_with_meta(self):
+ """@app.tool(meta=...) stores _meta extension."""
+ app = MCPApp(name="TestApp", version="1.0.0")
+
+ @app.tool(meta={"ui": {"resourceUri": "ui://test-app/index.html"}})
+ def get_data(query: Annotated[str, "A query"]) -> str:
+ """Get data."""
+ return "data"
+
+ assert "TestApp.GetData" in app._tool_meta_extensions
+ assert app._tool_meta_extensions["TestApp.GetData"] == {
+ "ui": {"resourceUri": "ui://test-app/index.html"}
+ }
+
+ def test_add_tool_with_meta(self):
+ """add_tool(meta=...) stores _meta extension."""
+ app = MCPApp(name="TestApp", version="1.0.0")
+
+ def my_tool(x: Annotated[str, "input"]) -> str:
+ """A tool."""
+ return x
+
+ app.add_tool(my_tool, meta={"ui": {"resourceUri": "ui://my-tool/index.html"}})
+
+ assert "TestApp.MyTool" in app._tool_meta_extensions
+ assert app._tool_meta_extensions["TestApp.MyTool"] == {
+ "ui": {"resourceUri": "ui://my-tool/index.html"}
+ }
+
+ def test_tool_with_meta_arbitrary_keys(self):
+ """meta with arbitrary keys is stored as-is."""
+ app = MCPApp(name="TestApp", version="1.0.0")
+
+ @app.tool(meta={"custom": {"key": "value"}, "other": 42})
+ def do_stuff(x: Annotated[str, "input"]) -> str:
+ """A tool."""
+ return x
+
+ assert "TestApp.DoStuff" in app._tool_meta_extensions
+ assert app._tool_meta_extensions["TestApp.DoStuff"] == {
+ "custom": {"key": "value"},
+ "other": 42,
+ }
+
+ def test_tool_with_meta_arcade_key_raises(self):
+ """meta containing 'arcade' key raises ToolDefinitionError."""
+ app = MCPApp(name="TestApp", version="1.0.0")
+
+ with pytest.raises(ToolDefinitionError, match="'arcade' key in meta is reserved"):
+
+ @app.tool(meta={"arcade": {"something": True}})
+ def bad_tool(x: Annotated[str, "input"]) -> str:
+ """A tool."""
+ return x
+
+ def test_tool_with_meta_empty_or_none(self):
+ """meta={} and meta=None do not create _tool_meta_extensions entries."""
+ app = MCPApp(name="TestApp", version="1.0.0")
+
+ @app.tool(meta={})
+ def tool_empty(x: Annotated[str, "input"]) -> str:
+ """A tool."""
+ return x
+
+ @app.tool(meta=None)
+ def tool_none(x: Annotated[str, "input"]) -> str:
+ """A tool."""
+ return x
+
+ assert "TestApp.ToolEmpty" not in app._tool_meta_extensions
+ assert "TestApp.ToolNone" not in app._tool_meta_extensions
+
+
+class TestMCPAppResourceAnnotationsMetaTemplates:
+ """Test MCPApp resource registration with annotations, meta, and templates."""
+
+ def test_add_resource_with_annotations(self):
+ from arcade_mcp_server.types import Annotations, Resource
+
+ app = MCPApp(name="TestApp", version="1.0.0")
+ app.add_resource(
+ "res://test",
+ annotations=Annotations(priority=0.5),
+ )
+ assert len(app._initial_resources) == 1
+ item, _handler = app._initial_resources[0]
+ assert isinstance(item, Resource)
+ assert item.annotations is not None
+ assert item.annotations.priority == 0.5
+
+ def test_add_resource_with_meta(self):
+ from arcade_mcp_server.types import Resource
+
+ app = MCPApp(name="TestApp", version="1.0.0")
+ app.add_resource(
+ "res://test",
+ meta={"custom": "value"},
+ )
+ item, _ = app._initial_resources[0]
+ assert isinstance(item, Resource)
+ assert item.meta == {"custom": "value"}
+
+ def test_resource_decorator_with_annotations(self):
+ from arcade_mcp_server.types import Annotations, Resource
+
+ app = MCPApp(name="TestApp", version="1.0.0")
+
+ @app.resource("res://dec", annotations=Annotations(priority=0.8))
+ def handler(uri: str) -> str:
+ return "ok"
+
+ item, _ = app._initial_resources[0]
+ assert isinstance(item, Resource)
+ assert item.annotations is not None
+ assert item.annotations.priority == 0.8
+
+ def test_resource_decorator_with_meta(self):
+ from arcade_mcp_server.types import Resource
+
+ app = MCPApp(name="TestApp", version="1.0.0")
+
+ @app.resource("res://dec", meta={"key": "val"})
+ def handler(uri: str) -> str:
+ return "ok"
+
+ item, _ = app._initial_resources[0]
+ assert isinstance(item, Resource)
+ assert item.meta == {"key": "val"}
+
+ def test_resource_decorator_with_template_uri(self):
+ from arcade_mcp_server.types import ResourceTemplate
+
+ app = MCPApp(name="TestApp", version="1.0.0")
+
+ @app.resource("weather://{city}/current")
+ def handler(uri: str, city: str) -> str:
+ return f"Weather for {city}"
+
+ item, _ = app._initial_resources[0]
+ assert isinstance(item, ResourceTemplate)
+ assert item.uriTemplate == "weather://{city}/current"
+
+ def test_resource_decorator_template_listed_as_template(self):
+ from arcade_mcp_server.types import Resource, ResourceTemplate
+
+ app = MCPApp(name="TestApp", version="1.0.0")
+
+ @app.resource("weather://{city}/current")
+ def handler(uri: str, city: str) -> str:
+ return f"Weather for {city}"
+
+ # Should be ResourceTemplate, not Resource
+ item, _ = app._initial_resources[0]
+ assert isinstance(item, ResourceTemplate)
+ assert not isinstance(item, Resource)
+
+ def test_mcp_app_add_text_resource(self):
+ from arcade_mcp_server.types import Resource
+
+ app = MCPApp(name="TestApp", version="1.0.0")
+ app.add_text_resource("text://hello", text="hello world")
+
+ assert len(app._initial_resources) == 1
+ item, handler = app._initial_resources[0]
+ assert isinstance(item, Resource)
+ assert handler is not None
+
+ def test_mcp_app_add_file_resource(self, tmp_path):
+ from arcade_mcp_server.types import Resource
+
+ f = tmp_path / "test.txt"
+ f.write_text("file content")
+
+ app = MCPApp(name="TestApp", version="1.0.0")
+ app.add_file_resource(
+ "file:///test.txt",
+ path=str(f),
+ name="Test File",
+ mime_type="text/plain",
+ )
+
+ assert len(app._initial_resources) == 1
+ item, handler = app._initial_resources[0]
+ assert isinstance(item, Resource)
+ assert item.name == "Test File"
+ assert handler is not None
+ # Handler should return the file content
+ assert handler("file:///test.txt") == "file content"
+
+ def test_mcp_app_add_file_resource_binary(self, tmp_path):
+ f = tmp_path / "image.bin"
+ f.write_bytes(b"\x89PNG\r\n")
+
+ app = MCPApp(name="TestApp", version="1.0.0")
+ app.add_file_resource("file:///image.bin", path=str(f))
+
+ _, handler = app._initial_resources[0]
+ result = handler("file:///image.bin")
+ assert isinstance(result, bytes)
+
+ def test_mcp_app_add_file_resource_missing_raises(self, tmp_path):
+ from arcade_mcp_server.exceptions import NotFoundError
+
+ app = MCPApp(name="TestApp", version="1.0.0")
+ app.add_file_resource("file:///missing.txt", path=str(tmp_path / "missing.txt"))
+
+ _, handler = app._initial_resources[0]
+ with pytest.raises(NotFoundError, match="File not found"):
+ handler("file:///missing.txt")
diff --git a/libs/tests/arcade_mcp_server/test_resource.py b/libs/tests/arcade_mcp_server/test_resource.py
index 1dd0c506..57f747a2 100644
--- a/libs/tests/arcade_mcp_server/test_resource.py
+++ b/libs/tests/arcade_mcp_server/test_resource.py
@@ -1,10 +1,18 @@
"""Tests for Resource Manager implementation."""
import asyncio
+import base64
+import logging
import pytest
-from arcade_mcp_server.exceptions import NotFoundError
-from arcade_mcp_server.managers.resource import ResourceManager
+from arcade_mcp_server.exceptions import NotFoundError, ResourceError
+from arcade_mcp_server.managers.resource import (
+ MultipleMatchPolicy,
+ ResourceManager,
+ _is_template_uri,
+ _template_to_regex,
+ _template_to_sample_uri,
+)
from arcade_mcp_server.types import (
BlobResourceContents,
Resource,
@@ -130,8 +138,6 @@ class TestResourceManager:
resource = Resource(uri="file:///image.png", name="image.png", mimeType="image/png")
async def image_handler(uri: str) -> list[ResourceContents]:
- import base64
-
png_data = base64.b64encode(
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde"
).decode()
@@ -191,3 +197,511 @@ class TestResourceManager:
assert resources == []
templates = await manager.list_resource_templates()
assert templates == []
+
+
+class TestBytesReturnType:
+ """Test that handlers returning raw bytes are auto-encoded to BlobResourceContents."""
+
+ @pytest.mark.asyncio
+ async def test_handler_returning_bytes_produces_blob_contents(self):
+ manager = ResourceManager()
+ resource = Resource(uri="img://test", name="test")
+ await manager.add_resource(resource, handler=lambda _uri: b"\x89PNG\r\n")
+
+ result = await manager.read_resource("img://test")
+
+ assert len(result) == 1
+ assert isinstance(result[0], BlobResourceContents)
+ assert result[0].blob == base64.b64encode(b"\x89PNG\r\n").decode("ascii")
+
+ @pytest.mark.asyncio
+ async def test_handler_returning_bytes_preserves_mime_type(self):
+ manager = ResourceManager()
+ resource = Resource(uri="img://png", name="png", mimeType="image/png")
+ await manager.add_resource(resource, handler=lambda _uri: b"\x89PNG")
+
+ result = await manager.read_resource("img://png")
+
+ assert isinstance(result[0], BlobResourceContents)
+ assert result[0].mimeType == "image/png"
+
+ @pytest.mark.asyncio
+ async def test_handler_returning_empty_bytes(self):
+ manager = ResourceManager()
+ resource = Resource(uri="img://empty", name="empty")
+ await manager.add_resource(resource, handler=lambda _uri: b"")
+
+ result = await manager.read_resource("img://empty")
+
+ assert isinstance(result[0], BlobResourceContents)
+ assert result[0].blob == ""
+
+
+class TestResourceTemplateMatching:
+ """Test URI template matching for resources/read."""
+
+ def test_is_template_uri_with_braces(self):
+ assert _is_template_uri("weather://{city}/current") is True
+
+ def test_is_template_uri_without_braces(self):
+ assert _is_template_uri("file:///static.txt") is False
+
+ def test_template_to_regex_single_param(self):
+ pattern = _template_to_regex("weather://{city}/current")
+ m = pattern.match("weather://london/current")
+ assert m is not None
+ assert m.group("city") == "london"
+
+ def test_template_to_regex_multiple_params(self):
+ pattern = _template_to_regex("db://{database}/{table}")
+ m = pattern.match("db://mydb/users")
+ assert m is not None
+ assert m.group("database") == "mydb"
+ assert m.group("table") == "users"
+
+ def test_template_to_regex_wildcard(self):
+ pattern = _template_to_regex("file:///{path*}")
+ m = pattern.match("file:///a/b/c.txt")
+ assert m is not None
+ assert m.group("path") == "a/b/c.txt"
+
+ @pytest.mark.asyncio
+ async def test_read_resource_matches_template(self):
+ manager = ResourceManager()
+ tmpl = ResourceTemplate(
+ uriTemplate="weather://{city}/current",
+ name="Weather",
+ mimeType="text/plain",
+ )
+
+ def handler(uri: str, city: str) -> str:
+ return f"Weather for {city}"
+
+ await manager.add_template_with_handler(tmpl, handler)
+
+ result = await manager.read_resource("weather://london/current")
+ assert len(result) == 1
+ assert isinstance(result[0], TextResourceContents)
+ assert result[0].text == "Weather for london"
+
+ @pytest.mark.asyncio
+ async def test_read_resource_template_handler_receives_params(self):
+ manager = ResourceManager()
+ tmpl = ResourceTemplate(
+ uriTemplate="weather://{city}/current",
+ name="Weather",
+ )
+ received: dict[str, str] = {}
+
+ def handler(uri: str, city: str) -> str:
+ received["city"] = city
+ return "ok"
+
+ await manager.add_template_with_handler(tmpl, handler)
+ await manager.read_resource("weather://london/current")
+ assert received["city"] == "london"
+
+ @pytest.mark.asyncio
+ async def test_read_resource_exact_match_takes_priority(self):
+ manager = ResourceManager()
+ # Add exact match
+ exact = Resource(uri="weather://london/current", name="London Weather")
+ await manager.add_resource(exact, handler=lambda _uri: "exact match")
+
+ # Add template
+ tmpl = ResourceTemplate(
+ uriTemplate="weather://{city}/current", name="Weather Template"
+ )
+ await manager.add_template_with_handler(tmpl, lambda uri, city: "template match")
+
+ result = await manager.read_resource("weather://london/current")
+ assert result[0].text == "exact match"
+
+ @pytest.mark.asyncio
+ async def test_read_resource_template_no_match_raises_not_found(self):
+ manager = ResourceManager()
+ tmpl = ResourceTemplate(
+ uriTemplate="weather://{city}/current", name="Weather"
+ )
+ await manager.add_template_with_handler(tmpl, lambda uri, city: "ok")
+
+ with pytest.raises(NotFoundError):
+ await manager.read_resource("completely://different")
+
+ @pytest.mark.asyncio
+ async def test_read_resource_template_handler_str_return_coercion(self):
+ manager = ResourceManager()
+ tmpl = ResourceTemplate(
+ uriTemplate="data://{item_id}",
+ name="Data",
+ mimeType="text/plain",
+ )
+ await manager.add_template_with_handler(tmpl, lambda uri, item_id: "hello")
+
+ result = await manager.read_resource("data://42")
+ assert isinstance(result[0], TextResourceContents)
+ assert result[0].text == "hello"
+
+ @pytest.mark.asyncio
+ async def test_read_resource_template_handler_bytes_return_coercion(self):
+ manager = ResourceManager()
+ tmpl = ResourceTemplate(
+ uriTemplate="bin://{item_id}",
+ name="Binary",
+ mimeType="application/octet-stream",
+ )
+ await manager.add_template_with_handler(tmpl, lambda uri, item_id: b"\x00\x01")
+
+ result = await manager.read_resource("bin://42")
+ assert isinstance(result[0], BlobResourceContents)
+ assert result[0].blob == base64.b64encode(b"\x00\x01").decode("ascii")
+
+ @pytest.mark.asyncio
+ async def test_read_resource_template_async_handler(self):
+ manager = ResourceManager()
+ tmpl = ResourceTemplate(
+ uriTemplate="async://{key}",
+ name="Async",
+ )
+
+ async def handler(uri: str, key: str) -> str:
+ return f"async-{key}"
+
+ await manager.add_template_with_handler(tmpl, handler)
+
+ result = await manager.read_resource("async://abc")
+ assert result[0].text == "async-abc"
+
+
+class TestStaticResources:
+ """Test convenience methods for static resources."""
+
+ @pytest.mark.asyncio
+ async def test_add_text_resource(self):
+ manager = ResourceManager()
+ await manager.add_text_resource("text://hello", text="hello")
+
+ result = await manager.read_resource("text://hello")
+ assert isinstance(result[0], TextResourceContents)
+ assert result[0].text == "hello"
+
+ @pytest.mark.asyncio
+ async def test_add_text_resource_with_metadata(self):
+ manager = ResourceManager()
+ await manager.add_text_resource(
+ "text://meta",
+ text="content",
+ name="my-resource",
+ description="A description",
+ mime_type="text/html",
+ )
+
+ resources = await manager.list_resources()
+ assert len(resources) == 1
+ assert resources[0].name == "my-resource"
+ assert resources[0].description == "A description"
+ assert resources[0].mimeType == "text/html"
+
+ @pytest.mark.asyncio
+ async def test_add_file_resource(self, tmp_path):
+ manager = ResourceManager()
+ f = tmp_path / "test.txt"
+ f.write_text("file content")
+
+ await manager.add_file_resource("file:///test.txt", path=str(f))
+
+ result = await manager.read_resource("file:///test.txt")
+ assert isinstance(result[0], TextResourceContents)
+ assert result[0].text == "file content"
+
+ @pytest.mark.asyncio
+ async def test_add_file_resource_binary(self, tmp_path):
+ manager = ResourceManager()
+ f = tmp_path / "image.bin"
+ f.write_bytes(b"\x89PNG\r\n")
+
+ await manager.add_file_resource("file:///image.bin", path=str(f))
+
+ result = await manager.read_resource("file:///image.bin")
+ assert isinstance(result[0], BlobResourceContents)
+
+ @pytest.mark.asyncio
+ async def test_add_file_resource_not_found(self, tmp_path):
+ manager = ResourceManager()
+ await manager.add_file_resource(
+ "file:///missing.txt", path=str(tmp_path / "missing.txt")
+ )
+
+ with pytest.raises(NotFoundError, match="File not found"):
+ await manager.read_resource("file:///missing.txt")
+
+
+class TestDuplicateHandlingPolicy:
+ """Test duplicate resource handling policies."""
+
+ def test_default_policy_is_warn(self):
+ manager = ResourceManager()
+ assert manager.duplicate_policy == "warn"
+
+ @pytest.mark.asyncio
+ async def test_policy_warn_logs_and_replaces(self, caplog):
+ manager = ResourceManager(duplicate_policy="warn")
+ r1 = Resource(uri="dup://test", name="first")
+ r2 = Resource(uri="dup://test", name="second")
+
+ await manager.add_resource(r1, handler=lambda _: "first")
+ with caplog.at_level(logging.WARNING, logger="arcade.mcp.managers.resource"):
+ await manager.add_resource(r2, handler=lambda _: "second")
+
+ assert "Replacing duplicate resource" in caplog.text
+
+ # Second handler should win
+ result = await manager.read_resource("dup://test")
+ assert result[0].text == "second"
+
+ @pytest.mark.asyncio
+ async def test_policy_error_raises(self):
+ manager = ResourceManager(duplicate_policy="error")
+ r = Resource(uri="dup://test", name="first")
+ await manager.add_resource(r)
+
+ with pytest.raises(ResourceError, match="already registered"):
+ await manager.add_resource(r)
+
+ @pytest.mark.asyncio
+ async def test_policy_replace_silently(self, caplog):
+ manager = ResourceManager(duplicate_policy="replace")
+ r1 = Resource(uri="dup://test", name="first")
+ r2 = Resource(uri="dup://test", name="second")
+
+ await manager.add_resource(r1, handler=lambda _: "first")
+ with caplog.at_level(logging.WARNING, logger="arcade.mcp.managers.resource"):
+ await manager.add_resource(r2, handler=lambda _: "second")
+
+ # No warning logged
+ assert "Replacing duplicate resource" not in caplog.text
+
+ result = await manager.read_resource("dup://test")
+ assert result[0].text == "second"
+
+ @pytest.mark.asyncio
+ async def test_policy_ignore_keeps_first(self):
+ manager = ResourceManager(duplicate_policy="ignore")
+ r1 = Resource(uri="dup://test", name="first")
+ r2 = Resource(uri="dup://test", name="second")
+
+ await manager.add_resource(r1, handler=lambda _: "first")
+ await manager.add_resource(r2, handler=lambda _: "second")
+
+ # First handler should be kept
+ result = await manager.read_resource("dup://test")
+ assert result[0].text == "first"
+
+
+class TestCoerceResultDictBranches:
+ """Test _coerce_result dict sub-branches and other handler return types."""
+
+ @pytest.mark.asyncio
+ async def test_handler_returning_dict_with_text_key(self):
+ manager = ResourceManager()
+ resource = Resource(uri="dict://text", name="text", mimeType="text/plain")
+ await manager.add_resource(resource, handler=lambda _uri: {"text": "from dict"})
+
+ result = await manager.read_resource("dict://text")
+ assert isinstance(result[0], TextResourceContents)
+ assert result[0].text == "from dict"
+ assert result[0].mimeType == "text/plain"
+
+ @pytest.mark.asyncio
+ async def test_handler_returning_dict_with_blob_key(self):
+ manager = ResourceManager()
+ resource = Resource(uri="dict://blob", name="blob", mimeType="application/octet-stream")
+ await manager.add_resource(resource, handler=lambda _uri: {"blob": "AQID"})
+
+ result = await manager.read_resource("dict://blob")
+ assert isinstance(result[0], BlobResourceContents)
+ assert result[0].blob == "AQID"
+ assert result[0].mimeType == "application/octet-stream"
+
+ @pytest.mark.asyncio
+ async def test_handler_returning_dict_with_no_text_or_blob(self):
+ manager = ResourceManager()
+ resource = Resource(uri="dict://empty", name="empty", mimeType="application/json")
+ await manager.add_resource(resource, handler=lambda _uri: {"foo": "bar"})
+
+ result = await manager.read_resource("dict://empty")
+ assert isinstance(result[0], ResourceContents)
+ assert result[0].mimeType == "application/json"
+
+ @pytest.mark.asyncio
+ async def test_handler_returning_non_standard_type(self):
+ """Handlers returning arbitrary types get str()-converted."""
+ manager = ResourceManager()
+ resource = Resource(uri="misc://int", name="int")
+ await manager.add_resource(resource, handler=lambda _uri: 42)
+
+ result = await manager.read_resource("misc://int")
+ assert isinstance(result[0], TextResourceContents)
+ assert result[0].text == "42"
+
+
+class TestUpdateResource:
+ """Test ResourceManager.update_resource."""
+
+ @pytest.mark.asyncio
+ async def test_update_existing_resource(self):
+ manager = ResourceManager()
+ r = Resource(uri="upd://test", name="original")
+ await manager.add_resource(r, handler=lambda _uri: "original")
+
+ updated = Resource(uri="upd://test", name="updated")
+ result = await manager.update_resource("upd://test", updated, handler=lambda _uri: "updated")
+ assert result.name == "updated"
+
+ contents = await manager.read_resource("upd://test")
+ assert contents[0].text == "updated"
+
+ @pytest.mark.asyncio
+ async def test_update_nonexistent_resource_raises(self):
+ manager = ResourceManager()
+ r = Resource(uri="upd://missing", name="missing")
+ with pytest.raises(NotFoundError, match="not found"):
+ await manager.update_resource("upd://missing", r)
+
+
+class TestRemoveTemplate:
+ """Test ResourceManager.remove_template."""
+
+ @pytest.mark.asyncio
+ async def test_remove_template_success(self):
+ manager = ResourceManager()
+ tmpl = ResourceTemplate(uriTemplate="rm://{id}", name="removable")
+ await manager.add_template_with_handler(tmpl, lambda uri, id: f"item {id}")
+
+ removed = await manager.remove_template("rm://{id}")
+ assert removed.uriTemplate == "rm://{id}"
+
+ # Template handler should no longer match
+ with pytest.raises(NotFoundError):
+ await manager.read_resource("rm://123")
+
+ @pytest.mark.asyncio
+ async def test_remove_template_not_found(self):
+ manager = ResourceManager()
+ with pytest.raises(NotFoundError, match="not found"):
+ await manager.remove_template("nonexistent://{x}")
+
+
+class TestTemplateOverlapWarning:
+ """Test that overlapping templates produce a warning at registration time."""
+
+ def test_template_to_sample_uri_simple(self):
+ assert _template_to_sample_uri("weather://{city}/current") == "weather://__city__/current"
+
+ def test_template_to_sample_uri_wildcard(self):
+ sample = _template_to_sample_uri("file:///{path*}")
+ assert sample == "file:///__path__/nested"
+
+ @pytest.mark.asyncio
+ async def test_wildcard_before_specific_warns(self, caplog):
+ """A broad wildcard registered first should warn when a narrower template is added."""
+ manager = ResourceManager()
+ broad = ResourceTemplate(uriTemplate="kb://docs/{path*}", name="Catch-all")
+ specific = ResourceTemplate(
+ uriTemplate="kb://docs/{category}/articles/{slug}", name="Specific"
+ )
+
+ await manager.add_template_with_handler(broad, lambda uri, path: "broad")
+ with caplog.at_level(logging.WARNING, logger="arcade.mcp.managers.resource"):
+ await manager.add_template_with_handler(specific, lambda uri, category, slug: "specific")
+
+ assert "overlaps" in caplog.text
+ assert "kb://docs/{path*}" in caplog.text
+ assert "kb://docs/{category}/articles/{slug}" in caplog.text
+
+ @pytest.mark.asyncio
+ async def test_specific_before_wildcard_warns(self, caplog):
+ """Adding a broad template after a specific one also warns."""
+ manager = ResourceManager()
+ specific = ResourceTemplate(
+ uriTemplate="kb://docs/{category}/articles/{slug}", name="Specific"
+ )
+ broad = ResourceTemplate(uriTemplate="kb://docs/{path*}", name="Catch-all")
+
+ await manager.add_template_with_handler(specific, lambda uri, category, slug: "specific")
+ with caplog.at_level(logging.WARNING, logger="arcade.mcp.managers.resource"):
+ await manager.add_template_with_handler(broad, lambda uri, path: "broad")
+
+ assert "overlaps" in caplog.text
+
+ @pytest.mark.asyncio
+ async def test_non_overlapping_templates_no_warning(self, caplog):
+ """Templates with different prefixes should not warn."""
+ manager = ResourceManager()
+ t1 = ResourceTemplate(uriTemplate="weather://{city}/current", name="Weather")
+ t2 = ResourceTemplate(uriTemplate="news://{topic}/latest", name="News")
+
+ await manager.add_template_with_handler(t1, lambda uri, city: "weather")
+ with caplog.at_level(logging.WARNING, logger="arcade.mcp.managers.resource"):
+ await manager.add_template_with_handler(t2, lambda uri, topic: "news")
+
+ assert "overlaps" not in caplog.text
+
+
+class TestMultipleMatchPolicy:
+ """Test runtime multiple-template-match detection via multiple_match_policy."""
+
+ async def _setup_overlapping(
+ self, policy: MultipleMatchPolicy
+ ) -> ResourceManager:
+ """Register a wildcard and a specific template that overlap."""
+ manager = ResourceManager(multiple_match_policy=policy)
+ broad = ResourceTemplate(uriTemplate="kb://docs/{path*}", name="Catch-all")
+ specific = ResourceTemplate(
+ uriTemplate="kb://docs/{category}/articles/{slug}", name="Specific"
+ )
+ await manager.add_template_with_handler(broad, lambda uri, path: "broad")
+ await manager.add_template_with_handler(specific, lambda uri, category, slug: "specific")
+ return manager
+
+ def test_default_policy_is_warn(self):
+ manager = ResourceManager()
+ assert manager.multiple_match_policy == "warn"
+
+ @pytest.mark.asyncio
+ async def test_warn_logs_and_returns_first(self, caplog):
+ manager = await self._setup_overlapping("warn")
+ with caplog.at_level(logging.WARNING, logger="arcade.mcp.managers.resource"):
+ result = await manager.read_resource("kb://docs/science/articles/quantum")
+
+ assert result[0].text == "broad"
+ assert "matched 2 resource templates" in caplog.text
+ assert "kb://docs/{path*}" in caplog.text
+
+ @pytest.mark.asyncio
+ async def test_error_raises(self):
+ manager = await self._setup_overlapping("error")
+ with pytest.raises(ResourceError, match="matched 2 resource templates"):
+ await manager.read_resource("kb://docs/science/articles/quantum")
+
+ @pytest.mark.asyncio
+ async def test_ignore_returns_first_silently(self, caplog):
+ manager = await self._setup_overlapping("ignore")
+ with caplog.at_level(logging.WARNING, logger="arcade.mcp.managers.resource"):
+ result = await manager.read_resource("kb://docs/science/articles/quantum")
+
+ assert result[0].text == "broad"
+ assert "matched" not in caplog.text
+
+ @pytest.mark.asyncio
+ async def test_single_match_no_warning(self, caplog):
+ """When only one template matches, no warning regardless of policy."""
+ manager = ResourceManager(multiple_match_policy="warn")
+ tmpl = ResourceTemplate(uriTemplate="weather://{city}/current", name="Weather")
+ await manager.add_template_with_handler(tmpl, lambda uri, city: f"weather-{city}")
+
+ with caplog.at_level(logging.WARNING, logger="arcade.mcp.managers.resource"):
+ result = await manager.read_resource("weather://london/current")
+
+ assert result[0].text == "weather-london"
+ assert "matched" not in caplog.text
diff --git a/libs/tests/arcade_mcp_server/test_server.py b/libs/tests/arcade_mcp_server/test_server.py
index cdfae286..e032772c 100644
--- a/libs/tests/arcade_mcp_server/test_server.py
+++ b/libs/tests/arcade_mcp_server/test_server.py
@@ -1556,3 +1556,224 @@ class TestMissingSecretsWarnings:
# Restore environment
if old_value is not None:
os.environ["FORMAT_TEST_KEY"] = old_value
+
+
+class TestServerToolMetaExtensions:
+ """Tests for _meta extensions on tools (e.g., MCP Apps ui.resourceUri)."""
+
+ @pytest.mark.asyncio
+ async def test_tool_meta_extensions_applied(self, tool_catalog, mcp_settings):
+ """tool_meta_extensions adds _meta.ui.resourceUri to tools."""
+ # Get the FQN of the first tool in the catalog
+ first_tool = next(iter(tool_catalog))
+ fqn = first_tool.definition.fully_qualified_name
+
+ server = MCPServer(
+ catalog=tool_catalog,
+ settings=mcp_settings,
+ tool_meta_extensions={fqn: {"ui": {"resourceUri": "ui://test/index.html"}}},
+ )
+ await server.start()
+ try:
+ tools = await server.tools.list_tools()
+ # Find the tool by its sanitized name
+ sanitized = fqn.replace(".", "_")
+ matched = [t for t in tools if t.name == sanitized]
+ assert len(matched) == 1
+ assert matched[0].meta is not None
+ assert matched[0].meta["ui"]["resourceUri"] == "ui://test/index.html"
+ finally:
+ await server.stop()
+
+ @pytest.mark.asyncio
+ async def test_no_tool_meta_extensions_by_default(self, tool_catalog, mcp_settings):
+ """Without extensions, tools that have no arcade meta have _meta=None or no ui key."""
+ server = MCPServer(
+ catalog=tool_catalog,
+ settings=mcp_settings,
+ )
+ await server.start()
+ try:
+ tools = await server.tools.list_tools()
+ for t in tools:
+ if t.meta:
+ assert "ui" not in t.meta
+ finally:
+ await server.stop()
+
+
+class TestServerInitialResources:
+ """Tests for loading build-time resources into MCPServer."""
+
+ @pytest.mark.asyncio
+ async def test_server_loads_initial_resources(self, tool_catalog, mcp_settings):
+ """MCPServer with initial_resources makes them available via list/read."""
+ from arcade_mcp_server.types import Resource
+
+ resource = Resource(uri="ui://app/index.html", name="App UI", mimeType="text/html")
+
+ def handler(uri: str) -> str:
+ return "hello"
+
+ server = MCPServer(
+ catalog=tool_catalog,
+ settings=mcp_settings,
+ initial_resources=[(resource, handler)],
+ )
+ await server.start()
+ try:
+ resources = await server.resources.list_resources()
+ uris = [r.uri for r in resources]
+ assert "ui://app/index.html" in uris
+
+ contents = await server.resources.read_resource("ui://app/index.html")
+ assert len(contents) == 1
+ assert contents[0].text == "hello" # type: ignore[attr-defined]
+ finally:
+ await server.stop()
+
+ @pytest.mark.asyncio
+ async def test_server_no_initial_resources_by_default(self, tool_catalog, mcp_settings):
+ """Backward compat: no initial resources by default."""
+ server = MCPServer(
+ catalog=tool_catalog,
+ settings=mcp_settings,
+ )
+ await server.start()
+ try:
+ resources = await server.resources.list_resources()
+ assert resources == []
+ finally:
+ await server.stop()
+
+ @pytest.mark.asyncio
+ async def test_server_initial_resources_with_async_handler(self, tool_catalog, mcp_settings):
+ """Async handlers work for initial resources."""
+ from arcade_mcp_server.types import Resource
+
+ resource = Resource(uri="ui://app/data.json", name="Data", mimeType="application/json")
+
+ async def async_handler(uri: str) -> str:
+ return '{"key": "value"}'
+
+ server = MCPServer(
+ catalog=tool_catalog,
+ settings=mcp_settings,
+ initial_resources=[(resource, async_handler)],
+ )
+ await server.start()
+ try:
+ contents = await server.resources.read_resource("ui://app/data.json")
+ assert len(contents) == 1
+ assert contents[0].text == '{"key": "value"}' # type: ignore[attr-defined]
+ finally:
+ await server.stop()
+
+ @pytest.mark.asyncio
+ async def test_server_handle_read_resource_round_trip(self, tool_catalog, mcp_settings):
+ """Integration: _handle_read_resource returns correct JSONRPCResponse."""
+ from arcade_mcp_server.types import (
+ ReadResourceParams,
+ ReadResourceRequest,
+ ReadResourceResult,
+ Resource,
+ TextResourceContents,
+ )
+
+ resource = Resource(uri="ui://app/page.html", name="Page", mimeType="text/html")
+
+ def handler(uri: str) -> str:
+ return "Hello"
+
+ server = MCPServer(
+ catalog=tool_catalog,
+ settings=mcp_settings,
+ initial_resources=[(resource, handler)],
+ )
+ await server.start()
+ try:
+ request = ReadResourceRequest(
+ id=1,
+ params=ReadResourceParams(uri="ui://app/page.html"),
+ )
+ response = await server._handle_read_resource(request)
+
+ assert not hasattr(response, "error"), f"Expected success, got error: {response}"
+ result = response.result
+ assert isinstance(result, ReadResourceResult)
+ assert len(result.contents) == 1
+ content = result.contents[0]
+ assert isinstance(content, TextResourceContents)
+ assert content.text == "Hello"
+ assert content.mimeType == "text/html"
+ finally:
+ await server.stop()
+
+ @pytest.mark.asyncio
+ async def test_server_loads_initial_template_resources(self, tool_catalog, mcp_settings):
+ """MCPServer with initial ResourceTemplate registers it as a template."""
+ from arcade_mcp_server.types import ResourceTemplate
+
+ tmpl = ResourceTemplate(
+ uriTemplate="data://{item_id}", name="Data", mimeType="text/plain"
+ )
+
+ def handler(uri: str, item_id: str) -> str:
+ return f"item-{item_id}"
+
+ server = MCPServer(
+ catalog=tool_catalog,
+ settings=mcp_settings,
+ initial_resources=[(tmpl, handler)],
+ )
+ await server.start()
+ try:
+ templates = await server.resources.list_resource_templates()
+ assert any(t.uriTemplate == "data://{item_id}" for t in templates)
+
+ contents = await server.resources.read_resource("data://42")
+ assert contents[0].text == "item-42"
+ finally:
+ await server.stop()
+
+ @pytest.mark.asyncio
+ async def test_server_loads_template_without_handler(self, tool_catalog, mcp_settings):
+ """MCPServer with ResourceTemplate and no handler registers template only."""
+ from arcade_mcp_server.types import ResourceTemplate
+
+ tmpl = ResourceTemplate(
+ uriTemplate="schema://{type}", name="Schema"
+ )
+
+ server = MCPServer(
+ catalog=tool_catalog,
+ settings=mcp_settings,
+ initial_resources=[(tmpl, None)],
+ )
+ await server.start()
+ try:
+ templates = await server.resources.list_resource_templates()
+ assert any(t.uriTemplate == "schema://{type}" for t in templates)
+ finally:
+ await server.stop()
+
+
+class TestToolMetaExtensionEdgeCases:
+ """Test apply_meta_extensions edge cases on ToolManager directly."""
+
+ @pytest.mark.asyncio
+ async def test_missing_fqn_logs_warning(self, tool_catalog, mcp_settings, caplog):
+ """Extensions referencing non-existent tools log a warning and skip."""
+ import logging
+
+ server = MCPServer(
+ catalog=tool_catalog,
+ settings=mcp_settings,
+ tool_meta_extensions={"NonExistent.Tool": {"ui": {"resourceUri": "ui://x"}}},
+ )
+ with caplog.at_level(logging.WARNING, logger="arcade.mcp.managers.tool"):
+ await server.start()
+ try:
+ assert "skipped: tool not found" in caplog.text
+ finally:
+ await server.stop()