patdev commited on
Commit
082d529
·
verified ·
1 Parent(s): 13cdab8

Update mcp-bridge OAuth server

Browse files
.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
- - Some MCP hosts only surface the first rich content item from a tool result; the bridge now prioritizes image items before text so screenshot-style tools remain visible.
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
- outputs: list[Any] = []
 
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
- outputs.append(Image(data=base64.b64decode(item.data), format=image_format))
 
 
 
 
109
  continue
110
 
111
  if hasattr(item, "model_dump"):
112
- outputs.append(json.dumps(item.model_dump(), ensure_ascii=False, indent=2))
113
  else:
114
- outputs.append(str(item))
 
 
 
115
 
116
- if outputs:
117
- return outputs
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": [tool.model_dump() if hasattr(tool, "model_dump") else str(tool) for tool in 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[1].type == "text"
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": {}}