Skip to content

Commit 7133dc6

Browse files
edis-uipathclaude
andcommitted
feat(mcp): suspend/resume the agent on long-running UiPath jobs (uipath.com/job), self-contained
Implements Option 6 of the long-running-jobs design entirely within uipath-langchain, with NO change to base uipath-python. A UiPath MCP server that backs a tool with an Orchestrator job advertises `uipath.com/job` on initialize; the client opts in per call and suspends the LangGraph agent while the job runs, resuming with the result — all over MCP `_meta`, so deployed agents gain the behavior on package upgrade with no agent.json change. Why self-contained (vs the base-SDK core in uipath-python #1717): base uipath-platform has zero MCP-protocol code today (only WaitJob*/CreateTask* wait-models and McpService CRUD). All MCP protocol code and every framework tool (process_tool, escalation_tool) already live here; an McpJobExecutor is the same kind of glue. This keeps base generic, removes the cross-package release coupling, and the base already provides the generic durable primitive we build on (WaitJobRaw + jobs service). See the §3b placement debate in mcp-longrunning-jobs-design.md. - `jobs.py` (new): the `uipath.com/job` `_meta` helpers + `UiPathJobHandle`, `JobStart`, `McpJobExecutor` Protocol, `BlockingJobExecutor` — framework- and MCP-SDK-neutral (plain dicts; imports only the already-published `Job`/`JobState`/jobs service). - `mcp_client.py`: read the initialize advertisement (`is_job_aware`/`job_version`), thread request `_meta` through `call_tool(..., meta=)`. - `mcp_tool.py`: `_invoke_job_aware` sends START `_meta`, parses the handle, delegates to the injected `McpJobExecutor` with neutral `start`/`fetch` closures. - `job_executor.py`: `LangGraphJobExecutor` (default) — START inside `@durable_interrupt`, interrupt with `WaitJobRaw`, on resume re-derive the handle and FETCH. `interrupt` is confined here. No `uipath-platform` floor bump, no #1717 dependency → CI is green standalone. This is an alternative to #906 (which stacks on #1717); pick one. Tests: 9 new (advertisement, START/FETCH `_meta`, executor suspend/resume/faulted); full test_mcp suite green (233 incl. circular-import guard); ruff + mypy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ccb95d3 commit 7133dc6

9 files changed

Lines changed: 765 additions & 16 deletions

File tree

src/uipath_langchain/agent/tools/mcp/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
"""MCP (Model Context Protocol) tools."""
22

3+
from .job_executor import LangGraphJobExecutor
4+
from .jobs import (
5+
BlockingJobExecutor,
6+
JobStart,
7+
McpJobExecutor,
8+
UiPathJobHandle,
9+
)
310
from .mcp_client import McpClient, SessionInfoFactory
411
from .mcp_tool import (
512
create_mcp_tools,
@@ -9,9 +16,14 @@
916
from .streamable_http import SessionInfo
1017

1118
__all__ = [
19+
"BlockingJobExecutor",
20+
"JobStart",
21+
"LangGraphJobExecutor",
1222
"McpClient",
23+
"McpJobExecutor",
1324
"SessionInfo",
1425
"SessionInfoFactory",
26+
"UiPathJobHandle",
1527
"create_mcp_tools_and_clients",
1628
"open_mcp_tools",
1729
"create_mcp_tools",

src/uipath_langchain/agent/tools/mcp/claude.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,15 @@ src/uipath_langchain/agent/tools/mcp/
2424
├── __init__.py # Public exports
2525
├── mcp_client.py # SessionInfoFactory, McpClient
2626
├── mcp_tool.py # Tool factory functions
27+
├── jobs.py # uipath.com/job _meta contract + executors (neutral)
28+
├── job_executor.py # LangGraphJobExecutor (uipath.com/job suspend/resume)
2729
└── streamable_http.py # SessionInfo, StreamableHTTPTransport (copied from MCP SDK)
2830
```
2931

3032
### Public Exports (`__init__.py`)
3133

3234
```python
35+
from .job_executor import LangGraphJobExecutor
3336
from .mcp_client import McpClient, SessionInfoFactory
3437
from .mcp_tool import (
3538
create_mcp_tools_and_clients,
@@ -502,6 +505,58 @@ endpoint (`GET/PUT agenthub_/design/debugstate/{agentId}/{key}`). It lives
502505
in `uipath-agents` because it depends on execution-type logic that belongs
503506
in the agent layer, not in the langchain tools layer.
504507

508+
## `uipath.com/job` — long-running job support
509+
510+
When a UiPath MCP server backs a tool with an Orchestrator **job**, the agent
511+
should *suspend* while the job runs and *resume* with its result instead of
512+
blocking. This is negotiated entirely through MCP `_meta` (no `agent.json`
513+
change), so a deployed agent gains the behavior on package upgrade. Full design:
514+
`mcp-longrunning-jobs-design.md` (workspace root).
515+
516+
**Flow (single key `uipath.com/job`, all in `_meta`):**
517+
518+
1. **Advertise** — on `initialize`, a job-capable server returns
519+
`InitializeResult._meta["uipath.com/job"] = {"version": 1}`.
520+
`McpClient._apply_job_advertisement()` reads it and sets `is_job_aware` /
521+
`job_version`. Re-read on every fresh `initialize`, so the flag is stable
522+
across a production suspend/resume.
523+
2. **START** — for a job-aware session, `mcp_tool._invoke_job_aware` sends
524+
`params._meta["uipath.com/job"] = {"version": N}` (no `key`) on `tools/call`
525+
via `McpClient.call_tool(..., meta=...)`. A job-backed tool returns
526+
`result._meta["uipath.com/job"] = {"key", "folderKey"}` immediately.
527+
3. **Suspend** — the `McpJobExecutor` (default `LangGraphJobExecutor`) interrupts
528+
with `WaitJobRaw(Job(id=0, key, folder_key), process_folder_key=folder_key)`
529+
inside `@durable_interrupt` (same mechanism as `process_tool`). The runtime
530+
persists a `JOB` resume trigger (`JOB_RAW` name → no output extraction);
531+
Orchestrator resumes the agent when the child job is terminal.
532+
4. **FETCH** — on resume the body is skipped; `interrupt(None)` returns the
533+
terminal `Job`. The executor re-derives `{key, folderKey}` from it and calls
534+
the neutral `fetch`, which re-issues `tools/call` (no args) with
535+
`params._meta["uipath.com/job"] = {"key", "folderKey"}`. The server returns
536+
the formatted result — that `CallToolResult` is the tool's output.
537+
538+
**Where the pieces live:**
539+
540+
| Piece | Location | Notes |
541+
|-------|----------|-------|
542+
| `_meta` build/parse, `UiPathJobHandle`, `JobStart`, `McpJobExecutor`, `BlockingJobExecutor` | `jobs.py` (this package, self-contained) | Framework- and MCP-SDK-neutral (plain dicts); no base-SDK change (see the §3b placement debate in the design doc) |
543+
| `is_job_aware`, `job_version`, `job_executor`, `call_tool(meta=)`, `_apply_job_advertisement` | `mcp_client.py` | Reads the advertisement; threads `_meta` |
544+
| `_invoke_job_aware`, `_normalize_tool_result`, `create_mcp_tools_and_clients(job_executor=)` | `mcp_tool.py` | Builds the neutral `start`/`fetch`; default executor = `LangGraphJobExecutor` |
545+
| `LangGraphJobExecutor` (the only place `interrupt` is called) | `job_executor.py` | Suspend → resume → fetch |
546+
547+
**Key invariants:**
548+
549+
- `interrupt` is confined to `LangGraphJobExecutor`. The neutral core never
550+
imports langgraph; the executor never imports `mcp`.
551+
- The job handle survives the suspend **only** via the `WaitJobRaw` payload
552+
(re-derived from the resumed `Job`), never via closures — the
553+
`@durable_interrupt` body does not re-run on resume.
554+
- Non-job tools on a job-aware server: the server returns a normal result (no
555+
handle); `LangGraphJobExecutor` resolves it via `SkipInterruptValue`
556+
(`_NonJobStartValue`) so the durable index stays aligned with no real suspend.
557+
- Old/non-UiPath servers never advertise → `is_job_aware` stays `False` → today's
558+
plain blocking path (back-compat, zero behavior change).
559+
505560
## Tests
506561

507562
Tests are in `tests/agent/tools/test_mcp/`.
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""LangGraph executor for MCP-backed UiPath jobs.
2+
3+
When an MCP ``tools/call`` starts a UiPath job (the server returns a
4+
``uipath.com/job`` handle), :class:`LangGraphJobExecutor` suspends the LangGraph
5+
agent on that job and resumes when it finishes — the same durable suspend/resume
6+
mechanism :mod:`uipath_langchain.agent.tools.process_tool` uses.
7+
8+
It mirrors ``process_tool`` exactly:
9+
10+
* the START ``tools/call`` runs **inside** ``@durable_interrupt`` so it executes
11+
exactly once (a resume re-runs the node but skips the body);
12+
* it interrupts with ``WaitJobRaw`` so the runtime persists a ``JOB`` resume
13+
trigger (with the ``JOB_RAW`` name → resume without output extraction) and
14+
Orchestrator resumes the agent when the child job reaches a terminal state;
15+
* on resume the body is skipped and ``interrupt(None)`` returns the terminal
16+
``Job``; we re-derive the ``{key, folderKey}`` handle from it and FETCH the
17+
result with a follow-up ``tools/call`` (the server formats the output).
18+
19+
The neutral wire work (building the START/FETCH ``_meta``, parsing the handle)
20+
lives in the sibling :mod:`~uipath_langchain.agent.tools.mcp.jobs` module; this class only owns the *suspend*
21+
policy, so ``interrupt`` is confined here.
22+
"""
23+
24+
from __future__ import annotations
25+
26+
from typing import Any
27+
28+
from uipath.platform.common import WaitJobRaw
29+
from uipath.platform.orchestrator import Job, JobState
30+
31+
from uipath_langchain._utils.durable_interrupt import (
32+
SkipInterruptValue,
33+
durable_interrupt,
34+
)
35+
36+
from .jobs import (
37+
FetchFn,
38+
JobStart,
39+
StartFn,
40+
UiPathJobHandle,
41+
)
42+
43+
44+
class _NonJobStartValue(SkipInterruptValue):
45+
"""Carries a non-job :class:`JobStart` back through ``@durable_interrupt``.
46+
47+
A job-aware client sends the START ``_meta`` on every call, but the server
48+
only returns a handle for job-backed tools. For a normal (non-job) result we
49+
must NOT suspend — yet the ``@durable_interrupt`` body still has to run on
50+
every pass to keep the durable index aligned. Returning this value injects the
51+
result into the scratchpad and resumes immediately, without a real suspend.
52+
"""
53+
54+
def __init__(self, outcome: JobStart) -> None:
55+
self._outcome = outcome
56+
57+
@property
58+
def resume_value(self) -> Any:
59+
"""The :class:`JobStart` to return to the executor without suspending."""
60+
return self._outcome
61+
62+
63+
class LangGraphJobExecutor:
64+
"""``McpJobExecutor`` that suspends the LangGraph agent on the started job."""
65+
66+
async def run(self, *, start: StartFn, fetch: FetchFn, tool_name: str) -> Any:
67+
"""Start the job, suspend until it finishes, then FETCH its result.
68+
69+
Args:
70+
start: Issues the START ``tools/call`` once; returns a :class:`JobStart`.
71+
fetch: Re-calls the tool with the FETCH ``_meta`` for a handle.
72+
tool_name: The MCP tool name (for diagnostics).
73+
74+
Returns:
75+
The FETCH result for a job-backed call, or the normal tool result when
76+
the call did not start a job.
77+
"""
78+
79+
@durable_interrupt
80+
async def _suspend_on_job() -> Any:
81+
outcome = await start()
82+
if outcome.handle is None:
83+
# Non-job tool / no opt-in: do not suspend; carry the result.
84+
return _NonJobStartValue(outcome)
85+
# The whole handle round-trips the suspend via this WaitJobRaw payload.
86+
return WaitJobRaw(
87+
job=Job(
88+
id=0,
89+
key=outcome.handle.job_key,
90+
folder_key=outcome.handle.folder_key,
91+
),
92+
process_folder_key=outcome.handle.folder_key,
93+
)
94+
95+
resumed = await _suspend_on_job()
96+
97+
if isinstance(resumed, JobStart):
98+
# Non-job path: the START result is the tool's output.
99+
return resumed.result
100+
101+
# Resume path: `resumed` is the runtime-materialized terminal raw Job.
102+
# WaitJobRaw skips state validation, so re-check for failure (as process_tool does).
103+
job = resumed
104+
if (job.state or "").lower() == JobState.FAULTED:
105+
return str(
106+
getattr(job, "info", None) or f"Job for tool '{tool_name}' faulted"
107+
)
108+
if not job.key or not job.folder_key:
109+
return str(
110+
getattr(job, "info", None)
111+
or f"Job for tool '{tool_name}' returned no key to fetch its result"
112+
)
113+
114+
# Re-derive the handle from the resumed Job and let the server format the result.
115+
handle = UiPathJobHandle(job_key=job.key, folder_key=job.folder_key)
116+
return await fetch(handle)

0 commit comments

Comments
 (0)