Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .changeset/mcp-oauth-authorization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
'@objectstack/spec': minor
'@objectstack/plugin-auth': minor
'@objectstack/runtime': minor
'@objectstack/mcp': minor
---

feat(mcp): spec-compliant OAuth 2.1 authorization for `/api/v1/mcp` (#2698)

Any OAuth-capable MCP client (claude.ai custom connectors, Claude Desktop,
Claude Code) can now connect to a deployment **self-serve**: no admin-minted
API key, no central registry — you sign in through the browser as yourself and
every tool call runs under your own permissions and row-level security.

**Each deployment is its own authorization server**, backed by the embedded
better-auth instance (`@better-auth/oauth-provider`). Rationale for the design
decisions lives in #2698; the moving parts:

- **Discovery**: `/.well-known/oauth-protected-resource` (RFC 9728, incl. the
path-inserted variant for `/api/v1/mcp`) and
`/.well-known/oauth-authorization-server` (RFC 8414, incl. the path-inserted
variant for the `/api/v1/auth` issuer) are served from the deployment origin.
401s from `/api/v1/mcp` advertise the resource metadata via
`WWW-Authenticate`, so clients bootstrap the flow automatically.
- **Dynamic Client Registration (RFC 7591)** is enabled (unauthenticated, as
the MCP spec requires) whenever the MCP surface is on — every deployment is a
distinct AS, so clients cannot ship pre-registered IDs. Force it either way
with `OS_OIDC_DCR_ENABLED` or the new `plugins.dynamicClientRegistration`
auth-config field. The embedded AS itself now auto-enables when
`OS_MCP_SERVER_ENABLED=true` (explicit `OS_OIDC_PROVIDER_ENABLED=false` still
wins).
- **Authorization-code + PKCE** flow with RFC 8707 resource binding: access
tokens are minted with `aud=<origin>/api/v1/mcp` and verified locally
(signature/issuer/audience/expiry) against the deployment's own JWKS —
fail-closed parity with API keys: unknown/expired/wrong-audience tokens,
sub-less M2M tokens, or a presented-but-invalid bearer never fall back to an
ambient session, they 401.
- **Token → ExecutionContext**: a valid access token resolves to the same
principal-bound `ExecutionContext` as every other credential, single-sourced
through `resolveAuthzContext` — OAuth adds a second *provenance* for the
principal, not a second authz model. `ExecutionContext` gains an optional
`oauthScopes` field carrying the token's granted scopes.
- **Coarse scopes → tool families**, enforced at tool dispatch: `data:read`
(list/describe/query/get), `data:write` (create/update/delete),
`actions:execute` (list_actions/run_action). Constants live in
`@objectstack/spec/ai` (`MCP_OAUTH_SCOPES`). Tools outside the grant are not
registered — and therefore rejected — for that request. API-key and session
principals are unaffected (not scope-limited).
- **TLS required, localhost exempt** (OAuth 2.1): on a plain-HTTP non-loopback
origin the OAuth track stays dark (no metadata, no bearer acceptance) and the
endpoint remains API-key-only. Local clients reach intranet deployments;
claude.ai web connectors additionally need public HTTPS reachability.

**API keys are unchanged** (dual-track): `x-api-key` / `Authorization: ApiKey` /
`Authorization: Bearer osk_…` keep working exactly as before for CI and
headless agents — covered by new regression tests.
38 changes: 38 additions & 0 deletions content/docs/ai/agents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,44 @@ the Builder, never silently re-routed. (For data query and source-mode authoring
the **open edition** ships neither agent and uses `@objectstack/mcp` (BYO-AI)
instead — see the callout in the [AI Overview](/docs/ai).)

## Connect your AI (BYO-AI over MCP)

Every deployment with `OS_MCP_SERVER_ENABLED=true` serves MCP at
`/api/v1/mcp`, with two authentication tracks:

- **OAuth 2.1 (interactive clients — recommended).** Each deployment is its
own spec-compliant authorization server: the endpoint publishes
`.well-known/oauth-protected-resource` / `.well-known/oauth-authorization-server`
discovery metadata, clients self-register via Dynamic Client Registration
(RFC 7591) and run an authorization-code + PKCE flow. Nothing is
pre-registered with Anthropic or any central service, so **self-hosted and
private deployments work out of the box**. You log in through the browser
as yourself; every tool call runs under your permissions and RLS. Consent
scopes (`data:read`, `data:write`, `actions:execute`) narrow which tool
families the client gets.
- **API key (headless).** Mint a key (`POST /api/v1/keys`, shown once) and
send it as `x-api-key` — for CI, scripts, and agents without a browser.

Per client:

| Client | How to connect |
|---|---|
| **Claude Code** | `claude mcp add --transport http objectstack https://<your-deployment>/api/v1/mcp` — a browser login opens on first use. Headless alternative: add `--header "x-api-key: osk_..."`. |
| **Claude Desktop** | Settings → Connectors → *Add custom connector* → paste the MCP URL → sign in when prompted. |
| **claude.ai (web)** | Settings → Connectors → *Add custom connector* → paste the MCP URL. |
| **Other MCP clients** | Any client implementing MCP authorization discovers the flow automatically; header-based clients can send the API key instead. |

<Callout type="info">
**Private / intranet deployments.** OAuth requires HTTPS (localhost is
exempt, per OAuth 2.1). **Local** clients — Claude Code and Claude Desktop —
run on your machine, so they can reach an intranet-only deployment (an
internal CA works). **claude.ai web connectors** connect from Anthropic's
servers, so they additionally need the MCP endpoint reachable from the public
internet — a network decision, not a platform switch. On a plain-HTTP
non-localhost deployment the OAuth track stays dark and the endpoint is
API-key-only, fail-closed.
</Callout>

## You extend the platform with **skills**, not agents

`*.agent.ts` is **closed to third parties** — the `agent` metadata type is
Expand Down
26 changes: 23 additions & 3 deletions content/docs/getting-started/build-with-claude-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -309,9 +309,29 @@ security apply to the agent exactly as they do to a person. The support desk you
built in six steps is now a backend an agent can *run* — not just a database it
can read.

See [Actions as Tools](/docs/ai/actions-as-tools) for the `run_action` bridge and
[the MCP reference](/docs/references/ai/mcp) for enabling the server and connecting
a client.
Connecting is self-serve. Start the app with the MCP surface on
(`OS_MCP_SERVER_ENABLED=true`), then add it to your client — the deployment is
its own OAuth 2.1 authorization server, so interactive clients just open a
browser login (you connect as yourself, no admin-minted credentials):

```bash
claude mcp add --transport http support-desk https://your-deployment.example.com/api/v1/mcp
# first tool use opens a browser login — you're connected as yourself
```

For headless callers (CI, scripts), mint an API key instead
(`POST /api/v1/keys`, shown once) and pass it as a header:

```bash
claude mcp add --transport http support-desk https://your-deployment.example.com/api/v1/mcp \
--header "x-api-key: osk_..."
```

See [Connect your AI](/docs/ai/agents#connect-your-ai-byo-ai-over-mcp) for
per-client instructions (claude.ai / Claude Desktop / Claude Code and the
private-deployment reachability note), [Actions as Tools](/docs/ai/actions-as-tools)
for the `run_action` bridge, and [the MCP reference](/docs/references/ai/mcp) for
enabling the server.

## Recap — where each guardrail sat

Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/api/metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ Metadata query with filtering, sorting, and pagination

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **types** | `Enum<'object' \| 'field' \| 'validation' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'profile' \| 'role' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types |
| **types** | `Enum<'object' \| 'field' \| 'validation' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types |
| **namespaces** | `string[]` | optional | Filter by namespaces |
| **packageId** | `string` | optional | Filter by owning package |
| **search** | `string` | optional | Full-text search query |
Expand Down Expand Up @@ -349,7 +349,7 @@ Metadata query with filtering, sorting, and pagination

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'object' \| 'field' \| 'validation' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'profile' \| 'role' \| 'agent' \| 'tool' \| 'skill'>` | ✅ | Metadata type |
| **type** | `Enum<'object' \| 'field' \| 'validation' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>` | ✅ | Metadata type |
| **name** | `string` | ✅ | Item name (snake_case) |
| **data** | `Record<string, any>` | ✅ | Metadata payload |
| **namespace** | `string` | optional | Optional namespace |
Expand Down
12 changes: 6 additions & 6 deletions content/docs/references/identity/eval-user.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ RLS, client UI gates) under the canonical variable name `current_user`

(aliases `user`, `ctx.user`) with an **identical shape**. A predicate such as

`current_user.roles.exists(r, r == 'org_admin')` (or
`current_user.positions.exists(p, p == 'org_admin')` (or

`'org_admin' in current_user.roles`) therefore evaluates identically wherever
`'org_admin' in current_user.positions`) therefore evaluates identically wherever

it is written.

`roles: string[]` is the **only canonical** role field. Singular `role` is NOT
`positions: string[]` is the **only canonical** membership field (renamed from

part of this contract — its legacy "overwritten to 'admin' on promotion"
`roles`, ADR-0090 D3). A singular field is NOT part of this contract — its legacy "overwritten to 'admin' on promotion"

behavior is the footgun this eliminates.

Expand Down Expand Up @@ -52,8 +52,8 @@ const result = EvalUser.parse(data);
| **id** | `string` | ✅ | User ID |
| **name** | `string` | optional | Display name |
| **email** | `string` | optional | Email address |
| **roles** | `string[]` | ✅ | Canonical role names assigned to the user (scope-resolved) |
| **isPlatformAdmin** | `boolean` | optional | DERIVED alias of 'platform_admin' in roles. Deprecated. |
| **positions** | `string[]` | ✅ | Canonical position/identity names assigned to the user (scope-resolved) |
| **isPlatformAdmin** | `boolean` | optional | DERIVED alias of 'platform_admin' in positions. Deprecated. |
| **organizationId** | `string \| null` | optional | Active organization ID (null = platform/unscoped) |


Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/identity/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ This section contains all protocol schemas for the identity layer of ObjectStack
<Card href="/docs/references/identity/eval-user" title="Eval User" description="Source: packages/spec/src/identity/eval-user.zod.ts" />
<Card href="/docs/references/identity/identity" title="Identity" description="Source: packages/spec/src/identity/identity.zod.ts" />
<Card href="/docs/references/identity/organization" title="Organization" description="Source: packages/spec/src/identity/organization.zod.ts" />
<Card href="/docs/references/identity/role" title="Role" description="Source: packages/spec/src/identity/role.zod.ts" />
<Card href="/docs/references/identity/position" title="Position" description="Source: packages/spec/src/identity/position.zod.ts" />
<Card href="/docs/references/identity/scim" title="Scim" description="Source: packages/spec/src/identity/scim.zod.ts" />
</Cards>
2 changes: 1 addition & 1 deletion content/docs/references/identity/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"eval-user",
"identity",
"organization",
"role",
"position",
"scim"
]
}
90 changes: 90 additions & 0 deletions content/docs/references/identity/position.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
---
title: Position
description: Position protocol schemas
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}

Position Schema — the flat capability-distribution group (ADR-0090 D3).

A position (岗位, "job role" in NetSuite/Workday terms) is a **named,

assignable bundle of permission sets**: users hold positions

(`sys_user_position`), positions bind permission sets

(`sys_position_permission_set`), and a user's capability is the union of

every set reached that way plus direct grants.

Positions are deliberately **flat** — no `parent`, no hierarchy. The

visibility hierarchy lives on the business-unit tree (`sys_business_unit`,

ADR-0057 D2) and the manager chain (`sys_user.manager_id`); re-adding a

second tree here is the mistake ADR-0057 D5 retired and ADR-0090 D3

finalizes.

VOCABULARY (ADR-0090 D3): the word "role" is reserved-forbidden across the

platform — capability = permission_set, distribution = position,

hierarchy = business_unit. The sole exception is better-auth's internal

`sys_member.role` (org-membership tier), projected as

`org_membership_level`.

**NAMING CONVENTION:**

Position names MUST be lowercase snake_case to prevent security issues.

@example Good position names

- 'sales_manager'

- 'ceo'

- 'region_east_vp'

- 'engineering_lead'

@example Bad position names (will be rejected)

- 'SalesManager' (camelCase)

- 'CEO' (uppercase)

- 'Region East VP' (spaces and uppercase)

<Callout type="info">
**Source:** `packages/spec/src/identity/position.zod.ts`
</Callout>

## TypeScript Usage

```typescript
import { Position } from '@objectstack/spec/identity';
import type { Position } from '@objectstack/spec/identity';

// Validate data
const result = Position.parse(data);
```

---

## Position

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Unique position name (lowercase snake_case) |
| **label** | `string` | ✅ | Display label (e.g. VP of Sales) |
| **description** | `string` | optional | |


---

75 changes: 0 additions & 75 deletions content/docs/references/identity/role.mdx

This file was deleted.

6 changes: 5 additions & 1 deletion content/docs/references/kernel/execution-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,18 @@ const result = ExecutionContext.parse(data);
| **timezone** | `string` | optional | |
| **locale** | `string` | optional | |
| **currency** | `string` | optional | |
| **roles** | `string[]` | ✅ | |
| **positions** | `string[]` | ✅ | |
| **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | |
| **audience** | `Enum<'internal' \| 'external'>` | optional | |
| **onBehalfOf** | `Object` | optional | |
| **permissions** | `string[]` | ✅ | |
| **systemPermissions** | `string[]` | optional | |
| **tabPermissions** | `Record<string, Enum<'visible' \| 'hidden' \| 'default_on' \| 'default_off'>>` | optional | |
| **org_user_ids** | `string[]` | optional | |
| **rlsMembership** | `Record<string, string[]>` | optional | |
| **isSystem** | `boolean` | ✅ | |
| **skipTriggers** | `boolean` | optional | |
| **oauthScopes** | `string[]` | optional | |
| **accessToken** | `string` | optional | |
| **transaction** | `any` | optional | |
| **traceId** | `string` | optional | |
Expand Down
Loading
Loading