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
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.80.0",
"@aws-sdk/client-s3": "^3.1076.0",
"@contentrain/mcp": "1.8.1",
"@contentrain/query": "^6.1.1",
"@contentrain/types": "0.7.0",
"@contentrain/mcp": "1.10.0",
"@contentrain/query": "^6.2.1",
"@contentrain/types": "0.8.0",
"@gitbeaker/rest": "^43.8.0",
"@nuxt/eslint": "1.15.2",
"@nuxt/image": "2.0.0",
Expand Down
34 changes: 17 additions & 17 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion server/plugins/mcp-cloud-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

import { startHttpMcpServerWith } from '@contentrain/mcp/server/http'
import { createStudioGitProvider } from '../providers/git'
import { closeInternalMcp, setInternalMcp } from '../utils/mcp-cloud-runtime'
import { closeInternalMcp, mcpTenantFingerprint, setInternalMcp } from '../utils/mcp-cloud-runtime'

const HEADER_INSTALLATION_ID = 'x-cr-installation-id'
const HEADER_REPO_OWNER = 'x-cr-repo-owner'
Expand All @@ -54,6 +54,10 @@ async function bootInternalMcpServer(): Promise<void> {
port: 0,
host: '127.0.0.1',
sessionTtlMs: 15 * 60 * 1000,
// Bind every session to the tenant identity the proxy injected when it
// was created — a follow-up request whose `x-cr-*` headers resolve to a
// different project 404s instead of reusing someone else's provider.
sessionFingerprint: req => mcpTenantFingerprint(req.headers),
resolveProvider: (req) => {
const headers = req.headers
const installationIdRaw = headers[HEADER_INSTALLATION_ID]
Expand Down
22 changes: 22 additions & 0 deletions server/utils/mcp-cloud-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,28 @@
* named values from a plugin file is brittle across builds.
*/

/**
* Tenant fingerprint for loopback MCP sessions (1.10.0
* `sessionFingerprint`). Derived from the SAME proxy-injected `x-cr-*`
* headers `resolveProvider` reads, so a session created for one
* project can never be replayed against another via a stolen
* `Mcp-Session-Id`: a mismatch makes the loopback answer 404 and the
* client re-initializes against its own provider.
*/
export function mcpTenantFingerprint(headers: Record<string, string | string[] | undefined>): string | undefined {
const pick = (name: string) => {
const value = headers[name]
return typeof value === 'string' ? value : undefined
}

const installationId = pick('x-cr-installation-id')
const owner = pick('x-cr-repo-owner')
const repo = pick('x-cr-repo-name')
if (!installationId || !owner || !repo) return undefined

return `${installationId}:${owner}/${repo}:${pick('x-cr-content-root') ?? ''}`
}

let mcpUrl: string | null = null
let closer: (() => Promise<void>) | null = null

Expand Down
45 changes: 38 additions & 7 deletions server/utils/mcp-tool-classes.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
/**
* Tool classification for the MCP Cloud surfaces — single source for the
* proxy pipeline (write detection) and the OAuth scope registry (scope →
* tool derivation). Deliberately dependency-free beyond MCP's annotations
* so the scope module stays importable from plain unit tests.
* tool derivation). Deliberately dependency-free beyond MCP's annotation
* and availability exports so the scope module stays importable from
* plain unit tests.
*/
import { TOOL_ANNOTATIONS } from '@contentrain/mcp/tools/annotations'
import { TOOL_REQUIREMENTS } from '@contentrain/mcp/tools/availability'

/**
* Merge/review lifecycle is Studio-owned: these tools are capability-gated
Expand All @@ -19,16 +21,44 @@ export const STUDIO_OWNED_LIFECYCLE_TOOLS = new Set([
'contentrain_submit',
])

/**
* Media tools (1.10.0) — derived from MCP's own availability contract
* (`TOOL_REQUIREMENTS[tool].media`). They pass through to the provider's
* media facet and NEVER touch the content branch, so they are excluded
* from the write set (no brain invalidation, no auto-merge reconcile) and
* from the content scopes (they map to `media:read` / `media:write`).
* On today's loopback provider the facet is absent, so the MCP server
* hides them from tools/list entirely — the sets exist so scope
* enforcement is already correct the day the facet ships.
*/
export const MEDIA_TOOL_NAMES = new Set(
Object.entries(TOOL_REQUIREMENTS)
.filter(([, requirement]) => (requirement as { media?: boolean }).media === true)
.map(([name]) => name),
)

export const MEDIA_READ_TOOL_NAMES = new Set(
[...MEDIA_TOOL_NAMES].filter(name => TOOL_ANNOTATIONS[name]?.readOnlyHint === true),
)

export const MEDIA_WRITE_TOOL_NAMES = new Set(
[...MEDIA_TOOL_NAMES].filter(name => TOOL_ANNOTATIONS[name]?.readOnlyHint !== true),
)

/**
* Tools whose effects can land on the content branch — derived from MCP's
* own annotations (`readOnlyHint: false`) so a future MCP release that
* opens e.g. `contentrain_bulk` to remote providers is covered without a
* Studio change. Tools that are still localWorktree-gated merely cost a
* harmless no-op reconcile if invoked.
* Studio change. Lifecycle tools are excluded (Studio owns that path) and
* so are media writes (they go through the media service, not git).
* Tools that are still localWorktree-gated merely cost a harmless no-op
* reconcile if invoked.
*/
export const WRITE_TOOL_NAMES = new Set(
Object.entries(TOOL_ANNOTATIONS)
.filter(([name, annotation]) => annotation.readOnlyHint !== true && !STUDIO_OWNED_LIFECYCLE_TOOLS.has(name))
.filter(([name, annotation]) => annotation.readOnlyHint !== true
&& !STUDIO_OWNED_LIFECYCLE_TOOLS.has(name)
&& !MEDIA_TOOL_NAMES.has(name))
.map(([name]) => name),
)

Expand All @@ -41,12 +71,13 @@ export const METADATA_TOOL_NAMES = new Set([

/**
* Content reads — every readOnly tool that is neither lifecycle nor
* metadata (content_list, validate, scan, doctor, …).
* metadata nor media (content_list, validate, scan, doctor, …).
*/
export const READ_TOOL_NAMES = new Set(
Object.entries(TOOL_ANNOTATIONS)
.filter(([name, annotation]) => annotation.readOnlyHint === true
&& !STUDIO_OWNED_LIFECYCLE_TOOLS.has(name)
&& !METADATA_TOOL_NAMES.has(name))
&& !METADATA_TOOL_NAMES.has(name)
&& !MEDIA_TOOL_NAMES.has(name))
.map(([name]) => name),
)
31 changes: 21 additions & 10 deletions server/utils/oauth-server/scopes.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
/**
* Scope registry for the remote MCP OAuth surface.
*
* Six scopes are defined; four are advertised. `media:*` maps to no remote
* tool today (the media stack isn't part of the MCP Cloud tool surface) —
* the scopes stay registered so already-issued grants keep validating when
* media tools ship, but they are kept out of `scopes_supported` until then:
* clients request everything a server advertises, and a dead scope on the
* consent screen is a directory-review liability.
* Six scopes are defined; four are advertised. `media:*` maps to the
* 1.10.0 media tools, but the loopback provider doesn't implement the
* media facet yet — the MCP server hides those tools from tools/list
* entirely (TOOL_REQUIREMENTS), so the scopes stay OUT of
* `scopes_supported` until the facet ships: clients request everything a
* server advertises, and a scope granting invisible tools is a
* directory-review liability. Flip ADVERTISED_SCOPES when the facet lands.
*/
import { METADATA_TOOL_NAMES, READ_TOOL_NAMES, WRITE_TOOL_NAMES } from '../mcp-tool-classes'
import { MEDIA_READ_TOOL_NAMES, MEDIA_WRITE_TOOL_NAMES, METADATA_TOOL_NAMES, READ_TOOL_NAMES, WRITE_TOOL_NAMES } from '../mcp-tool-classes'

export const SUPPORTED_SCOPES = [
'content:read',
Expand Down Expand Up @@ -71,9 +72,11 @@ export function scopeIncludes(scope: string, member: SupportedScope): boolean {

/**
* Which tools a grant's scope may call — feeds the proxy pipeline's
* allowlist. `media:*` maps to no remote tool today (reserved until the
* media stack reaches the MCP Cloud surface); lifecycle tools are excluded
* from every scope (Studio owns the merge/review lifecycle).
* allowlist. Media scopes map to the 1.10.0 media tools; the tools only
* materialize in tools/list once the loopback provider ships its media
* facet, but the scope enforcement is already correct for that day.
* Lifecycle tools are excluded from every scope (Studio owns the
* merge/review lifecycle).
*/
export function toolsForScope(scope: string): string[] {
const parts = new Set(scope.split(' '))
Expand All @@ -88,6 +91,12 @@ export function toolsForScope(scope: string): string[] {
if (parts.has('content:write')) {
for (const name of WRITE_TOOL_NAMES) tools.add(name)
}
if (parts.has('media:read')) {
for (const name of MEDIA_READ_TOOL_NAMES) tools.add(name)
}
if (parts.has('media:write')) {
for (const name of MEDIA_WRITE_TOOL_NAMES) tools.add(name)
}

return [...tools]
}
Expand All @@ -105,5 +114,7 @@ export function scopeForTool(tool: string): SupportedScope | null {
if (METADATA_TOOL_NAMES.has(tool)) return 'project:metadata'
if (READ_TOOL_NAMES.has(tool)) return 'content:read'
if (WRITE_TOOL_NAMES.has(tool)) return 'content:write'
if (MEDIA_READ_TOOL_NAMES.has(tool)) return 'media:read'
if (MEDIA_WRITE_TOOL_NAMES.has(tool)) return 'media:write'
return null
}
40 changes: 40 additions & 0 deletions tests/unit/mcp-tenant-fingerprint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { mcpTenantFingerprint } from '../../server/utils/mcp-cloud-runtime'

/**
* Session ↔ tenant binding for the loopback MCP server (1.10.0
* `sessionFingerprint`). The fingerprint must be deterministic over the
* proxy-injected identity headers and absent when they are — a session
* created for one project must never validate for another.
*/
describe('mcpTenantFingerprint', () => {
const headers = {
'x-cr-installation-id': '42',
'x-cr-repo-owner': 'acme',
'x-cr-repo-name': 'site',
'x-cr-content-root': 'content',
}

it('derives a stable fingerprint from the tenant headers', () => {
expect(mcpTenantFingerprint(headers)).toBe('42:acme/site:content')
expect(mcpTenantFingerprint({ ...headers })).toBe(mcpTenantFingerprint(headers))
})

it('changes when any identity component changes', () => {
const base = mcpTenantFingerprint(headers)
expect(mcpTenantFingerprint({ ...headers, 'x-cr-installation-id': '43' })).not.toBe(base)
expect(mcpTenantFingerprint({ ...headers, 'x-cr-repo-name': 'other' })).not.toBe(base)
expect(mcpTenantFingerprint({ ...headers, 'x-cr-content-root': '' })).not.toBe(base)
})

it('returns undefined when required headers are missing or malformed', () => {
expect(mcpTenantFingerprint({})).toBeUndefined()
expect(mcpTenantFingerprint({ ...headers, 'x-cr-repo-owner': undefined })).toBeUndefined()
expect(mcpTenantFingerprint({ ...headers, 'x-cr-installation-id': ['42', '43'] })).toBeUndefined()
})

it('treats a missing content root as the empty root', () => {
const { 'x-cr-content-root': _root, ...rest } = headers
expect(mcpTenantFingerprint(rest)).toBe('42:acme/site:')
})
})
34 changes: 29 additions & 5 deletions tests/unit/oauth-scopes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import {
toolsForScope,
} from '../../server/utils/oauth-server/scopes'
import {
MEDIA_READ_TOOL_NAMES,
MEDIA_TOOL_NAMES,
MEDIA_WRITE_TOOL_NAMES,
METADATA_TOOL_NAMES,
READ_TOOL_NAMES,
STUDIO_OWNED_LIFECYCLE_TOOLS,
Expand Down Expand Up @@ -65,9 +68,9 @@ describe('scopeIncludes', () => {
})
})

describe('tool classification (derived from TOOL_ANNOTATIONS)', () => {
it('partitions the tool surface: metadata / read / write / lifecycle are disjoint', () => {
const sets = [METADATA_TOOL_NAMES, READ_TOOL_NAMES, WRITE_TOOL_NAMES, STUDIO_OWNED_LIFECYCLE_TOOLS]
describe('tool classification (derived from TOOL_ANNOTATIONS + TOOL_REQUIREMENTS)', () => {
it('partitions the tool surface: metadata / read / write / media / lifecycle are disjoint', () => {
const sets = [METADATA_TOOL_NAMES, READ_TOOL_NAMES, WRITE_TOOL_NAMES, MEDIA_TOOL_NAMES, STUDIO_OWNED_LIFECYCLE_TOOLS]
for (let a = 0; a < sets.length; a++) {
for (let b = a + 1; b < sets.length; b++) {
for (const name of sets[a]!) {
Expand All @@ -86,6 +89,18 @@ describe('tool classification (derived from TOOL_ANNOTATIONS)', () => {
expect(WRITE_TOOL_NAMES.has('contentrain_model_delete')).toBe(true)
expect(STUDIO_OWNED_LIFECYCLE_TOOLS.has('contentrain_merge')).toBe(true)
})

it('classifies the 1.10.0 media tools by facet requirement, split on readOnlyHint', () => {
expect(MEDIA_READ_TOOL_NAMES).toEqual(new Set(['contentrain_media_list', 'contentrain_media_get']))
expect(MEDIA_WRITE_TOOL_NAMES).toEqual(new Set(['contentrain_media_ingest', 'contentrain_media_update', 'contentrain_media_delete']))
})

it('media writes never enter the content-branch write set (no brain/auto-merge)', () => {
for (const name of MEDIA_TOOL_NAMES) {
expect(WRITE_TOOL_NAMES.has(name), `${name} must not trigger brain invalidation`).toBe(false)
expect(READ_TOOL_NAMES.has(name), `${name} must not ride content:read`).toBe(false)
}
})
})

describe('toolsForScope', () => {
Expand All @@ -112,8 +127,15 @@ describe('toolsForScope', () => {
}
})

it('media scopes and offline_access grant no tools today (reserved)', () => {
expect(toolsForScope('media:read media:write offline_access')).toEqual([])
it('media scopes grant exactly the media tools; offline_access grants none', () => {
expect(new Set(toolsForScope('media:read'))).toEqual(MEDIA_READ_TOOL_NAMES)
expect(new Set(toolsForScope('media:read media:write'))).toEqual(MEDIA_TOOL_NAMES)
expect(toolsForScope('offline_access')).toEqual([])
})

it('content scopes never leak media tools', () => {
const contentTools = toolsForScope('content:read content:write project:metadata')
for (const name of MEDIA_TOOL_NAMES) expect(contentTools).not.toContain(name)
})

it('scopeAllowsTool agrees with the derivation', () => {
Expand All @@ -127,6 +149,8 @@ describe('scopeForTool (step-up challenge routing)', () => {
expect(scopeForTool('contentrain_status')).toBe('project:metadata')
expect(scopeForTool('contentrain_content_list')).toBe('content:read')
expect(scopeForTool('contentrain_content_save')).toBe('content:write')
expect(scopeForTool('contentrain_media_get')).toBe('media:read')
expect(scopeForTool('contentrain_media_ingest')).toBe('media:write')
})

it('returns null for lifecycle/unknown tools — no step-up loop', () => {
Expand Down
Loading