Add full support for MCP Resources (#803)

Resolves
https://linear.app/arcadedev/issue/TOO-590/add-resources-support-to-server-framework


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Adds new resource registration/reading semantics (including URI
templates and duplicate/multiple-match policies) and changes JSON Schema
generation for tool I/O, which may affect MCP client compatibility and
runtime behavior across servers.
> 
> **Overview**
> **Adds first-class MCP Resources support across `arcade-mcp-server`.**
`MCPApp` can now register resources at build time via
`add_resource`/`@resource` plus convenience `add_text_resource` and
`add_file_resource`, and passes these through to `MCPServer` for startup
loading (including `ResourceTemplate` URIs with `{param}` and `{param*}`
matching).
> 
> **Extends `ResourceManager` behavior.** Resource reads now coerce
handler return types (including raw `bytes` to base64
`BlobResourceContents`), support template matching with
overlap/multiple-match detection, and introduce configurable duplicate
handling policies.
> 
> **Improves tool schema + MCP Apps linking.** Tool input/output JSON
Schema generation is refactored to recursively expand nested `json`
schemas and ensure `outputSchema` is always an object (wrapping
non-object returns in a `result` property); `MCPApp` also supports
attaching arbitrary tool `_meta` extensions (e.g., `ui.resourceUri`)
applied at server start.
> 
> Adds two new example servers (`resources`, `tools_with_output_schema`)
and broad test coverage for resource templates, static/file resources,
meta extensions, and schema wrapping/recursion.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
e785bee79d74110727519b00b81dcad6e9b74212. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eric Gustin 2026-03-27 15:27:57 -07:00 committed by GitHub
parent bbba7aec90
commit 9eec003c72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 2592 additions and 91 deletions

View file

@ -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 }

View file

@ -0,0 +1,165 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Knowledge Base — MCP App</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: #f8fafc; color: #1e293b;
padding: 1.5rem; max-width: 520px; margin: 0 auto;
}
h1 { font-size: 1.25rem; margin-bottom: 1rem; }
.card {
background: #fff; border: 1px solid #e2e8f0; border-radius: 0.5rem;
padding: 1rem; margin-bottom: 1rem;
}
.card h2 { font-size: 1rem; margin-bottom: 0.75rem; color: #475569; }
button {
background: #3b82f6; color: #fff; border: none; border-radius: 0.375rem;
padding: 0.5rem 1rem; cursor: pointer; font-size: 0.875rem;
}
button:hover { background: #2563eb; }
button:disabled { background: #94a3b8; cursor: not-allowed; }
.result {
margin-top: 0.75rem; padding: 0.5rem; background: #f1f5f9;
border-radius: 0.25rem; font-family: monospace; font-size: 0.8125rem;
min-height: 1.5rem; white-space: pre-wrap; word-break: break-word;
}
label { display: block; font-size: 0.8125rem; color: #64748b; margin-bottom: 0.25rem; }
input, select {
width: 100%; padding: 0.375rem 0.5rem; border: 1px solid #cbd5e1;
border-radius: 0.25rem; font-size: 0.875rem; margin-bottom: 0.5rem;
}
</style>
</head>
<body>
<h1>Knowledge Base — MCP App</h1>
<!-- Get Article card -->
<div class="card">
<h2>Get Article</h2>
<label for="article-slug">Article slug</label>
<select id="article-slug">
<option value="getting-started">getting-started</option>
<option value="api-guidelines">api-guidelines</option>
<option value="security-policy">security-policy</option>
</select>
<button id="article-btn">Fetch Article</button>
<div class="result" id="article-result">Select an article and click Fetch.</div>
</div>
<!-- Search Articles card -->
<div class="card">
<h2>Search Articles</h2>
<label for="search-query">Search query</label>
<input id="search-query" type="text" placeholder="e.g. API, security, onboarding" />
<button id="search-btn">Search</button>
<div class="result" id="search-result">Enter a query and click Search.</div>
</div>
<script type="module">
// Minimal JSON-RPC 2.0 over postMessage client for MCP Apps.
// In production, use: import { App } from "@modelcontextprotocol/ext-apps";
const JSONRPC_VERSION = "2.0";
const UI_PROTOCOL_VERSION = "2026-01-26";
let nextId = 1;
const pending = {};
window.addEventListener("message", (e) => {
const msg = e.data;
if (!msg || typeof msg !== "object" || msg.jsonrpc !== JSONRPC_VERSION) return;
if (msg.id !== undefined && pending[msg.id]) {
pending[msg.id](msg);
delete pending[msg.id];
}
});
function rpcRequest(method, params) {
return new Promise((resolve, reject) => {
const id = nextId++;
pending[id] = (msg) => {
if (msg.error) reject(new Error(msg.error.message || "RPC error"));
else resolve(msg.result);
};
window.parent.postMessage(
{ jsonrpc: JSONRPC_VERSION, id, method, params },
"*"
);
});
}
function rpcNotify(method, params) {
window.parent.postMessage(
{ jsonrpc: JSONRPC_VERSION, method, params },
"*"
);
}
async function initialize() {
try {
await rpcRequest("ui/initialize", {
appInfo: { name: "Knowledge Base MCP App", version: "1.0.0" },
protocolVersion: UI_PROTOCOL_VERSION,
capabilities: {},
});
rpcNotify("ui/notifications/initialized", {});
} catch (err) {
console.error("MCP Apps init failed:", err);
}
}
await initialize();
async function callServerTool(name, args) {
const result = await rpcRequest("tools/call", { name, arguments: args });
return result;
}
function extractText(result) {
const text = result?.content?.find((c) => c.type === "text")?.text;
return text || "[no result]";
}
// Get Article button
const articleBtn = document.getElementById("article-btn");
const articleResult = document.getElementById("article-result");
articleBtn.addEventListener("click", async () => {
articleBtn.disabled = true;
articleResult.textContent = "Loading...";
try {
const slug = document.getElementById("article-slug").value;
const result = await callServerTool("Resources_GetArticle", { slug });
articleResult.textContent = extractText(result);
} catch (err) {
articleResult.textContent = "Error: " + err.message;
} finally {
articleBtn.disabled = false;
}
});
// Search Articles button
const searchBtn = document.getElementById("search-btn");
const searchResult = document.getElementById("search-result");
searchBtn.addEventListener("click", async () => {
searchBtn.disabled = true;
searchResult.textContent = "Searching...";
try {
const query = document.getElementById("search-query").value;
const result = await callServerTool("Resources_SearchArticles", { query });
searchResult.textContent = extractText(result);
} catch (err) {
searchResult.textContent = "Error: " + err.message;
} finally {
searchBtn.disabled = false;
}
});
</script>
</body>
</html>

View file

@ -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"),
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 B

View file

@ -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]

View file

@ -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)

View file

@ -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 }

View file

@ -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)

View file

@ -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",
]

View file

@ -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

View file

@ -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>[^/]+)``
``{param*}`` -> ``(?P<param>.+)`` (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))

View file

@ -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)

View file

@ -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,
)

View file

@ -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()

View file

@ -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,
)

View file

@ -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

View file

@ -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" }]

View file

@ -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"}

View file

@ -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 "<html></html>"
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 "<html></html>"
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 "<html></html>"
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 "<html></html>"
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")

View file

@ -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

View file

@ -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 "<html>hello</html>"
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 == "<html>hello</html>" # 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 "<html><body>Hello</body></html>"
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 == "<html><body>Hello</body></html>"
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()