ReMe/reme/components/service/mcp_tools.py
jinliyl 3d2ecc60d2
feat(service): expose MCP through HTTP backend (#498)
* feat(service): expose MCP through HTTP backend

Serve JSON/SSE job endpoints and streamable HTTP MCP from one FastAPI application, sharing the same jobs and application lifecycle. Preserve the standalone MCP backend, add configurable MCP HTTP settings, update startup metadata and integration docs, and cover routing, lifecycle, configuration, and compatibility behavior with unit tests.

* fix(service): preserve MCP request protections

Route the exact MCP path through the complete FastMCP ASGI application so its middleware and state remain active. Reject non-literal MCP paths and validate reserved Job conflicts before tolerant service registration. Add regression coverage for middleware preservation, route syntax, and startup failure.

* fix(service): reject encoded MCP paths

Reject percent signs in mcp_path so ASGI path decoding cannot turn an accepted configuration into an unreachable route. Cover encoded slash, space, and double-encoded slash inputs.
2026-08-27 17:23:11 +08:00

52 lines
1.6 KiB
Python

"""Shared MCP tool registration for services that expose ReMe jobs."""
from typing import Any
from ..job import BaseJob, StreamJob
def add_mcp_job(
server: Any,
job: BaseJob,
*,
injected_job_kwargs: dict[str, Any],
tool_error_on_failure: bool,
) -> bool:
"""Register a non-stream job as an MCP tool on ``server``."""
from fastmcp.exceptions import ToolError
from fastmcp.tools import FunctionTool
if isinstance(job, StreamJob):
return False
async def execute_tool(**kwargs):
conflicts = sorted(injected_job_kwargs.keys() & kwargs.keys())
if conflicts:
names = ", ".join(conflicts)
raise ToolError(
f"{names} injected by the MCP server and cannot be provided by the caller",
)
kwargs.update(injected_job_kwargs)
response = await job(**kwargs)
if tool_error_on_failure and not response.success:
raise ToolError(str(response.answer))
return response.answer
parameters = dict(job.parameters or {})
injected_names = injected_job_kwargs.keys()
if "properties" in parameters:
parameters["properties"] = {
name: schema for name, schema in parameters["properties"].items() if name not in injected_names
}
if "required" in parameters:
parameters["required"] = [name for name in parameters["required"] if name not in injected_names]
server.add_tool(
FunctionTool(
name=job.name,
description=job.description,
fn=execute_tool,
parameters=parameters,
),
)
return True