Skip to main content
Glama
thenobody0817

Off Shoring MCP

README.md
# Off Shoring MCP

A **story-state MCP server**: it stores persistent fictional scene state —
stories, character assignments, branches, turns and editable summaries — and
exposes it through a small, validated set of MCP tools.

It is deliberately *not* a storyteller:

- it has **no model and no inference backend**;
- it **cannot see the conversation** it is being used in — it only sees the
  arguments a client actually sends to a tool;
- it **writes nothing unless a write tool is called**, and it never treats
  anything spoken only in the chat as saved.

The conversation happens in the MCP client. This server is the store behind
it, not the voice in it.

## Features

- **Ten MCP tools** for listing, creating, reading, updating, branching and
  exporting stories — all of them validated, all of them revision-checked.
- **Branching**: fork a story at a turn into a new branch without touching
  the original; a branch sees its ancestors' turns only up to its fork point.
- **Optimistic concurrency**: every mutation takes an `expected_revision`, so a
  stale write is refused instead of clobbering newer state.
- **Idempotency**: `append_turn` requires an `idempotency_key`, so a retry
  cannot duplicate a turn.
- **Canon rules**: only `status="completed"` turns count as settled story;
  drafts, cancellations and failures are stored but never treated as canon.
- **Editable summaries** that are derived context and never replace the
  original turns.
- **Structured tool errors** with stable codes, so a client can react instead
  of guessing.
- **A real HTTP authorization boundary**: the server is an OAuth 2.1 *resource
  server* that verifies bearer tokens (local JWKS or RFC 7662 introspection).
  It fails closed — there is no development bypass.
- **An unauthenticated `/healthz`** route that reports only whether
  authorization is configured.
- **A separate local relay** (`off_shoring_mcp.relay`) for passing explicit
  instructions and results between local agents over stdio.

## Requirements

- **Python 3.10** or newer (developed on 3.14).
- Linux, macOS or Windows — anything with SQLite, which ships with Python.
- No network access is required to run or to test: the auth tests use local
  loopback stand-ins.

Runtime dependencies are pinned in `requirements.txt`; the test dependencies
add `pytest` and `anyio` in `requirements-dev.txt`.

## Install

```sh
git clone <this-repository> off-shoring-mcp
cd off-shoring-mcp
python3 -m venv .venv
.venv/bin/pip install -r requirements-dev.txt   # runtime + test dependencies
```

The package is run **from source** — there is no build or install step. Put
`src/` on the path when running:

```sh
PYTHONPATH=src .venv/bin/python -m off_shoring_mcp --status
```

## Usage

```sh
# See the resolved configuration and exit (no database is created):
PYTHONPATH=src .venv/bin/python -m off_shoring_mcp --status

# Serve over Streamable HTTP on loopback (default) at http://127.0.0.1:8787/mcp:
PYTHONPATH=src .venv/bin/python -m off_shoring_mcp \
    --transport streamable-http --host 127.0.0.1 --port 8787 --path /mcp \
    --db var/off-shoring.sqlite3

# Seed the configuration from a file (values already in the environment win):
PYTHONPATH=src .venv/bin/python -m off_shoring_mcp --env-file config/off-shoring.env
```

Stop the server with `Ctrl-C`. The HTTP transport binds `127.0.0.1` by default
and refuses to bind a routable address while no authorization server is
configured.

The stdio transport has no network boundary at all, so it only starts when you
acknowledge that explicitly:

```sh
PYTHONPATH=src .venv/bin/python -m off_shoring_mcp --transport stdio --allow-local-stdio
```

### Connecting a client

Two connection shapes are possible:

- **Streamable HTTP (recommended):** point the MCP client at
  `http://127.0.0.1:8787/mcp` and send a bearer access token issued for this
  resource server (see *Authorization*). This is the only transport with an
  authentication boundary.
- **stdio:** configure the client to launch the process itself with
  `--transport stdio --allow-local-stdio`. Appropriate only when the client
  owns the process; there is no token check on that path.

### A suggested working loop

The server can describe this itself through its `offshoring://guide` resource
and its prompt:

1. `list_stories`, then `create_story` if there is nothing to continue.
2. `get_story_context` to load the active branch, the stored assignments
   (`user_role` is the character the human plays, `assistant_role` the one the
   model plays), the setup, the profiles, the summaries and a bounded window
   of turns.
3. Continue the scene **in the conversation**.
4. `append_turn` the turns that were actually settled, with
   `status="completed"` and a fresh `idempotency_key`. Say something is saved
   only after the tool confirms it.
5. To revise instead of overwrite, `branch_story` at the intended turn and
   save the alternate text there. The original branch is left untouched.
6. `export_story` when a readable copy is wanted; `delete_story` only with
   `confirm=true` and the current revision.

**Honesty note:** the server does not know whether a client is actually
connected, and it cannot observe the live conversation. A "saved" turn exists
only after a successful `append_turn` call returns its turn id.

## Tools

| Tool | Purpose |
| --- | --- |
| `list_stories` | Compact summaries of stored stories, newest update first. |
| `create_story` | Create a story with setup, assignments and profiles. |
| `get_story_context` | Active branch, assignments, profiles, summaries and a bounded turn window. |
| `read_turns` | One bounded page of turns from a specific branch. |
| `update_story` | Change title, setup, assignments, profiles or summaries. |
| `append_turn` | Save one explicit turn (the only way dialogue is written). |
| `branch_story` | Fork at a turn into a new branch; never erases the original. |
| `select_branch` | Persist which branch is active. |
| `export_story` | Render the story as JSON or Markdown, returned inline. |
| `delete_story` | Delete a story's local records (needs `confirm=true`). |

Design rules that the tools enforce:

- Every mutation takes an `expected_revision`; a stale write is refused with a
  `stale_revision` error instead of clobbering newer state.
- Every `append_turn` takes an `idempotency_key`, so a retry cannot duplicate
  a turn; reusing a key with different arguments is refused.
- Only `status="completed"` turns are canon. `draft`, `cancelled` and `failed`
  turns are stored but never treated as settled story.
- A branch sees its ancestors' turns only up to its fork point, plus its own.
  Sibling branches never leak into each other.
- Summaries are derived and editable; they never replace the original turns.
- Stored text, **including anything written as an instruction to the model**,
  is fictional story content, never configuration. There is no content
  blacklist.

Failures are returned as structured tool errors so a client can react:

```json
{"ok": false, "error": {"code": "stale_revision", "message": "...", "details": {}}}
```

Codes: `story_not_found`, `branch_not_found`, `turn_not_found`,
`stale_revision`, `invalid_fork_point`, `invalid_argument`, `input_too_large`,
`idempotency_conflict`, `confirmation_required`, `export_too_large`.

## Resources and prompt

- `offshoring://guide` — the usage guidance the server sends as instructions.
- `offshoring://story/{story_id}/export.json` and `.../export.md` — the same
  documents `export_story` produces, as resources.
- Prompt `offshoring_scene_turn` — guidance for continuing the stored scene.
  It is guidance only and calls no tool by itself.

## Authorization

Off Shoring is an OAuth 2.1 **resource server**: it *verifies* bearer tokens.
It is **not** a login service and **not** a token issuer. Something else — an
authorization server / identity provider you choose — issues the tokens this
code validates.

The boundary is always on and fails closed:

- Every MCP call over HTTP requires a valid bearer token. There is no
  development bypass and no unauthenticated fallback.
- With no usable auth configuration the server still starts, advertises an
  obvious placeholder issuer, and rejects every protected call with `401`.
- Tokens are checked for signature (or introspection status), expiry, issuer,
  audience/resource and required scopes. The audience is enforced inside the
  verifier for both verification modes.
- An unauthenticated `GET /healthz` reports only whether auth is configured.
  The protected-resource metadata document is served at
  `/.well-known/oauth-protected-resource/mcp`.

Two verification modes are implemented; configure exactly one:

- **JWKS (local):** set one of `OFFSHORING_MCP_JWKS_URI`,
  `OFFSHORING_MCP_JWKS_FILE` or `OFFSHORING_MCP_JWKS_JSON`. The signature is
  verified locally; the `none` algorithm and the symmetric `HS*` family are
  refused.
- **Introspection (RFC 7662):** set
  `OFFSHORING_MCP_INTROSPECTION_ENDPOINT` (and optional client credentials).
  The issuer verifies the signature; this server still enforces expiry,
  issuer, audience and scope from the response.

## Configuration

All configuration comes from `OFFSHORING_MCP_*` environment variables. See
[`config/off-shoring.example.env`](config/off-shoring.example.env) for a blank
template; `--env-file` seeds the variables from a file without overwriting
values that are already in the environment.

| Variable | Default | Meaning |
| --- | --- | --- |
| `OFFSHORING_MCP_DB_PATH` | `var/off-shoring.sqlite3` | Where the local story database lives. |
| `OFFSHORING_MCP_RESOURCE_URL` | `http://127.0.0.1:8787/mcp` | This resource server's own URL; the default audience. |
| `OFFSHORING_MCP_ISSUER_URL` | — | Issuer identifier of the authorization server; must match the token's `iss` claim. |
| `OFFSHORING_MCP_JWKS_URI` | — | JWKS endpoint for local signature verification. |
| `OFFSHORING_MCP_JWKS_FILE` | — | Path to a JWKS file for local signature verification. |
| `OFFSHORING_MCP_JWKS_JSON` | — | Inline JWKS document. |
| `OFFSHORING_MCP_INTROSPECTION_ENDPOINT` | — | RFC 7662 introspection endpoint. |
| `OFFSHORING_MCP_INTROSPECTION_CLIENT_ID` | — | Client id for introspection. |
| `OFFSHORING_MCP_INTROSPECTION_CLIENT_SECRET` | — | Client secret for introspection. |
| `OFFSHORING_MCP_AUDIENCE` | resource URL | Expected audience of the access token. |
| `OFFSHORING_MCP_REQUIRED_SCOPES` | — | Scopes every access token must hold (space- or comma-separated). |
| `OFFSHORING_MCP_ALLOWED_ALGORITHMS` | `RS256,RS384,…,EdDSA` | Accepted signature algorithms. Never add `none` or an `HS*` algorithm. |
| `OFFSHORING_MCP_LOG_LEVEL` | `INFO` | `debug`, `info`, `warning` or `error`. |
| `OFFSHORING_MCP_MAX_REQUEST_BODY_BYTES` | `1000000` | Maximum accepted HTTP request body. |

Input bounds are fixed in code (`src/off_shoring_mcp/config.py`): 500 characters
for a title, 20 000 for a setup or a profile description, 100 000 for a turn,
50 000 for a summary, 100 turns per page and a 1 MB request body.

## Data and privacy

- All state lives in one local SQLite database (default
  `var/off-shoring.sqlite3`, git-ignored).
- Exports are returned **inline in the tool result**. The tools accept no
  filesystem path and expose no server-local path; an export is not a
  downloadable file.
- Exports and stored turns can contain private fictional dialogue; treat the
  returned text and the database as sensitive.
- `delete_story` removes a story's branches, turns, profiles, summaries and
  write records. It cannot be undone.

## The local handoff relay

`off_shoring_mcp.relay` is a separate, local-only MCP server for passing
explicit instructions and results between agents that can launch a stdio MCP
server. It does not change the story server and never opens a network port. It
uses the existing Python environment and stores its queue under
`$XDG_STATE_HOME/off-shoring-relay/handoffs.sqlite3` (or
`~/.local/state/off-shoring-relay/handoffs.sqlite3`) with private directory and
database permissions. Handoff text can be sensitive; do not put credentials or
secrets in it.

Run it locally with:

```sh
PYTHONPATH=src .venv/bin/python -m off_shoring_mcp.relay
```

Its tools are `submit_handoff`, read-only `list_handoffs`,
`claim_next_handoff`, `complete_handoff`, `read_handoff`, and read-only
`clipboard_status` (reports the host clipboard byte count and MIME types,
never its text). A first queue status check does not create the database. A
consumer gets a five-minute claim lease; if it exits, another consumer can
reclaim the task.

Ready-to-review, project-scoped client examples are in
[`config/empryo.project.example.json`](config/empryo.project.example.json) and
[`config/opencode.project.example.json`](config/opencode.project.example.json).
They are inert templates: replace the placeholder path with the absolute path
to your checkout, then copy them to the client's own project configuration to
make the relay start when that project opens. This project never changes
application configuration on its own.

## Development

```
src/off_shoring_mcp/
├── __main__.py     CLI: transports, --status, safety refusals
├── config.py       environment configuration, input bounds
├── db.py           SQLite connections, transactions, migrations
├── migrations/     001_init.sql schema
├── models.py       typed tool inputs and outputs
├── errors.py       structured tool error codes
├── store.py        domain logic: revisions, idempotency, lineage, pagination
├── export.py       Markdown renderer
├── auth.py         bearer-token verifiers (JWKS and introspection)
├── server.py       tools, resources, prompt, /healthz
└── relay.py        local stdio MCP handoff queue, separate from story storage
tests/              store, protocol, HTTP auth, verifier, config, CLI, relay tests
config/             example env file and client configuration templates
```

### Tests

```sh
PYTHONPATH=src:. .venv/bin/python -m pytest
```

The suite covers the store directly, the tools and resources over the real MCP
client protocol (in-process transport), the HTTP authorization boundary
against a real loopback server, the token verifiers claim by claim, the CLI and
the relay. The in-process and stdio transports have **no HTTP authentication
boundary**, so they are not auth tests: authentication is tested only over real
HTTP, with test-only signing keys generated under `tests/`.

## Limitations and known issues

- **No model, no inference, no story generation.** The server stores; it does
  not write.
- **No observation of the live conversation.** State changes only when a tool
  is called.
- **No public hosting, tunnels or non-loopback listener** is set up or
  supported here. A routable bind requires a configured authorization server.
- **No multi-user accounts.** The boundary authenticates a token, not a person,
  and the database is a single local store.
- **No HTTPS.** Anything beyond loopback needs TLS in front of the server.
- **No token issuance.** You must bring your own identity provider; the server
  only verifies.
- **No content filtering.** Stored text is never inspected or moderated — by
  design, since it is fiction.
- The relay only exchanges text between local clients. It does not connect to
  any remote service, and it has not been validated against a live third-party
  deployment.

## Acknowledgements

- The [Model Context Protocol](https://modelcontextprotocol.io/) and the
  `mcp` Python SDK, which provide the server, tools, resources and the
  transports used here.
- [PyJWT](https://pyjwt.readthedocs.io/) for JWT signature verification and
  [httpx](https://www.python-httpx.org/) for RFC 7662 introspection.
- [Pydantic](https://docs.pydantic.dev/) for the typed tool inputs and
  outputs, via the MCP SDK.
- SQLite, which ships with Python.

## License

MIT — see [LICENSE](LICENSE).

Copyright (c) 2026 the nobody.

Maintenance

ActivityMaintained
ResponsivenessNo issues