Skip to content

Commit 6b51035

Browse files
committed
First commit
0 parents  commit 6b51035

File tree

13 files changed

+1038
-0
lines changed

13 files changed

+1038
-0
lines changed

.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.13

README.md

Whitespace-only changes.

nexusmcp/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from temporalio import workflow
2+
3+
with workflow.unsafe.imports_passed_through():
4+
from .inbound_gateway import NexusMCPInboundGateway
5+
from .service import MCPService
6+
from .service_handler import MCPServiceHandler, exclude
7+
8+
__all__ = ["MCPService", "MCPServiceHandler", "NexusMCPInboundGateway", "exclude"]

nexusmcp/inbound_gateway.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import uuid
2+
from collections.abc import AsyncGenerator
3+
from contextlib import asynccontextmanager
4+
from typing import Any
5+
6+
import mcp.types as types
7+
from mcp.server.lowlevel import Server
8+
from temporalio.client import Client
9+
from temporalio.worker import Worker
10+
11+
from .proxy_workflow import (
12+
ToolCallInput,
13+
ToolCallWorkflow,
14+
ToolListInput,
15+
ToolListWorkflow,
16+
)
17+
18+
19+
class NexusMCPInboundGateway:
20+
client: Client
21+
endpoint: str
22+
task_queue: str
23+
24+
def __init__(self, client: Client, endpoint: str):
25+
self.client = client
26+
self.endpoint = endpoint
27+
self.task_queue = "nexus-proxy-queue" # TODO: remove this when we support direct nexus client calls.
28+
29+
async def handle_list_tools(self) -> list[types.Tool]:
30+
return await self.client.execute_workflow(
31+
ToolListWorkflow.run,
32+
arg=ToolListInput(endpoint=self.endpoint),
33+
id=str(uuid.uuid4()),
34+
task_queue=self.task_queue,
35+
)
36+
37+
async def handle_call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
38+
service, operation = name.split(".", maxsplit=1)
39+
return await self.client.execute_workflow(
40+
ToolCallWorkflow.run,
41+
arg=ToolCallInput(
42+
endpoint=self.endpoint,
43+
service=service,
44+
operation=operation,
45+
arguments=arguments,
46+
),
47+
id=str(uuid.uuid4()),
48+
task_queue=self.task_queue,
49+
)
50+
51+
@asynccontextmanager
52+
async def run(self) -> AsyncGenerator[None]:
53+
async with Worker(
54+
self.client,
55+
task_queue=self.task_queue,
56+
workflows=[ToolListWorkflow, ToolCallWorkflow],
57+
):
58+
yield
59+
60+
def register(self, server: Server) -> None:
61+
server.list_tools()(self.handle_list_tools) # type: ignore[no-untyped-call]
62+
server.call_tool()(self.handle_call_tool)

nexusmcp/proxy_workflow.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from typing import Any
2+
3+
from temporalio import workflow
4+
5+
with workflow.unsafe.imports_passed_through():
6+
import mcp.types
7+
8+
from .service import MCPService
9+
10+
from pydantic import BaseModel
11+
12+
13+
class ToolListInput(BaseModel):
14+
endpoint: str
15+
pass
16+
17+
18+
class ToolCallInput(BaseModel):
19+
endpoint: str
20+
service: str
21+
operation: str
22+
arguments: dict[str, Any]
23+
24+
25+
class ProxyWorkflowInput(BaseModel):
26+
action: ToolListInput | ToolCallInput
27+
28+
29+
@workflow.defn
30+
class ToolListWorkflow:
31+
@workflow.run
32+
async def run(self, input: ToolListInput) -> list[mcp.types.Tool]:
33+
client = workflow.create_nexus_client(
34+
endpoint=input.endpoint,
35+
service=MCPService,
36+
)
37+
return await client.execute_operation(MCPService.list_tools, None)
38+
39+
40+
@workflow.defn
41+
class ToolCallWorkflow:
42+
@workflow.run
43+
async def run(self, input: ToolCallInput) -> Any:
44+
client = workflow.create_nexus_client(
45+
endpoint=input.endpoint,
46+
service=input.service,
47+
)
48+
return await client.execute_operation(input.operation, input.arguments)

nexusmcp/service.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import nexusrpc
2+
from temporalio import workflow
3+
4+
with workflow.unsafe.imports_passed_through():
5+
import mcp.types
6+
7+
8+
@nexusrpc.service(name="MCP")
9+
class MCPService:
10+
list_tools: nexusrpc.Operation[None, list[mcp.types.Tool]]

nexusmcp/service_handler.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from collections.abc import Callable
2+
from dataclasses import dataclass
3+
from typing import Any
4+
5+
import mcp.types
6+
import nexusrpc
7+
import pydantic
8+
9+
from .service import MCPService
10+
11+
12+
@dataclass
13+
class Tool:
14+
func: Callable[..., Any]
15+
defn: nexusrpc.Operation[Any, Any]
16+
17+
def to_mcp_tool(self, service: nexusrpc.ServiceDefinition) -> mcp.types.Tool:
18+
return mcp.types.Tool(
19+
name=f"{service.name}.{self.defn.name}",
20+
description=(self.func.__doc__.strip() if self.func.__doc__ is not None else None),
21+
inputSchema=(
22+
self.defn.input_type.model_json_schema()
23+
if self.defn.input_type is not None and issubclass(self.defn.input_type, pydantic.BaseModel)
24+
else {}
25+
),
26+
)
27+
28+
29+
@dataclass
30+
class ToolService:
31+
defn: nexusrpc.ServiceDefinition
32+
tools: list[Tool]
33+
34+
35+
@nexusrpc.handler.service_handler(service=MCPService)
36+
class MCPServiceHandler:
37+
tool_services: list[ToolService]
38+
39+
def __init__(self) -> None:
40+
self.tool_services = []
41+
42+
def tool_service(self, cls: type) -> type:
43+
service_defn = nexusrpc.get_service_definition(cls)
44+
if service_defn is None:
45+
raise ValueError(f"Class {cls.__name__} is not a Nexus Service")
46+
47+
tools: list[Tool] = []
48+
for op in service_defn.operations.values():
49+
attr_name = op.method_name or op.name
50+
attr = getattr(cls, attr_name)
51+
if not callable(attr):
52+
raise ValueError(f"Attribute {attr_name} is not callable")
53+
if not getattr(attr, "__nexus_mcp_tool__", True):
54+
continue
55+
tools.append(Tool(attr, op))
56+
57+
self.tool_services.append(ToolService(tools=tools, defn=service_defn))
58+
return cls
59+
60+
@nexusrpc.handler.sync_operation
61+
async def list_tools(self, _ctx: nexusrpc.handler.StartOperationContext, _input: None) -> list[mcp.types.Tool]:
62+
return [tool.to_mcp_tool(service.defn) for service in self.tool_services for tool in service.tools]
63+
64+
65+
ExcludedCallable = Callable[..., Any]
66+
67+
68+
def exclude(fn: ExcludedCallable) -> ExcludedCallable:
69+
"""
70+
Decorate a function to exclude it from the MCP inventory.
71+
"""
72+
setattr(fn, "__nexus_mcp_tool__", False)
73+
return fn

pyproject.toml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
[project]
2+
name = "nexus-mcp"
3+
version = "0.1.0"
4+
description = "Add your description here"
5+
readme = "README.md"
6+
requires-python = ">=3.13"
7+
dependencies = [
8+
"mcp>=1.13.0",
9+
"nexus-rpc>=1.1.0",
10+
"temporalio>=1.15.0",
11+
]
12+
13+
[dependency-groups]
14+
dev = [
15+
"pytest>=8.4.1",
16+
"pytest-asyncio>=1.1.0",
17+
"ruff>=0.8.0",
18+
"mypy>=1.13.0",
19+
]
20+
21+
[tool.pytest.ini_options]
22+
minversion = "8.4.1"
23+
testpaths = ["tests"]
24+
addopts = "-v --tb=short"
25+
26+
[tool.ruff]
27+
target-version = "py313"
28+
line-length = 120
29+
fix = true
30+
31+
[tool.ruff.lint.per-file-ignores]
32+
"tests/**/*" = ["S101"] # Allow assert in tests
33+
34+
[tool.ruff.format]
35+
quote-style = "double"
36+
indent-style = "space"
37+
38+
[tool.mypy]
39+
python_version = "3.13"
40+
strict = true
41+
42+
[tool.uv.sources]
43+
temporalio = { git = "https://github.com/temporalio/sdk-python" }

tests/__init__.py

Whitespace-only changes.

tests/service.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from nexusrpc import Operation, service
2+
from nexusrpc.handler import StartOperationContext, service_handler, sync_operation
3+
from pydantic import BaseModel
4+
5+
from nexusmcp import MCPServiceHandler, exclude
6+
7+
8+
class MyInput(BaseModel):
9+
name: str
10+
11+
12+
class MyOutput(BaseModel):
13+
message: str
14+
15+
16+
@service(name="modified-service-name")
17+
class TestService:
18+
op1: Operation[MyInput, MyOutput] = Operation(name="modified-op-name")
19+
op2: Operation[MyInput, MyOutput]
20+
op3: Operation[MyInput, MyOutput]
21+
22+
23+
mcp_service = MCPServiceHandler()
24+
25+
26+
@mcp_service.tool_service
27+
@service_handler(service=TestService)
28+
class TestServiceHandler:
29+
# @nexus.workflow_run_operation
30+
# async def op1(
31+
# self, ctx: nexus.WorkflowRunOperationContext, input: TestInput
32+
# ) -> TestOutput:
33+
# """
34+
# This is a test operation.
35+
# """
36+
# return TestOutput(message=f"Hello, {input.name}")
37+
38+
@sync_operation
39+
async def op1(self, ctx: StartOperationContext, input: MyInput) -> MyOutput:
40+
"""
41+
This is a test operation.
42+
"""
43+
return MyOutput(message=f"Hello, {input.name}")
44+
45+
@sync_operation
46+
async def op2(self, ctx: StartOperationContext, input: MyInput) -> MyOutput:
47+
"""
48+
This is also a test operation.
49+
"""
50+
return MyOutput(message=f"Hello, {input.name}")
51+
52+
@exclude
53+
@sync_operation
54+
async def op3(self, ctx: StartOperationContext, input: MyInput) -> MyOutput:
55+
"""
56+
This is also a test operation.
57+
"""
58+
return MyOutput(message=f"Hello, {input.name}")

0 commit comments

Comments
 (0)