Resolves https://linear.app/arcadedev/issue/TOO-788/mypy-failures-are-silently-dropped-during-arcade-mcp-ci <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Low risk: primarily CI/Makefile behavior and type-annotation tweaks; functional logic is unchanged aside from stricter failure propagation in `make check`. > > **Overview** > **Stops CI from silently ignoring mypy failures.** The `make check` target now runs `mypy` across `libs/arcade*/` and exits non-zero if any package fails, reporting the failed libs. > > Separately tightens typing to satisfy `mypy` (removing `type: ignore` on OAuth helpers, adding `cast()`/`Any` annotations for JSON response shapes and subprocess kwargs, and handling non-`str` `server_address` hosts), and bumps patch versions for `arcade-mcp` and `arcade-mcp-server`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e79575b13a2d03adf3548104a0064c643f1e21b1. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
288 lines
11 KiB
Python
288 lines
11 KiB
Python
import base64
|
|
import json
|
|
import logging
|
|
from typing import Any
|
|
|
|
from arcade_core.catalog import MaterializedTool
|
|
from arcade_core.schema import ToolDefinition
|
|
|
|
from arcade_mcp_server.types import MCPContent, MCPTool, TextContent, ToolAnnotations
|
|
|
|
logger = logging.getLogger("arcade.mcp")
|
|
|
|
|
|
def _build_arcade_meta(definition: ToolDefinition) -> dict[str, Any] | None:
|
|
"""Build the _meta.arcade structure from a tool definition.
|
|
|
|
The structure of _meta.arcade mirrors Arcade format when possible.
|
|
"""
|
|
arcade_meta: dict[str, Any] = {}
|
|
|
|
requirements = definition.requirements
|
|
if requirements.authorization or requirements.secrets or requirements.metadata:
|
|
arcade_meta["requirements"] = requirements.model_dump(exclude_none=True)
|
|
|
|
tool_metadata = definition.metadata
|
|
if tool_metadata:
|
|
metadata_dump = tool_metadata.model_dump(mode="json", exclude_none=True)
|
|
if metadata_dump:
|
|
arcade_meta["metadata"] = metadata_dump
|
|
|
|
return arcade_meta if arcade_meta else None
|
|
|
|
|
|
def create_mcp_tool(materialized_tool: MaterializedTool) -> MCPTool:
|
|
"""
|
|
Create an MCP-compatible tool definition from a MaterializedTool.
|
|
|
|
Computes MCP annotations from tool metadata behavior fields and builds
|
|
the ``_meta.arcade`` structure with requirements and metadata.
|
|
|
|
Args:
|
|
materialized_tool: A materialized Arcade tool
|
|
|
|
Returns:
|
|
An MCP tool definition
|
|
"""
|
|
definition = materialized_tool.definition
|
|
name = definition.fully_qualified_name.replace(".", "_")
|
|
|
|
# Build the tool's description
|
|
description = definition.description
|
|
deprecation_msg = getattr(definition, "deprecation_message", None)
|
|
if deprecation_msg:
|
|
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
|
|
title = getattr(materialized_tool.tool, "__tool_name__", definition.name)
|
|
tool_metadata = definition.metadata
|
|
if tool_metadata and tool_metadata.behavior:
|
|
behavior = tool_metadata.behavior
|
|
annotations = ToolAnnotations(
|
|
title=title,
|
|
readOnlyHint=behavior.read_only,
|
|
destructiveHint=behavior.destructive,
|
|
idempotentHint=behavior.idempotent,
|
|
openWorldHint=behavior.open_world,
|
|
)
|
|
else:
|
|
annotations = ToolAnnotations(title=title)
|
|
|
|
# Build _meta.arcade structure
|
|
arcade_meta = _build_arcade_meta(definition)
|
|
meta = {"arcade": arcade_meta} if arcade_meta else None
|
|
|
|
return MCPTool(
|
|
name=name,
|
|
title=title,
|
|
description=str(description),
|
|
inputSchema=build_input_schema_from_definition(definition),
|
|
outputSchema=output_schema if output_schema else None,
|
|
annotations=annotations,
|
|
_meta=meta,
|
|
)
|
|
|
|
|
|
def convert_to_mcp_content(value: Any) -> list[MCPContent]:
|
|
"""
|
|
Convert a Python value to MCP-compatible content.
|
|
"""
|
|
if value is None:
|
|
return []
|
|
|
|
if isinstance(value, (str, bool, int, float)):
|
|
return [TextContent(type="text", text=str(value))]
|
|
|
|
if isinstance(value, (dict, list)):
|
|
try:
|
|
return [TextContent(type="text", text=json.dumps(value, ensure_ascii=False))]
|
|
except Exception as exc:
|
|
raise ValueError("Failed to serialize value to JSON for MCP content") from exc
|
|
|
|
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
# Encode bytes as base64 text so it can be transmitted safely
|
|
b = bytes(value)
|
|
encoded = base64.b64encode(b).decode("ascii")
|
|
return [TextContent(type="text", text=encoded)]
|
|
|
|
# Default fallback
|
|
return [TextContent(type="text", text=str(value))]
|
|
|
|
|
|
def convert_content_to_structured_content(value: Any) -> dict[str, Any] | None:
|
|
"""
|
|
Convert a Python value to MCP-compatible structured content (JSON object).
|
|
|
|
According to the MCP specification, structuredContent should be a JSON object
|
|
that represents the structured result of the tool call.
|
|
|
|
Args:
|
|
value: The value to convert to structured content
|
|
|
|
Returns:
|
|
A dictionary representing the structured content, or None if value is None
|
|
"""
|
|
if value is None:
|
|
return None
|
|
|
|
if isinstance(value, dict):
|
|
# Already a dictionary - use as-is
|
|
return value
|
|
elif isinstance(value, list):
|
|
# List - wrap in a result object
|
|
return {"result": value}
|
|
elif isinstance(value, (str, int, float, bool)):
|
|
# Primitive types - wrap in a result object
|
|
return {"result": value}
|
|
else:
|
|
# For other types, convert to string and wrap
|
|
return {"result": str(value)}
|
|
|
|
|
|
def _map_type_to_json_schema_type(val_type: str | None) -> str:
|
|
"""
|
|
Map Arcade value types to JSON schema types.
|
|
|
|
Args:
|
|
val_type: The Arcade value type as a string.
|
|
|
|
Returns:
|
|
The corresponding JSON schema type as a string.
|
|
"""
|
|
if val_type is None:
|
|
return "string"
|
|
|
|
mapping: dict[str, str] = {
|
|
"string": "string",
|
|
"integer": "integer",
|
|
"number": "number",
|
|
"boolean": "boolean",
|
|
"json": "object",
|
|
"array": "array",
|
|
}
|
|
return mapping.get(val_type, "string")
|
|
|
|
|
|
def build_input_schema_from_definition(definition: ToolDefinition) -> dict[str, Any]:
|
|
"""Build a JSON schema object for tool inputs from a ToolDefinition.
|
|
|
|
Returns a dict with keys: type, properties, and optional required.
|
|
"""
|
|
properties: dict[str, Any] = {}
|
|
required: list[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] = (
|
|
_value_schema_to_json_schema(val_schema) if val_schema else {"type": "string"}
|
|
)
|
|
|
|
if getattr(param, "description", None):
|
|
schema["description"] = param.description
|
|
|
|
properties[param.name] = schema
|
|
if getattr(param, "required", False):
|
|
required.append(param.name)
|
|
|
|
input_schema: dict[str, Any] = {
|
|
"type": "object",
|
|
"properties": properties,
|
|
"additionalProperties": False,
|
|
}
|
|
if required:
|
|
input_schema["required"] = required
|
|
return input_schema
|
|
|
|
|
|
def _build_value_schema_json(value_schema: Any) -> dict[str, Any]:
|
|
"""Map a ValueSchema to a JSON Schema ``outputSchema``.
|
|
|
|
Per the MCP specification, ``outputSchema.type`` MUST be ``"object"``
|
|
because ``structuredContent`` is always a JSON object.
|
|
|
|
* **object** return types (``val_type == "json"``) are emitted directly
|
|
as ``{"type": "object", "properties": {…}}``.
|
|
* All other return types (primitives, arrays) are wrapped in
|
|
``{"type": "object", "properties": {"result": <inner>}}`` to mirror
|
|
the wrapping performed at runtime by
|
|
:func:`convert_content_to_structured_content`.
|
|
"""
|
|
inner_schema = _value_schema_to_json_schema(value_schema)
|
|
|
|
# Object return types are already top-level objects, emit directly.
|
|
# Check for both "object" and ["object", "null"] (nullable top-level TypedDict).
|
|
schema_type = inner_schema.get("type")
|
|
if schema_type == "object" or (isinstance(schema_type, list) and "object" in schema_type):
|
|
return inner_schema
|
|
|
|
# Primitives/arrays must be wrapped so outputSchema.type is "object" per MCP spec.
|
|
return {
|
|
"type": "object",
|
|
"properties": {
|
|
"result": inner_schema,
|
|
},
|
|
}
|
|
|
|
|
|
def _apply_nullable(schema: dict[str, Any], value_schema: Any) -> dict[str, Any]:
|
|
"""If value_schema.nullable, add null to type and enum."""
|
|
if not getattr(value_schema, "nullable", False):
|
|
return schema
|
|
base_type = schema.get("type")
|
|
if isinstance(base_type, str):
|
|
schema["type"] = [base_type, "null"]
|
|
elif isinstance(base_type, list) and "null" not in base_type:
|
|
schema["type"] = [*base_type, "null"]
|
|
if "enum" in schema and None not in schema["enum"]:
|
|
schema["enum"] = [*schema["enum"], None]
|
|
return 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
|
|
if getattr(value_schema, "required_keys", None):
|
|
schema["required"] = list(value_schema.required_keys)
|
|
return _apply_nullable(schema, value_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
|
|
if getattr(value_schema, "inner_required_keys", None):
|
|
items_schema["required"] = list(value_schema.inner_required_keys)
|
|
schema["items"] = items_schema
|
|
return _apply_nullable(schema, value_schema)
|