- OAuth (recommended) — paste the endpoint URL into Claude.ai, Claude Desktop or Claude Code, sign in to your application and pick the permissions. No key to copy. See Connect through OAuth.
- API key — for clients that only send a static header (Cursor, Codex, scripts, CI). See Create an MCP API key.
1. Connect through OAuth (Claude.ai, Claude Desktop, Claude Code)
The server is an OAuth 2.1 authorization server for its own MCP endpoint (MCP authorization spec: protected-resource metadata, dynamic client registration, PKCE). A client that supports it needs only the URL:- Claude.ai / Claude Desktop
- Claude Code
Settings → Connectors → Add custom connector, paste
https://MY_APPLICATION_DOMAIN/api/mcp and leave the OAuth client id / secret empty — the
client registers itself. Claude opens your application in the browser: sign in (if you are not
already), choose the scopes the connection gets and click Allow./ai/admin → MCP API keys) as OAuth connection:
<client> — revoke it there to disconnect the client. Tokens are bound to the application URL
they were issued for and are rejected on any other host.
The consent screen has to be completed within 10 minutes of starting the connection; after that
the client simply has to start again.
Connector icon. The MCP handshake advertises your application’s name and icon (the icon
configured for the app, or its logo), and
/favicon.ico on your application domain serves that
same icon. Clients that honour the MCP icons field show it right away. Claude.ai currently
ignores it and derives a custom connector’s icon from the favicon of the connector’s domain, as
it resolves it (with a custom domain such as app.example.com it typically shows the favicon of
example.com; on the default *.azurewebsites.net host expect a generic icon).2. Create an MCP API key
MCP access can also be authenticated with personal API keys (cnk_...), bound to your user and this application, with explicit scopes and an expiration date. Create one with a GraphQL mutation against https://MY_APPLICATION_DOMAIN/graphql (authenticated as your user):
mcpApiKeys query and revoke one at any time with revokeMcpApiKey(_id: "...").
Scopes
The two data scopes are gated differently from the rest: instead of the generic read/write buckets, each maps to the exact agent permission its equivalent chat-agent intent already requires, and both are dropped entirely unless per-entity permission enforcement is on for the tenant. Until then the data tools simply don’t appear in the tool list, even on a key that was created with the scopes.
Creating a key requires, for each scope you tick, the agent permission that scope needs:
Asking for a scope your role does not back is rejected naming the scope and the permission it needs, and the keys panel (
/ai/admin → MCP API keys) only offers the scopes your role can actually grant — that list comes from the server, so it already accounts for the admin bypass and for whether entity enforcement is on. So a business user with agent-search can create a data:read key without being a designer — get_graphql_schema works under the data scopes, so that key is usable on its own — while config:read stays with the designer permission, since the application-context tools it unlocks return hook code and feature customCode.
Data scopes are only grantable once per-entity permission enforcement is enabled for the application; without it the key would be issued with a scope that is dropped on every request. Each scope needs the same permission to stay alive as it needed to be granted, and effective permissions are re-evaluated on every request as the intersection of the key’s scopes and your current role permissions — if your user is disabled or demoted, the key stops working within a minute (losing the designer permission, for instance, deactivates config:read on keys you already created). Admin-only scopes (hooks:write, roles:write, env:write, config:delete) additionally require that the key’s user still is an admin at request time: losing the admin role deactivates those scopes immediately, even on existing keys.
3. Connect your agent with an API key
The key travels in theAuthorization header.
Name the server per application — codenull-<app-name> — so connecting several Codenull
applications at the same time doesn’t collide on the name (the keys panel generates the snippets
with this convention already). The examples below use codenull-myapp.
- Claude Code
- Claude Desktop / Claude.ai
- Cursor
- Codex (CLI, app & IDE)
- MCP Inspector
The server is stateless: only
POST /api/mcp is served. Tools your key’s (or
connection’s) scopes don’t allow are not even listed.Discovery endpoints
OAuth-capable clients find everything they need from the MCP URL; nothing here has to be configured by hand:3. Available tools
Discovery (config:read) — list_features, get_feature_details, list_hooks, get_hook_details, list_entities, get_entity_details, list_menus, list_roles, list_environment_variables, get_graphql_schema*, get_feature_authoring_guide, validate_feature_code, validate_hook_code.
Writing — create_feature / update_feature / edit_feature_code (features:write), create_hook / update_hook / edit_hook_code (hooks:write), create_table / update_table / add_table_field (tables:write), upsert_menu (menus:write), grant_feature_access (roles:write), set_environment_variable (env:write), delete_feature / delete_hook (config:delete, require confirm: true).
Architecture decisions — list_decisions / get_decision_details (config:read), create_decision / supersede_decision (decisions:write, implied by features:write, hooks:write or tables:write), confirm_decision (decisions:write), delete_decision (config:delete, requires confirm: true). From a child environment, decisions are stored in the parent application with status pending and are visible only to that environment until they are confirmed; recording and confirming them there requires an admin user, checked by the API rather than by a separate scope. See ADR-0028 in the repository.
Business data — execute_data_query (data:read) and execute_data_mutation (data:write). Everything above manages your application’s configuration; these two read and write the records living in your tables. Both take a complete GraphQL document plus variables and run it against the same per-table resolvers the compiled app uses, so canRead / canWrite and hideFromQueries apply exactly as they do anywhere else. Call get_graphql_schema first to learn the generated query and mutation names, filters and pagination for each table.
* get_graphql_schema is the one discovery tool a data-only key can also call: it is reachable
with config:read, data:read or data:write. What it returns is the schema of your business
tables — configuration schemas are excluded — so it shows exactly what those scopes already let the
key read, and without it a data-only key would have no way to learn which fields each table exposes.
A typical flow the agent follows to create a custom feature:
get_feature_authoring_guide— learns the allowed modules (antd, Apollo hooks, dayjs, charts through@codenull/chartsandrecharts…), the runtime rules, and your application’s configured theme (colors + shell CSS variables), used as the base palette when you don’t ask for specific colors — explicit color requests always take precedence.get_graphql_schema+get_entity_details— learns your data model.validate_feature_code— dry-run validation of the JSX (never executed server-side). Also returns informational warnings when the code uses fixed hex colors or Tailwind color classes (fine if you asked for them; otherwise the theme is the default).create_feature— persists through the same resolver the builder uses (id validation, default permissions, versioning).
4. Usage examples
Once connected, you talk to your agent in natural language — it discovers and calls the MCP tools on its own. These are real prompts you can paste into Claude Code or Cursor, with the tool sequence the agent typically runs.Video walkthrough
Building a real application through the MCP, in three parts: Part 1Explore an application you don’t know
“Connect to my Codenull app and give me an overview: what entities, features and hooks does it have?”The agent calls
list_entities, list_features and list_hooks, then summarizes your data model, screens and server-side logic. Requires only config:read.
Create a table and a screen for it
“Create a Cliente table with fields Nombre (text, required), Correo (text, unique) and FechaRegistro (date). Then build a screen to list and create clients, and add it to the side menu.”
Typical sequence:
get_entity_detailson an existing table — to copy your field type conventions.create_table— createsClientewith the fields (tables:write).get_feature_authoring_guide+get_graphql_schema— learns the runtime rules and the generatedClienteCRUD operations.validate_feature_codethencreate_feature— a custom feature with an antd table + creation form wired touseQuery/useMutation(features:write).upsert_menu— adds the/feature/clientesentry to the side menu (menus:write). It returns the menu as it was left by the write, so the response is enough to confirm the entry landed — nolist_menusafterwards.
Add a validation hook
“Add a beforeSave validation on the Facturas table: reject any invoice whose Total is negative. Follow the same style as the existing hooks.”Sequence:
list_hooks + get_hook_details (to mimic your patterns) → validate_hook_code (syntax + rules dry-run) → create_hook with type: "beforeSave" and the table’s tableId. Requires hooks:write (admin-granted).
Turn a hook off without deleting it
“The ExportPersonas hook is broken and breaking the Export button — stop it from running.”
Sequence: list_hooks (to get its _id) → update_hook with enabled: false. The hook keeps its code but the runtime skips it entirely (no execution, no logs, no errors), and a scheduled job is unregistered from Agenda. Reverse it with enabled: true. Requires hooks:write — unlike delete_hook, this is reversible, so prefer it whenever the goal is just to stop a hook from running. create_hook accepts enabled: false too, to add a hook that starts out inactive.
Agents cannot rename a hook:
update_hook has no name field. A hook’s
name is how features, scheduled jobs and transaction hooks reference it, and
nothing rewrites those references on rename — they would keep pointing at a
name that no longer exists and fail at runtime. Rename from the visual
builder instead, where you can see what points at the hook.Modify an existing feature
“In the feature ventas_dashboard, add a date-range filter and a card with the month’s total.”
Sequence: get_feature_details (reads the current customCode) → validate_feature_code → update_feature. Updates run through the same versioning flow as the builder — pass status: "draft"/"published" if you use draft versions.
Grant access to a role
“Make the new Clientes screen visible to the Ventas role, plus Admin.”Sequence:
list_roles + list_features (to resolve ids) → grant_feature_access with the feature _id and the role ids (roles:write).
grant_feature_access is additive by default (mode: "add"): the roles that already had access keep it, so you only send the roles you want to add — no role, Admin included, loses access because you left it out. To set the list to exactly roleIds and revoke everyone else, pass mode: "replace"; use it only when someone explicitly asked to remove access. Either way the tool returns added / removed / keptNotRequested so you can check what the write actually did — a non-empty keptNotRequested means roles you never named kept their access, which is what you want when adding and a mistake when you meant to restrict. Those fields are read back from the database after the write, not derived from what you sent, so they are a verification and not an echo — there is no need to follow up with list_roles to confirm.
Anonymous access to a feature is configured on its own screen and is never touched by grant_feature_access.
Calling the server without an agent (raw JSON-RPC)
The endpoint speaks standard MCP over Streamable HTTP, so you can also script it directly:Security model
- Keys: 256-bit entropy, SHA-256 hashed at rest, mandatory expiry (max 365 days), revocable,
lastUsedAttracking. Thecnk_prefix makes leaked keys easy to detect with secret scanners. - OAuth connections: authorization code with PKCE (S256) only; access tokens live one hour, refresh tokens (
cnr_prefix) 90 days and are rotated on every use; both are hashed at rest and bound to the application URL (RFC 8707). The consent screen applies the same scope gate as key creation, and the connection is subject to every rule below exactly like a key. - Tenant isolation: each deployment serves one application; keys are bound to it and rejected elsewhere.
- Attribution: every mutation records your user as the author, same as in the builder.
- Audit: every tool call is logged (
mcp_audit_logs) with redacted arguments — env var values and API keys never reach the log; long code payloads are stored as a preview plus SHA-256 hash. Entries are retained for one year. - Rate limits: 60 requests/minute per key; failed authentication is limited per IP. Limits are tracked per server instance, so with N replicas the effective ceiling is N× the stated value.
- Payload limits: 2 MB per request, 200 KB per hook, 1 MB per feature code.
- Prompt-injection guard: read-tool responses are wrapped with a note marking application content as data, not instructions.
- Business data: the
data:read/data:writescopes never expose deletion, and never let the caller choose which hook runs. They are also inactive unless per-entity permission enforcement is enabled for the tenant — without it those scopes would grant wider access than the user has inside the application itself.