@olaservo/scryfall-mcp-server 1.0.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/README.md +50 -0
- package/dist/card-viewer.html +134 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +25 -0
- package/dist/scryfall-api.d.ts +36 -0
- package/dist/scryfall-api.js +40 -0
- package/dist/tools/fetch.d.ts +3 -0
- package/dist/tools/fetch.js +102 -0
- package/dist/tools/index.d.ts +2 -0
- package/dist/tools/index.js +42 -0
- package/dist/tools/search.d.ts +2 -0
- package/dist/tools/search.js +54 -0
- package/package.json +55 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { registerTools } from "./tools/index.js";
|
|
4
|
+
const server = new McpServer({
|
|
5
|
+
name: "scryfall-mcp-server",
|
|
6
|
+
version: "1.0.0",
|
|
7
|
+
}, {
|
|
8
|
+
capabilities: {
|
|
9
|
+
tools: {},
|
|
10
|
+
resources: {},
|
|
11
|
+
},
|
|
12
|
+
instructions: "Search and fetch Magic: The Gathering card data from Scryfall. " +
|
|
13
|
+
"Use the 'search' tool with Scryfall full-text query syntax to find cards, " +
|
|
14
|
+
"then use 'fetch' with a card ID to get full details.",
|
|
15
|
+
});
|
|
16
|
+
registerTools(server);
|
|
17
|
+
async function main() {
|
|
18
|
+
const transport = new StdioServerTransport();
|
|
19
|
+
await server.connect(transport);
|
|
20
|
+
console.error("Scryfall MCP server running via stdio");
|
|
21
|
+
}
|
|
22
|
+
main().catch((error) => {
|
|
23
|
+
console.error("Fatal error:", error);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface ScryfallCard {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
scryfall_uri: string;
|
|
5
|
+
set: string;
|
|
6
|
+
set_name: string;
|
|
7
|
+
collector_number: string;
|
|
8
|
+
type_line: string;
|
|
9
|
+
mana_cost?: string;
|
|
10
|
+
oracle_text?: string;
|
|
11
|
+
colors?: string[];
|
|
12
|
+
rarity: string;
|
|
13
|
+
released_at: string;
|
|
14
|
+
prices: Record<string, string | null>;
|
|
15
|
+
image_uris?: Record<string, string>;
|
|
16
|
+
card_faces?: Array<{
|
|
17
|
+
name?: string;
|
|
18
|
+
type_line?: string;
|
|
19
|
+
mana_cost?: string;
|
|
20
|
+
oracle_text?: string;
|
|
21
|
+
image_uris?: Record<string, string>;
|
|
22
|
+
}>;
|
|
23
|
+
uri: string;
|
|
24
|
+
}
|
|
25
|
+
export interface ScryfallSearchResponse {
|
|
26
|
+
data: ScryfallCard[];
|
|
27
|
+
total_cards: number;
|
|
28
|
+
has_more: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface ScryfallError {
|
|
31
|
+
status: number;
|
|
32
|
+
body: string;
|
|
33
|
+
}
|
|
34
|
+
export declare function isScryfallError(result: ScryfallSearchResponse | ScryfallCard | ScryfallError): result is ScryfallError;
|
|
35
|
+
export declare function searchCards(query: string): Promise<ScryfallSearchResponse | ScryfallError>;
|
|
36
|
+
export declare function fetchCard(id: string): Promise<ScryfallCard | ScryfallError>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const BASE_URL = "https://api.scryfall.com";
|
|
2
|
+
const USER_AGENT = "scryfall-mcp-server/1.0 (+https://github.com/olaservo/scryfall-mcp-app)";
|
|
3
|
+
const MIN_DELAY_MS = 120; // ~8-9 rps (Scryfall asks for 50-100ms & <10 rps)
|
|
4
|
+
let lastCall = 0;
|
|
5
|
+
async function rateLimit() {
|
|
6
|
+
const now = Date.now();
|
|
7
|
+
const wait = Math.max(0, lastCall + MIN_DELAY_MS - now);
|
|
8
|
+
if (wait > 0) {
|
|
9
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
10
|
+
}
|
|
11
|
+
lastCall = Date.now();
|
|
12
|
+
}
|
|
13
|
+
function headers() {
|
|
14
|
+
return {
|
|
15
|
+
"User-Agent": USER_AGENT,
|
|
16
|
+
Accept: "application/json",
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function isScryfallError(result) {
|
|
20
|
+
return "status" in result && "body" in result;
|
|
21
|
+
}
|
|
22
|
+
export async function searchCards(query) {
|
|
23
|
+
await rateLimit();
|
|
24
|
+
const url = new URL(`${BASE_URL}/cards/search`);
|
|
25
|
+
url.searchParams.set("q", query);
|
|
26
|
+
const res = await fetch(url.toString(), { headers: headers() });
|
|
27
|
+
if (!res.ok) {
|
|
28
|
+
return { status: res.status, body: await res.text() };
|
|
29
|
+
}
|
|
30
|
+
return (await res.json());
|
|
31
|
+
}
|
|
32
|
+
export async function fetchCard(id) {
|
|
33
|
+
await rateLimit();
|
|
34
|
+
const url = `${BASE_URL}/cards/${encodeURIComponent(id)}`;
|
|
35
|
+
const res = await fetch(url, { headers: headers() });
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
return { status: res.status, body: await res.text() };
|
|
38
|
+
}
|
|
39
|
+
return (await res.json());
|
|
40
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { fetchCard, isScryfallError } from "../scryfall-api.js";
|
|
3
|
+
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
|
|
4
|
+
export const CARD_VIEWER_URI = "ui://scryfall/card-viewer.html";
|
|
5
|
+
const FetchInputShape = {
|
|
6
|
+
id: z
|
|
7
|
+
.string()
|
|
8
|
+
.uuid()
|
|
9
|
+
.describe("Scryfall card UUID (obtained from the search tool results)"),
|
|
10
|
+
};
|
|
11
|
+
const FetchSchema = z.object(FetchInputShape);
|
|
12
|
+
function formatCardText(card) {
|
|
13
|
+
const parts = [];
|
|
14
|
+
// Card faces or single card text
|
|
15
|
+
if (card.card_faces && card.card_faces.length > 0) {
|
|
16
|
+
parts.push(card.card_faces
|
|
17
|
+
.map((face) => `${face.name || ""}\n${face.type_line || ""}\n${face.mana_cost || ""}\n\n${face.oracle_text || ""}`)
|
|
18
|
+
.join("\n\n---\n\n"));
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
parts.push(`${card.name}\n${card.type_line}\n${card.mana_cost || ""}\n\n${card.oracle_text || ""}`);
|
|
22
|
+
}
|
|
23
|
+
// Metadata
|
|
24
|
+
parts.push(`Set: ${card.set_name} (${card.set.toUpperCase()}) #${card.collector_number}`);
|
|
25
|
+
parts.push(`Rarity: ${card.rarity}`);
|
|
26
|
+
parts.push(`Released: ${card.released_at}`);
|
|
27
|
+
if (card.colors && card.colors.length > 0) {
|
|
28
|
+
parts.push(`Colors: ${card.colors.join(", ")}`);
|
|
29
|
+
}
|
|
30
|
+
// Prices
|
|
31
|
+
const priceEntries = Object.entries(card.prices)
|
|
32
|
+
.filter(([, v]) => v != null && v !== "")
|
|
33
|
+
.map(([k, v]) => `${k}: $${v}`);
|
|
34
|
+
if (priceEntries.length > 0) {
|
|
35
|
+
parts.push(`Prices: ${priceEntries.join(", ")}`);
|
|
36
|
+
}
|
|
37
|
+
// Image URI for non-UI hosts
|
|
38
|
+
const imageUri = card.image_uris?.normal ?? card.card_faces?.[0]?.image_uris?.normal;
|
|
39
|
+
if (imageUri) {
|
|
40
|
+
parts.push(`Image: ${imageUri}`);
|
|
41
|
+
}
|
|
42
|
+
parts.push(`ID: ${card.id}`);
|
|
43
|
+
parts.push(`Scryfall: ${card.scryfall_uri}`);
|
|
44
|
+
parts.push(`API: ${card.uri}`);
|
|
45
|
+
return parts.join("\n");
|
|
46
|
+
}
|
|
47
|
+
export const registerFetchTool = (server) => {
|
|
48
|
+
registerAppTool(server, "fetch", {
|
|
49
|
+
title: "Fetch Card",
|
|
50
|
+
description: "Fetch detailed information for a single Magic: The Gathering card by its Scryfall UUID. " +
|
|
51
|
+
"Returns the card's full oracle text, type line, mana cost, colors, set info, rarity, " +
|
|
52
|
+
"prices, and image URIs. Handles double-faced cards (e.g., transform, modal DFC).",
|
|
53
|
+
inputSchema: FetchInputShape,
|
|
54
|
+
annotations: {
|
|
55
|
+
readOnlyHint: true,
|
|
56
|
+
destructiveHint: false,
|
|
57
|
+
idempotentHint: true,
|
|
58
|
+
openWorldHint: true,
|
|
59
|
+
},
|
|
60
|
+
_meta: {
|
|
61
|
+
ui: { resourceUri: CARD_VIEWER_URI },
|
|
62
|
+
},
|
|
63
|
+
}, async (args) => {
|
|
64
|
+
const { id } = FetchSchema.parse(args);
|
|
65
|
+
const result = await fetchCard(id);
|
|
66
|
+
if (isScryfallError(result)) {
|
|
67
|
+
return {
|
|
68
|
+
content: [
|
|
69
|
+
{
|
|
70
|
+
type: "text",
|
|
71
|
+
text: JSON.stringify({
|
|
72
|
+
error: true,
|
|
73
|
+
status: result.status,
|
|
74
|
+
body: result.body,
|
|
75
|
+
}),
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
isError: true,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const structured = {
|
|
82
|
+
name: result.name,
|
|
83
|
+
type_line: result.type_line,
|
|
84
|
+
mana_cost: result.mana_cost,
|
|
85
|
+
oracle_text: result.oracle_text,
|
|
86
|
+
set_name: result.set_name,
|
|
87
|
+
set: result.set,
|
|
88
|
+
rarity: result.rarity,
|
|
89
|
+
collector_number: result.collector_number,
|
|
90
|
+
released_at: result.released_at,
|
|
91
|
+
colors: result.colors,
|
|
92
|
+
prices: result.prices,
|
|
93
|
+
scryfall_uri: result.scryfall_uri,
|
|
94
|
+
image_uris: result.image_uris,
|
|
95
|
+
card_faces: result.card_faces,
|
|
96
|
+
};
|
|
97
|
+
return {
|
|
98
|
+
content: [{ type: "text", text: formatCardText(result) }],
|
|
99
|
+
structuredContent: structured,
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { registerAppResource, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server";
|
|
2
|
+
import { registerSearchTool } from "./search.js";
|
|
3
|
+
import { registerFetchTool, CARD_VIEWER_URI } from "./fetch.js";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
function getDistDir() {
|
|
8
|
+
const thisDir = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
// When running from dist/, thisDir is dist/tools/
|
|
10
|
+
// When running from src/ via tsx, thisDir is src/tools/
|
|
11
|
+
if (thisDir.includes("dist")) {
|
|
12
|
+
return path.resolve(thisDir, "..");
|
|
13
|
+
}
|
|
14
|
+
return path.resolve(thisDir, "..", "..", "dist");
|
|
15
|
+
}
|
|
16
|
+
export const registerTools = (server) => {
|
|
17
|
+
registerSearchTool(server);
|
|
18
|
+
registerFetchTool(server);
|
|
19
|
+
registerAppResource(server, CARD_VIEWER_URI, CARD_VIEWER_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => {
|
|
20
|
+
const distDir = getDistDir();
|
|
21
|
+
const html = await fs.readFile(path.join(distDir, "card-viewer.html"), "utf-8");
|
|
22
|
+
return {
|
|
23
|
+
contents: [
|
|
24
|
+
{
|
|
25
|
+
uri: CARD_VIEWER_URI,
|
|
26
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
27
|
+
text: html,
|
|
28
|
+
_meta: {
|
|
29
|
+
ui: {
|
|
30
|
+
csp: {
|
|
31
|
+
resourceDomains: [
|
|
32
|
+
"https://cards.scryfall.io",
|
|
33
|
+
"https://svgs.scryfall.io",
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { searchCards, isScryfallError } from "../scryfall-api.js";
|
|
3
|
+
const SearchSchema = z.object({
|
|
4
|
+
query: z
|
|
5
|
+
.string()
|
|
6
|
+
.min(1)
|
|
7
|
+
.describe("Scryfall full-text search query. Supports Scryfall syntax " +
|
|
8
|
+
'(e.g., "c:red t:creature cmc=3", "set:mkm", "o:\\"draw a card\\"")'),
|
|
9
|
+
});
|
|
10
|
+
export const registerSearchTool = (server) => {
|
|
11
|
+
server.registerTool("search", {
|
|
12
|
+
title: "Search Cards",
|
|
13
|
+
description: "Search for Magic: The Gathering cards using Scryfall full-text query syntax. " +
|
|
14
|
+
"Returns a list of matching cards with their Scryfall IDs, names, and URLs. " +
|
|
15
|
+
"Use Scryfall search syntax: color (c:), type (t:), CMC (cmc=), set (set:), " +
|
|
16
|
+
'oracle text (o:"..."), power/toughness (pow=, tou=), rarity (r:), etc.',
|
|
17
|
+
inputSchema: SearchSchema,
|
|
18
|
+
annotations: {
|
|
19
|
+
readOnlyHint: true,
|
|
20
|
+
destructiveHint: false,
|
|
21
|
+
idempotentHint: true,
|
|
22
|
+
openWorldHint: true,
|
|
23
|
+
},
|
|
24
|
+
}, async (args) => {
|
|
25
|
+
const { query } = SearchSchema.parse(args);
|
|
26
|
+
const result = await searchCards(query);
|
|
27
|
+
if (isScryfallError(result)) {
|
|
28
|
+
return {
|
|
29
|
+
content: [
|
|
30
|
+
{
|
|
31
|
+
type: "text",
|
|
32
|
+
text: JSON.stringify({
|
|
33
|
+
error: true,
|
|
34
|
+
status: result.status,
|
|
35
|
+
body: result.body,
|
|
36
|
+
}),
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
isError: true,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const results = {
|
|
43
|
+
results: result.data.map((card) => ({
|
|
44
|
+
id: card.id,
|
|
45
|
+
title: card.name,
|
|
46
|
+
url: card.scryfall_uri ||
|
|
47
|
+
`https://scryfall.com/card/${card.set}/${card.collector_number}`,
|
|
48
|
+
})),
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
content: [{ type: "text", text: JSON.stringify(results) }],
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@olaservo/scryfall-mcp-server",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Community MCP server to search and fetch Magic: The Gathering card data from Scryfall with a rich card viewer UI. Not affiliated with or endorsed by Scryfall.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"scryfall-mcp-server": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"mcp",
|
|
15
|
+
"model-context-protocol",
|
|
16
|
+
"magic-the-gathering",
|
|
17
|
+
"mtg",
|
|
18
|
+
"scryfall",
|
|
19
|
+
"cards",
|
|
20
|
+
"search",
|
|
21
|
+
"claude"
|
|
22
|
+
],
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/olaservo/scryfall-mcp-app.git"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/olaservo/scryfall-mcp-app#readme",
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18.0.0"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build:ui": "cross-env INPUT=src/ui/card-viewer.html vite build",
|
|
34
|
+
"build:server": "tsc",
|
|
35
|
+
"build": "npm run build:ui && npm run build:server",
|
|
36
|
+
"prepublishOnly": "npm run build",
|
|
37
|
+
"start": "node dist/index.js",
|
|
38
|
+
"dev": "tsx watch src/index.ts",
|
|
39
|
+
"inspector": "npx @modelcontextprotocol/inspector node dist/index.js",
|
|
40
|
+
"pack": "npm run build && rm -rf node_modules && npm install --omit=dev --omit=optional && npx --yes @anthropic-ai/mcpb -- pack . scryfall-mcp-server.mcpb; npm install"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@modelcontextprotocol/ext-apps": "^1.0.1",
|
|
44
|
+
"@modelcontextprotocol/sdk": "^1.24.3",
|
|
45
|
+
"zod": "^3.25.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^22.10.0",
|
|
49
|
+
"cross-env": "^10.1.0",
|
|
50
|
+
"tsx": "^4.19.2",
|
|
51
|
+
"typescript": "^5.7.2",
|
|
52
|
+
"vite": "^7.3.1",
|
|
53
|
+
"vite-plugin-singlefile": "^2.3.0"
|
|
54
|
+
}
|
|
55
|
+
}
|