openhands-agent-server 1.22.1__tar.gz → 1.23.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 (62) hide show
  1. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/PKG-INFO +1 -1
  2. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/api.py +4 -0
  3. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/conversation_router.py +52 -1
  4. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/conversation_router_acp.py +27 -2
  5. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/conversation_service.py +19 -0
  6. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/event_service.py +57 -3
  7. openhands_agent_server-1.23.0/openhands/agent_server/mcp_router.py +225 -0
  8. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/models.py +54 -3
  9. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/persistence/__init__.py +14 -0
  10. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/persistence/models.py +49 -0
  11. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/persistence/store.py +107 -1
  12. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/server_details_router.py +10 -0
  13. openhands_agent_server-1.23.0/openhands/agent_server/skills_router.py +530 -0
  14. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/skills_service.py +298 -0
  15. openhands_agent_server-1.23.0/openhands/agent_server/workspaces_router.py +244 -0
  16. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands_agent_server.egg-info/PKG-INFO +1 -1
  17. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands_agent_server.egg-info/SOURCES.txt +4 -0
  18. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/pyproject.toml +1 -1
  19. openhands_agent_server-1.22.1/openhands/agent_server/skills_router.py +0 -192
  20. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/__init__.py +0 -0
  21. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/__main__.py +0 -0
  22. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/_secrets_exposure.py +0 -0
  23. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/auth_router.py +0 -0
  24. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/bash_router.py +0 -0
  25. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/bash_service.py +0 -0
  26. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/cloud_proxy_router.py +0 -0
  27. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/config.py +0 -0
  28. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/conversation_lease.py +0 -0
  29. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/dependencies.py +0 -0
  30. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/desktop_router.py +0 -0
  31. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/desktop_service.py +0 -0
  32. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/docker/Dockerfile +0 -0
  33. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/docker/build.py +0 -0
  34. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/docker/wallpaper.svg +0 -0
  35. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/env_parser.py +0 -0
  36. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/event_router.py +0 -0
  37. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/file_router.py +0 -0
  38. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/git_router.py +0 -0
  39. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/hooks_router.py +0 -0
  40. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/hooks_service.py +0 -0
  41. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/llm_router.py +0 -0
  42. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/logging_config.py +0 -0
  43. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/middleware.py +0 -0
  44. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/openapi.py +0 -0
  45. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/profiles_router.py +0 -0
  46. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/pub_sub.py +0 -0
  47. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/py.typed +0 -0
  48. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/settings_router.py +0 -0
  49. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/sockets.py +0 -0
  50. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/tool_preload_service.py +0 -0
  51. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/tool_router.py +0 -0
  52. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/utils.py +0 -0
  53. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
  54. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
  55. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/vscode_router.py +0 -0
  56. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/vscode_service.py +0 -0
  57. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/workspace_router.py +0 -0
  58. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
  59. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands_agent_server.egg-info/entry_points.txt +0 -0
  60. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands_agent_server.egg-info/requires.txt +0 -0
  61. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands_agent_server.egg-info/top_level.txt +0 -0
  62. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-agent-server
3
- Version: 1.22.1
3
+ Version: 1.23.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
@@ -38,6 +38,7 @@ from openhands.agent_server.file_router import file_router
38
38
  from openhands.agent_server.git_router import git_router
39
39
  from openhands.agent_server.hooks_router import hooks_router
40
40
  from openhands.agent_server.llm_router import llm_router
41
+ from openhands.agent_server.mcp_router import mcp_router
41
42
  from openhands.agent_server.middleware import LocalhostCORSMiddleware
42
43
  from openhands.agent_server.profiles_router import profiles_router
43
44
  from openhands.agent_server.server_details_router import (
@@ -53,6 +54,7 @@ from openhands.agent_server.tool_router import tool_router
53
54
  from openhands.agent_server.vscode_router import vscode_router
54
55
  from openhands.agent_server.vscode_service import get_vscode_service
55
56
  from openhands.agent_server.workspace_router import workspace_router
57
+ from openhands.agent_server.workspaces_router import workspaces_router
56
58
  from openhands.sdk.logger import DEBUG, get_logger
57
59
  from openhands.sdk.utils.redact import sanitize_dict
58
60
  from openhands.tools.terminal.constants import TMUX_SOCKET_NAME
@@ -288,7 +290,9 @@ def _add_api_routes(app: FastAPI, config: Config) -> None:
288
290
  api_router.include_router(skills_router)
289
291
  api_router.include_router(hooks_router)
290
292
  api_router.include_router(llm_router)
293
+ api_router.include_router(mcp_router)
291
294
  api_router.include_router(settings_router)
295
+ api_router.include_router(workspaces_router)
292
296
  api_router.include_router(profiles_router)
293
297
  api_router.include_router(cloud_proxy_router)
294
298
  # /api/auth/* mints workspace cookies and requires the header to bootstrap,
@@ -22,6 +22,7 @@ from openhands.agent_server._secrets_exposure import (
22
22
  from openhands.agent_server.conversation_service import ConversationService
23
23
  from openhands.agent_server.dependencies import get_conversation_service
24
24
  from openhands.agent_server.models import (
25
+ INCLUDE_SKILLS_PARAM_TITLE,
25
26
  AgentResponseResult,
26
27
  AskAgentRequest,
27
28
  AskAgentResponse,
@@ -36,6 +37,7 @@ from openhands.agent_server.models import (
36
37
  Success,
37
38
  UpdateConversationRequest,
38
39
  UpdateSecretsRequest,
40
+ trim_conversation_response_skills,
39
41
  )
40
42
  from openhands.sdk import LLM, Agent, TextContent
41
43
  from openhands.sdk.conversation.state import ConversationExecutionStatus
@@ -86,14 +88,28 @@ async def search_conversations(
86
88
  ConversationSortOrder,
87
89
  Query(title="Sort order for conversations"),
88
90
  ] = ConversationSortOrder.CREATED_AT_DESC,
91
+ include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
89
92
  conversation_service: ConversationService = Depends(get_conversation_service),
90
93
  ) -> ConversationPage:
91
94
  """Search / List conversations"""
92
95
  assert limit > 0
93
96
  assert limit <= 100
94
- return await conversation_service.search_conversations(
97
+ page = await conversation_service.search_conversations(
95
98
  page_id, limit, status, sort_order
96
99
  )
100
+ if not include_skills:
101
+ # ``model_copy`` rather than in-place mutation so we never
102
+ # write back into whatever the upstream service handed us
103
+ # (matters for services that cache their return value,
104
+ # including the ``AsyncMock`` used in route tests).
105
+ page = page.model_copy(
106
+ update={
107
+ "items": [
108
+ trim_conversation_response_skills(item) for item in page.items
109
+ ]
110
+ }
111
+ )
112
+ return page
97
113
 
98
114
 
99
115
  @conversation_router.get("/count")
@@ -114,12 +130,15 @@ async def count_conversations(
114
130
  )
115
131
  async def get_conversation(
116
132
  conversation_id: UUID,
133
+ include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
117
134
  conversation_service: ConversationService = Depends(get_conversation_service),
118
135
  ) -> ConversationInfo:
119
136
  """Given an id, get a conversation"""
120
137
  conversation = await conversation_service.get_conversation(conversation_id)
121
138
  if conversation is None:
122
139
  raise HTTPException(status.HTTP_404_NOT_FOUND)
140
+ if not include_skills:
141
+ conversation = trim_conversation_response_skills(conversation)
123
142
  return conversation
124
143
 
125
144
 
@@ -147,12 +166,18 @@ async def get_conversation_agent_final_response(
147
166
  @conversation_router.get("")
148
167
  async def batch_get_conversations(
149
168
  ids: Annotated[list[UUID], Query()],
169
+ include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
150
170
  conversation_service: ConversationService = Depends(get_conversation_service),
151
171
  ) -> list[ConversationInfo | None]:
152
172
  """Get a batch of conversations given their ids, returning null for
153
173
  any missing item"""
154
174
  assert len(ids) < 100
155
175
  conversations = await conversation_service.batch_get_conversations(ids)
176
+ if not include_skills:
177
+ return [
178
+ trim_conversation_response_skills(c) if c is not None else None
179
+ for c in conversations
180
+ ]
156
181
  return conversations
157
182
 
158
183
 
@@ -165,11 +190,14 @@ async def start_conversation(
165
190
  StartConversationRequest, Body(examples=START_CONVERSATION_EXAMPLES)
166
191
  ],
167
192
  response: Response,
193
+ include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
168
194
  conversation_service: ConversationService = Depends(get_conversation_service),
169
195
  ) -> ConversationInfo:
170
196
  """Start a conversation in the local environment."""
171
197
  info, is_new = await conversation_service.start_conversation(request)
172
198
  response.status_code = status.HTTP_201_CREATED if is_new else status.HTTP_200_OK
199
+ if not include_skills:
200
+ info = trim_conversation_response_skills(info)
173
201
  return info
174
202
 
175
203
 
@@ -187,6 +215,26 @@ async def pause_conversation(
187
215
  return Success()
188
216
 
189
217
 
218
+ @conversation_router.post(
219
+ "/{conversation_id}/interrupt",
220
+ responses={404: {"description": "Item not found"}},
221
+ )
222
+ async def interrupt_conversation(
223
+ conversation_id: UUID,
224
+ conversation_service: ConversationService = Depends(get_conversation_service),
225
+ ) -> Success:
226
+ """Immediately interrupt a running conversation.
227
+
228
+ Unlike ``/pause``, which waits for the current LLM call to finish,
229
+ ``/interrupt`` cancels the in-flight request so the effect is instant.
230
+ The conversation transitions to *paused* and can be resumed later.
231
+ """
232
+ interrupted = await conversation_service.interrupt_conversation(conversation_id)
233
+ if not interrupted:
234
+ raise HTTPException(status.HTTP_400_BAD_REQUEST)
235
+ return Success()
236
+
237
+
190
238
  @conversation_router.delete(
191
239
  "/{conversation_id}", responses={404: {"description": "Item not found"}}
192
240
  )
@@ -407,6 +455,7 @@ async def condense_conversation(
407
455
  async def fork_conversation(
408
456
  conversation_id: UUID,
409
457
  request: Annotated[ForkConversationRequest, Body()] = ForkConversationRequest(), # noqa: B008
458
+ include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
410
459
  conversation_service: ConversationService = Depends(get_conversation_service),
411
460
  ) -> ConversationInfo:
412
461
  """Fork a conversation, deep-copying its event history.
@@ -432,4 +481,6 @@ async def fork_conversation(
432
481
  status.HTTP_404_NOT_FOUND,
433
482
  detail="Source conversation not found",
434
483
  )
484
+ if not include_skills:
485
+ info = trim_conversation_response_skills(info)
435
486
  return info
@@ -14,11 +14,13 @@ from pydantic import SecretStr
14
14
  from openhands.agent_server.conversation_service import ConversationService
15
15
  from openhands.agent_server.dependencies import get_conversation_service
16
16
  from openhands.agent_server.models import (
17
+ INCLUDE_SKILLS_PARAM_TITLE,
17
18
  ACPConversationInfo,
18
19
  ACPConversationPage,
19
20
  ConversationSortOrder,
20
21
  SendMessageRequest,
21
22
  StartACPConversationRequest,
23
+ trim_conversation_response_skills,
22
24
  )
23
25
  from openhands.sdk import LLM, Agent, TextContent
24
26
  from openhands.sdk.agent.acp_agent import ACPAgent
@@ -76,6 +78,7 @@ async def search_acp_conversations(
76
78
  ConversationSortOrder,
77
79
  Query(title="Sort order for conversations"),
78
80
  ] = ConversationSortOrder.CREATED_AT_DESC,
81
+ include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
79
82
  conversation_service: ConversationService = Depends(get_conversation_service),
80
83
  ) -> ACPConversationPage:
81
84
  """Search conversations using the ACP-capable contract.
@@ -85,9 +88,18 @@ async def search_acp_conversations(
85
88
  """
86
89
  assert limit > 0
87
90
  assert limit <= 100
88
- return await conversation_service.search_acp_conversations(
91
+ page = await conversation_service.search_acp_conversations(
89
92
  page_id, limit, status, sort_order
90
93
  )
94
+ if not include_skills:
95
+ page = page.model_copy(
96
+ update={
97
+ "items": [
98
+ trim_conversation_response_skills(item) for item in page.items
99
+ ]
100
+ }
101
+ )
102
+ return page
91
103
 
92
104
 
93
105
  @conversation_router_acp.get("/count", deprecated=True)
@@ -113,6 +125,7 @@ async def count_acp_conversations(
113
125
  )
114
126
  async def get_acp_conversation(
115
127
  conversation_id: UUID,
128
+ include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
116
129
  conversation_service: ConversationService = Depends(get_conversation_service),
117
130
  ) -> ACPConversationInfo:
118
131
  """Get a conversation using the ACP-capable contract.
@@ -123,12 +136,15 @@ async def get_acp_conversation(
123
136
  conversation = await conversation_service.get_acp_conversation(conversation_id)
124
137
  if conversation is None:
125
138
  raise HTTPException(status.HTTP_404_NOT_FOUND)
139
+ if not include_skills:
140
+ conversation = trim_conversation_response_skills(conversation)
126
141
  return conversation
127
142
 
128
143
 
129
144
  @conversation_router_acp.get("", deprecated=True)
130
145
  async def batch_get_acp_conversations(
131
146
  ids: Annotated[list[UUID], Query()],
147
+ include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
132
148
  conversation_service: ConversationService = Depends(get_conversation_service),
133
149
  ) -> list[ACPConversationInfo | None]:
134
150
  """Batch get conversations using the ACP-capable contract.
@@ -137,7 +153,13 @@ async def batch_get_acp_conversations(
137
153
  Use ``/api/conversations`` instead.
138
154
  """
139
155
  assert len(ids) < 100
140
- return await conversation_service.batch_get_acp_conversations(ids)
156
+ conversations = await conversation_service.batch_get_acp_conversations(ids)
157
+ if not include_skills:
158
+ return [
159
+ trim_conversation_response_skills(c) if c is not None else None
160
+ for c in conversations
161
+ ]
162
+ return conversations
141
163
 
142
164
 
143
165
  @conversation_router_acp.post("", deprecated=True)
@@ -147,6 +169,7 @@ async def start_acp_conversation(
147
169
  Body(examples=START_ACP_CONVERSATION_EXAMPLES),
148
170
  ],
149
171
  response: Response,
172
+ include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
150
173
  conversation_service: ConversationService = Depends(get_conversation_service),
151
174
  ) -> ACPConversationInfo:
152
175
  """Start a conversation using the ACP-capable contract.
@@ -157,4 +180,6 @@ async def start_acp_conversation(
157
180
  """
158
181
  info, is_new = await conversation_service.start_acp_conversation(request)
159
182
  response.status_code = status.HTTP_201_CREATED if is_new else status.HTTP_200_OK
183
+ if not include_skills:
184
+ info = trim_conversation_response_skills(info)
160
185
  return info
@@ -649,6 +649,25 @@ class ConversationService:
649
649
  await self._notify_conversation_webhooks(conversation_info)
650
650
  return bool(event_service)
651
651
 
652
+ async def interrupt_conversation(self, conversation_id: UUID) -> bool:
653
+ """Immediately cancel an in-flight LLM call for a conversation.
654
+
655
+ Unlike :meth:`pause_conversation`, which waits for the current
656
+ LLM request to finish, this cancels the running ``arun()`` task
657
+ so the interruption takes effect mid-stream.
658
+ """
659
+ if self._event_services is None:
660
+ raise ValueError("inactive_service")
661
+ event_service = self._event_services.get(conversation_id)
662
+ if event_service:
663
+ await event_service.interrupt()
664
+ state = await event_service.get_state()
665
+ conversation_info = _compose_webhook_conversation_info(
666
+ event_service.stored, state
667
+ )
668
+ await self._notify_conversation_webhooks(conversation_info)
669
+ return bool(event_service)
670
+
652
671
  async def resume_conversation(self, conversation_id: UUID) -> bool:
653
672
  if self._event_services is None:
654
673
  raise ValueError("inactive_service")
@@ -18,6 +18,7 @@ from openhands.agent_server.models import (
18
18
  )
19
19
  from openhands.agent_server.pub_sub import PubSub, Subscriber
20
20
  from openhands.sdk import LLM, AgentBase, Event, Message, get_logger
21
+ from openhands.sdk.conversation.base import BaseConversation
21
22
  from openhands.sdk.conversation.impl.local_conversation import LocalConversation
22
23
  from openhands.sdk.conversation.response_utils import get_agent_final_response
23
24
  from openhands.sdk.conversation.secret_registry import SecretValue
@@ -706,8 +707,11 @@ class EventService:
706
707
  """Run the conversation asynchronously in the background.
707
708
 
708
709
  This method starts the conversation run in a background task and returns
709
- immediately. The conversation status can be monitored via the
710
- GET /api/conversations/{id} endpoint or WebSocket events.
710
+ immediately. When possible, the conversation is driven via its native
711
+ ``arun()`` coroutine so LLM I/O does not tie up a thread-pool worker.
712
+ For conversations that do not expose ``arun()`` (e.g., custom
713
+ subclasses), the synchronous ``run()`` is executed in the thread pool as
714
+ before.
711
715
 
712
716
  Raises:
713
717
  ValueError: If the service is inactive or conversation is already running.
@@ -735,7 +739,28 @@ class EventService:
735
739
 
736
740
  async def _run_and_publish():
737
741
  try:
738
- await loop.run_in_executor(self._run_executor, conversation.run)
742
+ # Prefer the native async path when available so the event
743
+ # loop is free during LLM I/O. Fall back to thread-pool
744
+ # execution for backward compatibility.
745
+ #
746
+ # Both guards are required:
747
+ # • iscoroutinefunction – filters out non-async objects
748
+ # (e.g. MagicMock in tests).
749
+ # • override check – BaseConversation defines a default
750
+ # ``async def arun()`` that delegates to sync ``run()``,
751
+ # so iscoroutinefunction alone is always True for real
752
+ # subclasses. We detect an *actual* override to avoid
753
+ # running a sync-only subclass on the event loop.
754
+ arun = getattr(conversation, "arun", None)
755
+ has_native_arun = (
756
+ arun is not None
757
+ and asyncio.iscoroutinefunction(arun)
758
+ and type(conversation).arun is not BaseConversation.arun
759
+ )
760
+ if has_native_arun:
761
+ await conversation.arun()
762
+ else:
763
+ await loop.run_in_executor(self._run_executor, conversation.run)
739
764
  except Exception:
740
765
  logger.exception("Error during conversation run")
741
766
  finally:
@@ -787,6 +812,28 @@ class EventService:
787
812
  # Publish state update after pause to ensure stats are updated
788
813
  await self._publish_state_update()
789
814
 
815
+ async def interrupt(self):
816
+ """Immediately cancel an in-flight async LLM call.
817
+
818
+ Delegates to :meth:`LocalConversation.interrupt` which cancels the
819
+ ``arun()`` task. If no async run is in progress the call falls
820
+ back to :meth:`pause`.
821
+ """
822
+ if self._conversation:
823
+ self._conversation.interrupt()
824
+ # Wait for the run task to finish so we can publish the final
825
+ # state update (PAUSED + InterruptEvent) cleanly.
826
+ if self._run_task is not None and not self._run_task.done():
827
+ with suppress(Exception):
828
+ await asyncio.wait_for(self._run_task, timeout=5.0)
829
+ # Only clear _run_task if it actually finished; if
830
+ # wait_for timed out the task may still be running and
831
+ # clearing prematurely would allow a second run() to
832
+ # start while the first is still in progress.
833
+ if self._run_task is not None and self._run_task.done():
834
+ self._run_task = None
835
+ await self._publish_state_update()
836
+
790
837
  async def update_secrets(self, secrets: dict[str, SecretValue]):
791
838
  """Update secrets in the conversation."""
792
839
  if not self._conversation:
@@ -832,8 +879,15 @@ class EventService:
832
879
  logger.warning(
833
880
  "Failed to pause conversation during close", exc_info=True
834
881
  )
882
+ # Cancel the run task so arun()'s CancelledError handler can
883
+ # transition to PAUSED cleanly. For the legacy thread-pool
884
+ # path the underlying thread keeps running but the wrapper
885
+ # task still settles, unblocking the wait below.
886
+ self._run_task.cancel()
835
887
  try:
836
888
  await asyncio.wait_for(self._run_task, timeout=10.0)
889
+ except asyncio.CancelledError:
890
+ pass # Expected after cancel()
837
891
  except Exception as exc:
838
892
  logger.warning("Run task did not exit cleanly during close: %s", exc)
839
893
  self._run_task = None
@@ -0,0 +1,225 @@
1
+ """MCP router for OpenHands SDK.
2
+
3
+ Exposes a single endpoint, ``POST /api/mcp/test``, that lets clients verify
4
+ a candidate MCP server configuration in isolation -- before persisting it
5
+ to settings, where a misconfiguration would otherwise surface only at
6
+ conversation start (and there manifest as a noisy traceback that aborts
7
+ agent initialization).
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.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ from typing import Annotated, Any, Literal
18
+
19
+ from fastapi import APIRouter
20
+ from pydantic import BaseModel, Field, model_validator
21
+
22
+ from openhands.sdk.logger import get_logger
23
+ from openhands.sdk.mcp import create_mcp_tools
24
+ from openhands.sdk.mcp.exceptions import MCPError, MCPTimeoutError
25
+
26
+
27
+ logger = get_logger(__name__)
28
+
29
+ mcp_router = APIRouter(prefix="/mcp", tags=["MCP"])
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Request / response models
34
+ # ---------------------------------------------------------------------------
35
+ #
36
+ # We accept a single server spec instead of the full ``MCPConfig`` map. The
37
+ # UI flow this powers ("add a new MCP server") always validates one server
38
+ # at a time, and keeping the request shape narrow avoids exposing tuple-of-
39
+ # transports semantics the caller doesn't need.
40
+
41
+ _DEFAULT_SERVER_NAME = "test-server"
42
+
43
+
44
+ class _StdioMCPServerSpec(BaseModel):
45
+ """Stdio (subprocess) MCP server spec.
46
+
47
+ Mirrors the subset of ``fastmcp.mcp_config.StdioMCPServer`` fields the
48
+ OpenHands UI exposes today.
49
+ """
50
+
51
+ type: Literal["stdio"] = "stdio"
52
+ command: str = Field(..., min_length=1, description="Executable to invoke")
53
+ args: list[str] = Field(default_factory=list)
54
+ env: dict[str, str] = Field(default_factory=dict)
55
+ cwd: str | None = None
56
+
57
+
58
+ class _RemoteMCPServerSpec(BaseModel):
59
+ """Remote (HTTP / SSE) MCP server spec."""
60
+
61
+ # ``shttp`` is the alias the OpenHands settings layer uses for
62
+ # streamable-http; we accept both spellings so the UI can forward
63
+ # its own value unchanged.
64
+ type: Literal["http", "shttp", "streamable-http", "sse"]
65
+ url: str = Field(..., min_length=1)
66
+ headers: dict[str, str] = Field(default_factory=dict)
67
+ api_key: str | None = Field(
68
+ default=None,
69
+ description=(
70
+ "Bearer token. If provided, sent as 'Authorization: Bearer <token>'."
71
+ ),
72
+ )
73
+
74
+ def to_fastmcp_dict(self) -> dict[str, Any]:
75
+ # fastmcp's RemoteMCPServer accepts "http", "streamable-http", "sse";
76
+ # collapse the OpenHands-specific "shttp" alias to "http".
77
+ transport = "http" if self.type == "shttp" else self.type
78
+ out: dict[str, Any] = {"url": self.url, "transport": transport}
79
+ headers = dict(self.headers)
80
+ if self.api_key:
81
+ # Don't clobber a caller-provided Authorization header.
82
+ headers.setdefault("Authorization", f"Bearer {self.api_key}")
83
+ if headers:
84
+ out["headers"] = headers
85
+ return out
86
+
87
+
88
+ class MCPTestRequest(BaseModel):
89
+ """Body for ``POST /api/mcp/test``."""
90
+
91
+ name: str = Field(
92
+ default=_DEFAULT_SERVER_NAME,
93
+ min_length=1,
94
+ max_length=128,
95
+ description=(
96
+ "Name to use for the server inside the temporary MCPConfig. "
97
+ "Only affects error messages -- does not need to match any "
98
+ "persisted setting."
99
+ ),
100
+ )
101
+ server: Annotated[
102
+ _StdioMCPServerSpec | _RemoteMCPServerSpec,
103
+ Field(discriminator="type"),
104
+ ]
105
+ timeout: float = Field(
106
+ default=15.0,
107
+ gt=0,
108
+ le=120,
109
+ description="Seconds to wait for connection + tools/list to complete.",
110
+ )
111
+
112
+ @model_validator(mode="after")
113
+ def _strip_name(self) -> MCPTestRequest:
114
+ # Mirror the validation MCPConfig itself applies to server keys --
115
+ # whitespace-only names would silently bypass min_length=1 above.
116
+ self.name = self.name.strip() or _DEFAULT_SERVER_NAME
117
+ return self
118
+
119
+
120
+ class MCPTestSuccess(BaseModel):
121
+ """Response when the candidate server connects and lists its tools."""
122
+
123
+ ok: Literal[True] = True
124
+ tools: list[str] = Field(
125
+ default_factory=list,
126
+ description="Names of tools advertised by the MCP server.",
127
+ )
128
+
129
+
130
+ class MCPTestFailure(BaseModel):
131
+ """Response when the candidate server fails to connect or list tools.
132
+
133
+ The endpoint returns HTTP 200 in both success and failure cases: a
134
+ failure here is the *expected* outcome of validating a user-supplied
135
+ config, not a server-side error. The structured shape makes it easy
136
+ for the UI to render an actionable message.
137
+ """
138
+
139
+ ok: Literal[False] = False
140
+ error: str = Field(description="Human-readable error message.")
141
+ error_kind: Literal["timeout", "connection", "unknown"] = Field(
142
+ description="Coarse error classification, useful for branching UI."
143
+ )
144
+
145
+
146
+ MCPTestResponse = MCPTestSuccess | MCPTestFailure
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # Endpoint
151
+ # ---------------------------------------------------------------------------
152
+
153
+
154
+ def _server_to_fastmcp_dict(spec: _StdioMCPServerSpec | _RemoteMCPServerSpec) -> dict:
155
+ if isinstance(spec, _StdioMCPServerSpec):
156
+ out: dict[str, Any] = {"command": spec.command, "args": list(spec.args)}
157
+ if spec.env:
158
+ out["env"] = dict(spec.env)
159
+ if spec.cwd:
160
+ out["cwd"] = spec.cwd
161
+ return out
162
+ return spec.to_fastmcp_dict()
163
+
164
+
165
+ def _probe_mcp_server(request: MCPTestRequest) -> MCPTestResponse:
166
+ """Synchronous probe -- safe to run inside ``run_in_executor``.
167
+
168
+ ``create_mcp_tools`` already runs its own event loop in a background
169
+ thread via ``MCPClient.call_async_from_sync``. We deliberately do not
170
+ call it from the FastAPI request task; instead the caller hops into a
171
+ threadpool first.
172
+ """
173
+
174
+ config = {"mcpServers": {request.name: _server_to_fastmcp_dict(request.server)}}
175
+
176
+ try:
177
+ # ``create_mcp_tools`` returns a client that owns a background loop
178
+ # and a (possibly long-lived) subprocess. Use the context-manager
179
+ # form so we always tear it down, even when listing succeeded.
180
+ with create_mcp_tools(config, timeout=request.timeout) as client:
181
+ return MCPTestSuccess(tools=[tool.name for tool in client.tools])
182
+ except MCPTimeoutError as exc:
183
+ logger.info("MCP test timed out for server %r: %s", request.name, exc)
184
+ return MCPTestFailure(error=str(exc), error_kind="timeout")
185
+ except MCPError as exc:
186
+ # ``MCPError("MCP Connection Failure")`` is what client.connect()
187
+ # raises when the underlying fastmcp client fails to start. Surface
188
+ # the root-cause message (e.g. "sh: 1: mcp-server-github: Permission
189
+ # denied") because the wrapper alone isn't useful.
190
+ cause = exc.__cause__ or exc.__context__
191
+ detail = str(cause) if cause else str(exc) or "Failed to connect to MCP server"
192
+ logger.info(
193
+ "MCP test connection failed for server %r: %s", request.name, detail
194
+ )
195
+ return MCPTestFailure(error=detail, error_kind="connection")
196
+ except Exception as exc: # noqa: BLE001 - we want to surface anything else
197
+ # Any other exception is unexpected but should still return a
198
+ # structured response: the UI can't recover from a 500.
199
+ logger.warning(
200
+ "MCP test failed unexpectedly for server %r",
201
+ request.name,
202
+ exc_info=True,
203
+ )
204
+ return MCPTestFailure(
205
+ error=f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__,
206
+ error_kind="unknown",
207
+ )
208
+
209
+
210
+ @mcp_router.post(
211
+ "/test",
212
+ response_model=MCPTestResponse,
213
+ summary="Test an MCP server configuration",
214
+ description=(
215
+ "Attempt to connect to a candidate MCP server and list its tools, "
216
+ "without persisting any settings. Useful for validating user input "
217
+ "in 'add MCP server' flows before storing the config. "
218
+ "Returns 200 with `ok=false` for connection / timeout failures "
219
+ "(those are expected during validation, not server errors)."
220
+ ),
221
+ )
222
+ async def test_mcp_server(request: MCPTestRequest) -> MCPTestResponse:
223
+ """Probe a single MCP server config and report whether it works."""
224
+ loop = asyncio.get_running_loop()
225
+ return await loop.run_in_executor(None, _probe_mcp_server, request)