Skip to content

Commit d422fa9

Browse files
authored
fix: generate request bodies in REST OpenAPI (#68)
1 parent 3fb5834 commit d422fa9

File tree

5 files changed

+97
-10
lines changed

5 files changed

+97
-10
lines changed

docker/pyproject.deps.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "mcp-plex"
3-
version = "0.26.34"
3+
version = "0.26.35"
44
requires-python = ">=3.11,<3.13"
55
dependencies = [
66
"fastmcp>=2.11.2",

mcp_plex/server.py

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from fastmcp.prompts import Message
1515
from fastmcp.server import FastMCP
1616
from fastmcp.server.context import Context as FastMCPContext
17-
from pydantic import Field
17+
from pydantic import BaseModel, Field, create_model
1818
from qdrant_client import models
1919
from qdrant_client.async_qdrant_client import AsyncQdrantClient
2020
from starlette.requests import Request
@@ -99,6 +99,36 @@ def reranker(self) -> CrossEncoder | None:
9999
server = PlexServer(settings=settings)
100100

101101

102+
def _request_model(name: str, fn: Callable[..., Any]) -> type[BaseModel] | None:
103+
"""Generate a Pydantic model representing the callable's parameters."""
104+
105+
signature = inspect.signature(fn)
106+
if not signature.parameters:
107+
return None
108+
109+
fields: dict[str, tuple[Any, Any]] = {}
110+
for param_name, parameter in signature.parameters.items():
111+
annotation = (
112+
parameter.annotation
113+
if parameter.annotation is not inspect._empty
114+
else Any
115+
)
116+
default = (
117+
parameter.default
118+
if parameter.default is not inspect._empty
119+
else ...
120+
)
121+
fields[param_name] = (annotation, default)
122+
123+
if not fields:
124+
return None
125+
126+
model_name = "".join(part.capitalize() for part in name.replace("-", "_").split("_"))
127+
model_name = f"{model_name or 'Request'}Request"
128+
request_model = create_model(model_name, **fields) # type: ignore[arg-type]
129+
return request_model
130+
131+
102132
async def _find_records(identifier: str, limit: int = 5) -> list[models.Record]:
103133
"""Locate records matching an identifier or title."""
104134
# First, try direct ID lookup
@@ -522,15 +552,50 @@ async def rest_docs(request: Request) -> Response:
522552
def _build_openapi_schema() -> dict[str, Any]:
523553
app = FastAPI()
524554
for name, tool in server._tool_manager._tools.items():
525-
app.post(f"/rest/{name}")(tool.fn)
555+
request_model = _request_model(name, tool.fn)
556+
557+
if request_model is None:
558+
app.post(f"/rest/{name}")(tool.fn)
559+
continue
560+
561+
async def _tool_stub(payload: request_model) -> None: # type: ignore[name-defined]
562+
pass
563+
564+
_tool_stub.__name__ = f"tool_{name.replace('-', '_')}"
565+
_tool_stub.__doc__ = tool.fn.__doc__
566+
_tool_stub.__signature__ = inspect.Signature(
567+
parameters=[
568+
inspect.Parameter(
569+
"payload",
570+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
571+
annotation=request_model,
572+
)
573+
],
574+
return_annotation=Any,
575+
)
576+
577+
app.post(f"/rest/{name}")(_tool_stub)
526578
for name, prompt in server._prompt_manager._prompts.items():
527579
async def _p_stub(**kwargs): # noqa: ARG001
528580
pass
529581
_p_stub.__name__ = f"prompt_{name.replace('-', '_')}"
530582
_p_stub.__doc__ = prompt.fn.__doc__
531-
_p_stub.__signature__ = inspect.signature(prompt.fn).replace(
532-
return_annotation=Any
533-
)
583+
request_model = _request_model(name, prompt.fn)
584+
if request_model is None:
585+
_p_stub.__signature__ = inspect.signature(prompt.fn).replace(
586+
return_annotation=Any
587+
)
588+
else:
589+
_p_stub.__signature__ = inspect.Signature(
590+
parameters=[
591+
inspect.Parameter(
592+
"payload",
593+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
594+
annotation=request_model,
595+
)
596+
],
597+
return_annotation=Any,
598+
)
534599
app.post(f"/rest/prompt/{name}")(_p_stub)
535600
for uri, resource in server._resource_manager._templates.items():
536601
path = uri.replace("resource://", "")

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "mcp-plex"
7-
version = "0.26.34"
7+
version = "0.26.35"
88

99
description = "Plex-Oriented Model Context Protocol Server"
1010
requires-python = ">=3.11,<3.13"

tests/test_server.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,10 +195,32 @@ def test_rest_endpoints(monkeypatch):
195195
assert resp.json()["rating_key"] == "49915"
196196

197197
spec = client.get("/openapi.json").json()
198+
def _resolve(schema: dict):
199+
if "$ref" in schema:
200+
ref = schema["$ref"].split("/")[-1]
201+
return spec["components"]["schemas"][ref]
202+
return schema
203+
198204
get_media = spec["paths"]["/rest/get-media"]["post"]
199205
assert get_media["description"].startswith("Retrieve media items")
200-
params = {p["name"]: p for p in get_media["parameters"]}
201-
assert params["identifier"]["schema"]["description"].startswith("Rating key")
206+
assert "parameters" not in get_media or not get_media["parameters"]
207+
get_media_schema = get_media["requestBody"]["content"]["application/json"][
208+
"schema"
209+
]
210+
get_media_schema = _resolve(get_media_schema)
211+
assert (
212+
get_media_schema["properties"]["identifier"]["description"].startswith(
213+
"Rating key"
214+
)
215+
)
216+
217+
search_media = spec["paths"]["/rest/search-media"]["post"]
218+
assert "parameters" not in search_media or not search_media["parameters"]
219+
search_schema = search_media["requestBody"]["content"][
220+
"application/json"
221+
]["schema"]
222+
search_schema = _resolve(search_schema)
223+
assert "query" in search_schema["required"]
202224
assert "/rest/prompt/media-info" in spec["paths"]
203225
assert "/rest/resource/media-ids/{identifier}" in spec["paths"]
204226

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)