Skip to content

Commit 82db20c

Browse files
committed
refactor: replace ValidationOptions with validate_structured_outputs boolean parameter per PR feedback
1 parent 7722e38 commit 82db20c

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

85 files changed

+13465
-30
lines changed

PR_DESCRIPTION.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Fix: Propagate HTTP errors to client
2+
3+
## Motivation and Context
4+
HTTP request failures were not being properly communicated to clients, causing silent failures. This fix ensures errors are sent through the read stream so clients are notified when requests fail.
5+
6+
## How Has This Been Tested?
7+
Has been tested in production environments (used by https://github.com/hud-evals/hud-python with a remote server).
8+
9+
## Breaking Changes
10+
None - this is a backwards-compatible error handling improvement.
11+
12+
## Types of changes
13+
- [x] Bug fix (non-breaking change which fixes an issue)
14+
15+
## Checklist
16+
- [x] I have read the [MCP Documentation](https://modelcontextprotocol.io)
17+
- [x] My code follows the repository's style guidelines
18+
- [x] New and existing tests pass locally
19+
- [x] I have added appropriate error handling
20+
- [x] Documentation not needed for this internal fix
21+
22+
## Additional context
23+
This change wraps the `handle_request_async` function in a try-catch block and sends any exceptions to `ctx.read_stream_writer` to ensure proper error propagation in the streamable HTTP transport layer.
24+
25+
### Example scenario where this fix is critical:
26+
27+
**HTTP Error Codes (502, 503, 504, etc.)**
28+
```python
29+
# Without this fix: Client hangs when server returns 502 Bad Gateway
30+
# With this fix: Client receives the HTTP error
31+
from mcp.client.streamable_http import streamablehttp_client
32+
from mcp.client.session import ClientSession
33+
34+
async with streamablehttp_client(server_url) as (read_stream, write_stream):
35+
async with ClientSession(read_stream, write_stream) as session:
36+
await session.initialize()
37+
try:
38+
result = await session.call_tool("api_operation", arguments={})
39+
except Exception as e:
40+
print(f"Error received: {e}") # Now properly catches 502, 503, 504 errors
41+
```

src/hud_mcp/__init__.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
from .client.session import ClientSession
2+
from .client.session_group import ClientSessionGroup
3+
from .client.stdio import StdioServerParameters, stdio_client
4+
from .server.session import ServerSession
5+
from .server.stdio import stdio_server
6+
from .shared.exceptions import McpError
7+
from .types import (
8+
CallToolRequest,
9+
ClientCapabilities,
10+
ClientNotification,
11+
ClientRequest,
12+
ClientResult,
13+
CompleteRequest,
14+
CreateMessageRequest,
15+
CreateMessageResult,
16+
ErrorData,
17+
GetPromptRequest,
18+
GetPromptResult,
19+
Implementation,
20+
IncludeContext,
21+
InitializedNotification,
22+
InitializeRequest,
23+
InitializeResult,
24+
JSONRPCError,
25+
JSONRPCRequest,
26+
JSONRPCResponse,
27+
ListPromptsRequest,
28+
ListPromptsResult,
29+
ListResourcesRequest,
30+
ListResourcesResult,
31+
ListToolsResult,
32+
LoggingLevel,
33+
LoggingMessageNotification,
34+
Notification,
35+
PingRequest,
36+
ProgressNotification,
37+
PromptsCapability,
38+
ReadResourceRequest,
39+
ReadResourceResult,
40+
Resource,
41+
ResourcesCapability,
42+
ResourceUpdatedNotification,
43+
RootsCapability,
44+
SamplingMessage,
45+
ServerCapabilities,
46+
ServerNotification,
47+
ServerRequest,
48+
ServerResult,
49+
SetLevelRequest,
50+
StopReason,
51+
SubscribeRequest,
52+
Tool,
53+
ToolsCapability,
54+
UnsubscribeRequest,
55+
)
56+
from .types import (
57+
Role as SamplingRole,
58+
)
59+
60+
__all__ = [
61+
"CallToolRequest",
62+
"ClientCapabilities",
63+
"ClientNotification",
64+
"ClientRequest",
65+
"ClientResult",
66+
"ClientSession",
67+
"ClientSessionGroup",
68+
"CreateMessageRequest",
69+
"CreateMessageResult",
70+
"ErrorData",
71+
"GetPromptRequest",
72+
"GetPromptResult",
73+
"Implementation",
74+
"IncludeContext",
75+
"InitializeRequest",
76+
"InitializeResult",
77+
"InitializedNotification",
78+
"JSONRPCError",
79+
"JSONRPCRequest",
80+
"ListPromptsRequest",
81+
"ListPromptsResult",
82+
"ListResourcesRequest",
83+
"ListResourcesResult",
84+
"ListToolsResult",
85+
"LoggingLevel",
86+
"LoggingMessageNotification",
87+
"McpError",
88+
"Notification",
89+
"PingRequest",
90+
"ProgressNotification",
91+
"PromptsCapability",
92+
"ReadResourceRequest",
93+
"ReadResourceResult",
94+
"ResourcesCapability",
95+
"ResourceUpdatedNotification",
96+
"Resource",
97+
"RootsCapability",
98+
"SamplingMessage",
99+
"SamplingRole",
100+
"ServerCapabilities",
101+
"ServerNotification",
102+
"ServerRequest",
103+
"ServerResult",
104+
"ServerSession",
105+
"SetLevelRequest",
106+
"StdioServerParameters",
107+
"StopReason",
108+
"SubscribeRequest",
109+
"Tool",
110+
"ToolsCapability",
111+
"UnsubscribeRequest",
112+
"stdio_client",
113+
"stdio_server",
114+
"CompleteRequest",
115+
"JSONRPCResponse",
116+
]

src/hud_mcp/cli/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""FastMCP CLI package."""
2+
3+
from .cli import app
4+
5+
if __name__ == "__main__":
6+
app()

src/hud_mcp/cli/claude.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"""Claude app integration utilities."""
2+
3+
import json
4+
import os
5+
import shutil
6+
import sys
7+
from pathlib import Path
8+
from typing import Any
9+
10+
from mcp.server.fastmcp.utilities.logging import get_logger
11+
12+
logger = get_logger(__name__)
13+
14+
MCP_PACKAGE = "mcp[cli]"
15+
16+
17+
def get_claude_config_path() -> Path | None:
18+
"""Get the Claude config directory based on platform."""
19+
if sys.platform == "win32":
20+
path = Path(Path.home(), "AppData", "Roaming", "Claude")
21+
elif sys.platform == "darwin":
22+
path = Path(Path.home(), "Library", "Application Support", "Claude")
23+
elif sys.platform.startswith("linux"):
24+
path = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"), "Claude")
25+
else:
26+
return None
27+
28+
if path.exists():
29+
return path
30+
return None
31+
32+
33+
def get_uv_path() -> str:
34+
"""Get the full path to the uv executable."""
35+
uv_path = shutil.which("uv")
36+
if not uv_path:
37+
logger.error(
38+
"uv executable not found in PATH, falling back to 'uv'. Please ensure uv is installed and in your PATH"
39+
)
40+
return "uv" # Fall back to just "uv" if not found
41+
return uv_path
42+
43+
44+
def update_claude_config(
45+
file_spec: str,
46+
server_name: str,
47+
*,
48+
with_editable: Path | None = None,
49+
with_packages: list[str] | None = None,
50+
env_vars: dict[str, str] | None = None,
51+
) -> bool:
52+
"""Add or update a FastMCP server in Claude's configuration.
53+
54+
Args:
55+
file_spec: Path to the server file, optionally with :object suffix
56+
server_name: Name for the server in Claude's config
57+
with_editable: Optional directory to install in editable mode
58+
with_packages: Optional list of additional packages to install
59+
env_vars: Optional dictionary of environment variables. These are merged with
60+
any existing variables, with new values taking precedence.
61+
62+
Raises:
63+
RuntimeError: If Claude Desktop's config directory is not found, indicating
64+
Claude Desktop may not be installed or properly set up.
65+
"""
66+
config_dir = get_claude_config_path()
67+
uv_path = get_uv_path()
68+
if not config_dir:
69+
raise RuntimeError(
70+
"Claude Desktop config directory not found. Please ensure Claude Desktop"
71+
" is installed and has been run at least once to initialize its config."
72+
)
73+
74+
config_file = config_dir / "claude_desktop_config.json"
75+
if not config_file.exists():
76+
try:
77+
config_file.write_text("{}")
78+
except Exception:
79+
logger.exception(
80+
"Failed to create Claude config file",
81+
extra={
82+
"config_file": str(config_file),
83+
},
84+
)
85+
return False
86+
87+
try:
88+
config = json.loads(config_file.read_text())
89+
if "mcpServers" not in config:
90+
config["mcpServers"] = {}
91+
92+
# Always preserve existing env vars and merge with new ones
93+
if server_name in config["mcpServers"] and "env" in config["mcpServers"][server_name]:
94+
existing_env = config["mcpServers"][server_name]["env"]
95+
if env_vars:
96+
# New vars take precedence over existing ones
97+
env_vars = {**existing_env, **env_vars}
98+
else:
99+
env_vars = existing_env
100+
101+
# Build uv run command
102+
args = ["run"]
103+
104+
# Collect all packages in a set to deduplicate
105+
packages = {MCP_PACKAGE}
106+
if with_packages:
107+
packages.update(pkg for pkg in with_packages if pkg)
108+
109+
# Add all packages with --with
110+
for pkg in sorted(packages):
111+
args.extend(["--with", pkg])
112+
113+
if with_editable:
114+
args.extend(["--with-editable", str(with_editable)])
115+
116+
# Convert file path to absolute before adding to command
117+
# Split off any :object suffix first
118+
if ":" in file_spec:
119+
file_path, server_object = file_spec.rsplit(":", 1)
120+
file_spec = f"{Path(file_path).resolve()}:{server_object}"
121+
else:
122+
file_spec = str(Path(file_spec).resolve())
123+
124+
# Add fastmcp run command
125+
args.extend(["mcp", "run", file_spec])
126+
127+
server_config: dict[str, Any] = {"command": uv_path, "args": args}
128+
129+
# Add environment variables if specified
130+
if env_vars:
131+
server_config["env"] = env_vars
132+
133+
config["mcpServers"][server_name] = server_config
134+
135+
config_file.write_text(json.dumps(config, indent=2))
136+
logger.info(
137+
f"Added server '{server_name}' to Claude config",
138+
extra={"config_file": str(config_file)},
139+
)
140+
return True
141+
except Exception:
142+
logger.exception(
143+
"Failed to update Claude config",
144+
extra={
145+
"config_file": str(config_file),
146+
},
147+
)
148+
return False

0 commit comments

Comments
 (0)