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