Add full support for MCP Resources (#803)
Resolves https://linear.app/arcadedev/issue/TOO-590/add-resources-support-to-server-framework <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds new resource registration/reading semantics (including URI templates and duplicate/multiple-match policies) and changes JSON Schema generation for tool I/O, which may affect MCP client compatibility and runtime behavior across servers. > > **Overview** > **Adds first-class MCP Resources support across `arcade-mcp-server`.** `MCPApp` can now register resources at build time via `add_resource`/`@resource` plus convenience `add_text_resource` and `add_file_resource`, and passes these through to `MCPServer` for startup loading (including `ResourceTemplate` URIs with `{param}` and `{param*}` matching). > > **Extends `ResourceManager` behavior.** Resource reads now coerce handler return types (including raw `bytes` to base64 `BlobResourceContents`), support template matching with overlap/multiple-match detection, and introduce configurable duplicate handling policies. > > **Improves tool schema + MCP Apps linking.** Tool input/output JSON Schema generation is refactored to recursively expand nested `json` schemas and ensure `outputSchema` is always an object (wrapping non-object returns in a `result` property); `MCPApp` also supports attaching arbitrary tool `_meta` extensions (e.g., `ui.resourceUri`) applied at server start. > > Adds two new example servers (`resources`, `tools_with_output_schema`) and broad test coverage for resource templates, static/file resources, meta extensions, and schema wrapping/recursion. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e785bee79d74110727519b00b81dcad6e9b74212. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bbba7aec90
commit
9eec003c72
23 changed files with 2592 additions and 91 deletions
44
examples/mcp_servers/resources/pyproject.toml
Normal file
44
examples/mcp_servers/resources/pyproject.toml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
[project]
|
||||
name = "resources"
|
||||
version = "0.1.0"
|
||||
description = "Example MCP server showcasing resource features (static, file-backed, templates, annotations)"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-mcp-server>=1.19.0,<2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"arcade-mcp[all]>=1.12.1,<2.0.0",
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"mypy>=1.0.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
|
||||
# Tell Arcade.dev that this package has Arcade tools
|
||||
[project.entry-points.arcade_toolkits]
|
||||
toolkit_name = "resources"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/resources"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = false
|
||||
|
||||
# Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode
|
||||
# Otherwise, you will install the following packages from PyPI
|
||||
# [tool.uv.sources]
|
||||
# arcade-mcp = { path = "../../../", editable = true }
|
||||
# arcade-serve = { path = "../../../libs/arcade-serve/", editable = true }
|
||||
# arcade-mcp-server = { path = "../../../libs/arcade-mcp-server/", editable = true }
|
||||
0
examples/mcp_servers/resources/src/resources/__init__.py
Normal file
0
examples/mcp_servers/resources/src/resources/__init__.py
Normal file
165
examples/mcp_servers/resources/src/resources/app.html
Normal file
165
examples/mcp_servers/resources/src/resources/app.html
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Knowledge Base — MCP App</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: #f8fafc; color: #1e293b;
|
||||
padding: 1.5rem; max-width: 520px; margin: 0 auto;
|
||||
}
|
||||
h1 { font-size: 1.25rem; margin-bottom: 1rem; }
|
||||
.card {
|
||||
background: #fff; border: 1px solid #e2e8f0; border-radius: 0.5rem;
|
||||
padding: 1rem; margin-bottom: 1rem;
|
||||
}
|
||||
.card h2 { font-size: 1rem; margin-bottom: 0.75rem; color: #475569; }
|
||||
button {
|
||||
background: #3b82f6; color: #fff; border: none; border-radius: 0.375rem;
|
||||
padding: 0.5rem 1rem; cursor: pointer; font-size: 0.875rem;
|
||||
}
|
||||
button:hover { background: #2563eb; }
|
||||
button:disabled { background: #94a3b8; cursor: not-allowed; }
|
||||
.result {
|
||||
margin-top: 0.75rem; padding: 0.5rem; background: #f1f5f9;
|
||||
border-radius: 0.25rem; font-family: monospace; font-size: 0.8125rem;
|
||||
min-height: 1.5rem; white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
label { display: block; font-size: 0.8125rem; color: #64748b; margin-bottom: 0.25rem; }
|
||||
input, select {
|
||||
width: 100%; padding: 0.375rem 0.5rem; border: 1px solid #cbd5e1;
|
||||
border-radius: 0.25rem; font-size: 0.875rem; margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Knowledge Base — MCP App</h1>
|
||||
|
||||
<!-- Get Article card -->
|
||||
<div class="card">
|
||||
<h2>Get Article</h2>
|
||||
<label for="article-slug">Article slug</label>
|
||||
<select id="article-slug">
|
||||
<option value="getting-started">getting-started</option>
|
||||
<option value="api-guidelines">api-guidelines</option>
|
||||
<option value="security-policy">security-policy</option>
|
||||
</select>
|
||||
<button id="article-btn">Fetch Article</button>
|
||||
<div class="result" id="article-result">Select an article and click Fetch.</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Articles card -->
|
||||
<div class="card">
|
||||
<h2>Search Articles</h2>
|
||||
<label for="search-query">Search query</label>
|
||||
<input id="search-query" type="text" placeholder="e.g. API, security, onboarding" />
|
||||
<button id="search-btn">Search</button>
|
||||
<div class="result" id="search-result">Enter a query and click Search.</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
// Minimal JSON-RPC 2.0 over postMessage client for MCP Apps.
|
||||
// In production, use: import { App } from "@modelcontextprotocol/ext-apps";
|
||||
|
||||
const JSONRPC_VERSION = "2.0";
|
||||
const UI_PROTOCOL_VERSION = "2026-01-26";
|
||||
let nextId = 1;
|
||||
const pending = {};
|
||||
|
||||
window.addEventListener("message", (e) => {
|
||||
const msg = e.data;
|
||||
if (!msg || typeof msg !== "object" || msg.jsonrpc !== JSONRPC_VERSION) return;
|
||||
|
||||
if (msg.id !== undefined && pending[msg.id]) {
|
||||
pending[msg.id](msg);
|
||||
delete pending[msg.id];
|
||||
}
|
||||
});
|
||||
|
||||
function rpcRequest(method, params) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = nextId++;
|
||||
pending[id] = (msg) => {
|
||||
if (msg.error) reject(new Error(msg.error.message || "RPC error"));
|
||||
else resolve(msg.result);
|
||||
};
|
||||
window.parent.postMessage(
|
||||
{ jsonrpc: JSONRPC_VERSION, id, method, params },
|
||||
"*"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function rpcNotify(method, params) {
|
||||
window.parent.postMessage(
|
||||
{ jsonrpc: JSONRPC_VERSION, method, params },
|
||||
"*"
|
||||
);
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
try {
|
||||
await rpcRequest("ui/initialize", {
|
||||
appInfo: { name: "Knowledge Base MCP App", version: "1.0.0" },
|
||||
protocolVersion: UI_PROTOCOL_VERSION,
|
||||
capabilities: {},
|
||||
});
|
||||
rpcNotify("ui/notifications/initialized", {});
|
||||
} catch (err) {
|
||||
console.error("MCP Apps init failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
await initialize();
|
||||
|
||||
async function callServerTool(name, args) {
|
||||
const result = await rpcRequest("tools/call", { name, arguments: args });
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractText(result) {
|
||||
const text = result?.content?.find((c) => c.type === "text")?.text;
|
||||
return text || "[no result]";
|
||||
}
|
||||
|
||||
// Get Article button
|
||||
const articleBtn = document.getElementById("article-btn");
|
||||
const articleResult = document.getElementById("article-result");
|
||||
|
||||
articleBtn.addEventListener("click", async () => {
|
||||
articleBtn.disabled = true;
|
||||
articleResult.textContent = "Loading...";
|
||||
try {
|
||||
const slug = document.getElementById("article-slug").value;
|
||||
const result = await callServerTool("Resources_GetArticle", { slug });
|
||||
articleResult.textContent = extractText(result);
|
||||
} catch (err) {
|
||||
articleResult.textContent = "Error: " + err.message;
|
||||
} finally {
|
||||
articleBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Search Articles button
|
||||
const searchBtn = document.getElementById("search-btn");
|
||||
const searchResult = document.getElementById("search-result");
|
||||
|
||||
searchBtn.addEventListener("click", async () => {
|
||||
searchBtn.disabled = true;
|
||||
searchResult.textContent = "Searching...";
|
||||
try {
|
||||
const query = document.getElementById("search-query").value;
|
||||
const result = await callServerTool("Resources_SearchArticles", { query });
|
||||
searchResult.textContent = extractText(result);
|
||||
} catch (err) {
|
||||
searchResult.textContent = "Error: " + err.message;
|
||||
} finally {
|
||||
searchBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
37
examples/mcp_servers/resources/src/resources/data.py
Normal file
37
examples/mcp_servers/resources/src/resources/data.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"""In-memory data for the "Company Knowledge Base" demo."""
|
||||
|
||||
from resources.schemas import Author
|
||||
|
||||
KB_ARTICLES: dict[str, dict[str, str]] = {
|
||||
"getting-started": {
|
||||
"title": "Getting Started",
|
||||
"category": "onboarding",
|
||||
"author": "docs-team",
|
||||
"body": "Welcome to the company knowledge base! Start here to learn the basics.",
|
||||
},
|
||||
"api-guidelines": {
|
||||
"title": "API Design Guidelines",
|
||||
"category": "engineering",
|
||||
"author": "platform-team",
|
||||
"body": "All APIs must follow REST conventions and use JSON payloads.",
|
||||
},
|
||||
"security-policy": {
|
||||
"title": "Security Policy",
|
||||
"category": "compliance",
|
||||
"author": "security-team",
|
||||
"body": "All employees must use MFA and rotate credentials every 90 days.",
|
||||
},
|
||||
}
|
||||
|
||||
KB_CATEGORIES: dict[str, list[str]] = {
|
||||
"onboarding": ["getting-started"],
|
||||
"engineering": ["api-guidelines"],
|
||||
"compliance": ["security-policy"],
|
||||
}
|
||||
|
||||
# Maps short author keys to structured Author records
|
||||
TEAMS: dict[str, Author] = {
|
||||
"docs-team": Author(name="Documentation Team", team="Developer Experience"),
|
||||
"platform-team": Author(name="Platform Team", team="Engineering"),
|
||||
"security-team": Author(name="Security Team", team="Compliance & Risk"),
|
||||
}
|
||||
BIN
examples/mcp_servers/resources/src/resources/logo.png
Normal file
BIN
examples/mcp_servers/resources/src/resources/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 199 B |
37
examples/mcp_servers/resources/src/resources/schemas.py
Normal file
37
examples/mcp_servers/resources/src/resources/schemas.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"""TypedDict definitions for structured tool outputs."""
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
|
||||
class Author(TypedDict):
|
||||
"""The team or person who authored an article."""
|
||||
|
||||
name: str
|
||||
team: str
|
||||
|
||||
|
||||
class ArticleDetail(TypedDict):
|
||||
"""Full article detail with nested author information."""
|
||||
|
||||
slug: str
|
||||
title: str
|
||||
category: str
|
||||
body: str
|
||||
author: Author
|
||||
|
||||
|
||||
class SearchMatch(TypedDict):
|
||||
"""A single search hit with relevance context."""
|
||||
|
||||
slug: str
|
||||
title: str
|
||||
category: str
|
||||
matched_field: str
|
||||
|
||||
|
||||
class SearchResult(TypedDict):
|
||||
"""Search results with metadata"""
|
||||
|
||||
query: str
|
||||
total_matches: int
|
||||
matches: list[SearchMatch]
|
||||
322
examples/mcp_servers/resources/src/resources/server.py
Normal file
322
examples/mcp_servers/resources/src/resources/server.py
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Resources Example MCP Server
|
||||
|
||||
Comprehensive showcase of MCP resource features in arcade-mcp-server:
|
||||
|
||||
1. @app.resource(uri) decorator — register a resource with a handler
|
||||
2. app.add_resource(uri, handler=...) — imperative registration
|
||||
3. app.add_text_resource(uri, text=...) — static text convenience
|
||||
4. app.add_file_resource(uri, path=...) — file-backed resource
|
||||
5. URI templates with {param} — parameterized resources
|
||||
6. Wildcard templates {param*} — greedy path matching
|
||||
7. Annotations(priority=...) — resource annotations
|
||||
8. meta={...} — custom metadata
|
||||
9. Async handlers + return types — bytes, dict, str
|
||||
10. @app.tool(meta={...}) — MCP Apps (tool-to-UI linking)
|
||||
11. Nested TypedDict tool outputs — recursive structured output schemas
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from arcade_mcp_server import Annotations, MCPApp
|
||||
from resources.data import KB_ARTICLES, KB_CATEGORIES, TEAMS
|
||||
from resources.schemas import ArticleDetail, Author, SearchMatch, SearchResult
|
||||
|
||||
app = MCPApp(name="resources", version="1.0.0", log_level="DEBUG")
|
||||
|
||||
_HERE = Path(__file__).parent
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 1. @app.resource decorator
|
||||
# ===========================================================================
|
||||
# The simplest way to register a resource with a handler function.
|
||||
# The handler receives the URI as its first argument and returns content.
|
||||
|
||||
|
||||
@app.resource(
|
||||
"kb://articles/index",
|
||||
name="Article Index",
|
||||
description="List of all knowledge base articles",
|
||||
mime_type="application/json",
|
||||
)
|
||||
def article_index(uri: str) -> str:
|
||||
"""Return a JSON index of all articles."""
|
||||
index = [
|
||||
{"slug": slug, "title": a["title"], "category": a["category"]}
|
||||
for slug, a in KB_ARTICLES.items()
|
||||
]
|
||||
return json.dumps(index, indent=2)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 2. app.add_resource — imperative registration
|
||||
# ===========================================================================
|
||||
# Use this when you want to register a resource without a decorator,
|
||||
# for example when the handler is defined elsewhere or generated dynamically.
|
||||
|
||||
|
||||
def _serve_category_list(uri: str) -> str:
|
||||
"""Return the list of categories as JSON."""
|
||||
return json.dumps(list(KB_CATEGORIES.keys()))
|
||||
|
||||
|
||||
app.add_resource(
|
||||
"kb://categories",
|
||||
name="Categories",
|
||||
description="List of all article categories",
|
||||
mime_type="application/json",
|
||||
handler=_serve_category_list,
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 3. app.add_text_resource — static text convenience
|
||||
# ===========================================================================
|
||||
# One-liner for resources whose content is known at registration time.
|
||||
# No handler function needed — the text is served directly.
|
||||
|
||||
app.add_text_resource(
|
||||
"kb://readme",
|
||||
text="Welcome to the Company Knowledge Base.\n\nBrowse articles by category or search by slug.",
|
||||
name="README",
|
||||
description="Knowledge base welcome text",
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 4. app.add_file_resource — file-backed resource
|
||||
# ===========================================================================
|
||||
# Serves a file from disk. Text files are returned as TextResourceContents;
|
||||
# binary files (detected via UnicodeDecodeError) as BlobResourceContents.
|
||||
|
||||
app.add_file_resource(
|
||||
"kb://config/pyproject",
|
||||
path=_HERE.resolve().parents[1] / "pyproject.toml",
|
||||
name="Project Config",
|
||||
description="The pyproject.toml for this example server",
|
||||
mime_type="text/plain",
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 5. URI templates with {param}
|
||||
# ===========================================================================
|
||||
# When a URI contains {braces}, it is automatically registered as a
|
||||
# ResourceTemplate. The handler receives extracted parameters as kwargs.
|
||||
|
||||
|
||||
@app.resource(
|
||||
"kb://articles/{slug}",
|
||||
name="Article by Slug",
|
||||
description="Retrieve a specific article by its slug",
|
||||
mime_type="application/json",
|
||||
)
|
||||
def article_by_slug(uri: str, slug: str) -> str:
|
||||
"""Return a single article as JSON. 'slug' is extracted from the URI."""
|
||||
article = KB_ARTICLES.get(slug)
|
||||
if article is None:
|
||||
return json.dumps({"error": f"Article '{slug}' not found"})
|
||||
return json.dumps(article, indent=2)
|
||||
|
||||
|
||||
# Multi-parameter template — both {category} and {slug} are extracted.
|
||||
@app.resource(
|
||||
"kb://categories/{category}/articles/{slug}",
|
||||
name="Article by Category and Slug",
|
||||
description="Retrieve an article scoped to a category",
|
||||
mime_type="application/json",
|
||||
)
|
||||
def article_in_category(uri: str, category: str, slug: str) -> str:
|
||||
"""Return an article only if it belongs to the given category."""
|
||||
if category not in KB_CATEGORIES:
|
||||
return json.dumps({"error": f"Category '{category}' not found"})
|
||||
if slug not in KB_CATEGORIES[category]:
|
||||
return json.dumps({"error": f"Article '{slug}' not in category '{category}'"})
|
||||
return json.dumps(KB_ARTICLES[slug], indent=2)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 6. Wildcard templates {param*}
|
||||
# ===========================================================================
|
||||
# The {param*} syntax matches greedily across '/' separators, useful for
|
||||
# nested paths like "guides/setup/linux".
|
||||
|
||||
|
||||
@app.resource(
|
||||
"kb://docs/{path*}",
|
||||
name="Docs Tree",
|
||||
description="Retrieve documentation by nested path (e.g. 'guides/setup/linux')",
|
||||
mime_type="text/plain",
|
||||
)
|
||||
def docs_by_path(uri: str, path: str) -> str:
|
||||
"""Wildcard match — 'path' captures everything including slashes."""
|
||||
return f"You requested documentation at path: {path}\n(In a real server, this would read from a docs tree.)"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 7. Annotations
|
||||
# ===========================================================================
|
||||
# Resource annotations let clients sort, filter, or prioritize resources.
|
||||
# The Annotations model supports 'audience' and 'priority' fields.
|
||||
|
||||
|
||||
@app.resource(
|
||||
"kb://announcements/pinned",
|
||||
name="Pinned Announcement",
|
||||
description="The current pinned company announcement",
|
||||
mime_type="text/plain",
|
||||
annotations=Annotations(
|
||||
audience=["user"],
|
||||
priority=1.0,
|
||||
),
|
||||
)
|
||||
def pinned_announcement(uri: str) -> str:
|
||||
"""A high-priority resource. Clients can use annotations to sort/filter."""
|
||||
return "All-hands meeting this Friday at 3 PM."
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 8. Custom metadata (meta)
|
||||
# ===========================================================================
|
||||
# Arbitrary metadata attached to a resource, visible to clients in
|
||||
# resources/list responses under the _meta field.
|
||||
|
||||
|
||||
@app.resource(
|
||||
"kb://articles/api-guidelines/metadata",
|
||||
name="API Guidelines Metadata",
|
||||
description="Article with custom metadata tags",
|
||||
mime_type="application/json",
|
||||
meta={"tags": ["api", "engineering", "standards"], "version": 2, "reviewed": True},
|
||||
)
|
||||
def article_metadata(uri: str) -> str:
|
||||
"""Resource with custom _meta fields. Clients see these in resources/list."""
|
||||
return json.dumps(KB_ARTICLES["api-guidelines"], indent=2)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 9. Async handlers + return types
|
||||
# ===========================================================================
|
||||
# Handlers can be async. Return types are automatically coerced:
|
||||
# - str → TextResourceContents
|
||||
# - bytes → BlobResourceContents (base64-encoded)
|
||||
# - dict with "text" key → TextResourceContents
|
||||
# - dict with "blob" key → BlobResourceContents
|
||||
|
||||
# Static PNG file — demonstrates binary file-backed resources.
|
||||
app.add_file_resource(
|
||||
"kb://branding/logo",
|
||||
path=_HERE / "logo.png",
|
||||
name="Company Logo",
|
||||
description="A small 32x32 pixel-art KB logo (binary file resource)",
|
||||
mime_type="image/png",
|
||||
)
|
||||
|
||||
|
||||
@app.resource(
|
||||
"kb://status",
|
||||
name="Server Status",
|
||||
description="Server health status (async handler returning dict)",
|
||||
mime_type="application/json",
|
||||
)
|
||||
async def server_status(uri: str) -> dict:
|
||||
"""Async handler returning dict with 'text' key → TextResourceContents."""
|
||||
await asyncio.sleep(0) # simulate async I/O
|
||||
status = {
|
||||
"healthy": True,
|
||||
"article_count": len(KB_ARTICLES),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
return {"text": json.dumps(status, indent=2)}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 10. MCP Apps — tool-to-UI resource linking with nested TypedDict outputs
|
||||
# ===========================================================================
|
||||
# Tools can declare a meta dict to attach _meta extensions. For MCP Apps,
|
||||
# set meta={"ui": {"resourceUri": "..."}} to link to an interactive HTML
|
||||
# resource. MCP Apps hosts render the HTML in a sandboxed iframe and the UI
|
||||
# can call tools back on the server via postMessage JSON-RPC.
|
||||
#
|
||||
# These tools also showcase nested TypedDict return types, which
|
||||
# arcade-mcp-server recursively expands into full JSON Schema output schemas.
|
||||
#
|
||||
# See: https://modelcontextprotocol.io/extensions/apps/build
|
||||
|
||||
APP_RESOURCE_URI = "ui://resources/mcp-app.html"
|
||||
|
||||
|
||||
@app.tool(meta={"ui": {"resourceUri": APP_RESOURCE_URI}})
|
||||
def get_article(
|
||||
slug: Annotated[str, "The article slug (e.g. 'getting-started')"],
|
||||
) -> Annotated[ArticleDetail, "Full article with nested author information"]:
|
||||
"""Retrieve a knowledge-base article by slug, including author details.
|
||||
|
||||
Returns an ArticleDetail (nested TypedDict) with a nested Author object,
|
||||
demonstrating recursive structured output schemas.
|
||||
"""
|
||||
raw = KB_ARTICLES.get(slug)
|
||||
if raw is None:
|
||||
raise ValueError(f"Article '{slug}' not found")
|
||||
return ArticleDetail(
|
||||
slug=slug,
|
||||
title=raw["title"],
|
||||
category=raw["category"],
|
||||
body=raw["body"],
|
||||
author=TEAMS.get(raw["author"], Author(name=raw["author"], team="Unknown")),
|
||||
)
|
||||
|
||||
|
||||
@app.tool(meta={"ui": {"resourceUri": APP_RESOURCE_URI}})
|
||||
def search_articles(
|
||||
query: Annotated[str, "Case-insensitive search query"],
|
||||
) -> Annotated[SearchResult, "Search results with nested match details"]:
|
||||
"""Search knowledge-base articles by title, category, or body text.
|
||||
|
||||
Returns a SearchResult (nested TypedDict) containing a list of SearchMatch
|
||||
objects, demonstrating recursive structured output schemas with lists.
|
||||
"""
|
||||
q = query.lower()
|
||||
matches: list[SearchMatch] = []
|
||||
for slug, article in KB_ARTICLES.items():
|
||||
for field in ("title", "category", "body"):
|
||||
if q in article[field].lower():
|
||||
matches.append(
|
||||
SearchMatch(
|
||||
slug=slug,
|
||||
title=article["title"],
|
||||
category=article["category"],
|
||||
matched_field=field,
|
||||
)
|
||||
)
|
||||
break # one match per article
|
||||
return SearchResult(
|
||||
query=query,
|
||||
total_matches=len(matches),
|
||||
matches=matches,
|
||||
)
|
||||
|
||||
|
||||
@app.resource(APP_RESOURCE_URI, name="MCP App UI", mime_type="text/html;profile=mcp-app")
|
||||
def serve_app_ui(uri: str) -> str:
|
||||
"""Serve the MCP App HTML from the co-located app.html file."""
|
||||
return (_HERE / "app.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Get transport from command line argument, default to "stdio"
|
||||
# - "stdio" (default): Standard I/O for Claude Desktop, CLI tools, etc.
|
||||
# - "http": HTTPS streaming for Cursor, VS Code, etc.
|
||||
transport = sys.argv[1] if len(sys.argv) > 1 else "stdio"
|
||||
app.run(transport=transport, host="127.0.0.1", port=8000)
|
||||
44
examples/mcp_servers/tools_with_output_schema/pyproject.toml
Normal file
44
examples/mcp_servers/tools_with_output_schema/pyproject.toml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
[project]
|
||||
name = "tools_with_output_schema"
|
||||
version = "0.1.0"
|
||||
description = "MCP Server created with Arcade.dev"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-mcp-server>=1.19.0,<2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"arcade-mcp[all]>=1.12.1,<2.0.0",
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"mypy>=1.0.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
|
||||
# Tell Arcade.dev that this package has Arcade tools
|
||||
[project.entry-points.arcade_toolkits]
|
||||
toolkit_name = "tools_with_output_schema"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/tools_with_output_schema"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = false
|
||||
|
||||
# Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode
|
||||
# Otherwise, you will install the following packages from PyPI
|
||||
# [tool.uv.sources]
|
||||
# arcade-mcp = { path = "../../../", editable = true }
|
||||
# arcade-serve = { path = "../../../libs/arcade-serve/", editable = true }
|
||||
# arcade-mcp-server = { path = "../../../libs/arcade-mcp-server/", editable = true }
|
||||
|
|
@ -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)
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -6,10 +6,14 @@ Async-safe resources with registry-based storage and deterministic listing.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import Any, Callable
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Literal
|
||||
|
||||
from arcade_mcp_server.exceptions import NotFoundError
|
||||
from arcade_mcp_server.exceptions import NotFoundError, ResourceError
|
||||
from arcade_mcp_server.managers.base import ComponentManager
|
||||
from arcade_mcp_server.types import (
|
||||
BlobResourceContents,
|
||||
|
|
@ -21,6 +25,73 @@ from arcade_mcp_server.types import (
|
|||
|
||||
logger = logging.getLogger("arcade.mcp.managers.resource")
|
||||
|
||||
DuplicatePolicy = Literal["warn", "error", "replace", "ignore"]
|
||||
MultipleMatchPolicy = Literal["warn", "error", "ignore"]
|
||||
|
||||
|
||||
def _is_template_uri(uri: str) -> bool:
|
||||
"""Return True if *uri* contains RFC 6570-style template variables."""
|
||||
return bool(re.search(r"\{[^}]+\}", uri))
|
||||
|
||||
|
||||
def _template_to_regex(template: str) -> re.Pattern[str]:
|
||||
"""Convert a URI template to a compiled regex with named groups.
|
||||
|
||||
``{param}`` -> ``(?P<param>[^/]+)``
|
||||
``{param*}`` -> ``(?P<param>.+)`` (wildcard / greedy)
|
||||
"""
|
||||
pattern = re.escape(template)
|
||||
# Wildcard parameters first (e.g. {path*})
|
||||
pattern = re.sub(
|
||||
r"\\{(\w+)\\\*\\}",
|
||||
lambda m: f"(?P<{m.group(1)}>.+)",
|
||||
pattern,
|
||||
)
|
||||
# Simple parameters (e.g. {city})
|
||||
pattern = re.sub(
|
||||
r"\\{(\w+)\\}",
|
||||
lambda m: f"(?P<{m.group(1)}>[^/]+)",
|
||||
pattern,
|
||||
)
|
||||
return re.compile(f"^{pattern}$")
|
||||
|
||||
|
||||
def _template_to_sample_uri(template: str) -> str:
|
||||
"""Replace template variables with dummy values to produce a concrete sample URI.
|
||||
|
||||
``{param}`` -> ``__param__``
|
||||
``{param*}`` -> ``__param__/nested`` (includes slash to exercise wildcard)
|
||||
"""
|
||||
# Wildcards first
|
||||
result = re.sub(r"\{(\w+)\*\}", r"__\1__/nested", template)
|
||||
# Simple params
|
||||
result = re.sub(r"\{(\w+)\}", r"__\1__", result)
|
||||
return result
|
||||
|
||||
|
||||
def make_text_handler(text: str) -> Callable[[str], str]:
|
||||
"""Create a handler that returns static text."""
|
||||
|
||||
def handler(_uri: str) -> str:
|
||||
return text
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
def make_file_handler(path: str | Path) -> Callable[[str], str | bytes]:
|
||||
"""Create a handler that reads a file, returning text or bytes."""
|
||||
file_path = Path(path)
|
||||
|
||||
def _read_file(_uri: str) -> str | bytes:
|
||||
if not file_path.exists():
|
||||
raise NotFoundError(f"File not found: {file_path}")
|
||||
try:
|
||||
return file_path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return file_path.read_bytes()
|
||||
|
||||
return _read_file
|
||||
|
||||
|
||||
class ResourceManager(ComponentManager[str, Resource]):
|
||||
"""
|
||||
|
|
@ -29,10 +100,16 @@ class ResourceManager(ComponentManager[str, Resource]):
|
|||
|
||||
def __init__(
|
||||
self,
|
||||
duplicate_policy: DuplicatePolicy = "warn",
|
||||
multiple_match_policy: MultipleMatchPolicy = "warn",
|
||||
) -> None:
|
||||
super().__init__("resource")
|
||||
self._templates: dict[str, ResourceTemplate] = {}
|
||||
self._resource_handlers: dict[str, Callable[[str], Any]] = {}
|
||||
self._template_handlers: dict[str, Callable[..., Any]] = {}
|
||||
self._template_patterns: dict[str, re.Pattern[str]] = {}
|
||||
self.duplicate_policy: DuplicatePolicy = duplicate_policy
|
||||
self.multiple_match_policy: MultipleMatchPolicy = multiple_match_policy
|
||||
|
||||
async def list_resources(self) -> list[Resource]:
|
||||
return await self.registry.list()
|
||||
|
|
@ -43,21 +120,47 @@ class ResourceManager(ComponentManager[str, Resource]):
|
|||
async def read_resource(self, uri: str) -> list[ResourceContents]:
|
||||
handler = self._resource_handlers.get(uri)
|
||||
if handler:
|
||||
# Look up the registered resource's mimeType so we can propagate it
|
||||
mime_type: str | None = None
|
||||
try:
|
||||
registered: Resource = await self.registry.get(uri)
|
||||
mime_type = registered.mimeType
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
result = handler(uri)
|
||||
if hasattr(result, "__await__"):
|
||||
result = await result
|
||||
if isinstance(result, str):
|
||||
return [TextResourceContents(uri=uri, text=result)]
|
||||
elif isinstance(result, dict):
|
||||
if "text" in result:
|
||||
return [TextResourceContents(uri=uri, text=result["text"])]
|
||||
if "blob" in result:
|
||||
return [BlobResourceContents(uri=uri, blob=result["blob"])]
|
||||
return [ResourceContents(uri=uri)]
|
||||
elif isinstance(result, list):
|
||||
return result
|
||||
else:
|
||||
return [TextResourceContents(uri=uri, text=str(result))]
|
||||
return self._coerce_result(uri, mime_type, result)
|
||||
|
||||
# Try template matching before giving up — collect all matches
|
||||
matches: list[tuple[str, re.Match[str]]] = []
|
||||
for tmpl_str, pattern in self._template_patterns.items():
|
||||
match = pattern.match(uri)
|
||||
if match:
|
||||
matches.append((tmpl_str, match))
|
||||
|
||||
if matches:
|
||||
if len(matches) > 1 and self.multiple_match_policy != "ignore":
|
||||
matched_templates = [m[0] for m in matches]
|
||||
message = (
|
||||
f"URI '{uri}' matched {len(matches)} resource templates: "
|
||||
f"{matched_templates}. Using first registered match "
|
||||
f"'{matched_templates[0]}'."
|
||||
)
|
||||
if self.multiple_match_policy == "error":
|
||||
raise ResourceError(message)
|
||||
else: # "warn"
|
||||
logger.warning(message)
|
||||
|
||||
tmpl_str, match = matches[0]
|
||||
params = match.groupdict()
|
||||
tmpl_handler = self._template_handlers[tmpl_str]
|
||||
mime_type = self._templates[tmpl_str].mimeType
|
||||
result = tmpl_handler(uri, **params)
|
||||
if hasattr(result, "__await__"):
|
||||
result = await result
|
||||
return self._coerce_result(uri, mime_type, result)
|
||||
|
||||
try:
|
||||
_ = await self.registry.get(uri)
|
||||
|
|
@ -66,9 +169,43 @@ class ResourceManager(ComponentManager[str, Resource]):
|
|||
|
||||
return [TextResourceContents(uri=uri, text="")] # static placeholder
|
||||
|
||||
@staticmethod
|
||||
def _coerce_result(uri: str, mime_type: str | None, result: Any) -> list[ResourceContents]:
|
||||
"""Convert a handler return value into a list of ResourceContents."""
|
||||
if isinstance(result, bytes):
|
||||
blob = base64.b64encode(result).decode("ascii")
|
||||
return [BlobResourceContents(uri=uri, mimeType=mime_type, blob=blob)]
|
||||
elif isinstance(result, str):
|
||||
return [TextResourceContents(uri=uri, mimeType=mime_type, text=result)]
|
||||
elif isinstance(result, dict):
|
||||
if "text" in result:
|
||||
return [TextResourceContents(uri=uri, mimeType=mime_type, text=result["text"])]
|
||||
if "blob" in result:
|
||||
return [BlobResourceContents(uri=uri, mimeType=mime_type, blob=result["blob"])]
|
||||
return [ResourceContents(uri=uri, mimeType=mime_type)]
|
||||
elif isinstance(result, list):
|
||||
return result
|
||||
else:
|
||||
return [TextResourceContents(uri=uri, mimeType=mime_type, text=str(result))]
|
||||
|
||||
async def add_resource(
|
||||
self, resource: Resource, handler: Callable[[str], Any] | None = None
|
||||
) -> None:
|
||||
# Duplicate-detection
|
||||
existing: Resource | None = None
|
||||
with contextlib.suppress(KeyError):
|
||||
existing = await self.registry.get(resource.uri)
|
||||
|
||||
if existing is not None:
|
||||
if self.duplicate_policy == "error":
|
||||
raise ResourceError(f"Resource '{resource.uri}' already registered")
|
||||
elif self.duplicate_policy == "ignore":
|
||||
return
|
||||
elif self.duplicate_policy == "warn":
|
||||
logger.warning(f"Replacing duplicate resource '{resource.uri}'")
|
||||
# "replace" and "warn" both fall through to upsert
|
||||
self._resource_handlers.pop(resource.uri, None)
|
||||
|
||||
await self.registry.upsert(resource.uri, resource)
|
||||
if handler:
|
||||
self._resource_handlers[resource.uri] = handler
|
||||
|
|
@ -88,6 +225,7 @@ class ResourceManager(ComponentManager[str, Resource]):
|
|||
await self.registry.remove(uri)
|
||||
except KeyError:
|
||||
raise NotFoundError(f"Resource '{uri}' not found")
|
||||
self._resource_handlers.pop(uri, None)
|
||||
await self.registry.upsert(resource.uri, resource)
|
||||
if handler:
|
||||
self._resource_handlers[resource.uri] = handler
|
||||
|
|
@ -96,7 +234,66 @@ class ResourceManager(ComponentManager[str, Resource]):
|
|||
async def add_template(self, template: ResourceTemplate) -> None:
|
||||
self._templates[template.uriTemplate] = template
|
||||
|
||||
async def add_template_with_handler(
|
||||
self, template: ResourceTemplate, handler: Callable[..., Any]
|
||||
) -> None:
|
||||
"""Store a template together with its handler and compiled regex.
|
||||
|
||||
Warns at registration time if the new template overlaps with any
|
||||
existing template, since only the first registered match is used at
|
||||
read time.
|
||||
"""
|
||||
new_pattern = _template_to_regex(template.uriTemplate)
|
||||
new_sample = _template_to_sample_uri(template.uriTemplate)
|
||||
|
||||
for existing_tmpl_str, existing_pattern in self._template_patterns.items():
|
||||
existing_sample = _template_to_sample_uri(existing_tmpl_str)
|
||||
# Check both directions: does the new pattern match an existing
|
||||
# template's sample URI, or does an existing pattern match the
|
||||
# new template's sample URI?
|
||||
if existing_pattern.match(new_sample) or new_pattern.match(existing_sample):
|
||||
logger.warning(
|
||||
"Resource template '%s' overlaps with already-registered "
|
||||
"template '%s'. The first registered template will take "
|
||||
"priority when both match a URI. Consider registering more "
|
||||
"specific templates before broader ones.",
|
||||
template.uriTemplate,
|
||||
existing_tmpl_str,
|
||||
)
|
||||
|
||||
self._templates[template.uriTemplate] = template
|
||||
self._template_handlers[template.uriTemplate] = handler
|
||||
self._template_patterns[template.uriTemplate] = new_pattern
|
||||
|
||||
async def remove_template(self, uri_template: str) -> ResourceTemplate:
|
||||
if uri_template not in self._templates:
|
||||
raise NotFoundError(f"Resource template '{uri_template}' not found")
|
||||
self._template_handlers.pop(uri_template, None)
|
||||
self._template_patterns.pop(uri_template, None)
|
||||
return self._templates.pop(uri_template)
|
||||
|
||||
async def add_text_resource(
|
||||
self,
|
||||
uri: str,
|
||||
text: str,
|
||||
*,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
mime_type: str = "text/plain",
|
||||
) -> None:
|
||||
"""Convenience: register a static text resource."""
|
||||
resource = Resource(uri=uri, name=name or uri, description=description, mimeType=mime_type)
|
||||
await self.add_resource(resource, handler=make_text_handler(text))
|
||||
|
||||
async def add_file_resource(
|
||||
self,
|
||||
uri: str,
|
||||
path: str | Path,
|
||||
*,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
mime_type: str | None = None,
|
||||
) -> None:
|
||||
"""Convenience: register a file-backed resource."""
|
||||
resource = Resource(uri=uri, name=name or uri, description=description, mimeType=mime_type)
|
||||
await self.add_resource(resource, handler=make_file_handler(path))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" }]
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@ from typing import Annotated
|
|||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from arcade_core.catalog import MaterializedTool
|
||||
|
||||
from arcade_core.catalog import MaterializedTool, ToolDefinitionError
|
||||
from arcade_mcp_server import tool
|
||||
from arcade_mcp_server.mcp_app import MCPApp
|
||||
from arcade_mcp_server.server import MCPServer
|
||||
|
|
@ -443,6 +442,8 @@ class TestMCPApp:
|
|||
mcp_settings=mcp_app._mcp_settings,
|
||||
debug=False,
|
||||
resource_server_validator=mcp_app.resource_server_validator,
|
||||
initial_resources=mcp_app._initial_resources,
|
||||
tool_meta_extensions=mcp_app._tool_meta_extensions,
|
||||
)
|
||||
mock_serve.assert_called_once_with(
|
||||
app=mock_fastapi_app,
|
||||
|
|
@ -467,6 +468,8 @@ class TestMCPApp:
|
|||
mcp_settings=mcp_app._mcp_settings,
|
||||
debug=True,
|
||||
resource_server_validator=mcp_app.resource_server_validator,
|
||||
initial_resources=mcp_app._initial_resources,
|
||||
tool_meta_extensions=mcp_app._tool_meta_extensions,
|
||||
)
|
||||
mock_serve.assert_called_once_with(
|
||||
app=mock_fastapi_app,
|
||||
|
|
@ -742,3 +745,341 @@ class TestMCPApp:
|
|||
app = MCPApp()
|
||||
with pytest.raises(expected_error):
|
||||
app._validate_name(name)
|
||||
|
||||
|
||||
class TestMCPAppResourceRegistration:
|
||||
"""Tests for build-time resource registration on MCPApp."""
|
||||
|
||||
def test_add_resource_stores_resource(self):
|
||||
"""Verify add_resource stores a (Resource, None) tuple in _initial_resources."""
|
||||
app = MCPApp(name="TestApp", version="1.0.0")
|
||||
app.add_resource("ui://app/index.html", name="App UI", mime_type="text/html")
|
||||
|
||||
assert len(app._initial_resources) == 1
|
||||
resource, handler = app._initial_resources[0]
|
||||
assert resource.uri == "ui://app/index.html"
|
||||
assert resource.name == "App UI"
|
||||
assert resource.mimeType == "text/html"
|
||||
assert handler is None
|
||||
|
||||
def test_add_resource_with_handler(self):
|
||||
"""Verify handler is stored alongside resource."""
|
||||
app = MCPApp(name="TestApp", version="1.0.0")
|
||||
|
||||
def my_handler(uri: str) -> str:
|
||||
return "content"
|
||||
|
||||
app.add_resource("file:///data.json", name="Data", handler=my_handler)
|
||||
|
||||
assert len(app._initial_resources) == 1
|
||||
resource, handler = app._initial_resources[0]
|
||||
assert resource.uri == "file:///data.json"
|
||||
assert handler is my_handler
|
||||
|
||||
def test_add_resource_name_defaults_to_uri(self):
|
||||
"""Name defaults to URI when omitted."""
|
||||
app = MCPApp(name="TestApp", version="1.0.0")
|
||||
app.add_resource("ui://app/page.html")
|
||||
|
||||
resource, _ = app._initial_resources[0]
|
||||
assert resource.name == "ui://app/page.html"
|
||||
|
||||
def test_add_resource_with_title(self):
|
||||
"""Verify title is set on the Resource object."""
|
||||
app = MCPApp(name="TestApp", version="1.0.0")
|
||||
app.add_resource(
|
||||
"ui://app/index.html",
|
||||
name="App UI",
|
||||
title="My Application",
|
||||
mime_type="text/html",
|
||||
)
|
||||
|
||||
resource, _ = app._initial_resources[0]
|
||||
assert resource.title == "My Application"
|
||||
|
||||
def test_resource_decorator_with_title(self):
|
||||
"""Verify title is passed through from the decorator."""
|
||||
app = MCPApp(name="TestApp", version="1.0.0")
|
||||
|
||||
@app.resource("ui://app/index.html", title="My App UI")
|
||||
def serve_ui(uri: str) -> str:
|
||||
return "<html></html>"
|
||||
|
||||
resource, _ = app._initial_resources[0]
|
||||
assert resource.title == "My App UI"
|
||||
|
||||
def test_resource_decorator_registers_resource(self):
|
||||
"""@app.resource(uri) stores (Resource, fn)."""
|
||||
app = MCPApp(name="TestApp", version="1.0.0")
|
||||
|
||||
@app.resource("ui://app/index.html", mime_type="text/html")
|
||||
def serve_ui(uri: str) -> str:
|
||||
return "<html></html>"
|
||||
|
||||
assert len(app._initial_resources) == 1
|
||||
resource, handler = app._initial_resources[0]
|
||||
assert resource.uri == "ui://app/index.html"
|
||||
assert handler is serve_ui
|
||||
|
||||
def test_resource_decorator_name_defaults_to_function_name(self):
|
||||
"""Name defaults to fn.__name__ when using decorator."""
|
||||
app = MCPApp(name="TestApp", version="1.0.0")
|
||||
|
||||
@app.resource("ui://app/index.html")
|
||||
def serve_ui(uri: str) -> str:
|
||||
return "<html></html>"
|
||||
|
||||
resource, _ = app._initial_resources[0]
|
||||
assert resource.name == "serve_ui"
|
||||
|
||||
def test_resource_decorator_preserves_function(self):
|
||||
"""Decorated function is still directly callable."""
|
||||
app = MCPApp(name="TestApp", version="1.0.0")
|
||||
|
||||
@app.resource("ui://app/index.html")
|
||||
def serve_ui(uri: str) -> str:
|
||||
return f"content for {uri}"
|
||||
|
||||
assert serve_ui("test://uri") == "content for test://uri"
|
||||
|
||||
def test_multiple_resources_registered(self):
|
||||
"""Multiple add_resource + decorator calls accumulate."""
|
||||
app = MCPApp(name="TestApp", version="1.0.0")
|
||||
|
||||
app.add_resource("file:///a.txt", name="A")
|
||||
app.add_resource("file:///b.txt", name="B")
|
||||
|
||||
@app.resource("ui://app/index.html")
|
||||
def serve_ui(uri: str) -> str:
|
||||
return "<html></html>"
|
||||
|
||||
assert len(app._initial_resources) == 3
|
||||
|
||||
def test_run_exits_when_no_tools_or_resources(self):
|
||||
"""run() exits when neither tools nor resources are registered."""
|
||||
app = MCPApp(name="TestApp", version="1.0.0")
|
||||
with pytest.raises(SystemExit):
|
||||
app.run(transport="http")
|
||||
|
||||
def test_run_allows_resource_only_server(self):
|
||||
"""run() does not exit when only resources are registered (no tools)."""
|
||||
app = MCPApp(name="TestApp", version="1.0.0")
|
||||
app.add_resource("ui://app/index.html", name="App UI")
|
||||
|
||||
with (
|
||||
patch("arcade_mcp_server.mcp_app.create_arcade_mcp") as mock_create,
|
||||
patch("arcade_mcp_server.mcp_app.serve_with_force_quit"),
|
||||
):
|
||||
mock_create.return_value = Mock()
|
||||
# Should NOT raise SystemExit
|
||||
app.run(transport="http", host="127.0.0.1", port=8000)
|
||||
|
||||
def test_tool_with_meta(self):
|
||||
"""@app.tool(meta=...) stores _meta extension."""
|
||||
app = MCPApp(name="TestApp", version="1.0.0")
|
||||
|
||||
@app.tool(meta={"ui": {"resourceUri": "ui://test-app/index.html"}})
|
||||
def get_data(query: Annotated[str, "A query"]) -> str:
|
||||
"""Get data."""
|
||||
return "data"
|
||||
|
||||
assert "TestApp.GetData" in app._tool_meta_extensions
|
||||
assert app._tool_meta_extensions["TestApp.GetData"] == {
|
||||
"ui": {"resourceUri": "ui://test-app/index.html"}
|
||||
}
|
||||
|
||||
def test_add_tool_with_meta(self):
|
||||
"""add_tool(meta=...) stores _meta extension."""
|
||||
app = MCPApp(name="TestApp", version="1.0.0")
|
||||
|
||||
def my_tool(x: Annotated[str, "input"]) -> str:
|
||||
"""A tool."""
|
||||
return x
|
||||
|
||||
app.add_tool(my_tool, meta={"ui": {"resourceUri": "ui://my-tool/index.html"}})
|
||||
|
||||
assert "TestApp.MyTool" in app._tool_meta_extensions
|
||||
assert app._tool_meta_extensions["TestApp.MyTool"] == {
|
||||
"ui": {"resourceUri": "ui://my-tool/index.html"}
|
||||
}
|
||||
|
||||
def test_tool_with_meta_arbitrary_keys(self):
|
||||
"""meta with arbitrary keys is stored as-is."""
|
||||
app = MCPApp(name="TestApp", version="1.0.0")
|
||||
|
||||
@app.tool(meta={"custom": {"key": "value"}, "other": 42})
|
||||
def do_stuff(x: Annotated[str, "input"]) -> str:
|
||||
"""A tool."""
|
||||
return x
|
||||
|
||||
assert "TestApp.DoStuff" in app._tool_meta_extensions
|
||||
assert app._tool_meta_extensions["TestApp.DoStuff"] == {
|
||||
"custom": {"key": "value"},
|
||||
"other": 42,
|
||||
}
|
||||
|
||||
def test_tool_with_meta_arcade_key_raises(self):
|
||||
"""meta containing 'arcade' key raises ToolDefinitionError."""
|
||||
app = MCPApp(name="TestApp", version="1.0.0")
|
||||
|
||||
with pytest.raises(ToolDefinitionError, match="'arcade' key in meta is reserved"):
|
||||
|
||||
@app.tool(meta={"arcade": {"something": True}})
|
||||
def bad_tool(x: Annotated[str, "input"]) -> str:
|
||||
"""A tool."""
|
||||
return x
|
||||
|
||||
def test_tool_with_meta_empty_or_none(self):
|
||||
"""meta={} and meta=None do not create _tool_meta_extensions entries."""
|
||||
app = MCPApp(name="TestApp", version="1.0.0")
|
||||
|
||||
@app.tool(meta={})
|
||||
def tool_empty(x: Annotated[str, "input"]) -> str:
|
||||
"""A tool."""
|
||||
return x
|
||||
|
||||
@app.tool(meta=None)
|
||||
def tool_none(x: Annotated[str, "input"]) -> str:
|
||||
"""A tool."""
|
||||
return x
|
||||
|
||||
assert "TestApp.ToolEmpty" not in app._tool_meta_extensions
|
||||
assert "TestApp.ToolNone" not in app._tool_meta_extensions
|
||||
|
||||
|
||||
class TestMCPAppResourceAnnotationsMetaTemplates:
|
||||
"""Test MCPApp resource registration with annotations, meta, and templates."""
|
||||
|
||||
def test_add_resource_with_annotations(self):
|
||||
from arcade_mcp_server.types import Annotations, Resource
|
||||
|
||||
app = MCPApp(name="TestApp", version="1.0.0")
|
||||
app.add_resource(
|
||||
"res://test",
|
||||
annotations=Annotations(priority=0.5),
|
||||
)
|
||||
assert len(app._initial_resources) == 1
|
||||
item, _handler = app._initial_resources[0]
|
||||
assert isinstance(item, Resource)
|
||||
assert item.annotations is not None
|
||||
assert item.annotations.priority == 0.5
|
||||
|
||||
def test_add_resource_with_meta(self):
|
||||
from arcade_mcp_server.types import Resource
|
||||
|
||||
app = MCPApp(name="TestApp", version="1.0.0")
|
||||
app.add_resource(
|
||||
"res://test",
|
||||
meta={"custom": "value"},
|
||||
)
|
||||
item, _ = app._initial_resources[0]
|
||||
assert isinstance(item, Resource)
|
||||
assert item.meta == {"custom": "value"}
|
||||
|
||||
def test_resource_decorator_with_annotations(self):
|
||||
from arcade_mcp_server.types import Annotations, Resource
|
||||
|
||||
app = MCPApp(name="TestApp", version="1.0.0")
|
||||
|
||||
@app.resource("res://dec", annotations=Annotations(priority=0.8))
|
||||
def handler(uri: str) -> str:
|
||||
return "ok"
|
||||
|
||||
item, _ = app._initial_resources[0]
|
||||
assert isinstance(item, Resource)
|
||||
assert item.annotations is not None
|
||||
assert item.annotations.priority == 0.8
|
||||
|
||||
def test_resource_decorator_with_meta(self):
|
||||
from arcade_mcp_server.types import Resource
|
||||
|
||||
app = MCPApp(name="TestApp", version="1.0.0")
|
||||
|
||||
@app.resource("res://dec", meta={"key": "val"})
|
||||
def handler(uri: str) -> str:
|
||||
return "ok"
|
||||
|
||||
item, _ = app._initial_resources[0]
|
||||
assert isinstance(item, Resource)
|
||||
assert item.meta == {"key": "val"}
|
||||
|
||||
def test_resource_decorator_with_template_uri(self):
|
||||
from arcade_mcp_server.types import ResourceTemplate
|
||||
|
||||
app = MCPApp(name="TestApp", version="1.0.0")
|
||||
|
||||
@app.resource("weather://{city}/current")
|
||||
def handler(uri: str, city: str) -> str:
|
||||
return f"Weather for {city}"
|
||||
|
||||
item, _ = app._initial_resources[0]
|
||||
assert isinstance(item, ResourceTemplate)
|
||||
assert item.uriTemplate == "weather://{city}/current"
|
||||
|
||||
def test_resource_decorator_template_listed_as_template(self):
|
||||
from arcade_mcp_server.types import Resource, ResourceTemplate
|
||||
|
||||
app = MCPApp(name="TestApp", version="1.0.0")
|
||||
|
||||
@app.resource("weather://{city}/current")
|
||||
def handler(uri: str, city: str) -> str:
|
||||
return f"Weather for {city}"
|
||||
|
||||
# Should be ResourceTemplate, not Resource
|
||||
item, _ = app._initial_resources[0]
|
||||
assert isinstance(item, ResourceTemplate)
|
||||
assert not isinstance(item, Resource)
|
||||
|
||||
def test_mcp_app_add_text_resource(self):
|
||||
from arcade_mcp_server.types import Resource
|
||||
|
||||
app = MCPApp(name="TestApp", version="1.0.0")
|
||||
app.add_text_resource("text://hello", text="hello world")
|
||||
|
||||
assert len(app._initial_resources) == 1
|
||||
item, handler = app._initial_resources[0]
|
||||
assert isinstance(item, Resource)
|
||||
assert handler is not None
|
||||
|
||||
def test_mcp_app_add_file_resource(self, tmp_path):
|
||||
from arcade_mcp_server.types import Resource
|
||||
|
||||
f = tmp_path / "test.txt"
|
||||
f.write_text("file content")
|
||||
|
||||
app = MCPApp(name="TestApp", version="1.0.0")
|
||||
app.add_file_resource(
|
||||
"file:///test.txt",
|
||||
path=str(f),
|
||||
name="Test File",
|
||||
mime_type="text/plain",
|
||||
)
|
||||
|
||||
assert len(app._initial_resources) == 1
|
||||
item, handler = app._initial_resources[0]
|
||||
assert isinstance(item, Resource)
|
||||
assert item.name == "Test File"
|
||||
assert handler is not None
|
||||
# Handler should return the file content
|
||||
assert handler("file:///test.txt") == "file content"
|
||||
|
||||
def test_mcp_app_add_file_resource_binary(self, tmp_path):
|
||||
f = tmp_path / "image.bin"
|
||||
f.write_bytes(b"\x89PNG\r\n")
|
||||
|
||||
app = MCPApp(name="TestApp", version="1.0.0")
|
||||
app.add_file_resource("file:///image.bin", path=str(f))
|
||||
|
||||
_, handler = app._initial_resources[0]
|
||||
result = handler("file:///image.bin")
|
||||
assert isinstance(result, bytes)
|
||||
|
||||
def test_mcp_app_add_file_resource_missing_raises(self, tmp_path):
|
||||
from arcade_mcp_server.exceptions import NotFoundError
|
||||
|
||||
app = MCPApp(name="TestApp", version="1.0.0")
|
||||
app.add_file_resource("file:///missing.txt", path=str(tmp_path / "missing.txt"))
|
||||
|
||||
_, handler = app._initial_resources[0]
|
||||
with pytest.raises(NotFoundError, match="File not found"):
|
||||
handler("file:///missing.txt")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1556,3 +1556,224 @@ class TestMissingSecretsWarnings:
|
|||
# Restore environment
|
||||
if old_value is not None:
|
||||
os.environ["FORMAT_TEST_KEY"] = old_value
|
||||
|
||||
|
||||
class TestServerToolMetaExtensions:
|
||||
"""Tests for _meta extensions on tools (e.g., MCP Apps ui.resourceUri)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_meta_extensions_applied(self, tool_catalog, mcp_settings):
|
||||
"""tool_meta_extensions adds _meta.ui.resourceUri to tools."""
|
||||
# Get the FQN of the first tool in the catalog
|
||||
first_tool = next(iter(tool_catalog))
|
||||
fqn = first_tool.definition.fully_qualified_name
|
||||
|
||||
server = MCPServer(
|
||||
catalog=tool_catalog,
|
||||
settings=mcp_settings,
|
||||
tool_meta_extensions={fqn: {"ui": {"resourceUri": "ui://test/index.html"}}},
|
||||
)
|
||||
await server.start()
|
||||
try:
|
||||
tools = await server.tools.list_tools()
|
||||
# Find the tool by its sanitized name
|
||||
sanitized = fqn.replace(".", "_")
|
||||
matched = [t for t in tools if t.name == sanitized]
|
||||
assert len(matched) == 1
|
||||
assert matched[0].meta is not None
|
||||
assert matched[0].meta["ui"]["resourceUri"] == "ui://test/index.html"
|
||||
finally:
|
||||
await server.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_tool_meta_extensions_by_default(self, tool_catalog, mcp_settings):
|
||||
"""Without extensions, tools that have no arcade meta have _meta=None or no ui key."""
|
||||
server = MCPServer(
|
||||
catalog=tool_catalog,
|
||||
settings=mcp_settings,
|
||||
)
|
||||
await server.start()
|
||||
try:
|
||||
tools = await server.tools.list_tools()
|
||||
for t in tools:
|
||||
if t.meta:
|
||||
assert "ui" not in t.meta
|
||||
finally:
|
||||
await server.stop()
|
||||
|
||||
|
||||
class TestServerInitialResources:
|
||||
"""Tests for loading build-time resources into MCPServer."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_loads_initial_resources(self, tool_catalog, mcp_settings):
|
||||
"""MCPServer with initial_resources makes them available via list/read."""
|
||||
from arcade_mcp_server.types import Resource
|
||||
|
||||
resource = Resource(uri="ui://app/index.html", name="App UI", mimeType="text/html")
|
||||
|
||||
def handler(uri: str) -> str:
|
||||
return "<html>hello</html>"
|
||||
|
||||
server = MCPServer(
|
||||
catalog=tool_catalog,
|
||||
settings=mcp_settings,
|
||||
initial_resources=[(resource, handler)],
|
||||
)
|
||||
await server.start()
|
||||
try:
|
||||
resources = await server.resources.list_resources()
|
||||
uris = [r.uri for r in resources]
|
||||
assert "ui://app/index.html" in uris
|
||||
|
||||
contents = await server.resources.read_resource("ui://app/index.html")
|
||||
assert len(contents) == 1
|
||||
assert contents[0].text == "<html>hello</html>" # type: ignore[attr-defined]
|
||||
finally:
|
||||
await server.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_no_initial_resources_by_default(self, tool_catalog, mcp_settings):
|
||||
"""Backward compat: no initial resources by default."""
|
||||
server = MCPServer(
|
||||
catalog=tool_catalog,
|
||||
settings=mcp_settings,
|
||||
)
|
||||
await server.start()
|
||||
try:
|
||||
resources = await server.resources.list_resources()
|
||||
assert resources == []
|
||||
finally:
|
||||
await server.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_initial_resources_with_async_handler(self, tool_catalog, mcp_settings):
|
||||
"""Async handlers work for initial resources."""
|
||||
from arcade_mcp_server.types import Resource
|
||||
|
||||
resource = Resource(uri="ui://app/data.json", name="Data", mimeType="application/json")
|
||||
|
||||
async def async_handler(uri: str) -> str:
|
||||
return '{"key": "value"}'
|
||||
|
||||
server = MCPServer(
|
||||
catalog=tool_catalog,
|
||||
settings=mcp_settings,
|
||||
initial_resources=[(resource, async_handler)],
|
||||
)
|
||||
await server.start()
|
||||
try:
|
||||
contents = await server.resources.read_resource("ui://app/data.json")
|
||||
assert len(contents) == 1
|
||||
assert contents[0].text == '{"key": "value"}' # type: ignore[attr-defined]
|
||||
finally:
|
||||
await server.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_handle_read_resource_round_trip(self, tool_catalog, mcp_settings):
|
||||
"""Integration: _handle_read_resource returns correct JSONRPCResponse."""
|
||||
from arcade_mcp_server.types import (
|
||||
ReadResourceParams,
|
||||
ReadResourceRequest,
|
||||
ReadResourceResult,
|
||||
Resource,
|
||||
TextResourceContents,
|
||||
)
|
||||
|
||||
resource = Resource(uri="ui://app/page.html", name="Page", mimeType="text/html")
|
||||
|
||||
def handler(uri: str) -> str:
|
||||
return "<html><body>Hello</body></html>"
|
||||
|
||||
server = MCPServer(
|
||||
catalog=tool_catalog,
|
||||
settings=mcp_settings,
|
||||
initial_resources=[(resource, handler)],
|
||||
)
|
||||
await server.start()
|
||||
try:
|
||||
request = ReadResourceRequest(
|
||||
id=1,
|
||||
params=ReadResourceParams(uri="ui://app/page.html"),
|
||||
)
|
||||
response = await server._handle_read_resource(request)
|
||||
|
||||
assert not hasattr(response, "error"), f"Expected success, got error: {response}"
|
||||
result = response.result
|
||||
assert isinstance(result, ReadResourceResult)
|
||||
assert len(result.contents) == 1
|
||||
content = result.contents[0]
|
||||
assert isinstance(content, TextResourceContents)
|
||||
assert content.text == "<html><body>Hello</body></html>"
|
||||
assert content.mimeType == "text/html"
|
||||
finally:
|
||||
await server.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_loads_initial_template_resources(self, tool_catalog, mcp_settings):
|
||||
"""MCPServer with initial ResourceTemplate registers it as a template."""
|
||||
from arcade_mcp_server.types import ResourceTemplate
|
||||
|
||||
tmpl = ResourceTemplate(
|
||||
uriTemplate="data://{item_id}", name="Data", mimeType="text/plain"
|
||||
)
|
||||
|
||||
def handler(uri: str, item_id: str) -> str:
|
||||
return f"item-{item_id}"
|
||||
|
||||
server = MCPServer(
|
||||
catalog=tool_catalog,
|
||||
settings=mcp_settings,
|
||||
initial_resources=[(tmpl, handler)],
|
||||
)
|
||||
await server.start()
|
||||
try:
|
||||
templates = await server.resources.list_resource_templates()
|
||||
assert any(t.uriTemplate == "data://{item_id}" for t in templates)
|
||||
|
||||
contents = await server.resources.read_resource("data://42")
|
||||
assert contents[0].text == "item-42"
|
||||
finally:
|
||||
await server.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_loads_template_without_handler(self, tool_catalog, mcp_settings):
|
||||
"""MCPServer with ResourceTemplate and no handler registers template only."""
|
||||
from arcade_mcp_server.types import ResourceTemplate
|
||||
|
||||
tmpl = ResourceTemplate(
|
||||
uriTemplate="schema://{type}", name="Schema"
|
||||
)
|
||||
|
||||
server = MCPServer(
|
||||
catalog=tool_catalog,
|
||||
settings=mcp_settings,
|
||||
initial_resources=[(tmpl, None)],
|
||||
)
|
||||
await server.start()
|
||||
try:
|
||||
templates = await server.resources.list_resource_templates()
|
||||
assert any(t.uriTemplate == "schema://{type}" for t in templates)
|
||||
finally:
|
||||
await server.stop()
|
||||
|
||||
|
||||
class TestToolMetaExtensionEdgeCases:
|
||||
"""Test apply_meta_extensions edge cases on ToolManager directly."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_fqn_logs_warning(self, tool_catalog, mcp_settings, caplog):
|
||||
"""Extensions referencing non-existent tools log a warning and skip."""
|
||||
import logging
|
||||
|
||||
server = MCPServer(
|
||||
catalog=tool_catalog,
|
||||
settings=mcp_settings,
|
||||
tool_meta_extensions={"NonExistent.Tool": {"ui": {"resourceUri": "ui://x"}}},
|
||||
)
|
||||
with caplog.at_level(logging.WARNING, logger="arcade.mcp.managers.tool"):
|
||||
await server.start()
|
||||
try:
|
||||
assert "skipped: tool not found" in caplog.text
|
||||
finally:
|
||||
await server.stop()
|
||||
|
|
|
|||
Loading…
Reference in a new issue