Skip to main content
Glama
EezyMAcc

Tattoo Feed

by EezyMAcc
README.md
# Tattoo Feed

An **MCP (Model Context Protocol) server** that lets an LLM client (e.g. Claude
Desktop) browse and curate posts from a hand-picked list of Instagram tattoo
artists, via Instagram's **Business Discovery** API.

You point it at the artists you follow, and from your chat client you can pull a
merged feed, discover one post at a time, bookmark the ones you like, and record
notes about your taste so a future session remembers them.

---

## Architecture

A deliberate two-layer split so a future GUI can reuse the logic without a
rewrite:

- **`core`** (`src/tattoo_feed/` excluding `server/`) — all real logic: domain
  models, typed errors, JSON-file repositories, the Graph API client, image
  processing, and the services that orchestrate them. Knows nothing about MCP.
- **`server`** (`src/tattoo_feed/server/`) — a thin [FastMCP](https://github.com/modelcontextprotocol/python-sdk)
  adapter that exposes `core` as MCP tools. Holds no business logic.

```
src/tattoo_feed/
  config.py            # lazy env config (IG_ACCESS_TOKEN, IG_USER_ID)
  models.py            # Pydantic v2 value objects
  errors.py            # typed error hierarchy
  imaging.py           # preview downscale + EXIF strip
  repositories/        # Repository ABC + JSON-file stores (atomic writes)
  graph/client.py      # Business Discovery client
  services/            # FeedService, ArtistService, InspirationService, PreferenceService
  server/app.py        # FastMCP tools + stdio entrypoint
```

---

## Setup

Requirements: Python 3.12 and [uv](https://docs.astral.sh/uv/).

```bash
uv sync                       # create the venv and install pinned deps
cp .env.example .env          # then edit .env with your real credentials
```

### Environment variables

| Variable           | Meaning                                                        |
|--------------------|----------------------------------------------------------------|
| `IG_ACCESS_TOKEN`  | A long-lived Instagram Graph API access token.                 |
| `IG_USER_ID`       | The Instagram Business/Creator account id that owns the token. |
| `TATTOO_FEED_DATA_DIR` | Optional. Where the JSON stores live (default `./data`).   |

`.env` is gitignored and must never be committed. Only `.env.example` (with
placeholders) is in the repo.

Getting credentials is a one-time manual step on Meta's side: create a Meta app,
connect an Instagram Business/Creator account, and mint a **long-lived** access
token with Business Discovery permission. Both the querying account and the
artists you look up must be professional accounts.

---

## Running

### Locally (stdio)

```bash
uv run python -m tattoo_feed.server.app
```

The server speaks the MCP stdio protocol, so you normally don't run it by hand —
you register it with an MCP client. For Claude Desktop, add to its config:

```json
{
  "mcpServers": {
    "tattoo-feed": {
      "command": "uv",
      "args": ["run", "python", "-m", "tattoo_feed.server.app"],
      "cwd": "/absolute/path/to/tattoo-feed",
      "env": {
        "IG_ACCESS_TOKEN": "your-token",
        "IG_USER_ID": "your-business-user-id"
      }
    }
  }
}
```

### In Docker

A dev image (`Dockerfile`) bundles Python, `uv`, Node, and the toolchain. Build
and open a shell with **only this folder** mounted to `/workspace`:

```bash
docker build -t tattoo-feed-dev .
./run-loop.sh        # mounts $PWD to /workspace, nothing else on your machine
```

Inside the container you have the full gate and can run the server exactly as
above. The volume mount means anything written under `/workspace` lands back in
this folder on your host.

---

## The tools (MCP surface)

| Tool | What it does |
|------|--------------|
| `list_artists` | List tracked artists. |
| `add_artist(handle)` | Validate the handle is a reachable **professional** account, then track it. |
| `remove_artist(handle)` | Stop tracking a handle. |
| `get_feed(limit_per_artist=10)` | Merged, newest-first feed. Metadata + permalinks only (no images). |
| `next_inspiration()` | One **not-yet-seen** post, marked seen, with a rendered preview image. |
| `save_to_inspiration(post_id, notes=None)` | Bookmark a post into the saved collection. |
| `list_inspiration()` | The saved collection, in save order. |
| `remove_from_inspiration(post_id)` | Remove a saved item. |
| `reset_seen()` | Clear the seen-set so inspiration starts fresh. |
| `record_preference(observation)` | Persist a taste note (**propose-then-confirm**, see below). |
| `get_preference_summary()` | All recorded preferences, to reload taste in a fresh session. |

---

## Design decisions

- **Two-layer split (core / server).** MCP concepts never leak into `core`;
  business logic never leaks into `server`. This is what makes a phase-2 GUI a
  bolt-on rather than a rewrite.
- **JSON-file persistence behind a `Repository` interface.** Simple, inspectable,
  and swappable. Writes are **atomic** (temp file + `os.replace`) so a crash
  mid-write can never corrupt a store.
- **Pydantic v2 frozen models** for everything crossing a boundary, so external
  data is validated once and treated as immutable values thereafter.
- **Typed error hierarchy** (`TattooFeedError` and friends). Every external
  failure maps to a typed error; nothing raises bare exceptions across a
  boundary, so the client always gets a readable message instead of a stack
  trace.
- **Lazy credentials.** The server boots and lists its tools with no network and
  no real credentials; the token is only read when a tool actually calls
  Instagram.
- **Hermetic tests.** All Instagram HTTP is mocked with `respx`; there are zero
  live network calls in the test suite. (`mypy --strict`, `ruff`, and a 90%
  coverage floor are enforced.)
- **Images only where they earn their context.** Only `next_inspiration` returns
  a rendered image — the one-at-a-time conversational moment. `get_feed` stays
  metadata-only to keep the context window light.

---

## Limitations (by design)

- **No video.** Video posts are filtered out entirely at the Graph-client layer
  and never enter the feed, inspiration, or stores.
- **Carousels show the first image only.** Multi-image expansion is out of scope.
- **Manual token refresh.** There is no automatic token refresh. When the token
  expires, tools fail with a clear `TokenExpiredError` telling you to mint a new
  long-lived token and update `IG_ACCESS_TOKEN`.
- **Preview sizing is fixed.** Previews are capped at **640px on the long edge**,
  aspect ratio preserved, never upscaled, re-encoded as **JPEG quality 85**.
- **`record_preference` is propose-then-confirm.** The tool persists whatever it
  is given; the *discipline* of proposing the observation to you and getting your
  explicit confirmation **before** the tool is called lives in the tool's
  description, so the calling assistant honours it.
- **No write access to Instagram.** No posting, commenting, or messaging — this
  is strictly read-and-curate.

---

## Attribution & copyright

Posts belong to the artists who made them. This tool is for **personal
discovery and curation**, not redistribution:

- Previews are **downscaled** copies (≤640px, EXIF stripped), not full-resolution
  downloads.
- Every image and saved item carries the artist's **handle** and the post's
  **permalink**, so attribution travels with the content and you can always open
  the original on Instagram.
- Respect each artist's rights: don't repost or reuse their work without
  permission.

---

## Development

The full gate (must all exit 0):

```bash
uv run ruff format --check .
uv run ruff check .
uv run mypy --strict src
uv run pytest -q --cov=src/tattoo_feed --cov-report=term-missing --cov-fail-under=90
```

## License

MIT — see [`LICENSE`](LICENSE).

TDQS

A3.7/5.0

Scored across 11 tools

Disambiguation4/5

Tools generally have distinct purposes, but 'get_feed' and 'next_inspiration' both involve browsing posts from tracked artists, with 'next_inspiration' showing one unseen post at a time while 'get_feed' returns all recent posts. This could confuse an agent about which to use for browsing.

Naming Consistency5/5

All tool names follow a consistent verb_noun snake_case pattern, such as 'add_artist', 'list_inspiration', 'save_to_inspiration'. Even 'next_inspiration' with a non-standard verb is still in snake_case and fits the pattern.

Tool Count5/5

With 11 tools, the server provides a well-scoped set for tracking artists, managing inspiration, and recording preferences. Each tool has a clear role without redundancy or excessive overlap.

Completeness4/5

The tool set covers core workflows: artist management (add/list/remove), feed browsing, inspiration management, and preference recording. However, there is no tool to delete or update a recorded preference, which is a minor gap.

Maintenance

ActivityInactive
ResponsivenessNo issues