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
Binary file added docs/assets/pr/ai-settings-mcp-connections.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/pr/ai-settings-providers.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
257 changes: 136 additions & 121 deletions docs/features/mcp-connectors.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/reference/architecture-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ See [docs/features/plugin-system.md](../features/plugin-system.md).
| `ai-tool-input-object.test.ts` | Every provider-facing site/content tool schema has a plain `type: object` root with no root-level `anyOf`, `oneOf`, or `allOf`, matching Anthropic's tool-schema contract while keeping one cross-provider registry. |
| `ai-tool-schema-ssot.test.ts` | Site write-tool input schemas live once in `src/core/ai/toolSchemas.ts` (re-exported from `@core/ai`). Every registered tool's `inputSchema` is the exact object from `@core/ai` (referential identity check); consumer modules (`writeTools.ts`, `executor.ts`, `tokenRunners.ts`) import from `@core/ai` and do not redeclare local copies. |
| `ai-driver-isolation.test.ts` | Provider SDKs (`@anthropic-ai/claude-agent-sdk`, `@openai/agents`, `@openrouter/agent`), `zod`, and `@anthropic-ai/sdk` are banned repo-wide with no allowed callers. `@modelcontextprotocol/sdk` is **scoped**: allowed only under `server/ai/mcp/` (the MCP server), banned everywhere else (drivers + browser). Drivers talk directly to each provider's REST API over HTTP/SSE and pass TypeBox schemas through as JSON Schema. `src/` and `server/` are both scanned. |
| `ai-mcp-connectors-never-leak.test.ts` | The MCP connector wire projection (`toConnectorView`) and `McpConnectorViewSchema` never expose the token or its hash. Mirrors `ai-credentials-never-leak.test.ts`. |
| `ai-mcp-connectors-never-leak.test.ts` | The MCP connection wire projection (`toConnectionView`) and `McpConnectionViewSchema` never expose personal access tokens, OAuth tokens, or their hashes. Mirrors `ai-credentials-never-leak.test.ts`. |
| `ai-driver-shared-helpers.test.ts` | Two cross-provider helpers must have exactly one source: (1) `parseToolArguments` is exported only from `server/ai/drivers/http/toolArgs.ts` — every driver imports it; private copies (`parseJsonOrEmpty`, etc.) are banned. (2) `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` is declared only in `server/ai/runtime/types.ts` — prompt builders and drivers import it. Divergent copies silently produce different error handling or break prompt caching per provider. |
| `ai-handlers-capability-gated.test.ts` | Every handler under `server/ai/handlers/` calls `requireCapability` or `requireAnyCapability` before doing work. Prevents unauthenticated access to AI endpoints. |
| `ai-credentials-never-leak.test.ts` | AI handler response bodies do not contain credential ciphertext or raw `apiKey` fields. Handlers must project through `toCredentialView()` before serialising a `CredentialRecord`. |
Expand Down
6 changes: 4 additions & 2 deletions server/ai/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import { tryHandleAiCredentials } from './credentials'
import { tryHandleAiConversations } from './conversations'
import { tryHandleAiDefaults } from './defaults'
import { tryHandleAiModels } from './models'
import { tryHandleAiMcpConnectors } from '../mcp/handlers/connectors'
import { tryHandleAiMcpManagement } from '../mcp/handlers/management'
import { tryHandleMcpOAuthAuthorization } from '../mcp/handlers/oauthAuthorization'
import { tryHandleAiEditorBridge } from '../mcp/handlers/editorBridge'

export function tryHandleAi(
Expand All @@ -39,7 +40,8 @@ export function tryHandleAi(
// generic credentials/:id route — both live inside the credentials
// handler so the order is handled there.
return (
tryHandleAiMcpConnectors(req, db, pathname) ??
tryHandleMcpOAuthAuthorization(req, db, url, pathname) ??
tryHandleAiMcpManagement(req, db, pathname) ??
tryHandleAiEditorBridge(req, db, pathname) ??
tryHandleAiAudit(req, db, url, pathname) ??
tryHandleAiChat(req, db, pathname) ??
Expand Down
76 changes: 60 additions & 16 deletions server/ai/mcp/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,18 @@ import { createSqliteClient } from '../../db/sqlite'
import { sqliteMigrations } from '../../db/migrations-sqlite'
import { runMigrations } from '../../db/runMigrations'
import type { DbClient } from '../../db/client'
import { createConnector } from './connectors/store'
import { generateConnectorToken, hashConnectorToken } from './connectors/token'
import { createBearerConnection } from './connectors/store'
import {
generatePersonalAccessToken,
hashMcpSecret,
pkceChallengeForVerifier,
} from './connectors/token'
import { resolveMcpAuth, unauthorizedResponse } from './auth'
import {
createOAuthAuthorizationGrant,
exchangeAuthorizationCode,
registerOAuthClient,
} from './oauth/store'

async function freshDb(): Promise<DbClient> {
const db = createSqliteClient(':memory:')
Expand All @@ -22,10 +31,10 @@ beforeEach(async () => { db = await freshDb() })

describe('mcp auth', () => {
it('resolves a valid bearer token to a connector + capabilities', async () => {
const token = generateConnectorToken()
await createConnector(db, {
userId: 'u1', label: 'L', type: 'remote',
capabilities: ['ai.chat', 'content.manage'], tokenHash: await hashConnectorToken(token),
const token = generatePersonalAccessToken()
await createBearerConnection(db, {
userId: 'u1', label: 'L',
capabilities: ['ai.chat', 'content.manage'], tokenHash: await hashMcpSecret(token),
})
const req = new Request('http://x/_instatic/mcp', { headers: { Authorization: `Bearer ${token}` } })
const res = await resolveMcpAuth(req, db)
Expand All @@ -47,21 +56,21 @@ describe('mcp auth', () => {
})

it('rejects a revoked connector token', async () => {
const token = generateConnectorToken()
const rec = await createConnector(db, {
userId: 'u1', label: 'L', type: 'remote', capabilities: ['ai.chat'], tokenHash: await hashConnectorToken(token),
const token = generatePersonalAccessToken()
const rec = await createBearerConnection(db, {
userId: 'u1', label: 'L', capabilities: ['ai.chat'], tokenHash: await hashMcpSecret(token),
})
await db`update ai_mcp_connectors set revoked_at = current_timestamp where id = ${rec.id}`
const req = new Request('http://x/_instatic/mcp', { headers: { Authorization: `Bearer ${token}` } })
expect((await resolveMcpAuth(req, db)).ok).toBe(false)
})

it('rejects an expired connector token', async () => {
const token = generateConnectorToken()
const token = generatePersonalAccessToken()
// Create a connector that expired 1 second ago.
const pastExpiry = new Date(Date.now() - 1000).toISOString()
const rec = await createConnector(db, {
userId: 'u1', label: 'L', type: 'remote', capabilities: ['ai.chat'], tokenHash: await hashConnectorToken(token),
const rec = await createBearerConnection(db, {
userId: 'u1', label: 'L', capabilities: ['ai.chat'], tokenHash: await hashMcpSecret(token),
ttlDays: 90,
})
// Backdate expires_at to a past timestamp to simulate expiry.
Expand All @@ -71,9 +80,9 @@ describe('mcp auth', () => {
})

it('accepts a grandfathered connector with NULL expires_at (non-expiring)', async () => {
const token = generateConnectorToken()
const rec = await createConnector(db, {
userId: 'u1', label: 'Legacy', type: 'remote', capabilities: ['ai.chat'], tokenHash: await hashConnectorToken(token),
const token = generatePersonalAccessToken()
const rec = await createBearerConnection(db, {
userId: 'u1', label: 'Legacy', capabilities: ['ai.chat'], tokenHash: await hashMcpSecret(token),
})
// Simulate a pre-migration 019 row with no expiry set.
await db`update ai_mcp_connectors set expires_at = null where id = ${rec.id}`
Expand All @@ -82,8 +91,43 @@ describe('mcp auth', () => {
expect(res.ok).toBe(true)
})

it('resolves a resource-bound OAuth access token through the same capability gate', async () => {
const verifier = 'p'.repeat(64)
const client = await registerOAuthClient(db, {
clientName: 'Claude',
redirectUris: ['https://claude.ai/api/mcp/auth_callback'],
})
const { code } = await createOAuthAuthorizationGrant(db, {
userId: 'u1',
clientName: client.clientName,
capabilities: ['ai.chat', 'site.read'],
request: {
responseType: 'code',
clientId: client.clientId,
redirectUri: client.redirectUris[0]!,
codeChallenge: await pkceChallengeForVerifier(verifier),
codeChallengeMethod: 'S256',
scope: 'mcp offline_access',
resource: 'http://x/_instatic/mcp',
},
})
const tokens = await exchangeAuthorizationCode(db, {
code,
clientId: client.clientId,
redirectUri: client.redirectUris[0]!,
codeVerifier: verifier,
resource: 'http://x/_instatic/mcp',
})
const req = new Request('http://x/_instatic/mcp', {
headers: { Authorization: `Bearer ${tokens!.accessToken}` },
})
const result = await resolveMcpAuth(req, db)
expect(result.ok).toBe(true)
if (result.ok) expect(result.capabilities).toEqual(['ai.chat', 'site.read'])
})

it('builds a spec-correct 401 with a resource_metadata pointer', () => {
const r = unauthorizedResponse(new URL('http://x/_instatic/mcp'))
const r = unauthorizedResponse(new Request('http://x/_instatic/mcp'))
expect(r.status).toBe(401)
const wwwAuth = r.headers.get('WWW-Authenticate') ?? ''
expect(wwwAuth).toContain('Bearer')
Expand Down
46 changes: 28 additions & 18 deletions server/ai/mcp/auth.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
/**
* MCP bearer auth. Resolves an incoming request to the connector that owns the
* presented token, yielding its user id + granted capabilities — which flow
* straight into the existing tool capability gate.
*
* Current auth is static bearer tokens. The 401 advertises a Protected
* Resource Metadata pointer (`WWW-Authenticate`) so MCP-aware clients receive
* a spec-shaped challenge even when the bearer token is missing or invalid.
* MCP bearer resolution. The bearer value may be a personal access token or
* a short-lived OAuth access token; both resolve to the same connector grant
* and capability set consumed by the tool gate.
*/
import type { DbClient } from '../../db/client'
import type { CoreCapability } from '@core/capabilities'
import { findConnectorByTokenHash, touchConnectorLastUsed } from './connectors/store'
import { hashConnectorToken } from './connectors/token'
import { findConnectionByTokenHash, touchConnectorLastUsed } from './connectors/store'
import { hashMcpSecret } from './connectors/token'
import { findOAuthAccessGrant } from './oauth/store'
import { mcpProtectedResourceMetadataUrl, mcpResource, MCP_OAUTH_SCOPE } from './oauth/protocol'

export type McpAuthResult =
| { ok: true; connectorId: string; userId: string; capabilities: readonly CoreCapability[] }
Expand All @@ -21,12 +19,18 @@ const BEARER_RE = /^Bearer\s+(.+)$/i
export async function resolveMcpAuth(req: Request, db: DbClient): Promise<McpAuthResult> {
const match = BEARER_RE.exec((req.headers.get('Authorization') ?? '').trim())
if (!match) return { ok: false }
const connector = await findConnectorByTokenHash(db, await hashConnectorToken(match[1]!.trim()))
const token = match[1]!.trim()
const oauthGrant = token.startsWith('imcp_at_')
? await findOAuthAccessGrant(db, token, mcpResource(req))
: null
if (oauthGrant) {
stampLastUsed(db, oauthGrant.connectorId)
return { ok: true, ...oauthGrant }
}

const connector = await findConnectionByTokenHash(db, await hashMcpSecret(token))
if (!connector || !connector.tokenHash) return { ok: false }
// Best-effort last-used stamp; never block the request on it.
void touchConnectorLastUsed(db, connector.id).catch((err) => {
console.error('[ai:mcp] failed to stamp connector last_used_at:', err)
})
stampLastUsed(db, connector.id)
return {
ok: true,
connectorId: connector.id,
Expand All @@ -37,15 +41,21 @@ export async function resolveMcpAuth(req: Request, db: DbClient): Promise<McpAut

/**
* RFC 9728-aware 401. The `resource_metadata` pointer lets spec-compliant
* clients discover the (future) OAuth authorization server.
* clients discover the OAuth authorization server.
*/
export function unauthorizedResponse(url: URL): Response {
const resourceMetadata = `${url.origin}/.well-known/oauth-protected-resource`
export function unauthorizedResponse(req: Request): Response {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: {
'Content-Type': 'application/json',
'WWW-Authenticate': `Bearer resource_metadata="${resourceMetadata}"`,
'WWW-Authenticate': `Bearer resource_metadata="${mcpProtectedResourceMetadataUrl(req)}", scope="${MCP_OAUTH_SCOPE}"`,
},
})
}

function stampLastUsed(db: DbClient, connectorId: string): void {
// Best-effort last-used stamp; never block the request on it.
void touchConnectorLastUsed(db, connectorId).catch((err) => {
console.error('[ai:mcp] failed to stamp connector last_used_at:', err)
})
}
Loading
Loading