ObjectStackObjectStack

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/mcp

For 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 manager

Send it on every request in any of three equivalent forms:

HeaderExample
x-api-keyx-api-key: osk_...
Authorization: ApiKeyAuthorization: ApiKey osk_...
Authorization: BearerAuthorization: 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 start

The 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:

ToolWhat it does
list_objects / describe_objectDiscover which objects exist and their fields
validate_expressionCheck a CEL expression against an object's schema before authoring it into a formula, validation, or flow condition
query_records / get_recordRead data (list queries are capped at 50 rows per page by default)
aggregate_recordsGrouped aggregation (registered when the active driver supports it)
create_record / update_record / delete_recordWrite data
list_actions / run_actionDiscover 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 an ai.description of 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) and flow (with the automation service registered). url, modal, form, and api have 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 message

In 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 skillsdefineSkill 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 ExecutionContext as 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, and actions:execute scopes — 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.exposed gate and requiredPermissions are 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_objectsdescribe_objectquery_recordsrun_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

SymptomCause → fix
404 on /api/v1/mcpThe HTTP surface is disabled — unset OS_MCP_SERVER_ENABLED (default is on)
501 Not ImplementedThe 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 onThe 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 closedOS_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 callAnonymous 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_scopeThe 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_actionsai.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 deniedWorking as designed — the caller's permissions and RLS apply. Verify with the same user in the Console

On this page