diff --git a/mcp_plex/server.py b/mcp_plex/server.py index 3b31801..ae9ab96 100644 --- a/mcp_plex/server.py +++ b/mcp_plex/server.py @@ -3,15 +3,23 @@ import argparse import asyncio +import inspect import json import os from collections import OrderedDict from typing import Annotated, Any +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 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 +483,84 @@ 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") + + +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: + """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 8372058..fc0d55d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,12 +4,13 @@ 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" 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..6ca780d 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,21 @@ 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/get-media", json={"identifier": "49915"}) + assert resp.status_code == 200 + assert resp.json()[0]["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 2e8eda3..9131a56 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.5" +version = "0.26.6" 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" },