MCP client setup — Claude, Cursor and IDEs
Point Claude Code, Claude Desktop, or any MCP client at your running app — OAuth or API key — and verify the agent can see and operate it.
Every ObjectStack deployment is already an MCP server. The runtime serves the
Model Context Protocol at /api/v1/mcp
— on by default, no plugin to install, no configuration step. Your objects and
exposed actions become typed tools the moment you define them; this page is
about the only thing left to do: connecting a client and proving it works.
To turn the surface off, set OS_MCP_SERVER_ENABLED=false — the endpoint then
returns 404 and the Connect an Agent page disappears with it, along with
both its Setup and Account navigation entries. See
environment variables.
Claude Code (one command)
Interactive clients use OAuth — each deployment is its own OAuth 2.1 authorization server, so there are no admin-minted credentials to pass around. The first tool call opens a browser login and you connect as yourself:
# local dev server
claude mcp add --transport http my-app http://localhost:3000/api/v1/mcp
# a deployed instance
claude mcp add --transport http my-app https://your-deployment.example.com/api/v1/mcpThen open a new Claude Code session and run /mcp — pick the server and
choose Authenticate to complete the browser login. Two things to expect on
this track:
- Until that login has happened,
claude mcp list/claude mcp get my-appreport the server as ✘ Failed to connect. That is the health probe being answered with a 401, not a broken setup — the status flips once the session is authenticated. - A Claude Code session that was already running when you ran
claude mcp adddoes not pick the server up; start a fresh one.
For headless use (CI, containers), or whenever you would rather not go through the browser login, skip OAuth and attach an API key instead — with a key the server reports ✔ Connected immediately, no login step:
claude mcp add --transport http my-app https://your-deployment.example.com/api/v1/mcp \
--header "x-api-key: osk_..."claude mcp add registers the server at local scope by default — visible
only from the directory you ran it in. Add -s user to make it available from
every project.
Claude Desktop and claude.ai
Settings → Connectors → Add custom connector, then paste the MCP URL
(https://your-deployment.example.com/api/v1/mcp). The first use walks through
the same browser login.
This track needs a publicly reachable HTTPS endpoint — it cannot reach
localhost or an intranet address. Add custom connector is the same
claude.ai connector system in both surfaces, and it connects to your deployment
from Anthropic's servers, not from the machine the client is running on.
Anthropic's custom-connector documentation requires the MCP server to be
reachable over the public internet from Anthropic's IP ranges, and states that
a server on a private corporate network, behind a VPN, or blocked by a firewall
will not connect. A locally trusted TLS certificate does not make a private
address reachable, and an http:// URL is refused before any of that.
For a local or intranet deployment, connect through
Claude Code instead — claude mcp add runs on your
own machine, so localhost and intranet addresses work there.
Any MCP client (.mcp.json)
Clients that read an mcpServers map connect the same way. With an API key:
{
"mcpServers": {
"objectstack": {
"type": "http",
"url": "https://your-deployment.example.com/api/v1/mcp",
"headers": { "x-api-key": "osk_..." }
}
}
}Headless: API keys
Mint a key from the Connect an Agent page in the Console. One page, two doors — which one you have depends on your permissions:
- Any signed-in user — open the avatar menu (top right) → Profile to
enter the Account app, then Developer → Connect an Agent in its sidebar.
Direct link:
/_console/apps/com.objectstack.account/page/connect_agent. - Platform admins — Setup → Connect an Agent, at
/_console/apps/com.objectstack.setup/page/connect_agent, reached from the Setup sidebar. The Setup app requires thesetup.accesspermission, so this door alone is not enough for an ordinary member; the Account one needs none.
On the page, the Create key button sits at the bottom under API keys. The
new key is shown once, together with a ready-to-paste x-api-key: osk_...
header line — copy it before you dismiss the panel. The same page carries
copy-paste connect snippets for Claude Code, Claude Desktop, Cursor, VS Code,
and Codex, and a download link for the app's SKILL.md.
Your own keys are listed (prefix only) and revoked under Account → Developer
→ API Keys. Platform admins get the tenant-wide list at Setup → Access
Control → API Keys, which requires manage_platform_settings.
Or mint one over REST:
curl -b cookies.txt -X POST https://your-deployment.example.com/api/v1/keys
# → { "success": true, "data": { "key": "osk_...", "prefix": "osk_...", "name": "API Key",
# "expires_at": "2027-01-12T09:30:00.000Z" } }
# the raw key is shown once — store it in your secret managerKey lifetime, inactivity and rotation
Personal API keys do not live forever. The policy is part of the
Authentication settings (the auth namespace, group API keys), with
these defaults:
| Rule | Default | Setting |
|---|---|---|
A key created without expires_at expires after | 90 days | api_key_default_expiry_days |
The furthest expires_at a new key may ask for | 1 year from creation | api_key_max_expiry_days |
| A key with no recorded use is refused after | 90 days | api_key_inactivity_days (0 turns it off) |
- Creating a key. Pass
{ "expires_at": "<ISO date>" }to choose the expiry. A date further away than the maximum is refused with400 API_KEY_EXPIRY_TOO_LONG, whose message names the latest date allowed; exactly the maximum is accepted. Withoutexpires_atthe key gets the default lifetime, and the response echoes the date it got. - Use is recorded. Each key's
last_used_atis stamped when the key is used (at most once an hour) and shows on the API Keys page. - Keys created before the policy. A key that was created with no expiry
date gets one year of grace from the moment the deployment started enforcing
the policy (recorded once, as
api_key_policy_started_at). The date is written onto the key'sexpires_atthe first time it is used, so the API Keys page shows it, and the refusal after it names it. A key with no recorded use counts its inactivity from that same moment (or from its own creation, when that is later).
A key that stops working is refused with 403 and a code that says why — the
message names the key's prefix, the date, and the remedy:
| Code | Meaning |
|---|---|
API_KEY_EXPIRED | Past its expires_at (or past the grace of a key created with none). |
API_KEY_INACTIVE | No recorded use for longer than the inactivity window. The key is not revoked. |
API_KEY_REVOKED | Revoked by its owner or an administrator. |
The remedy is the same for all three: create a new key and replace the old
one wherever it is configured — keys do not renew. To rotate without a gap,
create the new key first, switch the integration over, then revoke the old one.
An unknown key (a typo, a key that never existed) still gets the generic
401 UNAUTHENTICATED.
These codes reach the caller on every endpoint that accepts a key: the MCP
HTTP endpoint (/api/v1/mcp) and the other endpoints the runtime serves, the
REST data API (/api/v1/data/…), the settings, file-download, datasource
admin and share-link routes, the marketplace install routes, and the MCP
stdio transport.
Send it on every request in any of three equivalent forms:
| Header | Example |
|---|---|
x-api-key | x-api-key: osk_... |
Authorization: ApiKey | Authorization: ApiKey osk_... |
Authorization: Bearer | Authorization: Bearer osk_... (recognized by the osk_ prefix) |
OAuth requires TLS — plain-HTTP deployments (except localhost) fall back to
API-key-only: the browser-login track is disabled rather than allowed to
run insecurely.
Local stdio transport (opt-in)
Alongside the HTTP endpoint, the runtime can also speak MCP over a long-lived stdio transport — a local pipe for a client on the same host rather than a network surface. It is off by default and deliberately stricter than HTTP; turn it on at boot with two environment variables:
OS_MCP_STDIO_ENABLED=true OS_MCP_STDIO_API_KEY=osk_... os startThe HTTP surface authenticates each caller (OAuth as a person, or their own
API key). A stdio pipe has no per-request identity to resolve, so it runs as a
single principal you pin up front — the identity of OS_MCP_STDIO_API_KEY.
That key is resolved through the same verify + authorization chain as an
HTTP/REST request and re-checked on every call, so RBAC, row-level
security, field-level security, and tenant isolation apply to the stdio session
exactly as they would to that identity in the Console — and revoking the key
stops the session on its next call.
The transport is fail-closed: a missing, unknown, revoked, expired,
inactive, or owner-less key makes stdio refuse to start rather than fall back to an
unscoped or system session — there is deliberately no bypass. For a
full-authority local agent, mint the key on a platform-admin or dedicated
service identity; for a scoped one, mint it on a user with exactly the
access it should have. See
environment variables for
the switches and ADR-0101 for the
decision.
What the agent gets
Twelve tools, generated from your metadata:
| Tool | What it does |
|---|---|
list_objects / describe_object | Discover which objects exist and their fields |
validate_expression | Check a CEL expression against an object's schema before authoring it into a formula, validation, or flow condition |
query_records / get_record | Read data (list queries are capped at 50 rows per page by default) |
aggregate_records | Grouped aggregation (registered when the active driver supports it) |
create_record / update_record / delete_record | Write data |
list_actions / run_action | Discover and invoke your business actions by name |
resume_run | Finish a flow action that paused on a screen, by submitting the screen's values |
The tools are a fixed ~10-tool spine with the object name as a parameter
(query_records(objectName, …)), not one tool per object — so the list stays
usable on an app with hundreds of objects. Which objects and actions that spine
can reach is the default exposure policy (v1):
- Objects are exposed automatically — except
sys_*system objects, which are blocked fail-closed. - Actions require the author's opt-in:
ai: { exposed: true }plus anai.descriptionof at least 40 characters, and the action must be callable without a UI. In the open framework that means exactly two types —script(with an inline body or a registered handler) andflow(with the automation service registered).url,modal,form, andapihave no headless dispatch here. See Actions as Tools and Actions.
This is the default policy, not a ceiling. A finer, metadata-declared exposure config (opt objects out, curate the exposed set, promote a few high-value actions to typed per-action tools) is the planned next step, consistent with ADR-0097's "what is exposed should itself be authorable" — tracked in #3167.
Prompts: your skills, served to the client
Tools are not the only primitive. Every skill you author (*.skill.ts,
defineSkill) that carries instructions is served as an MCP prompt — so
a connected client can list your app's playbooks by name and pull one into the
conversation:
export const CaseTriageSkill = defineSkill({
name: 'case_triage',
label: 'Case Triage',
description: 'How this team triages an inbound support case',
instructions: `Read the case, classify severity from the account tier and the
symptom, then propose the next action. Never close a case the customer has not
confirmed.`,
tools: ['query_records', 'action_resolve_case'],
});// prompts/list
{ "prompts": [ { "name": "case_triage", "title": "Case Triage",
"description": "How this team triages an inbound support case" } ] }
// prompts/get { "name": "case_triage" } → the instructions text, as a messageIn Claude Code that surfaces as /mcp__my-app__case_triage; other clients show
prompts in their own picker. Nothing to enable — the surface appears as soon as
the app has a skill with instructions, and prompts/list simply returns an
empty list until then.
Only the instructions half projects. A skill's tools / surface /
triggerConditions are read by the in-product agent runtime (the ask /
build agents, cloud / Enterprise), which is the thing that composes a tool set
and decides when a skill activates. MCP has neither step: the model lives in
your client, driving one flat tool list, and your AI-exposed actions are
already reachable there as list_actions / run_action. So on the open
framework a skill contributes its judgment (as a prompt), not its wiring — and
the same skill file keeps its full meaning when the app runs on the cloud
runtime. See AI Agents.
Two different "skills" again. These are agent skills — defineSkill
metadata inside your app. The SKILL.md file at GET /api/v1/mcp/skill
(below) is the authoring skill that teaches an external coding agent how to
drive this MCP server. Same word, different layer.
The security model
- Every call runs as the caller. The MCP bridge resolves the same
ExecutionContextas a REST request, so RBAC, row-level security, and field-level security apply to the agent exactly as they do to a person in the Console. Sparse results or denied writes usually mean governance is working, not that the connection is broken. - OAuth scopes narrow the toolset. Tokens carry
data:read,data:write, andactions:executescopes — tools outside the granted scopes are not even registered for that session. API-key and session callers get the full set, still permission-checked per call. - Action bodies run as trusted app code once invoked (the
ai.exposedgate andrequiredPermissionsare checked at invoke time). Treat writing an action as a code-review-worthy act — that's the real security boundary. - An action can also declare
ai.requiresConfirmation; destructive-looking actions (confirmText, danger variants) default to requiring it.
Verify the connection
Before involving a client, prove the server itself is answering — the anonymous
probe below must return 401, which is the surface saying "present, needs a
credential" (a WWW-Authenticate header naming the OAuth resource metadata
means the browser-login track is live too). Anything else is a server-side
issue; see Troubleshooting.
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/api/v1/mcp \
-H 'content-type: application/json' -d '{}'
# → 401With an API key you can drive the whole protocol from the shell — a handshake, the tool list, and one real read:
MCP=http://localhost:3000/api/v1/mcp
KEY=osk_...
call() { curl -s -X POST "$MCP" -H "x-api-key: $KEY" \
-H 'content-type: application/json' -H 'accept: application/json, text/event-stream' -d "$1"; }
call '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}'
# → {"result":{"serverInfo":{"name":"objectstack",...},"capabilities":{"tools":{...},"prompts":{}}},...}
call '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | grep -o '"name":"[a-z_]*"'
# → the twelve tools, list_objects … resume_run
call '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"query_records","arguments":{"objectName":"crm_opportunity","limit":3}}}'
# → {"result":{"content":[{"type":"text","text":"{ \"object\": \"crm_opportunity\", \"records\": [ ... ], \"total\": 23, \"hasMore\": true }"}]}}Two details that trip up a first attempt: the object argument is
objectName, and its value is the object's API name as returned by
list_objects (crm_opportunity, not opportunity) — a wrong name comes back
as Object 'opportunity' not found.
Then, in the client, ask the agent something only the live schema can answer:
"What objects does this app have, and what fields does the main one carry?"
You should see list_objects and describe_object fire. The natural working
pattern for an agent is list_objects → describe_object → query_records →
run_action — if all four work, the connection is fully operational.
Agents work noticeably better with the app's skill file: download it from
GET /api/v1/mcp/skill, or install the
official Claude plugin
(claude plugin marketplace add objectstack-ai/claude-plugin) which bundles the
skill and a guided /objectstack:connect command.
Troubleshooting
| Symptom | Cause → fix |
|---|---|
claude mcp list says ✘ Failed to connect right after claude mcp add (OAuth track, no --header) | Expected until you authenticate — open a new session, run /mcp, pick the server, Authenticate. Or attach an API key, which connects without a login step |
✘ Failed to connect / ConnectionRefused on a server that used to work | The app is not running. Start it — the client reconnects on its own, no need to re-register |
The server is missing from /mcp in a running session | Sessions read MCP config at start — open a new one. If it is still missing, you registered at local scope from another directory; re-add with -s user |
Object 'x' not found on query_records / get_record | Wrong object name — pass the API name from list_objects in objectName (crm_opportunity, not opportunity) |
404 on /api/v1/mcp | The HTTP surface is disabled — unset OS_MCP_SERVER_ENABLED (default is on) |
501 Not Implemented | The MCP plugin isn't part of this build — check your stack's plugins |
mcp missing from GET /api/v1/discovery and no Connect-an-Agent card, but OS_MCP_SERVER_ENABLED is on | The same cause as the 501 above, seen from the other side: the surface is enabled but not serveable, so discovery declines to advertise a route that would 501 rather than over-promising it (declared === enforced). Load the MCP plugin — os serve / os dev do it for you; a host that embeds @objectstack/rest directly must add @objectstack/mcp itself |
| stdio won't start / boot fails closed | OS_MCP_STDIO_ENABLED=true but OS_MCP_STDIO_API_KEY is missing, unknown, revoked, or expired — fail-closed by design (ADR-0101). Set a valid osk_ key; there is no unscoped or system fallback |
401 on every call | Anonymous or invalid credentials. Interactive clients: complete the browser login (the WWW-Authenticate header advertises the OAuth metadata). Headless: check the osk_ key and header spelling |
403 insufficient_scope | The OAuth token lacks the scope for that tool family (e.g. writes without data:write) — reconnect and grant the scope |
An action is missing from list_actions | ai.exposed is not true, ai.description is shorter than 40 characters, the type isn't headless-callable here (only script with a body/handler and flow with an automation service are — url, modal, form, and api never appear), it targets a sys_* object, or the caller fails its requiredPermissions |
| Reads return few rows / writes denied | Working as designed — the caller's permissions and RLS apply. Verify with the same user in the Console |
Related
- Prompts from your skills — the other MCP primitive this server serves
- Actions as Tools — the
run_actionbridge and its governance - Actions — defining the actions you expose
- Your app as an MCP server — the API-level view
- AI Agents — building agents inside your app (the other direction)
- Connectors — the outbound counterpart: consuming an external MCP server as a flow-dispatchable connector (
provider: 'mcp')