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_fields
It also exposes two MCP resources and two MCP prompts:
- resources:
exif://privacy-guideexif://supported-formats
- prompts:
review-photo-privacyclean-photos-for-sharing
It is designed for AI clients and agent workflows, and this repository is focused on the MCP server itself.
This repository is MCP-first:
- the shared EXIF logic lives under
src/exif_mcp_server/core/ - the MCP adapter lives under
src/exif_mcp_server/tools/,resources/, andprompts/ - tests, examples, and docs are included so the project can work as a sample MCP server for learning and reuse
Current v1 support is intentionally narrow:
.jpg.jpeg.png.webp.tif.tiff
Do not assume IPTC or XMP support in this MCP server.
Requirements:
- Python 3.11+
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:
pytestRun one focused test file:
pytest tests/test_inspect.py
pytest tests/test_privacy.py
pytest tests/test_clean.py
pytest tests/test_batch.pyThe repo also includes manual-test sample images in examples/sample_images/.
Run Ruff:
ruff check .Run mypy against the typed source tree:
mypyThe 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 .mypypytest
Start the MCP server over stdio:
python -m exif_mcp_server.serverOr use the installed console entrypoint:
exif-mcp-serverThe 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-httpsse
Recommended remote transport:
streamable-http
Run the server over Streamable HTTP on 127.0.0.1:8001:
python -m exif_mcp_server.server --transport streamable-httpChoose 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 /mcpUseful optional flags:
--json-response--stateless-http
Equivalent 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=true
Run the server over SSE:
python -m exif_mcp_server.server --transport sseCustomize 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:
- command:
.venv/bin/python - args:
-m exif_mcp_server.server - working directory: this repo root
If your MCP client expects the installed entrypoint instead, you can use:
- command:
.venv/bin/exif-mcp-server
For a remote client that supports Streamable HTTP, run:
python -m exif_mcp_server.server --transport streamable-http --host 127.0.0.1 --port 8001Then 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_fields
Expected resources:
exif://privacy-guideexif://supported-formats
Expected prompts:
review-photo-privacyclean-photos-for-sharing
The 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:
- use
MCP Inspectorfor the first manual smoke test - use
Claude Codeif you want the fastest terminal-based setup - use
VS CodeorCursorif you want the server available inside your editor - use
streamable-httpwhen you want one running server shared by multiple clients
Add the local stdio server:
claude mcp add --transport stdio exif-mcp -- \
/absolute/path/to/image-mcp-server/.venv/bin/python \
-m exif_mcp_server.serverAdd the remote Streamable HTTP server:
claude mcp add --transport http exif-mcp-http \
http://127.0.0.1:8001/mcpIf 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 8001Useful Claude Code commands:
claude mcp listclaude mcp get exif-mcp/mcp
VS 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:
- workspace config lives in
.vscode/mcp.json - user-level config is available via
MCP: Open User Configuration - VS Code also supports auto-discovery from other apps such as Claude Desktop
Cursor 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.serverFor remote testing, first start the server:
python -m exif_mcp_server.server \
--transport streamable-http \
--host 127.0.0.1 \
--port 8001Then connect the Inspector to:
http://127.0.0.1:8001/mcp
Claude Desktop's current official direction is different for local and remote servers:
- local tools are now primarily packaged as desktop extensions (
.mcpb) - remote MCP servers are added through
Settings > Connectors
This 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-guide- practical explanation of EXIF privacy risk
- what the server removes
- what the server does not remove
exif://supported-formats- currently supported image formats
- overwrite behavior summary
- stdio-first transport note
The server publishes two prompt templates:
review-photo-privacy- guides a client through
inspect_exif,has_gps_exif, andsummarize_exif_privacy
- guides a client through
clean-photos-for-sharing- guides a client through safe folder cleanup with
batch_strip_exif
- guides a client through safe folder cleanup with
Useful 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.tiff
inspect_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:
- read-only tools do not modify files
strip_exifdoes not overwrite the source file unlessoverwrite=true- default sibling outputs such as
photo.cleaned.jpgorphoto.cleaned.pngwill not overwrite an existing file unlessoverwrite=true batch_strip_exifcontinues even if one file fails- selective cleanup tools follow the same safe defaults:
strip_selected_exif_fieldsbatch_strip_selected_exif_fields
When 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_run- validate the request and show the predicted output path
- no image files or report files are written
include_comparison- include a compact before/after EXIF summary in the result
- fields:
before_has_exifafter_has_exifremoved_fieldsremaining_fields
write_report- write a sidecar JSON report next to each cleaned output image
- example sidecar path:
photo.cleaned.exif-report.json
Example 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.tiff
Useful manual checks:
- Call
inspect_exifonbasic-exif.jpgand confirm device/timestamp fields are present. - Call
has_gps_exifongps-exif.jpgand confirm GPS fields are detected. - Call
summarize_exif_privacyongps-exif.jpgand confirm the risk ishigh. - Call
strip_exifongps-exif.jpgand confirm the cleaned output hashas_exif: false. - Call
find_images_with_gps_exifon a folder and confirm only GPS-bearing files are returned. - Call
batch_strip_gps_exifon a folder and confirm GPS data is removed while other EXIF fields remain when possible. - Call
batch_strip_exifonexamples/sample_images/and confirm supported files are processed and pre-existing*.cleaned.<ext>outputs are not overwritten unless requested. - Call
inspect_exiforstrip_exifontiff-exif.tiffand confirm TIFF EXIF is inspected and cleaned correctly. - Call
find_images_with_exif_fieldswith["Artist", "XPAuthor", "Copyright"]and confirm only author-bearing files match. - Call
batch_strip_selected_exif_fieldswith anoutput_folderand 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_error
The project is structured in three layers:
- Shared core in
src/exif_mcp_server/core/ - Thin MCP tool wrappers in
src/exif_mcp_server/tools/ - Stdio server bootstrap in
src/exif_mcp_server/server.py
The 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:
- stdio,
streamable-http, andssetransports are available - JPG/JPEG/PNG/WebP/TIFF support is implemented and tested
- optional MCP resources and prompts are implemented
- GPS-focused folder scan and GPS-only batch cleanup tools are implemented
Still out of scope or future-facing:
- IPTC or XMP editing
- cloud storage workflows
- production auth and deployment hardening for remote transport
MIT