diff --git a/alloccontext/mcp/bazaar.py b/alloccontext/mcp/bazaar.py index 55ea638..c47f058 100644 --- a/alloccontext/mcp/bazaar.py +++ b/alloccontext/mcp/bazaar.py @@ -10,6 +10,24 @@ declare_mcp_discovery_extension, ) +from alloccontext.mcp.tool_catalog import ( + ASSET_FILTER_SCHEMA, + AS_OF_SCHEMA, + BAND_SCHEMA, + CURRENT_AS_OF_SCHEMA, + FRESHNESS_SCHEMA, + MATCH_SCHEMA, + MCP_SERVER_PROMPTS, + MCP_SERVER_RESOURCES, + OPTIONAL_TARGET_PCT_SCHEMA, + PRIOR_AS_OF_SCHEMA, + SCENARIOS_SCHEMA, + SCOPE_SCHEMA, + TARGET_PCT_SCHEMA, + allocation_pct_schema, + server_card_tool_entry, +) + SERVICE_NAME = "AllocContext" OFFICIAL_HOSTED_MCP_URL = "https://mcp.alloc-context.com/mcp" USE_DOCS_PATH = "docs/USE.md" @@ -98,54 +116,22 @@ f"MCP at {OFFICIAL_HOSTED_MCP_URL} — see {USE_DOCS_PATH}." ) -_ASSET_FILTER_SCHEMA = { - "type": "array", - "items": {"type": "string", "enum": ["BTC", "ETH", "CASH"]}, - "description": "Subset market and ETF fields (default BTC and ETH).", -} - -_TARGET_PCT_SCHEMA = { - "type": "object", - "description": "Target weights keyed by BTC, ETH, CASH.", - "properties": { - "BTC": {"type": "number"}, - "ETH": {"type": "number"}, - "CASH": {"type": "number"}, - }, - "required": ["BTC", "ETH", "CASH"], -} - -_BAND_SCHEMA = { - "type": "number", - "description": "Drift band width (for example 0.15 = 15%).", -} - _MCP_TOOLS: tuple[dict[str, Any], ...] = ( { "tool_name": "get_market_context", "description": ( - "Fused market backdrop for portfolio context: Fear & Greed, Kalshi " - "sentiment, macro calendar, FRED indicators, ETF flows, and breadth. " - "Use freshness=cached for hosted cache; freshness=live runs ingest " - "first (requires ingest API keys on the host)." + "Return read-only fused market backdrop for crypto portfolio context: " + "sentiment (Fear & Greed, Kalshi), macro events, FRED indicators, ETF " + "flows, and market breadth — no portfolio holdings. Use " + "get_context_bundle when you also need holdings, delta, or regime. " + "freshness=cached reads the ingest DB; freshness=live runs ingest first." ), "input_schema": { "type": "object", "properties": { - "scope": { - "type": "string", - "enum": ["daily", "weekly"], - "description": "Rollup horizon for macro and context bundle.", - }, - "freshness": { - "type": "string", - "enum": ["cached", "live"], - "description": ( - "cached reads the ingest DB; live runs ingest first " - "(requires ingest API keys on the host)." - ), - }, - "assets": _ASSET_FILTER_SCHEMA, + "scope": SCOPE_SCHEMA, + "freshness": FRESHNESS_SCHEMA, + "assets": ASSET_FILTER_SCHEMA, }, }, "example": {"scope": "daily", "freshness": "cached", "assets": ["BTC", "ETH"]}, @@ -163,25 +149,20 @@ { "tool_name": "get_context_bundle", "description": ( - "Full ContextBundle JSON: portfolio holdings and band weights, " - "market, sentiment, macro, regime hints, and delta vs the prior " - "saved snapshot. Optional target_pct and band enable " - "allocation_analysis (opt-in drift math)." + "Return the full read-only ContextBundle JSON: portfolio holdings, " + "market, sentiment, macro, regime hints, and delta vs the prior saved " + "snapshot. Use get_market_context for market-only; use get_context_at " + "for a historical snapshot. Optional target_pct and band attach " + "allocation_analysis drift math." ), "input_schema": { "type": "object", "properties": { - "scope": { - "type": "string", - "enum": ["daily", "weekly"], - }, - "freshness": { - "type": "string", - "enum": ["cached", "live"], - }, - "assets": _ASSET_FILTER_SCHEMA, - "target_pct": _TARGET_PCT_SCHEMA, - "band": _BAND_SCHEMA, + "scope": SCOPE_SCHEMA, + "freshness": FRESHNESS_SCHEMA, + "assets": ASSET_FILTER_SCHEMA, + "target_pct": TARGET_PCT_SCHEMA, + "band": BAND_SCHEMA, }, }, "example": { @@ -211,45 +192,28 @@ { "tool_name": "get_rebalance_plan", "description": ( - "Compute USD deltas and exchange-style move lines toward a target " - "BTC/ETH/CASH allocation from current band weights and NAV. Pure " - "math — explicit inputs required." + "Compute read-only USD deltas and suggested exchange move lines to " + "reach a BTC/ETH/CASH target split. Pure math — no exchange API calls. " + "Requires allocation_pct, target_pct, and nav_usd. Use get_portfolio_state " + "or get_context_bundle when you need live weights first." ), "input_schema": { "type": "object", "properties": { - "allocation_pct": { - "type": "object", - "description": "Current weights keyed by BTC, ETH, CASH.", - "properties": { - "BTC": {"type": "number"}, - "ETH": {"type": "number"}, - "CASH": {"type": "number"}, - }, - "required": ["BTC", "ETH", "CASH"], - }, - "target_pct": { - "type": "object", - "description": "Target weights keyed by BTC, ETH, CASH.", - "properties": { - "BTC": {"type": "number"}, - "ETH": {"type": "number"}, - "CASH": {"type": "number"}, - }, - "required": ["BTC", "ETH", "CASH"], - }, + "allocation_pct": allocation_pct_schema(role="Current"), + "target_pct": allocation_pct_schema(role="Target"), "nav_usd": { "type": "number", - "description": "Portfolio NAV in USD.", + "description": "Portfolio net asset value in USD.", }, "exchange": { "type": "string", "enum": ["kraken", "coinbase"], "description": ( - "Exchange-specific move wording (default kraken)." + "Spot exchange for move wording: kraken (default) or coinbase." ), }, - "band": _BAND_SCHEMA, + "band": BAND_SCHEMA, }, "required": ["allocation_pct", "target_pct", "nav_usd"], }, @@ -272,10 +236,10 @@ { "tool_name": "get_portfolio_state", "description": ( - "Live portfolio read: NAV, holdings[], band weights, and optional " - "allocation_analysis when target_pct is supplied. Pass read-only " - "Kraken or Coinbase credentials in the request; never stored " - "server-side." + "Fetch live read-only portfolio NAV, holdings[], and band weights from " + "Kraken or Coinbase credentials passed in this call (never stored). " + "Requires exchange, api_key, and api_secret. Returns available=false " + "with reason on invalid credentials — no side effects." ), "input_schema": { "type": "object", @@ -283,11 +247,13 @@ "exchange": { "type": "string", "enum": ["kraken", "coinbase"], - "description": "Spot exchange to query.", + "description": "Spot exchange to query: kraken or coinbase.", }, "api_key": { "type": "string", - "description": "Read-only API key (CDP key name for Coinbase).", + "description": ( + "Read-only exchange API key (Coinbase CDP key name)." + ), }, "api_secret": { "type": "string", @@ -295,19 +261,8 @@ "Read-only API secret (Kraken base64 secret or Coinbase EC PEM)." ), }, - "target_pct": { - "type": "object", - "description": "Optional target weights for allocation_analysis.", - "properties": { - "BTC": {"type": "number"}, - "ETH": {"type": "number"}, - "CASH": {"type": "number"}, - }, - }, - "band": { - "type": "number", - "description": "Drift band width when target_pct is supplied (e.g. 0.15).", - }, + "target_pct": OPTIONAL_TARGET_PCT_SCHEMA, + "band": BAND_SCHEMA, }, "required": ["exchange", "api_key", "api_secret"], }, @@ -330,34 +285,19 @@ { "tool_name": "check_allocation_band", "description": ( - "Check whether band weights (BTC/ETH/CASH) are outside a drift band " - "vs target and return hint (within_band, consider_rebalance, etc.). " - "Explicit inputs required." + "Check read-only drift: are BTC/ETH/CASH band weights outside the drift " + "band vs target_pct? Returns rebalance_hint (within_band, " + "consider_rebalance, etc.). Single scenario — use check_allocation_bands " + "for multiple targets. Use get_rebalance_plan when you need USD move lines." ), "input_schema": { "type": "object", "properties": { - "allocation_pct": { - "type": "object", - "properties": { - "BTC": {"type": "number"}, - "ETH": {"type": "number"}, - "CASH": {"type": "number"}, - }, - "required": ["BTC", "ETH", "CASH"], - }, - "target_pct": { - "type": "object", - "properties": { - "BTC": {"type": "number"}, - "ETH": {"type": "number"}, - "CASH": {"type": "number"}, - }, - "required": ["BTC", "ETH", "CASH"], - }, + "allocation_pct": allocation_pct_schema(role="Current"), + "target_pct": allocation_pct_schema(role="Target"), "band": { "type": "number", - "description": "Drift band width (default 0.15 = 15%).", + "description": "Drift band width as a fraction (default 0.15 = 15%).", }, }, "required": ["allocation_pct", "target_pct"], @@ -378,16 +318,20 @@ { "tool_name": "get_context_at", "description": ( - "Load a saved ContextBundle snapshot from ingest history by ISO " - "timestamp (match exact or at_or_before)." + "Load a read-only ContextBundle snapshot from ingest history at a point " + "in time. Use get_context_bundle for the latest snapshot; use " + "get_context_delta to compare two timestamps. Returns unavailable when " + "no snapshot matches as_of and match." ), "input_schema": { "type": "object", "properties": { - "as_of": {"type": "string", "description": "ISO timestamp."}, - "scope": {"type": "string", "enum": ["daily", "weekly"]}, - "match": {"type": "string", "enum": ["exact", "at_or_before"]}, - "assets": _ASSET_FILTER_SCHEMA, + "as_of": AS_OF_SCHEMA, + "scope": SCOPE_SCHEMA, + "match": MATCH_SCHEMA, + "assets": ASSET_FILTER_SCHEMA, + "target_pct": TARGET_PCT_SCHEMA, + "band": BAND_SCHEMA, }, "required": ["as_of"], }, @@ -406,15 +350,18 @@ { "tool_name": "get_context_delta", "description": ( - "Compare two ContextBundle snapshots and return notable_shifts." + "Compare two read-only ContextBundle snapshots and return notable_shifts " + "between them. Requires prior_as_of; omit current_as_of to diff against " + "the latest live bundle. Use get_context_at to load one snapshot without " + "diffing." ), "input_schema": { "type": "object", "properties": { - "prior_as_of": {"type": "string"}, - "scope": {"type": "string", "enum": ["daily", "weekly"]}, - "current_as_of": {"type": "string"}, - "assets": _ASSET_FILTER_SCHEMA, + "prior_as_of": PRIOR_AS_OF_SCHEMA, + "scope": SCOPE_SCHEMA, + "current_as_of": CURRENT_AS_OF_SCHEMA, + "assets": ASSET_FILTER_SCHEMA, }, "required": ["prior_as_of"], }, @@ -431,39 +378,15 @@ { "tool_name": "check_allocation_bands", "description": ( - "Evaluate allocation drift against multiple target/band scenarios." + "Evaluate read-only allocation drift against multiple target_pct/band " + "scenarios in one call. Each scenario requires target_pct; optional name " + "and band (default 0.15). Use check_allocation_band for a single target." ), "input_schema": { "type": "object", "properties": { - "allocation_pct": { - "type": "object", - "properties": { - "BTC": {"type": "number"}, - "ETH": {"type": "number"}, - "CASH": {"type": "number"}, - }, - "required": ["BTC", "ETH", "CASH"], - }, - "scenarios": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "target_pct": { - "type": "object", - "properties": { - "BTC": {"type": "number"}, - "ETH": {"type": "number"}, - "CASH": {"type": "number"}, - }, - }, - "band": {"type": "number"}, - }, - "required": ["target_pct"], - }, - }, + "allocation_pct": allocation_pct_schema(role="Current"), + "scenarios": SCENARIOS_SCHEMA, }, "required": ["allocation_pct", "scenarios"], }, @@ -715,14 +638,7 @@ def build_mcp_server_card(*, version: str) -> dict[str, Any]: "see /.well-known/x402.json for pricing." ), }, - "tools": [ - { - "name": spec["tool_name"], - "description": spec["description"], - "inputSchema": spec["input_schema"], - } - for spec in _MCP_TOOLS - ], - "resources": [], - "prompts": [], + "tools": [server_card_tool_entry(spec) for spec in _MCP_TOOLS], + "resources": list(MCP_SERVER_RESOURCES), + "prompts": list(MCP_SERVER_PROMPTS), } diff --git a/alloccontext/mcp/server.py b/alloccontext/mcp/server.py index 5794145..8649ccc 100644 --- a/alloccontext/mcp/server.py +++ b/alloccontext/mcp/server.py @@ -6,6 +6,7 @@ from alloccontext.config import load_config from alloccontext.mcp import handlers from alloccontext.mcp.instructions import PRODUCT_INSTRUCTIONS, REBALANCE_HINT_GUIDE +from alloccontext.mcp.tool_catalog import tool_annotations, tool_title from alloccontext.mcp.tool_fields import ( AllocationPct, ApiKey, @@ -72,6 +73,12 @@ def _require_mcp(): return FastMCP +def _tool_hints(tool_name: str): + from mcp.types import ToolAnnotations + + return ToolAnnotations(**tool_annotations(tool_name)) + + def create_server( *, config_path: str | None = None, @@ -97,6 +104,8 @@ def create_server( @mcp.tool( name="get_context_bundle", + title=tool_title("get_context_bundle"), + annotations=_tool_hints("get_context_bundle"), description=( "Return the full read-only ContextBundle JSON: portfolio holdings, " "market, sentiment, macro, regime hints, and delta vs the prior saved " @@ -133,6 +142,8 @@ def get_context_bundle( @mcp.tool( name="get_market_context", + title=tool_title("get_market_context"), + annotations=_tool_hints("get_market_context"), description=( "Return read-only fused market backdrop: sentiment (Fear & Greed, " "Kalshi), macro events, FRED indicators, ETF flows, and market breadth " @@ -163,6 +174,8 @@ def get_market_context( @mcp.tool( name="get_rebalance_plan", + title=tool_title("get_rebalance_plan"), + annotations=_tool_hints("get_rebalance_plan"), description=( "Compute read-only USD deltas and suggested exchange move lines to " "reach a BTC/ETH/CASH target split. Pure math — no exchange API calls. " @@ -192,6 +205,8 @@ def get_rebalance_plan( @mcp.tool( name="get_portfolio_state", + title=tool_title("get_portfolio_state"), + annotations=_tool_hints("get_portfolio_state"), description=( "Fetch live read-only portfolio NAV, holdings[], and band weights from " "Kraken or Coinbase credentials passed in this call (never stored). " @@ -221,6 +236,8 @@ def get_portfolio_state( @mcp.tool( name="check_allocation_band", + title=tool_title("check_allocation_band"), + annotations=_tool_hints("check_allocation_band"), description=( "Read-only drift check: are BTC/ETH/CASH band weights outside the " "drift band vs target_pct? Returns rebalance_hint (within_band, " @@ -241,6 +258,8 @@ def check_allocation_band( @mcp.tool( name="get_context_at", + title=tool_title("get_context_at"), + annotations=_tool_hints("get_context_at"), description=( "Load a read-only ContextBundle snapshot from ingest history at a " "point in time. Use get_context_bundle for the latest snapshot; use " @@ -277,6 +296,8 @@ def get_context_at( @mcp.tool( name="get_context_delta", + title=tool_title("get_context_delta"), + annotations=_tool_hints("get_context_delta"), description=( "Compare two read-only ContextBundle snapshots and return " "notable_shifts between them. Requires prior_as_of; omit current_as_of " @@ -307,6 +328,8 @@ def get_context_delta( @mcp.tool( name="check_allocation_bands", + title=tool_title("check_allocation_bands"), + annotations=_tool_hints("check_allocation_bands"), description=( "Read-only batch drift check: evaluate allocation_pct against multiple " "target_pct/band scenarios in one call. Each scenario requires " diff --git a/alloccontext/mcp/tool_catalog.py b/alloccontext/mcp/tool_catalog.py new file mode 100644 index 0000000..9ebf751 --- /dev/null +++ b/alloccontext/mcp/tool_catalog.py @@ -0,0 +1,311 @@ +"""Shared MCP tool metadata for Smithery server cards and FastMCP registration.""" + +from __future__ import annotations + +from typing import Any + +READ_ONLY_ANNOTATIONS: dict[str, bool] = { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, +} + +OPEN_WORLD_READ_ANNOTATIONS: dict[str, bool] = { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True, +} + +_TOOL_TITLES: dict[str, str] = { + "get_market_context": "Get Market Context", + "get_context_bundle": "Get Context Bundle", + "get_rebalance_plan": "Get Rebalance Plan", + "get_portfolio_state": "Get Portfolio State", + "check_allocation_band": "Check Allocation Band", + "get_context_at": "Get Context at Timestamp", + "get_context_delta": "Get Context Delta", + "check_allocation_bands": "Check Allocation Bands", +} + +_OPEN_WORLD_TOOLS = frozenset({"get_portfolio_state", "get_market_context", "get_context_bundle"}) + + +def tool_title(tool_name: str) -> str: + return _TOOL_TITLES.get(tool_name, tool_name.replace("_", " ").title()) + + +def tool_annotations(tool_name: str) -> dict[str, bool]: + if tool_name in _OPEN_WORLD_TOOLS: + return dict(OPEN_WORLD_READ_ANNOTATIONS) + return dict(READ_ONLY_ANNOTATIONS) + + +def pct_key_properties(*, role: str) -> dict[str, dict[str, Any]]: + return { + "BTC": { + "type": "number", + "description": f"{role} Bitcoin weight as a fraction of NAV (0–1).", + }, + "ETH": { + "type": "number", + "description": f"{role} Ethereum weight as a fraction of NAV (0–1).", + }, + "CASH": { + "type": "number", + "description": f"{role} cash/stablecoin weight as a fraction of NAV (0–1).", + }, + } + + +def allocation_pct_schema(*, role: str = "Current") -> dict[str, Any]: + return { + "type": "object", + "description": ( + f"{role} BTC/ETH/CASH band weights; values typically sum to ~1." + ), + "properties": pct_key_properties(role=role), + "required": ["BTC", "ETH", "CASH"], + } + + +SCOPE_SCHEMA: dict[str, Any] = { + "type": "string", + "enum": ["daily", "weekly"], + "description": "Rollup scope: daily (default) or weekly.", +} + +FRESHNESS_SCHEMA: dict[str, Any] = { + "type": "string", + "enum": ["cached", "live"], + "description": ( + "cached: read local ingest DB only. live: run ingest first " + "(requires ingest API keys on the host)." + ), +} + +ASSET_FILTER_SCHEMA: dict[str, Any] = { + "type": "array", + "items": {"type": "string", "enum": ["BTC", "ETH", "CASH"]}, + "description": ( + "Optional asset symbols for market fields (default BTC and ETH), " + "e.g. ['BTC', 'ETH', 'HYPE']." + ), +} + +TARGET_PCT_SCHEMA: dict[str, Any] = { + "type": "object", + "description": ( + "Optional target weights; when set, attaches allocation_analysis " + "drift math to the response." + ), + "properties": pct_key_properties(role="Target"), + "required": ["BTC", "ETH", "CASH"], +} + +OPTIONAL_TARGET_PCT_SCHEMA: dict[str, Any] = { + "type": "object", + "description": ( + "Optional target weights; when set, attaches allocation_analysis " + "to the portfolio response." + ), + "properties": pct_key_properties(role="Target"), +} + +BAND_SCHEMA: dict[str, Any] = { + "type": "number", + "description": "Drift band width as a fraction (e.g. 0.15 = 15% outside target).", +} + +MATCH_SCHEMA: dict[str, Any] = { + "type": "string", + "enum": ["exact", "at_or_before"], + "description": ( + "exact: snapshot at as_of only. at_or_before (default): latest " + "snapshot on or before as_of." + ), +} + +AS_OF_SCHEMA: dict[str, Any] = { + "type": "string", + "description": "ISO-8601 timestamp for the snapshot to load.", +} + +PRIOR_AS_OF_SCHEMA: dict[str, Any] = { + "type": "string", + "description": "ISO-8601 timestamp of the earlier snapshot (required).", +} + +CURRENT_AS_OF_SCHEMA: dict[str, Any] = { + "type": "string", + "description": ( + "ISO-8601 timestamp of the later snapshot; omit for latest live bundle." + ), +} + +SCENARIOS_SCHEMA: dict[str, Any] = { + "type": "array", + "description": ( + "Scenario objects, each with required target_pct and optional name " + "and band (default 0.15)." + ), + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Optional label for this scenario (e.g. base, conservative).", + }, + "target_pct": { + "type": "object", + "description": "Target weights for this scenario.", + "properties": pct_key_properties(role="Target"), + "required": ["BTC", "ETH", "CASH"], + }, + "band": BAND_SCHEMA, + }, + "required": ["target_pct"], + }, +} + + +def output_schema_from_example(example: dict[str, Any]) -> dict[str, Any]: + properties: dict[str, Any] = {} + for key, value in example.items(): + if isinstance(value, bool): + prop_type: str | list[str] = "boolean" + elif isinstance(value, int): + prop_type = "integer" + elif isinstance(value, float): + prop_type = "number" + elif isinstance(value, str): + prop_type = "string" + elif isinstance(value, list): + prop_type = "array" + elif isinstance(value, dict): + prop_type = "object" + else: + prop_type = "string" + properties[key] = {"type": prop_type} + return { + "type": "object", + "description": "Deterministic JSON tool result from AllocContext.", + "properties": properties, + "additionalProperties": True, + } + + +def server_card_tool_entry(spec: dict[str, Any]) -> dict[str, Any]: + tool_name = spec["tool_name"] + return { + "name": tool_name, + "title": tool_title(tool_name), + "description": spec["description"], + "inputSchema": spec["input_schema"], + "outputSchema": output_schema_from_example(spec["output_example"]), + "annotations": tool_annotations(tool_name), + } + + +MCP_SERVER_PROMPTS: tuple[dict[str, Any], ...] = ( + { + "name": "portfolio_context_review", + "description": ( + "Load the latest portfolio-aware crypto context bundle, then " + "summarize holdings, market sentiment, and notable regime shifts " + "for the user." + ), + "arguments": [ + { + "name": "scope", + "description": "Rollup scope: daily or weekly.", + "required": False, + } + ], + }, + { + "name": "allocation_drift_check", + "description": ( + "Compare current BTC/ETH/CASH weights to a target allocation and " + "report whether the portfolio is outside the drift band." + ), + "arguments": [ + { + "name": "band", + "description": "Drift band width as a fraction (default 0.15).", + "required": False, + } + ], + }, + { + "name": "rebalance_planning", + "description": ( + "Given current allocation weights and NAV, compute USD deltas and " + "suggested exchange move lines toward a target split." + ), + "arguments": [ + { + "name": "exchange", + "description": "kraken or coinbase — affects move wording only.", + "required": False, + } + ], + }, + { + "name": "context_time_travel", + "description": ( + "Load a historical ContextBundle at a timestamp and diff it " + "against the latest snapshot to highlight notable shifts." + ), + "arguments": [ + { + "name": "prior_as_of", + "description": "ISO-8601 timestamp of the earlier snapshot.", + "required": True, + } + ], + }, +) + +MCP_SERVER_RESOURCES: tuple[dict[str, Any], ...] = ( + { + "uri": "context-bundle://schema/v2", + "name": "ContextBundle schema v2", + "description": "JSON Schema for portfolio-first ContextBundle responses.", + "mimeType": "application/schema+json", + }, + { + "uri": "alloc-context://tools/rebalance-hints", + "name": "Rebalance hint guide", + "description": "Codes returned in allocation_analysis and band_check hints.", + "mimeType": "text/markdown", + }, +) + + +def assert_input_schema_descriptions(schema: dict[str, Any], *, tool_name: str) -> None: + """Raise AssertionError when any input property lacks a description.""" + props = schema.get("properties") or {} + for name, spec in props.items(): + if not spec.get("description"): + msg = f"{tool_name}.{name} missing inputSchema description" + raise AssertionError(msg) + nested = spec.get("properties") or {} + for nested_name, nested_spec in nested.items(): + if not nested_spec.get("description"): + msg = ( + f"{tool_name}.{name}.{nested_name} missing " + "inputSchema description" + ) + raise AssertionError(msg) + items = spec.get("items") + if isinstance(items, dict): + item_props = items.get("properties") or {} + for item_name, item_spec in item_props.items(): + if not item_spec.get("description"): + msg = ( + f"{tool_name}.{name}[].{item_name} missing " + "inputSchema description" + ) + raise AssertionError(msg) diff --git a/pyproject.toml b/pyproject.toml index 659e580..7c4f4c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ dependencies = [ ] [project.urls] -Homepage = "https://github.com/negillett/alloc-context" +Homepage = "https://mcp.alloc-context.com/llms.txt" Documentation = "https://github.com/negillett/alloc-context/blob/main/docs/agent-integration.md" Repository = "https://github.com/negillett/alloc-context" Issues = "https://github.com/negillett/alloc-context/issues" diff --git a/tests/test_mcp_bazaar.py b/tests/test_mcp_bazaar.py index 6564e18..5065302 100644 --- a/tests/test_mcp_bazaar.py +++ b/tests/test_mcp_bazaar.py @@ -131,6 +131,12 @@ def test_build_mcp_server_card_lists_tools() -> None: assert card["authentication"]["required"] is True names = {tool["name"] for tool in card["tools"]} assert names == _EXPECTED_TOOLS + first = card["tools"][0] + assert first.get("title") + assert first.get("annotations") + assert first.get("outputSchema") + assert card["prompts"] + assert card["resources"] def test_mcp_server_card_route(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/test_tool_catalog.py b/tests/test_tool_catalog.py new file mode 100644 index 0000000..cd7c156 --- /dev/null +++ b/tests/test_tool_catalog.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import pytest + +from alloccontext.mcp.bazaar import build_mcp_server_card, mcp_tool_specs +from alloccontext.mcp.tool_catalog import ( + MCP_SERVER_PROMPTS, + MCP_SERVER_RESOURCES, + assert_input_schema_descriptions, + tool_annotations, +) + + +def test_bazaar_tool_input_schemas_have_descriptions() -> None: + for spec in mcp_tool_specs(): + assert_input_schema_descriptions( + spec["input_schema"], + tool_name=spec["tool_name"], + ) + + +def test_server_card_tools_include_smithery_metadata() -> None: + card = build_mcp_server_card(version="0.2.1") + assert len(card["tools"]) == 8 + for tool in card["tools"]: + assert tool.get("title") + assert tool.get("description") + assert tool.get("inputSchema") + assert tool.get("outputSchema") + annotations = tool.get("annotations") or {} + assert annotations.get("readOnlyHint") is True + assert annotations.get("destructiveHint") is False + assert "idempotentHint" in annotations + assert "openWorldHint" in annotations + + +def test_server_card_includes_prompts_and_resources() -> None: + card = build_mcp_server_card(version="0.2.1") + assert len(card["prompts"]) == len(MCP_SERVER_PROMPTS) >= 3 + assert len(card["resources"]) == len(MCP_SERVER_RESOURCES) >= 1 + + +def test_portfolio_tool_is_open_world() -> None: + hints = tool_annotations("get_portfolio_state") + assert hints["openWorldHint"] is True + + +def test_mcp_server_tools_expose_titles_and_annotations() -> None: + pytest.importorskip("mcp") + import asyncio + + from alloccontext.mcp.server import create_server + from alloccontext.mcp.tool_catalog import tool_title + + async def _check() -> None: + server = create_server() + tools = await server.list_tools() + assert len(tools) == 8 + for tool in tools: + assert tool.title == tool_title(tool.name) + assert tool.annotations is not None + assert tool.annotations.readOnlyHint is True + assert tool.annotations.destructiveHint is False + + asyncio.run(_check())