Skip to main content
Every Codenull application exposes a remote MCP (Model Context Protocol) server at:
Through it, any MCP-compatible AI agent can explore your application (features, hooks, entities, menus, roles) and — depending on the permissions you grant — create and update configuration, running through the exact same resolvers, validations and audit trail as the visual builder. There are two ways to authenticate:
  • 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.
Prefer to watch it? This video walks through the full server setup (API key + connecting an agent):

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:
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.
What you grant on the consent screen follows the same rules as API-key scopes (next section): only the scopes your role can grant are offered, you can narrow what the client asked for but never widen it (a client that asks for no scope, like Claude.ai, lets you choose), and the connection acts as your user. Access tokens last one hour and are renewed automatically with a refresh token (90 days, rotated on every use). The connection shows up in the keys panel (/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):
The apiKey value is returned only once. Codenull stores a hash and cannot recover it. Anyone holding the key can act as your user within its scopes — treat it like a password.
List your keys with the 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 the Authorization 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.
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:
No extra setup, rules files or skills are needed on the agent side: the server ships its own playbook via the MCP instructions field during the connection handshake, so any compatible client (Claude Code, Cursor, Codex) automatically teaches its model the Codenull working method — explore first, read the authoring guide and schema, validate, then create.

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.
These two tools cannot delete business records, and cannot choose which server-side code runs. execute_data_mutation accepts create, update and upsert only. It rejects every delete* mutation, any document that marks a detail row as deleted (marked_as_deleted) inside a master-detail write, the per-table custom* mutations, and the customHookId / beforeTransactionHookId / afterTransactionHookId arguments — those pick which hook the application executes, and a hook could delete rows from the inside. The per-table Export query is excluded from data:read for the same reason. Both tools also reject any field that isn’t generated CRUD for one of your tables, so configuration (hooks, roles, environment variables, MCP keys) stays out of reach even though the GraphQL schema exposes it on the same Query / Mutation type.A response carrying securityWarnings means the mutation modified records that already existed, as opposed to only adding new ones.Reads are always paginated: execute_data_query rejects a limit that is null or above 1000 rows, whether it is written in the document, passed as a variable or carried in a variable’s default value. A limit: null would drop the SQL LIMIT altogether and pull the whole table into memory. Page through large tables with limit + offset, and use the generated <Table>AndCount query when you only need the total. To count, sum or average by category or date, use the generated <Table>Aggregate query (groupBy + metrics) instead of paging through the table: it returns up to 200 groups by default, or a single row without groupBy.<Table>AndCount returns { items { ...fields } total }, and the selection set must include items with at least one field inside it: the resolver reads the columns to select from the first field of the selection set, so { total } on its own fails. When items is present but written after total, the tool reorders it before executing — field order between siblings carries no meaning in GraphQL, so this costs nothing and saves a round trip. Schema discovery goes through get_graphql_schema, which a data-only key can also call; GraphQL introspection (__schema / __type) is not reachable from these tools.A document cannot invoke more than 50 root fields, counting aliases — aliasing the same field dozens of times (a0: Facturas ... a699: Facturas) would multiply the SQL work it triggers well past anything a single call needs.
See what the agent changed, line by line. When modifying existing code, agents are instructed to use edit_feature_code / edit_hook_code, which take old_str/new_str patches — so the tool call itself shows exactly what changes. The server applies the patches to the current code, validates the result, persists it, and returns a unified diff of what was actually written (also returned by update_feature/update_hook when replacing code in full). If a patch doesn’t match the current code, the edit is rejected instead of overwriting the file with a stale version.
A typical flow the agent follows to create a custom feature:
  1. get_feature_authoring_guide — learns the allowed modules (antd, Apollo hooks, dayjs, charts through @codenull/charts and recharts…), 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.
  2. get_graphql_schema + get_entity_details — learns your data model.
  3. 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).
  4. 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 1
Part 2
Part 3

Explore 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:
  1. get_entity_details on an existing table — to copy your field type conventions.
  2. create_table — creates Cliente with the fields (tables:write).
  3. get_feature_authoring_guide + get_graphql_schema — learns the runtime rules and the generated Cliente CRUD operations.
  4. validate_feature_code then create_feature — a custom feature with an antd table + creation form wired to useQuery/useMutation (features:write).
  5. upsert_menu — adds the /feature/clientes entry 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 — no list_menus afterwards.

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:
Destructive tools (delete_feature, delete_hook) refuse to run unless the agent passes confirm: true — agents are instructed to ask you before confirming. To stop a hook from running there is a reversible alternative: update_hook with enabled: false. If a tool you expect is missing from tools/list, your key lacks that scope (or you lost the role permission backing it).

Security model

  • Keys: 256-bit entropy, SHA-256 hashed at rest, mandatory expiry (max 365 days), revocable, lastUsedAt tracking. The cnk_ 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:write scopes 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.
hooks:write deserves special care: hook content is JavaScript executed by your application’s backend. A leaked key with this scope means remote code execution. Only admins can grant it, and every hook write is audited with a hash of the exact code persisted. Application owners receive an email notification on every hook creation and modification, whichever entry point was used (visual builder or MCP).

The same tools inside the platform chat

The tools listed on this page also power the Codenull Agent, an agentic mode of the in-app chat. Writes made from either surface run through the same validations and appear in the same audit log.