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.
@@ -0,0 +1,219 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import sharp from "sharp";
4
+ import { ColorLayout } from "trace.moe-id";
5
+ sharp.cache(false);
6
+ sharp.concurrency(1);
7
+ function getVideoFrameRect(data, width, height, channels = 3, colorTolerance = 10) {
8
+ function isDark(x, y) {
9
+ const i = (y * width + x) * channels;
10
+ return (data[i] <= colorTolerance && data[i + 1] <= colorTolerance && data[i + 2] <= colorTolerance);
11
+ }
12
+ function isRowDark(y) {
13
+ let darkPixelCount = 0;
14
+ for (let x = 0; x < width; x++) {
15
+ if (isDark(x, y))
16
+ darkPixelCount++;
17
+ }
18
+ return darkPixelCount > width * 0.95;
19
+ }
20
+ function isColDark(x) {
21
+ let darkPixelCount = 0;
22
+ for (let y = 0; y < height; y++) {
23
+ if (isDark(x, y))
24
+ darkPixelCount++;
25
+ }
26
+ return darkPixelCount > height * 0.95;
27
+ }
28
+ let top, bottom, left, right;
29
+ const centerY = Math.floor(height / 2);
30
+ const centerX = Math.floor(width / 2);
31
+ if (!isDark(centerX, centerY)) {
32
+ top = centerY;
33
+ bottom = centerY;
34
+ left = centerX;
35
+ right = centerX;
36
+ while (top > 0 && !isRowDark(top - 1))
37
+ top--;
38
+ while (bottom < height - 1 && !isRowDark(bottom + 1))
39
+ bottom++;
40
+ while (left > 0 && !isColDark(left - 1))
41
+ left--;
42
+ while (right < width - 1 && !isColDark(right + 1))
43
+ right++;
44
+ }
45
+ else {
46
+ top = 0;
47
+ bottom = height - 1;
48
+ left = 0;
49
+ right = width - 1;
50
+ while (top < height && isRowDark(top))
51
+ top++;
52
+ while (bottom > top && isRowDark(bottom))
53
+ bottom--;
54
+ while (left < width && isColDark(left))
55
+ left++;
56
+ while (right > left && isColDark(right))
57
+ right--;
58
+ }
59
+ return {
60
+ x: left,
61
+ y: top,
62
+ width: Math.max(1, right - left + 1),
63
+ height: Math.max(1, bottom - top + 1),
64
+ };
65
+ }
66
+ function getNearestAspectRatio(width, height, targetAspectRatios, threshold = 0.05) {
67
+ const aspectRatio = width / height;
68
+ let bestRatio = null;
69
+ let minDiff = Infinity;
70
+ for (const targetRatio of targetAspectRatios) {
71
+ const diff = Math.abs(aspectRatio - targetRatio);
72
+ if (diff < minDiff) {
73
+ minDiff = diff;
74
+ bestRatio = targetRatio;
75
+ }
76
+ }
77
+ if (minDiff <= threshold) {
78
+ return bestRatio;
79
+ }
80
+ return null;
81
+ }
82
+ function snapRectToNearestAspectRatio(rect, maxW, maxH) {
83
+ const targetRatios = [4 / 3, 16 / 9, 21 / 9];
84
+ const currentRatio = rect.width / (rect.height || 1);
85
+ let R = targetRatios[0];
86
+ let minDiff = Math.abs(currentRatio - R);
87
+ for (let i = 1; i < targetRatios.length; i++) {
88
+ const diff = Math.abs(currentRatio - targetRatios[i]);
89
+ if (diff < minDiff) {
90
+ minDiff = diff;
91
+ R = targetRatios[i];
92
+ }
93
+ }
94
+ let w = rect.width;
95
+ let h = rect.height;
96
+ if (w / R > h) {
97
+ w = h * R;
98
+ }
99
+ else {
100
+ h = w / R;
101
+ }
102
+ if (w < 10)
103
+ w = 10;
104
+ if (h < 10)
105
+ h = 10;
106
+ const cx = rect.x + rect.width / 2;
107
+ const cy = rect.y + rect.height / 2;
108
+ let x = cx - w / 2;
109
+ let y = cy - h / 2;
110
+ if (x < 0)
111
+ x = 0;
112
+ if (y < 0)
113
+ y = 0;
114
+ if (x + w > maxW) {
115
+ x = maxW - w;
116
+ if (x < 0) {
117
+ x = 0;
118
+ w = maxW;
119
+ }
120
+ }
121
+ if (y + h > maxH) {
122
+ y = maxH - h;
123
+ if (y < 0) {
124
+ y = 0;
125
+ h = maxH;
126
+ }
127
+ }
128
+ return {
129
+ x: Math.round(x),
130
+ y: Math.round(y),
131
+ width: Math.round(w),
132
+ height: Math.round(h),
133
+ };
134
+ }
135
+ /**
136
+ * Resizes and optionally crops black borders from an image buffer,
137
+ * returning raw RGB pixel data and dimensions.
138
+ */
139
+ export async function resizeAndCropImage(imageBuffer, cutBorders = true) {
140
+ const resized = await sharp(imageBuffer)
141
+ .resize({ width: 320, height: 320, fit: "inside" })
142
+ .toBuffer();
143
+ let cropped = sharp(resized);
144
+ if (cutBorders) {
145
+ try {
146
+ const { data, info } = await sharp(resized)
147
+ .removeAlpha()
148
+ .raw()
149
+ .toBuffer({ resolveWithObject: true });
150
+ const targetRatios = [4 / 3, 16 / 9, 21 / 9];
151
+ const matchedRatio = getNearestAspectRatio(info.width, info.height, targetRatios);
152
+ if (matchedRatio === null) {
153
+ const detected = getVideoFrameRect(data, info.width, info.height, 3, 10);
154
+ const snapped = snapRectToNearestAspectRatio(detected, info.width, info.height);
155
+ cropped = sharp(resized).extract({
156
+ left: snapped.x,
157
+ top: snapped.y,
158
+ width: snapped.width,
159
+ height: snapped.height,
160
+ });
161
+ }
162
+ }
163
+ catch {
164
+ // If border trimming fails, fallback to uncropped resized image
165
+ cropped = sharp(resized);
166
+ }
167
+ }
168
+ const { data, info } = await cropped
169
+ .flatten({ background: "#000000" })
170
+ .removeAlpha()
171
+ .raw()
172
+ .toBuffer({ resolveWithObject: true });
173
+ return {
174
+ data,
175
+ width: info.width,
176
+ height: info.height,
177
+ };
178
+ }
179
+ /**
180
+ * Extracts the 33-element MPEG-7 Color Layout Vector from an image buffer.
181
+ */
182
+ export async function extractVectorFromBuffer(imageBuffer, cutBorders = true) {
183
+ const { data, width, height } = await resizeAndCropImage(imageBuffer, cutBorders);
184
+ return ColorLayout.extract({ data, width, height, channels: 3 });
185
+ }
186
+ /**
187
+ * Resolves an image input (URL, local file path, or Base64 string) to a Buffer.
188
+ */
189
+ export async function resolveImageInputToBuffer(input) {
190
+ if (input.imageBase64) {
191
+ // Strip data URL prefix if present (e.g. data:image/jpeg;base64,...)
192
+ const cleanBase64 = input.imageBase64.replace(/^data:image\/[a-z]+;base64,/, "");
193
+ return Buffer.from(cleanBase64, "base64");
194
+ }
195
+ if (input.filePath) {
196
+ const resolvedPath = path.resolve(input.filePath);
197
+ return await fs.readFile(resolvedPath);
198
+ }
199
+ if (input.url) {
200
+ const response = await fetch(input.url, {
201
+ headers: {
202
+ "User-Agent": "trace.moe-mcp/1.0.0",
203
+ },
204
+ });
205
+ if (!response.ok) {
206
+ throw new Error(`Failed to fetch image from URL: ${response.status} ${response.statusText}`);
207
+ }
208
+ const arrayBuffer = await response.arrayBuffer();
209
+ return Buffer.from(arrayBuffer);
210
+ }
211
+ throw new Error("No image source provided. Must provide url, filePath, or imageBase64.");
212
+ }
213
+ /**
214
+ * High-level helper to process an image input directly into a 33-element vector.
215
+ */
216
+ export async function processImageToVector(input, cutBorders = true) {
217
+ const buffer = await resolveImageInputToBuffer(input);
218
+ return extractVectorFromBuffer(buffer, cutBorders);
219
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trace.moe-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Model Context Protocol (MCP) server for trace.moe anime scene search",
5
5
  "keywords": [
6
6
  "anime",
@@ -18,16 +18,17 @@
18
18
  "url": "git+https://github.com/soruly/trace.moe-mcp.git"
19
19
  },
20
20
  "bin": {
21
- "trace.moe-mcp": "./index.ts"
21
+ "tracemoe-mcp": "./dist/index.js"
22
22
  },
23
23
  "files": [
24
- "index.ts",
25
- "src"
24
+ "dist"
26
25
  ],
27
26
  "type": "module",
28
- "main": "index.ts",
27
+ "main": "./dist/index.js",
29
28
  "scripts": {
30
- "start": "node index.ts",
29
+ "build": "tsc",
30
+ "start": "node dist/index.js",
31
+ "prepublishOnly": "npm run build && npm test",
31
32
  "format": "oxfmt",
32
33
  "lint": "oxlint",
33
34
  "lint:fix": "oxlint --fix",
@@ -43,7 +44,8 @@
43
44
  "@types/node": "^26.5.1",
44
45
  "@types/sharp": "^0.31.1",
45
46
  "oxfmt": "^0.67.0",
46
- "oxlint": "^1.82.0"
47
+ "oxlint": "^1.82.0",
48
+ "typescript": "^7.0.2"
47
49
  },
48
50
  "engines": {
49
51
  "node": ">=24"
package/index.ts DELETED
@@ -1,370 +0,0 @@
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
-
6
- import { defaultClient } from "./src/api.ts";
7
- import {
8
- formatAnilistSearchResultsMarkdown,
9
- formatSearchResultsMarkdown,
10
- formatUserQuotaMarkdown,
11
- } from "./src/format.ts";
12
- import { processImageToVector } from "./src/image-processor.ts";
13
-
14
- const server = new McpServer({
15
- name: "trace.moe-mcp",
16
- version: "1.0.0",
17
- });
18
-
19
- // Tool: Search Anime by Image URL (with local vector preprocessing)
20
- server.registerTool(
21
- "search_anime_by_image_url",
22
- {
23
- title: "Search Anime by Image URL",
24
- description:
25
- "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.",
26
- inputSchema: {
27
- url: z.url().describe("Direct HTTP/HTTPS URL of the anime screenshot"),
28
- cutBorders: z
29
- .boolean()
30
- .optional()
31
- .default(true)
32
- .describe("Automatically crop black letterbox or pillarbox borders before searching"),
33
- anilistInfo: z
34
- .boolean()
35
- .optional()
36
- .default(true)
37
- .describe("Include full anime titles and metadata"),
38
- anilistID: z
39
- .number()
40
- .int()
41
- .positive()
42
- .optional()
43
- .describe("Optional Anilist ID to filter search results within a specific anime"),
44
- },
45
- },
46
- async ({ url, cutBorders, anilistInfo, anilistID }) => {
47
- try {
48
- const vector = await processImageToVector({ url }, cutBorders);
49
- const searchResult = await defaultClient.searchByVector(vector, {
50
- anilistInfo,
51
- anilistID,
52
- });
53
-
54
- const markdown = formatSearchResultsMarkdown(searchResult);
55
-
56
- return {
57
- content: [
58
- {
59
- type: "text",
60
- text: markdown,
61
- },
62
- {
63
- type: "text",
64
- text: JSON.stringify(searchResult, null, 2),
65
- },
66
- ],
67
- };
68
- } catch (error) {
69
- return {
70
- isError: true,
71
- content: [
72
- {
73
- type: "text",
74
- text: `Failed to search anime by image URL: ${error instanceof Error ? error.message : String(error)}`,
75
- },
76
- ],
77
- };
78
- }
79
- },
80
- );
81
-
82
- // Tool: Search Anime by Local File or Base64 Image
83
- server.registerTool(
84
- "search_anime_by_image_file",
85
- {
86
- title: "Search Anime by Image File or Base64",
87
- description:
88
- "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.",
89
- inputSchema: {
90
- filePath: z
91
- .string()
92
- .optional()
93
- .describe("Local absolute or relative filesystem path to the image file"),
94
- imageBase64: z
95
- .string()
96
- .optional()
97
- .describe("Base64-encoded image string (with or without data URI scheme prefix)"),
98
- cutBorders: z
99
- .boolean()
100
- .optional()
101
- .default(true)
102
- .describe("Automatically crop black letterbox or pillarbox borders before searching"),
103
- anilistInfo: z
104
- .boolean()
105
- .optional()
106
- .default(true)
107
- .describe("Include full anime titles and metadata"),
108
- anilistID: z
109
- .number()
110
- .int()
111
- .positive()
112
- .optional()
113
- .describe("Optional Anilist ID to filter search results within a specific anime"),
114
- },
115
- },
116
- async ({ filePath, imageBase64, cutBorders, anilistInfo, anilistID }) => {
117
- try {
118
- if (!filePath && !imageBase64) {
119
- throw new Error("Must provide either 'filePath' or 'imageBase64'.");
120
- }
121
-
122
- const vector = await processImageToVector({ filePath, imageBase64 }, cutBorders);
123
- const searchResult = await defaultClient.searchByVector(vector, {
124
- anilistInfo,
125
- anilistID,
126
- });
127
-
128
- const markdown = formatSearchResultsMarkdown(searchResult);
129
-
130
- return {
131
- content: [
132
- {
133
- type: "text",
134
- text: markdown,
135
- },
136
- {
137
- type: "text",
138
- text: JSON.stringify(searchResult, null, 2),
139
- },
140
- ],
141
- };
142
- } catch (error) {
143
- return {
144
- isError: true,
145
- content: [
146
- {
147
- type: "text",
148
- text: `Failed to search anime by image file: ${error instanceof Error ? error.message : String(error)}`,
149
- },
150
- ],
151
- };
152
- }
153
- },
154
- );
155
-
156
- // Tool: Search Anime by 33-element Color Layout Vector
157
- server.registerTool(
158
- "search_anime_by_vector",
159
- {
160
- title: "Search Anime by Color Layout Vector",
161
- description:
162
- "Search anime scene directly using a 33-element MPEG-7 Color Layout Descriptor vector.",
163
- inputSchema: {
164
- vector: z
165
- .array(z.number())
166
- .length(33)
167
- .describe("33-element integer vector representing MPEG-7 Color Layout Descriptor"),
168
- anilistInfo: z
169
- .boolean()
170
- .optional()
171
- .default(true)
172
- .describe("Include full anime titles and metadata"),
173
- anilistID: z
174
- .number()
175
- .int()
176
- .positive()
177
- .optional()
178
- .describe("Optional Anilist ID to filter search results within a specific anime"),
179
- },
180
- },
181
- async ({ vector, anilistInfo, anilistID }) => {
182
- try {
183
- const searchResult = await defaultClient.searchByVector(vector, {
184
- anilistInfo,
185
- anilistID,
186
- });
187
-
188
- const markdown = formatSearchResultsMarkdown(searchResult);
189
-
190
- return {
191
- content: [
192
- {
193
- type: "text",
194
- text: markdown,
195
- },
196
- {
197
- type: "text",
198
- text: JSON.stringify(searchResult, null, 2),
199
- },
200
- ],
201
- };
202
- } catch (error) {
203
- return {
204
- isError: true,
205
- content: [
206
- {
207
- type: "text",
208
- text: `Failed to search anime by vector: ${error instanceof Error ? error.message : String(error)}`,
209
- },
210
- ],
211
- };
212
- }
213
- },
214
- );
215
-
216
- // Tool: Search Anime by Name
217
- server.registerTool(
218
- "search_anime_by_name",
219
- {
220
- title: "Search Anime by Name",
221
- description:
222
- "Search anime titles, romanized names, and synonyms using trace.moe Anilist database to retrieve Anilist IDs and metadata.",
223
- inputSchema: {
224
- query: z
225
- .string()
226
- .min(1)
227
- .describe("Anime name, Chinese name, Japanese name, or keyword to search"),
228
- },
229
- },
230
- async ({ query }) => {
231
- try {
232
- const results = await defaultClient.searchAnilist(query);
233
- const markdown = formatAnilistSearchResultsMarkdown(results, query);
234
-
235
- return {
236
- content: [
237
- {
238
- type: "text",
239
- text: markdown,
240
- },
241
- {
242
- type: "text",
243
- text: JSON.stringify(results, null, 2),
244
- },
245
- ],
246
- };
247
- } catch (error) {
248
- return {
249
- isError: true,
250
- content: [
251
- {
252
- type: "text",
253
- text: `Failed to search anime by name: ${error instanceof Error ? error.message : String(error)}`,
254
- },
255
- ],
256
- };
257
- }
258
- },
259
- );
260
-
261
- // Tool: Get Account Quota & Concurrency
262
- server.registerTool(
263
- "get_account_quota",
264
- {
265
- title: "Get Account Quota & Concurrency",
266
- description:
267
- "Check remaining daily search quota, concurrency limit, and priority for the current IP / API key on trace.moe.",
268
- inputSchema: {},
269
- },
270
- async () => {
271
- try {
272
- const user = await defaultClient.getMe();
273
- const markdown = formatUserQuotaMarkdown(user);
274
- return {
275
- content: [
276
- {
277
- type: "text",
278
- text: markdown,
279
- },
280
- {
281
- type: "text",
282
- text: JSON.stringify(user, null, 2),
283
- },
284
- ],
285
- };
286
- } catch (error) {
287
- return {
288
- isError: true,
289
- content: [
290
- {
291
- type: "text",
292
- text: `Failed to get quota: ${error instanceof Error ? error.message : String(error)}`,
293
- },
294
- ],
295
- };
296
- }
297
- },
298
- );
299
-
300
- // Resource: tracemoe://me
301
- server.registerResource(
302
- "user-quota",
303
- "tracemoe://me",
304
- {
305
- mimeType: "application/json",
306
- description: "Current trace.moe search quota, limits, and usage",
307
- },
308
- async () => {
309
- const user = await defaultClient.getMe();
310
- return {
311
- contents: [
312
- {
313
- uri: "tracemoe://me",
314
- mimeType: "application/json",
315
- text: JSON.stringify(user, null, 2),
316
- },
317
- ],
318
- };
319
- },
320
- );
321
-
322
- // Prompt: trace_moe
323
- server.registerPrompt(
324
- "trace_moe",
325
- {
326
- title: "Trace Anime Scene",
327
- description:
328
- "Guide the model on identifying an anime screenshot scene using trace.moe and presenting accurate information.",
329
- argsSchema: {
330
- imageUrl: z.string().optional().describe("URL of the anime scene image to identify"),
331
- filePath: z
332
- .string()
333
- .optional()
334
- .describe("Local file path of the anime scene image to identify"),
335
- notes: z.string().optional().describe("Additional clues or context"),
336
- },
337
- },
338
- ({ imageUrl, filePath, notes }) => {
339
- const promptText = `Please identify the anime scene from the provided image ${imageUrl ? `at ${imageUrl}` : ""}${filePath ? `(file: ${filePath})` : ""}.${notes ? ` Additional context: ${notes}` : ""}
340
-
341
- Steps to follow:
342
- 1. Use the 'search_anime_by_image_url' or 'search_anime_by_image_file' tool to search trace.moe.
343
- 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).
344
- 3. Include the Anilist link and preview thumbnail / video clip URL for verification.
345
- 4. If similarity is low (< 80%), warn the user that the match might be uncertain.`;
346
-
347
- return {
348
- messages: [
349
- {
350
- role: "user",
351
- content: {
352
- type: "text",
353
- text: promptText,
354
- },
355
- },
356
- ],
357
- };
358
- },
359
- );
360
-
361
- // Start server using Stdio transport
362
- async function main() {
363
- const transport = new StdioServerTransport();
364
- await server.connect(transport);
365
- }
366
-
367
- main().catch((err) => {
368
- console.error("Fatal error running trace.moe MCP server:", err);
369
- process.exit(1);
370
- });