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.
@@ -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.
@@ -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
@@ -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,3 @@
1
+ """omdb-mcp — MCP server for the OMDb API."""
2
+
3
+ __version__ = "0.1.0"
@@ -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,8 @@
1
+ """Pytest config — ensures src/ is importable without an editable install."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ SRC = Path(__file__).parent.parent / "src"
7
+ if str(SRC) not in sys.path:
8
+ sys.path.insert(0, str(SRC))
@@ -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}"