@pipeworx/mcp-imdb 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pipeworx
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.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # mcp-imdb
2
+
3
+ IMDB MCP — title metadata, ratings, episodes, and crew from IMDB's
4
+
5
+ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 965+ live data sources.
6
+
7
+ ## Tools
8
+
9
+ | Tool | Description |
10
+ |------|-------------|
11
+
12
+ ## Quick Start
13
+
14
+ Add to your MCP client (Claude Desktop, Cursor, Windsurf, etc.):
15
+
16
+ ```json
17
+ {
18
+ "mcpServers": {
19
+ "imdb": {
20
+ "url": "https://gateway.pipeworx.io/imdb/mcp"
21
+ }
22
+ }
23
+ }
24
+ ```
25
+
26
+ Or connect to the full Pipeworx gateway for access to all 965+ data sources:
27
+
28
+ ```json
29
+ {
30
+ "mcpServers": {
31
+ "pipeworx": {
32
+ "url": "https://gateway.pipeworx.io/mcp"
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ ## Using with ask_pipeworx
39
+
40
+ Instead of calling tools directly, you can ask questions in plain English:
41
+
42
+ ```
43
+ ask_pipeworx({ question: "your question about Imdb data" })
44
+ ```
45
+
46
+ The gateway picks the right tool and fills the arguments automatically.
47
+
48
+ ## More
49
+
50
+ - [All tools and guides](https://github.com/pipeworx-io/examples)
51
+ - [pipeworx.io](https://pipeworx.io)
52
+
53
+ ## License
54
+
55
+ MIT
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@pipeworx/mcp-imdb",
3
+ "version": "0.1.0",
4
+ "description": "IMDB MCP — title metadata, ratings, episodes, and crew from IMDB's",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "keywords": ["mcp", "mcp-server", "model-context-protocol", "pipeworx", "imdb"],
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/pipeworx-io/mcp-imdb"
13
+ },
14
+ "scripts": {
15
+ "typecheck": "tsc --noEmit"
16
+ },
17
+ "devDependencies": {
18
+ "typescript": "^5.7.0"
19
+ }
20
+ }
package/server.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.pipeworx-io/imdb",
4
+ "title": "Imdb",
5
+ "description": "IMDB MCP — title metadata, ratings, episodes, and crew from IMDB's",
6
+ "version": "0.1.0",
7
+ "websiteUrl": "https://pipeworx.io/packs/imdb",
8
+ "repository": {
9
+ "url": "https://github.com/pipeworx-io/mcp-imdb",
10
+ "source": "github"
11
+ },
12
+ "remotes": [
13
+ {
14
+ "type": "streamable-http",
15
+ "url": "https://gateway.pipeworx.io/imdb/mcp"
16
+ }
17
+ ]
18
+ }
package/src/index.ts ADDED
@@ -0,0 +1,363 @@
1
+ interface McpToolDefinition {
2
+ name: string;
3
+ description: string;
4
+ inputSchema: {
5
+ type: 'object';
6
+ properties: Record<string, unknown>;
7
+ required?: string[];
8
+ };
9
+ }
10
+
11
+ interface McpToolExport {
12
+ tools: McpToolDefinition[];
13
+ callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>;
14
+ meter?: { credits: number };
15
+ cost?: Record<string, unknown>;
16
+ provider?: string;
17
+ }
18
+
19
+ /**
20
+ * IMDB MCP — title metadata, ratings, episodes, and crew from IMDB's
21
+ * official non-commercial bulk dumps (https://datasets.imdbws.com/).
22
+ *
23
+ * Coverage: movies, TV series, TV mini-series (shorts and episodes filtered
24
+ * out of imdb_titles for size; episodes are still queryable via imdb_episodes
25
+ * which carries the parent series id).
26
+ *
27
+ * Data is ingested daily by workers/data-pipeline; this pack queries the
28
+ * imdb_titles / imdb_ratings / imdb_episodes / imdb_crew tables directly
29
+ * via PostgREST. _supabaseUrl and _supabaseKey are gateway-injected.
30
+ *
31
+ * Person names (directors/writers) are returned as nconsts (IMDB person IDs).
32
+ * Pair with OMDB or another source to resolve nconst → name; we don't ingest
33
+ * the name.basics file in v1 due to size.
34
+ */
35
+
36
+
37
+ const TITLE_TYPES = ['movie', 'tvSeries', 'tvMiniSeries'] as const;
38
+
39
+ const tools: McpToolExport['tools'] = [
40
+ {
41
+ name: 'imdb_search',
42
+ description:
43
+ 'Search IMDB titles by primary_title (case-insensitive substring). Joins ratings inline so each hit includes average_rating + num_votes. Optionally filter by title_type, year range, or genre. Returns up to `limit` matches sorted by num_votes desc (popular first).',
44
+ inputSchema: {
45
+ type: 'object' as const,
46
+ properties: {
47
+ query: { type: 'string', description: 'Title substring (e.g. "godfather", "succession")' },
48
+ title_type: { type: 'string', description: `Optional: ${TITLE_TYPES.join(' | ')}` },
49
+ year_from: { type: 'number', description: 'Minimum start year (inclusive)' },
50
+ year_to: { type: 'number', description: 'Maximum start year (inclusive)' },
51
+ genre: { type: 'string', description: 'Genre to filter by (e.g. "Comedy", "Drama")' },
52
+ min_votes: { type: 'number', description: 'Minimum num_votes (default 0 — no filter)' },
53
+ limit: { type: 'number', description: '1-100 (default 20)' },
54
+ },
55
+ required: ['query'],
56
+ },
57
+ },
58
+ {
59
+ name: 'imdb_get_title',
60
+ description:
61
+ 'Full detail for one IMDB title by tconst id (e.g. "tt0111161"). Returns title, year, runtime, genres, plus rating (average + votes) and crew (directors/writers as nconst arrays).',
62
+ inputSchema: {
63
+ type: 'object' as const,
64
+ properties: {
65
+ imdb_id: { type: 'string', description: 'tconst, e.g. "tt0111161"' },
66
+ },
67
+ required: ['imdb_id'],
68
+ },
69
+ },
70
+ {
71
+ name: 'imdb_top_rated',
72
+ description:
73
+ 'Highest-rated titles, filterable by title_type, genre, year range, and min vote count. Use min_votes to filter out obscure titles with inflated averages (e.g. min_votes=10000 for IMDB-Top-250-style lists).',
74
+ inputSchema: {
75
+ type: 'object' as const,
76
+ properties: {
77
+ title_type: { type: 'string', description: `${TITLE_TYPES.join(' | ')} (default "movie")` },
78
+ genre: { type: 'string', description: 'Genre filter (e.g. "Horror", "Documentary")' },
79
+ year_from: { type: 'number', description: 'Minimum start year' },
80
+ year_to: { type: 'number', description: 'Maximum start year' },
81
+ min_votes: { type: 'number', description: 'Minimum num_votes (default 10000)' },
82
+ limit: { type: 'number', description: '1-100 (default 25)' },
83
+ },
84
+ required: [],
85
+ },
86
+ },
87
+ {
88
+ name: 'imdb_episodes',
89
+ description:
90
+ 'List every episode for a TV series, in season/episode order. Returns parent series detail plus episode list with title and rating per episode.',
91
+ inputSchema: {
92
+ type: 'object' as const,
93
+ properties: {
94
+ series_id: { type: 'string', description: 'Series tconst, e.g. "tt0903747" (Breaking Bad)' },
95
+ season: { type: 'number', description: 'Optional: filter to one season' },
96
+ limit: { type: 'number', description: 'Max episodes returned (default 500, max 2000)' },
97
+ },
98
+ required: ['series_id'],
99
+ },
100
+ },
101
+ ];
102
+
103
+ interface SupabaseConfig {
104
+ url: string;
105
+ key: string;
106
+ }
107
+
108
+ async function pg<T>(cfg: SupabaseConfig, table: string, query: string): Promise<T> {
109
+ const url = `${cfg.url}/rest/v1/${table}?${query}`;
110
+ const res = await fetch(url, {
111
+ headers: { apikey: cfg.key, Authorization: `Bearer ${cfg.key}` },
112
+ });
113
+ if (!res.ok) {
114
+ const text = await res.text();
115
+ throw new Error(`Supabase ${table}: ${res.status} ${text.slice(0, 160)}`);
116
+ }
117
+ return res.json() as Promise<T>;
118
+ }
119
+
120
+ interface RawTitle {
121
+ imdb_id: string;
122
+ title_type: string | null;
123
+ primary_title: string | null;
124
+ original_title: string | null;
125
+ is_adult: boolean | null;
126
+ start_year: number | null;
127
+ end_year: number | null;
128
+ runtime_minutes: number | null;
129
+ genres: string[] | null;
130
+ }
131
+
132
+ interface RawRating {
133
+ imdb_id: string;
134
+ average_rating: string | number | null; // PostgREST returns numeric as string
135
+ num_votes: number | null;
136
+ }
137
+
138
+ interface RawCrew {
139
+ imdb_id: string;
140
+ directors: string[] | null;
141
+ writers: string[] | null;
142
+ }
143
+
144
+ interface RawEpisode {
145
+ imdb_id: string;
146
+ parent_imdb_id: string | null;
147
+ season_number: number | null;
148
+ episode_number: number | null;
149
+ }
150
+
151
+ function shapeTitle(t: RawTitle, r?: RawRating, c?: RawCrew) {
152
+ return {
153
+ imdb_id: t.imdb_id,
154
+ title: t.primary_title,
155
+ original_title: t.original_title !== t.primary_title ? t.original_title : null,
156
+ title_type: t.title_type,
157
+ is_adult: t.is_adult,
158
+ start_year: t.start_year,
159
+ end_year: t.end_year,
160
+ runtime_minutes: t.runtime_minutes,
161
+ genres: t.genres ?? [],
162
+ rating: r
163
+ ? {
164
+ average: r.average_rating !== null ? Number(r.average_rating) : null,
165
+ votes: r.num_votes,
166
+ }
167
+ : null,
168
+ crew: c
169
+ ? {
170
+ directors: c.directors ?? [],
171
+ writers: c.writers ?? [],
172
+ }
173
+ : null,
174
+ url: `https://www.imdb.com/title/${t.imdb_id}/`,
175
+ };
176
+ }
177
+
178
+ // ── Tools ──────────────────────────────────────────────────────────
179
+
180
+ async function imdbSearch(cfg: SupabaseConfig, args: Record<string, unknown>) {
181
+ const query = String(args.query ?? '').trim();
182
+ if (!query) throw new Error('query is required (e.g. "godfather").');
183
+ const limit = Math.min(Math.max(Number(args.limit ?? 20), 1), 100);
184
+ const minVotes = Math.max(0, Number(args.min_votes ?? 0));
185
+
186
+ const titleParts: string[] = [
187
+ `primary_title=ilike.*${encodeURIComponent(query)}*`,
188
+ 'select=imdb_id,title_type,primary_title,original_title,is_adult,start_year,end_year,runtime_minutes,genres',
189
+ `limit=${limit * 4}`, // overfetch so we can rank by rating after the join
190
+ ];
191
+ if (args.title_type) titleParts.push(`title_type=eq.${encodeURIComponent(String(args.title_type))}`);
192
+ if (args.year_from !== undefined) titleParts.push(`start_year=gte.${Number(args.year_from)}`);
193
+ if (args.year_to !== undefined) titleParts.push(`start_year=lte.${Number(args.year_to)}`);
194
+ if (args.genre) titleParts.push(`genres=cs.{${encodeURIComponent(String(args.genre))}}`);
195
+
196
+ const titles = await pg<RawTitle[]>(cfg, 'imdb_titles', titleParts.join('&'));
197
+ if (titles.length === 0) {
198
+ return { query, count: 0, results: [] };
199
+ }
200
+
201
+ const ids = titles.map((t) => t.imdb_id);
202
+ const ratings = await pg<RawRating[]>(
203
+ cfg,
204
+ 'imdb_ratings',
205
+ `imdb_id=in.(${ids.map((id) => encodeURIComponent(id)).join(',')})&select=imdb_id,average_rating,num_votes`,
206
+ );
207
+ const ratingByImdb = new Map(ratings.map((r) => [r.imdb_id, r]));
208
+
209
+ const ranked = titles
210
+ .map((t) => ({ title: t, rating: ratingByImdb.get(t.imdb_id) }))
211
+ .filter(({ rating }) => (rating?.num_votes ?? 0) >= minVotes)
212
+ .sort((a, b) => (b.rating?.num_votes ?? 0) - (a.rating?.num_votes ?? 0))
213
+ .slice(0, limit);
214
+
215
+ return {
216
+ query,
217
+ count: ranked.length,
218
+ results: ranked.map(({ title, rating }) => shapeTitle(title, rating)),
219
+ };
220
+ }
221
+
222
+ async function imdbGetTitle(cfg: SupabaseConfig, args: Record<string, unknown>) {
223
+ const id = String(args.imdb_id ?? '').trim();
224
+ if (!id) throw new Error('imdb_id is required (e.g. "tt0111161").');
225
+
226
+ const [titles, ratings, crews] = await Promise.all([
227
+ pg<RawTitle[]>(cfg, 'imdb_titles', `imdb_id=eq.${encodeURIComponent(id)}&select=*`),
228
+ pg<RawRating[]>(cfg, 'imdb_ratings', `imdb_id=eq.${encodeURIComponent(id)}&select=imdb_id,average_rating,num_votes`),
229
+ pg<RawCrew[]>(cfg, 'imdb_crew', `imdb_id=eq.${encodeURIComponent(id)}&select=imdb_id,directors,writers`),
230
+ ]);
231
+
232
+ if (titles.length === 0) {
233
+ return { error: 'not_found', message: `No IMDB title with id "${id}".` };
234
+ }
235
+ return shapeTitle(titles[0], ratings[0], crews[0]);
236
+ }
237
+
238
+ async function imdbTopRated(cfg: SupabaseConfig, args: Record<string, unknown>) {
239
+ const titleType = String(args.title_type ?? 'movie');
240
+ const limit = Math.min(Math.max(Number(args.limit ?? 25), 1), 100);
241
+ const minVotes = Math.max(1000, Number(args.min_votes ?? 10000));
242
+
243
+ // Strategy: pull the top N ratings (well over the requested limit so the
244
+ // genre/year filters have room to whittle down), then join titles, filter
245
+ // client-side, and return. The partial index on num_votes > 1000 makes
246
+ // the ratings scan fast.
247
+ const ratingOverfetch = Math.max(limit * 20, 500);
248
+ const ratings = await pg<RawRating[]>(
249
+ cfg,
250
+ 'imdb_ratings',
251
+ `num_votes=gte.${minVotes}&select=imdb_id,average_rating,num_votes&order=average_rating.desc.nullslast,num_votes.desc&limit=${ratingOverfetch}`,
252
+ );
253
+ if (ratings.length === 0) return { count: 0, results: [] };
254
+
255
+ const ids = ratings.map((r) => r.imdb_id);
256
+ const titleParts: string[] = [
257
+ `imdb_id=in.(${ids.map((id) => encodeURIComponent(id)).join(',')})`,
258
+ `title_type=eq.${encodeURIComponent(titleType)}`,
259
+ 'select=imdb_id,title_type,primary_title,original_title,is_adult,start_year,end_year,runtime_minutes,genres',
260
+ ];
261
+ if (args.year_from !== undefined) titleParts.push(`start_year=gte.${Number(args.year_from)}`);
262
+ if (args.year_to !== undefined) titleParts.push(`start_year=lte.${Number(args.year_to)}`);
263
+ if (args.genre) titleParts.push(`genres=cs.{${encodeURIComponent(String(args.genre))}}`);
264
+
265
+ const titles = await pg<RawTitle[]>(cfg, 'imdb_titles', titleParts.join('&'));
266
+ const titleByImdb = new Map(titles.map((t) => [t.imdb_id, t]));
267
+
268
+ const ranked = ratings
269
+ .map((r) => ({ title: titleByImdb.get(r.imdb_id), rating: r }))
270
+ .filter((x): x is { title: RawTitle; rating: RawRating } => Boolean(x.title))
271
+ .slice(0, limit);
272
+
273
+ return {
274
+ count: ranked.length,
275
+ filters: { title_type: titleType, min_votes: minVotes, genre: args.genre ?? null, year_from: args.year_from ?? null, year_to: args.year_to ?? null },
276
+ results: ranked.map(({ title, rating }) => shapeTitle(title, rating)),
277
+ };
278
+ }
279
+
280
+ async function imdbEpisodes(cfg: SupabaseConfig, args: Record<string, unknown>) {
281
+ const seriesId = String(args.series_id ?? '').trim();
282
+ if (!seriesId) throw new Error('series_id is required.');
283
+ const limit = Math.min(Math.max(Number(args.limit ?? 500), 1), 2000);
284
+ const season = args.season !== undefined ? Number(args.season) : null;
285
+
286
+ const epQueryParts: string[] = [
287
+ `parent_imdb_id=eq.${encodeURIComponent(seriesId)}`,
288
+ 'select=imdb_id,parent_imdb_id,season_number,episode_number',
289
+ 'order=season_number.asc.nullslast,episode_number.asc.nullslast',
290
+ `limit=${limit}`,
291
+ ];
292
+ if (season !== null) epQueryParts.push(`season_number=eq.${season}`);
293
+
294
+ const [seriesRow, episodes] = await Promise.all([
295
+ pg<RawTitle[]>(cfg, 'imdb_titles', `imdb_id=eq.${encodeURIComponent(seriesId)}&select=*`),
296
+ pg<RawEpisode[]>(cfg, 'imdb_episodes', epQueryParts.join('&')),
297
+ ]);
298
+ if (episodes.length === 0) {
299
+ return { error: 'not_found', message: `No episodes found for series "${seriesId}".`, series: seriesRow[0] ? shapeTitle(seriesRow[0]) : null };
300
+ }
301
+
302
+ const episodeIds = episodes.map((e) => e.imdb_id);
303
+ const [epTitles, epRatings] = await Promise.all([
304
+ pg<RawTitle[]>(
305
+ cfg,
306
+ 'imdb_titles',
307
+ `imdb_id=in.(${episodeIds.map((id) => encodeURIComponent(id)).join(',')})&select=imdb_id,primary_title`,
308
+ ),
309
+ pg<RawRating[]>(
310
+ cfg,
311
+ 'imdb_ratings',
312
+ `imdb_id=in.(${episodeIds.map((id) => encodeURIComponent(id)).join(',')})&select=imdb_id,average_rating,num_votes`,
313
+ ),
314
+ ]);
315
+ // Episode titles aren't in imdb_titles (filtered to movie/tvSeries/tvMiniSeries),
316
+ // so epTitles will usually be empty — that's expected. Ratings work because
317
+ // we ingest title.ratings unfiltered.
318
+ const titleByImdb = new Map(epTitles.map((t) => [t.imdb_id, t.primary_title]));
319
+ const ratingByImdb = new Map(epRatings.map((r) => [r.imdb_id, r]));
320
+
321
+ return {
322
+ series: seriesRow[0] ? shapeTitle(seriesRow[0]) : { imdb_id: seriesId, title: null },
323
+ season_filter: season,
324
+ episode_count: episodes.length,
325
+ episodes: episodes.map((e) => {
326
+ const r = ratingByImdb.get(e.imdb_id);
327
+ return {
328
+ imdb_id: e.imdb_id,
329
+ season: e.season_number,
330
+ episode: e.episode_number,
331
+ title: titleByImdb.get(e.imdb_id) ?? null,
332
+ rating: r
333
+ ? { average: r.average_rating !== null ? Number(r.average_rating) : null, votes: r.num_votes }
334
+ : null,
335
+ url: `https://www.imdb.com/title/${e.imdb_id}/`,
336
+ };
337
+ }),
338
+ };
339
+ }
340
+
341
+ async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
342
+ const supabaseUrl = (args._supabaseUrl as string | undefined)?.trim();
343
+ const supabaseKey = (args._supabaseKey as string | undefined)?.trim();
344
+ if (!supabaseUrl || !supabaseKey) {
345
+ throw new Error('IMDB pack requires platform Supabase credentials (operator-configured).');
346
+ }
347
+ const cfg: SupabaseConfig = { url: supabaseUrl, key: supabaseKey };
348
+
349
+ switch (name) {
350
+ case 'imdb_search':
351
+ return imdbSearch(cfg, args);
352
+ case 'imdb_get_title':
353
+ return imdbGetTitle(cfg, args);
354
+ case 'imdb_top_rated':
355
+ return imdbTopRated(cfg, args);
356
+ case 'imdb_episodes':
357
+ return imdbEpisodes(cfg, args);
358
+ default:
359
+ throw new Error(`Unknown tool: ${name}`);
360
+ }
361
+ }
362
+
363
+ export default { tools, callTool, meter: { credits: 1 } } satisfies McpToolExport;
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist",
10
+ "rootDir": "src",
11
+ "declaration": true
12
+ },
13
+ "include": ["src"]
14
+ }