From 8b65daa42d278a29c361765c83e10a96e686ebc5 Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Sat, 13 Sep 2025 19:29:50 -0600 Subject: [PATCH] feat(server): expose prompts and resources via dynamic REST endpoints --- mcp_plex/server.py | 141 +++++++++++++++++++++++++++++-------------- pyproject.toml | 2 +- tests/test_server.py | 12 ++++ uv.lock | 2 +- 4 files changed, 111 insertions(+), 46 deletions(-) diff --git a/mcp_plex/server.py b/mcp_plex/server.py index ae9ab96..29f385d 100644 --- a/mcp_plex/server.py +++ b/mcp_plex/server.py @@ -7,12 +7,12 @@ import json import os from collections import OrderedDict -from typing import Annotated, Any +from typing import Annotated, Any, Callable -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI from fastapi.openapi.docs import get_swagger_ui_html from fastapi.openapi.utils import get_openapi -from fastmcp.exceptions import NotFoundError +from fastmcp.prompts import Message from fastmcp.server import FastMCP from fastmcp.server.context import Context as FastMCPContext from pydantic import Field @@ -483,6 +483,23 @@ async def media_background( return art +@server.prompt("media-info") +async def media_info( + identifier: Annotated[ + str, + Field( + description="Rating key, IMDb/TMDb ID, or media title", + examples=["49915", "tt8367814", "The Gentlemen"], + ), + ], +) -> list[Message]: + """Return a basic description for the given media identifier.""" + data = await _get_media_data(identifier) + title = data.get("title") or data.get("plex", {}).get("title", "") + summary = data.get("summary") or data.get("plex", {}).get("summary", "") + return [Message(f"{title}: {summary}")] + + @server.custom_route("/rest", methods=["GET"]) async def rest_docs(request: Request) -> Response: """Serve Swagger UI for REST endpoints.""" @@ -493,6 +510,25 @@ 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) + for name, prompt in server._prompt_manager._prompts.items(): + async def _p_stub(**kwargs): # noqa: ARG001 + pass + _p_stub.__name__ = f"prompt_{name.replace('-', '_')}" + _p_stub.__doc__ = prompt.fn.__doc__ + _p_stub.__signature__ = inspect.signature(prompt.fn).replace( + return_annotation=Any + ) + app.post(f"/rest/prompt/{name}")(_p_stub) + for uri, resource in server._resource_manager._templates.items(): + path = uri.replace("resource://", "") + async def _r_stub(**kwargs): # noqa: ARG001 + pass + _r_stub.__name__ = f"resource_{path.replace('/', '_').replace('{', '').replace('}', '')}" + _r_stub.__doc__ = resource.fn.__doc__ + _r_stub.__signature__ = inspect.signature(resource.fn).replace( + return_annotation=Any + ) + app.get(f"/rest/resource/{path}")(_r_stub) return get_openapi(title="MCP REST API", version="1.0.0", routes=app.routes) @@ -505,8 +541,14 @@ async def openapi_json(request: Request) -> Response: # noqa: ARG001 return JSONResponse(_OPENAPI_SCHEMA) -# Dynamically expose tools under `/rest/{tool_name}` while preserving metadata -def _register_rest_tools() -> None: + +def _register_rest_endpoints() -> None: + def _register(path: str, method: str, handler: Callable, fn: Callable, name: str) -> None: + handler.__name__ = name + handler.__doc__ = fn.__doc__ + handler.__signature__ = inspect.signature(fn).replace(return_annotation=Any) + server.custom_route(path, methods=[method])(handler) + for name, tool in server._tool_manager._tools.items(): async def _rest_tool(request: Request, _tool=tool) -> Response: # noqa: ARG001 try: @@ -517,48 +559,59 @@ async def _rest_tool(request: Request, _tool=tool) -> Response: # noqa: ARG001 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( + f"/rest/{name}", + "POST", + _rest_tool, + tool.fn, + f"rest_{name.replace('-', '_')}", + ) + + for name, prompt in server._prompt_manager._prompts.items(): + async def _rest_prompt(request: Request, _prompt=prompt) -> Response: # noqa: ARG001 + try: + arguments = await request.json() + except Exception: + arguments = None + async with FastMCPContext(fastmcp=server): + messages = await _prompt.render(arguments) + return JSONResponse([m.model_dump() for m in messages]) + + _register( + f"/rest/prompt/{name}", + "POST", + _rest_prompt, + prompt.fn, + f"rest_prompt_{name.replace('-', '_')}", + ) + + for uri, resource in server._resource_manager._templates.items(): + path = uri.replace("resource://", "") + async def _rest_resource(request: Request, _uri_template=uri, _resource=resource) -> Response: + formatted = _uri_template + for key, value in request.path_params.items(): + formatted = formatted.replace(f"{{{key}}}", value) + async with FastMCPContext(fastmcp=server): + data = await server._resource_manager.read_resource(formatted) + 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) + + handler_name = f"rest_resource_{path.replace('/', '_').replace('{', '').replace('}', '')}" + _register( + f"/rest/resource/{path}", + "GET", + _rest_resource, + resource.fn, + handler_name, + ) -_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) +_register_rest_endpoints() def main(argv: list[str] | None = None) -> None: diff --git a/pyproject.toml b/pyproject.toml index fc0d55d..7ec2fe1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mcp-plex" -version = "0.26.6" +version = "0.26.7" 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 6ca780d..40dddc6 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -167,11 +167,23 @@ def test_rest_endpoints(monkeypatch): assert resp.status_code == 200 assert resp.json()[0]["plex"]["rating_key"] == "49915" + resp = client.post("/rest/prompt/media-info", json={"identifier": "49915"}) + assert resp.status_code == 200 + msg = resp.json()[0] + assert msg["role"] == "user" + assert "The Gentlemen" in msg["content"]["text"] + + resp = client.get("/rest/resource/media-ids/49915") + assert resp.status_code == 200 + assert resp.json()["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") + assert "/rest/prompt/media-info" in spec["paths"] + assert "/rest/resource/media-ids/{identifier}" in spec["paths"] resp = client.get("/rest") assert resp.status_code == 200 diff --git a/uv.lock b/uv.lock index 9131a56..1f50dc9 100644 --- a/uv.lock +++ b/uv.lock @@ -690,7 +690,7 @@ wheels = [ [[package]] name = "mcp-plex" -version = "0.26.6" +version = "0.26.7" source = { editable = "." } dependencies = [ { name = "fastapi" },