loading…
Search for a command to run...
loading…
Inspect and remove EXIF metadata locally through MCP tools. Supports reading EXIF, detecting GPS, summarizing privacy risks, and stripping EXIF from images.
Inspect and remove EXIF metadata locally through MCP tools. Supports reading EXIF, detecting GPS, summarizing privacy risks, and stripping EXIF from images.
Inspect and remove EXIF metadata locally through MCP tools.
This project is a stdio-first Python MCP server for reading EXIF metadata, detecting GPS/location fields, summarizing privacy-sensitive metadata, and writing cleaned image copies with full or selective EXIF removal for supported formats.
The server exposes eleven MCP tools for local image paths:
inspect_exifinspect_exif_detailedhas_gps_exiffind_images_with_gps_exiffind_images_with_exif_fieldssummarize_exif_privacystrip_exifstrip_selected_exif_fieldsbatch_strip_exifbatch_strip_gps_exifbatch_strip_selected_exif_fieldsIt also exposes two MCP resources and two MCP prompts:
exif://privacy-guideexif://supported-formatsreview-photo-privacyclean-photos-for-sharingIt is designed for AI clients and agent workflows, and this repository is focused on the MCP server itself.
This repository is MCP-first:
src/exif_mcp_server/core/src/exif_mcp_server/tools/, resources/, and
prompts/Current v1 support is intentionally narrow:
.jpg.jpeg.png.webp.tif.tiffDo not assume IPTC or XMP support in this MCP server.
Requirements:
Set up a local virtual environment and install dependencies:
python3 -m venv .venv
source .venv/bin/activate
pip install -e '.[dev]'
Why quote '.[dev]':
Some shells such as zsh treat brackets as glob patterns.
Run the full test suite:
pytest
Run one focused test file:
pytest tests/test_inspect.py
pytest tests/test_privacy.py
pytest tests/test_clean.py
pytest tests/test_batch.py
The repo also includes manual-test sample images in examples/sample_images/.
Run Ruff:
ruff check .
Run mypy against the typed source tree:
mypy
The current mypy configuration checks src/ and ignores missing type stubs for
piexif, which does not ship typed metadata.
Use the same core verification steps before publishing changes:
ruff check .mypypytestStart the MCP server over stdio:
python -m exif_mcp_server.server
Or use the installed console entrypoint:
exif-mcp-server
The server will appear idle in the terminal because it is waiting for an MCP client over stdio.
The default transport is still stdio. Remote transport is now optional and
must be selected explicitly.
The server can now run with:
stdiostreamable-httpsseRecommended remote transport:
streamable-httpRun the server over Streamable HTTP on 127.0.0.1:8001:
python -m exif_mcp_server.server --transport streamable-http
Choose a custom host, port, and endpoint path:
python -m exif_mcp_server.server \
--transport streamable-http \
--host 0.0.0.0 \
--port 9000 \
--streamable-http-path /mcp
Useful optional flags:
--json-response--stateless-httpEquivalent environment variables:
EXIF_MCP_TRANSPORT=streamable-httpEXIF_MCP_HOST=0.0.0.0EXIF_MCP_PORT=9000EXIF_MCP_STREAMABLE_HTTP_PATH=/mcpEXIF_MCP_JSON_RESPONSE=trueEXIF_MCP_STATELESS_HTTP=trueRun the server over SSE:
python -m exif_mcp_server.server --transport sse
Customize host, port, mount path, and SSE endpoint paths:
python -m exif_mcp_server.server \
--transport sse \
--host 0.0.0.0 \
--port 9001 \
--mount-path /github \
--sse-path /events \
--message-path /messages/
Equivalent environment variables:
EXIF_MCP_TRANSPORT=sseEXIF_MCP_HOST=0.0.0.0EXIF_MCP_PORT=9001EXIF_MCP_MOUNT_PATH=/githubEXIF_MCP_SSE_PATH=/eventsEXIF_MCP_MESSAGE_PATH=/messages/Quickly verify that the server can be created:
python -c "from exif_mcp_server.server import create_server; print(type(create_server()).__name__)"
Expected output:
FastMCP
This project is stdio-first. To test it in an MCP Inspector or another local MCP client, configure a stdio server with:
.venv/bin/python-m exif_mcp_server.serverIf your MCP client expects the installed entrypoint instead, you can use:
.venv/bin/exif-mcp-serverFor a remote client that supports Streamable HTTP, run:
python -m exif_mcp_server.server --transport streamable-http --host 127.0.0.1 --port 8001
Then connect the client to:
http://127.0.0.1:8001/mcp
Expected tools:
inspect_exifinspect_exif_detailedhas_gps_exiffind_images_with_gps_exiffind_images_with_exif_fieldssummarize_exif_privacystrip_exifstrip_selected_exif_fieldsbatch_strip_exifbatch_strip_gps_exifbatch_strip_selected_exif_fieldsExpected resources:
exif://privacy-guideexif://supported-formatsExpected prompts:
review-photo-privacyclean-photos-for-sharingThe exact configuration shape depends on the MCP client. The examples below were checked against the official client docs on April 18, 2026.
| Client | Best for | Local stdio | Remote HTTP | Notes |
|---|---|---|---|---|
| Claude Code | terminal-first MCP workflows | yes | yes | best fit if you want quick local testing and CLI management |
| VS Code | editor-integrated development | yes | yes | good default if you want MCP tools inside a coding workspace |
| Cursor | editor-integrated AI workflows | yes | yes | good fit if your main coding flow already lives in Cursor |
| MCP Inspector | debugging and manual verification | yes | yes | best choice for checking raw tool/resource/prompt behavior |
| Claude Desktop | end-user desktop app workflows | limited | yes | local setup now centers on desktop extensions rather than raw stdio config |
Recommended starting points:
MCP Inspector for the first manual smoke testClaude Code if you want the fastest terminal-based setupVS Code or Cursor if you want the server available inside your editorstreamable-http when you want one running server shared by multiple clientsAdd the local stdio server:
claude mcp add --transport stdio exif-mcp -- \
/absolute/path/to/image-mcp-server/.venv/bin/python \
-m exif_mcp_server.server
Add the remote Streamable HTTP server:
claude mcp add --transport http exif-mcp-http \
http://127.0.0.1:8001/mcp
If you want to use remote transport first, start the server separately:
python -m exif_mcp_server.server \
--transport streamable-http \
--host 127.0.0.1 \
--port 8001
Useful Claude Code commands:
claude mcp listclaude mcp get exif-mcp/mcpVS Code uses mcp.json with a "servers" object. For a workspace-local setup,
create .vscode/mcp.json with:
{
"servers": {
"exif-mcp": {
"command": "/absolute/path/to/image-mcp-server/.venv/bin/python",
"args": ["-m", "exif_mcp_server.server"]
}
}
}
For remote Streamable HTTP, use:
{
"servers": {
"exif-mcp-http": {
"type": "http",
"url": "http://127.0.0.1:8001/mcp"
}
}
}
Notes:
.vscode/mcp.jsonMCP: Open User ConfigurationCursor uses .cursor/mcp.json in the project, or ~/.cursor/mcp.json
globally, with an "mcpServers" object.
Project-local stdio example:
{
"mcpServers": {
"exif-mcp": {
"type": "stdio",
"command": "/absolute/path/to/image-mcp-server/.venv/bin/python",
"args": ["-m", "exif_mcp_server.server"]
}
}
}
Cursor's docs also support remote MCP configuration with fields such as url
and headers. For this server, the remote endpoint is:
http://127.0.0.1:8001/mcp
For a local stdio session:
npx @modelcontextprotocol/inspector \
/absolute/path/to/image-mcp-server/.venv/bin/python \
-m exif_mcp_server.server
For remote testing, first start the server:
python -m exif_mcp_server.server \
--transport streamable-http \
--host 127.0.0.1 \
--port 8001
Then connect the Inspector to:
http://127.0.0.1:8001/mcp
Claude Desktop's current official direction is different for local and remote servers:
.mcpb)Settings > ConnectorsThis repo does not currently ship a Claude Desktop extension bundle, so the most straightforward client setups today are Claude Code, VS Code, Cursor, or MCP Inspector.
The server publishes two short static resources:
exif://privacy-guideexif://supported-formatsThe server publishes two prompt templates:
review-photo-privacyinspect_exif, has_gps_exif, and summarize_exif_privacyclean-photos-for-sharingbatch_strip_exifUseful local sample paths from this repo:
examples/sample_images/plain-no-exif.jpgexamples/sample_images/basic-exif.jpgexamples/sample_images/gps-exif.jpgexamples/sample_images/tiff-exif.tiffinspect_exif
Input:
{
"image_path": "/absolute/path/to/photo.jpg"
}
Example output:
{
"image_path": "/absolute/path/to/photo.jpg",
"has_exif": true,
"exif": {
"Make": "Apple",
"Model": "iPhone 14",
"DateTimeOriginal": "2026:04:16 10:30:00"
},
"warnings": []
}
inspect_exif_detailed
Input:
{
"image_path": "/absolute/path/to/photo.jpg"
}
Example output (trimmed):
{
"image_path": "/absolute/path/to/photo.jpg",
"has_exif": true,
"exif": {
"Artist": "Blue J.",
"Make": "Canon"
},
"warnings": [],
"tags": [
{
"ifd": "0th",
"tag_id": 315,
"field_name": "Artist",
"field_key": "Artist",
"value": "Blue J."
}
]
}
has_gps_exif
Input:
{
"image_path": "/absolute/path/to/photo.jpg"
}
find_images_with_gps_exif
Input:
{
"folder_path": "/absolute/path/to/folder",
"recursive": false,
"extensions": null
}
Example output:
{
"folder_path": "/absolute/path/to/folder",
"scanned_count": 2,
"matched_count": 1,
"failed_count": 0,
"skipped_count": 1,
"matches": [
{
"image_path": "/absolute/path/to/folder/photo.jpg",
"gps_fields_present": [
"GPSLatitude",
"GPSLatitudeRef",
"GPSLongitude",
"GPSLongitudeRef"
]
}
],
"failures": []
}
Example output:
{
"image_path": "/absolute/path/to/photo.jpg",
"has_gps": true,
"gps_fields_present": [
"GPSLatitude",
"GPSLatitudeRef",
"GPSLongitude",
"GPSLongitudeRef"
]
}
find_images_with_exif_fields
Input:
{
"folder_path": "/absolute/path/to/folder",
"field_names": ["Artist", "XPAuthor", "Copyright"],
"match_mode": "any",
"recursive": false,
"extensions": null
}
Example output:
{
"folder_path": "/absolute/path/to/folder",
"requested_fields": ["Artist", "XPAuthor", "Copyright"],
"match_mode": "any",
"scanned_count": 2,
"matched_count": 1,
"failed_count": 0,
"skipped_count": 1,
"matches": [
{
"image_path": "/absolute/path/to/folder/author.jpg",
"matched_fields": ["Artist"]
}
],
"failures": []
}
summarize_exif_privacy
Input:
{
"image_path": "/absolute/path/to/photo.jpg"
}
Example output:
{
"image_path": "/absolute/path/to/photo.jpg",
"has_exif": true,
"privacy_risk": "high",
"findings": [
{
"field": "GPSLatitude",
"severity": "high",
"reason": "Location metadata can reveal where the photo was taken."
}
],
"summary": "This image contains GPS metadata."
}
strip_exif
Input:
{
"image_path": "/absolute/path/to/photo.jpg",
"output_path": null,
"overwrite": false,
"dry_run": false,
"include_comparison": false,
"write_report": false
}
Example output:
{
"source_path": "/absolute/path/to/photo.jpg",
"output_path": "/absolute/path/to/photo.cleaned.jpg",
"removed_exif": true,
"notes": [
"Created sibling cleaned file.",
"Removed EXIF metadata from the written image."
]
}
strip_selected_exif_fields
Input:
{
"image_path": "/absolute/path/to/photo.jpg",
"field_names": ["Artist", "XPAuthor", "Copyright"],
"output_path": null,
"overwrite": false,
"dry_run": false,
"include_comparison": false,
"write_report": false
}
Example output:
{
"source_path": "/absolute/path/to/photo.jpg",
"output_path": "/absolute/path/to/photo.cleaned.jpg",
"removed_fields": ["Artist"],
"removed_tag_count": 1,
"notes": [
"Created sibling cleaned file.",
"Removed selected EXIF fields from the written image."
]
}
batch_strip_exif
Input:
{
"folder_path": "/absolute/path/to/folder",
"output_folder": null,
"recursive": false,
"overwrite": false,
"extensions": null,
"dry_run": false,
"include_comparison": false,
"write_report": false
}
batch_strip_selected_exif_fields
Input:
{
"folder_path": "/absolute/path/to/folder",
"field_names": ["Artist", "XPAuthor", "Copyright"],
"output_folder": "/absolute/path/to/cleaned",
"recursive": false,
"overwrite": false,
"extensions": null,
"dry_run": false,
"include_comparison": false,
"write_report": false
}
Example output:
{
"folder_path": "/absolute/path/to/folder",
"requested_fields": ["Artist", "XPAuthor", "Copyright"],
"processed_count": 1,
"success_count": 1,
"failed_count": 0,
"skipped_count": 0,
"results": [
{
"source_path": "/absolute/path/to/folder/author.jpg",
"output_path": "/absolute/path/to/cleaned/author.cleaned.jpg",
"status": "success",
"message": "Selected EXIF fields removed.",
"removed_fields": ["Artist"],
"removed_tag_count": 1
}
]
}
batch_strip_gps_exif
Input:
{
"folder_path": "/absolute/path/to/folder",
"output_folder": "/absolute/path/to/cleaned",
"recursive": false,
"overwrite": false,
"extensions": null,
"dry_run": false,
"include_comparison": false,
"write_report": false
}
Example output:
{
"folder_path": "/absolute/path/to/folder",
"processed_count": 1,
"success_count": 1,
"failed_count": 0,
"skipped_count": 0,
"results": [
{
"source_path": "/absolute/path/to/folder/photo.jpg",
"output_path": "/absolute/path/to/cleaned/photo.cleaned.jpg",
"status": "success",
"message": "GPS EXIF removed.",
"removed_gps": true
}
]
}
Example output:
{
"folder_path": "/absolute/path/to/folder",
"processed_count": 2,
"success_count": 1,
"failed_count": 0,
"skipped_count": 1,
"results": [
{
"source_path": "/absolute/path/to/folder/photo.jpg",
"output_path": "/absolute/path/to/folder/photo.cleaned.jpg",
"status": "success",
"message": "EXIF removed."
},
{
"source_path": "/absolute/path/to/folder/ignore.bmp",
"status": "skipped",
"message": "Skipped because the file extension is not selected for batch processing."
}
]
}
The server is safe by default:
strip_exif does not overwrite the source file unless overwrite=truephoto.cleaned.jpg or photo.cleaned.png
will not overwrite an
existing file unless overwrite=truebatch_strip_exif continues even if one file failsstrip_selected_exif_fieldsbatch_strip_selected_exif_fieldsWhen overwrite=true, the server may rewrite the source image or replace an
existing target file.
strip_exif, strip_selected_exif_fields, batch_strip_exif,
batch_strip_gps_exif, and batch_strip_selected_exif_fields support three
optional features:
dry_runinclude_comparisonbefore_has_exifafter_has_exifremoved_fieldsremaining_fieldswrite_reportphoto.cleaned.exif-report.jsonExample strip_exif dry run:
{
"image_path": "/absolute/path/to/photo.jpg",
"dry_run": true,
"include_comparison": true,
"write_report": true
}
Example dry-run result:
{
"source_path": "/absolute/path/to/photo.jpg",
"output_path": "/absolute/path/to/photo.cleaned.jpg",
"removed_exif": true,
"dry_run": true,
"comparison": {
"before_has_exif": true,
"after_has_exif": false,
"removed_fields": ["DateTimeOriginal", "Make"],
"remaining_fields": []
},
"notes": [
"Created sibling cleaned file.",
"Dry run only; no files were written.",
"Dry run would remove EXIF metadata from the output image.",
"Dry run skipped writing the sidecar JSON report."
]
}
Example sidecar report output:
{
"source_path": "/absolute/path/to/photo.jpg",
"output_path": "/absolute/path/to/photo.cleaned.jpg",
"removed_exif": true,
"dry_run": false,
"comparison": {
"before_has_exif": true,
"after_has_exif": false,
"removed_fields": ["DateTimeOriginal", "Make"],
"remaining_fields": []
},
"notes": [
"Created sibling cleaned file.",
"Removed EXIF metadata from the written image."
]
}
The repo includes small synthetic images under examples/sample_images/:
plain-no-exif.jpgbasic-exif.jpggps-exif.jpgtiff-exif.tiffUseful manual checks:
inspect_exif on basic-exif.jpg and confirm device/timestamp fields are present.has_gps_exif on gps-exif.jpg and confirm GPS fields are detected.summarize_exif_privacy on gps-exif.jpg and confirm the risk is high.strip_exif on gps-exif.jpg and confirm the cleaned output has has_exif: false.find_images_with_gps_exif on a folder and confirm only GPS-bearing files are returned.batch_strip_gps_exif on a folder and confirm GPS data is removed while other EXIF fields remain when possible.batch_strip_exif on examples/sample_images/ and confirm supported files are processed and pre-existing *.cleaned.<ext> outputs are not overwritten unless requested.inspect_exif or strip_exif on tiff-exif.tiff and confirm TIFF EXIF is inspected and cleaned correctly.find_images_with_exif_fields with ["Artist", "XPAuthor", "Copyright"] and confirm only author-bearing files match.batch_strip_selected_exif_fields with an output_folder and confirm selected fields are removed while non-selected EXIF remains.Successful tool responses keep their normal JSON result shapes.
Tool failures are exposed with a stable error string prefix so MCP clients can recognize and parse them predictably:
EXIF_TOOL_ERROR {"code":"file_not_found","message":"...","tool":"inspect_exif"}
Current public error codes include:
file_not_foundinvalid_pathinvalid_metadata_selectionunsupported_image_typeexif_read_errorexif_write_errorunsafe_overwriteexif_errorinternal_errorThe project is structured in three layers:
src/exif_mcp_server/core/src/exif_mcp_server/tools/src/exif_mcp_server/server.pyThe MCP layer is intentionally thin. EXIF reading, GPS detection, privacy summary logic, GPS-folder scanning, single-file cleaning, and batch cleaning live in the shared core.
The required MVP tools are implemented and the project now goes beyond the original MVP:
streamable-http, and sse transports are availableStill out of scope or future-facing:
MIT
Добавь это в claude_desktop_config.json и перезапусти Claude Desktop.
{
"mcpServers": {
"exif-mcp-server": {
"command": "npx",
"args": []
}
}
}