Skip to content

Commit 90a6728

Browse files
edis-uipathclaude
andcommitted
feat(mcp-jobs): framework-neutral core for long-running UiPath jobs over MCP
Adds `uipath.platform.mcp_jobs`, the MCP-SDK-free core for the Option-6 "UiPath jobs over MCP via _meta" contract: - `UiPathJobHandle` / `JobStart` value objects (frozen dataclasses). - `_meta` helpers for the single `uipath.com/job` key — `build_start_meta`, `build_fetch_meta`, `read_job_handle`, `read_job_version` — operating on plain dicts so they work across any mcp SDK version (no `mcp` import). - `McpJobExecutor` Protocol (`run(*, start, fetch, tool_name)`) and the neutral `BlockingJobExecutor` default (polls `jobs.retrieve_async` to a terminal state, then FETCHes). Framework adapters (e.g. LangGraph suspend) live downstream. Framework integrations reuse this core and supply only their own executor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent dce87b2 commit 90a6728

8 files changed

Lines changed: 507 additions & 2 deletions

File tree

packages/uipath-platform/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-platform"
3-
version = "0.1.64"
3+
version = "0.1.65"
44
description = "HTTP client library for programmatic access to UiPath Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Framework-neutral client core for long-running UiPath jobs over MCP.
2+
3+
This package owns the ``uipath.com/job`` ``_meta`` contract and the executor
4+
abstraction used to suspend/await a UiPath job that an MCP server started behind a
5+
``tools/call``. It never imports the ``mcp`` SDK or any agent framework, so every
6+
integration (uipath-langchain, future SDKs) can reuse it and supply only its own
7+
:class:`McpJobExecutor`. See the design doc "Long-running UiPath jobs over MCP".
8+
"""
9+
10+
from ._executor import (
11+
BlockingJobExecutor,
12+
FetchFn,
13+
JobStatusReader,
14+
McpJobExecutor,
15+
StartFn,
16+
)
17+
from ._handle import JobStart, UiPathJobHandle
18+
from ._meta import (
19+
JOB_META_KEY,
20+
JOB_PROTOCOL_VERSION,
21+
build_fetch_meta,
22+
build_start_meta,
23+
read_job_handle,
24+
read_job_version,
25+
)
26+
27+
__all__ = [
28+
"JOB_META_KEY",
29+
"JOB_PROTOCOL_VERSION",
30+
"BlockingJobExecutor",
31+
"FetchFn",
32+
"JobStart",
33+
"JobStatusReader",
34+
"McpJobExecutor",
35+
"StartFn",
36+
"UiPathJobHandle",
37+
"build_fetch_meta",
38+
"build_start_meta",
39+
"read_job_handle",
40+
"read_job_version",
41+
]
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"""Framework-neutral execution of MCP-backed UiPath jobs.
2+
3+
The START ``tools/call`` and the FETCH re-call are MCP-shaped concerns owned by the
4+
caller (they touch the ``mcp`` SDK). This module models only HOW to await the started
5+
job — by suspending the host (a framework-specific adapter) or by polling (the neutral
6+
:class:`BlockingJobExecutor` default). See the design doc "Long-running UiPath jobs
7+
over MCP".
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import asyncio
13+
from typing import Any, Awaitable, Callable, Optional, Protocol, runtime_checkable
14+
15+
from ..orchestrator.job import Job, JobState
16+
from ._handle import JobStart, UiPathJobHandle
17+
18+
__all__ = [
19+
"BlockingJobExecutor",
20+
"FetchFn",
21+
"McpJobExecutor",
22+
"StartFn",
23+
]
24+
25+
StartFn = Callable[[], Awaitable[JobStart]]
26+
"""Issues the START ``tools/call`` once and returns its :class:`JobStart` outcome."""
27+
28+
FetchFn = Callable[[UiPathJobHandle], Awaitable[Any]]
29+
"""Re-calls the tool with the FETCH ``_meta`` for a handle; returns the job result."""
30+
31+
_TERMINAL_STATES = frozenset({JobState.SUCCESSFUL.value, JobState.FAULTED.value})
32+
33+
34+
@runtime_checkable
35+
class McpJobExecutor(Protocol):
36+
"""Awaits a job-backed MCP tool call and returns its final output.
37+
38+
An implementation owns the START → await → FETCH lifecycle for one tool call:
39+
it invokes ``start`` (exactly once, inside its durable boundary when it
40+
suspends), waits for the job to finish (by suspending the host or by polling),
41+
then returns ``await fetch(handle)``. Implementations differ only in *how* they
42+
wait, never in the wire contract.
43+
"""
44+
45+
async def run(self, *, start: StartFn, fetch: FetchFn, tool_name: str) -> Any:
46+
"""Run one job-backed tool call to completion.
47+
48+
Args:
49+
start: Issues the START ``tools/call`` once; returns a :class:`JobStart`.
50+
fetch: Re-calls the tool with the FETCH ``_meta`` for a handle.
51+
tool_name: The MCP tool name (for diagnostics/tracing).
52+
53+
Returns:
54+
The tool's final output — the FETCH result for a job-backed call, or the
55+
normal tool result when the call did not start a job.
56+
"""
57+
...
58+
59+
60+
@runtime_checkable
61+
class JobStatusReader(Protocol):
62+
"""Minimal jobs-service shape consumed by :class:`BlockingJobExecutor`."""
63+
64+
async def retrieve_async(
65+
self, job_key: str, *, folder_key: Optional[str] = None
66+
) -> Job:
67+
"""Retrieve the job identified by ``job_key`` in folder ``folder_key``."""
68+
...
69+
70+
71+
class BlockingJobExecutor:
72+
"""Neutral default executor: poll the job to a terminal state, then FETCH.
73+
74+
This executor does **not** suspend the host. It is correct in any environment
75+
(a CLI, an eval harness, a framework without durable interrupts): the child job
76+
stays running while we poll, but the tool always returns the right result. Hosts
77+
that *can* suspend should inject a framework-specific executor instead (for
78+
example a LangGraph one that interrupts on the job and resumes when it finishes).
79+
"""
80+
81+
def __init__(
82+
self,
83+
jobs: Optional[JobStatusReader] = None,
84+
*,
85+
poll_interval: float = 5.0,
86+
timeout: Optional[float] = None,
87+
) -> None:
88+
"""Initialize the executor.
89+
90+
Args:
91+
jobs: A jobs service exposing
92+
``retrieve_async(job_key, *, folder_key)``. Defaults to
93+
``UiPath().jobs`` (constructed lazily) when ``None``.
94+
poll_interval: Seconds to wait between status polls.
95+
timeout: Optional overall timeout in seconds; ``None`` waits
96+
indefinitely.
97+
"""
98+
self._jobs = jobs
99+
self._poll_interval = poll_interval
100+
self._timeout = timeout
101+
102+
def _jobs_service(self) -> JobStatusReader:
103+
if self._jobs is None:
104+
from .._uipath import UiPath
105+
106+
self._jobs = UiPath().jobs
107+
return self._jobs
108+
109+
async def run(self, *, start: StartFn, fetch: FetchFn, tool_name: str) -> Any:
110+
"""Start the job, poll until terminal, then FETCH its result.
111+
112+
Args:
113+
start: Issues the START ``tools/call`` once.
114+
fetch: Re-calls the tool with the FETCH ``_meta`` for a handle.
115+
tool_name: The MCP tool name (for diagnostics/tracing).
116+
117+
Returns:
118+
The FETCH result for a job-backed call, or the normal tool result when
119+
the call did not start a job.
120+
"""
121+
outcome = await start()
122+
if outcome.handle is None:
123+
return outcome.result
124+
await self._wait_until_terminal(outcome.handle)
125+
return await fetch(outcome.handle)
126+
127+
async def _wait_until_terminal(self, handle: UiPathJobHandle) -> None:
128+
jobs = self._jobs_service()
129+
loop = asyncio.get_event_loop()
130+
deadline = None if self._timeout is None else loop.time() + self._timeout
131+
while True:
132+
job = await jobs.retrieve_async(
133+
handle.job_key, folder_key=handle.folder_key
134+
)
135+
if (job.state or "").lower() in _TERMINAL_STATES:
136+
return
137+
if deadline is not None and loop.time() >= deadline:
138+
raise TimeoutError(
139+
f"Job {handle.job_key} did not reach a terminal state "
140+
f"within {self._timeout}s"
141+
)
142+
await asyncio.sleep(self._poll_interval)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Framework-neutral value objects for MCP-backed UiPath jobs."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from typing import Any, Optional
7+
8+
__all__ = ["JobStart", "UiPathJobHandle"]
9+
10+
11+
@dataclass(frozen=True)
12+
class UiPathJobHandle:
13+
"""Handle to a UiPath job started behind an MCP ``tools/call``.
14+
15+
The server returns this in the START response ``_meta`` (under
16+
``uipath.com/job``). It is used to drive the job to completion (suspend +
17+
resume, or poll) and to FETCH its result with a follow-up ``tools/call``.
18+
19+
Attributes:
20+
job_key: The Orchestrator job key (GUID) — also the resume-trigger
21+
``item_key`` when the host suspends on the job.
22+
folder_key: The key of the folder the job runs in.
23+
"""
24+
25+
job_key: str
26+
folder_key: str
27+
28+
29+
@dataclass(frozen=True)
30+
class JobStart:
31+
"""Outcome of the START ``tools/call``.
32+
33+
Either the server handed back a job handle (the tool is job-backed and the
34+
server started a job) or it returned a normal tool result (a non-job tool, or
35+
no opt-in / an older server).
36+
37+
Attributes:
38+
handle: The job handle when the call started a job, else ``None``.
39+
result: The normalized tool result when ``handle`` is ``None``, else
40+
``None``.
41+
"""
42+
43+
handle: Optional[UiPathJobHandle]
44+
result: Any = None
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""The ``uipath.com/job`` ``_meta`` contract (framework- and MCP-SDK-neutral).
2+
3+
All proprietary signaling for long-running UiPath jobs over MCP rides ``_meta``
4+
under the single key ``uipath.com/job``:
5+
6+
* **Advertise** (server → client, ``InitializeResult._meta``): ``{"version": N}``
7+
— present only when the server can back tools with jobs.
8+
* **START** (client → server, request ``params._meta``): ``{"version": N}`` — no
9+
``key`` means "start the job and hand me a handle".
10+
* **Handle** (server → client, result ``_meta``): ``{"key", "folderKey"}`` — the
11+
started job.
12+
* **FETCH** (client → server, request ``params._meta``): ``{"key", "folderKey"}``
13+
— ``key`` present means "return the job's current status/result".
14+
15+
These helpers build and parse that key as plain ``dict`` / ``Mapping`` values, so
16+
they work against any MCP SDK version (the wire ``_meta`` is a plain JSON object)
17+
without importing ``mcp``.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from typing import Any, Dict, Mapping, Optional
23+
24+
from ._handle import UiPathJobHandle
25+
26+
__all__ = [
27+
"JOB_META_KEY",
28+
"JOB_PROTOCOL_VERSION",
29+
"build_fetch_meta",
30+
"build_start_meta",
31+
"read_job_handle",
32+
"read_job_version",
33+
]
34+
35+
JOB_META_KEY = "uipath.com/job"
36+
"""Reverse-DNS ``_meta`` key under which all job signaling lives."""
37+
38+
JOB_PROTOCOL_VERSION = 1
39+
"""Current ``uipath.com/job`` contract version emitted by this client."""
40+
41+
42+
def build_start_meta(version: int = JOB_PROTOCOL_VERSION) -> Dict[str, Any]:
43+
"""Build the START opt-in ``_meta`` (no ``key`` ⇒ START intent).
44+
45+
Args:
46+
version: The contract version to send on the call.
47+
48+
Returns:
49+
A ``_meta`` mapping to merge into ``CallToolRequest.params._meta``.
50+
"""
51+
return {JOB_META_KEY: {"version": version}}
52+
53+
54+
def build_fetch_meta(handle: UiPathJobHandle) -> Dict[str, Any]:
55+
"""Build the FETCH ``_meta`` for a started job (``key`` present ⇒ FETCH intent).
56+
57+
Args:
58+
handle: The job handle returned by the START response.
59+
60+
Returns:
61+
A ``_meta`` mapping to merge into ``CallToolRequest.params._meta``.
62+
"""
63+
return {JOB_META_KEY: {"key": handle.job_key, "folderKey": handle.folder_key}}
64+
65+
66+
def _job_section(meta: Optional[Mapping[str, Any]]) -> Optional[Mapping[str, Any]]:
67+
"""Return the ``uipath.com/job`` sub-object of a ``_meta`` mapping, if present."""
68+
if not meta:
69+
return None
70+
section = meta.get(JOB_META_KEY)
71+
return section if isinstance(section, Mapping) else None
72+
73+
74+
def read_job_handle(meta: Optional[Mapping[str, Any]]) -> Optional[UiPathJobHandle]:
75+
"""Parse a job handle from a result's ``_meta`` mapping.
76+
77+
Args:
78+
meta: The result ``_meta`` mapping (``result._meta`` / ``result.meta``).
79+
80+
Returns:
81+
A :class:`UiPathJobHandle` when both ``key`` and ``folderKey`` are present
82+
(a START response), else ``None`` (a normal result, or a version-only
83+
opt-in echoed back).
84+
"""
85+
section = _job_section(meta)
86+
if not section:
87+
return None
88+
key = section.get("key")
89+
folder_key = section.get("folderKey")
90+
if isinstance(key, str) and key and isinstance(folder_key, str) and folder_key:
91+
return UiPathJobHandle(job_key=key, folder_key=folder_key)
92+
return None
93+
94+
95+
def read_job_version(meta: Optional[Mapping[str, Any]]) -> Optional[int]:
96+
"""Parse the advertised / opted-in contract version from a ``_meta`` mapping.
97+
98+
Args:
99+
meta: A ``_meta`` mapping (an ``InitializeResult._meta`` advertisement, or
100+
a request opt-in).
101+
102+
Returns:
103+
The integer ``version`` when present, else ``None``.
104+
"""
105+
section = _job_section(meta)
106+
if not section:
107+
return None
108+
version = section.get("version")
109+
return version if isinstance(version, int) else None

0 commit comments

Comments
 (0)