Custom routes

Endpoints that live alongside CRUD on the same resource.

Why

A User resource often needs /me, /login, /logout — endpoints that share the same authentication, rate limit, cache, but do not map to "row by id" CRUD. Custom routes are the answer.

Definition

class UserResource(BaseResource):
    model = User
    routes = [
        {'path': r'/me$',     'func': 'me',     'allowed_methods': ['get']},
        {'path': r'/login$',  'func': 'login',  'allowed_methods': ['post']},
        {'path': r'/logout$', 'func': 'logout', 'allowed_methods': ['post']},
    ]

    async def me(self, request, match=None):
        return {'id': self.user['id'], 'email': self.user['email']}

    async def login(self, request, match=None, body=None):
        # body is the parsed JSON, already validated against login_schema if any
        ...

Path patterns

path is a regex matched against the request path. Use named groups to capture parameters:

routes = [
    {'path': r'/(?P<token>[^/]+)/verify$', 'func': 'verify', 'allowed_methods': ['get']},
]

async def verify(self, request, match=None):
    token = match['token']
    ...

Cache opt-in

Custom routes do not cache by default. Add cache: True to the route definition:

{'path': r'/me$', 'func': 'me', 'allowed_methods': ['get'], 'cache': True}

OpenAPI metadata

Decorate the handler with @openapi(...) to give Scalar a real description:

from zeromcp import openapi
from pydantic import BaseModel

class MeOut(BaseModel):
    id: int
    email: str

class UserResource(BaseResource):
    routes = [{'path': r'/me$', 'func': 'me', 'allowed_methods': ['get']}]

    @openapi(summary='Current user', response=MeOut)
    async def me(self, request, match=None):
        ...

Without the decorator, custom routes still appear in the spec, just with a generic summary.

0-mcp by Stamatios Stamou Jr — github.com/ssjunior/0-mcp