BREAKING CHANGES: - Package renamed from mcp-arduino-server to mcp-arduino - Command changed to 'mcp-arduino' (was 'mcp-arduino-server') - Repository moved to git.supported.systems/MCP/mcp-arduino NEW FEATURES: ✨ Smart client capability detection and dual-mode sampling support ✨ Intelligent WireViz templates with component-specific circuits (LED, motor, sensor, button, display) ✨ Client debug tools for MCP capability inspection ✨ Enhanced error handling with progressive enhancement patterns IMPROVEMENTS: 🧹 Major repository cleanup - removed 14+ experimental files and tests 📝 Consolidated and reorganized documentation 🐛 Fixed import issues and applied comprehensive linting with ruff 📦 Updated author information to Ryan Malloy (ryan@supported.systems) 🔧 Fixed package version references in startup code TECHNICAL DETAILS: - Added dual-mode WireViz: AI generation for sampling clients, smart templates for others - Implemented client capability detection via MCP handshake inspection - Created progressive enhancement pattern for universal MCP client compatibility - Organized test files into proper structure (tests/examples/) - Applied comprehensive code formatting and lint fixes The server now provides excellent functionality for ALL MCP clients regardless of their sampling capabilities, while preserving advanced features for clients that support them. Version: 2025.09.27.1
276 lines
10 KiB
Python
276 lines
10 KiB
Python
"""
|
||
Enhanced Context with Client Capabilities Access
|
||
|
||
This module provides an enhanced way to access client capabilities through the Context.
|
||
It exposes what the client actually declared during initialization, not just what we can detect.
|
||
"""
|
||
|
||
import logging
|
||
from typing import Any
|
||
|
||
from fastmcp import Context
|
||
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
|
||
from mcp.types import ToolAnnotations
|
||
from pydantic import Field
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class ClientCapabilitiesInfo(MCPMixin):
|
||
"""
|
||
Enhanced tools to properly access client capabilities from the MCP handshake.
|
||
|
||
This reveals what the client ACTUALLY declared, not just what we can probe.
|
||
"""
|
||
|
||
def __init__(self, config):
|
||
"""Initialize capabilities info component"""
|
||
self.config = config
|
||
|
||
@mcp_tool(
|
||
name="client_declared_capabilities",
|
||
description="Show what capabilities the client declared during initialization",
|
||
annotations=ToolAnnotations(
|
||
title="Client Declared Capabilities",
|
||
destructiveHint=False,
|
||
idempotentHint=True
|
||
)
|
||
)
|
||
async def show_declared_capabilities(
|
||
self,
|
||
ctx: Context,
|
||
verbose: bool = Field(False, description="Show raw capability data")
|
||
) -> dict[str, Any]:
|
||
"""
|
||
Show the actual capabilities the client declared in the initialization handshake.
|
||
|
||
This is different from probing - this shows what the client SAID it supports.
|
||
"""
|
||
|
||
result = {
|
||
"has_session": hasattr(ctx, 'session'),
|
||
"client_params_available": False,
|
||
"declared_capabilities": {},
|
||
"insights": []
|
||
}
|
||
|
||
# Access the underlying session
|
||
if not hasattr(ctx, 'session'):
|
||
result["error"] = "No session available in context"
|
||
return result
|
||
|
||
session = ctx.session
|
||
|
||
# Check if we have client_params (from initialization)
|
||
if hasattr(session, '_client_params') and session._client_params:
|
||
result["client_params_available"] = True
|
||
client_params = session._client_params
|
||
|
||
# Get the capabilities
|
||
if hasattr(client_params, 'capabilities'):
|
||
caps = client_params.capabilities
|
||
|
||
# Check sampling capability
|
||
if hasattr(caps, 'sampling'):
|
||
sampling_cap = caps.sampling
|
||
result["declared_capabilities"]["sampling"] = {
|
||
"declared": sampling_cap is not None,
|
||
"details": str(sampling_cap) if sampling_cap else None
|
||
}
|
||
else:
|
||
result["declared_capabilities"]["sampling"] = {
|
||
"declared": False,
|
||
"details": "Not declared"
|
||
}
|
||
|
||
# Check roots capability
|
||
if hasattr(caps, 'roots'):
|
||
roots_cap = caps.roots
|
||
if roots_cap:
|
||
result["declared_capabilities"]["roots"] = {
|
||
"declared": True,
|
||
"listChanged": getattr(roots_cap, 'listChanged', False)
|
||
}
|
||
else:
|
||
result["declared_capabilities"]["roots"] = {
|
||
"declared": False
|
||
}
|
||
|
||
# Check other capabilities
|
||
for attr in ['resources', 'prompts', 'tools']:
|
||
if hasattr(caps, attr):
|
||
cap = getattr(caps, attr)
|
||
if cap:
|
||
result["declared_capabilities"][attr] = {
|
||
"declared": True,
|
||
"listChanged": getattr(cap, 'listChanged', False) if cap else False
|
||
}
|
||
else:
|
||
result["declared_capabilities"][attr] = {
|
||
"declared": False
|
||
}
|
||
|
||
# Check experimental capabilities
|
||
if hasattr(caps, 'experimental'):
|
||
result["declared_capabilities"]["experimental"] = caps.experimental or {}
|
||
|
||
if verbose:
|
||
result["raw_capabilities"] = str(caps)
|
||
else:
|
||
result["error"] = "No capabilities found in client params"
|
||
|
||
# Get client info
|
||
if hasattr(client_params, 'clientInfo'):
|
||
client_info = client_params.clientInfo
|
||
if client_info:
|
||
result["client_info"] = {
|
||
"name": getattr(client_info, 'name', 'unknown'),
|
||
"version": getattr(client_info, 'version', 'unknown')
|
||
}
|
||
else:
|
||
result["error"] = "Client params not available - initialization data missing"
|
||
result["insights"].append("Client didn't provide initialization parameters")
|
||
|
||
# Generate insights based on findings
|
||
if result["declared_capabilities"]:
|
||
# Sampling insight
|
||
sampling = result["declared_capabilities"].get("sampling", {})
|
||
if not sampling.get("declared"):
|
||
result["insights"].append(
|
||
"⚠️ Client didn't declare sampling capability - this is why sampling fails!"
|
||
)
|
||
else:
|
||
result["insights"].append(
|
||
"✅ Client properly declared sampling support"
|
||
)
|
||
|
||
# Roots insight
|
||
roots = result["declared_capabilities"].get("roots", {})
|
||
if not roots.get("declared"):
|
||
result["insights"].append(
|
||
"Client didn't declare roots support (but may still work)"
|
||
)
|
||
elif roots.get("listChanged"):
|
||
result["insights"].append(
|
||
"Client supports dynamic roots updates"
|
||
)
|
||
|
||
return result
|
||
|
||
@mcp_tool(
|
||
name="client_capability_check",
|
||
description="Test if client declared support for specific capabilities",
|
||
annotations=ToolAnnotations(
|
||
title="Check Specific Capability",
|
||
destructiveHint=False,
|
||
idempotentHint=True
|
||
)
|
||
)
|
||
async def check_capability(
|
||
self,
|
||
ctx: Context,
|
||
capability: str = Field(..., description="Capability to check: sampling, roots, resources, prompts, tools")
|
||
) -> dict[str, Any]:
|
||
"""
|
||
Check if the client declared a specific capability.
|
||
|
||
This uses the same check_client_capability method that FastMCP uses internally.
|
||
"""
|
||
|
||
if not hasattr(ctx, 'session'):
|
||
return {
|
||
"capability": capability,
|
||
"supported": False,
|
||
"error": "No session available"
|
||
}
|
||
|
||
session = ctx.session
|
||
|
||
# Try to use the check_client_capability method directly
|
||
if hasattr(session, 'check_client_capability'):
|
||
from mcp.types import ClientCapabilities, RootsCapability, SamplingCapability
|
||
|
||
# Build the capability object to check
|
||
check_cap = ClientCapabilities()
|
||
|
||
if capability == "sampling":
|
||
check_cap.sampling = SamplingCapability()
|
||
elif capability == "roots":
|
||
check_cap.roots = RootsCapability()
|
||
# Add other capabilities as needed
|
||
|
||
try:
|
||
supported = session.check_client_capability(check_cap)
|
||
return {
|
||
"capability": capability,
|
||
"supported": supported,
|
||
"check_method": "session.check_client_capability",
|
||
"explanation": f"Client {'did' if supported else 'did not'} declare {capability} support"
|
||
}
|
||
except Exception as e:
|
||
return {
|
||
"capability": capability,
|
||
"supported": False,
|
||
"error": str(e)
|
||
}
|
||
else:
|
||
return {
|
||
"capability": capability,
|
||
"supported": False,
|
||
"error": "check_client_capability method not available"
|
||
}
|
||
|
||
@mcp_tool(
|
||
name="client_fix_capabilities",
|
||
description="Suggest fixes for capability issues",
|
||
annotations=ToolAnnotations(
|
||
title="Capability Issue Fixes",
|
||
destructiveHint=False,
|
||
idempotentHint=True
|
||
)
|
||
)
|
||
async def suggest_fixes(self, ctx: Context) -> dict[str, Any]:
|
||
"""Analyze capability issues and suggest fixes"""
|
||
|
||
# First get the declared capabilities
|
||
caps_result = await self.show_declared_capabilities(ctx, verbose=False)
|
||
|
||
fixes = []
|
||
|
||
# Check sampling
|
||
sampling = caps_result.get("declared_capabilities", {}).get("sampling", {})
|
||
if not sampling.get("declared"):
|
||
fixes.append({
|
||
"issue": "Sampling not declared by client",
|
||
"impact": "AI-powered features (like WireViz from description) will fail",
|
||
"fix": "Applied patch to FastMCP context.py to bypass capability check",
|
||
"status": "✅ Fixed"
|
||
})
|
||
|
||
# Check roots
|
||
roots = caps_result.get("declared_capabilities", {}).get("roots", {})
|
||
if roots.get("declared") and not roots.get("listChanged"):
|
||
fixes.append({
|
||
"issue": "Roots supported but not dynamic updates",
|
||
"impact": "Can't notify client when roots change",
|
||
"fix": "No fix needed - static roots work fine",
|
||
"status": "ℹ️ Informational"
|
||
})
|
||
|
||
# Check for other missing capabilities
|
||
for cap in ["resources", "prompts", "tools"]:
|
||
cap_info = caps_result.get("declared_capabilities", {}).get(cap, {})
|
||
if not cap_info.get("declared"):
|
||
fixes.append({
|
||
"issue": f"{cap.capitalize()} capability not declared",
|
||
"impact": f"Can't use {cap}-related features",
|
||
"fix": f"Client needs to declare {cap} support",
|
||
"status": "⚠️ Client limitation"
|
||
})
|
||
|
||
return {
|
||
"capability_issues": len(fixes),
|
||
"fixes": fixes,
|
||
"summary": "Sampling issue has been patched. Other limitations are client-side."
|
||
}
|