Metadata-Version: 2.4
Name: easyapi-django
Version: 1.1.3
Summary: Zero-effort MCP server (and REST API) generator from Django models.
Author-email: Stamatios Stamou Jr <bushier.outsets.0c@icloud.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/ssjunior/easyapi-django
Project-URL: Bug Tracker, https://github.com/ssjunior/easyapi-django/issues
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Classifier: Framework :: Django
Classifier: Intended Audience :: Developers
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Django>=5.0
Requires-Dist: redis>=4.5
Requires-Dist: pandas
Requires-Dist: pytz
Requires-Dist: jsonschema>=4
Requires-Dist: pydantic>=2
Provides-Extra: schemas
Requires-Dist: pydantic>=2; extra == "schemas"
Provides-Extra: oauth
Provides-Extra: gen
Requires-Dist: jinja2>=3; extra == "gen"
Requires-Dist: PyYAML>=6; extra == "gen"
Provides-Extra: gen-postgres
Requires-Dist: jinja2>=3; extra == "gen-postgres"
Requires-Dist: PyYAML>=6; extra == "gen-postgres"
Requires-Dist: psycopg2-binary>=2.9; extra == "gen-postgres"
Provides-Extra: gen-mysql
Requires-Dist: jinja2>=3; extra == "gen-mysql"
Requires-Dist: PyYAML>=6; extra == "gen-mysql"
Requires-Dist: PyMySQL>=1.1; extra == "gen-mysql"
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
Requires-Dist: fakeredis>=2.20; extra == "dev"
Requires-Dist: jinja2>=3; extra == "dev"
Requires-Dist: PyYAML>=6; extra == "dev"
Dynamic: license-file

# easyapi

A framework I built for myself. It gives you a production MCP server **and** a REST API on top of Django models, sharing the same auth, rate limit, and tenant routing. REST and MCP each get a dedicated, well-shaped surface — they're no longer welded together. If you don't have Django, `easyapi init` reads a MySQL or Postgres schema and generates the whole project.

I run it in six of my own products. Sharing it because I'd like help making it better — issues, PRs, and "this broke for me" reports are all welcome.

## Install

```bash
pip install easyapi-django                   # framework only
pip install 'easyapi-django[gen-mysql]'      # + generator for MySQL
pip install 'easyapi-django[gen-postgres]'   # + generator for Postgres
```

> The PyPI distribution is `easyapi-django`. Imports use `from easyapi
> import ...`. The CLI is `easyapi`.

---

## What it looks like

REST resources are one class. MCP toolsets are another. They share auth, rate limiting and tenant routing through a common `CoreResource` base.

**REST** — `BaseResource` exposes a Django model as a CRUD endpoint:

```python
from easyapi import BaseResource
from myapp.models import Space

class SpaceResource(BaseResource):
    model = Space
```

That class gives you:

- REST endpoints (`GET`, `POST`, `PATCH`, `DELETE`) with pagination, filters, search, ordering.
- OpenAPI 3.0.3 spec at `/openapi.json` and an interactive docs page at `/docs`.
- Async dispatch, session + API-key + Bearer auth, per-IP rate limit, scanner blocking, multi-tenant DB routing.

**MCP** — `Tools` collects intent-named methods into an MCP tool registry. Type hints become JSON Schema (Draft 2020-12); docstrings become tool descriptions for the agent:

```python
from typing import Literal
from pydantic import BaseModel
from easyapi import Tools, tool

class OrderOut(BaseModel):
    id: int
    status: str

class Orders(Tools):
    scope = "orders:read"

    async def find(
        self,
        status: Literal["open", "closed"] | None = None,
        limit: int = 50,
    ) -> list[OrderOut]:
        """List the caller's orders, optionally filtered by status."""
        qs = Order.objects.filter(owner_id=self.user_id)
        if status:
            qs = qs.filter(status=status)
        return [OrderOut.from_orm(o) async for o in qs[:limit]]

    @tool(scope="orders:write", destructive=True, rate_limit="10/m")
    async def cancel(self, order_id: int, reason: str) -> OrderOut:
        """Cancel an order and refund the original payment method."""
        order = await Order.objects.aget(pk=order_id)
        await order.cancel(reason)
        return OrderOut.from_orm(order)
```

Tools surface as `<namespace>_<method>` — `orders_find`, `orders_cancel`. The framework follows the MCP client name regex `^[a-zA-Z0-9_-]{1,64}$` (dots aren't accepted by Claude.ai). The namespace defaults to the lower-cased class name, trimmed of a trailing `tools` (so `OrdersTools` → `orders`); override via `namespace = '...'` on the Toolset.

Wire both surfaces in `urls.py`:

```python
from easyapi import get_routes

urlpatterns = get_routes(
    endpoints={r'orders(.*)$': OrderResource},
    toolsets=[Orders, Billing],
)
```

You get `/orders…` for REST, `/mcp` for JSON-RPC, `/openapi.json`, `/docs`, `/mcp/tools` (browseable) and `/mcp/tools.json` (machine-readable).

If you don't have Django yet:

```bash
easyapi init
```

The CLI prompts for host, db, credentials. About ten seconds later you have a working Django project — every table is a model with a REST resource. Sensitive columns (`password`, `token`, `api_key`) are auto-masked. Read-only by default. Pass `--writable` when you mean it. Write your MCP tools as `Tools` subclasses on top of the generated models.

---

## Why it exists

Two years ago I got tired of writing the same Django REST API for the tenth time — DRF, Ninja, FastAPI, all powerful, all the same boilerplate. So I wrote a small framework for myself: one class, set some attributes, get the endpoints. I called it easyapi.

When MCP showed up and every project I had needed an agent surface, I expected to write a second codebase. Instead the MCP server fell out of the same engine in a weekend — auth was already there, rate limit was already there, the field whitelists were already there. Only the wire format changed.

REST is mostly a solved problem now. The new pain is MCP — most teams are rebuilding the same scaffolding. So I cleaned up **easyapi** and put it on GitHub — same engine, now with a first-class MCP surface.

---

## What you get

**REST (`BaseResource`)**
- Async CRUD on Django models with pagination, filters, search, ordering.
- OpenAPI 3.0.3 spec + Scalar UI.
- Cache with namespace invalidation. Writes don't blow away unrelated rows.
- **Ownership scoping.** One attribute (`owner_field = 'owner_id'`) restricts every CRUD operation to rows owned by the authenticated user — the cheapest IDOR defense I know.

**MCP (`Tools` + `MCPServer`)**
- Intent-named tools: each public method is a tool; `_underscore` methods stay private. Helpers that must stay public-but-non-tool go in `excluded_methods = (...)`.
- JSON Schema (Draft 2020-12) generated from type hints; raw schema escape hatch when you need it (`@tool(input_schema={...})`).
- Docstrings become tool descriptions for the agent — the full docstring is published, not just the first line.
- MCP `annotations` (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) via `@tool(...)`. Read-scoped tools get `readOnlyHint` + `idempotentHint` automatically; override per-method when needed.
- Per-tool `scope` and `rate_limit` (`'10/m'` shorthand or `{'limit': N, 'window': seconds}`). `tools/list` filters by the caller's OAuth scope so an agent only sees what it can call.
- Class-level `default_output_schema` (set to `False` when methods return free-shape `dict` so the agent isn't misled by an auto-derived loose schema).
- Pinned envelope: `{tool, code, data}` on success; `{tool, code, message, ...}` on error. Error codes are stable strings: `OK`, `VALIDATION_ERROR`, `NOT_FOUND`, `FORBIDDEN`, `INSUFFICIENT_SCOPE`, `RATE_LIMITED`, `METHOD_NOT_ALLOWED`, `INTERNAL`.
- `before_call` / `after_call` hooks for audit and membership preloading.
- `MCPTestClient` drives the registry in-process — unit tests don't need HTTP.

**Shared (`CoreResource`)**
- Bearer + `X-Api-Key` + session-cookie auth.
- Per-IP rate limit, scanner blocking, 4xx flood detection.
- Multi-tenant DB routing. One call switches the connection for the request.
- Sliding session TTLs via Redis `GETEX`. Configure with `SESSION_TTL` (default 1800s) and `API_SESSION_TTL` (default 300s).
- Sensitive-field scrubbing (`password`, `api_key`, `token` baseline plus your additions) — applied on both REST and MCP responses.
- Global read-only switch (`MCP = {'READ_ONLY': True}`) rejects every non-`GET` request with `405`.
- Async end-to-end. Async ORM, async Redis, async dispatch.

Full docs and reference: https://github.com/ssjunior/easyapi-django

---

## Connecting an agent

Add to `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "myapp": {
      "command": "python",
      "args": ["manage.py", "mcp_serve", "myapp.mcp.toolsets"],
      "cwd": "/path/to/your/project",
      "env": {
        "DJANGO_SETTINGS_MODULE": "myapp.settings"
      }
    }
  }
}
```

`myapp.mcp.toolsets` is a module-level sequence of `Tools` subclasses, e.g. `toolsets = [Orders, Billing]`. Restart Claude Desktop. The agent now sees every tool you declared.

For HTTP-based agents (Cursor, custom copilots, anything else that speaks JSON-RPC over POST), the same tools live at `POST /mcp`. Auth is whatever you've wired into your `BaseResource` stack — Bearer, `X-Api-Key`, or session cookie all work; per-tool scopes filter `tools/list` automatically.

```bash
curl -X POST http://localhost:8000/mcp \
  -H 'Content-Type: application/json' \
  -H "X-Api-Key: $TOKEN" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

---

## When this isn't the right tool

I'd rather you bounce now than get stuck a month in.

- **No Redis available.** Sessions, cache, rate limit and abuse blocking all rely on it. Redis 6.2+ (uses `GETEX` for sliding session TTLs). Non-negotiable.
- **You need complex auth.** OAuth2 server, SAML, intricate permission matrices — DRF or a custom stack will fit better.
- **Your endpoints are mostly RPC, not CRUD.** And you don't want them as MCP tools either.
- **You don't want Django.** easyapi wraps the Django ORM. The `init` command generates a Django project. If that's a dealbreaker, this isn't your tool.
- **You want a big plugin ecosystem.** It's small on purpose.

---

## Hardening before you ship

Defaults are demo-friendly. Production deployments should:

- **Cookie auth.** Set at least one of `ENFORCE_TOKEN = True` (HMAC anti-replay on state-changing requests) or `ALLOWED_ORIGINS = [...]` (Origin allowlist). Without either, the framework logs a startup warning — there is no built-in CSRF defense for `POST/PATCH/DELETE`.
- **Per-resource ownership.** Set `owner_field = 'owner_id'` on resources where rows belong to a single user — restricts every CRUD operation to the row's owner. `POST` always forces `owner_id` to the caller (override with `allow_owner_override = True` for admin paths).
- **Authenticated cache.** If you turn on `cache = True` for an authenticated resource, set `session_cache = True` or `cache_scope_fields = (...)` — otherwise responses can leak across users. The framework warns at runtime when it detects this combination.
- **Tune session TTLs.** `SESSION_TTL` (cookies, default 1800s) and `API_SESSION_TTL` (api-key cache, default 300s) both slide on use. Pick numbers that match your security/UX trade-off.

---

## Help wanted

If you try it and something breaks, please tell me. The kinds of help that make this better:

- **Bug reports.** Open an issue with what you tried and what happened. Including the Python/Django version helps.
- **PRs.** Small ones welcome. For larger changes, open an issue first so we can talk through the shape.
- **"This is confusing"** feedback on the docs. The doc site needs more eyes.
- **Sharing how you use it.** I'm curious what shapes of projects this actually lands in.

There's no CLA, no contributor matrix, no roadmap voting. Just open an issue and we figure it out.

---

## Project

- **Author** — Stamatios Stamou Jr
- **License** — MIT
- **Python** — 3.10+
- **Django** — 5.0+
- **Repo** — github.com/ssjunior/easyapi-django

