mcp-arduino/src/mcp_arduino_server/components/client_capabilities.py
Ryan Malloy eb524b8c1d Major project refactor: Rename to mcp-arduino with smart client capabilities
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
2025-09-27 20:16:43 -06:00

276 lines
10 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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