From 29eb051c5f7c644c7d2bd922700383e879ee374b Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Sat, 13 Sep 2025 18:44:38 -0600 Subject: [PATCH 1/2] feat(server): expose mcp components via rest --- mcp_plex/server.py | 65 ++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 3 +- tests/test_server.py | 17 ++++++++++++ uv.lock | 18 +++++++++++- 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/mcp_plex/server.py b/mcp_plex/server.py index 3b31801..76668c1 100644 --- a/mcp_plex/server.py +++ b/mcp_plex/server.py @@ -8,10 +8,16 @@ from collections import OrderedDict from typing import Annotated, Any +from fastapi import HTTPException +from fastapi.openapi.docs import get_swagger_ui_html +from fastmcp.exceptions import NotFoundError from fastmcp.server import FastMCP +from fastmcp.server.context import Context as FastMCPContext from pydantic import Field from qdrant_client import models from qdrant_client.async_qdrant_client import AsyncQdrantClient +from starlette.requests import Request +from starlette.responses import JSONResponse, PlainTextResponse, Response try: from sentence_transformers import CrossEncoder @@ -475,6 +481,65 @@ async def media_background( return art +@server.custom_route("/rest", methods=["GET"]) +async def rest_docs(request: Request) -> Response: + """Serve Swagger UI for REST endpoints.""" + return get_swagger_ui_html(openapi_url="/openapi.json", title="MCP REST API") + + +@server.custom_route("/rest/tool/{tool_name}", methods=["POST"]) +async def rest_tool(request: Request) -> Response: + """Execute a tool via REST.""" + tool_name = request.path_params["tool_name"] + try: + arguments = await request.json() + except Exception: + arguments = {} + try: + tool = await server._tool_manager.get_tool(tool_name) + except NotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + async with FastMCPContext(fastmcp=server): + result = await tool.fn(**arguments) + return JSONResponse(result) + + +@server.custom_route("/rest/prompt/{prompt_name}", methods=["POST"]) +async def rest_prompt(request: Request) -> Response: + """Render a prompt via REST.""" + prompt_name = request.path_params["prompt_name"] + try: + arguments = await request.json() + except Exception: + arguments = None + try: + prompt = await server._prompt_manager.get_prompt(prompt_name) + except NotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + async with FastMCPContext(fastmcp=server): + messages = await prompt.render(arguments) + return JSONResponse([m.model_dump() for m in messages]) + + +@server.custom_route("/rest/resource/{path:path}", methods=["GET"]) +async def rest_resource(request: Request) -> Response: + """Read a resource via REST.""" + path = request.path_params["path"] + uri = f"resource://{path}" + try: + resource = await server._resource_manager.get_resource(uri) + except NotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + async with FastMCPContext(fastmcp=server): + data = await server._resource_manager.read_resource(uri) + if isinstance(data, bytes): + return Response(content=data, media_type=resource.mime_type) + try: + return JSONResponse(json.loads(data), media_type=resource.mime_type) + except Exception: + return PlainTextResponse(str(data), media_type=resource.mime_type) + + def main(argv: list[str] | None = None) -> None: """CLI entrypoint for running the MCP server.""" global _DENSE_MODEL_NAME, _SPARSE_MODEL_NAME diff --git a/pyproject.toml b/pyproject.toml index 38555c9..6cc2e31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,12 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "mcp-plex" -version = "0.26.4" +version = "0.26.5" description = "Plex-Oriented Model Context Protocol Server" requires-python = ">=3.11,<3.13" dependencies = [ "fastmcp>=2.11.2", + "fastapi>=0.115.0", "pydantic>=2.11.7", "plexapi>=4.17.0", "qdrant-client[fastembed-gpu]>=1.15.1", diff --git a/tests/test_server.py b/tests/test_server.py index 5f7907e..0c7ef34 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -9,6 +9,7 @@ import builtins import pytest +from starlette.testclient import TestClient from mcp_plex import loader @@ -156,3 +157,19 @@ def __init__(self, *args, **kwargs): monkeypatch.setitem(sys.modules, "sentence_transformers", st_module) server = importlib.reload(importlib.import_module("mcp_plex.server")) assert server._reranker is None + + +def test_rest_endpoints(monkeypatch): + module = _load_server(monkeypatch) + client = TestClient(module.server.http_app()) + + resp = client.post("/rest/tool/get-media", json={"identifier": "49915"}) + assert resp.status_code == 200 + assert resp.json()[0]["plex"]["rating_key"] == "49915" + + resp = client.get("/rest/resource/media-item/49915") + assert resp.status_code == 200 + assert resp.json()["plex"]["rating_key"] == "49915" + + resp = client.get("/rest") + assert resp.status_code == 200 diff --git a/uv.lock b/uv.lock index ed675be..68369fd 100644 --- a/uv.lock +++ b/uv.lock @@ -264,6 +264,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, ] +[[package]] +name = "fastapi" +version = "0.116.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/d7/6c8b3bfe33eeffa208183ec037fee0cce9f7f024089ab1c5d12ef04bd27c/fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143", size = 296485, upload-time = "2025-07-11T16:22:32.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" }, +] + [[package]] name = "fastembed-gpu" version = "0.7.3" @@ -676,9 +690,10 @@ wheels = [ [[package]] name = "mcp-plex" -version = "0.26.4" +version = "0.26.5" source = { editable = "." } dependencies = [ + { name = "fastapi" }, { name = "fastmcp" }, { name = "httpx" }, { name = "plexapi" }, @@ -697,6 +712,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "fastapi", specifier = ">=0.115.0" }, { name = "fastmcp", specifier = ">=2.11.2" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "plexapi", specifier = ">=4.17.0" }, From f7ea5b3890d96b49e0f7bfad2ef499e9e263af16 Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Sat, 13 Sep 2025 19:00:32 -0600 Subject: [PATCH 2/2] fix: register tool REST endpoints with metadata --- mcp_plex/server.py | 53 +++++++++++++++++++++++++++++++------------- pyproject.toml | 2 +- tests/test_server.py | 10 +++++---- uv.lock | 2 +- 4 files changed, 45 insertions(+), 22 deletions(-) diff --git a/mcp_plex/server.py b/mcp_plex/server.py index 76668c1..ae9ab96 100644 --- a/mcp_plex/server.py +++ b/mcp_plex/server.py @@ -3,13 +3,15 @@ import argparse import asyncio +import inspect import json import os from collections import OrderedDict from typing import Annotated, Any -from fastapi import HTTPException +from fastapi import FastAPI, HTTPException from fastapi.openapi.docs import get_swagger_ui_html +from fastapi.openapi.utils import get_openapi from fastmcp.exceptions import NotFoundError from fastmcp.server import FastMCP from fastmcp.server.context import Context as FastMCPContext @@ -487,22 +489,41 @@ async def rest_docs(request: Request) -> Response: return get_swagger_ui_html(openapi_url="/openapi.json", title="MCP REST API") -@server.custom_route("/rest/tool/{tool_name}", methods=["POST"]) -async def rest_tool(request: Request) -> Response: - """Execute a tool via REST.""" - tool_name = request.path_params["tool_name"] - try: - arguments = await request.json() - except Exception: - arguments = {} - try: - tool = await server._tool_manager.get_tool(tool_name) - except NotFoundError as e: - raise HTTPException(status_code=404, detail=str(e)) - async with FastMCPContext(fastmcp=server): - result = await tool.fn(**arguments) - return JSONResponse(result) +def _build_openapi_schema() -> dict[str, Any]: + app = FastAPI() + for name, tool in server._tool_manager._tools.items(): + app.post(f"/rest/{name}")(tool.fn) + return get_openapi(title="MCP REST API", version="1.0.0", routes=app.routes) + + +_OPENAPI_SCHEMA = _build_openapi_schema() + + +@server.custom_route("/openapi.json", methods=["GET"]) +async def openapi_json(request: Request) -> Response: # noqa: ARG001 + """Return the OpenAPI schema for REST endpoints.""" + return JSONResponse(_OPENAPI_SCHEMA) + + +# Dynamically expose tools under `/rest/{tool_name}` while preserving metadata +def _register_rest_tools() -> None: + for name, tool in server._tool_manager._tools.items(): + async def _rest_tool(request: Request, _tool=tool) -> Response: # noqa: ARG001 + try: + arguments = await request.json() + except Exception: + arguments = {} + async with FastMCPContext(fastmcp=server): + result = await _tool.fn(**arguments) + return JSONResponse(result) + + _rest_tool.__name__ = f"rest_{name.replace('-', '_')}" + _rest_tool.__doc__ = tool.fn.__doc__ + _rest_tool.__signature__ = inspect.signature(tool.fn) + server.custom_route(f"/rest/{name}", methods=["POST"])(_rest_tool) + +_register_rest_tools() @server.custom_route("/rest/prompt/{prompt_name}", methods=["POST"]) async def rest_prompt(request: Request) -> Response: diff --git a/pyproject.toml b/pyproject.toml index 6cc2e31..fc0d55d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mcp-plex" -version = "0.26.5" +version = "0.26.6" description = "Plex-Oriented Model Context Protocol Server" requires-python = ">=3.11,<3.13" diff --git a/tests/test_server.py b/tests/test_server.py index 0c7ef34..6ca780d 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -163,13 +163,15 @@ def test_rest_endpoints(monkeypatch): module = _load_server(monkeypatch) client = TestClient(module.server.http_app()) - resp = client.post("/rest/tool/get-media", json={"identifier": "49915"}) + resp = client.post("/rest/get-media", json={"identifier": "49915"}) assert resp.status_code == 200 assert resp.json()[0]["plex"]["rating_key"] == "49915" - resp = client.get("/rest/resource/media-item/49915") - assert resp.status_code == 200 - assert resp.json()["plex"]["rating_key"] == "49915" + spec = client.get("/openapi.json").json() + get_media = spec["paths"]["/rest/get-media"]["post"] + assert get_media["description"].startswith("Retrieve media items") + params = {p["name"]: p for p in get_media["parameters"]} + assert params["identifier"]["schema"]["description"].startswith("Rating key") resp = client.get("/rest") assert resp.status_code == 200 diff --git a/uv.lock b/uv.lock index 68369fd..9131a56 100644 --- a/uv.lock +++ b/uv.lock @@ -690,7 +690,7 @@ wheels = [ [[package]] name = "mcp-plex" -version = "0.26.5" +version = "0.26.6" source = { editable = "." } dependencies = [ { name = "fastapi" },