omdb-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- omdb_mcp-0.1.0/.env.example +25 -0
- omdb_mcp-0.1.0/.gitignore +38 -0
- omdb_mcp-0.1.0/LICENSE +21 -0
- omdb_mcp-0.1.0/PKG-INFO +295 -0
- omdb_mcp-0.1.0/README.md +265 -0
- omdb_mcp-0.1.0/pyproject.toml +52 -0
- omdb_mcp-0.1.0/src/omdb_mcp/__init__.py +3 -0
- omdb_mcp-0.1.0/src/omdb_mcp/server.py +397 -0
- omdb_mcp-0.1.0/tests/__init__.py +0 -0
- omdb_mcp-0.1.0/tests/conftest.py +8 -0
- omdb_mcp-0.1.0/tests/test_client.py +110 -0
- omdb_mcp-0.1.0/tests/test_config.py +69 -0
- omdb_mcp-0.1.0/tests/test_server.py +39 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# ---- Required ----
|
|
2
|
+
# Get your free key at https://www.omdbapi.com/apikey.aspx
|
|
3
|
+
OMDB_API_KEY=your_key_here
|
|
4
|
+
|
|
5
|
+
# ---- Transport ----
|
|
6
|
+
# stdio | http (CLI flag --transport overrides this)
|
|
7
|
+
OMDB_MCP_TRANSPORT=stdio
|
|
8
|
+
|
|
9
|
+
# ---- HTTP mode only ----
|
|
10
|
+
# Bind address. Use 127.0.0.1 for local only, 0.0.0.0 to expose on LAN/Docker.
|
|
11
|
+
OMDB_MCP_HOST=127.0.0.1
|
|
12
|
+
OMDB_MCP_PORT=8087
|
|
13
|
+
OMDB_MCP_PATH=/mcp
|
|
14
|
+
|
|
15
|
+
# Optional bearer token. If set, HTTP clients must send:
|
|
16
|
+
# Authorization: Bearer <token>
|
|
17
|
+
# Strongly recommended whenever OMDB_MCP_HOST is not 127.0.0.1.
|
|
18
|
+
OMDB_MCP_AUTH_TOKEN=
|
|
19
|
+
|
|
20
|
+
# ---- Operational ----
|
|
21
|
+
OMDB_MCP_LOG_LEVEL=INFO
|
|
22
|
+
OMDB_REQUEST_TIMEOUT=10
|
|
23
|
+
|
|
24
|
+
# Override only if you proxy/mirror OMDB.
|
|
25
|
+
OMDB_BASE_URL=https://www.omdbapi.com/
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Secrets
|
|
2
|
+
.env
|
|
3
|
+
|
|
4
|
+
# Python
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
*$py.class
|
|
8
|
+
*.so
|
|
9
|
+
.Python
|
|
10
|
+
*.egg-info/
|
|
11
|
+
*.egg
|
|
12
|
+
.installed.cfg
|
|
13
|
+
|
|
14
|
+
# Build artifacts
|
|
15
|
+
build/
|
|
16
|
+
dist/
|
|
17
|
+
wheels/
|
|
18
|
+
*.whl
|
|
19
|
+
|
|
20
|
+
# Virtual environments
|
|
21
|
+
.venv/
|
|
22
|
+
venv/
|
|
23
|
+
env/
|
|
24
|
+
|
|
25
|
+
# Testing / caches
|
|
26
|
+
.pytest_cache/
|
|
27
|
+
.ruff_cache/
|
|
28
|
+
.mypy_cache/
|
|
29
|
+
.coverage
|
|
30
|
+
htmlcov/
|
|
31
|
+
|
|
32
|
+
# IDE / OS
|
|
33
|
+
.idea/
|
|
34
|
+
.vscode/
|
|
35
|
+
.DS_Store
|
|
36
|
+
|
|
37
|
+
# uv
|
|
38
|
+
uv.lock
|
omdb_mcp-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
omdb_mcp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: omdb-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for the OMDb API (https://www.omdbapi.com/) — search movies, series, and episodes via FastMCP. Supports STDIO and Streamable HTTP transports.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Dworf/omdb-mcp
|
|
6
|
+
Project-URL: Repository, https://github.com/Dworf/omdb-mcp
|
|
7
|
+
Project-URL: Issues, https://github.com/Dworf/omdb-mcp/issues
|
|
8
|
+
Author: David
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: fastmcp,imdb,mcp,model-context-protocol,movies,omdb
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: fastmcp>=0.4.0
|
|
23
|
+
Requires-Dist: httpx>=0.27.0
|
|
24
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: respx>=0.21.0; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# omdb-mcp
|
|
32
|
+
|
|
33
|
+
An [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server for the [OMDb API](https://www.omdbapi.com/) — search movies, series, and episodes from any MCP-compatible client.
|
|
34
|
+
|
|
35
|
+
Built with [FastMCP](https://github.com/jlowin/fastmcp). Supports both **STDIO** and **Streamable HTTP** transports, with optional bearer-token auth for HTTP mode.
|
|
36
|
+
|
|
37
|
+
## Features
|
|
38
|
+
|
|
39
|
+
- Five tools: `search_movies`, `get_by_title`, `get_by_imdb_id`, `get_episode`, `get_season`
|
|
40
|
+
- Dual transport: STDIO (for MetaMCP / Claude Desktop / Hermes) and Streamable HTTP (for standalone agents)
|
|
41
|
+
- Optional bearer-token authentication for HTTP mode
|
|
42
|
+
- Configuration via `.env` or environment variables
|
|
43
|
+
- Minimal dependencies: `fastmcp`, `httpx`, `python-dotenv`
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# Recommended: run directly with uvx (no install needed)
|
|
49
|
+
uvx omdb-mcp
|
|
50
|
+
|
|
51
|
+
# Or install into your environment
|
|
52
|
+
pip install omdb-mcp
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Get a free OMDb API key at <https://www.omdbapi.com/apikey.aspx> (1,000 requests/day on the free tier).
|
|
56
|
+
|
|
57
|
+
## Configuration
|
|
58
|
+
|
|
59
|
+
All settings come from environment variables (a `.env` file in the working directory is auto-loaded).
|
|
60
|
+
|
|
61
|
+
| Variable | Default | Description |
|
|
62
|
+
| ---------------------- | ----------------------------- | ---------------------------------------------------------- |
|
|
63
|
+
| `OMDB_API_KEY` | _(required)_ | Your OMDb API key |
|
|
64
|
+
| `OMDB_MCP_TRANSPORT` | `stdio` | `stdio` or `http` |
|
|
65
|
+
| `OMDB_MCP_HOST` | `127.0.0.1` | HTTP bind address (`0.0.0.0` for LAN/Docker) |
|
|
66
|
+
| `OMDB_MCP_PORT` | `8087` | HTTP port |
|
|
67
|
+
| `OMDB_MCP_PATH` | `/mcp` | HTTP mount path |
|
|
68
|
+
| `OMDB_MCP_AUTH_TOKEN` | _(unset)_ | If set, HTTP clients must send `Authorization: Bearer ...` |
|
|
69
|
+
| `OMDB_MCP_LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
|
|
70
|
+
| `OMDB_REQUEST_TIMEOUT` | `10` | OMDb HTTP timeout (seconds) |
|
|
71
|
+
| `OMDB_BASE_URL` | `https://www.omdbapi.com/` | Override only if proxying/mirroring OMDb |
|
|
72
|
+
|
|
73
|
+
See `.env.example` for a copy-paste template.
|
|
74
|
+
|
|
75
|
+
## Running
|
|
76
|
+
|
|
77
|
+
### STDIO mode (for MCP clients that spawn the process)
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
omdb-mcp
|
|
81
|
+
# or with uv:
|
|
82
|
+
uvx omdb-mcp
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Streamable HTTP mode (for standalone, long-lived service)
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
omdb-mcp-http
|
|
89
|
+
# or override port/host:
|
|
90
|
+
omdb-mcp-http --port 8087 --host 0.0.0.0
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
CLI flags `--transport`, `--host`, `--port`, `--path` override env vars.
|
|
94
|
+
|
|
95
|
+
## Client setup
|
|
96
|
+
|
|
97
|
+
### MetaMCP
|
|
98
|
+
|
|
99
|
+
**Option A — STDIO (simplest, no service to manage):**
|
|
100
|
+
|
|
101
|
+
- **Type:** STDIO
|
|
102
|
+
- **Command:** `uvx`
|
|
103
|
+
- **Args:** `omdb-mcp`
|
|
104
|
+
- **Env:** `OMDB_API_KEY=your_key_here`
|
|
105
|
+
|
|
106
|
+
**Option B — Streamable HTTP (standalone service):**
|
|
107
|
+
|
|
108
|
+
Run the server on the host:
|
|
109
|
+
```bash
|
|
110
|
+
OMDB_API_KEY=your_key OMDB_MCP_AUTH_TOKEN=mysecret omdb-mcp-http
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
In MetaMCP:
|
|
114
|
+
- **Type:** Streamable HTTP
|
|
115
|
+
- **URL:** `http://host.docker.internal:8087/mcp`
|
|
116
|
+
- **Header:** `Authorization: Bearer mysecret` (if `OMDB_MCP_AUTH_TOKEN` is set)
|
|
117
|
+
|
|
118
|
+
### Claude Desktop
|
|
119
|
+
|
|
120
|
+
Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{
|
|
124
|
+
"mcpServers": {
|
|
125
|
+
"omdb": {
|
|
126
|
+
"command": "uvx",
|
|
127
|
+
"args": ["omdb-mcp"],
|
|
128
|
+
"env": {
|
|
129
|
+
"OMDB_API_KEY": "your_key_here"
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Cursor / Windsurf / Cline
|
|
137
|
+
|
|
138
|
+
Same JSON shape, different config path. Cursor reads `~/.cursor/mcp.json`:
|
|
139
|
+
|
|
140
|
+
```json
|
|
141
|
+
{
|
|
142
|
+
"mcpServers": {
|
|
143
|
+
"omdb": {
|
|
144
|
+
"command": "uvx",
|
|
145
|
+
"args": ["omdb-mcp"],
|
|
146
|
+
"env": { "OMDB_API_KEY": "your_key_here" }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Generic Streamable HTTP client
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"mcpServers": {
|
|
157
|
+
"omdb": {
|
|
158
|
+
"url": "http://127.0.0.1:8087/mcp",
|
|
159
|
+
"headers": {
|
|
160
|
+
"Authorization": "Bearer your_token_here"
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Tools
|
|
168
|
+
|
|
169
|
+
| Tool | OMDb param(s) | Description |
|
|
170
|
+
| ---------------- | ------------------------------------ | -------------------------------------------- |
|
|
171
|
+
| `search_movies` | `s`, `y`, `type`, `page` | Free-text search (paginated, 10 per page) |
|
|
172
|
+
| `get_by_title` | `t`, `y`, `type`, `plot` | Lookup by exact title |
|
|
173
|
+
| `get_by_imdb_id` | `i`, `plot` | Lookup by IMDb ID (`tt...`) |
|
|
174
|
+
| `get_episode` | `i`/`t`, `Season`, `Episode`, `plot` | Single TV episode |
|
|
175
|
+
| `get_season` | `i`/`t`, `Season` | All episodes in a season |
|
|
176
|
+
|
|
177
|
+
## Running as a service (HTTP mode)
|
|
178
|
+
|
|
179
|
+
### macOS — launchd
|
|
180
|
+
|
|
181
|
+
Create `~/Library/LaunchAgents/com.dworf.omdb-mcp.plist`:
|
|
182
|
+
|
|
183
|
+
```xml
|
|
184
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
185
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
186
|
+
<plist version="1.0">
|
|
187
|
+
<dict>
|
|
188
|
+
<key>Label</key><string>com.dworf.omdb-mcp</string>
|
|
189
|
+
<key>ProgramArguments</key>
|
|
190
|
+
<array>
|
|
191
|
+
<string>/Users/YOU/.local/bin/uvx</string>
|
|
192
|
+
<string>omdb-mcp-http</string>
|
|
193
|
+
</array>
|
|
194
|
+
<key>EnvironmentVariables</key>
|
|
195
|
+
<dict>
|
|
196
|
+
<key>OMDB_API_KEY</key><string>your_key_here</string>
|
|
197
|
+
<key>OMDB_MCP_AUTH_TOKEN</key><string>your_token_here</string>
|
|
198
|
+
<key>OMDB_MCP_HOST</key><string>127.0.0.1</string>
|
|
199
|
+
<key>OMDB_MCP_PORT</key><string>8087</string>
|
|
200
|
+
</dict>
|
|
201
|
+
<key>RunAtLoad</key><true/>
|
|
202
|
+
<key>KeepAlive</key><true/>
|
|
203
|
+
<key>StandardOutPath</key><string>/tmp/omdb-mcp.log</string>
|
|
204
|
+
<key>StandardErrorPath</key><string>/tmp/omdb-mcp.err</string>
|
|
205
|
+
</dict>
|
|
206
|
+
</plist>
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Load it:
|
|
210
|
+
```bash
|
|
211
|
+
launchctl load ~/Library/LaunchAgents/com.dworf.omdb-mcp.plist
|
|
212
|
+
# stop / unload:
|
|
213
|
+
launchctl unload ~/Library/LaunchAgents/com.dworf.omdb-mcp.plist
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Linux — systemd
|
|
217
|
+
|
|
218
|
+
Create `~/.config/systemd/user/omdb-mcp.service`:
|
|
219
|
+
|
|
220
|
+
```ini
|
|
221
|
+
[Unit]
|
|
222
|
+
Description=OMDb MCP server (Streamable HTTP)
|
|
223
|
+
After=network.target
|
|
224
|
+
|
|
225
|
+
[Service]
|
|
226
|
+
ExecStart=%h/.local/bin/uvx omdb-mcp-http
|
|
227
|
+
Environment=OMDB_API_KEY=your_key_here
|
|
228
|
+
Environment=OMDB_MCP_AUTH_TOKEN=your_token_here
|
|
229
|
+
Environment=OMDB_MCP_HOST=127.0.0.1
|
|
230
|
+
Environment=OMDB_MCP_PORT=8087
|
|
231
|
+
Restart=on-failure
|
|
232
|
+
RestartSec=5
|
|
233
|
+
|
|
234
|
+
[Install]
|
|
235
|
+
WantedBy=default.target
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
systemctl --user daemon-reload
|
|
240
|
+
systemctl --user enable --now omdb-mcp
|
|
241
|
+
systemctl --user status omdb-mcp
|
|
242
|
+
journalctl --user -u omdb-mcp -f
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Docker
|
|
246
|
+
|
|
247
|
+
```dockerfile
|
|
248
|
+
FROM python:3.12-slim
|
|
249
|
+
RUN pip install --no-cache-dir omdb-mcp
|
|
250
|
+
EXPOSE 8087
|
|
251
|
+
ENV OMDB_MCP_HOST=0.0.0.0 OMDB_MCP_PORT=8087
|
|
252
|
+
CMD ["omdb-mcp-http"]
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
docker build -t omdb-mcp .
|
|
257
|
+
docker run -d --name omdb-mcp -p 8087:8087 \
|
|
258
|
+
-e OMDB_API_KEY=your_key \
|
|
259
|
+
-e OMDB_MCP_AUTH_TOKEN=your_token \
|
|
260
|
+
--restart unless-stopped omdb-mcp
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## Development
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
git clone https://github.com/Dworf/omdb-mcp
|
|
267
|
+
cd omdb-mcp
|
|
268
|
+
uv venv && source .venv/bin/activate
|
|
269
|
+
uv pip install -e ".[dev]"
|
|
270
|
+
pytest
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## Security note
|
|
274
|
+
|
|
275
|
+
If you bind to `0.0.0.0` without setting `OMDB_MCP_AUTH_TOKEN`, anyone on the network can use your OMDb API key. The server logs a warning at startup in that case — **set a token** in any non-localhost deployment.
|
|
276
|
+
|
|
277
|
+
## Changelog
|
|
278
|
+
|
|
279
|
+
### v0.1.0 — 2026-05-14
|
|
280
|
+
- Initial release.
|
|
281
|
+
- Five tools: `search_movies`, `get_by_title`, `get_by_imdb_id`, `get_episode`, `get_season`.
|
|
282
|
+
- Dual transport: STDIO (default) and Streamable HTTP.
|
|
283
|
+
- Optional bearer-token auth for HTTP mode.
|
|
284
|
+
- `.env` loading via `python-dotenv`.
|
|
285
|
+
- 20 unit tests; smoke-tested against the live OMDb API.
|
|
286
|
+
|
|
287
|
+
## Acknowledgements
|
|
288
|
+
|
|
289
|
+
- [OMDb API](https://www.omdbapi.com/) — Brian Fritz's free movie/series database. If you use this MCP server in production, please consider [donating to OMDb](https://www.omdbapi.com/) or upgrading to a Patreon-tier key.
|
|
290
|
+
- [FastMCP](https://github.com/jlowin/fastmcp) — the Python MCP framework this server is built on.
|
|
291
|
+
- [Model Context Protocol](https://modelcontextprotocol.io/) — the open protocol that makes this possible.
|
|
292
|
+
|
|
293
|
+
## License
|
|
294
|
+
|
|
295
|
+
MIT
|
omdb_mcp-0.1.0/README.md
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# omdb-mcp
|
|
2
|
+
|
|
3
|
+
An [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server for the [OMDb API](https://www.omdbapi.com/) — search movies, series, and episodes from any MCP-compatible client.
|
|
4
|
+
|
|
5
|
+
Built with [FastMCP](https://github.com/jlowin/fastmcp). Supports both **STDIO** and **Streamable HTTP** transports, with optional bearer-token auth for HTTP mode.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Five tools: `search_movies`, `get_by_title`, `get_by_imdb_id`, `get_episode`, `get_season`
|
|
10
|
+
- Dual transport: STDIO (for MetaMCP / Claude Desktop / Hermes) and Streamable HTTP (for standalone agents)
|
|
11
|
+
- Optional bearer-token authentication for HTTP mode
|
|
12
|
+
- Configuration via `.env` or environment variables
|
|
13
|
+
- Minimal dependencies: `fastmcp`, `httpx`, `python-dotenv`
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Recommended: run directly with uvx (no install needed)
|
|
19
|
+
uvx omdb-mcp
|
|
20
|
+
|
|
21
|
+
# Or install into your environment
|
|
22
|
+
pip install omdb-mcp
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Get a free OMDb API key at <https://www.omdbapi.com/apikey.aspx> (1,000 requests/day on the free tier).
|
|
26
|
+
|
|
27
|
+
## Configuration
|
|
28
|
+
|
|
29
|
+
All settings come from environment variables (a `.env` file in the working directory is auto-loaded).
|
|
30
|
+
|
|
31
|
+
| Variable | Default | Description |
|
|
32
|
+
| ---------------------- | ----------------------------- | ---------------------------------------------------------- |
|
|
33
|
+
| `OMDB_API_KEY` | _(required)_ | Your OMDb API key |
|
|
34
|
+
| `OMDB_MCP_TRANSPORT` | `stdio` | `stdio` or `http` |
|
|
35
|
+
| `OMDB_MCP_HOST` | `127.0.0.1` | HTTP bind address (`0.0.0.0` for LAN/Docker) |
|
|
36
|
+
| `OMDB_MCP_PORT` | `8087` | HTTP port |
|
|
37
|
+
| `OMDB_MCP_PATH` | `/mcp` | HTTP mount path |
|
|
38
|
+
| `OMDB_MCP_AUTH_TOKEN` | _(unset)_ | If set, HTTP clients must send `Authorization: Bearer ...` |
|
|
39
|
+
| `OMDB_MCP_LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
|
|
40
|
+
| `OMDB_REQUEST_TIMEOUT` | `10` | OMDb HTTP timeout (seconds) |
|
|
41
|
+
| `OMDB_BASE_URL` | `https://www.omdbapi.com/` | Override only if proxying/mirroring OMDb |
|
|
42
|
+
|
|
43
|
+
See `.env.example` for a copy-paste template.
|
|
44
|
+
|
|
45
|
+
## Running
|
|
46
|
+
|
|
47
|
+
### STDIO mode (for MCP clients that spawn the process)
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
omdb-mcp
|
|
51
|
+
# or with uv:
|
|
52
|
+
uvx omdb-mcp
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Streamable HTTP mode (for standalone, long-lived service)
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
omdb-mcp-http
|
|
59
|
+
# or override port/host:
|
|
60
|
+
omdb-mcp-http --port 8087 --host 0.0.0.0
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
CLI flags `--transport`, `--host`, `--port`, `--path` override env vars.
|
|
64
|
+
|
|
65
|
+
## Client setup
|
|
66
|
+
|
|
67
|
+
### MetaMCP
|
|
68
|
+
|
|
69
|
+
**Option A — STDIO (simplest, no service to manage):**
|
|
70
|
+
|
|
71
|
+
- **Type:** STDIO
|
|
72
|
+
- **Command:** `uvx`
|
|
73
|
+
- **Args:** `omdb-mcp`
|
|
74
|
+
- **Env:** `OMDB_API_KEY=your_key_here`
|
|
75
|
+
|
|
76
|
+
**Option B — Streamable HTTP (standalone service):**
|
|
77
|
+
|
|
78
|
+
Run the server on the host:
|
|
79
|
+
```bash
|
|
80
|
+
OMDB_API_KEY=your_key OMDB_MCP_AUTH_TOKEN=mysecret omdb-mcp-http
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
In MetaMCP:
|
|
84
|
+
- **Type:** Streamable HTTP
|
|
85
|
+
- **URL:** `http://host.docker.internal:8087/mcp`
|
|
86
|
+
- **Header:** `Authorization: Bearer mysecret` (if `OMDB_MCP_AUTH_TOKEN` is set)
|
|
87
|
+
|
|
88
|
+
### Claude Desktop
|
|
89
|
+
|
|
90
|
+
Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"mcpServers": {
|
|
95
|
+
"omdb": {
|
|
96
|
+
"command": "uvx",
|
|
97
|
+
"args": ["omdb-mcp"],
|
|
98
|
+
"env": {
|
|
99
|
+
"OMDB_API_KEY": "your_key_here"
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Cursor / Windsurf / Cline
|
|
107
|
+
|
|
108
|
+
Same JSON shape, different config path. Cursor reads `~/.cursor/mcp.json`:
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
{
|
|
112
|
+
"mcpServers": {
|
|
113
|
+
"omdb": {
|
|
114
|
+
"command": "uvx",
|
|
115
|
+
"args": ["omdb-mcp"],
|
|
116
|
+
"env": { "OMDB_API_KEY": "your_key_here" }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Generic Streamable HTTP client
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"mcpServers": {
|
|
127
|
+
"omdb": {
|
|
128
|
+
"url": "http://127.0.0.1:8087/mcp",
|
|
129
|
+
"headers": {
|
|
130
|
+
"Authorization": "Bearer your_token_here"
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Tools
|
|
138
|
+
|
|
139
|
+
| Tool | OMDb param(s) | Description |
|
|
140
|
+
| ---------------- | ------------------------------------ | -------------------------------------------- |
|
|
141
|
+
| `search_movies` | `s`, `y`, `type`, `page` | Free-text search (paginated, 10 per page) |
|
|
142
|
+
| `get_by_title` | `t`, `y`, `type`, `plot` | Lookup by exact title |
|
|
143
|
+
| `get_by_imdb_id` | `i`, `plot` | Lookup by IMDb ID (`tt...`) |
|
|
144
|
+
| `get_episode` | `i`/`t`, `Season`, `Episode`, `plot` | Single TV episode |
|
|
145
|
+
| `get_season` | `i`/`t`, `Season` | All episodes in a season |
|
|
146
|
+
|
|
147
|
+
## Running as a service (HTTP mode)
|
|
148
|
+
|
|
149
|
+
### macOS — launchd
|
|
150
|
+
|
|
151
|
+
Create `~/Library/LaunchAgents/com.dworf.omdb-mcp.plist`:
|
|
152
|
+
|
|
153
|
+
```xml
|
|
154
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
155
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
156
|
+
<plist version="1.0">
|
|
157
|
+
<dict>
|
|
158
|
+
<key>Label</key><string>com.dworf.omdb-mcp</string>
|
|
159
|
+
<key>ProgramArguments</key>
|
|
160
|
+
<array>
|
|
161
|
+
<string>/Users/YOU/.local/bin/uvx</string>
|
|
162
|
+
<string>omdb-mcp-http</string>
|
|
163
|
+
</array>
|
|
164
|
+
<key>EnvironmentVariables</key>
|
|
165
|
+
<dict>
|
|
166
|
+
<key>OMDB_API_KEY</key><string>your_key_here</string>
|
|
167
|
+
<key>OMDB_MCP_AUTH_TOKEN</key><string>your_token_here</string>
|
|
168
|
+
<key>OMDB_MCP_HOST</key><string>127.0.0.1</string>
|
|
169
|
+
<key>OMDB_MCP_PORT</key><string>8087</string>
|
|
170
|
+
</dict>
|
|
171
|
+
<key>RunAtLoad</key><true/>
|
|
172
|
+
<key>KeepAlive</key><true/>
|
|
173
|
+
<key>StandardOutPath</key><string>/tmp/omdb-mcp.log</string>
|
|
174
|
+
<key>StandardErrorPath</key><string>/tmp/omdb-mcp.err</string>
|
|
175
|
+
</dict>
|
|
176
|
+
</plist>
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Load it:
|
|
180
|
+
```bash
|
|
181
|
+
launchctl load ~/Library/LaunchAgents/com.dworf.omdb-mcp.plist
|
|
182
|
+
# stop / unload:
|
|
183
|
+
launchctl unload ~/Library/LaunchAgents/com.dworf.omdb-mcp.plist
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Linux — systemd
|
|
187
|
+
|
|
188
|
+
Create `~/.config/systemd/user/omdb-mcp.service`:
|
|
189
|
+
|
|
190
|
+
```ini
|
|
191
|
+
[Unit]
|
|
192
|
+
Description=OMDb MCP server (Streamable HTTP)
|
|
193
|
+
After=network.target
|
|
194
|
+
|
|
195
|
+
[Service]
|
|
196
|
+
ExecStart=%h/.local/bin/uvx omdb-mcp-http
|
|
197
|
+
Environment=OMDB_API_KEY=your_key_here
|
|
198
|
+
Environment=OMDB_MCP_AUTH_TOKEN=your_token_here
|
|
199
|
+
Environment=OMDB_MCP_HOST=127.0.0.1
|
|
200
|
+
Environment=OMDB_MCP_PORT=8087
|
|
201
|
+
Restart=on-failure
|
|
202
|
+
RestartSec=5
|
|
203
|
+
|
|
204
|
+
[Install]
|
|
205
|
+
WantedBy=default.target
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
systemctl --user daemon-reload
|
|
210
|
+
systemctl --user enable --now omdb-mcp
|
|
211
|
+
systemctl --user status omdb-mcp
|
|
212
|
+
journalctl --user -u omdb-mcp -f
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Docker
|
|
216
|
+
|
|
217
|
+
```dockerfile
|
|
218
|
+
FROM python:3.12-slim
|
|
219
|
+
RUN pip install --no-cache-dir omdb-mcp
|
|
220
|
+
EXPOSE 8087
|
|
221
|
+
ENV OMDB_MCP_HOST=0.0.0.0 OMDB_MCP_PORT=8087
|
|
222
|
+
CMD ["omdb-mcp-http"]
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
docker build -t omdb-mcp .
|
|
227
|
+
docker run -d --name omdb-mcp -p 8087:8087 \
|
|
228
|
+
-e OMDB_API_KEY=your_key \
|
|
229
|
+
-e OMDB_MCP_AUTH_TOKEN=your_token \
|
|
230
|
+
--restart unless-stopped omdb-mcp
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## Development
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
git clone https://github.com/Dworf/omdb-mcp
|
|
237
|
+
cd omdb-mcp
|
|
238
|
+
uv venv && source .venv/bin/activate
|
|
239
|
+
uv pip install -e ".[dev]"
|
|
240
|
+
pytest
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
## Security note
|
|
244
|
+
|
|
245
|
+
If you bind to `0.0.0.0` without setting `OMDB_MCP_AUTH_TOKEN`, anyone on the network can use your OMDb API key. The server logs a warning at startup in that case — **set a token** in any non-localhost deployment.
|
|
246
|
+
|
|
247
|
+
## Changelog
|
|
248
|
+
|
|
249
|
+
### v0.1.0 — 2026-05-14
|
|
250
|
+
- Initial release.
|
|
251
|
+
- Five tools: `search_movies`, `get_by_title`, `get_by_imdb_id`, `get_episode`, `get_season`.
|
|
252
|
+
- Dual transport: STDIO (default) and Streamable HTTP.
|
|
253
|
+
- Optional bearer-token auth for HTTP mode.
|
|
254
|
+
- `.env` loading via `python-dotenv`.
|
|
255
|
+
- 20 unit tests; smoke-tested against the live OMDb API.
|
|
256
|
+
|
|
257
|
+
## Acknowledgements
|
|
258
|
+
|
|
259
|
+
- [OMDb API](https://www.omdbapi.com/) — Brian Fritz's free movie/series database. If you use this MCP server in production, please consider [donating to OMDb](https://www.omdbapi.com/) or upgrading to a Patreon-tier key.
|
|
260
|
+
- [FastMCP](https://github.com/jlowin/fastmcp) — the Python MCP framework this server is built on.
|
|
261
|
+
- [Model Context Protocol](https://modelcontextprotocol.io/) — the open protocol that makes this possible.
|
|
262
|
+
|
|
263
|
+
## License
|
|
264
|
+
|
|
265
|
+
MIT
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "omdb-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server for the OMDb API (https://www.omdbapi.com/) — search movies, series, and episodes via FastMCP. Supports STDIO and Streamable HTTP transports."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "David" }]
|
|
13
|
+
keywords = ["mcp", "omdb", "imdb", "movies", "fastmcp", "model-context-protocol"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Topic :: Software Development :: Libraries",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"fastmcp>=0.4.0",
|
|
27
|
+
"httpx>=0.27.0",
|
|
28
|
+
"python-dotenv>=1.0.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=8.0.0",
|
|
34
|
+
"pytest-asyncio>=0.23.0",
|
|
35
|
+
"respx>=0.21.0",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
omdb-mcp = "omdb_mcp.server:main_stdio"
|
|
40
|
+
omdb-mcp-http = "omdb_mcp.server:main_http"
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/Dworf/omdb-mcp"
|
|
44
|
+
Repository = "https://github.com/Dworf/omdb-mcp"
|
|
45
|
+
Issues = "https://github.com/Dworf/omdb-mcp/issues"
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["src/omdb_mcp"]
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
testpaths = ["tests"]
|
|
52
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""omdb-mcp FastMCP server.
|
|
2
|
+
|
|
3
|
+
Exposes the OMDb API (https://www.omdbapi.com/) as MCP tools.
|
|
4
|
+
|
|
5
|
+
Two transports:
|
|
6
|
+
- stdio: spawned by MetaMCP / Claude Desktop / Hermes (default)
|
|
7
|
+
- http : long-lived Streamable HTTP service for standalone agents
|
|
8
|
+
|
|
9
|
+
Entry points (see pyproject.toml [project.scripts]):
|
|
10
|
+
omdb-mcp -> main_stdio()
|
|
11
|
+
omdb-mcp-http -> main_http()
|
|
12
|
+
Both also accept --transport / --host / --port / --path overrides.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Any, Literal
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
from dotenv import load_dotenv
|
|
26
|
+
from fastmcp import FastMCP
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Configuration
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
DEFAULT_BASE_URL = "https://www.omdbapi.com/"
|
|
33
|
+
DEFAULT_TRANSPORT: Literal["stdio", "http"] = "stdio"
|
|
34
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
35
|
+
DEFAULT_PORT = 8087
|
|
36
|
+
DEFAULT_PATH = "/mcp"
|
|
37
|
+
DEFAULT_LOG_LEVEL = "INFO"
|
|
38
|
+
DEFAULT_TIMEOUT = 10.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Config:
|
|
43
|
+
"""Runtime configuration assembled from env + CLI flags."""
|
|
44
|
+
|
|
45
|
+
api_key: str
|
|
46
|
+
base_url: str = DEFAULT_BASE_URL
|
|
47
|
+
transport: Literal["stdio", "http"] = DEFAULT_TRANSPORT
|
|
48
|
+
host: str = DEFAULT_HOST
|
|
49
|
+
port: int = DEFAULT_PORT
|
|
50
|
+
path: str = DEFAULT_PATH
|
|
51
|
+
auth_token: str | None = None
|
|
52
|
+
log_level: str = DEFAULT_LOG_LEVEL
|
|
53
|
+
timeout: float = DEFAULT_TIMEOUT
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_env(cls) -> "Config":
|
|
57
|
+
api_key = os.environ.get("OMDB_API_KEY", "").strip()
|
|
58
|
+
if not api_key:
|
|
59
|
+
raise RuntimeError(
|
|
60
|
+
"OMDB_API_KEY is not set. Add it to your environment or .env file. "
|
|
61
|
+
"Get a free key at https://www.omdbapi.com/apikey.aspx"
|
|
62
|
+
)
|
|
63
|
+
transport = os.environ.get("OMDB_MCP_TRANSPORT", DEFAULT_TRANSPORT).lower()
|
|
64
|
+
if transport not in ("stdio", "http"):
|
|
65
|
+
raise RuntimeError(
|
|
66
|
+
f"OMDB_MCP_TRANSPORT must be 'stdio' or 'http', got: {transport!r}"
|
|
67
|
+
)
|
|
68
|
+
auth_token = os.environ.get("OMDB_MCP_AUTH_TOKEN", "").strip() or None
|
|
69
|
+
return cls(
|
|
70
|
+
api_key=api_key,
|
|
71
|
+
base_url=os.environ.get("OMDB_BASE_URL", DEFAULT_BASE_URL).strip() or DEFAULT_BASE_URL,
|
|
72
|
+
transport=transport, # type: ignore[arg-type]
|
|
73
|
+
host=os.environ.get("OMDB_MCP_HOST", DEFAULT_HOST).strip() or DEFAULT_HOST,
|
|
74
|
+
port=int(os.environ.get("OMDB_MCP_PORT", DEFAULT_PORT)),
|
|
75
|
+
path=os.environ.get("OMDB_MCP_PATH", DEFAULT_PATH).strip() or DEFAULT_PATH,
|
|
76
|
+
auth_token=auth_token,
|
|
77
|
+
log_level=os.environ.get("OMDB_MCP_LOG_LEVEL", DEFAULT_LOG_LEVEL).upper(),
|
|
78
|
+
timeout=float(os.environ.get("OMDB_REQUEST_TIMEOUT", DEFAULT_TIMEOUT)),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# OMDb HTTP client
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class OMDBError(RuntimeError):
|
|
88
|
+
"""Raised when the OMDb API returns Response=False."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class OMDBClient:
|
|
92
|
+
"""Thin async wrapper around the single OMDb endpoint."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, api_key: str, base_url: str = DEFAULT_BASE_URL, timeout: float = DEFAULT_TIMEOUT):
|
|
95
|
+
self.api_key = api_key
|
|
96
|
+
self.base_url = base_url
|
|
97
|
+
self.timeout = timeout
|
|
98
|
+
|
|
99
|
+
async def _request(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
100
|
+
# Drop None values so we don't send empty query params.
|
|
101
|
+
clean = {k: v for k, v in params.items() if v is not None}
|
|
102
|
+
clean["apikey"] = self.api_key
|
|
103
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
104
|
+
resp = await client.get(self.base_url, params=clean)
|
|
105
|
+
resp.raise_for_status()
|
|
106
|
+
data = resp.json()
|
|
107
|
+
# OMDb signals errors via {"Response": "False", "Error": "..."}.
|
|
108
|
+
if isinstance(data, dict) and data.get("Response") == "False":
|
|
109
|
+
raise OMDBError(data.get("Error", "Unknown OMDb error"))
|
|
110
|
+
return data
|
|
111
|
+
|
|
112
|
+
async def search(
|
|
113
|
+
self,
|
|
114
|
+
query: str,
|
|
115
|
+
year: int | None = None,
|
|
116
|
+
type_: str | None = None,
|
|
117
|
+
page: int = 1,
|
|
118
|
+
) -> dict[str, Any]:
|
|
119
|
+
return await self._request({"s": query, "y": year, "type": type_, "page": page})
|
|
120
|
+
|
|
121
|
+
async def by_title(
|
|
122
|
+
self,
|
|
123
|
+
title: str,
|
|
124
|
+
year: int | None = None,
|
|
125
|
+
type_: str | None = None,
|
|
126
|
+
plot: str = "short",
|
|
127
|
+
) -> dict[str, Any]:
|
|
128
|
+
return await self._request({"t": title, "y": year, "type": type_, "plot": plot})
|
|
129
|
+
|
|
130
|
+
async def by_id(self, imdb_id: str, plot: str = "short") -> dict[str, Any]:
|
|
131
|
+
return await self._request({"i": imdb_id, "plot": plot})
|
|
132
|
+
|
|
133
|
+
async def episode(
|
|
134
|
+
self,
|
|
135
|
+
season: int,
|
|
136
|
+
episode: int,
|
|
137
|
+
imdb_id: str | None = None,
|
|
138
|
+
title: str | None = None,
|
|
139
|
+
plot: str = "short",
|
|
140
|
+
) -> dict[str, Any]:
|
|
141
|
+
if not imdb_id and not title:
|
|
142
|
+
raise ValueError("episode(): provide either imdb_id or title")
|
|
143
|
+
return await self._request(
|
|
144
|
+
{
|
|
145
|
+
"i": imdb_id,
|
|
146
|
+
"t": title,
|
|
147
|
+
"Season": season,
|
|
148
|
+
"Episode": episode,
|
|
149
|
+
"plot": plot,
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
async def season(
|
|
154
|
+
self,
|
|
155
|
+
season: int,
|
|
156
|
+
imdb_id: str | None = None,
|
|
157
|
+
title: str | None = None,
|
|
158
|
+
) -> dict[str, Any]:
|
|
159
|
+
if not imdb_id and not title:
|
|
160
|
+
raise ValueError("season(): provide either imdb_id or title")
|
|
161
|
+
return await self._request({"i": imdb_id, "t": title, "Season": season})
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
# Tool validation helpers
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
_VALID_TYPES = {"movie", "series", "episode"}
|
|
169
|
+
_VALID_PLOTS = {"short", "full"}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _validate_type(type_: str | None) -> str | None:
|
|
173
|
+
if type_ is None:
|
|
174
|
+
return None
|
|
175
|
+
t = type_.lower().strip()
|
|
176
|
+
if t not in _VALID_TYPES:
|
|
177
|
+
raise ValueError(f"type must be one of {sorted(_VALID_TYPES)}, got: {type_!r}")
|
|
178
|
+
return t
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _validate_plot(plot: str) -> str:
|
|
182
|
+
p = plot.lower().strip()
|
|
183
|
+
if p not in _VALID_PLOTS:
|
|
184
|
+
raise ValueError(f"plot must be one of {sorted(_VALID_PLOTS)}, got: {plot!r}")
|
|
185
|
+
return p
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
# Server factory
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def build_server(config: Config) -> FastMCP:
|
|
194
|
+
"""Construct a FastMCP server with all tools wired to an OMDBClient."""
|
|
195
|
+
mcp = FastMCP("omdb-mcp")
|
|
196
|
+
client = OMDBClient(config.api_key, config.base_url, config.timeout)
|
|
197
|
+
|
|
198
|
+
@mcp.tool
|
|
199
|
+
async def search_movies(
|
|
200
|
+
query: str,
|
|
201
|
+
year: int | None = None,
|
|
202
|
+
type: str | None = None,
|
|
203
|
+
page: int = 1,
|
|
204
|
+
) -> dict[str, Any]:
|
|
205
|
+
"""Search OMDb for movies/series/episodes by free-text title.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
query: Search text (OMDb's `s` parameter). Required.
|
|
209
|
+
year: Optional release year filter.
|
|
210
|
+
type: Optional filter: "movie", "series", or "episode".
|
|
211
|
+
page: Page number (10 results per page).
|
|
212
|
+
"""
|
|
213
|
+
return await client.search(query, year=year, type_=_validate_type(type), page=page)
|
|
214
|
+
|
|
215
|
+
@mcp.tool
|
|
216
|
+
async def get_by_title(
|
|
217
|
+
title: str,
|
|
218
|
+
year: int | None = None,
|
|
219
|
+
type: str | None = None,
|
|
220
|
+
plot: str = "short",
|
|
221
|
+
) -> dict[str, Any]:
|
|
222
|
+
"""Look up a single title by exact name (OMDb `t` parameter).
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
title: Exact title to look up.
|
|
226
|
+
year: Optional release year disambiguator.
|
|
227
|
+
type: Optional filter: "movie", "series", or "episode".
|
|
228
|
+
plot: "short" (default) or "full".
|
|
229
|
+
"""
|
|
230
|
+
return await client.by_title(
|
|
231
|
+
title, year=year, type_=_validate_type(type), plot=_validate_plot(plot)
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
@mcp.tool
|
|
235
|
+
async def get_by_imdb_id(imdb_id: str, plot: str = "short") -> dict[str, Any]:
|
|
236
|
+
"""Look up a title by its IMDb ID (e.g. "tt0111161"). OMDb `i` parameter.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
imdb_id: IMDb ID, including the "tt" prefix.
|
|
240
|
+
plot: "short" (default) or "full".
|
|
241
|
+
"""
|
|
242
|
+
return await client.by_id(imdb_id, plot=_validate_plot(plot))
|
|
243
|
+
|
|
244
|
+
@mcp.tool
|
|
245
|
+
async def get_episode(
|
|
246
|
+
season: int,
|
|
247
|
+
episode: int,
|
|
248
|
+
imdb_id: str | None = None,
|
|
249
|
+
title: str | None = None,
|
|
250
|
+
plot: str = "short",
|
|
251
|
+
) -> dict[str, Any]:
|
|
252
|
+
"""Look up a specific TV episode.
|
|
253
|
+
|
|
254
|
+
Provide either `imdb_id` (preferred) or `title` to identify the series,
|
|
255
|
+
plus `season` and `episode` numbers.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
season: Season number (1-indexed).
|
|
259
|
+
episode: Episode number within the season.
|
|
260
|
+
imdb_id: IMDb ID of the series (preferred).
|
|
261
|
+
title: Series title (used if imdb_id is not provided).
|
|
262
|
+
plot: "short" (default) or "full".
|
|
263
|
+
"""
|
|
264
|
+
return await client.episode(
|
|
265
|
+
season=season,
|
|
266
|
+
episode=episode,
|
|
267
|
+
imdb_id=imdb_id,
|
|
268
|
+
title=title,
|
|
269
|
+
plot=_validate_plot(plot),
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
@mcp.tool
|
|
273
|
+
async def get_season(
|
|
274
|
+
season: int,
|
|
275
|
+
imdb_id: str | None = None,
|
|
276
|
+
title: str | None = None,
|
|
277
|
+
) -> dict[str, Any]:
|
|
278
|
+
"""List all episodes in a given season of a series.
|
|
279
|
+
|
|
280
|
+
Provide either `imdb_id` (preferred) or `title`.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
season: Season number (1-indexed).
|
|
284
|
+
imdb_id: IMDb ID of the series (preferred).
|
|
285
|
+
title: Series title (used if imdb_id is not provided).
|
|
286
|
+
"""
|
|
287
|
+
return await client.season(season=season, imdb_id=imdb_id, title=title)
|
|
288
|
+
|
|
289
|
+
return mcp
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ---------------------------------------------------------------------------
|
|
293
|
+
# CLI entry points
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _setup_logging(level: str) -> None:
|
|
298
|
+
logging.basicConfig(
|
|
299
|
+
level=getattr(logging, level.upper(), logging.INFO),
|
|
300
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
301
|
+
stream=sys.stderr,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _parse_args(argv: list[str] | None, default_transport: str) -> argparse.Namespace:
|
|
306
|
+
p = argparse.ArgumentParser(prog="omdb-mcp", description="OMDb MCP server")
|
|
307
|
+
p.add_argument(
|
|
308
|
+
"--transport",
|
|
309
|
+
choices=("stdio", "http"),
|
|
310
|
+
default=None,
|
|
311
|
+
help="Override OMDB_MCP_TRANSPORT (default: env or %s)" % default_transport,
|
|
312
|
+
)
|
|
313
|
+
p.add_argument("--host", default=None, help="HTTP bind address (default: env or 127.0.0.1)")
|
|
314
|
+
p.add_argument("--port", type=int, default=None, help="HTTP port (default: env or 8087)")
|
|
315
|
+
p.add_argument("--path", default=None, help="HTTP mount path (default: env or /mcp)")
|
|
316
|
+
return p.parse_args(argv)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _build_config(argv: list[str] | None, default_transport: str) -> Config:
|
|
320
|
+
load_dotenv()
|
|
321
|
+
args = _parse_args(argv, default_transport=default_transport)
|
|
322
|
+
config = Config.from_env()
|
|
323
|
+
# CLI flags trump env.
|
|
324
|
+
if args.transport:
|
|
325
|
+
config.transport = args.transport # type: ignore[assignment]
|
|
326
|
+
elif default_transport == "http" and os.environ.get("OMDB_MCP_TRANSPORT") is None:
|
|
327
|
+
# main_http entry point forces HTTP unless user explicitly set env or flag.
|
|
328
|
+
config.transport = "http"
|
|
329
|
+
if args.host:
|
|
330
|
+
config.host = args.host
|
|
331
|
+
if args.port:
|
|
332
|
+
config.port = args.port
|
|
333
|
+
if args.path:
|
|
334
|
+
config.path = args.path
|
|
335
|
+
return config
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _run(config: Config) -> None:
|
|
339
|
+
_setup_logging(config.log_level)
|
|
340
|
+
log = logging.getLogger("omdb-mcp")
|
|
341
|
+
mcp = build_server(config)
|
|
342
|
+
|
|
343
|
+
if config.transport == "stdio":
|
|
344
|
+
log.info("Starting omdb-mcp on STDIO")
|
|
345
|
+
mcp.run()
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
# HTTP mode
|
|
349
|
+
if config.host == "0.0.0.0" and not config.auth_token:
|
|
350
|
+
log.warning(
|
|
351
|
+
"Binding 0.0.0.0 without OMDB_MCP_AUTH_TOKEN — "
|
|
352
|
+
"anyone on the network can use your OMDb API key."
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
if config.auth_token:
|
|
356
|
+
try:
|
|
357
|
+
from fastmcp.server.auth.providers.jwt import StaticTokenVerifier
|
|
358
|
+
|
|
359
|
+
mcp.auth = StaticTokenVerifier(
|
|
360
|
+
tokens={config.auth_token: {"client_id": "omdb-mcp", "scopes": []}}
|
|
361
|
+
)
|
|
362
|
+
log.info("Bearer auth enabled")
|
|
363
|
+
except Exception as e: # pragma: no cover - depends on fastmcp version
|
|
364
|
+
log.error("Failed to enable bearer auth (%s); refusing to start insecure.", e)
|
|
365
|
+
raise
|
|
366
|
+
|
|
367
|
+
log.info(
|
|
368
|
+
"Starting omdb-mcp Streamable HTTP on http://%s:%d%s",
|
|
369
|
+
config.host,
|
|
370
|
+
config.port,
|
|
371
|
+
config.path,
|
|
372
|
+
)
|
|
373
|
+
mcp.run(
|
|
374
|
+
transport="streamable-http",
|
|
375
|
+
host=config.host,
|
|
376
|
+
port=config.port,
|
|
377
|
+
path=config.path,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def main_stdio(argv: list[str] | None = None) -> None:
|
|
382
|
+
"""Entry point: `omdb-mcp` (defaults to STDIO)."""
|
|
383
|
+
config = _build_config(argv, default_transport="stdio")
|
|
384
|
+
_run(config)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def main_http(argv: list[str] | None = None) -> None:
|
|
388
|
+
"""Entry point: `omdb-mcp-http` (defaults to Streamable HTTP)."""
|
|
389
|
+
config = _build_config(argv, default_transport="http")
|
|
390
|
+
if config.transport == "stdio":
|
|
391
|
+
# Respect user's explicit --transport stdio override; otherwise force http.
|
|
392
|
+
pass
|
|
393
|
+
_run(config)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
if __name__ == "__main__":
|
|
397
|
+
main_stdio()
|
|
File without changes
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Tests for OMDBClient — fully mocked, no network."""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
import respx
|
|
6
|
+
|
|
7
|
+
from omdb_mcp.server import OMDBClient, OMDBError
|
|
8
|
+
|
|
9
|
+
BASE = "https://www.omdbapi.com/"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def client():
|
|
14
|
+
return OMDBClient(api_key="testkey", base_url=BASE, timeout=5.0)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@respx.mock
|
|
18
|
+
async def test_search_drops_none_params(client):
|
|
19
|
+
route = respx.get(BASE).mock(
|
|
20
|
+
return_value=httpx.Response(200, json={"Response": "True", "Search": [], "totalResults": "0"})
|
|
21
|
+
)
|
|
22
|
+
await client.search("matrix")
|
|
23
|
+
call = route.calls.last
|
|
24
|
+
params = dict(call.request.url.params)
|
|
25
|
+
assert params == {"s": "matrix", "page": "1", "apikey": "testkey"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@respx.mock
|
|
29
|
+
async def test_search_with_filters(client):
|
|
30
|
+
route = respx.get(BASE).mock(
|
|
31
|
+
return_value=httpx.Response(200, json={"Response": "True", "Search": [], "totalResults": "0"})
|
|
32
|
+
)
|
|
33
|
+
await client.search("matrix", year=1999, type_="movie", page=2)
|
|
34
|
+
params = dict(route.calls.last.request.url.params)
|
|
35
|
+
assert params["s"] == "matrix"
|
|
36
|
+
assert params["y"] == "1999"
|
|
37
|
+
assert params["type"] == "movie"
|
|
38
|
+
assert params["page"] == "2"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@respx.mock
|
|
42
|
+
async def test_by_title(client):
|
|
43
|
+
route = respx.get(BASE).mock(
|
|
44
|
+
return_value=httpx.Response(200, json={"Response": "True", "Title": "The Matrix"})
|
|
45
|
+
)
|
|
46
|
+
result = await client.by_title("The Matrix", plot="full")
|
|
47
|
+
assert result["Title"] == "The Matrix"
|
|
48
|
+
params = dict(route.calls.last.request.url.params)
|
|
49
|
+
assert params["t"] == "The Matrix"
|
|
50
|
+
assert params["plot"] == "full"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@respx.mock
|
|
54
|
+
async def test_by_id(client):
|
|
55
|
+
respx.get(BASE).mock(
|
|
56
|
+
return_value=httpx.Response(200, json={"Response": "True", "imdbID": "tt0133093"})
|
|
57
|
+
)
|
|
58
|
+
result = await client.by_id("tt0133093")
|
|
59
|
+
assert result["imdbID"] == "tt0133093"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@respx.mock
|
|
63
|
+
async def test_omdb_error_response(client):
|
|
64
|
+
respx.get(BASE).mock(
|
|
65
|
+
return_value=httpx.Response(200, json={"Response": "False", "Error": "Movie not found!"})
|
|
66
|
+
)
|
|
67
|
+
with pytest.raises(OMDBError, match="Movie not found"):
|
|
68
|
+
await client.by_title("nonexistent zzz")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@respx.mock
|
|
72
|
+
async def test_http_error_raises(client):
|
|
73
|
+
respx.get(BASE).mock(return_value=httpx.Response(500))
|
|
74
|
+
with pytest.raises(httpx.HTTPStatusError):
|
|
75
|
+
await client.by_title("anything")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@respx.mock
|
|
79
|
+
async def test_episode_requires_identifier(client):
|
|
80
|
+
with pytest.raises(ValueError, match="imdb_id or title"):
|
|
81
|
+
await client.episode(season=1, episode=1)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@respx.mock
|
|
85
|
+
async def test_episode_by_imdb_id(client):
|
|
86
|
+
route = respx.get(BASE).mock(
|
|
87
|
+
return_value=httpx.Response(200, json={"Response": "True", "Title": "Pilot"})
|
|
88
|
+
)
|
|
89
|
+
await client.episode(season=1, episode=1, imdb_id="tt0903747")
|
|
90
|
+
params = dict(route.calls.last.request.url.params)
|
|
91
|
+
assert params["i"] == "tt0903747"
|
|
92
|
+
assert params["Season"] == "1"
|
|
93
|
+
assert params["Episode"] == "1"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@respx.mock
|
|
97
|
+
async def test_season(client):
|
|
98
|
+
route = respx.get(BASE).mock(
|
|
99
|
+
return_value=httpx.Response(200, json={"Response": "True", "Episodes": []})
|
|
100
|
+
)
|
|
101
|
+
await client.season(season=2, imdb_id="tt0903747")
|
|
102
|
+
params = dict(route.calls.last.request.url.params)
|
|
103
|
+
assert params["i"] == "tt0903747"
|
|
104
|
+
assert params["Season"] == "2"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@respx.mock
|
|
108
|
+
async def test_season_requires_identifier(client):
|
|
109
|
+
with pytest.raises(ValueError):
|
|
110
|
+
await client.season(season=1)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Tests for Config.from_env."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from omdb_mcp.server import Config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture(autouse=True)
|
|
11
|
+
def clean_env(monkeypatch):
|
|
12
|
+
# Wipe every OMDB_* var so each test starts clean.
|
|
13
|
+
for k in list(os.environ):
|
|
14
|
+
if k.startswith("OMDB_"):
|
|
15
|
+
monkeypatch.delenv(k, raising=False)
|
|
16
|
+
yield
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_missing_api_key_raises(monkeypatch):
|
|
20
|
+
with pytest.raises(RuntimeError, match="OMDB_API_KEY"):
|
|
21
|
+
Config.from_env()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_defaults(monkeypatch):
|
|
25
|
+
monkeypatch.setenv("OMDB_API_KEY", "abc123")
|
|
26
|
+
cfg = Config.from_env()
|
|
27
|
+
assert cfg.api_key == "abc123"
|
|
28
|
+
assert cfg.transport == "stdio"
|
|
29
|
+
assert cfg.host == "127.0.0.1"
|
|
30
|
+
assert cfg.port == 8087
|
|
31
|
+
assert cfg.path == "/mcp"
|
|
32
|
+
assert cfg.auth_token is None
|
|
33
|
+
assert cfg.base_url == "https://www.omdbapi.com/"
|
|
34
|
+
assert cfg.timeout == 10.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_full_override(monkeypatch):
|
|
38
|
+
monkeypatch.setenv("OMDB_API_KEY", "abc123")
|
|
39
|
+
monkeypatch.setenv("OMDB_MCP_TRANSPORT", "http")
|
|
40
|
+
monkeypatch.setenv("OMDB_MCP_HOST", "0.0.0.0")
|
|
41
|
+
monkeypatch.setenv("OMDB_MCP_PORT", "9000")
|
|
42
|
+
monkeypatch.setenv("OMDB_MCP_PATH", "/omdb")
|
|
43
|
+
monkeypatch.setenv("OMDB_MCP_AUTH_TOKEN", "secret")
|
|
44
|
+
monkeypatch.setenv("OMDB_MCP_LOG_LEVEL", "debug")
|
|
45
|
+
monkeypatch.setenv("OMDB_REQUEST_TIMEOUT", "5.5")
|
|
46
|
+
monkeypatch.setenv("OMDB_BASE_URL", "https://example.com/")
|
|
47
|
+
cfg = Config.from_env()
|
|
48
|
+
assert cfg.transport == "http"
|
|
49
|
+
assert cfg.host == "0.0.0.0"
|
|
50
|
+
assert cfg.port == 9000
|
|
51
|
+
assert cfg.path == "/omdb"
|
|
52
|
+
assert cfg.auth_token == "secret"
|
|
53
|
+
assert cfg.log_level == "DEBUG"
|
|
54
|
+
assert cfg.timeout == 5.5
|
|
55
|
+
assert cfg.base_url == "https://example.com/"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_invalid_transport(monkeypatch):
|
|
59
|
+
monkeypatch.setenv("OMDB_API_KEY", "abc123")
|
|
60
|
+
monkeypatch.setenv("OMDB_MCP_TRANSPORT", "websocket")
|
|
61
|
+
with pytest.raises(RuntimeError, match="OMDB_MCP_TRANSPORT"):
|
|
62
|
+
Config.from_env()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_empty_auth_token_becomes_none(monkeypatch):
|
|
66
|
+
monkeypatch.setenv("OMDB_API_KEY", "abc123")
|
|
67
|
+
monkeypatch.setenv("OMDB_MCP_AUTH_TOKEN", " ")
|
|
68
|
+
cfg = Config.from_env()
|
|
69
|
+
assert cfg.auth_token is None
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Tests for parameter validation helpers and the FastMCP server factory."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from omdb_mcp.server import Config, _validate_plot, _validate_type, build_server
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_validate_type_accepts_valid():
|
|
9
|
+
assert _validate_type("movie") == "movie"
|
|
10
|
+
assert _validate_type("Series") == "series"
|
|
11
|
+
assert _validate_type(None) is None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_validate_type_rejects_invalid():
|
|
15
|
+
with pytest.raises(ValueError):
|
|
16
|
+
_validate_type("show")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_validate_plot_accepts_valid():
|
|
20
|
+
assert _validate_plot("short") == "short"
|
|
21
|
+
assert _validate_plot("FULL") == "full"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_validate_plot_rejects_invalid():
|
|
25
|
+
with pytest.raises(ValueError):
|
|
26
|
+
_validate_plot("medium")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_build_server_registers_tools():
|
|
30
|
+
cfg = Config(api_key="testkey")
|
|
31
|
+
mcp = build_server(cfg)
|
|
32
|
+
# FastMCP exposes a get_tools() coroutine. Use the underlying _tool_manager
|
|
33
|
+
# to introspect synchronously across versions.
|
|
34
|
+
import asyncio
|
|
35
|
+
|
|
36
|
+
tools = asyncio.run(mcp.list_tools())
|
|
37
|
+
tool_names = {t.name for t in tools}
|
|
38
|
+
expected = {"search_movies", "get_by_title", "get_by_imdb_id", "get_episode", "get_season"}
|
|
39
|
+
assert expected.issubset(tool_names), f"missing tools: {expected - tool_names}"
|