Conversation
There was a problem hiding this comment.
Pull request overview
Adds support for MCP’s streamable-http transport in the CLI runtime so sessions can connect over HTTP to a shared server process instead of spawning per-session stdio processes.
Changes:
- Extend
McpServerconfig model withtransport(defaultstdio) and optionalurl, plus helperis_streamable_http. - Refactor session handling into a transport-agnostic base class, with concrete
stdioandstreamable-httpsession implementations. - Update runtime to spawn/monitor a shared HTTP server process, wait for readiness, and register tools over streamable-http when configured.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/uipath_mcp/_cli/_utils/_config.py |
Adds transport/url fields and streamable-http detection to server config model. |
src/uipath_mcp/_cli/_runtime/_session.py |
Introduces BaseSessionServer relay logic and adds StreamableHttpSessionServer. |
src/uipath_mcp/_cli/_runtime/_runtime.py |
Implements shared streamable-http server process lifecycle + registration and per-session transport selection. |
src/uipath_mcp/_cli/_runtime/_factory.py |
Adds streamable-http config validation and adjusts error categorization for server-not-found. |
Comments suppressed due to low confidence (4)
src/uipath_mcp/_cli/_utils/_config.py:52
__repr__rendersurlasurl='{self.url}', which printsurl='None'when unset and can look like an actual string value in logs. Consider omittingurlwhen it isNone, or using{self.url!r}without surrounding quotes so the representation is unambiguous.
def __repr__(self) -> str:
return f"McpServer(name='{self.name}', type='{self.type}', transport='{self.transport}', command='{self.command}', args={self.args}, url='{self.url}')"
src/uipath_mcp/_cli/_utils/_config.py:47
to_dict()now always emits atransportkey becausetransportdefaults to'stdio'and the condition isif self.transport:. If the intent is to preserve the original config shape (only includetransportwhen explicitly configured), consider tracking whether it was provided in the source config and only serializing it in that case.
def to_dict(self) -> dict[str, Any]:
"""Convert the server model back to a dictionary."""
result: dict[str, Any] = {
"type": self.type,
"command": self.command,
"args": self.args,
}
if self.transport:
result["transport"] = self.transport
if self.url:
src/uipath_mcp/_cli/_runtime/_runtime.py:560
- In the streamable-http registration path,
_wait_for_http_server_ready()is called before validating thatself._server.urlis set, and it can raise a plainValueError(and after_start_http_server_process()has already spawned a process). Recommend validatingurl(and raisingUiPathMcpRuntimeErrorwith CONFIGURATION_ERROR) before starting the process / waiting for readiness, so misconfiguration fails fast and consistently.
if self._server.is_streamable_http:
# spawn process, wait for readiness, connect via HTTP
await self._start_http_server_process()
await self._wait_for_http_server_ready()
src/uipath_mcp/_cli/_runtime/_factory.py:131
McpServer.commandis coerced withstr(...), so a missing command becomes the literal string'None'. This makes validation brittle (requiringserver.command == "None"checks). Consider keepingcommandasstr | NoneinMcpServer(no coercion) and validating againstNone/empty, which will simplify this streamable-http validation and avoid propagating'None'into subprocess execution.
if not server.command or server.command == "None":
raise UiPathMcpRuntimeError(
McpErrorCode.CONFIGURATION_ERROR,
"Invalid configuration",
f"Server '{entrypoint}' uses streamable-http transport but 'command' is not specified in mcp.json",
UiPathErrorCategory.USER,
)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
a5b4bef to
3fd3fa6
Compare
3fd3fa6 to
f9c8cd9
Compare
f9c8cd9 to
c1fc90c
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 6 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (5)
src/uipath_mcp/_cli/_runtime/_runtime.py:451
_http_server_stderr_linesgrows without bound as_drain_http_stderrappends every stderr line. For long-running servers this can become a memory leak; consider storing only the last N lines (e.g.,collections.deque(maxlen=...)) or truncating when the list exceeds a limit.
async for line in self._http_server_process.stderr:
decoded = line.decode("utf-8", errors="replace").rstrip()
self._http_server_stderr_lines.append(decoded)
logger.debug(f"HTTP server stderr: {decoded}")
src/uipath_mcp/_cli/_runtime/_session.py:371
_run_http_sessioncatchesException, which includesasyncio.CancelledErrorin Python 3.11. This will log an error during normal shutdown (whenstop()cancels the task) and suppress cancellation semantics. Handleasyncio.CancelledErrorexplicitly (re-raise) and reserve error logging for unexpected failures.
except Exception as e:
logger.error(
f"Unexpected error for HTTP session {self._session_id}: {e}",
exc_info=True,
)
src/uipath_mcp/_cli/_utils/_config.py:47
McpServer.to_dict()will always includetransportbecause the default "stdio" is truthy. If this dict is used to persist config, it will start writingtransport: stdioeven when the user didn’t specify it. Consider only includingtransportwhen it differs from the default or was explicitly present in the source config.
if self.transport:
result["transport"] = self.transport
if self.url:
src/uipath_mcp/_cli/_utils/_config.py:52
McpServer.__repr__wrapsurlin quotes unconditionally, sourl=Noneis rendered asurl='None', which is misleading in logs/debugging. Consider renderingurlvia{self.url!r}(or conditionally omitting it) soNoneis clearly represented.
def __repr__(self) -> str:
return f"McpServer(name='{self.name}', type='{self.type}', transport='{self.transport}', command='{self.command}', args={self.args}, url='{self.url}')"
src/uipath_mcp/_cli/_runtime/_runtime.py:551
- If the shared HTTP server process exits unexpectedly,
_monitor_http_server_processcurrently logs and stops HTTP sessions but leaves the runtime running. Consider also triggering a runtime abort/cancel (or a controlled restart) so the runtime doesn’t remain “up” in a broken state.
if not self._cancel_event.is_set():
logger.error(
f"HTTP server process exited unexpectedly with code {returncode}"
)
# Stop all HTTP sessions, they will fail on next request anyway
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| except httpx.HTTPError: | ||
| # Any other HTTP error means server is listening | ||
| logger.info("HTTP server is ready (responded with error, but is up)") | ||
| return |
There was a problem hiding this comment.
In _wait_for_http_server_ready, treating any httpx.HTTPError as “server is ready” can incorrectly mark readiness on ReadTimeout/WriteTimeout/protocol errors (port open but server not usable). Consider retrying on timeout-related errors and only treating an actual response (any status code) or HTTPStatusError as readiness.
Adds support for MCP servers that use the
streamable-httptransport.Previously, the runtime only worked with stdio-based servers (one process per session). With this change, a server configured with
transport: streamable-httpand aurlinmcp.jsonwill have its process started once and shared across all sessions, with each session connecting over HTTP viastreamable_http_client.The session layer was refactored into a
BaseSessionServerwith two implementations:SessionServerforstdioStreamableHttpSessionServerforHTTPThe runtime manages the shared process lifecycle: spawning it before registration, polling until it's ready, monitoring it for unexpected exits, and shutting it down.