vision-electronic-indexing-pi 0.1.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.
@@ -0,0 +1,859 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ vision_inventory_mcp.py
4
+
5
+ Single-file local MCP server for processing electronics / PCB images into
6
+ structured visual inventory JSON using Cloudflare Workers AI.
7
+
8
+ Exposed MCP tools:
9
+ - process_image
10
+ - process_image_folder
11
+ - save_inventory
12
+
13
+ Version 1 constraints:
14
+ - local stdio MCP server
15
+ - one Python file
16
+ - no database
17
+ - no GUI
18
+ - no part-number web lookup
19
+ - only external request is to Cloudflare Workers AI
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ import csv
26
+ import io
27
+ import json
28
+ import os
29
+ from pathlib import Path
30
+ from typing import Any, Dict, List, Optional, Tuple
31
+
32
+ import requests
33
+ from PIL import Image, ImageOps
34
+
35
+ # Optional .env support. The app still works if python-dotenv is not installed.
36
+ try:
37
+ from dotenv import load_dotenv
38
+
39
+ load_dotenv()
40
+ except Exception:
41
+ pass
42
+
43
+ # Optional iPhone HEIC/HEIF support. The app still works if pillow-heif is not installed.
44
+ try:
45
+ import pillow_heif
46
+
47
+ pillow_heif.register_heif_opener()
48
+ except Exception:
49
+ pass
50
+
51
+ # Support both the official MCP Python SDK import path and the standalone FastMCP package.
52
+ try:
53
+ from mcp.server.fastmcp import FastMCP
54
+ except ImportError: # pragma: no cover - compatibility fallback
55
+ from fastmcp import FastMCP # type: ignore
56
+
57
+
58
+ DEFAULT_MODEL = os.getenv("WORKERS_AI_MODEL", "@cf/meta/llama-4-scout-17b-16e-instruct")
59
+ DEFAULT_MAX_SIDE = 4000
60
+ DEFAULT_JPEG_QUALITY = 96
61
+ DEFAULT_MAX_TOKENS = 1600
62
+ DEFAULT_TEMPERATURE = 0.05
63
+ DEFAULT_TOP_P = 0.8
64
+
65
+ SUPPORTED_EXTENSIONS = {
66
+ ".jpg",
67
+ ".jpeg",
68
+ ".png",
69
+ ".webp",
70
+ ".bmp",
71
+ ".gif",
72
+ ".heic",
73
+ ".heif",
74
+ }
75
+
76
+ SYSTEM_PROMPT = """
77
+ You are a careful electronics image-analysis assistant.
78
+
79
+ Your job is to inspect electronics/PCB images and extract visible inventory information.
80
+
81
+ Do not perform web lookup.
82
+ Do not invent part numbers.
83
+ Do not infer missing letters or numbers.
84
+ For package markings, transcribe only what is visible.
85
+ Use [?] for unclear characters.
86
+ If text is blurry or partially hidden, set marking_confidence to "low" or "unreadable".
87
+ Prefer uncertainty over guessing.
88
+ Return only valid JSON.
89
+ """.strip()
90
+
91
+
92
+ def build_default_user_prompt(image_name: str) -> str:
93
+ return f"""
94
+ Analyze this electronics image and return an inventory of visible components.
95
+
96
+ Image filename: {image_name}
97
+
98
+ Focus especially on IC packages and readable package markings.
99
+
100
+ Return only valid JSON using this schema:
101
+
102
+ {{
103
+ "image": "{image_name}",
104
+ "items": [
105
+ {{
106
+ "item_type": "IC | connector | passive | module | switch | sensor | display | mechanical | unknown",
107
+ "count_index": 1,
108
+ "package_marking": "exact visible marking, unclear, unreadable, or [?]-marked partial text",
109
+ "marking_confidence": "high | medium | low | unreadable",
110
+ "likely_part": "visible part marking only, or unknown",
111
+ "description": "short visual description, not web lookup",
112
+ "position_hint": "top-left / center / near USB connector / etc.",
113
+ "needs_review": true
114
+ }}
115
+ ],
116
+ "warnings": []
117
+ }}
118
+
119
+ Rules:
120
+ - Return JSON only.
121
+ - Do not wrap the JSON in markdown.
122
+ - Do not identify parts from memory unless the marking is clearly visible.
123
+ - Do not use web lookup.
124
+ - If a marking is not readable, write "unreadable".
125
+ - If a component is visible but not identifiable, item_type should be "unknown".
126
+ - needs_review must be true when marking_confidence is "low" or "unreadable".
127
+ """.strip()
128
+
129
+
130
+ def build_ic_consensus_prompt(image_name: str, first_pass: Dict[str, Any]) -> str:
131
+ first_pass_json = json.dumps(first_pass, indent=2, ensure_ascii=False)
132
+ return f"""
133
+ Analyze this electronics image again, but focus ONLY on the IC package top markings.
134
+
135
+ Image filename: {image_name}
136
+
137
+ Important known constraint for this dataset:
138
+ - All ICs visible in one image should have the same part marking.
139
+ - The previous pass may contain OCR mistakes or hallucinated similar-looking 74LS part numbers.
140
+ - Do not trust the previous pass. Use it only as a list of candidates to verify against the image.
141
+ - Return ONE consensus IC inventory item for the shared marking, not separate items with different markings.
142
+ - If any character is unclear, use [?] in that character position and set needs_review=true.
143
+ - Prefer low confidence with [?] over a confident but guessed part number.
144
+ - Do not use web lookup.
145
+
146
+ Previous pass to verify:
147
+ {first_pass_json}
148
+
149
+ Return only valid JSON using this schema:
150
+
151
+ {{
152
+ "image": "{image_name}",
153
+ "items": [
154
+ {{
155
+ "item_type": "IC",
156
+ "count_index": 1,
157
+ "package_marking": "one consensus visible marking shared by all ICs, or [?]-marked partial text",
158
+ "marking_confidence": "high | medium | low | unreadable",
159
+ "likely_part": "same as package_marking if visible, or unknown",
160
+ "description": "consensus result; include the visible IC count if clear",
161
+ "position_hint": "multiple ICs / board-wide IC group / etc.",
162
+ "needs_review": true
163
+ }}
164
+ ],
165
+ "warnings": [],
166
+ "ic_marking_observations": [
167
+ {{
168
+ "position_hint": "where this individual IC appears",
169
+ "package_marking": "best visible marking for this individual IC, or [?]-marked partial text",
170
+ "marking_confidence": "high | medium | low | unreadable"
171
+ }}
172
+ ]
173
+ }}
174
+
175
+ Rules:
176
+ - Return JSON only.
177
+ - Return exactly one consensus IC item if ICs are visible.
178
+ - Also list each visible IC's individual marking observation in ic_marking_observations.
179
+ - Individual observations may disagree; do not force them to match. Use [?] for unclear characters.
180
+ - Do not return multiple different consensus IC items.
181
+ """.strip()
182
+
183
+
184
+ mcp = FastMCP("Vision Inventory")
185
+
186
+
187
+ def error_response(message: str, **extra: Any) -> Dict[str, Any]:
188
+ response: Dict[str, Any] = {
189
+ "error": True,
190
+ "message": message,
191
+ }
192
+ response.update(extra)
193
+ return response
194
+
195
+
196
+ def get_cloudflare_credentials() -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
197
+ account_id = os.getenv("CLOUDFLARE_ACCOUNT_ID", "").strip()
198
+ api_token = (
199
+ os.getenv("CLOUDFLARE_AUTH_TOKEN", "").strip()
200
+ or os.getenv("CLOUDFLARE_API_TOKEN", "").strip()
201
+ )
202
+
203
+ if not account_id:
204
+ return None, None, error_response("Missing CLOUDFLARE_ACCOUNT_ID environment variable.")
205
+
206
+ if not api_token:
207
+ return None, None, error_response(
208
+ "Missing Cloudflare API token. Set CLOUDFLARE_AUTH_TOKEN or CLOUDFLARE_API_TOKEN."
209
+ )
210
+
211
+ return account_id, api_token, None
212
+
213
+
214
+ def validate_image_path(image_path: str) -> Tuple[Optional[Path], Optional[Dict[str, Any]]]:
215
+ if not image_path or not image_path.strip():
216
+ return None, error_response("image_path is required.")
217
+
218
+ path = Path(image_path).expanduser()
219
+
220
+ if not path.exists():
221
+ return None, error_response(f"Image file does not exist: {image_path}")
222
+
223
+ if not path.is_file():
224
+ return None, error_response(f"Path is not a file: {image_path}")
225
+
226
+ if path.suffix.lower() not in SUPPORTED_EXTENSIONS:
227
+ return None, error_response(
228
+ f"Unsupported image extension '{path.suffix}'. Supported extensions: "
229
+ f"{', '.join(sorted(SUPPORTED_EXTENSIONS))}"
230
+ )
231
+
232
+ return path, None
233
+
234
+
235
+ def prepare_image_data_url(
236
+ image_path: Path,
237
+ max_side: int = DEFAULT_MAX_SIDE,
238
+ jpeg_quality: int = DEFAULT_JPEG_QUALITY,
239
+ ) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
240
+ if max_side < 256:
241
+ return None, error_response("max_side must be at least 256.")
242
+
243
+ if jpeg_quality < 1 or jpeg_quality > 100:
244
+ return None, error_response("jpeg_quality must be between 1 and 100.")
245
+
246
+ try:
247
+ image = Image.open(image_path)
248
+ image = ImageOps.exif_transpose(image)
249
+
250
+ resample = getattr(Image, "Resampling", Image).LANCZOS
251
+ image.thumbnail((max_side, max_side), resample)
252
+
253
+ # Convert transparency to white background before JPEG encoding.
254
+ if image.mode in ("RGBA", "LA"):
255
+ background = Image.new("RGB", image.size, (255, 255, 255))
256
+ alpha = image.getchannel("A")
257
+ background.paste(image, mask=alpha)
258
+ image = background
259
+ else:
260
+ image = image.convert("RGB")
261
+
262
+ buffer = io.BytesIO()
263
+ image.save(buffer, format="JPEG", quality=jpeg_quality, optimize=True)
264
+ encoded = base64.b64encode(buffer.getvalue()).decode("utf-8")
265
+ return f"data:image/jpeg;base64,{encoded}", None
266
+
267
+ except Exception as exc:
268
+ return None, error_response(f"Failed to prepare image: {exc}")
269
+
270
+
271
+ def workers_ai_url(account_id: str, model: str) -> str:
272
+ return f"https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/{model}"
273
+
274
+
275
+ def call_workers_ai(
276
+ image_data_url: str,
277
+ image_name: str,
278
+ user_prompt: str,
279
+ account_id: str,
280
+ api_token: str,
281
+ model: str = DEFAULT_MODEL,
282
+ ) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
283
+ url = workers_ai_url(account_id, model)
284
+ headers = {
285
+ "Authorization": f"Bearer {api_token}",
286
+ "Content-Type": "application/json",
287
+ }
288
+
289
+ payload = {
290
+ "messages": [
291
+ {
292
+ "role": "system",
293
+ "content": SYSTEM_PROMPT,
294
+ },
295
+ {
296
+ "role": "user",
297
+ "content": [
298
+ {
299
+ "type": "text",
300
+ "text": user_prompt,
301
+ },
302
+ {
303
+ "type": "image_url",
304
+ "image_url": {
305
+ "url": image_data_url,
306
+ },
307
+ },
308
+ ],
309
+ },
310
+ ],
311
+ "max_tokens": DEFAULT_MAX_TOKENS,
312
+ "temperature": DEFAULT_TEMPERATURE,
313
+ "top_p": DEFAULT_TOP_P,
314
+ }
315
+
316
+ try:
317
+ response = requests.post(url, headers=headers, json=payload, timeout=180)
318
+ except requests.RequestException as exc:
319
+ return None, error_response(f"Cloudflare request failed: {exc}", image=image_name)
320
+
321
+ try:
322
+ data = response.json()
323
+ except ValueError:
324
+ return None, error_response(
325
+ "Cloudflare returned a non-JSON response.",
326
+ http_status=response.status_code,
327
+ body=response.text[:2000],
328
+ image=image_name,
329
+ )
330
+
331
+ if not response.ok or not data.get("success", False):
332
+ return None, error_response(
333
+ "Cloudflare Workers AI request failed.",
334
+ http_status=response.status_code,
335
+ errors=data.get("errors", []),
336
+ messages=data.get("messages", []),
337
+ image=image_name,
338
+ )
339
+
340
+ result = data.get("result", {})
341
+
342
+ # Most Workers AI text-generation model responses include result.response.
343
+ if isinstance(result, dict):
344
+ if isinstance(result.get("response"), str):
345
+ return result["response"], None
346
+
347
+ # Some OpenAI-style responses may include choices.
348
+ choices = result.get("choices")
349
+ if isinstance(choices, list) and choices:
350
+ message = choices[0].get("message", {}) if isinstance(choices[0], dict) else {}
351
+ content = message.get("content")
352
+ if isinstance(content, str):
353
+ return content, None
354
+
355
+ return json.dumps(result), None
356
+
357
+ if isinstance(result, str):
358
+ return result, None
359
+
360
+ return str(result), None
361
+
362
+
363
+ def strip_markdown_fences(text: str) -> str:
364
+ cleaned = text.strip()
365
+
366
+ if cleaned.startswith("```"):
367
+ lines = cleaned.splitlines()
368
+ if lines and lines[0].strip().startswith("```"):
369
+ lines = lines[1:]
370
+ if lines and lines[-1].strip() == "```":
371
+ lines = lines[:-1]
372
+ cleaned = "\n".join(lines).strip()
373
+
374
+ return cleaned
375
+
376
+
377
+ def extract_json_object(text: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
378
+ """Extract the first JSON object from model text."""
379
+ cleaned = strip_markdown_fences(text)
380
+
381
+ # Fast path: whole response is valid JSON.
382
+ try:
383
+ parsed = json.loads(cleaned)
384
+ if isinstance(parsed, dict):
385
+ return parsed, None
386
+ return None, "Model returned JSON, but it was not an object."
387
+ except json.JSONDecodeError:
388
+ pass
389
+
390
+ # Robust path: find the first object starting with '{' and decode from there.
391
+ decoder = json.JSONDecoder()
392
+ for index, char in enumerate(cleaned):
393
+ if char != "{":
394
+ continue
395
+ try:
396
+ parsed, _end = decoder.raw_decode(cleaned[index:])
397
+ if isinstance(parsed, dict):
398
+ return parsed, None
399
+ except json.JSONDecodeError:
400
+ continue
401
+
402
+ return None, "Model returned invalid JSON."
403
+
404
+
405
+ def coerce_bool(value: Any) -> bool:
406
+ if isinstance(value, bool):
407
+ return value
408
+ if isinstance(value, str):
409
+ return value.strip().lower() in {"true", "yes", "1", "y"}
410
+ return bool(value)
411
+
412
+
413
+ def normalize_item(item: Any, fallback_index: int) -> Dict[str, Any]:
414
+ default_item: Dict[str, Any] = {
415
+ "item_type": "unknown",
416
+ "count_index": fallback_index,
417
+ "package_marking": "unknown",
418
+ "marking_confidence": "unreadable",
419
+ "likely_part": "unknown",
420
+ "description": "unknown",
421
+ "position_hint": "unknown",
422
+ "needs_review": True,
423
+ }
424
+
425
+ if not isinstance(item, dict):
426
+ return default_item
427
+
428
+ normalized = dict(default_item)
429
+ normalized.update({k: v for k, v in item.items() if k in normalized})
430
+
431
+ try:
432
+ normalized["count_index"] = int(normalized.get("count_index", fallback_index))
433
+ except Exception:
434
+ normalized["count_index"] = fallback_index
435
+
436
+ confidence = str(normalized.get("marking_confidence", "unreadable")).strip().lower()
437
+ if confidence not in {"high", "medium", "low", "unreadable"}:
438
+ confidence = "low"
439
+ normalized["marking_confidence"] = confidence
440
+
441
+ normalized["needs_review"] = coerce_bool(normalized.get("needs_review", True))
442
+ if confidence in {"low", "unreadable"}:
443
+ normalized["needs_review"] = True
444
+
445
+ # Ensure string fields are strings and not nulls/lists.
446
+ for key in [
447
+ "item_type",
448
+ "package_marking",
449
+ "likely_part",
450
+ "description",
451
+ "position_hint",
452
+ ]:
453
+ value = normalized.get(key)
454
+ if value is None:
455
+ normalized[key] = "unknown"
456
+ elif not isinstance(value, str):
457
+ normalized[key] = str(value)
458
+ elif not value.strip():
459
+ normalized[key] = "unknown"
460
+
461
+ return normalized
462
+
463
+
464
+ def normalize_ic_marking_observations(value: Any) -> List[Dict[str, str]]:
465
+ if not isinstance(value, list):
466
+ return []
467
+
468
+ observations: List[Dict[str, str]] = []
469
+ for entry in value:
470
+ if not isinstance(entry, dict):
471
+ continue
472
+ confidence = str(entry.get("marking_confidence", "unreadable")).strip().lower()
473
+ if confidence not in {"high", "medium", "low", "unreadable"}:
474
+ confidence = "low"
475
+ observations.append({
476
+ "position_hint": str(entry.get("position_hint", "unknown") or "unknown"),
477
+ "package_marking": str(entry.get("package_marking", "unknown") or "unknown"),
478
+ "marking_confidence": confidence,
479
+ })
480
+ return observations
481
+
482
+
483
+ def normalize_inventory_result(result: Dict[str, Any], image_name: str) -> Dict[str, Any]:
484
+ normalized: Dict[str, Any] = {
485
+ "image": image_name,
486
+ "items": [],
487
+ "warnings": [],
488
+ }
489
+
490
+ if isinstance(result.get("image"), str) and result["image"].strip():
491
+ # Keep only the basename to avoid leaking full local paths through model output.
492
+ normalized["image"] = Path(result["image"]).name
493
+
494
+ raw_items = result.get("items", [])
495
+ if not isinstance(raw_items, list):
496
+ raw_items = []
497
+ normalized["warnings"].append("Model response did not contain a valid items list.")
498
+
499
+ normalized["items"] = [normalize_item(item, idx + 1) for idx, item in enumerate(raw_items)]
500
+
501
+ raw_warnings = result.get("warnings", [])
502
+ if isinstance(raw_warnings, list):
503
+ normalized["warnings"].extend(str(w) for w in raw_warnings if str(w).strip())
504
+ elif isinstance(raw_warnings, str) and raw_warnings.strip():
505
+ normalized["warnings"].append(raw_warnings.strip())
506
+
507
+ observations = normalize_ic_marking_observations(result.get("ic_marking_observations"))
508
+ if observations:
509
+ normalized["ic_marking_observations"] = observations
510
+
511
+ return normalized
512
+
513
+
514
+ def visible_ic_items(result: Dict[str, Any]) -> List[Dict[str, Any]]:
515
+ items = result.get("items", [])
516
+ if not isinstance(items, list):
517
+ return []
518
+ return [
519
+ item for item in items
520
+ if isinstance(item, dict) and str(item.get("item_type", "")).strip().lower() == "ic"
521
+ ]
522
+
523
+
524
+ def verify_ic_consensus_pass(
525
+ image_data_url: str,
526
+ image_name: str,
527
+ first_pass: Dict[str, Any],
528
+ account_id: str,
529
+ api_token: str,
530
+ ) -> Dict[str, Any]:
531
+ """Run a second vision pass that enforces the project rule that all ICs match."""
532
+ ic_items = visible_ic_items(first_pass)
533
+ if len(ic_items) < 2:
534
+ return first_pass
535
+
536
+ response_text, cloudflare_error = call_workers_ai(
537
+ image_data_url=image_data_url,
538
+ image_name=image_name,
539
+ user_prompt=build_ic_consensus_prompt(image_name, first_pass),
540
+ account_id=account_id,
541
+ api_token=api_token,
542
+ model=DEFAULT_MODEL,
543
+ )
544
+ if cloudflare_error:
545
+ first_pass.setdefault("warnings", []).append(
546
+ f"IC consensus verification failed: {cloudflare_error.get('message', 'unknown error')}"
547
+ )
548
+ return first_pass
549
+ assert response_text is not None
550
+
551
+ parsed, parse_error = extract_json_object(response_text)
552
+ if parse_error or parsed is None:
553
+ first_pass.setdefault("warnings", []).append(
554
+ f"IC consensus verification returned invalid JSON: {parse_error or 'unknown parse error'}"
555
+ )
556
+ first_pass["ic_consensus_raw_response"] = response_text
557
+ return first_pass
558
+
559
+ consensus = normalize_inventory_result(parsed, image_name)
560
+ previous_markings = [str(item.get("package_marking", "unknown")) for item in ic_items]
561
+ consensus.setdefault("warnings", []).append("Multi-pass IC consensus verification applied.")
562
+ consensus.setdefault("warnings", []).append(
563
+ "First pass IC marking candidates: " + ", ".join(previous_markings)
564
+ )
565
+ consensus["first_pass_items"] = first_pass.get("items", [])
566
+ return consensus
567
+
568
+
569
+ def process_image_impl(
570
+ image_path: str,
571
+ max_side: int = DEFAULT_MAX_SIDE,
572
+ jpeg_quality: int = DEFAULT_JPEG_QUALITY,
573
+ custom_prompt: Optional[str] = None,
574
+ ) -> Dict[str, Any]:
575
+ image_file, validation_error = validate_image_path(image_path)
576
+ if validation_error:
577
+ return validation_error
578
+ assert image_file is not None
579
+
580
+ account_id, api_token, credential_error = get_cloudflare_credentials()
581
+ if credential_error:
582
+ return credential_error
583
+ assert account_id is not None and api_token is not None
584
+
585
+ image_data_url, image_error = prepare_image_data_url(
586
+ image_file,
587
+ max_side=max_side,
588
+ jpeg_quality=jpeg_quality,
589
+ )
590
+ if image_error:
591
+ return image_error
592
+ assert image_data_url is not None
593
+
594
+ image_name = image_file.name
595
+ prompt = custom_prompt.strip() if custom_prompt and custom_prompt.strip() else build_default_user_prompt(image_name)
596
+
597
+ response_text, cloudflare_error = call_workers_ai(
598
+ image_data_url=image_data_url,
599
+ image_name=image_name,
600
+ user_prompt=prompt,
601
+ account_id=account_id,
602
+ api_token=api_token,
603
+ model=DEFAULT_MODEL,
604
+ )
605
+ if cloudflare_error:
606
+ return cloudflare_error
607
+ assert response_text is not None
608
+
609
+ parsed, parse_error = extract_json_object(response_text)
610
+ if parse_error or parsed is None:
611
+ return {
612
+ "image": image_name,
613
+ "items": [],
614
+ "warnings": [parse_error or "Model returned invalid JSON."],
615
+ "raw_response": response_text,
616
+ }
617
+
618
+ first_pass = normalize_inventory_result(parsed, image_name)
619
+ return verify_ic_consensus_pass(
620
+ image_data_url=image_data_url,
621
+ image_name=image_name,
622
+ first_pass=first_pass,
623
+ account_id=account_id,
624
+ api_token=api_token,
625
+ )
626
+
627
+
628
+ @mcp.tool()
629
+ def process_image(
630
+ image_path: str,
631
+ max_side: int = DEFAULT_MAX_SIDE,
632
+ jpeg_quality: int = DEFAULT_JPEG_QUALITY,
633
+ custom_prompt: Optional[str] = None,
634
+ ) -> Dict[str, Any]:
635
+ """
636
+ Analyze one electronics/PCB image and return structured visible inventory data.
637
+
638
+ This tool does not do web lookup or datasheet lookup. It only extracts visible
639
+ components and markings from the image.
640
+ """
641
+ return process_image_impl(
642
+ image_path=image_path,
643
+ max_side=max_side,
644
+ jpeg_quality=jpeg_quality,
645
+ custom_prompt=custom_prompt,
646
+ )
647
+
648
+
649
+ def find_images_in_folder(folder: Path, recursive: bool, limit: Optional[int]) -> List[Path]:
650
+ iterator = folder.rglob("*") if recursive else folder.iterdir()
651
+ images = sorted(
652
+ [p for p in iterator if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS],
653
+ key=lambda p: str(p).lower(),
654
+ )
655
+
656
+ if limit is not None:
657
+ images = images[: max(0, int(limit))]
658
+
659
+ return images
660
+
661
+
662
+ @mcp.tool()
663
+ def process_image_folder(
664
+ folder_path: str,
665
+ recursive: bool = False,
666
+ max_side: int = DEFAULT_MAX_SIDE,
667
+ jpeg_quality: int = DEFAULT_JPEG_QUALITY,
668
+ limit: Optional[int] = None,
669
+ ) -> Dict[str, Any]:
670
+ """
671
+ Process all supported images in a folder and return a combined inventory object.
672
+
673
+ Individual image failures are included in the errors list; the folder operation
674
+ continues processing the remaining images.
675
+ """
676
+ if not folder_path or not folder_path.strip():
677
+ return error_response("folder_path is required.")
678
+
679
+ folder = Path(folder_path).expanduser()
680
+ if not folder.exists():
681
+ return error_response(f"Folder does not exist: {folder_path}")
682
+ if not folder.is_dir():
683
+ return error_response(f"Path is not a folder: {folder_path}")
684
+
685
+ try:
686
+ images = find_images_in_folder(folder, recursive=recursive, limit=limit)
687
+ except Exception as exc:
688
+ return error_response(f"Failed to scan folder: {exc}")
689
+
690
+ if not images:
691
+ return {
692
+ "source_folder": str(folder),
693
+ "image_count": 0,
694
+ "processed_count": 0,
695
+ "failed_count": 0,
696
+ "results": [],
697
+ "errors": [],
698
+ "warnings": ["No supported image files found in folder."],
699
+ }
700
+
701
+ results: List[Dict[str, Any]] = []
702
+ errors: List[Dict[str, Any]] = []
703
+
704
+ for image_path in images:
705
+ try:
706
+ result = process_image_impl(
707
+ image_path=str(image_path),
708
+ max_side=max_side,
709
+ jpeg_quality=jpeg_quality,
710
+ )
711
+
712
+ if result.get("error"):
713
+ errors.append({
714
+ "image": image_path.name,
715
+ "error": result.get("message", "Unknown error"),
716
+ "details": result,
717
+ })
718
+ else:
719
+ results.append(result)
720
+
721
+ except Exception as exc:
722
+ errors.append({
723
+ "image": image_path.name,
724
+ "error": str(exc),
725
+ })
726
+
727
+ return {
728
+ "source_folder": str(folder),
729
+ "image_count": len(images),
730
+ "processed_count": len(results),
731
+ "failed_count": len(errors),
732
+ "results": results,
733
+ "errors": errors,
734
+ }
735
+
736
+
737
+ def count_inventory_rows(inventory: Dict[str, Any]) -> int:
738
+ if isinstance(inventory.get("items"), list):
739
+ return len(inventory["items"])
740
+
741
+ results = inventory.get("results", [])
742
+ if isinstance(results, list):
743
+ count = 0
744
+ for result in results:
745
+ if isinstance(result, dict) and isinstance(result.get("items"), list):
746
+ count += len(result["items"])
747
+ return count
748
+
749
+ return 0
750
+
751
+
752
+ def flatten_inventory_for_csv(inventory: Dict[str, Any]) -> List[Dict[str, Any]]:
753
+ rows: List[Dict[str, Any]] = []
754
+
755
+ if isinstance(inventory.get("items"), list):
756
+ image_results = [inventory]
757
+ else:
758
+ raw_results = inventory.get("results", [])
759
+ image_results = raw_results if isinstance(raw_results, list) else []
760
+
761
+ for result in image_results:
762
+ if not isinstance(result, dict):
763
+ continue
764
+
765
+ image_name = str(result.get("image", "unknown"))
766
+ warnings = result.get("warnings", [])
767
+ if isinstance(warnings, list):
768
+ warnings_text = " | ".join(str(w) for w in warnings)
769
+ else:
770
+ warnings_text = str(warnings)
771
+
772
+ items = result.get("items", [])
773
+ if not isinstance(items, list):
774
+ continue
775
+
776
+ for item in items:
777
+ if not isinstance(item, dict):
778
+ continue
779
+ rows.append({
780
+ "image": image_name,
781
+ "item_type": item.get("item_type", "unknown"),
782
+ "count_index": item.get("count_index", ""),
783
+ "package_marking": item.get("package_marking", "unknown"),
784
+ "marking_confidence": item.get("marking_confidence", "unreadable"),
785
+ "likely_part": item.get("likely_part", "unknown"),
786
+ "description": item.get("description", "unknown"),
787
+ "position_hint": item.get("position_hint", "unknown"),
788
+ "needs_review": item.get("needs_review", True),
789
+ "warnings": warnings_text,
790
+ })
791
+
792
+ return rows
793
+
794
+
795
+ @mcp.tool()
796
+ def save_inventory(
797
+ inventory: Dict[str, Any],
798
+ output_path: str,
799
+ format: str = "json",
800
+ ) -> Dict[str, Any]:
801
+ """
802
+ Save inventory results to disk as JSON or CSV.
803
+
804
+ The input inventory can be the result of process_image or process_image_folder.
805
+ """
806
+ if not isinstance(inventory, dict):
807
+ return error_response("inventory must be an object/dict.")
808
+
809
+ if not output_path or not output_path.strip():
810
+ return error_response("output_path is required.")
811
+
812
+ output = Path(output_path).expanduser()
813
+ fmt = format.strip().lower()
814
+
815
+ if fmt not in {"json", "csv"}:
816
+ return error_response("format must be either 'json' or 'csv'.")
817
+
818
+ try:
819
+ output.parent.mkdir(parents=True, exist_ok=True)
820
+
821
+ if fmt == "json":
822
+ output.write_text(json.dumps(inventory, indent=2, ensure_ascii=False), encoding="utf-8")
823
+ row_count = count_inventory_rows(inventory)
824
+
825
+ else:
826
+ rows = flatten_inventory_for_csv(inventory)
827
+ fieldnames = [
828
+ "image",
829
+ "item_type",
830
+ "count_index",
831
+ "package_marking",
832
+ "marking_confidence",
833
+ "likely_part",
834
+ "description",
835
+ "position_hint",
836
+ "needs_review",
837
+ "warnings",
838
+ ]
839
+
840
+ with output.open("w", newline="", encoding="utf-8") as csv_file:
841
+ writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
842
+ writer.writeheader()
843
+ writer.writerows(rows)
844
+
845
+ row_count = len(rows)
846
+
847
+ return {
848
+ "saved": True,
849
+ "output_path": str(output),
850
+ "format": fmt,
851
+ "row_count": row_count,
852
+ }
853
+
854
+ except Exception as exc:
855
+ return error_response(f"Failed to save inventory: {exc}", output_path=str(output), format=fmt)
856
+
857
+
858
+ if __name__ == "__main__":
859
+ mcp.run(transport="stdio")