Skip to content

Commit 4c29564

Browse files
ABB65Contentrain
andauthored
feat(mcp): media facet — the 1.10.0 media tools over MCP Cloud (#146)
Implements RepoProvider.media on the loopback provider so contentrain_media_list/get/ingest/update/delete work on both MCP Cloud surfaces, with policy at the proxy and mechanism at the loopback. - server/utils/mcp-media-facet.ts: the contract MediaProvider as a deterministic passthrough over Studio's ee media stack — cursor pagination, tenant isolation (get→null / update+delete→404 on foreign assets), and the ingest quota dance (fetchRemoteMedia SSRF/MIME/size → reserveStorageIfAllowed → upload source:'agent' → reconcile/release). Returns null when useMediaProvider() is null (CE / no R2) so the tools stay hidden. - proxy: STUDIO_HEADERS + x-cr-{project-id,workspace-id,media-owner,plan}, injected ONLY when mediaEligible (media stack + plan media.upload + cdn_enabled + owner). Media tools are excluded from the empty-allowlist "unrestricted" rule — a key needs media_enabled or an explicit listing, so existing keys never silently gain URL ingest / destructive delete. - loopback: resolveProvider builds the facet from the four proxy headers; git.ts carries RepoProvider.media; the tenant fingerprint gains the project id so an eligibility flip re-inits the session within a request. - key surface: mcp_cloud_keys.media_enabled (migration 018, default false) + create route + panel "Allow media tools" toggle + Media badge. - OAuth: advertisedScopes() adds media:read/media:write only where the media stack is configured; the 4 well-known routes inject availability; scope→tool mapping + step-up already routed media (from #145). - deps already at 1.10.0 (#145). openWorldHint present on all 24 tools. - tests: facet unit suite (18), fingerprint + scope/metadata units, proxy eligibility matrix + key media gate + oauth media path, contract media_enabled column case. lint/typecheck clean; unit+integration+nuxt 1048, contract 105, managed e2e 12/12 (media absent without R2). Co-authored-by: Contentrain <mcp@contentrain.io>
1 parent 003faee commit 4c29564

33 files changed

Lines changed: 947 additions & 53 deletions

.contentrain/content/system/error-messages/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,11 @@
140140
"media.file_not_found_storage": "The file was not found in storage. It may have been removed.",
141141
"media.file_too_large": "File exceeds {limit}MB limit.",
142142
"media.file_type_not_allowed": "File type not allowed: {type}",
143+
"media.invalid_cursor": "Invalid pagination cursor. Start a new listing without a cursor.",
143144
"media.library_upgrade": "Asset Manager is available on all plans. {plan} includes {mediaStorage} storage. Upgrade to {toPlan} for {toMediaStorage} storage and custom variants.",
144145
"media.list_failed": "Failed to list media assets: {detail}",
145146
"media.no_file_provided": "No file was provided for upload.",
147+
"media.rename_not_supported": "Renaming media assets is not supported over MCP. Update alt text or tags instead.",
146148
"media.storage_not_configured": "Media storage is not configured. Contact your administrator to set up R2 storage credentials.",
147149
"media.update_failed": "Failed to update media asset: {detail}",
148150
"media.upload_upgrade": "Media upload is available on all plans. {plan} includes {mediaStorage} storage and {maxFileSize} file size limit. Upgrade to {toPlan} for {toMediaStorage} storage and {toMaxFileSize} file limit.",

.contentrain/content/system/ui-strings/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,9 @@
471471
"mcp_cloud.key_created_title": "API key created",
472472
"mcp_cloud.key_created_warning": "Copy your key now — it won't be shown again.",
473473
"mcp_cloud.last_used": "Last used {when}",
474+
"mcp_cloud.media_badge": "Media",
475+
"mcp_cloud.media_enabled_hint": "Lets this key list, ingest (from a URL), update and delete media assets. Requires the media add-on and CDN delivery on the project.",
476+
"mcp_cloud.media_enabled_label": "Allow media tools",
474477
"mcp_cloud.name_label": "Key name",
475478
"mcp_cloud.name_placeholder": "My MCP agent",
476479
"mcp_cloud.never_used": "Never used",

CLAUDE.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,22 @@ rate-limited but free. Keys live in `mcp_cloud_keys` (SHA-256 hashed); UI is
293293
(15 min TTL). Multiple instances need sticky sessions for `mcp-session-id`
294294
affinity; rate limit (Redis) and quota (DB) are already instance-safe.
295295

296+
**Media facet (MCP 1.10.0's 5 media tools).** The facet is
297+
`RepoProvider.media` — absent = the tools are hidden from `tools/list`.
298+
Policy lives at the **proxy** (`runMcpCloudProxy` computes `mediaEligible`
299+
= media stack + plan `media.upload` + project `cdn_enabled` + workspace
300+
owner), which injects `x-cr-project-id`/`x-cr-workspace-id`/`x-cr-media-owner`/`x-cr-plan`
301+
ONLY when eligible; the loopback (`mcp-cloud-server.ts`) turns those into
302+
`buildMcpMediaFacet` (`server/utils/mcp-media-facet.ts`), a passthrough
303+
over the ee media stack (`useMediaProvider`) that owns SSRF/MIME/size (via
304+
`fetchRemoteMedia`) + storage-quota + tenant isolation. Key-surface
305+
exception: media tools are **never** covered by the empty-`allowed_tools`
306+
"unrestricted" rule — a key needs `media_enabled=true` or an explicit
307+
listing (else existing keys would silently gain URL ingest + destructive
308+
delete). OAuth advertises `media:read`/`media:write` only where the media
309+
stack is configured (`advertisedScopes`). The tenant fingerprint includes
310+
project id, so an eligibility flip re-inits the session within one request.
311+
296312
**Two deliberate boundaries — keep them in mind when changing this path:**
297313

298314
- **Reduced tool surface.** The loopback server runs against Studio's

app/components/organisms/WorkspaceMcpCloudPanel.vue

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ interface McpCloudKey {
1414
key_prefix: string
1515
project_id: string
1616
allowed_tools: string[]
17+
media_enabled: boolean
1718
rate_limit_per_minute: number
1819
monthly_call_limit: number | null
1920
last_used_at: string | null
@@ -33,6 +34,7 @@ const loading = ref(true)
3334
3435
const newKeyName = ref('')
3536
const newKeyProjectId = ref<string>('')
37+
const newKeyMediaEnabled = ref(false)
3638
const creating = ref(false)
3739
3840
const revealedKey = ref<string | null>(null)
@@ -118,13 +120,15 @@ async function handleCreate() {
118120
body: {
119121
name: newKeyName.value.trim(),
120122
projectId: newKeyProjectId.value,
123+
mediaEnabled: newKeyMediaEnabled.value,
121124
},
122125
},
123126
)
124127
revealedKey.value = created.key
125128
revealedProjectId.value = created.project_id ?? newKeyProjectId.value
126129
revealDialogOpen.value = true
127130
newKeyName.value = ''
131+
newKeyMediaEnabled.value = false
128132
toast.success(t('mcp_cloud.create_success'))
129133
await refresh()
130134
}
@@ -198,8 +202,13 @@ function formatRelative(iso: string | null): string {
198202
<li v-for="key in keys" :key="key.id" class="flex items-center gap-3 px-4 py-3">
199203
<span class="icon-[annon--key] size-4 text-muted" aria-hidden="true" />
200204
<div class="min-w-0 flex-1">
201-
<div class="truncate text-sm font-medium text-heading dark:text-secondary-100">
202-
{{ key.name }}
205+
<div class="flex items-center gap-2">
206+
<span class="truncate text-sm font-medium text-heading dark:text-secondary-100">
207+
{{ key.name }}
208+
</span>
209+
<AtomsBadge v-if="key.media_enabled" variant="info" size="sm">
210+
{{ t('mcp_cloud.media_badge') }}
211+
</AtomsBadge>
203212
</div>
204213
<div class="text-xs text-muted">
205214
<span class="font-mono">{{ key.key_prefix }}…</span>
@@ -250,6 +259,16 @@ function formatRelative(iso: string | null): string {
250259
class="mt-1.5"
251260
/>
252261
</div>
262+
<div>
263+
<AtomsFormSwitch
264+
:model-value="newKeyMediaEnabled"
265+
:label="t('mcp_cloud.media_enabled_label')"
266+
@update:model-value="newKeyMediaEnabled = $event"
267+
/>
268+
<p class="mt-1 text-xs text-muted">
269+
{{ t('mcp_cloud.media_enabled_hint') }}
270+
</p>
271+
</div>
253272
<AtomsBaseButton type="submit" variant="primary" size="md" :disabled="!newKeyName.trim() || !newKeyProjectId || creating">
254273
{{ creating ? t('mcp_cloud.creating') : t('mcp_cloud.create') }}
255274
</AtomsBaseButton>

docs/REMOTE_MCP.md

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,24 @@ different project (the old connection stays until you disconnect it).
3636
| `project:metadata` | Project structure reads: `status`, `describe`, `describe_format` |
3737
| `content:read` | Content reads: `content_list`, `validate`, … |
3838
| `content:write` | Content/model writes: `content_save`, `content_delete`, `model_save`, `model_delete` |
39+
| `media:read` | Media reads: `media_list`, `media_get` |
40+
| `media:write` | Media writes: `media_ingest` (from a URL), `media_update`, `media_delete` |
3941
| `offline_access` | A refresh token (stay connected without re-approving) |
4042

41-
`media:read` / `media:write` are reserved for the media stack and grant no
42-
tools yet. Studio's merge/review lifecycle tools are never exposed over
43-
remote MCP — writes land as `cr/*` branches and follow the project's
44-
workflow (auto-merge or review), exactly like the API-key surface.
43+
The `media:*` scopes are **advertised only on deployments where the media
44+
stack is configured** (Enterprise + object storage). The media tools
45+
additionally require the workspace plan's `media.upload` feature and the
46+
project's CDN delivery being enabled — where any of those is missing, the
47+
tools don't appear at all. Ingest is **URL-only** (MCP has no binary
48+
channel) and the fetch is SSRF-guarded, MIME-whitelisted and size-capped
49+
server-side. Studio's merge/review lifecycle tools are never exposed over
50+
remote MCP — content writes land as `cr/*` branches and follow the
51+
project's workflow (auto-merge or review), exactly like the API-key
52+
surface; media writes go through the media service, not git.
53+
54+
API keys never gain media tools implicitly: an "unrestricted" key (empty
55+
tool list) still cannot call them — media access must be explicitly
56+
enabled per key (the "Allow media tools" toggle, or by listing the tools).
4557

4658
Calls from remote MCP connections and API keys draw on the **same**
4759
`api.mcp_calls_per_month` workspace quota.

server/api/mcp/remote/[...slug].ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,15 @@ export default defineEventHandler(async (event) => {
5353

5454
const project = await db.getProjectById(
5555
grant.projectId,
56-
'id, repo_full_name, content_root, workspace_id',
56+
'id, repo_full_name, content_root, workspace_id, cdn_enabled',
5757
)
5858
if (!project || project.workspace_id !== grant.workspaceId) {
5959
throw createError({ statusCode: 404, message: errorMessage('project.not_found') })
6060
}
6161

6262
const workspace = await db.getWorkspaceById(
6363
grant.workspaceId,
64-
'id, github_installation_id, plan, overage_settings',
64+
'id, github_installation_id, plan, overage_settings, owner_id',
6565
)
6666
if (!workspace?.github_installation_id) {
6767
throw createError({ statusCode: 400, message: errorMessage('github.installation_missing') })
@@ -87,6 +87,8 @@ export default defineEventHandler(async (event) => {
8787
installationId: workspace.github_installation_id as number,
8888
repoFullName: project.repo_full_name as string,
8989
contentRoot: (project.content_root as string | null) ?? '',
90+
cdnEnabled: project.cdn_enabled === true,
91+
mediaOwnerId: (workspace.owner_id as string | null) ?? null,
9092
allowedTools: toolsForScope(grant.scope),
9193
rateLimitPerMinute: OAUTH_RATE_LIMIT_PER_MINUTE,
9294
rateLimitKey: `mcp-oauth:${grant.grantId}`,

server/api/mcp/v1/[projectId]/[...slug].ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,15 @@ export default defineEventHandler(async (event) => {
4242

4343
const project = await db.getProjectById(
4444
keyData.projectId,
45-
'id, repo_full_name, content_root, workspace_id',
45+
'id, repo_full_name, content_root, workspace_id, cdn_enabled',
4646
)
4747
if (!project || project.workspace_id !== keyData.workspaceId) {
4848
throw createError({ statusCode: 404, message: errorMessage('project.not_found') })
4949
}
5050

5151
const workspace = await db.getWorkspaceById(
5252
keyData.workspaceId,
53-
'id, github_installation_id, plan, overage_settings',
53+
'id, github_installation_id, plan, overage_settings, owner_id',
5454
)
5555
if (!workspace?.github_installation_id) {
5656
throw createError({ statusCode: 400, message: errorMessage('github.installation_missing') })
@@ -69,6 +69,9 @@ export default defineEventHandler(async (event) => {
6969
installationId: workspace.github_installation_id as number,
7070
repoFullName: project.repo_full_name as string,
7171
contentRoot: (project.content_root as string | null) ?? '',
72+
cdnEnabled: project.cdn_enabled === true,
73+
mediaOwnerId: (workspace.owner_id as string | null) ?? null,
74+
mediaToolsOptIn: keyData.mediaEnabled,
7275
allowedTools: keyData.allowedTools,
7376
rateLimitPerMinute: keyData.rateLimitPerMinute,
7477
rateLimitKey: `mcp-cloud:${keyData.keyId}`,

server/api/workspaces/[workspaceId]/mcp-cloud-keys/index.post.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ interface CreateKeyInput {
2020
projectId: string
2121
name: string
2222
allowedTools?: string[]
23+
mediaEnabled?: boolean
2324
rateLimitPerMinute?: number
2425
monthlyCallLimit?: number | null
2526
}
@@ -93,6 +94,7 @@ export default defineEventHandler(async (event) => {
9394
keyHash,
9495
keyPrefix,
9596
allowedTools,
97+
mediaEnabled: body.mediaEnabled === true,
9698
rateLimitPerMinute: body.rateLimitPerMinute,
9799
monthlyCallLimit: body.monthlyCallLimit ?? null,
98100
createdBy: session.user.id,
@@ -105,6 +107,7 @@ export default defineEventHandler(async (event) => {
105107
name: row.name,
106108
projectId: row.project_id,
107109
allowedTools: row.allowed_tools ?? [],
110+
mediaEnabled: row.media_enabled === true,
108111
rateLimitPerMinute: row.rate_limit_per_minute,
109112
monthlyCallLimit: row.monthly_call_limit,
110113
createdAt: row.created_at,

server/plugins/mcp-cloud-server.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,27 @@
2727

2828
import { startHttpMcpServerWith } from '@contentrain/mcp/server/http'
2929
import { createStudioGitProvider } from '../providers/git'
30+
import { buildMcpMediaFacet } from '../utils/mcp-media-facet'
3031
import { closeInternalMcp, mcpTenantFingerprint, setInternalMcp } from '../utils/mcp-cloud-runtime'
32+
import type { Plan } from '../utils/license'
3133

3234
const HEADER_INSTALLATION_ID = 'x-cr-installation-id'
3335
const HEADER_REPO_OWNER = 'x-cr-repo-owner'
3436
const HEADER_REPO_NAME = 'x-cr-repo-name'
3537
const HEADER_CONTENT_ROOT = 'x-cr-content-root'
3638
const HEADER_MEDIA_BASE = 'x-cr-media-base'
39+
// Media facet identity — injected by the proxy only for media-eligible
40+
// requests (plan + cdn_enabled + media stack checked proxy-side). All
41+
// four must be present or the facet is not built and the media tools
42+
// stay hidden from tools/list.
43+
const HEADER_PROJECT_ID = 'x-cr-project-id'
44+
const HEADER_WORKSPACE_ID = 'x-cr-workspace-id'
45+
const HEADER_MEDIA_OWNER = 'x-cr-media-owner'
46+
const HEADER_PLAN = 'x-cr-plan'
47+
48+
function headerString(value: string | string[] | undefined): string | undefined {
49+
return typeof value === 'string' && value.length > 0 ? value : undefined
50+
}
3751

3852
export default defineNitroPlugin((nitroApp) => {
3953
// Fire-and-forget: we do not want to block Nitro's startup on an
@@ -83,6 +97,18 @@ async function bootInternalMcpServer(): Promise<void> {
8397
// same absolute URLs Studio's own write path produces.
8498
const mediaBase = headers[HEADER_MEDIA_BASE]
8599

100+
// Media facet — built only when the proxy attested eligibility by
101+
// injecting ALL four identity headers (client values are stripped
102+
// proxy-side, so these are trusted). Absent set → no facet → the 5
103+
// media tools stay out of this session's tools/list.
104+
const projectId = headerString(headers[HEADER_PROJECT_ID])
105+
const workspaceId = headerString(headers[HEADER_WORKSPACE_ID])
106+
const mediaOwner = headerString(headers[HEADER_MEDIA_OWNER])
107+
const plan = headerString(headers[HEADER_PLAN])
108+
const media = (projectId && workspaceId && mediaOwner && plan)
109+
? buildMcpMediaFacet({ projectId, workspaceId, uploadedBy: mediaOwner, plan: plan as Plan }) ?? undefined
110+
: undefined
111+
86112
return createStudioGitProvider({
87113
installationId,
88114
owner,
@@ -93,6 +119,7 @@ async function bootInternalMcpServer(): Promise<void> {
93119
mediaBaseUrl: typeof mediaBase === 'string' && mediaBase.length > 0
94120
? mediaBase
95121
: undefined,
122+
media,
96123
})
97124
},
98125
})

server/providers/database.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,7 @@ export interface DatabaseProvider {
630630
keyHash: string
631631
keyPrefix: string
632632
allowedTools: string[]
633+
mediaEnabled?: boolean
633634
rateLimitPerMinute?: number
634635
monthlyCallLimit?: number | null
635636
createdBy?: string | null

0 commit comments

Comments
 (0)