Connect an MCP Client
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 Setup → Connect an Agent page disappears with it. 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/mcpFor headless use (CI, containers) skip OAuth and attach an API key instead:
claude mcp add --transport http my-app https://your-deployment.example.com/api/v1/mcp \
--header "x-api-key: osk_..."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.
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 Setup → Connect an Agent in the Console (which also shows copy-paste-ready connect snippets per client), or 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" } }
# the raw key is shown once — store it in your secret managerSend 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, 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
Eleven 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 |
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
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 |
|---|---|
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')