Skip to content

nextframedev/exif_privacy_agent

Repository files navigation

EXIF Privacy Agent

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_agent

Before 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_server

After installation, you can run the agent either as the installed exif-privacy-agent command or with python -m exif_privacy_agent.agent.

Project Shape

The scaffold is split into a few small modules:

  • planner.py turns a plain-language request into a small plan
  • planner.py also defines the planner boundary, a rule-based planner, and a model-backed planner that validates structured candidate plans
  • react.py defines an optional ReAct-style loop scaffold and a scripted reasoning model that makes the iterative shape visible
  • policies.py decides whether a request is read-only or mutating
  • approvals.py handles approval checks before mutations
  • loop.py runs the observe, plan, act flow
  • clients.py defines the tool-client interface, a direct local adapter, a real stdio MCP client, and an HTTP MCP client
  • formatter.py turns structured results into user-facing summaries
  • llm/ contains provider scaffolding for OpenAI, Anthropic/Claude, and local structured-output models, plus a usable local HTTP client path

What This Scaffold Demonstrates

The goal is not to build a giant agent system on day one.

The goal is to show:

  1. how an agent reads intent
  2. how it chooses the next action
  3. how it asks for approval before mutations
  4. how it uses structured tool results
  5. how it summarizes outcomes in plain language

Two Loop Modes

The project now exposes two loop shapes:

  • bounded plan once, then execute a known workflow
  • react choose 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

Three Capability Paths

The project supports three ways to reach the EXIF capability layer:

  • mcp-stdio launches the EXIF MCP server as a real stdio subprocess and calls tools through the MCP SDK
  • mcp-http connects to a running streamable HTTP MCP server over HTTP through the MCP SDK
  • direct imports the shared EXIF core directly inside the same Python process

The planner layer has a similar split:

  • RuleBasedPlanner keeps the first version deterministic and easy to inspect
  • ModelBackedPlanner accepts 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:

  • ScriptedReactModel makes the iterative loop shape visible without needing a live model
  • ModelBackedReactModel gives 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.

Clear Boundary for a Model-Backed Planner

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.py
  • approvals.py
  • loop.py
  • formatter.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

Provider Scaffolding: OpenAI, Claude, and Local

The project now includes a provider scaffold under src/exif_privacy_agent/llm/:

  • openai_provider.py
  • anthropic_provider.py
  • local_provider.py
  • factory.py

Right now:

  • local has a usable runtime path
  • openai has a usable runtime path
  • anthropic has a usable runtime path

These wrappers all share the same idea:

  1. a provider-specific wrapper implements PlanningModel or ReactActionModel
  2. a thin client adapter implements StructuredOutputClient
  3. in react mode, the wrapper passes the request, validated plan, and observed history into the model input
  4. 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.

Using OpenAI

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?'

Using Anthropic or Claude

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.

Using The Local LLM Runtime

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

A Concrete Local Setup

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:

  1. start your local model server
  2. make sure it exposes an OpenAI-compatible HTTP API
  3. make sure the model name you plan to use is available there
  4. 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/models

If 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(),
    ),
)

Local Development

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/pytest

exif_mcp_server is a required companion install for all three capability paths:

  • mcp-stdio uses it to launch python -m exif_mcp_server.server
  • mcp-http uses it as a separately running streamable HTTP MCP server
  • direct uses it to import the shared EXIF core

Using The Real MCP Path

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.'

Using MCP Over HTTP

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-http

Then 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.

Using The Direct Local Adapter

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.'

Example Requests

  • 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.

A Good First Session

One practical session with the scaffold looks like this:

  1. review the folder
  2. preview one cleanup as a dry run
  3. run one targeted cleanup with an explicit output path
  4. 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.

Non-Goals For This Scaffold

  • 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.

License

MIT

Books by the Authors

QR code to our books on Amazon
Scan to check out our books on Amazon

About

A practical Python AI agent for reviewing image privacy risk, using EXIF metadata tools, approval gates, and optional MCP-backed capability access.

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages