diff --git a/examples/mcp_servers/resources/pyproject.toml b/examples/mcp_servers/resources/pyproject.toml new file mode 100644 index 00000000..285a6416 --- /dev/null +++ b/examples/mcp_servers/resources/pyproject.toml @@ -0,0 +1,44 @@ +[project] +name = "resources" +version = "0.1.0" +description = "Example MCP server showcasing resource features (static, file-backed, templates, annotations)" +requires-python = ">=3.10" +dependencies = [ + "arcade-mcp-server>=1.19.0,<2.0.0", +] + +[project.optional-dependencies] +dev = [ + "arcade-mcp[all]>=1.12.1,<2.0.0", + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "mypy>=1.0.0", + "ruff>=0.1.0", +] + +# Tell Arcade.dev that this package has Arcade tools +[project.entry-points.arcade_toolkits] +toolkit_name = "resources" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/resources"] + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.mypy] +python_version = "3.12" +warn_unused_configs = true +disallow_untyped_defs = false + +# Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode +# Otherwise, you will install the following packages from PyPI +# [tool.uv.sources] +# arcade-mcp = { path = "../../../", editable = true } +# arcade-serve = { path = "../../../libs/arcade-serve/", editable = true } +# arcade-mcp-server = { path = "../../../libs/arcade-mcp-server/", editable = true } diff --git a/examples/mcp_servers/resources/src/resources/__init__.py b/examples/mcp_servers/resources/src/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/mcp_servers/resources/src/resources/app.html b/examples/mcp_servers/resources/src/resources/app.html new file mode 100644 index 00000000..5d1ce41e --- /dev/null +++ b/examples/mcp_servers/resources/src/resources/app.html @@ -0,0 +1,165 @@ + + + + + +Knowledge Base — MCP App + + + +

Knowledge Base — MCP App

+ + +
+

Get Article

+ + + +
Select an article and click Fetch.
+
+ + +
+

Search Articles

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