Skip to content

Commit 15b5395

Browse files
bokelleyclaude
andauthored
fix: parse list_creative_formats response into structured type (#12)
* fix: parse list_creative_formats response into structured type Problem: Creative agent returns text content instead of structured data - Current: TextContent(text='Found 42 creative formats') - Expected: ListCreativeFormatsResponse(formats=[Format(...), ...]) - Cause: Adapters return raw content without type parsing Solution: - Add response_parser.py with parse_mcp_content() and parse_json_or_text() - Update list_creative_formats() to parse adapter responses - Handle both MCP (content array) and A2A (dict) response formats - Return properly typed ListCreativeFormatsResponse objects - Gracefully handle invalid responses with clear error messages Testing: - Added 12 unit tests for response parser functions - Added 3 integration tests for list_creative_formats parsing - All 83 tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: define FormatId as proper object type per ADCP spec Problem: FormatId was incorrectly defined as a string type alias, but the ADCP spec requires it to be a structured object with agent_url and id fields. Root Cause: The format-id.json schema was missing from the downloaded schemas, causing the type generator to create a placeholder string type. Solution: - Downloaded format-id.json schema from adcontextprotocol.org - Defined FormatId as proper Pydantic model with agent_url and id fields - Updated tests to use correct FormatId structure: {agent_url, id} This ensures all format references use the structured format ID objects as required by the ADCP specification, enabling proper format resolution across different creative agents. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: move response parsing to adapter layer Problem: Response parsing was only implemented for list_creative_formats, leaving all other methods returning unparsed raw data. Solution: Move parsing logic to adapter base class so ALL methods benefit: 1. Added _parse_response() helper in ProtocolAdapter base class - Handles MCP content arrays and A2A dict responses - Validates against expected Pydantic types - Returns properly typed TaskResult 2. Added specific ADCP method declarations in base adapter - get_products, list_creative_formats, sync_creatives, etc. - Default implementations delegate to call_tool() - Keeps adapters simple while enabling type-safe interface 3. Updated client to use specific adapter methods - Calls adapter.list_creative_formats() instead of adapter.call_tool() - Delegates parsing to adapter._parse_response() - Removes duplicate parsing logic from client layer Benefits: - ALL ADCP methods now return properly typed responses - Single parsing implementation shared across all methods - Adapters handle protocol differences (MCP vs A2A) - Client layer stays focused on business logic - Type-safe interface prevents tool name typos 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * remove: delete call_tool generic fallback method Remove the generic call_tool() method from adapters and client. No fallbacks - every ADCP method must be explicitly implemented. Changes: - Removed call_tool() from ProtocolAdapter base class - Renamed internal helpers: call_tool → _call_a2a_tool / _call_mcp_tool - Removed call_tool() from ADCPClient - Updated all tests to mock specific methods instead of call_tool Benefits: - Forces explicit implementation of every ADCP protocol method - No magic "it might work" fallbacks that hide bugs - Clear contract: adapters MUST implement all 9 ADCP methods - Type-safe: impossible to typo a tool name - Better tooling: IDE autocomplete knows all methods 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: address critical code review issues Fixed 3 critical issues identified in code review: 1. FormatId Validation (CRITICAL) - Added field_validator to enforce regex pattern ^[a-zA-Z0-9_-]+$ - Pattern parameter alone doesn't enforce validation in Pydantic v2 - Added 9 comprehensive validation tests - Prevents invalid format IDs with spaces, special chars, unicode 2. Inconsistent Response Parsing (MAJOR BUG) - Applied _parse_response() to ALL 9 client methods, not just one - Methods now return properly typed TaskResult[SpecificResponse] - Ensures MCP content arrays are parsed into structured objects - Consistent behavior across all ADCP protocol methods 3. Test Data Validation - Updated test_get_products to mock parsing separately - Verifies parsing is called with correct response types - All 92 tests pass (83 original + 9 new validation tests) Impact: - Type safety actually enforced, not just declared - All responses properly parsed regardless of protocol (MCP/A2A) - Invalid data caught at validation layer - Consistent client behavior across all methods 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: auto-generate FormatId with validation in schema generation Problem: CI failed because generated.py was manually edited but marked as auto-generated. Schema validation check detected drift. Root Cause: - format-id.json schema was downloaded but not included in generation - Generator hardcoded FormatId = str as fallback - Manual edits violated "DO NOT EDIT" contract Solution: 1. Add format-id.json to core_types list in generator 2. Remove hardcoded FormatId = str fallback 3. Add add_format_id_validation() post-processor 4. Auto-inject field_validator for pattern enforcement 5. Import re and field_validator in generated code Result: - generated.py now properly auto-generated with validation - CI schema validation will pass - FormatId validation maintained - No manual edits required 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: normalize format-id.json formatting for CI CI schema validation expects specific JSON formatting: - Multiline "required" array - Trailing newline at end of file Ran scripts/fix_schema_refs.py to normalize formatting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unused imports to pass linter CI linter detected unused imports: - TaskStatus in src/adcp/client.py (leftover from refactoring) - parse_mcp_content in src/adcp/protocols/mcp.py (unused after moving parsing to base) Removed both unused imports. All tests still pass (92/92). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: apply linting and type checking fixes after local validation - Auto-fix import ordering and remove unused imports - Fix unused type:ignore comment in base.py - Replace removed call_tool method in CLI with explicit dispatch - Add _dispatch_tool helper with if/elif chain for mypy compatibility - Fix line length issues (E501) in __main__.py This commit demonstrates the lesson learned from the debugger analysis: always run full validation (format + lint + typecheck + test) before pushing to avoid sequential fix commits. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: restore inline Field formatting in generated.py for CI The CI check-schema-drift target regenerates models without running black formatter, so generated.py should remain in the inline format that the generator outputs. Running `make format` reformats Field definitions to multiline, causing CI drift detection to fail. This commit reverts the black formatting of generated.py to match what `make regenerate-schemas` produces. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: exclude generated.py from black formatting to prevent CI drift Critical fix addressing code review feedback: 1. Add extend-exclude pattern to [tool.black] in pyproject.toml - Prevents black from reformatting generated.py - Uses regex pattern: /(generated|tasks)\.py$ 2. Update Makefile format target documentation - Clarifies that generated files are excluded 3. Format __main__.py (black auto-fix) This prevents the schema drift CI failures that occur when: - Developer runs `make format` (includes black) - Black reformats generated.py Field definitions (multiline) - CI runs `make check-schema-drift` (regenerates without formatting) - Generator outputs inline format - CI detects drift and fails With this fix, black will skip generated.py entirely, keeping it in the inline format that the generator produces. Resolves: Code Review Critical Issue #2 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: implement code review suggestions for maintainability Addresses remaining code review feedback from the reviewer agent: 1. CLI Dispatch Refactor (Suggestion #3) - Replace if/elif chain with dict-based TOOL_DISPATCH mapping - Single source of truth for available tools - Lazy initialization of request types to avoid circular imports - Easier to maintain and extend 2. Pydantic Validation Error Handling (Edge Case #6) - Catch ValidationError in _dispatch_tool() - Return user-friendly error messages showing field-level issues - Format: "Invalid request payload for {tool}:\n - field: message" 3. Response Parser Error Context (Suggestion #4) - Add content preview to parse_mcp_content() error messages - Include first 2 items, max 500 chars for debugging - Helps diagnose real-world parsing failures 4. Adapter Response Parsing Edge Case (Edge Case #7) - Fix _parse_response() to explicitly construct TaskResult[T] - Handle success=False or data=None without type: ignore - Provide clear error message when data is missing Benefits: - Maintainability: CLI tool list in one place, easier to add new ADCP methods - User Experience: Clear validation errors instead of Python tracebacks - Debuggability: Content preview helps diagnose parsing issues - Type Safety: Proper typed TaskResult construction without suppressions All tests pass (92/92), linting and type checking clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: implement high-priority protocol expert recommendations Addresses protocol expert feedback for production readiness: 1. **Webhook Response Typing** (High Priority #1) - handle_webhook() now returns TaskResult[Any] instead of None - Added _parse_webhook_result() helper method - Maps webhook task_type to response types for type-safe parsing - Validates WebhookPayload schema with Pydantic - Example usage: ```python result = await client.handle_webhook(payload, signature) if result.success and isinstance(result.data, GetProductsResponse): print(f"Found {len(result.data.products)} products") ``` 2. **ProtocolEnvelope Type** (High Priority #2) - Already auto-generated from schema (protocol-envelope.json) - Includes: context_id, task_id, status, message, timestamp, payload - Used for async operation responses - No code changes needed - type already exists 3. **Format Caching Documentation** (High Priority #3) - Added comprehensive caching guidance to CLAUDE.md - Documented TTL-based and LRU cache strategies - Explained cache invalidation options - Provided code examples for production implementations Benefits: - Type-safe webhook handling enables better error detection - Structured webhook responses integrate with existing TaskResult pattern - Production developers have clear caching guidance - All async workflows now properly typed All tests pass (92/92), linting and type checking clean. Resolves: Protocol Expert High Priority Issues #1, #2, #3 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a75d9f9 commit 15b5395

20 files changed

+1505
-277
lines changed

CLAUDE.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,50 @@ async def _get_client(self) -> httpx.AsyncClient:
9595
return self._client
9696
```
9797

98+
**Format Definition Caching (Production Consideration)**
99+
100+
Creative formats rarely change, so production implementations should cache format definitions to avoid redundant `list_creative_formats` calls:
101+
102+
```python
103+
from functools import lru_cache
104+
105+
# Option 1: Simple LRU cache for format lookups
106+
@lru_cache(maxsize=100)
107+
async def get_format_definition(agent_url: str, format_id: str) -> Format:
108+
"""Cached format definition lookup."""
109+
agent = get_agent_for_url(agent_url)
110+
result = await agent.list_creative_formats()
111+
for fmt in result.data.formats:
112+
if fmt.format_id.id == format_id:
113+
return fmt
114+
raise ValueError(f"Format not found: {format_id}")
115+
116+
# Option 2: TTL-based cache with periodic refresh
117+
class FormatCache:
118+
def __init__(self, ttl_seconds: int = 3600):
119+
self._cache: dict[tuple[str, str], tuple[Format, float]] = {}
120+
self._ttl = ttl_seconds
121+
122+
async def get_format(self, agent_url: str, format_id: str) -> Format:
123+
"""Get format with TTL-based caching."""
124+
key = (agent_url, format_id)
125+
if key in self._cache:
126+
fmt, timestamp = self._cache[key]
127+
if time.time() - timestamp < self._ttl:
128+
return fmt
129+
130+
# Cache miss or expired - fetch fresh
131+
fmt = await self._fetch_format(agent_url, format_id)
132+
self._cache[key] = (fmt, time.time())
133+
return fmt
134+
```
135+
136+
**Cache Invalidation Strategies:**
137+
1. **TTL-based** (recommended): Formats rarely change, 1-hour TTL is reasonable
138+
2. **Version-based**: Use format version in ID (e.g., `banner_300x250_v2`)
139+
3. **ETags**: Check `Last-Modified` headers if agents support it
140+
4. **Explicit invalidation**: Clear cache when format changes detected
141+
98142
## Common Pitfalls to Avoid
99143

100144
**String Escaping in Code Generation**

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ help: ## Show this help message
1717
install-dev: ## Install package in development mode with dev dependencies
1818
$(PIP) install -e ".[dev]"
1919

20-
format: ## Format code with black
20+
format: ## Format code with black (excludes generated files)
2121
$(BLACK) src/ tests/ scripts/
22-
@echo "✓ Code formatted successfully"
22+
@echo "✓ Code formatted successfully (generated.py excluded via pyproject.toml)"
2323

2424
lint: ## Run linter (ruff) on source code
2525
$(RUFF) check src/ tests/

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ where = ["src"]
5858
[tool.black]
5959
line-length = 100
6060
target-version = ["py310", "py311", "py312"]
61+
extend-exclude = "/(generated|tasks)\\.py$"
6162

6263
[tool.ruff]
6364
line-length = 100

schemas/cache/1.0.0/format-id.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"$id": "/schemas/v1/core/format-id.json",
4+
"title": "Format ID",
5+
"description": "Structured format identifier with agent URL and format name",
6+
"type": "object",
7+
"properties": {
8+
"agent_url": {
9+
"type": "string",
10+
"format": "uri",
11+
"description": "URL of the agent that defines this format (e.g., 'https://creatives.adcontextprotocol.org' for standard formats, or 'https://publisher.com/.well-known/adcp/sales' for custom formats)"
12+
},
13+
"id": {
14+
"type": "string",
15+
"pattern": "^[a-zA-Z0-9_-]+$",
16+
"description": "Format identifier within the agent's namespace (e.g., 'display_300x250', 'video_standard_30s')"
17+
}
18+
},
19+
"required": [
20+
"agent_url",
21+
"id"
22+
],
23+
"additionalProperties": false
24+
}

scripts/generate_models.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,23 @@ def main():
3030
# Generate models using datamodel-code-generator
3131
cmd = [
3232
"datamodel-codegen",
33-
"--input", str(SCHEMAS_DIR),
34-
"--input-file-type", "jsonschema",
35-
"--output", str(OUTPUT_FILE),
36-
"--output-model-type", "pydantic_v2.BaseModel",
33+
"--input",
34+
str(SCHEMAS_DIR),
35+
"--input-file-type",
36+
"jsonschema",
37+
"--output",
38+
str(OUTPUT_FILE),
39+
"--output-model-type",
40+
"pydantic_v2.BaseModel",
3741
"--use-standard-collections",
3842
"--use-schema-description",
3943
"--use-field-description",
4044
"--field-constraints",
4145
"--use-default",
42-
"--enum-field-as-literal", "all",
43-
"--target-python-version", "3.10",
46+
"--enum-field-as-literal",
47+
"all",
48+
"--target-python-version",
49+
"3.10",
4450
"--collapse-root-models",
4551
"--allow-extra-fields",
4652
"--enable-version-header",

scripts/generate_models_simple.py

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ def generate_model_for_schema(schema_file: Path) -> str:
8181
lines = [f"# Type alias for {schema.get('title', model_name)}"]
8282
if "description" in schema:
8383
desc = escape_string_for_python(schema["description"])
84-
lines.append(f'# {desc}')
84+
lines.append(f"# {desc}")
8585
lines.append(f"{model_name} = {python_type}")
8686
return "\n".join(lines)
8787

@@ -223,6 +223,70 @@ def validate_imports(output_file: Path) -> tuple[bool, str]:
223223
return False, f"Import validation error: {e}"
224224

225225

226+
def add_format_id_validation(code: str) -> str:
227+
"""
228+
Add field validator to FormatId class for pattern enforcement.
229+
230+
The format-id.json schema specifies a pattern, but Pydantic v2 requires
231+
explicit field_validator to enforce it.
232+
"""
233+
# Find the FormatId class - match the class and find where to insert
234+
# Look for the pattern: class FormatId(...): ... id: str = Field(...)
235+
# Then add the validator after the last field
236+
237+
lines = code.split("\n")
238+
result_lines = []
239+
in_format_id = False
240+
found_id_field = False
241+
indent = ""
242+
243+
for i, line in enumerate(lines):
244+
result_lines.append(line)
245+
246+
# Detect start of FormatId class
247+
if "class FormatId(BaseModel):" in line:
248+
in_format_id = True
249+
# Detect indent level (usually 4 spaces)
250+
if i + 1 < len(lines):
251+
next_line = lines[i + 1]
252+
indent = next_line[: len(next_line) - len(next_line.lstrip())]
253+
254+
# Detect the id field in FormatId class
255+
if in_format_id and line.strip().startswith("id: str"):
256+
found_id_field = True
257+
258+
# After the id field, add the validator
259+
if (
260+
in_format_id
261+
and found_id_field
262+
and (
263+
line.strip() == ""
264+
or (
265+
i + 1 < len(lines)
266+
and not lines[i + 1].strip().startswith(("agent_url:", "id:"))
267+
)
268+
)
269+
):
270+
# Add validator here
271+
validator_lines = [
272+
"",
273+
f'{indent}@field_validator("id")',
274+
f"{indent}@classmethod",
275+
f"{indent}def validate_id_pattern(cls, v: str) -> str:",
276+
f'{indent} """Validate format ID contains only alphanumeric characters, hyphens, and underscores."""',
277+
f'{indent} if not re.match(r"^[a-zA-Z0-9_-]+$", v):',
278+
f"{indent} raise ValueError(",
279+
f'{indent} f"Invalid format ID: {{v!r}}. Must contain only alphanumeric characters, hyphens, and underscores"',
280+
f"{indent} )",
281+
f"{indent} return v",
282+
]
283+
result_lines.extend(validator_lines)
284+
in_format_id = False
285+
found_id_field = False
286+
287+
return "\n".join(result_lines)
288+
289+
226290
def main():
227291
"""Generate models for core types and task request/response schemas."""
228292
if not SCHEMAS_DIR.exists():
@@ -233,6 +297,7 @@ def main():
233297

234298
# Core domain types that are referenced by task schemas
235299
core_types = [
300+
"format-id.json", # Must come before format.json (which references it)
236301
"product.json",
237302
"media-buy.json",
238303
"package.json",
@@ -294,9 +359,10 @@ def main():
294359
"",
295360
"from __future__ import annotations",
296361
"",
362+
"import re",
297363
"from typing import Any, Literal",
298364
"",
299-
"from pydantic import BaseModel, Field",
365+
"from pydantic import BaseModel, Field, field_validator",
300366
"",
301367
"",
302368
"# ============================================================================",
@@ -305,7 +371,6 @@ def main():
305371
"",
306372
"# These types are referenced in schemas but don't have schema files",
307373
"# Defining them as type aliases to maintain type safety",
308-
"FormatId = str",
309374
"PackageRequest = dict[str, Any]",
310375
"PushNotificationConfig = dict[str, Any]",
311376
"ReportingCapabilities = dict[str, Any]",
@@ -353,6 +418,9 @@ def main():
353418
# Join all lines into final code
354419
generated_code = "\n".join(output_lines)
355420

421+
# Add custom validation for FormatId
422+
generated_code = add_format_id_validation(generated_code)
423+
356424
# Validate syntax before writing
357425
print("\nValidating generated code...")
358426
is_valid, error_msg = validate_python_syntax(generated_code, "generated.py")

src/adcp/__main__.py

Lines changed: 107 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,15 @@ def print_result(result: Any, json_output: bool = False) -> None:
3636
"data": result.data,
3737
"error": result.error,
3838
"metadata": result.metadata,
39-
"debug_info": {
40-
"request": result.debug_info.request,
41-
"response": result.debug_info.response,
42-
"duration_ms": result.debug_info.duration_ms,
43-
}
44-
if result.debug_info
45-
else None,
39+
"debug_info": (
40+
{
41+
"request": result.debug_info.request,
42+
"response": result.debug_info.response,
43+
"duration_ms": result.debug_info.duration_ms,
44+
}
45+
if result.debug_info
46+
else None
47+
),
4648
}
4749
)
4850
else:
@@ -73,10 +75,107 @@ async def execute_tool(
7375
config = AgentConfig(**agent_config)
7476

7577
async with ADCPClient(config) as client:
76-
result = await client.call_tool(tool_name, payload)
78+
# Dispatch to specific method based on tool name
79+
result = await _dispatch_tool(client, tool_name, payload)
7780
print_result(result, json_output)
7881

7982

83+
# Tool dispatch mapping - single source of truth for ADCP methods
84+
# Types are filled at runtime to avoid circular imports
85+
TOOL_DISPATCH: dict[str, tuple[str, type | None]] = {
86+
"get_products": ("get_products", None),
87+
"list_creative_formats": ("list_creative_formats", None),
88+
"sync_creatives": ("sync_creatives", None),
89+
"list_creatives": ("list_creatives", None),
90+
"get_media_buy_delivery": ("get_media_buy_delivery", None),
91+
"list_authorized_properties": ("list_authorized_properties", None),
92+
"get_signals": ("get_signals", None),
93+
"activate_signal": ("activate_signal", None),
94+
"provide_performance_feedback": ("provide_performance_feedback", None),
95+
}
96+
97+
98+
async def _dispatch_tool(client: ADCPClient, tool_name: str, payload: dict[str, Any]) -> Any:
99+
"""Dispatch tool call to appropriate client method.
100+
101+
Args:
102+
client: ADCP client instance
103+
tool_name: Name of the tool to invoke
104+
payload: Request payload as dict
105+
106+
Returns:
107+
TaskResult with typed response or error
108+
109+
Raises:
110+
ValidationError: If payload doesn't match request schema (caught and returned as TaskResult)
111+
"""
112+
from pydantic import ValidationError
113+
114+
from adcp.types import generated as gen
115+
from adcp.types.core import TaskResult, TaskStatus
116+
117+
# Lazy initialization of request types (avoid circular imports)
118+
if TOOL_DISPATCH["get_products"][1] is None:
119+
TOOL_DISPATCH["get_products"] = ("get_products", gen.GetProductsRequest)
120+
TOOL_DISPATCH["list_creative_formats"] = (
121+
"list_creative_formats",
122+
gen.ListCreativeFormatsRequest,
123+
)
124+
TOOL_DISPATCH["sync_creatives"] = ("sync_creatives", gen.SyncCreativesRequest)
125+
TOOL_DISPATCH["list_creatives"] = ("list_creatives", gen.ListCreativesRequest)
126+
TOOL_DISPATCH["get_media_buy_delivery"] = (
127+
"get_media_buy_delivery",
128+
gen.GetMediaBuyDeliveryRequest,
129+
)
130+
TOOL_DISPATCH["list_authorized_properties"] = (
131+
"list_authorized_properties",
132+
gen.ListAuthorizedPropertiesRequest,
133+
)
134+
TOOL_DISPATCH["get_signals"] = ("get_signals", gen.GetSignalsRequest)
135+
TOOL_DISPATCH["activate_signal"] = ("activate_signal", gen.ActivateSignalRequest)
136+
TOOL_DISPATCH["provide_performance_feedback"] = (
137+
"provide_performance_feedback",
138+
gen.ProvidePerformanceFeedbackRequest,
139+
)
140+
141+
# Check if tool exists
142+
if tool_name not in TOOL_DISPATCH:
143+
available = ", ".join(sorted(TOOL_DISPATCH.keys()))
144+
return TaskResult(
145+
status=TaskStatus.FAILED,
146+
error=f"Unknown tool: {tool_name}. Available tools: {available}",
147+
)
148+
149+
# Get method and request type
150+
method_name, request_type = TOOL_DISPATCH[tool_name]
151+
152+
# Type guard - request_type should be initialized by this point
153+
if request_type is None:
154+
return TaskResult(
155+
status=TaskStatus.FAILED,
156+
error=f"Internal error: {tool_name} request type not initialized",
157+
)
158+
159+
method = getattr(client, method_name)
160+
161+
# Validate and invoke
162+
try:
163+
request = request_type(**payload)
164+
return await method(request)
165+
except ValidationError as e:
166+
# User-friendly error for invalid payloads
167+
error_details = []
168+
for error in e.errors():
169+
field = ".".join(str(loc) for loc in error["loc"])
170+
msg = error["msg"]
171+
error_details.append(f" - {field}: {msg}")
172+
173+
return TaskResult(
174+
status=TaskStatus.FAILED,
175+
error=f"Invalid request payload for {tool_name}:\n" + "\n".join(error_details),
176+
)
177+
178+
80179
def load_payload(payload_arg: str | None) -> dict[str, Any]:
81180
"""Load payload from argument (JSON, @file, or stdin)."""
82181
if not payload_arg:

0 commit comments

Comments
 (0)