Skip to content

Commit 5be00c3

Browse files
authored
feat(mcp): OAuth 2.1 authorization for /api/v1/mcp — self-serve connect for claude.ai / Claude Desktop / Claude Code (#2709)
Spec-compliant MCP authorization: each deployment is its own OAuth 2.1 authorization server (embedded better-auth oauth-provider). RFC 9728/8414 discovery (incl. path-inserted variants), unauthenticated RFC 7591 DCR, authorization-code + PKCE with RFC 8707 resource binding, local JWKS token verification resolving to the principal-bound ExecutionContext via the single shared resolveAuthzContext, coarse tool-family scopes (data:read / data:write / actions:execute) enforced at tool dispatch, WWW-Authenticate discovery advertisement on 401, TLS required with loopback exempt. API keys unchanged as the headless track (regression tested). Docs, changeset, e2e-verified (discovery → DCR → PKCE → tool calls under the logged-in user's RLS). Closes #2698
1 parent 54e43db commit 5be00c3

34 files changed

Lines changed: 1639 additions & 281 deletions
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/plugin-auth': minor
4+
'@objectstack/runtime': minor
5+
'@objectstack/mcp': minor
6+
---
7+
8+
feat(mcp): spec-compliant OAuth 2.1 authorization for `/api/v1/mcp` (#2698)
9+
10+
Any OAuth-capable MCP client (claude.ai custom connectors, Claude Desktop,
11+
Claude Code) can now connect to a deployment **self-serve**: no admin-minted
12+
API key, no central registry — you sign in through the browser as yourself and
13+
every tool call runs under your own permissions and row-level security.
14+
15+
**Each deployment is its own authorization server**, backed by the embedded
16+
better-auth instance (`@better-auth/oauth-provider`). Rationale for the design
17+
decisions lives in #2698; the moving parts:
18+
19+
- **Discovery**: `/.well-known/oauth-protected-resource` (RFC 9728, incl. the
20+
path-inserted variant for `/api/v1/mcp`) and
21+
`/.well-known/oauth-authorization-server` (RFC 8414, incl. the path-inserted
22+
variant for the `/api/v1/auth` issuer) are served from the deployment origin.
23+
401s from `/api/v1/mcp` advertise the resource metadata via
24+
`WWW-Authenticate`, so clients bootstrap the flow automatically.
25+
- **Dynamic Client Registration (RFC 7591)** is enabled (unauthenticated, as
26+
the MCP spec requires) whenever the MCP surface is on — every deployment is a
27+
distinct AS, so clients cannot ship pre-registered IDs. Force it either way
28+
with `OS_OIDC_DCR_ENABLED` or the new `plugins.dynamicClientRegistration`
29+
auth-config field. The embedded AS itself now auto-enables when
30+
`OS_MCP_SERVER_ENABLED=true` (explicit `OS_OIDC_PROVIDER_ENABLED=false` still
31+
wins).
32+
- **Authorization-code + PKCE** flow with RFC 8707 resource binding: access
33+
tokens are minted with `aud=<origin>/api/v1/mcp` and verified locally
34+
(signature/issuer/audience/expiry) against the deployment's own JWKS —
35+
fail-closed parity with API keys: unknown/expired/wrong-audience tokens,
36+
sub-less M2M tokens, or a presented-but-invalid bearer never fall back to an
37+
ambient session, they 401.
38+
- **Token → ExecutionContext**: a valid access token resolves to the same
39+
principal-bound `ExecutionContext` as every other credential, single-sourced
40+
through `resolveAuthzContext` — OAuth adds a second *provenance* for the
41+
principal, not a second authz model. `ExecutionContext` gains an optional
42+
`oauthScopes` field carrying the token's granted scopes.
43+
- **Coarse scopes → tool families**, enforced at tool dispatch: `data:read`
44+
(list/describe/query/get), `data:write` (create/update/delete),
45+
`actions:execute` (list_actions/run_action). Constants live in
46+
`@objectstack/spec/ai` (`MCP_OAUTH_SCOPES`). Tools outside the grant are not
47+
registered — and therefore rejected — for that request. API-key and session
48+
principals are unaffected (not scope-limited).
49+
- **TLS required, localhost exempt** (OAuth 2.1): on a plain-HTTP non-loopback
50+
origin the OAuth track stays dark (no metadata, no bearer acceptance) and the
51+
endpoint remains API-key-only. Local clients reach intranet deployments;
52+
claude.ai web connectors additionally need public HTTPS reachability.
53+
54+
**API keys are unchanged** (dual-track): `x-api-key` / `Authorization: ApiKey` /
55+
`Authorization: Bearer osk_…` keep working exactly as before for CI and
56+
headless agents — covered by new regression tests.

content/docs/ai/agents.mdx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,44 @@ the Builder, never silently re-routed. (For data query and source-mode authoring
4242
the **open edition** ships neither agent and uses `@objectstack/mcp` (BYO-AI)
4343
instead — see the callout in the [AI Overview](/docs/ai).)
4444

45+
## Connect your AI (BYO-AI over MCP)
46+
47+
Every deployment with `OS_MCP_SERVER_ENABLED=true` serves MCP at
48+
`/api/v1/mcp`, with two authentication tracks:
49+
50+
- **OAuth 2.1 (interactive clients — recommended).** Each deployment is its
51+
own spec-compliant authorization server: the endpoint publishes
52+
`.well-known/oauth-protected-resource` / `.well-known/oauth-authorization-server`
53+
discovery metadata, clients self-register via Dynamic Client Registration
54+
(RFC 7591) and run an authorization-code + PKCE flow. Nothing is
55+
pre-registered with Anthropic or any central service, so **self-hosted and
56+
private deployments work out of the box**. You log in through the browser
57+
as yourself; every tool call runs under your permissions and RLS. Consent
58+
scopes (`data:read`, `data:write`, `actions:execute`) narrow which tool
59+
families the client gets.
60+
- **API key (headless).** Mint a key (`POST /api/v1/keys`, shown once) and
61+
send it as `x-api-key` — for CI, scripts, and agents without a browser.
62+
63+
Per client:
64+
65+
| Client | How to connect |
66+
|---|---|
67+
| **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_..."`. |
68+
| **Claude Desktop** | Settings → Connectors → *Add custom connector* → paste the MCP URL → sign in when prompted. |
69+
| **claude.ai (web)** | Settings → Connectors → *Add custom connector* → paste the MCP URL. |
70+
| **Other MCP clients** | Any client implementing MCP authorization discovers the flow automatically; header-based clients can send the API key instead. |
71+
72+
<Callout type="info">
73+
**Private / intranet deployments.** OAuth requires HTTPS (localhost is
74+
exempt, per OAuth 2.1). **Local** clients — Claude Code and Claude Desktop —
75+
run on your machine, so they can reach an intranet-only deployment (an
76+
internal CA works). **claude.ai web connectors** connect from Anthropic's
77+
servers, so they additionally need the MCP endpoint reachable from the public
78+
internet — a network decision, not a platform switch. On a plain-HTTP
79+
non-localhost deployment the OAuth track stays dark and the endpoint is
80+
API-key-only, fail-closed.
81+
</Callout>
82+
4583
## You extend the platform with **skills**, not agents
4684

4785
`*.agent.ts` is **closed to third parties** — the `agent` metadata type is

content/docs/getting-started/build-with-claude-code.mdx

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -309,9 +309,29 @@ security apply to the agent exactly as they do to a person. The support desk you
309309
built in six steps is now a backend an agent can *run* — not just a database it
310310
can read.
311311

312-
See [Actions as Tools](/docs/ai/actions-as-tools) for the `run_action` bridge and
313-
[the MCP reference](/docs/references/ai/mcp) for enabling the server and connecting
314-
a client.
312+
Connecting is self-serve. Start the app with the MCP surface on
313+
(`OS_MCP_SERVER_ENABLED=true`), then add it to your client — the deployment is
314+
its own OAuth 2.1 authorization server, so interactive clients just open a
315+
browser login (you connect as yourself, no admin-minted credentials):
316+
317+
```bash
318+
claude mcp add --transport http support-desk https://your-deployment.example.com/api/v1/mcp
319+
# first tool use opens a browser login — you're connected as yourself
320+
```
321+
322+
For headless callers (CI, scripts), mint an API key instead
323+
(`POST /api/v1/keys`, shown once) and pass it as a header:
324+
325+
```bash
326+
claude mcp add --transport http support-desk https://your-deployment.example.com/api/v1/mcp \
327+
--header "x-api-key: osk_..."
328+
```
329+
330+
See [Connect your AI](/docs/ai/agents#connect-your-ai-byo-ai-over-mcp) for
331+
per-client instructions (claude.ai / Claude Desktop / Claude Code and the
332+
private-deployment reachability note), [Actions as Tools](/docs/ai/actions-as-tools)
333+
for the `run_action` bridge, and [the MCP reference](/docs/references/ai/mcp) for
334+
enabling the server.
315335

316336
## Recap — where each guardrail sat
317337

content/docs/references/api/metadata.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ Metadata query with filtering, sorting, and pagination
314314

315315
| Property | Type | Required | Description |
316316
| :--- | :--- | :--- | :--- |
317-
| **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 |
317+
| **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 |
318318
| **namespaces** | `string[]` | optional | Filter by namespaces |
319319
| **packageId** | `string` | optional | Filter by owning package |
320320
| **search** | `string` | optional | Full-text search query |
@@ -349,7 +349,7 @@ Metadata query with filtering, sorting, and pagination
349349

350350
| Property | Type | Required | Description |
351351
| :--- | :--- | :--- | :--- |
352-
| **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 |
352+
| **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 |
353353
| **name** | `string` || Item name (snake_case) |
354354
| **data** | `Record<string, any>` || Metadata payload |
355355
| **namespace** | `string` | optional | Optional namespace |

content/docs/references/identity/eval-user.mdx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@ RLS, client UI gates) under the canonical variable name `current_user`
1313

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

16-
`current_user.roles.exists(r, r == 'org_admin')` (or
16+
`current_user.positions.exists(p, p == 'org_admin')` (or
1717

18-
`'org_admin' in current_user.roles`) therefore evaluates identically wherever
18+
`'org_admin' in current_user.positions`) therefore evaluates identically wherever
1919

2020
it is written.
2121

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

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

2626
behavior is the footgun this eliminates.
2727

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

5959

content/docs/references/identity/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@ This section contains all protocol schemas for the identity layer of ObjectStack
99
<Card href="/docs/references/identity/eval-user" title="Eval User" description="Source: packages/spec/src/identity/eval-user.zod.ts" />
1010
<Card href="/docs/references/identity/identity" title="Identity" description="Source: packages/spec/src/identity/identity.zod.ts" />
1111
<Card href="/docs/references/identity/organization" title="Organization" description="Source: packages/spec/src/identity/organization.zod.ts" />
12-
<Card href="/docs/references/identity/role" title="Role" description="Source: packages/spec/src/identity/role.zod.ts" />
12+
<Card href="/docs/references/identity/position" title="Position" description="Source: packages/spec/src/identity/position.zod.ts" />
1313
<Card href="/docs/references/identity/scim" title="Scim" description="Source: packages/spec/src/identity/scim.zod.ts" />
1414
</Cards>

content/docs/references/identity/meta.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"eval-user",
55
"identity",
66
"organization",
7-
"role",
7+
"position",
88
"scim"
99
]
1010
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
---
2+
title: Position
3+
description: Position protocol schemas
4+
---
5+
6+
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
7+
8+
Position Schema — the flat capability-distribution group (ADR-0090 D3).
9+
10+
A position (岗位, "job role" in NetSuite/Workday terms) is a **named,
11+
12+
assignable bundle of permission sets**: users hold positions
13+
14+
(`sys_user_position`), positions bind permission sets
15+
16+
(`sys_position_permission_set`), and a user's capability is the union of
17+
18+
every set reached that way plus direct grants.
19+
20+
Positions are deliberately **flat** — no `parent`, no hierarchy. The
21+
22+
visibility hierarchy lives on the business-unit tree (`sys_business_unit`,
23+
24+
ADR-0057 D2) and the manager chain (`sys_user.manager_id`); re-adding a
25+
26+
second tree here is the mistake ADR-0057 D5 retired and ADR-0090 D3
27+
28+
finalizes.
29+
30+
VOCABULARY (ADR-0090 D3): the word "role" is reserved-forbidden across the
31+
32+
platform — capability = permission_set, distribution = position,
33+
34+
hierarchy = business_unit. The sole exception is better-auth's internal
35+
36+
`sys_member.role` (org-membership tier), projected as
37+
38+
`org_membership_level`.
39+
40+
**NAMING CONVENTION:**
41+
42+
Position names MUST be lowercase snake_case to prevent security issues.
43+
44+
@example Good position names
45+
46+
- 'sales_manager'
47+
48+
- 'ceo'
49+
50+
- 'region_east_vp'
51+
52+
- 'engineering_lead'
53+
54+
@example Bad position names (will be rejected)
55+
56+
- 'SalesManager' (camelCase)
57+
58+
- 'CEO' (uppercase)
59+
60+
- 'Region East VP' (spaces and uppercase)
61+
62+
<Callout type="info">
63+
**Source:** `packages/spec/src/identity/position.zod.ts`
64+
</Callout>
65+
66+
## TypeScript Usage
67+
68+
```typescript
69+
import { Position } from '@objectstack/spec/identity';
70+
import type { Position } from '@objectstack/spec/identity';
71+
72+
// Validate data
73+
const result = Position.parse(data);
74+
```
75+
76+
---
77+
78+
## Position
79+
80+
### Properties
81+
82+
| Property | Type | Required | Description |
83+
| :--- | :--- | :--- | :--- |
84+
| **name** | `string` || Unique position name (lowercase snake_case) |
85+
| **label** | `string` || Display label (e.g. VP of Sales) |
86+
| **description** | `string` | optional | |
87+
88+
89+
---
90+

content/docs/references/identity/role.mdx

Lines changed: 0 additions & 75 deletions
This file was deleted.

content/docs/references/kernel/execution-context.mdx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,18 @@ const result = ExecutionContext.parse(data);
5454
| **timezone** | `string` | optional | |
5555
| **locale** | `string` | optional | |
5656
| **currency** | `string` | optional | |
57-
| **roles** | `string[]` || |
57+
| **positions** | `string[]` || |
58+
| **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | |
59+
| **audience** | `Enum<'internal' \| 'external'>` | optional | |
60+
| **onBehalfOf** | `Object` | optional | |
5861
| **permissions** | `string[]` || |
5962
| **systemPermissions** | `string[]` | optional | |
6063
| **tabPermissions** | `Record<string, Enum<'visible' \| 'hidden' \| 'default_on' \| 'default_off'>>` | optional | |
6164
| **org_user_ids** | `string[]` | optional | |
6265
| **rlsMembership** | `Record<string, string[]>` | optional | |
6366
| **isSystem** | `boolean` || |
6467
| **skipTriggers** | `boolean` | optional | |
68+
| **oauthScopes** | `string[]` | optional | |
6569
| **accessToken** | `string` | optional | |
6670
| **transaction** | `any` | optional | |
6771
| **traceId** | `string` | optional | |

0 commit comments

Comments
 (0)