exif_privacy_agent is a companion project for learning how to build a
practical AI agent in Python.
It currently depends on the separate exif_mcp_server project for its EXIF
capability layer.
exif_mcp_server is the companion capability project behind this agent. It
provides the EXIF inspection, privacy summarization, and cleanup operations
that the agent uses through either direct shared-core imports, a real MCP
stdio server, or a running MCP HTTP server.
This repository is intentionally open-sourceable on its own, but it is not
intended to run on its own. Treat exif_mcp_server as a required companion
project and install it before using the agent.
The agent is intentionally narrow:
- it helps review image privacy risk
- it can review a whole folder before sharing
- it can remove only selected metadata such as GPS fields
- it can request safe cleanup actions
- it can audit folders for metadata that should not be shared
- it keeps approvals and output summaries explicit
This scaffold is designed to sit next to the exif_mcp_server project in the
same workspace.
The clearest open-source setup is to clone both repositories as siblings:
your-workspace/
exif_mcp_server/
exif_privacy_agent/
In the examples below, sample-image paths assume that layout. If you want to use your own images instead, replace those paths with any supported local image paths on your machine.
If you are starting from GitHub, a simple first setup looks like this:
git clone https://github.com/nextframedev/exif_privacy_agent.git
git clone https://github.com/nextframedev/exif_mcp_server.git
cd exif_privacy_agentBefore running the CLI examples below, create a virtual environment and install both the agent and the companion EXIF capability project:
python3 -m venv .venv
source .venv/bin/activate
pip install -e '.[dev]'
pip install -e ../exif_mcp_serverAfter installation, you can run the agent either as the installed
exif-privacy-agent command or with python -m exif_privacy_agent.agent.
The scaffold is split into a few small modules:
planner.pyturns a plain-language request into a small planplanner.pyalso defines the planner boundary, a rule-based planner, and a model-backed planner that validates structured candidate plansreact.pydefines an optional ReAct-style loop scaffold and a scripted reasoning model that makes the iterative shape visiblepolicies.pydecides whether a request is read-only or mutatingapprovals.pyhandles approval checks before mutationsloop.pyruns the observe, plan, act flowclients.pydefines the tool-client interface, a direct local adapter, a real stdio MCP client, and an HTTP MCP clientformatter.pyturns structured results into user-facing summariesllm/contains provider scaffolding for OpenAI, Anthropic/Claude, and local structured-output models, plus a usable local HTTP client path
The goal is not to build a giant agent system on day one.
The goal is to show:
- how an agent reads intent
- how it chooses the next action
- how it asks for approval before mutations
- how it uses structured tool results
- how it summarizes outcomes in plain language
The project now exposes two loop shapes:
boundedplan once, then execute a known workflowreactchoose one next action at a time, observe the result, then choose again
The default is bounded.
The two modes share the same:
- tool client layer
- policy layer
- approval layer
- formatter layer
Only the decision loop changes.
Bounded flow:
request
|
v
planner
|
v
policy -> approval -> workflow execution -> formatter
ReAct flow:
request
|
v
planner
|
v
reasoning model -> one tool action -> observation
^ |
| v
+--------- repeat until final answer
The project supports three ways to reach the EXIF capability layer:
mcp-stdiolaunches the EXIF MCP server as a real stdio subprocess and calls tools through the MCP SDKmcp-httpconnects to a running streamable HTTP MCP server over HTTP through the MCP SDKdirectimports the shared EXIF core directly inside the same Python process
The planner layer has a similar split:
RuleBasedPlannerkeeps the first version deterministic and easy to inspectModelBackedPlanneraccepts a structured candidate plan from a model, validates it, and then hands the result to the same policy and execution loop
The ReAct layer has a parallel split:
ScriptedReactModelmakes the iterative loop shape visible without needing a live modelModelBackedReactModelgives the model the original request, the validated plan, and the observed tool history, then validates the structured next-action payload before handing the result to the same loop
Use mcp-stdio when you want the agent to exercise the actual MCP boundary.
Use mcp-http when you want the same MCP boundary but with one separately
running HTTP server shared by multiple clients or deployment processes.
Use direct when you want the simplest possible debugging loop while you are
still shaping the planner, policies, and formatter.
The current HTTP scaffold targets the MCP Python SDK v1.x streamable HTTP client API. If that SDK surface changes in a later release, this adapter may need small updates.
If you later plug in an LLM, keep the boundary narrow.
The model should only propose a structured plan such as:
{
"intent": "clean_selected_image",
"image_path": "../exif_mcp_server/examples/sample_images/gps-exif.jpg",
"output_path": "cleaned/gps-cleaned.jpg",
"field_names": ["GPSLatitude", "GPSLongitude", "GPSLatitudeRef", "GPSLongitudeRef"],
"dry_run": false,
"explanation": "Remove only GPS metadata from one image."
}The model should not:
- approve file mutations
- decide whether policy gates are skipped
- call MCP tools directly
- rewrite the response contract on its own
Those responsibilities stay in:
policies.pyapprovals.pyloop.pyformatter.py
The same boundary applies in react mode.
Even there, a future model should only choose the next action from the current request, the validated plan, and the observed tool history.
Policy, approval, and tool execution still stay outside the model.
In other words:
- bounded + model-backed planner the model proposes the whole workflow plan once
- react + model-backed reasoning the model proposes one next action at a time
The project now includes a provider scaffold under src/exif_privacy_agent/llm/:
openai_provider.pyanthropic_provider.pylocal_provider.pyfactory.py
Right now:
localhas a usable runtime pathopenaihas a usable runtime pathanthropichas a usable runtime path
These wrappers all share the same idea:
- a provider-specific wrapper implements
PlanningModelorReactActionModel - a thin client adapter implements
StructuredOutputClient - in
reactmode, the wrapper passes the request, validated plan, and observed history into the model input - policy, approval, and tool execution stay outside the provider layer
That lets you support multiple providers without changing the safety-critical parts of the agent.
The OpenAI runtime path uses the Chat Completions API over HTTPS.
Default endpoint:
https://api.openai.com/v1/chat/completions
Expected environment variable:
OPENAI_API_KEY
Example:
export OPENAI_API_KEY=...
python -m exif_privacy_agent.agent \
--llm-provider openai \
--llm-model gpt-4o-mini \
'Can I safely share `../exif_mcp_server/examples/sample_images/gps-exif.jpg` as-is?'For ReAct mode:
python -m exif_privacy_agent.agent \
--mode react \
--llm-provider openai \
--llm-model gpt-4o-mini \
'Can I safely share `../exif_mcp_server/examples/sample_images/gps-exif.jpg` as-is?'The Anthropic runtime path uses the Messages API over HTTPS.
Default endpoint:
https://api.anthropic.com/v1/messages
Expected environment variable:
ANTHROPIC_API_KEY
Example:
export ANTHROPIC_API_KEY=...
python -m exif_privacy_agent.agent \
--llm-provider anthropic \
--llm-model your-anthropic-model \
'Can I safely share `../exif_mcp_server/examples/sample_images/gps-exif.jpg` as-is?'For ReAct mode:
python -m exif_privacy_agent.agent \
--mode react \
--llm-provider anthropic \
--llm-model your-anthropic-model \
'Can I safely share `../exif_mcp_server/examples/sample_images/gps-exif.jpg` as-is?'Use a model name that your Anthropic account can access.
The current real runtime integration is the local provider.
It expects an OpenAI-compatible local HTTP endpoint and defaults to:
http://127.0.0.1:11434/v1/chat/completions
You can select it with:
python -m exif_privacy_agent.agent \
--llm-provider local \
--llm-model your-local-model \
'Can I safely share `../exif_mcp_server/examples/sample_images/gps-exif.jpg` as-is?'For ReAct mode:
python -m exif_privacy_agent.agent \
--mode react \
--llm-provider local \
--llm-model your-local-model \
'Can I safely share `../exif_mcp_server/examples/sample_images/gps-exif.jpg` as-is?'Optional runtime flags:
--llm-endpoint--llm-temperature--show-fallbacks--debug-llm
Important:
- local LLM use is optional
- rule-based fallback stays enabled by default
- policy and approval checks still stay outside the model
To use the local provider in practice, you need a local model server that
accepts OpenAI-compatible chat-completions requests.
The current default in this project is:
http://127.0.0.1:11434/v1/chat/completions
A simple setup sequence looks like this:
- start your local model server
- make sure it exposes an OpenAI-compatible HTTP API
- make sure the model name you plan to use is available there
- run the agent with
--llm-provider local --llm-model ...
Before using the agent, it is worth checking that the server is reachable.
For example:
curl http://127.0.0.1:11434/v1/modelsIf your local runtime exposes a different endpoint, keep the same model name
but pass the custom URL with --llm-endpoint.
Once the endpoint is up, run a bounded request:
python -m exif_privacy_agent.agent \
--llm-provider local \
--llm-model llama3.2 \
'Can I safely share `../exif_mcp_server/examples/sample_images/gps-exif.jpg` as-is?'Then run the same request in ReAct mode:
python -m exif_privacy_agent.agent \
--mode react \
--llm-provider local \
--llm-model llama3.2 \
'Can I safely share `../exif_mcp_server/examples/sample_images/gps-exif.jpg` as-is?'If you are unsure whether the model path was really used, run:
python -m exif_privacy_agent.agent \
--llm-provider local \
--llm-model llama3.2 \
--show-fallbacks \
'Can I safely share `../exif_mcp_server/examples/sample_images/gps-exif.jpg` as-is?'If you see no fallback message, the model path stayed active.
If you do see a fallback message, the agent still ran safely, but it switched back to deterministic logic because:
- the endpoint was unavailable
- or the model returned JSON that did not fit the expected contract
If the local endpoint is unavailable or returns an invalid structured payload, the agent falls back safely:
- bounded mode falls back to the rule-based planner
- ReAct mode falls back to the scripted next-action model
To make that visible during a CLI run, use:
python -m exif_privacy_agent.agent \
--llm-provider local \
--llm-model llama3.2 \
--show-fallbacks \
'Can I safely share `../exif_mcp_server/examples/sample_images/gps-exif.jpg` as-is?'This is useful for two different classes of failure:
- the local model endpoint is not reachable yet
- the local model returns JSON that does not match the expected contract
If you want the full provider request, response, and fallback trace, use:
python -m exif_privacy_agent.agent \
--llm-provider local \
--llm-model llama3.2 \
--debug-llm \
'Can I safely share `../exif_mcp_server/examples/sample_images/gps-exif.jpg` as-is?'Example shape for bounded mode:
from exif_privacy_agent.agent import ExifPrivacyAgent
from exif_privacy_agent.approvals import StaticApprovalProvider
from exif_privacy_agent.clients import MCPStdioToolClient
from exif_privacy_agent.llm.base import LLMProviderConfig
from exif_privacy_agent.llm.factory import build_planning_model_for_provider
from exif_privacy_agent.planner import ModelBackedPlanner, RuleBasedPlanner
provider_client = ... # your OpenAI, Claude, or local structured-output adapter
config = LLMProviderConfig(provider="openai", model="gpt-5-mini")
planner_model = build_planning_model_for_provider(
client=provider_client,
config=config,
)
agent = ExifPrivacyAgent(
client=MCPStdioToolClient.for_local_server(),
approvals=StaticApprovalProvider(approved=False),
planner=ModelBackedPlanner(
model=planner_model,
fallback_planner=RuleBasedPlanner(),
),
mode="bounded",
)Example shape for ReAct mode:
from exif_privacy_agent.agent import ExifPrivacyAgent
from exif_privacy_agent.approvals import StaticApprovalProvider
from exif_privacy_agent.clients import MCPStdioToolClient
from exif_privacy_agent.llm.base import LLMProviderConfig
from exif_privacy_agent.llm.factory import build_react_model_for_provider
from exif_privacy_agent.react import ModelBackedReactModel, ScriptedReactModel
provider_client = ... # your OpenAI, Claude, or local structured-output adapter
config = LLMProviderConfig(provider="anthropic", model="your-anthropic-model")
react_action_model = build_react_model_for_provider(
client=provider_client,
config=config,
)
agent = ExifPrivacyAgent(
client=MCPStdioToolClient.for_local_server(),
approvals=StaticApprovalProvider(approved=False),
mode="react",
react_model=ModelBackedReactModel(
model=react_action_model,
fallback_model=ScriptedReactModel(),
),
)If you skipped the setup block near the top of this README, run that setup
first from the exif_privacy_agent folder, then verify the local toolchain:
./.venv/bin/ruff check .
./.venv/bin/mypy src
./.venv/bin/pytestexif_mcp_server is a required companion install for all three capability
paths:
mcp-stdiouses it to launchpython -m exif_mcp_server.servermcp-httpuses it as a separately running streamable HTTP MCP serverdirectuses it to import the shared EXIF core
The default CLI mode is mcp-stdio.
That path launches the EXIF MCP server as a stdio subprocess, initializes an
MCP session, and calls tools such as inspect_exif through the actual
protocol boundary.
Run a quick read-only example:
python -m exif_privacy_agent.agent \
'Can I safely share `../exif_mcp_server/examples/sample_images/gps-exif.jpg` as-is?'Run the same request through the ReAct-style scaffold:
python -m exif_privacy_agent.agent \
--mode react \
'Can I safely share `../exif_mcp_server/examples/sample_images/gps-exif.jpg` as-is?'Run a mutating example with explicit approval:
python -m exif_privacy_agent.agent \
--approve-mutations \
'Prepare `../exif_mcp_server/examples/sample_images` for sharing.'Run a targeted cleanup example:
python -m exif_privacy_agent.agent \
--interactive-approvals \
'Remove only GPS metadata from `../exif_mcp_server/examples/sample_images/gps-exif.jpg` and write it to `cleaned/gps-targeted-clean.jpg`.'Preview a cleanup without writing files:
python -m exif_privacy_agent.agent \
'Preview only: remove the metadata from `../exif_mcp_server/examples/sample_images/basic-exif.jpg` without writing files.'That preview still uses the cleanup workflow, but it stays no-write and does not require an approval prompt.
If you want the CLI to ask before writing files instead of auto-approving, use interactive approvals:
python -m exif_privacy_agent.agent \
--interactive-approvals \
'Prepare `../exif_mcp_server/examples/sample_images` for sharing.'If you want the agent to talk to a running HTTP MCP service instead of
launching a subprocess, start exif_mcp_server separately with
streamable-http.
In a separate terminal, from the exif_mcp_server checkout and an environment
where that package is installed, run:
python -m exif_mcp_server.server --transport streamable-httpThen point the agent at that URL:
python -m exif_privacy_agent.agent \
--client mcp-http \
--mcp-http-url http://127.0.0.1:8001/mcp \
'Can I safely share `../exif_mcp_server/examples/sample_images/gps-exif.jpg` as-is?'This path uses the current MCP Python SDK v1.x streamable HTTP client API. That SDK surface may evolve, so small adapter updates may be needed when you upgrade SDK versions.
This scaffold also includes DirectExifToolClient, which imports the EXIF
server's shared core directly from the installed exif_mcp_server companion
package. That keeps the first agent loop easy to inspect while you are still
designing behavior.
Run the same request through the direct adapter:
python -m exif_privacy_agent.agent \
--client direct \
'Can I safely share `../exif_mcp_server/examples/sample_images/gps-exif.jpg` as-is?'Or use the direct adapter with the ReAct-style scaffold:
python -m exif_privacy_agent.agent \
--client direct \
--mode react \
'Can I safely share `../exif_mcp_server/examples/sample_images/gps-exif.jpg` as-is?'If you want the full structured agent response instead of only the summary,
add --json:
python -m exif_privacy_agent.agent \
--json \
'Find all images in `../exif_mcp_server/examples/sample_images` that still contain GPS metadata.'Can I safely share `../exif_mcp_server/examples/sample_images/gps-exif.jpg`?Review `../exif_mcp_server/examples/sample_images` and tell me which photos are risky to share.Review `../exif_mcp_server/examples/sample_images` for privacy risk before sharing.Remove the metadata from `../exif_mcp_server/examples/sample_images/basic-exif.jpg` before I share it.Remove only GPS metadata from `../exif_mcp_server/examples/sample_images/gps-exif.jpg` and write it to `cleaned/gps-targeted-clean.jpg`.Preview only: remove the metadata from `../exif_mcp_server/examples/sample_images/basic-exif.jpg` without writing files.Prepare `../exif_mcp_server/examples/sample_images` for sharing and remove only GPS metadata.Prepare `../exif_mcp_server/examples/sample_images` for sharing.Find all images in `../exif_mcp_server/examples/sample_images` that still contain author or software metadata.Find all images in `../exif_mcp_server/examples/sample_images` that still contain GPS metadata.Find all images in `../exif_mcp_server/examples/sample_images` that contain all GPS metadata fields.
One practical session with the scaffold looks like this:
- review the folder
- preview one cleanup as a dry run
- run one targeted cleanup with an explicit output path
- verify with a follow-up audit
Example sequence:
Review `../exif_mcp_server/examples/sample_images` and tell me which photos are risky to share.
Preview only: remove the metadata from `../exif_mcp_server/examples/sample_images/basic-exif.jpg` without writing files.
Remove only GPS metadata from `../exif_mcp_server/examples/sample_images/gps-exif.jpg` and write it to `cleaned/gps-targeted-clean.jpg`.
Find all images in `../exif_mcp_server/examples/sample_images` that still contain GPS metadata.
- no multi-agent orchestration
- no long-term memory store
- no cloud deployment layer
- no long-lived async session manager yet
The first version is about building a clear agent loop, not a large platform.
MIT