diff --git a/package.json b/package.json index 6c52fce5..cf2c829d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0939eb36..b3f36725 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,14 +15,14 @@ importers: specifier: ^3.1076.0 version: 3.1076.0 '@contentrain/mcp': - specifier: 1.8.1 - version: 1.8.1(@gitbeaker/rest@43.8.0)(@octokit/rest@22.0.1) + specifier: 1.10.0 + version: 1.10.0(@gitbeaker/rest@43.8.0)(@octokit/rest@22.0.1) '@contentrain/query': - specifier: ^6.1.1 - version: 6.1.1 + specifier: ^6.2.1 + version: 6.2.1 '@contentrain/types': - specifier: 0.7.0 - version: 0.7.0 + specifier: 0.8.0 + version: 0.8.0 '@gitbeaker/rest': specifier: ^43.8.0 version: 43.8.0 @@ -681,8 +681,8 @@ packages: resolution: {integrity: sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==} engines: {node: '>=v18'} - '@contentrain/mcp@1.8.1': - resolution: {integrity: sha512-cg5dln/QgOBgNgD5RXXqBYHAOAETC60bTZnc98bCy2q5RqqYy1T84NhU/W+5GVEbAIkhD02/WSnK+owBo8EV7Q==} + '@contentrain/mcp@1.10.0': + resolution: {integrity: sha512-kEPbaGscawCbdeKeTMGxeY1KBYWr0GD8uHc3B8NSgIQtwhgtmYskOzPKPooDitQb2F/F67PP1JznjypfeMxoXQ==} hasBin: true peerDependencies: '@gitbeaker/rest': '>=43.0.0' @@ -693,12 +693,12 @@ packages: '@octokit/rest': optional: true - '@contentrain/query@6.1.1': - resolution: {integrity: sha512-RL4ipR/RSUOwKLpNBj/q1XyhOeVfHdei071wktgQVcEM1kb5rVKpBokGtCdUUQEzLPf489WxU2KahdmFBA4yFw==} + '@contentrain/query@6.2.1': + resolution: {integrity: sha512-AWLzwe+ai/e3Rx3VNRJdxpMQC0DepkR0VSml/l9lM6wY+t6KyeQLhLqqz5INlUYrY/138xTzurJAXnvNozUT6g==} hasBin: true - '@contentrain/types@0.7.0': - resolution: {integrity: sha512-TlZ/ysX5fSIhn6vIQlMCElO4/Uv+KeqBn+fUP8tp51XfYUMWJJCVP5fiRptz+sVCTAOn5qYNGyYsUy0MHXFc8Q==} + '@contentrain/types@0.8.0': + resolution: {integrity: sha512-huaAh1Bkwvpfk1JZ6XCkXTh9OKFiNCk6JT8fV3r5xdvDOfYcGEQ7rxOEmAt3nnPr/6Ssu5ED5i/3k0HvAqSs5g==} '@conventional-changelog/git-client@2.6.0': resolution: {integrity: sha512-T+uPDciKf0/ioNNDpMGc8FDsehJClZP0yR3Q5MN6wE/Y/1QZ7F+80OgznnTCOlMEG4AV0LvH2UJi3C/nBnaBUg==} @@ -8136,9 +8136,9 @@ snapshots: conventional-commits-parser: 6.3.0 picocolors: 1.1.1 - '@contentrain/mcp@1.8.1(@gitbeaker/rest@43.8.0)(@octokit/rest@22.0.1)': + '@contentrain/mcp@1.10.0(@gitbeaker/rest@43.8.0)(@octokit/rest@22.0.1)': dependencies: - '@contentrain/types': 0.7.0 + '@contentrain/types': 0.8.0 '@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76) simple-git: 3.36.0 typescript: 5.9.3 @@ -8153,11 +8153,11 @@ snapshots: - '@cfworker/json-schema' - supports-color - '@contentrain/query@6.1.1': + '@contentrain/query@6.2.1': dependencies: - '@contentrain/types': 0.7.0 + '@contentrain/types': 0.8.0 - '@contentrain/types@0.7.0': {} + '@contentrain/types@0.8.0': {} '@conventional-changelog/git-client@2.6.0(conventional-commits-parser@6.3.0)': dependencies: diff --git a/server/plugins/mcp-cloud-server.ts b/server/plugins/mcp-cloud-server.ts index ddbf0cbf..5da1a183 100644 --- a/server/plugins/mcp-cloud-server.ts +++ b/server/plugins/mcp-cloud-server.ts @@ -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' @@ -54,6 +54,10 @@ async function bootInternalMcpServer(): Promise { 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] diff --git a/server/utils/mcp-cloud-runtime.ts b/server/utils/mcp-cloud-runtime.ts index 6fe7c660..f5771dbe 100644 --- a/server/utils/mcp-cloud-runtime.ts +++ b/server/utils/mcp-cloud-runtime.ts @@ -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 | 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) | null = null diff --git a/server/utils/mcp-tool-classes.ts b/server/utils/mcp-tool-classes.ts index 567d9619..925f7da1 100644 --- a/server/utils/mcp-tool-classes.ts +++ b/server/utils/mcp-tool-classes.ts @@ -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 @@ -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), ) @@ -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), ) diff --git a/server/utils/oauth-server/scopes.ts b/server/utils/oauth-server/scopes.ts index d6f95596..0ed7a1c8 100644 --- a/server/utils/oauth-server/scopes.ts +++ b/server/utils/oauth-server/scopes.ts @@ -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', @@ -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(' ')) @@ -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] } @@ -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 } diff --git a/tests/unit/mcp-tenant-fingerprint.test.ts b/tests/unit/mcp-tenant-fingerprint.test.ts new file mode 100644 index 00000000..2356ef7b --- /dev/null +++ b/tests/unit/mcp-tenant-fingerprint.test.ts @@ -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:') + }) +}) diff --git a/tests/unit/oauth-scopes.test.ts b/tests/unit/oauth-scopes.test.ts index fcae8649..8ebc90e6 100644 --- a/tests/unit/oauth-scopes.test.ts +++ b/tests/unit/oauth-scopes.test.ts @@ -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, @@ -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]!) { @@ -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', () => { @@ -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', () => { @@ -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', () => {