trace.moe-mcp 1.0.0 → 1.0.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # trace.moe-mcp
2
2
 
3
- [![License](https://img.shields.io/github/license/soruly/trace.moe-mcp.svg?style=flat-square)](https://github.com/soruly/trace.moe-mcp/blob/master/LICENSE)
3
+ [![License](https://img.shields.io/github/license/soruly/trace.moe-mcp.svg?style=flat-square&)](https://github.com/soruly/trace.moe-mcp/blob/master/LICENSE)
4
4
  [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/soruly/trace.moe-mcp/node.js.yml?style=flat-square)](https://github.com/soruly/trace.moe-mcp/actions)
5
5
  [![npm](https://img.shields.io/npm/v/trace.moe-mcp.svg?style=flat-square)](https://www.npmjs.com/package/trace.moe-mcp)
6
6
  [![Discord](https://img.shields.io/discord/437578425767559188.svg?style=flat-square)](https://discord.gg/K9jn6Kj)
package/dist/index.js ADDED
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { defaultClient } from "./src/api.js";
6
+ import { formatAnilistSearchResultsMarkdown, formatSearchResultsMarkdown, formatUserQuotaMarkdown, } from "./src/format.js";
7
+ import { processImageToVector } from "./src/image-processor.js";
8
+ const server = new McpServer({
9
+ name: "trace.moe-mcp",
10
+ version: "1.0.0",
11
+ });
12
+ // Tool: Search Anime by Image URL (with local vector preprocessing)
13
+ server.registerTool("search_anime_by_image_url", {
14
+ title: "Search Anime by Image URL",
15
+ description: "Search anime scene by image URL. Pre-processes the image locally into a 33-element Color Layout Descriptor vector and sends only the vector to api.trace.moe.",
16
+ inputSchema: {
17
+ url: z.url().describe("Direct HTTP/HTTPS URL of the anime screenshot"),
18
+ cutBorders: z
19
+ .boolean()
20
+ .optional()
21
+ .default(true)
22
+ .describe("Automatically crop black letterbox or pillarbox borders before searching"),
23
+ anilistInfo: z
24
+ .boolean()
25
+ .optional()
26
+ .default(true)
27
+ .describe("Include full anime titles and metadata"),
28
+ anilistID: z
29
+ .number()
30
+ .int()
31
+ .positive()
32
+ .optional()
33
+ .describe("Optional Anilist ID to filter search results within a specific anime"),
34
+ },
35
+ }, async ({ url, cutBorders, anilistInfo, anilistID }) => {
36
+ try {
37
+ const vector = await processImageToVector({ url }, cutBorders);
38
+ const searchResult = await defaultClient.searchByVector(vector, {
39
+ anilistInfo,
40
+ anilistID,
41
+ });
42
+ const markdown = formatSearchResultsMarkdown(searchResult);
43
+ return {
44
+ content: [
45
+ {
46
+ type: "text",
47
+ text: markdown,
48
+ },
49
+ {
50
+ type: "text",
51
+ text: JSON.stringify(searchResult, null, 2),
52
+ },
53
+ ],
54
+ };
55
+ }
56
+ catch (error) {
57
+ return {
58
+ isError: true,
59
+ content: [
60
+ {
61
+ type: "text",
62
+ text: `Failed to search anime by image URL: ${error instanceof Error ? error.message : String(error)}`,
63
+ },
64
+ ],
65
+ };
66
+ }
67
+ });
68
+ // Tool: Search Anime by Local File or Base64 Image
69
+ server.registerTool("search_anime_by_image_file", {
70
+ title: "Search Anime by Image File or Base64",
71
+ description: "Search anime scene from a local file path or base64 encoded image. Pre-processes the image locally into a 33-element vector and sends only the vector to api.trace.moe.",
72
+ inputSchema: {
73
+ filePath: z
74
+ .string()
75
+ .optional()
76
+ .describe("Local absolute or relative filesystem path to the image file"),
77
+ imageBase64: z
78
+ .string()
79
+ .optional()
80
+ .describe("Base64-encoded image string (with or without data URI scheme prefix)"),
81
+ cutBorders: z
82
+ .boolean()
83
+ .optional()
84
+ .default(true)
85
+ .describe("Automatically crop black letterbox or pillarbox borders before searching"),
86
+ anilistInfo: z
87
+ .boolean()
88
+ .optional()
89
+ .default(true)
90
+ .describe("Include full anime titles and metadata"),
91
+ anilistID: z
92
+ .number()
93
+ .int()
94
+ .positive()
95
+ .optional()
96
+ .describe("Optional Anilist ID to filter search results within a specific anime"),
97
+ },
98
+ }, async ({ filePath, imageBase64, cutBorders, anilistInfo, anilistID }) => {
99
+ try {
100
+ if (!filePath && !imageBase64) {
101
+ throw new Error("Must provide either 'filePath' or 'imageBase64'.");
102
+ }
103
+ const vector = await processImageToVector({ filePath, imageBase64 }, cutBorders);
104
+ const searchResult = await defaultClient.searchByVector(vector, {
105
+ anilistInfo,
106
+ anilistID,
107
+ });
108
+ const markdown = formatSearchResultsMarkdown(searchResult);
109
+ return {
110
+ content: [
111
+ {
112
+ type: "text",
113
+ text: markdown,
114
+ },
115
+ {
116
+ type: "text",
117
+ text: JSON.stringify(searchResult, null, 2),
118
+ },
119
+ ],
120
+ };
121
+ }
122
+ catch (error) {
123
+ return {
124
+ isError: true,
125
+ content: [
126
+ {
127
+ type: "text",
128
+ text: `Failed to search anime by image file: ${error instanceof Error ? error.message : String(error)}`,
129
+ },
130
+ ],
131
+ };
132
+ }
133
+ });
134
+ // Tool: Search Anime by 33-element Color Layout Vector
135
+ server.registerTool("search_anime_by_vector", {
136
+ title: "Search Anime by Color Layout Vector",
137
+ description: "Search anime scene directly using a 33-element MPEG-7 Color Layout Descriptor vector.",
138
+ inputSchema: {
139
+ vector: z
140
+ .array(z.number())
141
+ .length(33)
142
+ .describe("33-element integer vector representing MPEG-7 Color Layout Descriptor"),
143
+ anilistInfo: z
144
+ .boolean()
145
+ .optional()
146
+ .default(true)
147
+ .describe("Include full anime titles and metadata"),
148
+ anilistID: z
149
+ .number()
150
+ .int()
151
+ .positive()
152
+ .optional()
153
+ .describe("Optional Anilist ID to filter search results within a specific anime"),
154
+ },
155
+ }, async ({ vector, anilistInfo, anilistID }) => {
156
+ try {
157
+ const searchResult = await defaultClient.searchByVector(vector, {
158
+ anilistInfo,
159
+ anilistID,
160
+ });
161
+ const markdown = formatSearchResultsMarkdown(searchResult);
162
+ return {
163
+ content: [
164
+ {
165
+ type: "text",
166
+ text: markdown,
167
+ },
168
+ {
169
+ type: "text",
170
+ text: JSON.stringify(searchResult, null, 2),
171
+ },
172
+ ],
173
+ };
174
+ }
175
+ catch (error) {
176
+ return {
177
+ isError: true,
178
+ content: [
179
+ {
180
+ type: "text",
181
+ text: `Failed to search anime by vector: ${error instanceof Error ? error.message : String(error)}`,
182
+ },
183
+ ],
184
+ };
185
+ }
186
+ });
187
+ // Tool: Search Anime by Name
188
+ server.registerTool("search_anime_by_name", {
189
+ title: "Search Anime by Name",
190
+ description: "Search anime titles, romanized names, and synonyms using trace.moe Anilist database to retrieve Anilist IDs and metadata.",
191
+ inputSchema: {
192
+ query: z
193
+ .string()
194
+ .min(1)
195
+ .describe("Anime name, Chinese name, Japanese name, or keyword to search"),
196
+ },
197
+ }, async ({ query }) => {
198
+ try {
199
+ const results = await defaultClient.searchAnilist(query);
200
+ const markdown = formatAnilistSearchResultsMarkdown(results, query);
201
+ return {
202
+ content: [
203
+ {
204
+ type: "text",
205
+ text: markdown,
206
+ },
207
+ {
208
+ type: "text",
209
+ text: JSON.stringify(results, null, 2),
210
+ },
211
+ ],
212
+ };
213
+ }
214
+ catch (error) {
215
+ return {
216
+ isError: true,
217
+ content: [
218
+ {
219
+ type: "text",
220
+ text: `Failed to search anime by name: ${error instanceof Error ? error.message : String(error)}`,
221
+ },
222
+ ],
223
+ };
224
+ }
225
+ });
226
+ // Tool: Get Account Quota & Concurrency
227
+ server.registerTool("get_account_quota", {
228
+ title: "Get Account Quota & Concurrency",
229
+ description: "Check remaining daily search quota, concurrency limit, and priority for the current IP / API key on trace.moe.",
230
+ inputSchema: {},
231
+ }, async () => {
232
+ try {
233
+ const user = await defaultClient.getMe();
234
+ const markdown = formatUserQuotaMarkdown(user);
235
+ return {
236
+ content: [
237
+ {
238
+ type: "text",
239
+ text: markdown,
240
+ },
241
+ {
242
+ type: "text",
243
+ text: JSON.stringify(user, null, 2),
244
+ },
245
+ ],
246
+ };
247
+ }
248
+ catch (error) {
249
+ return {
250
+ isError: true,
251
+ content: [
252
+ {
253
+ type: "text",
254
+ text: `Failed to get quota: ${error instanceof Error ? error.message : String(error)}`,
255
+ },
256
+ ],
257
+ };
258
+ }
259
+ });
260
+ // Resource: tracemoe://me
261
+ server.registerResource("user-quota", "tracemoe://me", {
262
+ mimeType: "application/json",
263
+ description: "Current trace.moe search quota, limits, and usage",
264
+ }, async () => {
265
+ const user = await defaultClient.getMe();
266
+ return {
267
+ contents: [
268
+ {
269
+ uri: "tracemoe://me",
270
+ mimeType: "application/json",
271
+ text: JSON.stringify(user, null, 2),
272
+ },
273
+ ],
274
+ };
275
+ });
276
+ // Prompt: trace_moe
277
+ server.registerPrompt("trace_moe", {
278
+ title: "Trace Anime Scene",
279
+ description: "Guide the model on identifying an anime screenshot scene using trace.moe and presenting accurate information.",
280
+ argsSchema: {
281
+ imageUrl: z.string().optional().describe("URL of the anime scene image to identify"),
282
+ filePath: z
283
+ .string()
284
+ .optional()
285
+ .describe("Local file path of the anime scene image to identify"),
286
+ notes: z.string().optional().describe("Additional clues or context"),
287
+ },
288
+ }, ({ imageUrl, filePath, notes }) => {
289
+ const promptText = `Please identify the anime scene from the provided image ${imageUrl ? `at ${imageUrl}` : ""}${filePath ? `(file: ${filePath})` : ""}.${notes ? ` Additional context: ${notes}` : ""}
290
+
291
+ Steps to follow:
292
+ 1. Use the 'search_anime_by_image_url' or 'search_anime_by_image_file' tool to search trace.moe.
293
+ 2. If similarity is >= 85%, report the matched Anime Title (English, Romaji, and Native Japanese), Episode number, and exact timestamp (e.g. 00:12:34).
294
+ 3. Include the Anilist link and preview thumbnail / video clip URL for verification.
295
+ 4. If similarity is low (< 80%), warn the user that the match might be uncertain.`;
296
+ return {
297
+ messages: [
298
+ {
299
+ role: "user",
300
+ content: {
301
+ type: "text",
302
+ text: promptText,
303
+ },
304
+ },
305
+ ],
306
+ };
307
+ });
308
+ // Start server using Stdio transport
309
+ async function main() {
310
+ const transport = new StdioServerTransport();
311
+ await server.connect(transport);
312
+ }
313
+ main().catch((err) => {
314
+ console.error("Fatal error running trace.moe MCP server:", err);
315
+ process.exit(1);
316
+ });
@@ -0,0 +1,109 @@
1
+ export class TraceMoeClient {
2
+ baseUrl;
3
+ apiKey;
4
+ constructor(options) {
5
+ this.baseUrl = (options?.baseUrl ||
6
+ process.env.TRACE_MOE_API_HOST ||
7
+ "https://api.trace.moe").replace(/\/$/, "");
8
+ this.apiKey = options?.apiKey || process.env.TRACE_MOE_API_KEY || "";
9
+ }
10
+ getHeaders() {
11
+ const headers = {
12
+ "User-Agent": "trace.moe-mcp/1.0.0",
13
+ };
14
+ if (this.apiKey) {
15
+ headers["x-trace-key"] = this.apiKey;
16
+ }
17
+ return headers;
18
+ }
19
+ /**
20
+ * Search anime scene by sending a 33-element MPEG-7 Color Layout Descriptor vector.
21
+ */
22
+ async searchByVector(vector, options) {
23
+ if (!Array.isArray(vector) || vector.length !== 33) {
24
+ throw new Error(`Invalid feature vector: expected 33 numbers, got ${Array.isArray(vector) ? vector.length : typeof vector}`);
25
+ }
26
+ const queryParams = new URLSearchParams();
27
+ if (options?.anilistInfo !== false) {
28
+ queryParams.set("anilistInfo", "2");
29
+ }
30
+ if (options?.anilistID !== undefined) {
31
+ queryParams.set("anilistID", String(options.anilistID));
32
+ }
33
+ const qs = queryParams.toString();
34
+ const url = `${this.baseUrl}/search${qs ? `?${qs}` : ""}`;
35
+ const headers = {
36
+ ...this.getHeaders(),
37
+ "Content-Type": "application/json",
38
+ };
39
+ let response;
40
+ let retries = 3;
41
+ while (retries > 0) {
42
+ response = await fetch(url, {
43
+ method: "POST",
44
+ headers,
45
+ body: JSON.stringify({ vector }),
46
+ });
47
+ if (response.status !== 503 || retries === 1) {
48
+ break;
49
+ }
50
+ retries--;
51
+ await new Promise((resolve) => setTimeout(resolve, 1500));
52
+ }
53
+ if (!response) {
54
+ throw new Error("No response received from trace.moe API");
55
+ }
56
+ if (!response.ok) {
57
+ const errorText = await response.text().catch(() => "");
58
+ let parsedError = errorText;
59
+ try {
60
+ const json = JSON.parse(errorText);
61
+ if (json.error)
62
+ parsedError = json.error;
63
+ }
64
+ catch { }
65
+ if (response.status === 402) {
66
+ throw new Error(`trace.moe search quota exceeded. ${parsedError}`);
67
+ }
68
+ if (response.status === 429) {
69
+ throw new Error(`trace.moe rate limit exceeded. Please try again later. ${parsedError}`);
70
+ }
71
+ if (response.status === 503) {
72
+ throw new Error(`trace.moe server is currently busy or overloaded. ${parsedError}`);
73
+ }
74
+ throw new Error(`trace.moe API error (${response.status}): ${parsedError}`);
75
+ }
76
+ return (await response.json());
77
+ }
78
+ /**
79
+ * Search anime by title/synonym using /anilist?q=...
80
+ */
81
+ async searchAnilist(query) {
82
+ const url = `${this.baseUrl}/anilist?q=${encodeURIComponent(query)}`;
83
+ const response = await fetch(url, {
84
+ method: "GET",
85
+ headers: this.getHeaders(),
86
+ });
87
+ if (!response.ok) {
88
+ const errorText = await response.text().catch(() => "");
89
+ throw new Error(`trace.moe anilist search failed (${response.status}): ${errorText}`);
90
+ }
91
+ return (await response.json());
92
+ }
93
+ /**
94
+ * Get user quota, priority, concurrency, and usage info from /me
95
+ */
96
+ async getMe() {
97
+ const url = `${this.baseUrl}/me`;
98
+ const response = await fetch(url, {
99
+ method: "GET",
100
+ headers: this.getHeaders(),
101
+ });
102
+ if (!response.ok) {
103
+ const errorText = await response.text().catch(() => "");
104
+ throw new Error(`trace.moe /me failed (${response.status}): ${errorText}`);
105
+ }
106
+ return (await response.json());
107
+ }
108
+ }
109
+ export const defaultClient = new TraceMoeClient();
@@ -0,0 +1,155 @@
1
+ import {} from "./api.js";
2
+ /**
3
+ * Formats seconds into MM:SS or HH:MM:SS format.
4
+ */
5
+ export function formatTime(seconds) {
6
+ if (isNaN(seconds) || seconds < 0)
7
+ return "00:00";
8
+ const sec = Math.floor(seconds);
9
+ const h = Math.floor(sec / 3600);
10
+ const m = Math.floor((sec % 3600) / 60);
11
+ const s = sec % 60;
12
+ const mm = String(m).padStart(2, "0");
13
+ const ss = String(s).padStart(2, "0");
14
+ if (h > 0) {
15
+ const hh = String(h).padStart(2, "0");
16
+ return `${hh}:${mm}:${ss}`;
17
+ }
18
+ return `${mm}:${ss}`;
19
+ }
20
+ /**
21
+ * Formats a similarity float (0.0 - 1.0) into a percentage string (e.g. "98.5%").
22
+ */
23
+ export function formatSimilarity(similarity) {
24
+ return `${(similarity * 100).toFixed(1)}%`;
25
+ }
26
+ /**
27
+ * Extracts anime titles for all languages (native Japanese, romaji, English, Chinese).
28
+ * Returns primary English/Romaji title and the full list of titles across all languages.
29
+ */
30
+ export function getAnimeTitles(anilist) {
31
+ if (typeof anilist === "number") {
32
+ return { primary: `Anilist ID ${anilist}`, titles: [`Anilist ID ${anilist}`], id: anilist };
33
+ }
34
+ const { chinese, english, native, romaji } = anilist.title || {};
35
+ const titles = [];
36
+ if (native && !titles.includes(native))
37
+ titles.push(native);
38
+ if (romaji && !titles.includes(romaji))
39
+ titles.push(romaji);
40
+ if (english && !titles.includes(english))
41
+ titles.push(english);
42
+ if (chinese && !titles.includes(chinese))
43
+ titles.push(chinese);
44
+ if (titles.length === 0) {
45
+ titles.push(`Anilist ID ${anilist.id}`);
46
+ }
47
+ const primary = english || romaji || native || chinese || `Anilist ID ${anilist.id}`;
48
+ return {
49
+ primary,
50
+ titles,
51
+ id: anilist.id,
52
+ };
53
+ }
54
+ /**
55
+ * Formats episode information into a readable string (e.g. "1/12", "1-2/12", "1", "1-2").
56
+ * Prefers episode_start and episode_end (aligned with Anilist episode count) over filename episode.
57
+ * Returns null if episode is unknown.
58
+ */
59
+ export function formatEpisode(item) {
60
+ let epText = null;
61
+ if (item.episode_start !== undefined && item.episode_start !== null) {
62
+ if (item.episode_end !== undefined &&
63
+ item.episode_end !== null &&
64
+ item.episode_end !== item.episode_start) {
65
+ epText = `${item.episode_start}-${item.episode_end}`;
66
+ }
67
+ else {
68
+ epText = String(item.episode_start);
69
+ }
70
+ }
71
+ else if (item.episode !== undefined && item.episode !== null && item.episode !== "") {
72
+ if (Array.isArray(item.episode)) {
73
+ epText = item.episode.join(", ");
74
+ }
75
+ else {
76
+ epText = String(item.episode);
77
+ }
78
+ }
79
+ if (!epText) {
80
+ return null;
81
+ }
82
+ // If total episode count from Anilist is available, append /total (e.g. "1/12")
83
+ if (typeof item.anilist === "object" && item.anilist !== null && item.anilist.episodes) {
84
+ epText = `${epText}/${item.anilist.episodes}`;
85
+ }
86
+ return epText;
87
+ }
88
+ /**
89
+ * Formats a list of search results into markdown.
90
+ */
91
+ export function formatSearchResultsMarkdown(response) {
92
+ if (!response.result || response.result.length === 0) {
93
+ return "No matching anime scene found on trace.moe.";
94
+ }
95
+ const lines = [];
96
+ lines.push(`### trace.moe Search Results (Found ${response.result.length} matches, compared ${response.frameCount.toLocaleString()} frames)`);
97
+ lines.push("");
98
+ response.result.slice(0, 5).forEach((item, index) => {
99
+ const titleInfo = getAnimeTitles(item.anilist);
100
+ const ep = formatEpisode(item);
101
+ const timeRange = `${formatTime(item.from)} - ${formatTime(item.to)}`;
102
+ const sim = formatSimilarity(item.similarity);
103
+ const warningEmoji = item.similarity < 0.8 ? " ⚠️" : "";
104
+ lines.push(`#### ${index + 1}. ${titleInfo.primary}${warningEmoji} (${sim} similarity)`);
105
+ for (const t of titleInfo.titles) {
106
+ if (t !== titleInfo.primary) {
107
+ lines.push(`- ${t}`);
108
+ }
109
+ }
110
+ if (ep) {
111
+ lines.push(`- **Episode**: ${ep}`);
112
+ }
113
+ lines.push(`- **Timestamp**: \`${timeRange}\``);
114
+ lines.push(`- **Anilist**: [https://anilist.co/anime/${titleInfo.id}](https://anilist.co/anime/${titleInfo.id})`);
115
+ lines.push(`- **Preview Image**: ${item.image}?size=l`);
116
+ lines.push(`- **Preview Video**: ${item.video}?size=l`);
117
+ lines.push("");
118
+ });
119
+ return lines.join("\n");
120
+ }
121
+ /**
122
+ * Formats anime name search results into markdown.
123
+ */
124
+ export function formatAnilistSearchResultsMarkdown(results, query) {
125
+ if (!results || results.length === 0) {
126
+ return `No anime found matching query: "${query}"`;
127
+ }
128
+ const lines = [`### Anime Search Results for "${query}" (Found ${results.length} matches):`, ""];
129
+ results.slice(0, 10).forEach((item, idx) => {
130
+ const titleInfo = getAnimeTitles(item.anilist);
131
+ const sim = (item.similarity * 100).toFixed(0);
132
+ lines.push(`#### ${idx + 1}. ${titleInfo.primary} (Match: ${sim}%, Anilist ID: \`${item.id}\`)`);
133
+ for (const t of titleInfo.titles) {
134
+ if (t !== titleInfo.primary) {
135
+ lines.push(`- ${t}`);
136
+ }
137
+ }
138
+ lines.push(`- **Anilist**: [https://anilist.co/anime/${item.id}](https://anilist.co/anime/${item.id})`);
139
+ lines.push("");
140
+ });
141
+ return lines.join("\n");
142
+ }
143
+ /**
144
+ * Formats user quota information into markdown.
145
+ */
146
+ export function formatUserQuotaMarkdown(user) {
147
+ const remaining = Math.max(0, user.quota - user.quotaUsed);
148
+ return `### trace.moe Account Quota & Status
149
+ - **ID**: \`${user.id}\`
150
+ - **Remaining Daily Quota**: **${remaining.toLocaleString()}** / ${user.quota.toLocaleString()}
151
+ - **Searches Used (last 24h)**: ${user.quotaUsed.toLocaleString()}
152
+ - **Concurrency Limit**: ${user.concurrency}
153
+ - **Search Queue Priority**: ${user.priority}
154
+ `;
155
+ }