Skip to content

Commit 073f11c

Browse files
brianstrauchtconley1428
authored andcommitted
Add Temporal MCP transport samples
1 parent 8110628 commit 073f11c

10 files changed

Lines changed: 1946 additions & 17 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,25 +25,41 @@ jobs:
2525
env:
2626
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
2727
run: |
28-
if [[ -n "$BASE_SHA" ]] && git cat-file -e "$BASE_SHA^{commit}"; then
29-
changed_files="$(git diff --name-only "$BASE_SHA" "$GITHUB_SHA")"
30-
else
31-
changed_files="$(git ls-files)"
32-
fi
28+
python - <<'PY'
29+
import json
30+
import os
31+
import subprocess
32+
from pathlib import Path
3333
34-
projects_json="$({
35-
for project in \
36-
bedrock_agentcore/strands_agent \
37-
lambda_worker \
38-
openai_agents/mcp_v2
39-
do
40-
if grep -q "^${project}/" <<< "$changed_files"; then
41-
printf '%s\n' "$project"
42-
fi
43-
done
44-
} | jq -Rsc 'split("\n") | map(select(length > 0))')"
34+
from scripts.run_root_tool import find_nested_projects
4535
46-
echo "projects=$projects_json" >> "$GITHUB_OUTPUT"
36+
base = os.environ.get("BASE_SHA")
37+
if base and subprocess.run(
38+
["git", "cat-file", "-e", f"{base}^{{commit}}"],
39+
check=False,
40+
).returncode == 0:
41+
changed_output = subprocess.check_output(
42+
["git", "diff", "--name-only", base, os.environ["GITHUB_SHA"]],
43+
text=True,
44+
)
45+
else:
46+
changed_output = subprocess.check_output(
47+
["git", "ls-files"],
48+
text=True,
49+
)
50+
51+
changed_files = changed_output.splitlines()
52+
projects = [
53+
project
54+
for project in find_nested_projects(Path.cwd())
55+
if any(
56+
path == project or path.startswith(f"{project}/")
57+
for path in changed_files
58+
)
59+
]
60+
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
61+
output.write(f"projects={json.dumps(projects)}\n")
62+
PY
4763
4864
standalone-lint-test:
4965
name: Standalone (${{ matrix.project }}, Python ${{ matrix.python }})

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ without wrapping them in a workflow.
110110
* [sentry](sentry) - Report errors to Sentry.
111111
* [sleep_for_days](sleep_for_days) - A workflow that runs forever, sending an email every 30 days.
112112
* [strands_plugin](strands_plugin) - Run Strands Agents as durable Temporal workflows (model calls, tools, MCP, HITL).
113+
* [temporal_mcp](temporal_mcp) - Call MCP tools, prompts, and resources durably from Temporal workflows.
113114
* [trio_async](trio_async) - Use asyncio Temporal in Trio-based environments.
114115
* [updatable_timer](updatable_timer) - A timer that can be updated while sleeping.
115116
* [worker_multiprocessing](worker_multiprocessing) - Leverage Python multiprocessing to parallelize workflow tasks and other CPU bound operations by running multiple workers.

‎temporal_mcp/README.md‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Durable MCP clients
2+
3+
This standalone project demonstrates
4+
[`temporalio-mcp` 0.2.0](https://pypi.org/project/temporalio-mcp/), which lets
5+
Temporal Workflow code call MCP tools, prompts, and resources through durable
6+
Activities. The real MCP client, transport, network connections, and credentials
7+
remain on the Worker.
8+
9+
The same Workflow is available over three MCP SDK v2 client forms:
10+
11+
- `in-process` constructs the MCP server directly inside the Worker process.
12+
- `stdio` lets the Worker launch and manage `server.py` as a subprocess.
13+
- `streamable-http` connects the Worker to a separately running HTTP server.
14+
15+
## Prerequisites
16+
17+
- Python 3.10 or newer and [uv](https://docs.astral.sh/uv/)
18+
- A local Temporal server: `temporal server start-dev`
19+
20+
Change to this directory and install the locked environment:
21+
22+
```bash
23+
cd temporal_mcp
24+
uv sync --locked --all-groups
25+
```
26+
27+
## Run a sample
28+
29+
For the in-process transport, start the Worker and Workflow in separate shells:
30+
31+
```bash
32+
uv run run_worker.py in-process
33+
uv run run_workflow.py in-process
34+
```
35+
36+
The stdio transport uses the same commands. The Worker launches `server.py`
37+
automatically with the current Python interpreter:
38+
39+
```bash
40+
uv run run_worker.py stdio
41+
uv run run_workflow.py stdio
42+
```
43+
44+
Streamable HTTP needs a third shell for the MCP server:
45+
46+
```bash
47+
uv run server.py streamable-http
48+
uv run run_worker.py streamable-http
49+
uv run run_workflow.py streamable-http
50+
```
51+
52+
The HTTP server defaults to `http://127.0.0.1:8000/mcp`. Use `--host` and
53+
`--port` on `server.py` and `--http-url` on `run_worker.py` to change it.
54+
55+
## What the Workflow demonstrates
56+
57+
`MCPDemoWorkflow` lists and calls tools, lists and gets prompts, and lists and
58+
reads both static and templated resources. Each MCP operation is a Temporal
59+
Activity. The second `list_tools()` call uses the Workflow client's replay-safe
60+
cache and does not schedule another Activity.
61+
62+
The sample bounds each Activity attempt and the complete retry series. MCP tools
63+
still have at-least-once execution semantics, so tools with side effects should
64+
accept a stable idempotency key. Resolve URLs, tokens, and other secrets in the
65+
worker-side client factory rather than putting them in Workflow inputs or the
66+
factory argument, where they would be recorded in Workflow history.
67+
68+
Run all three transports end-to-end against a local Temporal test server with:
69+
70+
```bash
71+
uv run poe test
72+
```

‎temporal_mcp/pyproject.toml‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
[project]
2+
name = "temporalio-samples-temporal-mcp"
3+
version = "0.1a1"
4+
description = "Temporal.io Python SDK durable MCP client samples"
5+
authors = [{ name = "Temporal Technologies Inc", email = "sdk@temporal.io" }]
6+
requires-python = ">=3.10"
7+
readme = "README.md"
8+
license = "MIT"
9+
dependencies = [
10+
"temporalio-mcp==0.2.0",
11+
"mcp>=2,<3",
12+
"mcp-types>=2,<3",
13+
"uvicorn>=0.31,<1",
14+
]
15+
16+
[dependency-groups]
17+
dev = [
18+
"ruff>=0.5.0,<0.6",
19+
"mypy>=1.4.1,<2",
20+
"poethepoet>=0.36.0",
21+
"pytest>=7.1.2,<8",
22+
"pytest-asyncio>=0.18.3,<0.19",
23+
]
24+
25+
[tool.poe.tasks]
26+
format = [
27+
{ cmd = "uv run ruff check --select I --fix" },
28+
{ cmd = "uv run ruff format" },
29+
]
30+
lint = [
31+
{ cmd = "uv run ruff check --select I" },
32+
{ cmd = "uv run ruff format --check" },
33+
{ ref = "lint-types" },
34+
]
35+
lint-types = "uv run --all-groups mypy --check-untyped-defs --namespace-packages --explicit-package-bases ."
36+
test = "uv run pytest"
37+
38+
[tool.pytest.ini_options]
39+
asyncio_mode = "auto"
40+
pythonpath = ["."]
41+
testpaths = ["tests"]
42+
43+
[tool.ruff]
44+
target-version = "py310"
45+
46+
[tool.mypy]
47+
ignore_missing_imports = true

‎temporal_mcp/run_worker.py‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Run a Worker configured for one of the sample MCP transports."""
2+
3+
import argparse
4+
import asyncio
5+
import sys
6+
from collections.abc import Callable
7+
from pathlib import Path
8+
from typing import cast
9+
10+
from mcp import Client as MCPClient
11+
from mcp import StdioServerParameters, stdio_client
12+
from temporalio.client import Client as TemporalClient
13+
from temporalio.envconfig import ClientConfig
14+
from temporalio.mcp import MCPPlugin
15+
from temporalio.worker import Worker
16+
17+
from server import create_server
18+
from workflow import TRANSPORTS, MCPDemoWorkflow, Transport, client_name, task_queue
19+
20+
SERVER_PATH = Path(__file__).parent / "server.py"
21+
DEFAULT_HTTP_URL = "http://127.0.0.1:8000/mcp"
22+
23+
24+
def client_factory(
25+
transport: Transport, http_url: str = DEFAULT_HTTP_URL
26+
) -> Callable[[], MCPClient]:
27+
"""Create the worker-side client factory for a transport."""
28+
if transport == "in-process":
29+
return lambda: MCPClient(create_server())
30+
if transport == "stdio":
31+
parameters = StdioServerParameters(
32+
command=sys.executable,
33+
args=[str(SERVER_PATH), "stdio"],
34+
)
35+
return lambda: MCPClient(stdio_client(parameters))
36+
return lambda: MCPClient(http_url)
37+
38+
39+
def create_plugin(transport: Transport, http_url: str = DEFAULT_HTTP_URL) -> MCPPlugin:
40+
return MCPPlugin({client_name(transport): client_factory(transport, http_url)})
41+
42+
43+
async def main(transport: Transport, http_url: str) -> None:
44+
config = ClientConfig.load_client_connect_config()
45+
config.setdefault("target_host", "localhost:7233")
46+
client = await TemporalClient.connect(
47+
**config,
48+
plugins=[create_plugin(transport, http_url)],
49+
)
50+
51+
worker = Worker(
52+
client,
53+
task_queue=task_queue(transport),
54+
workflows=[MCPDemoWorkflow],
55+
)
56+
print(f"Worker started for {transport}. Ctrl+C to exit.")
57+
await worker.run()
58+
59+
60+
if __name__ == "__main__":
61+
parser = argparse.ArgumentParser(description=__doc__)
62+
parser.add_argument("transport", choices=TRANSPORTS)
63+
parser.add_argument("--http-url", default=DEFAULT_HTTP_URL)
64+
args = parser.parse_args()
65+
asyncio.run(main(cast(Transport, args.transport), args.http_url))

‎temporal_mcp/run_workflow.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Start the durable MCP workflow for a selected transport."""
2+
3+
import argparse
4+
import asyncio
5+
from typing import cast
6+
from uuid import uuid4
7+
8+
from temporalio.client import Client
9+
from temporalio.envconfig import ClientConfig
10+
11+
from workflow import TRANSPORTS, MCPDemoWorkflow, Transport, task_queue
12+
13+
14+
async def main(transport: Transport) -> None:
15+
config = ClientConfig.load_client_connect_config()
16+
config.setdefault("target_host", "localhost:7233")
17+
client = await Client.connect(**config)
18+
19+
result = await client.execute_workflow(
20+
MCPDemoWorkflow.run,
21+
transport,
22+
id=f"temporal-mcp-{transport}-{uuid4()}",
23+
task_queue=task_queue(transport),
24+
)
25+
print(result)
26+
27+
28+
if __name__ == "__main__":
29+
parser = argparse.ArgumentParser(description=__doc__)
30+
parser.add_argument("transport", choices=TRANSPORTS)
31+
args = parser.parse_args()
32+
asyncio.run(main(cast(Transport, args.transport)))

‎temporal_mcp/server.py‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""MCP server shared by the in-process, stdio, and HTTP samples."""
2+
3+
import argparse
4+
from typing import Any
5+
6+
from mcp.server.mcpserver import MCPServer
7+
8+
9+
def create_server() -> MCPServer[Any]:
10+
"""Create the sample server with tools, prompts, and resources."""
11+
server = MCPServer("temporal-mcp-sample")
12+
13+
@server.tool()
14+
def echo(value: str) -> str:
15+
"""Return the supplied value unchanged."""
16+
return value
17+
18+
@server.prompt()
19+
def greeting(name: str) -> str:
20+
"""Create a greeting prompt."""
21+
return f"Hello, {name}!"
22+
23+
@server.resource("sample://about", name="about")
24+
def about() -> str:
25+
"""Describe this sample."""
26+
return "Temporal workflows can call MCP operations durably."
27+
28+
@server.resource("sample://items/{item}", name="item")
29+
def item(item: str) -> str:
30+
"""Return a resource identified by its path parameter."""
31+
return item
32+
33+
return server
34+
35+
36+
def main() -> None:
37+
parser = argparse.ArgumentParser(description="Run the sample MCP server")
38+
parser.add_argument(
39+
"transport",
40+
choices=("stdio", "streamable-http"),
41+
nargs="?",
42+
default="stdio",
43+
)
44+
parser.add_argument("--host", default="127.0.0.1")
45+
parser.add_argument("--port", default=8000, type=int)
46+
args = parser.parse_args()
47+
48+
server = create_server()
49+
if args.transport == "stdio":
50+
server.run()
51+
else:
52+
server.run(
53+
transport="streamable-http",
54+
host=args.host,
55+
port=args.port,
56+
stateless_http=True,
57+
)
58+
59+
60+
if __name__ == "__main__":
61+
main()

0 commit comments

Comments
 (0)