@sylphx/image-reader-mcp 0.1.0 → 0.1.1
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 +222 -16
- package/bin/image-reader-mcp +48 -0
- package/bin/native/image-reader-mcp-server +0 -0
- package/dist/doctor-cli.js +6705 -0
- package/package.json +22 -10
- package/src/doctor-cli.ts +18 -0
- package/src/doctor.ts +216 -0
- package/src/engine/rust-decode.ts +145 -0
- package/src/handlers/readImage.ts +252 -0
- package/src/mcp.ts +302 -0
- package/src/schemas/readImage.ts +110 -0
- package/src/sdk.ts +27 -0
- package/src/utils/errors.ts +18 -0
- package/src/utils/metadata.ts +108 -0
- package/src/utils/ocr.ts +279 -0
- package/src/utils/pathUtils.ts +40 -0
- package/src/utils/safety.ts +29 -0
- package/dist/index.js +0 -624
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { createServer as createHttpServer, type Server as HttpServer } from 'node:http';
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
6
|
+
import type {
|
|
7
|
+
CallToolResult,
|
|
8
|
+
ContentBlock,
|
|
9
|
+
ImageContent,
|
|
10
|
+
TextContent,
|
|
11
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
|
|
14
|
+
export const text = (value: string): TextContent => ({ type: 'text', text: value });
|
|
15
|
+
|
|
16
|
+
export const image = (data: string, mimeType: string): ImageContent => ({
|
|
17
|
+
type: 'image',
|
|
18
|
+
data,
|
|
19
|
+
mimeType,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const toolError = (message: string): CallToolResult => ({
|
|
23
|
+
content: [text(message)],
|
|
24
|
+
isError: true,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export type ToolHandlerResult = CallToolResult | ContentBlock | readonly ContentBlock[];
|
|
28
|
+
|
|
29
|
+
export type ToolHandler<TInput> = (args: {
|
|
30
|
+
input: TInput;
|
|
31
|
+
ctx: unknown;
|
|
32
|
+
}) => ToolHandlerResult | Promise<ToolHandlerResult>;
|
|
33
|
+
|
|
34
|
+
export interface ToolDefinition<TInput = unknown> {
|
|
35
|
+
description: string;
|
|
36
|
+
inputSchema: z.ZodType<TInput>;
|
|
37
|
+
handler(args: { input: TInput; ctx: unknown }): ToolHandlerResult | Promise<ToolHandlerResult>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class ToolBuilder<TInput = unknown> {
|
|
41
|
+
readonly #description: string | undefined;
|
|
42
|
+
readonly #inputSchema: z.ZodType<TInput> | undefined;
|
|
43
|
+
|
|
44
|
+
constructor(descriptionValue?: string, inputSchema?: z.ZodType<TInput>) {
|
|
45
|
+
this.#description = descriptionValue;
|
|
46
|
+
this.#inputSchema = inputSchema;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
description(value: string): ToolBuilder<TInput> {
|
|
50
|
+
return new ToolBuilder(value, this.#inputSchema);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
input<TSchema extends z.ZodType>(schema: TSchema): ToolBuilder<z.infer<TSchema>> {
|
|
54
|
+
return new ToolBuilder(this.#description, schema as z.ZodType<z.infer<TSchema>>);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
handler(handler: ToolHandler<TInput>): ToolDefinition<TInput> {
|
|
58
|
+
return {
|
|
59
|
+
description: this.#description ?? '',
|
|
60
|
+
inputSchema: this.#inputSchema ?? (z.object({}) as z.ZodType<TInput>),
|
|
61
|
+
handler,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const tool = (): ToolBuilder => new ToolBuilder();
|
|
67
|
+
|
|
68
|
+
interface StdioTransportConfig {
|
|
69
|
+
kind: 'stdio';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface HttpTransportConfig {
|
|
73
|
+
kind: 'http';
|
|
74
|
+
port: number;
|
|
75
|
+
hostname: string;
|
|
76
|
+
cors?: string;
|
|
77
|
+
apiKey?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type TransportConfig = StdioTransportConfig | HttpTransportConfig;
|
|
81
|
+
|
|
82
|
+
export const stdio = (): StdioTransportConfig => ({ kind: 'stdio' });
|
|
83
|
+
|
|
84
|
+
export const http = (config: {
|
|
85
|
+
port: number;
|
|
86
|
+
hostname: string;
|
|
87
|
+
cors?: string;
|
|
88
|
+
apiKey?: string;
|
|
89
|
+
}): HttpTransportConfig => ({ kind: 'http', ...config });
|
|
90
|
+
|
|
91
|
+
interface CreateServerOptions {
|
|
92
|
+
name: string;
|
|
93
|
+
version: string;
|
|
94
|
+
instructions?: string;
|
|
95
|
+
tools: Record<string, ToolDefinition<unknown>>;
|
|
96
|
+
transport: TransportConfig;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface ServerHandle {
|
|
100
|
+
start(): Promise<void>;
|
|
101
|
+
close(): Promise<void>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const isCallToolResult = (result: ToolHandlerResult): result is CallToolResult =>
|
|
105
|
+
typeof result === 'object' && result !== null && 'content' in result;
|
|
106
|
+
|
|
107
|
+
const isContentArray = (result: ToolHandlerResult): result is readonly ContentBlock[] =>
|
|
108
|
+
Array.isArray(result);
|
|
109
|
+
|
|
110
|
+
const normalizeToolResult = (result: ToolHandlerResult): CallToolResult => {
|
|
111
|
+
if (isContentArray(result)) return { content: [...result] };
|
|
112
|
+
if (isCallToolResult(result)) return result;
|
|
113
|
+
return { content: [result] };
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const withCors = (res: import('node:http').ServerResponse, origin: string | undefined) => {
|
|
117
|
+
if (!origin) return;
|
|
118
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
119
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
120
|
+
res.setHeader(
|
|
121
|
+
'Access-Control-Allow-Headers',
|
|
122
|
+
'Content-Type, Accept, MCP-Protocol-Version, Mcp-Session-Id, X-API-Key'
|
|
123
|
+
);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const isApiKeyValid = (
|
|
127
|
+
configuredKey: string,
|
|
128
|
+
presented: string | string[] | undefined
|
|
129
|
+
): boolean => {
|
|
130
|
+
const provided = Array.isArray(presented) ? presented[0] : presented;
|
|
131
|
+
if (typeof provided !== 'string' || provided.length === 0) return false;
|
|
132
|
+
const expected = createHash('sha256').update(configuredKey).digest();
|
|
133
|
+
const actual = createHash('sha256').update(provided).digest();
|
|
134
|
+
return timingSafeEqual(expected, actual);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const unauthorized = (res: import('node:http').ServerResponse) => {
|
|
138
|
+
res.writeHead(401, { 'Content-Type': 'application/json', 'WWW-Authenticate': 'X-API-Key' });
|
|
139
|
+
res.end(
|
|
140
|
+
JSON.stringify({
|
|
141
|
+
jsonrpc: '2.0',
|
|
142
|
+
id: null,
|
|
143
|
+
error: { code: -32001, message: 'Unauthorized: missing or invalid X-API-Key header' },
|
|
144
|
+
})
|
|
145
|
+
);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const ensureStreamableAcceptHeader = (req: import('node:http').IncomingMessage) => {
|
|
149
|
+
const accept = req.headers.accept;
|
|
150
|
+
const acceptValues = Array.isArray(accept) ? accept.join(', ') : (accept ?? '');
|
|
151
|
+
if (acceptValues.includes('application/json') && acceptValues.includes('text/event-stream')) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
req.headers.accept = 'application/json, text/event-stream';
|
|
155
|
+
|
|
156
|
+
const rawAcceptIndex = req.rawHeaders.findIndex(
|
|
157
|
+
(value, index) => index % 2 === 0 && value.toLowerCase() === 'accept'
|
|
158
|
+
);
|
|
159
|
+
if (rawAcceptIndex >= 0) {
|
|
160
|
+
req.rawHeaders[rawAcceptIndex + 1] = 'application/json, text/event-stream';
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
req.rawHeaders.push('Accept', 'application/json, text/event-stream');
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const handleNonMcpRoute = (
|
|
167
|
+
req: import('node:http').IncomingMessage,
|
|
168
|
+
res: import('node:http').ServerResponse,
|
|
169
|
+
pathname: string,
|
|
170
|
+
transportConfig: HttpTransportConfig
|
|
171
|
+
): boolean => {
|
|
172
|
+
if (pathname === '/mcp/health' && req.method === 'GET') {
|
|
173
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
174
|
+
res.end(JSON.stringify({ status: 'ok' }));
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (pathname !== '/mcp') {
|
|
179
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
180
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (req.method === 'OPTIONS') {
|
|
185
|
+
res.writeHead(204);
|
|
186
|
+
res.end();
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (
|
|
191
|
+
transportConfig.apiKey !== undefined &&
|
|
192
|
+
!isApiKeyValid(transportConfig.apiKey, req.headers['x-api-key'])
|
|
193
|
+
) {
|
|
194
|
+
unauthorized(res);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return false;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const handleHttpRequest = async (
|
|
202
|
+
req: import('node:http').IncomingMessage,
|
|
203
|
+
res: import('node:http').ServerResponse,
|
|
204
|
+
mcpServer: McpServer,
|
|
205
|
+
transportConfig: HttpTransportConfig
|
|
206
|
+
): Promise<void> => {
|
|
207
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? transportConfig.hostname}`);
|
|
208
|
+
|
|
209
|
+
withCors(res, transportConfig.cors);
|
|
210
|
+
|
|
211
|
+
if (handleNonMcpRoute(req, res, url.pathname, transportConfig)) return;
|
|
212
|
+
|
|
213
|
+
ensureStreamableAcceptHeader(req);
|
|
214
|
+
const transport = new StreamableHTTPServerTransport({ enableJsonResponse: true });
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
await mcpServer.connect(transport as unknown as Parameters<McpServer['connect']>[0]);
|
|
218
|
+
await transport.handleRequest(req, res);
|
|
219
|
+
} finally {
|
|
220
|
+
await mcpServer.close();
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const startHttpServer = async (
|
|
225
|
+
serverInfo: Pick<CreateServerOptions, 'name' | 'version' | 'instructions' | 'tools'>,
|
|
226
|
+
transportConfig: HttpTransportConfig
|
|
227
|
+
): Promise<{ mcpServer: McpServer; httpServer: HttpServer }> => {
|
|
228
|
+
const mcpServer = buildMcpServer(serverInfo);
|
|
229
|
+
const httpServer = createHttpServer((req, res) => {
|
|
230
|
+
void handleHttpRequest(req, res, mcpServer, transportConfig);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await new Promise<void>((resolve, reject) => {
|
|
234
|
+
httpServer.once('error', reject);
|
|
235
|
+
httpServer.listen(transportConfig.port, transportConfig.hostname, () => {
|
|
236
|
+
httpServer.off('error', reject);
|
|
237
|
+
resolve();
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
return { mcpServer, httpServer };
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const buildMcpServer = ({
|
|
245
|
+
name,
|
|
246
|
+
version,
|
|
247
|
+
instructions,
|
|
248
|
+
tools,
|
|
249
|
+
}: Pick<CreateServerOptions, 'name' | 'version' | 'instructions' | 'tools'>): McpServer => {
|
|
250
|
+
const mcpServer = new McpServer(
|
|
251
|
+
{ name, version },
|
|
252
|
+
{
|
|
253
|
+
...(instructions ? { instructions } : {}),
|
|
254
|
+
}
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
for (const [toolName, definition] of Object.entries(tools)) {
|
|
258
|
+
mcpServer.registerTool(
|
|
259
|
+
toolName,
|
|
260
|
+
{
|
|
261
|
+
description: definition.description,
|
|
262
|
+
inputSchema: definition.inputSchema,
|
|
263
|
+
},
|
|
264
|
+
async (input, ctx) => normalizeToolResult(await definition.handler({ input, ctx }))
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return mcpServer;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
export const createServer = (options: CreateServerOptions): ServerHandle => {
|
|
272
|
+
let mcpServer: McpServer | undefined;
|
|
273
|
+
let httpServer: HttpServer | undefined;
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
async start() {
|
|
277
|
+
if (options.transport.kind === 'stdio') {
|
|
278
|
+
mcpServer = buildMcpServer(options);
|
|
279
|
+
await mcpServer.connect(new StdioServerTransport());
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const started = await startHttpServer(options, options.transport);
|
|
284
|
+
mcpServer = started.mcpServer;
|
|
285
|
+
httpServer = started.httpServer;
|
|
286
|
+
},
|
|
287
|
+
async close() {
|
|
288
|
+
await mcpServer?.close();
|
|
289
|
+
const serverToClose = httpServer;
|
|
290
|
+
if (!serverToClose) return;
|
|
291
|
+
await new Promise<void>((resolve, reject) => {
|
|
292
|
+
serverToClose.close((error) => {
|
|
293
|
+
if (error) {
|
|
294
|
+
reject(error);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
resolve();
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const boundingBoxSchema = z.object({
|
|
4
|
+
x: z.number().describe('Left edge in pixels.'),
|
|
5
|
+
y: z.number().describe('Top edge in pixels.'),
|
|
6
|
+
width: z.number().describe('Width in pixels.'),
|
|
7
|
+
height: z.number().describe('Height in pixels.'),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const ocrLineSchema = z.object({
|
|
11
|
+
text: z.string(),
|
|
12
|
+
bbox: boundingBoxSchema,
|
|
13
|
+
confidence: z.number().min(0).max(100).optional(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const imageDimensionsSchema = z.object({
|
|
17
|
+
width: z.number().int().positive(),
|
|
18
|
+
height: z.number().int().positive(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const readImageArgsSchema = z.object({
|
|
22
|
+
path: z.string().min(1).describe('Path to the local image file (absolute or relative to cwd).'),
|
|
23
|
+
include_metadata: z
|
|
24
|
+
.boolean()
|
|
25
|
+
.optional()
|
|
26
|
+
.describe('Include EXIF, XMP, and IPTC metadata when present. Defaults to true.'),
|
|
27
|
+
include_ocr: z
|
|
28
|
+
.boolean()
|
|
29
|
+
.optional()
|
|
30
|
+
.describe(
|
|
31
|
+
'Attempt OCR via the local Tesseract adapter when installed. Defaults to false; gracefully skips when unavailable.'
|
|
32
|
+
),
|
|
33
|
+
ocr_languages: z
|
|
34
|
+
.array(z.string().min(1))
|
|
35
|
+
.optional()
|
|
36
|
+
.describe('OCR language codes for Tesseract (e.g. ["eng"]). Defaults to ["eng"].'),
|
|
37
|
+
ocr_min_confidence: z
|
|
38
|
+
.number()
|
|
39
|
+
.min(0)
|
|
40
|
+
.max(100)
|
|
41
|
+
.optional()
|
|
42
|
+
.describe('Drop OCR words below this Tesseract confidence (0-100). Defaults to 0.'),
|
|
43
|
+
include_ocr_words: z
|
|
44
|
+
.boolean()
|
|
45
|
+
.optional()
|
|
46
|
+
.describe('When OCR is enabled, also return word-level bbox evidence. Defaults to false.'),
|
|
47
|
+
region: boundingBoxSchema
|
|
48
|
+
.optional()
|
|
49
|
+
.describe('Optional pixel region to crop and attach as citeable evidence.'),
|
|
50
|
+
include_region_image: z
|
|
51
|
+
.boolean()
|
|
52
|
+
.optional()
|
|
53
|
+
.describe(
|
|
54
|
+
'When region is set, include base64 PNG bytes of the cropped region. Defaults to false.'
|
|
55
|
+
),
|
|
56
|
+
max_region_dimension: z
|
|
57
|
+
.number()
|
|
58
|
+
.int()
|
|
59
|
+
.positive()
|
|
60
|
+
.optional()
|
|
61
|
+
.describe('Maximum width or height when resizing the cropped region for evidence.'),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export const agentMediaTwinSchema = z.object({
|
|
65
|
+
filename: z.string(),
|
|
66
|
+
mime: z.string(),
|
|
67
|
+
dimensions: imageDimensionsSchema,
|
|
68
|
+
orientation: z.number().int().optional(),
|
|
69
|
+
color_space: z.string().optional(),
|
|
70
|
+
has_alpha: z.boolean().optional(),
|
|
71
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
72
|
+
ocr: z
|
|
73
|
+
.object({
|
|
74
|
+
available: z.boolean(),
|
|
75
|
+
skipped_reason: z.string().optional(),
|
|
76
|
+
route: z.string().optional(),
|
|
77
|
+
languages: z.array(z.string()).optional(),
|
|
78
|
+
languages_warning: z.string().optional(),
|
|
79
|
+
line_count: z.number().int().nonnegative().optional(),
|
|
80
|
+
dropped_low_confidence: z.number().int().nonnegative().optional(),
|
|
81
|
+
lines: z.array(ocrLineSchema),
|
|
82
|
+
words: z
|
|
83
|
+
.array(
|
|
84
|
+
z.object({
|
|
85
|
+
text: z.string(),
|
|
86
|
+
bbox: boundingBoxSchema,
|
|
87
|
+
confidence: z.number().min(0).max(100).optional(),
|
|
88
|
+
})
|
|
89
|
+
)
|
|
90
|
+
.optional(),
|
|
91
|
+
})
|
|
92
|
+
.optional(),
|
|
93
|
+
region_evidence: z
|
|
94
|
+
.object({
|
|
95
|
+
bbox: boundingBoxSchema,
|
|
96
|
+
dimensions: imageDimensionsSchema,
|
|
97
|
+
region_hash: z.string(),
|
|
98
|
+
mime: z.string(),
|
|
99
|
+
route: z.string(),
|
|
100
|
+
resized: z.boolean().optional(),
|
|
101
|
+
image_base64: z.string().optional(),
|
|
102
|
+
})
|
|
103
|
+
.optional(),
|
|
104
|
+
trust_warnings: z.array(z.string()),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export type ReadImageArgs = z.infer<typeof readImageArgsSchema>;
|
|
108
|
+
export type AgentMediaTwin = z.infer<typeof agentMediaTwinSchema>;
|
|
109
|
+
export type OcrLine = z.infer<typeof ocrLineSchema>;
|
|
110
|
+
export type BoundingBox = z.infer<typeof boundingBoxSchema>;
|
package/src/sdk.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Iris SDK — programmatic image evidence API (Sylphx).
|
|
3
|
+
* Isomorphic with MCP tool `read_image`.
|
|
4
|
+
*/
|
|
5
|
+
import { readImage } from './handlers/readImage.js';
|
|
6
|
+
import { readImageArgsSchema } from './schemas/readImage.js';
|
|
7
|
+
|
|
8
|
+
export type IrisReadInput = {
|
|
9
|
+
path: string;
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export { readImageArgsSchema };
|
|
14
|
+
|
|
15
|
+
export class Iris {
|
|
16
|
+
static create(): Iris {
|
|
17
|
+
return new Iris();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** MCP: read_image */
|
|
21
|
+
async read(input: IrisReadInput) {
|
|
22
|
+
const parsed = readImageArgsSchema.parse(input);
|
|
23
|
+
return readImage.handler({ input: parsed, ctx: {} });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default Iris;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Custom error class for Image Reader MCP
|
|
2
|
+
|
|
3
|
+
export enum ErrorCode {
|
|
4
|
+
InvalidParams = -32602,
|
|
5
|
+
InvalidRequest = -32600,
|
|
6
|
+
MethodNotFound = -32601,
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class ImageError extends Error {
|
|
10
|
+
constructor(
|
|
11
|
+
public code: ErrorCode,
|
|
12
|
+
message: string,
|
|
13
|
+
options?: { cause?: Error | undefined }
|
|
14
|
+
) {
|
|
15
|
+
super(message, options?.cause ? { cause: options.cause } : undefined);
|
|
16
|
+
this.name = 'ImageError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
const GPS_FIELD_PATTERN = /^(gps|geo|location|latitude|longitude|altitude|coordinates)/i;
|
|
2
|
+
|
|
3
|
+
const GPS_NESTED_KEYS = new Set([
|
|
4
|
+
'latitude',
|
|
5
|
+
'longitude',
|
|
6
|
+
'altitude',
|
|
7
|
+
'lat',
|
|
8
|
+
'lon',
|
|
9
|
+
'lng',
|
|
10
|
+
'GPSLatitude',
|
|
11
|
+
'GPSLongitude',
|
|
12
|
+
'GPSAltitude',
|
|
13
|
+
'GPSLatitudeRef',
|
|
14
|
+
'GPSLongitudeRef',
|
|
15
|
+
'GPSAltitudeRef',
|
|
16
|
+
'GPSDateStamp',
|
|
17
|
+
'GPSTimeStamp',
|
|
18
|
+
'GPSProcessingMethod',
|
|
19
|
+
'GPSAreaInformation',
|
|
20
|
+
'GPSDOP',
|
|
21
|
+
'GPSMapDatum',
|
|
22
|
+
'GPSDestLatitude',
|
|
23
|
+
'GPSDestLongitude',
|
|
24
|
+
'GPSDestBearing',
|
|
25
|
+
'GPSDestDistance',
|
|
26
|
+
'GPSHPositioningError',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const isGpsKey = (key: string): boolean => GPS_FIELD_PATTERN.test(key) || GPS_NESTED_KEYS.has(key);
|
|
30
|
+
|
|
31
|
+
const redactValue = (value: unknown): unknown => {
|
|
32
|
+
if (Array.isArray(value)) {
|
|
33
|
+
return value.map((item) => redactValue(item));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (value !== null && typeof value === 'object') {
|
|
37
|
+
return redactGpsFields(value as Record<string, unknown>).metadata;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return '[redacted]';
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const redactGpsFields = (
|
|
44
|
+
metadata: Record<string, unknown>
|
|
45
|
+
): { metadata: Record<string, unknown>; hadGps: boolean } => {
|
|
46
|
+
let hadGps = false;
|
|
47
|
+
const redacted: Record<string, unknown> = {};
|
|
48
|
+
|
|
49
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
50
|
+
if (key.toLowerCase() === 'gps' && value !== null && typeof value === 'object') {
|
|
51
|
+
hadGps = true;
|
|
52
|
+
redacted[key] = '[redacted]';
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (isGpsKey(key)) {
|
|
57
|
+
hadGps = true;
|
|
58
|
+
redacted[key] = redactValue(value);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
63
|
+
const nested = redactGpsFields(value as Record<string, unknown>);
|
|
64
|
+
if (nested.hadGps) hadGps = true;
|
|
65
|
+
redacted[key] = nested.metadata;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
redacted[key] = value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { metadata: redacted, hadGps };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const collectTrustWarnings = (
|
|
76
|
+
metadata: Record<string, unknown>,
|
|
77
|
+
hadGps: boolean
|
|
78
|
+
): string[] => {
|
|
79
|
+
const warnings: string[] = [];
|
|
80
|
+
|
|
81
|
+
if (hadGps) {
|
|
82
|
+
warnings.push(
|
|
83
|
+
'GPS coordinates were present in metadata and have been redacted from the response.'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const software = metadata['Software'] ?? metadata['software'];
|
|
88
|
+
if (
|
|
89
|
+
typeof software === 'string' &&
|
|
90
|
+
/photoshop|gimp|ai|generative|midjourney|stable diffusion/i.test(software)
|
|
91
|
+
) {
|
|
92
|
+
warnings.push(
|
|
93
|
+
`EXIF Software field suggests possible editing or synthetic origin: "${software}".`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const make = metadata['Make'] ?? metadata['make'];
|
|
98
|
+
const model = metadata['Model'] ?? metadata['model'];
|
|
99
|
+
if (
|
|
100
|
+
typeof make === 'string' &&
|
|
101
|
+
typeof model === 'string' &&
|
|
102
|
+
/unknown|fake|synthetic/i.test(`${make} ${model}`)
|
|
103
|
+
) {
|
|
104
|
+
warnings.push('Camera make/model metadata looks inconsistent or synthetic.');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return warnings;
|
|
108
|
+
};
|