Spaces:
Running
Running
Update mcp-bridge OAuth server
Browse files- .pytest_cache/v/cache/lastfailed +1 -3
- .pytest_cache/v/cache/nodeids +1 -0
- README.md +1 -1
- app.py +27 -11
- test_oauth_flow.py +37 -1
.pytest_cache/v/cache/lastfailed
CHANGED
|
@@ -1,3 +1 @@
|
|
| 1 |
-
{
|
| 2 |
-
"test_oauth_flow.py::test_mcp_tool_call_emits_native_image_content_end_to_end": true
|
| 3 |
-
}
|
|
|
|
| 1 |
+
{}
|
|
|
|
|
|
.pytest_cache/v/cache/nodeids
CHANGED
|
@@ -5,6 +5,7 @@
|
|
| 5 |
"test_oauth_flow.py::test_mcp_tool_call_emits_native_image_content_end_to_end",
|
| 6 |
"test_oauth_flow.py::test_mcp_tool_call_preserves_native_image_content",
|
| 7 |
"test_oauth_flow.py::test_mcp_tool_list_reports_target_url_on_connect_failure",
|
|
|
|
| 8 |
"test_oauth_flow.py::test_normalize_remote_url_adds_http_for_localhost_target",
|
| 9 |
"test_oauth_flow.py::test_normalize_remote_url_adds_https_for_bare_remote_host",
|
| 10 |
"test_oauth_flow.py::test_oauth_metadata_and_flow",
|
|
|
|
| 5 |
"test_oauth_flow.py::test_mcp_tool_call_emits_native_image_content_end_to_end",
|
| 6 |
"test_oauth_flow.py::test_mcp_tool_call_preserves_native_image_content",
|
| 7 |
"test_oauth_flow.py::test_mcp_tool_list_reports_target_url_on_connect_failure",
|
| 8 |
+
"test_oauth_flow.py::test_mcp_tool_list_strips_remote_output_schema",
|
| 9 |
"test_oauth_flow.py::test_normalize_remote_url_adds_http_for_localhost_target",
|
| 10 |
"test_oauth_flow.py::test_normalize_remote_url_adds_https_for_bare_remote_host",
|
| 11 |
"test_oauth_flow.py::test_oauth_metadata_and_flow",
|
README.md
CHANGED
|
@@ -125,6 +125,6 @@ python -c "import secrets; print(secrets.token_urlsafe(48))"
|
|
| 125 |
- `mcp_connect` expects an HTTP(S) MCP URL such as `https://example.com/sse` or `http://127.0.0.1:4000/mcp`; bare remote hosts are normalized to `https://...`, while `localhost`/`127.0.0.1` targets are normalized to `http://...`.
|
| 126 |
- `mcp_connect` now accepts optional `bearer_token` and `x_api_key` arguments for protected upstream MCP servers and verifies the remote endpoint by default during connect.
|
| 127 |
- If you do not want to pass remote credentials on each call, set `REMOTE_MCP_BEARER_TOKEN` or `REMOTE_MCP_X_API_KEY` in the bridge environment and they will be applied automatically when explicit auth headers are absent.
|
| 128 |
-
-
|
| 129 |
- For Windows-MCP specifically, `Snapshot` only includes an actual screenshot when called with `use_vision=true`; plain `Snapshot` calls can legitimately return text-only desktop state.
|
| 130 |
- Bridge sessions created by `mcp_connect` are in memory and reset when the Space restarts or sleeps.
|
|
|
|
| 125 |
- `mcp_connect` expects an HTTP(S) MCP URL such as `https://example.com/sse` or `http://127.0.0.1:4000/mcp`; bare remote hosts are normalized to `https://...`, while `localhost`/`127.0.0.1` targets are normalized to `http://...`.
|
| 126 |
- `mcp_connect` now accepts optional `bearer_token` and `x_api_key` arguments for protected upstream MCP servers and verifies the remote endpoint by default during connect.
|
| 127 |
- If you do not want to pass remote credentials on each call, set `REMOTE_MCP_BEARER_TOKEN` or `REMOTE_MCP_X_API_KEY` in the bridge environment and they will be applied automatically when explicit auth headers are absent.
|
| 128 |
+
- FastMCP tool results are serialized as text-first when a tool returns mixed text plus image content, so the bridge emits image-only outputs whenever the upstream result includes images. This preserves screenshot rendering in hosts that otherwise hide the image behind the leading text item.
|
| 129 |
- For Windows-MCP specifically, `Snapshot` only includes an actual screenshot when called with `use_vision=true`; plain `Snapshot` calls can legitimately return text-only desktop state.
|
| 130 |
- Bridge sessions created by `mcp_connect` are in memory and reset when the Space restarts or sleeps.
|
app.py
CHANGED
|
@@ -94,31 +94,47 @@ def _prioritize_image_content(result: CallToolResult) -> CallToolResult:
|
|
| 94 |
|
| 95 |
def _native_tool_outputs(result: CallToolResult) -> list[Any]:
|
| 96 |
prioritized = _prioritize_image_content(result)
|
| 97 |
-
|
|
|
|
| 98 |
|
| 99 |
for item in prioritized.content or []:
|
| 100 |
item_type = getattr(item, "type", None)
|
| 101 |
-
if item_type == "text":
|
| 102 |
-
outputs.append(item.text)
|
| 103 |
-
continue
|
| 104 |
-
|
| 105 |
if item_type == "image":
|
| 106 |
mime_type = getattr(item, "mimeType", None) or "image/png"
|
| 107 |
image_format = mime_type.split("/", 1)[1] if "/" in mime_type else "png"
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
continue
|
| 110 |
|
| 111 |
if hasattr(item, "model_dump"):
|
| 112 |
-
|
| 113 |
else:
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
-
if
|
| 117 |
-
return
|
| 118 |
|
| 119 |
return [json.dumps(prioritized.model_dump(), ensure_ascii=False, indent=2)]
|
| 120 |
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
def _b64url(data: bytes) -> str:
|
| 123 |
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
| 124 |
|
|
@@ -651,7 +667,7 @@ async def mcp_tool_list(session_id: str) -> Dict[str, Any]:
|
|
| 651 |
|
| 652 |
return {
|
| 653 |
"session_id": session_id,
|
| 654 |
-
"tools": [
|
| 655 |
}
|
| 656 |
|
| 657 |
|
|
|
|
| 94 |
|
| 95 |
def _native_tool_outputs(result: CallToolResult) -> list[Any]:
|
| 96 |
prioritized = _prioritize_image_content(result)
|
| 97 |
+
image_outputs: list[Any] = []
|
| 98 |
+
fallback_outputs: list[Any] = []
|
| 99 |
|
| 100 |
for item in prioritized.content or []:
|
| 101 |
item_type = getattr(item, "type", None)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
if item_type == "image":
|
| 103 |
mime_type = getattr(item, "mimeType", None) or "image/png"
|
| 104 |
image_format = mime_type.split("/", 1)[1] if "/" in mime_type else "png"
|
| 105 |
+
image_outputs.append(Image(data=base64.b64decode(item.data), format=image_format))
|
| 106 |
+
continue
|
| 107 |
+
|
| 108 |
+
if item_type == "text":
|
| 109 |
+
fallback_outputs.append(item.text)
|
| 110 |
continue
|
| 111 |
|
| 112 |
if hasattr(item, "model_dump"):
|
| 113 |
+
fallback_outputs.append(json.dumps(item.model_dump(), ensure_ascii=False, indent=2))
|
| 114 |
else:
|
| 115 |
+
fallback_outputs.append(str(item))
|
| 116 |
+
|
| 117 |
+
if image_outputs:
|
| 118 |
+
return image_outputs
|
| 119 |
|
| 120 |
+
if fallback_outputs:
|
| 121 |
+
return fallback_outputs
|
| 122 |
|
| 123 |
return [json.dumps(prioritized.model_dump(), ensure_ascii=False, indent=2)]
|
| 124 |
|
| 125 |
|
| 126 |
+
def _sanitized_tool_descriptor(tool: Any) -> Dict[str, Any]:
|
| 127 |
+
if hasattr(tool, "model_dump"):
|
| 128 |
+
descriptor = tool.model_dump()
|
| 129 |
+
elif isinstance(tool, dict):
|
| 130 |
+
descriptor = dict(tool)
|
| 131 |
+
else:
|
| 132 |
+
return {"name": str(tool)}
|
| 133 |
+
|
| 134 |
+
descriptor.pop("outputSchema", None)
|
| 135 |
+
return descriptor
|
| 136 |
+
|
| 137 |
+
|
| 138 |
def _b64url(data: bytes) -> str:
|
| 139 |
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
| 140 |
|
|
|
|
| 667 |
|
| 668 |
return {
|
| 669 |
"session_id": session_id,
|
| 670 |
+
"tools": [_sanitized_tool_descriptor(tool) for tool in tools],
|
| 671 |
}
|
| 672 |
|
| 673 |
|
test_oauth_flow.py
CHANGED
|
@@ -180,7 +180,7 @@ def test_mcp_tool_call_emits_native_image_content_end_to_end(monkeypatch):
|
|
| 180 |
)
|
| 181 |
assert returned.content[0].type == "image"
|
| 182 |
assert returned.content[0].mimeType == "image/png"
|
| 183 |
-
assert returned.content
|
| 184 |
assert returned.isError is False
|
| 185 |
|
| 186 |
try:
|
|
@@ -191,6 +191,42 @@ def test_mcp_tool_call_emits_native_image_content_end_to_end(monkeypatch):
|
|
| 191 |
assert fake_client.calls == [("Screenshot", {})]
|
| 192 |
|
| 193 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
def test_mcp_tool_list_reports_target_url_on_connect_failure(monkeypatch):
|
| 195 |
session_id = "broken-session"
|
| 196 |
SESSIONS[session_id] = {"url": "https://missing.example.test/sse", "headers": {}}
|
|
|
|
| 180 |
)
|
| 181 |
assert returned.content[0].type == "image"
|
| 182 |
assert returned.content[0].mimeType == "image/png"
|
| 183 |
+
assert len(returned.content) == 1
|
| 184 |
assert returned.isError is False
|
| 185 |
|
| 186 |
try:
|
|
|
|
| 191 |
assert fake_client.calls == [("Screenshot", {})]
|
| 192 |
|
| 193 |
|
| 194 |
+
def test_mcp_tool_list_strips_remote_output_schema(monkeypatch):
|
| 195 |
+
class FakeTool:
|
| 196 |
+
def model_dump(self):
|
| 197 |
+
return {
|
| 198 |
+
"name": "mcp_windows_Screenshot",
|
| 199 |
+
"description": "fake screenshot tool",
|
| 200 |
+
"inputSchema": {"type": "object"},
|
| 201 |
+
"outputSchema": {"type": "object", "properties": {"image": {"type": "string"}}},
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
class ListingClient:
|
| 205 |
+
async def __aenter__(self):
|
| 206 |
+
return self
|
| 207 |
+
|
| 208 |
+
async def __aexit__(self, exc_type, exc, tb):
|
| 209 |
+
return False
|
| 210 |
+
|
| 211 |
+
async def list_tools(self):
|
| 212 |
+
return [FakeTool()]
|
| 213 |
+
|
| 214 |
+
session_id = "tool-list-session"
|
| 215 |
+
SESSIONS[session_id] = {"url": "https://example.test/sse", "headers": {}}
|
| 216 |
+
monkeypatch.setattr("app._remote_client", lambda url, headers=None: ListingClient())
|
| 217 |
+
|
| 218 |
+
async def run_test():
|
| 219 |
+
returned = await mcp_tool_list.fn(session_id)
|
| 220 |
+
assert returned["tools"][0]["name"] == "mcp_windows_Screenshot"
|
| 221 |
+
assert returned["tools"][0]["inputSchema"] == {"type": "object"}
|
| 222 |
+
assert "outputSchema" not in returned["tools"][0]
|
| 223 |
+
|
| 224 |
+
try:
|
| 225 |
+
asyncio.run(run_test())
|
| 226 |
+
finally:
|
| 227 |
+
SESSIONS.pop(session_id, None)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
def test_mcp_tool_list_reports_target_url_on_connect_failure(monkeypatch):
|
| 231 |
session_id = "broken-session"
|
| 232 |
SESSIONS[session_id] = {"url": "https://missing.example.test/sse", "headers": {}}
|