Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ node_modules
/.svelte-kit
/build

# Python
**/.venv/
**/__pycache__/
**/.pytest_cache/
*.py[cod]

# OS
.DS_Store
Thumbs.db
Expand Down
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ bun.lockb
/.agents/
/.claude/skills/
skills-lock.json
/mcp/.venv/
/mcp/.pytest_cache/

# SpecSwarm planning artifacts (generated by /ss:* commands, not hand-edited source)
/.specswarm/
Expand Down
4 changes: 4 additions & 0 deletions mcp/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.venv
__pycache__/
.pytest_cache/
.env
17 changes: 17 additions & 0 deletions mcp/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Hosted Supabase project URL, without a trailing slash.
SUPABASE_URL=https://your-project.supabase.co
# Public browser-safe key. Never put a secret or service-role key in this file.
SUPABASE_PUBLISHABLE_KEY=sb_publishable_replace_me
# Public origin of this MCP service, without a trailing slash.
MCP_BASE_URL=https://mcp.example.com

# Use "supabase" for hosted projects and current local stacks. "jwt-hs256" supports
# legacy local stacks that still issue HS256 access tokens.
MCP_AUTH_MODE=supabase
# Asymmetric signing algorithm configured by the hosted Supabase project.
SUPABASE_JWT_ALGORITHM=ES256
# Required only for MCP_AUTH_MODE=jwt-hs256; obtain it from the legacy stack's status output.
SUPABASE_JWT_SECRET=

MCP_HOST=0.0.0.0
MCP_PORT=8000
24 changes: 24 additions & 0 deletions mcp/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
FROM astral/uv:0.12.13-python3.13-trixie-slim AS build
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never
WORKDIR /app

# Dependencies first: this layer is cached until pyproject.toml/uv.lock change.
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev

COPY pyproject.toml uv.lock ./
COPY src ./src
RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked --no-dev

FROM python:3.13-slim-trixie
RUN useradd --create-home --uid 10001 app
WORKDIR /app
COPY --from=build --chown=app:app /app /app
ENV PATH="/app/.venv/bin:$PATH"
USER app
EXPOSE 8000
CMD ["nexus-mcp"]
69 changes: 69 additions & 0 deletions mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Nexus Kitchen MCP connector

FastMCP 4 service for saving chat-extracted recipes and shopping lists into the signed-in user's Nexus Kitchen account. Each request uses the caller's Supabase access token against PostgREST, so the existing row-level security policies remain the authorization boundary. No secret or service-role key is deployed.

## Supabase OAuth server

Configure the hosted project in the Supabase dashboard:

1. Open **Authentication → OAuth Server** and enable the OAuth server.
2. Set **Authorization Path** to `/oauth/consent`.
3. Enable dynamic client registration.
4. Open **Authentication → URL Configuration** and set **Site URL** to the deployed app origin, for example `https://kitchen.example.com`.

The repository's `supabase/config.toml` contains the equivalent local configuration. The feature requires Supabase CLI 2.54.11 or newer. Restart local Supabase after auth configuration changes with `supabase stop && supabase start`; a plain start does not reload them.

## Configure the service

```sh
cd mcp
cp .env.example .env
```

Set `SUPABASE_URL`, the public `SUPABASE_PUBLISHABLE_KEY`, and the public `MCP_BASE_URL`. Hosted projects and current local stacks use `MCP_AUTH_MODE=supabase` with the project's asymmetric JWT algorithm. `MCP_AUTH_MODE=jwt-hs256` and `SUPABASE_JWT_SECRET` remain available only for legacy local stacks that still issue HS256 access tokens.

`SUPABASE_PUBLISHABLE_KEY` is intentionally public. Do not put a Supabase secret key, legacy service-role key, or any other privileged credential in this service.

## Run with Docker

```sh
cd mcp
docker compose up -d --build
```

The container listens through `127.0.0.1:8787`; expose it with Caddy:

```caddyfile
mcp.example.com {
reverse_proxy 127.0.0.1:8787
}
```

FastMCP serves `/.well-known/oauth-protected-resource*` on the MCP origin, so Caddy needs no additional routes or rewrites.

## Add a connector

- **Claude:** Settings → Connectors → Add custom connector → `https://mcp.example.com/mcp`
- **ChatGPT:** Settings → Connectors → add `https://mcp.example.com/mcp`

Both clients register through Supabase dynamic client registration; nothing is pre-registered.

## Local development

```sh
uv lock
uv sync
uv run pytest
MCP_AUTH_MODE=supabase \
SUPABASE_JWT_ALGORITHM=ES256 \
SUPABASE_URL=http://127.0.0.1:56321 \
SUPABASE_PUBLISHABLE_KEY="$PUBLISHABLE_KEY" \
MCP_BASE_URL=http://127.0.0.1:8000 \
uv run nexus-mcp
```

With a user access token, run the end-to-end harness:

```sh
uv run python scripts/smoke.py "$TOKEN"
```
17 changes: 17 additions & 0 deletions mcp/compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
services:
nexus-mcp:
build: .
env_file: .env
ports:
- '127.0.0.1:8787:8000'
restart: unless-stopped
healthcheck:
test:
- CMD
- python
- -c
- "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health')"
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
30 changes: 30 additions & 0 deletions mcp/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
[project]
name = "nexus-kitchen-mcp"
version = "0.1.0"
description = "Remote MCP connector for Nexus Kitchen"
requires-python = ">=3.13"
dependencies = [
"fastmcp>=4,<5",
"httpx>=0.28",
"pydantic>=2.9",
]

[project.scripts]
nexus-mcp = "nexus_mcp.server:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/nexus_mcp"]

[dependency-groups]
dev = [
"pytest>=8",
"pytest-asyncio>=0.24",
]

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
96 changes: 96 additions & 0 deletions mcp/scripts/smoke.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import asyncio
import json
import sys
from typing import Any

from fastmcp import Client
from fastmcp.client.auth import BearerAuth


async def call(client: Client, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
result = await client.call_tool(name, arguments)
if result.is_error:
raise RuntimeError(f"{name} failed: {result.content}")
if not isinstance(result.data, dict):
raise RuntimeError(f"{name} returned an unexpected result: {result.data!r}")
print(f"{name}: {json.dumps(result.data, sort_keys=True)}")
return result.data


async def main(token: str) -> None:
async with Client(
"http://127.0.0.1:8000/mcp",
auth=BearerAuth(token),
) as client:
await call(
client,
"save_recipe",
{
"title": "Weeknight Pancakes",
"servings": 4,
"ingredients": [
{"name": "plain flour", "quantity": 2, "unit": "cup"},
{"name": "eggs", "quantity": 3, "unit": "x"},
{"name": "milk", "quantity": 1.5, "unit": "cup"},
],
"steps": [
{"instruction": "Whisk the ingredients into a smooth batter."},
{"instruction": "Cook spoonfuls in a warm pan until golden."},
],
},
)
created = await call(
client,
"create_shopping_list",
{
"name": "From chat",
"items": [
{
"name": "Flour",
"quantity": 2,
"unit": "cup",
"needed_for": "Weeknight Pancakes",
},
{
"name": "flour",
"quantity": 1,
"unit": "cup",
"needed_for": "Weeknight Pancakes",
},
{
"name": "eggs",
"quantity": 3,
"unit": "x",
"needed_for": "Weeknight Pancakes",
},
],
},
)
await call(client, "list_shopping_lists", {})
await call(
client,
"add_shopping_items",
{
"list_id": created["list_id"],
"items": [
{
"name": "flour",
"quantity": 1,
"unit": "cup",
"needed_for": "Weeknight Pancakes",
},
{
"name": "butter",
"quantity": 200,
"unit": "g",
"needed_for": "Weeknight Pancakes",
},
],
},
)


if __name__ == "__main__":
if len(sys.argv) != 2:
raise SystemExit("Usage: uv run python scripts/smoke.py <access-token>")
asyncio.run(main(sys.argv[1]))
3 changes: 3 additions & 0 deletions mcp/src/nexus_mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Nexus Kitchen MCP connector."""

__version__ = "0.1.0"
5 changes: 5 additions & 0 deletions mcp/src/nexus_mcp/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from nexus_mcp.server import main


if __name__ == "__main__":
main()
22 changes: 22 additions & 0 deletions mcp/src/nexus_mcp/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from fastmcp.server.auth import AuthProvider
from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.server.auth.providers.supabase import SupabaseProvider

from nexus_mcp.settings import Settings


def build_auth(settings: Settings) -> AuthProvider:
if settings.auth_mode == "jwt-hs256":
if settings.jwt_secret is None:
raise RuntimeError("SUPABASE_JWT_SECRET is required when MCP_AUTH_MODE=jwt-hs256")
return JWTVerifier(
public_key=settings.jwt_secret,
issuer=f"{settings.supabase_url}/auth/v1",
algorithm="HS256",
base_url=settings.base_url,
)
return SupabaseProvider(
project_url=settings.supabase_url,
base_url=settings.base_url,
algorithm=settings.jwt_algorithm,
)
Loading
Loading