Skip to main content
Glama
mgnlia

Shelf Life MCP

by mgnlia
README.md
# Shelf Life MCP

[![CI](https://github.com/mgnlia/shelf-life-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/mgnlia/shelf-life-mcp/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

A self-hosted **Model Context Protocol (MCP) server** that tracks household
shelf life — what is in the fridge, what is about to expire, and what to cook
first. Built as an **Alexa+ Agent Skill** entry, it speaks the **open MCP spec
(2025-11-25+)** over **Streamable HTTP** and persists everything in a **local
SQLite** file. No cloud dependency, no partner-only SDK.

> **License:** MIT — see [LICENSE](LICENSE) at the top of the repo.

---

## Why this exists

Alexa+ "Agent Skills" are MCP servers. This project implements that contract
against the *open* specification so it can be demoed with the **MCP Inspector**
or any generic MCP client, rather than depending on Amazon's partner-only
Category SDK / MCP Toolkit.

## Language choice

**Python 3.10+ with `uv`.** Rationale:

- The official `mcp` Python SDK (v2) ships a first-class `MCPServer` class and a
  `Client` that can connect **in memory**, which makes a passing test per tool
  trivial and hermetic — no subprocess, no port, no flaky network.
- `uv` gives reproducible, fast dependency resolution and a lockfile.
- SQLite is in the standard library, so persistence adds zero runtime deps.

## Features

| Tool | Purpose |
|------|---------|
| `add_item` | Record a food item with an expiry date. |
| `list_items` | List items, filtered by `all` / `expiring` / `expired` / `consumed`. |
| `expiring_soon` | Items expiring within N days (default 7), soonest first. |
| `consume_item` | Reduce quantity; auto-marks consumed at zero. |
| `suggest_recipe` | Deterministic "use it up" suggestion; urgency or FIFO ranking. |

## Architecture

```
mcp.json ──► config.load_config() ──► MCPServer (server.py)
                                          │
                                          ├── add_item        ┐
                                          ├── list_items      │
                                          ├── expiring_soon   ├─► Store (store.py) ──► SQLite
                                          ├── consume_item    │
                                          └── suggest_recipe  ┘
                                          │
                                   mcp.run(transport="streamable-http", ...)
```

- **`mcp.json`** is a real MCP config document. It is **loaded in code** at
  import time (`server.py` → `load_config()`), and it drives the transport host,
  port, path, protocol version, and database location. It is not decorative.
- **`config.py`** validates the config with Pydantic and resolves paths.
- **`store.py`** owns all SQLite access (schema, queries, the recipe heuristic).
- **`server.py`** registers the five tools and derives `mcp.run()` kwargs from
  the loaded config.

## Setup

Requires Python 3.10+ and [`uv`](https://docs.astral.sh/uv/).

```bash
git clone https://github.com/mgnlia/shelf-life-mcp.git
cd shelf-life-mcp
uv sync --dev
```

## Run

Streamable HTTP (the transport declared in `mcp.json`):

```bash
uv run shelf-life-mcp
# or:
uv run python -m shelf_life_mcp
```

The server listens on `http://127.0.0.1:8000/mcp` (host/port/path come from
`mcp.json`). Override the config or database without editing files:

```bash
SHELF_LIFE_CONFIG=/path/to/mcp.json SHELF_LIFE_DB=/tmp/demo.db uv run shelf-life-mcp
```

### Try it with the MCP Inspector

```bash
npx @modelcontextprotocol/inspector
# Transport: Streamable HTTP
# URL:       http://127.0.0.1:8000/mcp
```

## MCP client configuration

Drop this into your MCP client's config (Claude Desktop, Cursor, VS Code, etc.).
The same document lives in [`mcp.json`](mcp.json) in this repo.

```json
{
  "mcpServers": {
    "shelf-life": {
      "type": "streamable-http",
      "url": "http://127.0.0.1:8000/mcp",
      "transport": { "host": "127.0.0.1", "port": 8000, "path": "/mcp" },
      "database": "./shelf_life.db",
      "protocolVersion": "2025-11-25"
    }
  }
}
```

## Tool schemas

Generated from the Python type hints; `output_schema` is derived from each
return annotation.

### `add_item`

```json
{
  "name": "add_item",
  "input_schema": {
    "type": "object",
    "properties": {
      "name": { "type": "string", "title": "Name" },
      "expires_on": { "type": "string", "title": "Expires On" },
      "qty": { "type": "number", "default": 1, "title": "Qty" },
      "unit": { "type": "string", "default": "item", "title": "Unit" }
    },
    "required": ["name", "expires_on"]
  }
}
```

### `list_items`

```json
{
  "name": "list_items",
  "input_schema": {
    "type": "object",
    "properties": {
      "filter": { "type": "string", "default": "all", "title": "Filter" }
    },
    "required": []
  }
}
```

`filter` ∈ `all` | `expiring` | `expired` | `consumed`.

### `expiring_soon`

```json
{
  "name": "expiring_soon",
  "input_schema": {
    "type": "object",
    "properties": { "days": { "type": "integer", "default": 7, "title": "Days" } },
    "required": []
  }
}
```

### `consume_item`

```json
{
  "name": "consume_item",
  "input_schema": {
    "type": "object",
    "properties": {
      "id": { "type": "integer", "title": "Id" },
      "qty": { "type": "number", "default": 1, "title": "Qty" }
    },
    "required": ["id"]
  }
}
```

### `suggest_recipe`

```json
{
  "name": "suggest_recipe",
  "input_schema": {
    "type": "object",
    "properties": {
      "use_first": { "type": "boolean", "default": true, "title": "Use First" }
    },
    "required": []
  }
}
```

`use_first` changes how the suggested ingredients are ranked:

- `use_first: true` (default) — rank by **urgency**: already-expired items
  first, then the soonest expiry, then name. The dish is named after the most
  urgent ingredient.
- `use_first: false` — ignore urgency and rank by **stock age** (FIFO): items
  added earliest come first, so the oldest stock is cleared before it spoils.
  The dish is named after the longest-held ingredient.

Both modes consider only unconsumed items expiring within 7 days and cap the
suggestion at three ingredients. When nothing is close to expiring, the tool
returns an empty `use_first` list and a "shop fresh" rationale.

## Tests

One passing test per tool, run through the in-memory MCP client against the real
registered tools:

```bash
uv run pytest
```

Covered: `add_item`, `list_items`, `expiring_soon`, `consume_item`,
`suggest_recipe` (both `use_first` modes), plus tool-registration and
config/edge-case tests.

## Project layout

```
shelf-life-mcp/
├── mcp.json                 # MCP config, loaded in code
├── pyproject.toml
├── src/shelf_life_mcp/
│   ├── config.py            # MCP config loading + validation
│   ├── store.py             # SQLite persistence + recipe heuristic
│   ├── server.py            # MCPServer, tools, transport
│   └── __main__.py
└── tests/
    ├── conftest.py
    ├── test_tools.py        # one test per tool
    └── test_config.py
```

## License

MIT © 2026 Shelf Life MCP contributors.