@sylphx/image-reader-mcp 0.1.0 → 0.1.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 +229 -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 +255 -0
- package/src/mcp.ts +302 -0
- package/src/schemas/readImage.ts +170 -0
- package/src/sdk.ts +27 -0
- package/src/utils/agentMap.ts +93 -0
- package/src/utils/applyImageIntelligence.ts +68 -0
- package/src/utils/errors.ts +18 -0
- package/src/utils/layout.ts +114 -0
- package/src/utils/metadata.ts +108 -0
- package/src/utils/ocr.ts +279 -0
- package/src/utils/optionalLlm.ts +67 -0
- package/src/utils/palette.ts +36 -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,170 @@
|
|
|
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
|
+
include_layout: z
|
|
63
|
+
.boolean()
|
|
64
|
+
.optional()
|
|
65
|
+
.describe(
|
|
66
|
+
'When OCR lines exist, cluster them into reading-order text blocks (image architecture). Defaults to true when include_ocr is true.'
|
|
67
|
+
),
|
|
68
|
+
include_agent_map: z
|
|
69
|
+
.boolean()
|
|
70
|
+
.optional()
|
|
71
|
+
.describe(
|
|
72
|
+
'Return a text-only agent_map so non-vision models can read image structure. Defaults to true.'
|
|
73
|
+
),
|
|
74
|
+
include_palette: z
|
|
75
|
+
.boolean()
|
|
76
|
+
.optional()
|
|
77
|
+
.describe('Sample an approximate local color palette via sharp (not ML). Defaults to false.'),
|
|
78
|
+
include_optional_llm: z
|
|
79
|
+
.boolean()
|
|
80
|
+
.optional()
|
|
81
|
+
.describe(
|
|
82
|
+
'Optional caption via IRIS_OPTIONAL_LLM_URL. Off by default; never authority over OCR/layout evidence.'
|
|
83
|
+
),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
export const agentMediaTwinSchema = z.object({
|
|
87
|
+
filename: z.string(),
|
|
88
|
+
mime: z.string(),
|
|
89
|
+
dimensions: imageDimensionsSchema,
|
|
90
|
+
orientation: z.number().int().optional(),
|
|
91
|
+
color_space: z.string().optional(),
|
|
92
|
+
has_alpha: z.boolean().optional(),
|
|
93
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
94
|
+
ocr: z
|
|
95
|
+
.object({
|
|
96
|
+
available: z.boolean(),
|
|
97
|
+
skipped_reason: z.string().optional(),
|
|
98
|
+
route: z.string().optional(),
|
|
99
|
+
languages: z.array(z.string()).optional(),
|
|
100
|
+
languages_warning: z.string().optional(),
|
|
101
|
+
line_count: z.number().int().nonnegative().optional(),
|
|
102
|
+
dropped_low_confidence: z.number().int().nonnegative().optional(),
|
|
103
|
+
lines: z.array(ocrLineSchema),
|
|
104
|
+
words: z
|
|
105
|
+
.array(
|
|
106
|
+
z.object({
|
|
107
|
+
text: z.string(),
|
|
108
|
+
bbox: boundingBoxSchema,
|
|
109
|
+
confidence: z.number().min(0).max(100).optional(),
|
|
110
|
+
})
|
|
111
|
+
)
|
|
112
|
+
.optional(),
|
|
113
|
+
})
|
|
114
|
+
.optional(),
|
|
115
|
+
region_evidence: z
|
|
116
|
+
.object({
|
|
117
|
+
bbox: boundingBoxSchema,
|
|
118
|
+
dimensions: imageDimensionsSchema,
|
|
119
|
+
region_hash: z.string(),
|
|
120
|
+
mime: z.string(),
|
|
121
|
+
route: z.string(),
|
|
122
|
+
resized: z.boolean().optional(),
|
|
123
|
+
image_base64: z.string().optional(),
|
|
124
|
+
})
|
|
125
|
+
.optional(),
|
|
126
|
+
layout: z
|
|
127
|
+
.object({
|
|
128
|
+
policy: z.string(),
|
|
129
|
+
block_count: z.number().int().nonnegative(),
|
|
130
|
+
blocks: z.array(
|
|
131
|
+
z.object({
|
|
132
|
+
id: z.string(),
|
|
133
|
+
kind: z.literal('text_block'),
|
|
134
|
+
text: z.string(),
|
|
135
|
+
bbox: boundingBoxSchema,
|
|
136
|
+
line_count: z.number().int().nonnegative(),
|
|
137
|
+
reading_order: z.number().int().positive(),
|
|
138
|
+
})
|
|
139
|
+
),
|
|
140
|
+
full_text: z.string(),
|
|
141
|
+
warnings: z.array(z.string()),
|
|
142
|
+
})
|
|
143
|
+
.optional(),
|
|
144
|
+
agent_map: z
|
|
145
|
+
.object({
|
|
146
|
+
policy: z.string(),
|
|
147
|
+
filename: z.string(),
|
|
148
|
+
mime: z.string(),
|
|
149
|
+
dimensions: imageDimensionsSchema,
|
|
150
|
+
outline: z.string(),
|
|
151
|
+
text_present: z.boolean(),
|
|
152
|
+
block_count: z.number().int().nonnegative(),
|
|
153
|
+
palette: z.array(z.object({ hex: z.string(), approx_share: z.number() })).optional(),
|
|
154
|
+
optional_llm: z
|
|
155
|
+
.object({
|
|
156
|
+
available: z.boolean(),
|
|
157
|
+
skipped_reason: z.string().optional(),
|
|
158
|
+
route: z.string().optional(),
|
|
159
|
+
caption: z.string().optional(),
|
|
160
|
+
})
|
|
161
|
+
.optional(),
|
|
162
|
+
})
|
|
163
|
+
.optional(),
|
|
164
|
+
trust_warnings: z.array(z.string()),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
export type ReadImageArgs = z.infer<typeof readImageArgsSchema>;
|
|
168
|
+
export type AgentMediaTwin = z.infer<typeof agentMediaTwinSchema>;
|
|
169
|
+
export type OcrLine = z.infer<typeof ocrLineSchema>;
|
|
170
|
+
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,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text-only Agent Map: lets a non-vision model "read" the image architecture.
|
|
3
|
+
* Deterministic, local-first. Optional LLM captions are layered elsewhere.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ImageLayout } from './layout.js';
|
|
7
|
+
|
|
8
|
+
export type AgentImageMap = {
|
|
9
|
+
policy: 'agent_image_map_v1';
|
|
10
|
+
filename: string;
|
|
11
|
+
mime: string;
|
|
12
|
+
dimensions: { width: number; height: number };
|
|
13
|
+
/** Human/agent readable outline (markdown-ish, not prose hallucination). */
|
|
14
|
+
outline: string;
|
|
15
|
+
text_present: boolean;
|
|
16
|
+
block_count: number;
|
|
17
|
+
palette?: Array<{ hex: string; approx_share: number }>;
|
|
18
|
+
optional_llm?: {
|
|
19
|
+
available: boolean;
|
|
20
|
+
skipped_reason?: string;
|
|
21
|
+
route?: string;
|
|
22
|
+
caption?: string;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function buildAgentImageMap(input: {
|
|
27
|
+
filename: string;
|
|
28
|
+
mime: string;
|
|
29
|
+
dimensions: { width: number; height: number };
|
|
30
|
+
layout?: ImageLayout;
|
|
31
|
+
ocrLineCount?: number;
|
|
32
|
+
palette?: Array<{ hex: string; approx_share: number }>;
|
|
33
|
+
optionalLlm?: AgentImageMap['optional_llm'];
|
|
34
|
+
}): AgentImageMap {
|
|
35
|
+
const { width, height } = input.dimensions;
|
|
36
|
+
const blocks = input.layout?.blocks ?? [];
|
|
37
|
+
const textPresent = (input.ocrLineCount ?? 0) > 0 || blocks.length > 0;
|
|
38
|
+
|
|
39
|
+
const lines: string[] = [
|
|
40
|
+
`# Image map: ${input.filename}`,
|
|
41
|
+
`- mime: ${input.mime}`,
|
|
42
|
+
`- size: ${width}×${height}px`,
|
|
43
|
+
`- text_present: ${textPresent}`,
|
|
44
|
+
`- layout_blocks: ${blocks.length}`,
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
if (input.palette && input.palette.length > 0) {
|
|
48
|
+
lines.push(
|
|
49
|
+
`- palette: ${input.palette.map((p) => `${p.hex}~${Math.round(p.approx_share * 100)}%`).join(', ')}`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (blocks.length > 0) {
|
|
54
|
+
lines.push('', '## Reading-order text blocks');
|
|
55
|
+
for (const b of blocks) {
|
|
56
|
+
const preview = b.text.replace(/\s+/g, ' ').slice(0, 160);
|
|
57
|
+
lines.push(
|
|
58
|
+
`### ${b.id} (order ${b.reading_order}, ${b.line_count} lines)`,
|
|
59
|
+
`- bbox: x=${b.bbox.x} y=${b.bbox.y} w=${b.bbox.width} h=${b.bbox.height}`,
|
|
60
|
+
`- text: ${preview}${b.text.length > 160 ? '…' : ''}`,
|
|
61
|
+
''
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
} else if (!textPresent) {
|
|
65
|
+
lines.push(
|
|
66
|
+
'',
|
|
67
|
+
'## Notes',
|
|
68
|
+
'- No OCR text recovered. Enable include_ocr for text architecture.',
|
|
69
|
+
'- Use crop_region / image_probe for geometry; optional LLM caption only if configured.'
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (input.optionalLlm?.available && input.optionalLlm.caption) {
|
|
74
|
+
lines.push('', '## Optional LLM caption (non-authority)', input.optionalLlm.caption);
|
|
75
|
+
} else if (input.optionalLlm && !input.optionalLlm.available) {
|
|
76
|
+
lines.push(
|
|
77
|
+
'',
|
|
78
|
+
`## Optional LLM: skipped (${input.optionalLlm.skipped_reason ?? 'not configured'})`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
policy: 'agent_image_map_v1',
|
|
84
|
+
filename: input.filename,
|
|
85
|
+
mime: input.mime,
|
|
86
|
+
dimensions: input.dimensions,
|
|
87
|
+
outline: lines.join('\n'),
|
|
88
|
+
text_present: textPresent,
|
|
89
|
+
block_count: blocks.length,
|
|
90
|
+
...(input.palette ? { palette: input.palette } : {}),
|
|
91
|
+
...(input.optionalLlm ? { optional_llm: input.optionalLlm } : {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { AgentMediaTwin, ReadImageArgs } from '../schemas/readImage.js';
|
|
2
|
+
import { buildAgentImageMap } from './agentMap.js';
|
|
3
|
+
import { buildLayoutFromOcrLines } from './layout.js';
|
|
4
|
+
import { maybeOptionalImageCaption } from './optionalLlm.js';
|
|
5
|
+
import { samplePalette } from './palette.js';
|
|
6
|
+
|
|
7
|
+
/** Attach layout, palette, optional LLM, and agent_map to an Agent Media Twin. */
|
|
8
|
+
export async function applyImageIntelligence(
|
|
9
|
+
twin: AgentMediaTwin,
|
|
10
|
+
resolvedPath: string,
|
|
11
|
+
input: ReadImageArgs,
|
|
12
|
+
includeOcr: boolean
|
|
13
|
+
): Promise<AgentMediaTwin> {
|
|
14
|
+
const next: AgentMediaTwin = {
|
|
15
|
+
...twin,
|
|
16
|
+
trust_warnings: [...twin.trust_warnings],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const includeLayout = input.include_layout ?? includeOcr;
|
|
20
|
+
if (includeLayout && next.ocr?.lines && next.ocr.lines.length > 0) {
|
|
21
|
+
const layout = buildLayoutFromOcrLines(
|
|
22
|
+
next.ocr.lines.map((line) => ({
|
|
23
|
+
text: line.text,
|
|
24
|
+
bbox: line.bbox,
|
|
25
|
+
...(line.confidence !== undefined ? { confidence: line.confidence } : {}),
|
|
26
|
+
}))
|
|
27
|
+
);
|
|
28
|
+
next.layout = layout;
|
|
29
|
+
for (const warning of layout.warnings) {
|
|
30
|
+
next.trust_warnings.push(`layout: ${warning}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let palette: Array<{ hex: string; approx_share: number }> | undefined;
|
|
35
|
+
if (input.include_palette) {
|
|
36
|
+
try {
|
|
37
|
+
palette = await samplePalette(resolvedPath);
|
|
38
|
+
} catch {
|
|
39
|
+
next.trust_warnings.push('palette: sampling failed; continuing without palette.');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const optionalLlm = await maybeOptionalImageCaption({
|
|
44
|
+
path: resolvedPath,
|
|
45
|
+
mime: next.mime,
|
|
46
|
+
enabled: input.include_optional_llm ?? false,
|
|
47
|
+
});
|
|
48
|
+
if (optionalLlm.available) {
|
|
49
|
+
next.trust_warnings.push(
|
|
50
|
+
'optional_llm caption is non-authority; prefer OCR/layout locators for claims.'
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const includeAgentMap = input.include_agent_map ?? true;
|
|
55
|
+
if (includeAgentMap) {
|
|
56
|
+
next.agent_map = buildAgentImageMap({
|
|
57
|
+
filename: next.filename,
|
|
58
|
+
mime: next.mime,
|
|
59
|
+
dimensions: next.dimensions,
|
|
60
|
+
...(next.layout ? { layout: next.layout } : {}),
|
|
61
|
+
ocrLineCount: next.ocr?.line_count ?? next.ocr?.lines?.length ?? 0,
|
|
62
|
+
...(palette ? { palette } : {}),
|
|
63
|
+
optionalLlm,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return next;
|
|
68
|
+
}
|
|
@@ -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
|
+
}
|