openhands-agent-server 1.25.0__tar.gz → 1.26.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/PKG-INFO +1 -1
  2. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/mcp_router.py +142 -12
  3. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/skills_service.py +3 -3
  4. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands_agent_server.egg-info/PKG-INFO +1 -1
  5. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/pyproject.toml +1 -1
  6. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/__init__.py +0 -0
  7. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/__main__.py +0 -0
  8. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/_secrets_exposure.py +0 -0
  9. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/api.py +0 -0
  10. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/auth_router.py +0 -0
  11. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/bash_router.py +0 -0
  12. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/bash_service.py +0 -0
  13. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/cloud_proxy_router.py +0 -0
  14. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/config.py +0 -0
  15. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/conversation_lease.py +0 -0
  16. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/conversation_router.py +0 -0
  17. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/conversation_router_acp.py +0 -0
  18. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/conversation_service.py +0 -0
  19. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/dependencies.py +0 -0
  20. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/desktop_router.py +0 -0
  21. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/desktop_service.py +0 -0
  22. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/docker/Dockerfile +0 -0
  23. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/docker/build.py +0 -0
  24. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/docker/wallpaper.svg +0 -0
  25. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/env_parser.py +0 -0
  26. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/event_router.py +0 -0
  27. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/event_service.py +0 -0
  28. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/file_router.py +0 -0
  29. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/git_router.py +0 -0
  30. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/hooks_router.py +0 -0
  31. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/hooks_service.py +0 -0
  32. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/llm_router.py +0 -0
  33. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/logging_config.py +0 -0
  34. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/middleware.py +0 -0
  35. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/models.py +0 -0
  36. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/openapi.py +0 -0
  37. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/persistence/__init__.py +0 -0
  38. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/persistence/models.py +0 -0
  39. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/persistence/store.py +0 -0
  40. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/profiles_router.py +0 -0
  41. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/pub_sub.py +0 -0
  42. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/py.typed +0 -0
  43. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/server_details_router.py +0 -0
  44. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/settings_router.py +0 -0
  45. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/skills_router.py +0 -0
  46. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/sockets.py +0 -0
  47. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/tool_preload_service.py +0 -0
  48. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/tool_router.py +0 -0
  49. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/utils.py +0 -0
  50. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
  51. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
  52. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/vscode_router.py +0 -0
  53. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/vscode_service.py +0 -0
  54. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/workspace_router.py +0 -0
  55. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands/agent_server/workspaces_router.py +0 -0
  56. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands_agent_server.egg-info/SOURCES.txt +0 -0
  57. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
  58. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands_agent_server.egg-info/entry_points.txt +0 -0
  59. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands_agent_server.egg-info/requires.txt +0 -0
  60. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/openhands_agent_server.egg-info/top_level.txt +0 -0
  61. {openhands_agent_server-1.25.0 → openhands_agent_server-1.26.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-agent-server
3
- Version: 1.25.0
3
+ Version: 1.26.0
4
4
  Summary: OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent
5
5
  Project-URL: Source, https://github.com/OpenHands/software-agent-sdk
6
6
  Project-URL: Homepage, https://github.com/OpenHands/software-agent-sdk
@@ -6,9 +6,12 @@ to settings, where a misconfiguration would otherwise surface only at
6
6
  conversation start (and there manifest as a noisy traceback that aborts
7
7
  agent initialization).
8
8
 
9
- The endpoint is intentionally side-effect-free: it spins up the MCP
10
- connection, lists the advertised tools, then tears the connection down.
11
- It never mutates server state or touches stored settings.
9
+ The endpoint never mutates server state or touches stored settings: it
10
+ spins up the MCP connection, lists the advertised tools, optionally invokes
11
+ one caller-chosen tool (``tool_call``), then tears the connection down.
12
+ The optional tool call exists because listing tools does not exercise the
13
+ credentials many servers only use inside tool handlers (e.g. the Slack MCP
14
+ server starts fine with a bogus token); callers must pick a read-only tool.
12
15
  """
13
16
 
14
17
  from __future__ import annotations
@@ -16,12 +19,16 @@ from __future__ import annotations
16
19
  import asyncio
17
20
  from typing import Annotated, Any, Literal
18
21
 
19
- from fastapi import APIRouter
22
+ import mcp.types
23
+ from fastapi import APIRouter, Request
20
24
  from pydantic import BaseModel, Field, model_validator
21
25
 
26
+ from openhands.agent_server._secrets_exposure import get_cipher
22
27
  from openhands.sdk.logger import get_logger
23
28
  from openhands.sdk.mcp import create_mcp_tools
24
29
  from openhands.sdk.mcp.exceptions import MCPError, MCPTimeoutError
30
+ from openhands.sdk.utils.cipher import Cipher
31
+ from openhands.sdk.utils.pydantic_secrets import decrypt_str_with_cipher_or_keep
25
32
 
26
33
 
27
34
  logger = get_logger(__name__)
@@ -85,6 +92,22 @@ class _RemoteMCPServerSpec(BaseModel):
85
92
  return out
86
93
 
87
94
 
95
+ class MCPToolCallSpec(BaseModel):
96
+ """A single tool invocation to run as part of the connection test.
97
+
98
+ Listing tools does not exercise the credentials many servers only use
99
+ inside tool handlers, so callers can name one tool to invoke after the
100
+ listing succeeds. Callers are responsible for choosing a read-only tool;
101
+ the endpoint executes it verbatim.
102
+ """
103
+
104
+ name: str = Field(..., min_length=1, description="Name of the tool to invoke")
105
+ arguments: dict[str, Any] = Field(
106
+ default_factory=dict,
107
+ description="Arguments passed to the tool unchanged.",
108
+ )
109
+
110
+
88
111
  class MCPTestRequest(BaseModel):
89
112
  """Body for ``POST /api/mcp/test``."""
90
113
 
@@ -108,6 +131,15 @@ class MCPTestRequest(BaseModel):
108
131
  le=120,
109
132
  description="Seconds to wait for connection + tools/list to complete.",
110
133
  )
134
+ tool_call: MCPToolCallSpec | None = Field(
135
+ default=None,
136
+ description=(
137
+ "Optional read-only tool to invoke after listing succeeds, so "
138
+ "callers can verify credentials the server only exercises on "
139
+ "tool invocation. Its outcome is reported verbatim in "
140
+ "`tool_result` without affecting `ok`."
141
+ ),
142
+ )
111
143
 
112
144
  @model_validator(mode="after")
113
145
  def _strip_name(self) -> MCPTestRequest:
@@ -117,6 +149,19 @@ class MCPTestRequest(BaseModel):
117
149
  return self
118
150
 
119
151
 
152
+ class MCPToolCallResult(BaseModel):
153
+ """Verbatim outcome of the requested ``tool_call``.
154
+
155
+ The endpoint stays provider-neutral: many servers report upstream
156
+ failures (e.g. Slack's ``{"ok": false, "error": "invalid_auth"}``)
157
+ as ordinary text content with ``isError`` unset, so interpreting the
158
+ payload is the caller's job.
159
+ """
160
+
161
+ is_error: bool = Field(description="The MCP-level isError flag of the result.")
162
+ text: str = Field(description="Concatenated text content of the result.")
163
+
164
+
120
165
  class MCPTestSuccess(BaseModel):
121
166
  """Response when the candidate server connects and lists its tools."""
122
167
 
@@ -125,6 +170,10 @@ class MCPTestSuccess(BaseModel):
125
170
  default_factory=list,
126
171
  description="Names of tools advertised by the MCP server.",
127
172
  )
173
+ tool_result: MCPToolCallResult | None = Field(
174
+ default=None,
175
+ description=("Outcome of the requested `tool_call`, when one was supplied."),
176
+ )
128
177
 
129
178
 
130
179
  class MCPTestFailure(BaseModel):
@@ -151,18 +200,81 @@ MCPTestResponse = MCPTestSuccess | MCPTestFailure
151
200
  # ---------------------------------------------------------------------------
152
201
 
153
202
 
154
- def _server_to_fastmcp_dict(spec: _StdioMCPServerSpec | _RemoteMCPServerSpec) -> dict:
203
+ def _decrypt_mapping(cipher: Cipher | None, mapping: dict[str, str]) -> dict[str, str]:
204
+ """Decrypt Fernet-encrypted values round-tripped from settings.
205
+
206
+ The GUI fetches stored settings with ``X-Expose-Secrets: encrypted`` and
207
+ forwards the ciphertext unchanged so the edit flow can test the *real*
208
+ stored credentials without ever seeing them. Plaintext values (the
209
+ common case: freshly typed input) pass through untouched.
210
+ """
211
+ if cipher is None:
212
+ return dict(mapping)
213
+ return {
214
+ key: decrypt_str_with_cipher_or_keep(
215
+ cipher, value, description="MCP test env/headers"
216
+ )
217
+ for key, value in mapping.items()
218
+ }
219
+
220
+
221
+ def _server_to_fastmcp_dict(
222
+ spec: _StdioMCPServerSpec | _RemoteMCPServerSpec, cipher: Cipher | None
223
+ ) -> dict:
155
224
  if isinstance(spec, _StdioMCPServerSpec):
156
225
  out: dict[str, Any] = {"command": spec.command, "args": list(spec.args)}
157
226
  if spec.env:
158
- out["env"] = dict(spec.env)
227
+ out["env"] = _decrypt_mapping(cipher, spec.env)
159
228
  if spec.cwd:
160
229
  out["cwd"] = spec.cwd
161
230
  return out
162
- return spec.to_fastmcp_dict()
231
+ remote = spec.to_fastmcp_dict()
232
+ if "headers" in remote:
233
+ remote["headers"] = _decrypt_mapping(cipher, remote["headers"])
234
+ return remote
235
+
236
+
237
+ def _run_tool_call(
238
+ client: Any, spec: MCPToolCallSpec, tool_names: list[str], timeout: float
239
+ ) -> MCPToolCallResult:
240
+ """Invoke the requested tool on the connected client.
241
+
242
+ Uses ``call_tool_mcp`` (not ``call_tool``, which raises on ``isError``)
243
+ so in-band failures come back as data -- mirrors ``MCPToolExecutor``.
244
+ A timeout is reported as an errored result rather than failing the
245
+ whole test: the server did connect and list, which is still useful.
246
+ """
247
+ if spec.name not in tool_names:
248
+ return MCPToolCallResult(
249
+ is_error=True,
250
+ text=(
251
+ f"Tool {spec.name!r} not advertised by server "
252
+ f"(available: {', '.join(tool_names) or 'none'})"
253
+ ),
254
+ )
255
+ try:
256
+ result: mcp.types.CallToolResult = client.call_async_from_sync(
257
+ client.call_tool_mcp,
258
+ name=spec.name,
259
+ arguments=spec.arguments,
260
+ timeout=timeout,
261
+ )
262
+ except TimeoutError:
263
+ return MCPToolCallResult(
264
+ is_error=True,
265
+ text=f"Tool {spec.name!r} call timed out after {timeout} seconds",
266
+ )
267
+ text = "\n".join(
268
+ block.text
269
+ for block in result.content
270
+ if isinstance(block, mcp.types.TextContent)
271
+ )
272
+ return MCPToolCallResult(is_error=bool(result.isError), text=text)
163
273
 
164
274
 
165
- def _probe_mcp_server(request: MCPTestRequest) -> MCPTestResponse:
275
+ def _probe_mcp_server(
276
+ request: MCPTestRequest, cipher: Cipher | None
277
+ ) -> MCPTestResponse:
166
278
  """Synchronous probe -- safe to run inside ``run_in_executor``.
167
279
 
168
280
  ``create_mcp_tools`` already runs its own event loop in a background
@@ -171,14 +283,22 @@ def _probe_mcp_server(request: MCPTestRequest) -> MCPTestResponse:
171
283
  threadpool first.
172
284
  """
173
285
 
174
- config = {"mcpServers": {request.name: _server_to_fastmcp_dict(request.server)}}
286
+ config = {
287
+ "mcpServers": {request.name: _server_to_fastmcp_dict(request.server, cipher)}
288
+ }
175
289
 
176
290
  try:
177
291
  # ``create_mcp_tools`` returns a client that owns a background loop
178
292
  # and a (possibly long-lived) subprocess. Use the context-manager
179
293
  # form so we always tear it down, even when listing succeeded.
180
294
  with create_mcp_tools(config, timeout=request.timeout) as client:
181
- return MCPTestSuccess(tools=[tool.name for tool in client.tools])
295
+ tool_names = [tool.name for tool in client.tools]
296
+ tool_result: MCPToolCallResult | None = None
297
+ if request.tool_call is not None:
298
+ tool_result = _run_tool_call(
299
+ client, request.tool_call, tool_names, request.timeout
300
+ )
301
+ return MCPTestSuccess(tools=tool_names, tool_result=tool_result)
182
302
  except MCPTimeoutError as exc:
183
303
  logger.info("MCP test timed out for server %r: %s", request.name, exc)
184
304
  return MCPTestFailure(error=str(exc), error_kind="timeout")
@@ -215,11 +335,21 @@ def _probe_mcp_server(request: MCPTestRequest) -> MCPTestResponse:
215
335
  "Attempt to connect to a candidate MCP server and list its tools, "
216
336
  "without persisting any settings. Useful for validating user input "
217
337
  "in 'add MCP server' flows before storing the config. "
338
+ "Optionally invokes one caller-chosen (read-only) tool via "
339
+ "`tool_call` and reports its outcome in `tool_result`, so callers "
340
+ "can verify credentials that are only exercised on tool invocation. "
341
+ "Encrypted `env`/`headers` values round-tripped from settings are "
342
+ "decrypted before the connection is attempted. "
218
343
  "Returns 200 with `ok=false` for connection / timeout failures "
219
344
  "(those are expected during validation, not server errors)."
220
345
  ),
221
346
  )
222
- async def test_mcp_server(request: MCPTestRequest) -> MCPTestResponse:
347
+ async def test_mcp_server(
348
+ request: MCPTestRequest, http_request: Request
349
+ ) -> MCPTestResponse:
223
350
  """Probe a single MCP server config and report whether it works."""
351
+ # Resolve the cipher here: the threadpool function below must not
352
+ # reach back into ``http_request.app.state``.
353
+ cipher = get_cipher(http_request)
224
354
  loop = asyncio.get_running_loop()
225
- return await loop.run_in_executor(None, _probe_mcp_server, request)
355
+ return await loop.run_in_executor(None, _probe_mcp_server, request, cipher)
@@ -40,7 +40,7 @@ from openhands.sdk.skills import (
40
40
  )
41
41
  from openhands.sdk.skills.skill import (
42
42
  DEFAULT_MARKETPLACE_PATH,
43
- PUBLIC_SKILLS_BRANCH,
43
+ PUBLIC_SKILLS_REF,
44
44
  PUBLIC_SKILLS_REPO,
45
45
  _invalidate_public_skills_cache,
46
46
  load_skills_from_dir,
@@ -391,7 +391,7 @@ def sync_public_skills() -> tuple[bool, str]:
391
391
  try:
392
392
  cache_dir = get_skills_cache_dir()
393
393
  result = update_skills_repository(
394
- PUBLIC_SKILLS_REPO, PUBLIC_SKILLS_BRANCH, cache_dir
394
+ PUBLIC_SKILLS_REPO, PUBLIC_SKILLS_REF, cache_dir
395
395
  )
396
396
 
397
397
  if result:
@@ -634,7 +634,7 @@ def _fetch_catalog_entries(marketplace_path: str) -> list[_CatalogEntry]:
634
634
  """
635
635
  cache_dir = get_skills_cache_dir()
636
636
  repo_path = update_skills_repository(
637
- PUBLIC_SKILLS_REPO, PUBLIC_SKILLS_BRANCH, cache_dir
637
+ PUBLIC_SKILLS_REPO, PUBLIC_SKILLS_REF, cache_dir
638
638
  )
639
639
 
640
640
  if repo_path is None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-agent-server
3
- Version: 1.25.0
3
+ Version: 1.26.0
4
4
  Summary: OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent
5
5
  Project-URL: Source, https://github.com/OpenHands/software-agent-sdk
6
6
  Project-URL: Homepage, https://github.com/OpenHands/software-agent-sdk
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openhands-agent-server"
3
- version = "1.25.0"
3
+ version = "1.26.0"
4
4
  description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
5
5
 
6
6
  requires-python = ">=3.12"