From f5c70eba861c8cb42dee90549f91f337b81d4d92 Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:54:25 +0300 Subject: [PATCH] feat(mcp): media tools over an optional provider media facet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the media discovery loop for external agents: list assets, pick a media/... path, reference it via contentrain_content_save. - types: MediaProvider contract (list/get/ingest/update/delete) + MediaAsset/MediaListOptions/MediaListResult/MediaIngestInput/ MediaUpdateInput; RepoProvider gains optional media?: MediaProvider facet (Studio MCP Cloud implements it; Local/GitHub/GitLab do not) - mcp: 5 new tools (contentrain_media_list/get/ingest/update/delete) as deterministic passthrough in src/tools/media.ts; registered only when the facet is present via a new media requirement in TOOL_REQUIREMENTS — local stdio servers keep listing exactly the 19 core tools. Ingest is URL-based (no binary channel in MCP); the provider owns SSRF/MIME/size policy, and media_ingest is the only openWorldHint: true tool. media_delete is destructive and requires confirm: true. - rules: MCP_TOOLS 19 -> 24, essentials document the media flow - skills: Media Tools section in references/mcp-tools.md (parity-tested) - docs: tool counts updated to 24 (19 core + 5 media) across README/docs site; stale '17 tools' mention fixed in embedding guide - fix(release): shorten server.json description to 93 chars — the MCP Registry publish step failed on main with 'expected length <= 100'; new tests/registry-manifest.test.ts pins the limit plus mcpName/npm identifier parity so this class of failure is caught pre-merge Tests: 10 new media tool cases (passthrough, SSRF policy surfacing, schema-level confirm/url guards, absent-facet behavior), availability tests updated for the media facet, 3 registry-manifest checks. Full MCP suite 675 passed (1 pre-existing branch-lifecycle timeout flake under parallel load — passes in isolation); rules 16/16, skills 85/85, oxlint 0/0, tsc clean in types/mcp, CLI compiles against new types. --- .changeset/mcp-media-tools.md | 21 ++ README.md | 6 +- docs/concepts.md | 2 +- docs/getting-started.md | 4 +- docs/guides/embedding-mcp.md | 6 +- docs/guides/http-transport.md | 2 +- docs/guides/providers.md | 2 +- docs/packages/mcp.md | 27 ++- docs/packages/rules.md | 2 +- packages/mcp/README.md | 13 +- packages/mcp/package.json | 2 +- packages/mcp/server.json | 2 +- packages/mcp/src/core/contracts/index.ts | 6 + packages/mcp/src/server.ts | 2 + packages/mcp/src/tools/annotations.ts | 43 +++- packages/mcp/src/tools/availability.ts | 12 +- packages/mcp/src/tools/media.ts | 167 +++++++++++++ packages/mcp/tests/registry-manifest.test.ts | 36 +++ .../tests/server/tool-availability.test.ts | 29 ++- packages/mcp/tests/tools/media.test.ts | 222 ++++++++++++++++++ packages/rules/README.md | 4 +- .../rules/essential/contentrain-essentials.md | 7 + packages/rules/src/index.ts | 6 +- packages/rules/tests/validate-rules.test.ts | 2 +- packages/skills/README.md | 2 +- .../contentrain/references/mcp-tools.md | 64 +++++ packages/types/src/index.ts | 6 + packages/types/src/provider.ts | 80 +++++++ 28 files changed, 742 insertions(+), 35 deletions(-) create mode 100644 .changeset/mcp-media-tools.md create mode 100644 packages/mcp/src/tools/media.ts create mode 100644 packages/mcp/tests/registry-manifest.test.ts create mode 100644 packages/mcp/tests/tools/media.test.ts diff --git a/.changeset/mcp-media-tools.md b/.changeset/mcp-media-tools.md new file mode 100644 index 0000000..2faabc7 --- /dev/null +++ b/.changeset/mcp-media-tools.md @@ -0,0 +1,21 @@ +--- +"@contentrain/types": minor +"@contentrain/mcp": minor +"@contentrain/rules": minor +"@contentrain/skills": minor +--- + +feat(mcp): media tools over an optional provider media facet + +**@contentrain/types**: new `MediaProvider` contract (`list`/`get`/`ingest`/`update`/`delete`) plus `MediaAsset`, `MediaListOptions`, `MediaListResult`, `MediaIngestInput`, `MediaUpdateInput`. `RepoProvider` gains an optional `media?: MediaProvider` facet — implemented by hosted providers (Studio MCP Cloud), absent on Local/GitHub/GitLab. + +**@contentrain/mcp**: five new tools — `contentrain_media_list`, `contentrain_media_get`, `contentrain_media_ingest`, `contentrain_media_update`, `contentrain_media_delete` — as a deterministic passthrough to the provider's media facet. + +- **Capability-aware:** registered only when `RepoProvider.media` is present (new `media` requirement in `TOOL_REQUIREMENTS`). Local stdio servers keep listing exactly the 19 core tools; nothing changes for existing embeddings. +- **URL-based ingest.** MCP has no binary channel; the provider fetches the source URL server-side and owns SSRF/MIME/size policy. `contentrain_media_ingest` is the only tool with `openWorldHint: true`. +- **Safety:** `media_delete` is `destructiveHint: true` and requires `confirm: true`; content references are never rewritten by MCP. +- Closes the discovery loop for external agents: list assets → pick a `media/...` path → reference it via `contentrain_content_save` (absolute delivery URLs via `mediaBaseUrl`). + +**@contentrain/rules**: `MCP_TOOLS` now lists 24 tools (19 core + 5 media); essential guardrails document the media flow. + +**@contentrain/skills**: `references/mcp-tools.md` gains a Media Tools section covering all five tools (parity-tested against the MCP registry). diff --git a/README.md b/README.md index 5d40185..8fc985f 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ This is the strongest entry point into the product: ``` ┌─────────────┐ ┌──────────────────┐ ┌──────────────┐ -│ AI Agent │────▶│ MCP (19 tools) │────▶│ .contentrain/│ +│ AI Agent │────▶│ MCP (24 tools) │────▶│ .contentrain/│ │ (decides) │ │ (enforces) │ │ (stores) │ └─────────────┘ └──────────────────┘ └──────┬───────┘ │ @@ -145,7 +145,7 @@ Works with Nuxt, Next.js, Astro, SvelteKit, Vue, React, Node, Go, Python, Swift, - **Git-native** — every write goes through worktree isolation + review branches - **Normalize flow** — scan codebase for hardcoded strings → extract → create i18n-ready content → patch source files -- **MCP engine** — 19 tools over stdio or HTTP transport, works with Claude Code, Cursor, Windsurf, or any MCP client +- **MCP engine** — 24 tools (19 core + 5 media) over stdio or HTTP transport, works with Claude Code, Cursor, Windsurf, or any MCP client - **Provider-agnostic engine** — the same tool surface runs over a local worktree, GitHub, or GitLab (self-hosted included) with zero tool-code changes. HTTP transport available for remote drivers such as Studio. - **Canonical serialization** — sorted keys, deterministic output, clean git diffs, conflict-free parallel edits - **Agent rules & skills** — behavioral policies and step-by-step workflows ship as npm packages @@ -176,7 +176,7 @@ See [`AGENTS.md`](AGENTS.md) for the full skill catalog and agent guidance. | Package | npm | Role | |---|---|---| -| [`@contentrain/mcp`](packages/mcp) | [![npm](https://img.shields.io/npm/v/%40contentrain%2Fmcp)](https://www.npmjs.com/package/@contentrain/mcp) | 19 MCP tools + stdio / HTTP transport + Local / GitHub / GitLab providers | +| [`@contentrain/mcp`](packages/mcp) | [![npm](https://img.shields.io/npm/v/%40contentrain%2Fmcp)](https://www.npmjs.com/package/@contentrain/mcp) | 24 MCP tools + stdio / HTTP transport + Local / GitHub / GitLab providers | | [`contentrain`](packages/cli) | [![npm](https://img.shields.io/npm/v/contentrain)](https://www.npmjs.com/package/contentrain) | CLI + Serve UI + MCP stdio entrypoint | | [`@contentrain/query`](packages/sdk/js) | [![npm](https://img.shields.io/npm/v/%40contentrain%2Fquery)](https://www.npmjs.com/package/@contentrain/query) | Generated TypeScript query SDK | | [`@contentrain/types`](packages/types) | [![npm](https://img.shields.io/npm/v/%40contentrain%2Ftypes)](https://www.npmjs.com/package/@contentrain/types) | Shared type definitions + constants | diff --git a/docs/concepts.md b/docs/concepts.md index 8aaca8e..137771b 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -43,7 +43,7 @@ Contentrain AI inverts the traditional CMS workflow: ### 1. MCP (Infrastructure) -19 tools that AI agents call to manage content: +24 tools (19 core + 5 media) that AI agents call to manage content: - **Read:** `contentrain_status`, `contentrain_describe`, `contentrain_describe_format`, `contentrain_doctor`, `contentrain_content_list` - **Project setup:** `contentrain_init`, `contentrain_scaffold` diff --git a/docs/getting-started.md b/docs/getting-started.md index 7f893c7..000b9d2 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -238,7 +238,7 @@ All packages are published on npm: | Package | Description | Install | |---|---|---| | [`contentrain`](https://www.npmjs.com/package/contentrain) | CLI (init, serve, generate, validate) | `npx contentrain init` | -| [`@contentrain/mcp`](https://www.npmjs.com/package/@contentrain/mcp) | 19 MCP tools for AI agents | `pnpm add @contentrain/mcp` | +| [`@contentrain/mcp`](https://www.npmjs.com/package/@contentrain/mcp) | 24 MCP tools for AI agents | `pnpm add @contentrain/mcp` | | [`@contentrain/query`](https://www.npmjs.com/package/@contentrain/query) | TypeScript query SDK (optional) | `pnpm add @contentrain/query` | | [`@contentrain/types`](https://www.npmjs.com/package/@contentrain/types) | Shared TypeScript types | `pnpm add @contentrain/types` | | [`@contentrain/rules`](https://www.npmjs.com/package/@contentrain/rules) | AI agent quality rules | `pnpm add @contentrain/rules` | @@ -276,7 +276,7 @@ Want to skip setup? Start from a production-ready template with content models, - [Core Concepts](/concepts) — Models, content kinds, domains, and the governance architecture - [Ecosystem Map](/ecosystem) — How AI packages and Studio fit together -- [MCP Tools](/packages/mcp) — All 19 tools available to your agent +- [MCP Tools](/packages/mcp) — All 24 tools available to your agent - [Normalize Flow](/guides/normalize) — Extract hardcoded strings from existing code - [i18n Workflow](/guides/i18n) — Add languages to your content - [Framework Integration](/guides/frameworks) — Platform-specific setup patterns diff --git a/docs/guides/embedding-mcp.md b/docs/guides/embedding-mcp.md index 9c3e230..5fd9e4a 100644 --- a/docs/guides/embedding-mcp.md +++ b/docs/guides/embedding-mcp.md @@ -15,7 +15,7 @@ Studio (`contentrain.io`) is the canonical consumer; the patterns below describe `@contentrain/mcp` ships three pieces you plug together: 1. **A `RepoProvider`** — Local / GitHub / GitLab (or your own). Wraps whatever git backend you're targeting. -2. **An `McpServer`** — the MCP JSON-RPC surface with all 17 Contentrain tools registered. +2. **An `McpServer`** — the MCP JSON-RPC surface with every Contentrain tool the provider can satisfy registered (19 core + 5 media on media-capable providers). 3. **A transport** — stdio (for IDE agents) or HTTP (for hosted / remote drivers). The three are orthogonal. Mix them freely. @@ -277,6 +277,8 @@ Each provider advertises a `ProviderCapabilities` manifest. `createServer` consu `init`, `scaffold`, `doctor`, and `bulk` additionally require a local `projectRoot` on disk. Input-dependent checks (`validate --fix`, `apply` reuse) stay as call-time guards and return the structured `capability_required` error above. +The five `contentrain_media_*` tools gate on the provider's optional **media facet** (`RepoProvider.media` — an object implementing `MediaProvider` from `@contentrain/types`, not a boolean flag). Implement it on a custom provider to get the media tools registered; leave it absent and they never appear. + Read-only tools (`status`, `describe`, `describe_format`, `content_list`, `validate` without `--fix`) work on every provider — they use only the reader surface. ## Extension: custom providers @@ -339,7 +341,7 @@ A GitHub Actions job: 4. Drive it with an MCP client 5. Let `contentrain_submit` push the `cr/*` branch -All 19 tools are available because the runner has `LocalProvider`. +All 19 core tools are available because the runner has `LocalProvider` (media tools need a media-capable provider). ### Scripted automation diff --git a/docs/guides/http-transport.md b/docs/guides/http-transport.md index eb5ebee..6dfce39 100644 --- a/docs/guides/http-transport.md +++ b/docs/guides/http-transport.md @@ -14,7 +14,7 @@ Typical drivers: - **CI runners** — deterministic content operations as part of a pipeline (scaffold, validate, submit). - **Remote agents** — any MCP client that wants to operate a Contentrain project without a local checkout. -All three tunnel the same 19 tools through the same `RepoProvider` contract. Which backend answers depends on how the server is wired — see [Providers & Transports](/guides/providers) for the capability matrix. +All three tunnel the same tool surface (19 core + 5 media on media-capable providers) through the same `RepoProvider` contract. Which backend answers depends on how the server is wired — see [Providers & Transports](/guides/providers) for the capability matrix. ## Starting the HTTP server diff --git a/docs/guides/providers.md b/docs/guides/providers.md index 4b592cf..b3ea57d 100644 --- a/docs/guides/providers.md +++ b/docs/guides/providers.md @@ -6,7 +6,7 @@ slug: providers # Providers & Transports -Contentrain MCP runs the same 19 tools over three backends: +Contentrain MCP runs the same tool surface (19 core + 5 media on media-capable providers) over three backends: - **LocalProvider** — simple-git + a temporary worktree on your disk. Default for `npx contentrain serve --stdio` and the HTTP transport when driven by the CLI. - **GitHubProvider** — Octokit over the GitHub Git Data + Repos APIs. No clone, no worktree. diff --git a/docs/packages/mcp.md b/docs/packages/mcp.md index 7688390..9b2de1e 100644 --- a/docs/packages/mcp.md +++ b/docs/packages/mcp.md @@ -1,6 +1,6 @@ --- title: MCP Tools -description: Complete reference for @contentrain/mcp — the provider-agnostic MCP engine powering AI content governance with 19 deterministic tools over stdio or HTTP +description: Complete reference for @contentrain/mcp — the provider-agnostic MCP engine powering AI content governance with 24 deterministic tools over stdio or HTTP order: 1 slug: mcp --- @@ -55,10 +55,10 @@ Use the local stdio server when the agent should work against a checkout on your ## Tool Catalog -The MCP server exposes **19 tools** organized by function. Each tool includes [MCP annotations](https://spec.modelcontextprotocol.io/specification/2025-03-26/server/tools/#annotations) (`readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint: false` — every tool operates on the configured repository only) so clients can distinguish safe reads from writes and destructive operations. +The MCP server exposes **24 tools** — 19 core + 5 media — organized by function. Each tool includes [MCP annotations](https://spec.modelcontextprotocol.io/specification/2025-03-26/server/tools/#annotations) (`readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint: false` everywhere except `contentrain_media_ingest`, which fetches a caller-supplied URL server-side) so clients can distinguish safe reads from writes and destructive operations. ::: info Capability-aware listing -`tools/list` is filtered per session: tools whose requirements (local project root, provider capabilities) cannot be met are not registered at all. A local stdio server lists all 19 tools; a remote-provider session (e.g. Studio MCP Cloud) lists only the remote-safe subset — `status`, `describe`, `describe_format`, `model_save`, `model_delete`, `content_save`, `content_delete`, `content_list`, `validate`. See `TOOL_REQUIREMENTS` in `@contentrain/mcp/tools/availability`. +`tools/list` is filtered per session: tools whose requirements (local project root, provider capabilities) cannot be met are not registered at all. A local stdio server lists the 19 core tools; a remote-provider session lists the remote-safe subset plus — on media-capable providers like Studio MCP Cloud — the 5 `contentrain_media_*` tools. Core remote-safe subset — `status`, `describe`, `describe_format`, `model_save`, `model_delete`, `content_save`, `content_delete`, `content_list`, `validate`. See `TOOL_REQUIREMENTS` in `@contentrain/mcp/tools/availability`. ::: | Tool | Title | Read-only | Destructive | @@ -82,6 +82,11 @@ The MCP server exposes **19 tools** organized by function. Each tool includes [M | `contentrain_scan` | Scan Source Code | Yes | — | | `contentrain_apply` | Apply Normalize | — | — | | `contentrain_bulk` | Bulk Operations | — | — | +| `contentrain_media_list` | List Media Assets | Yes | — | +| `contentrain_media_get` | Get Media Asset | Yes | — | +| `contentrain_media_ingest` | Ingest Media From URL | — | — | +| `contentrain_media_update` | Update Media Metadata | — | — | +| `contentrain_media_delete` | Delete Media Asset | — | **Yes** | ### Detailed Reference @@ -119,6 +124,18 @@ The MCP server exposes **19 tools** organized by function. Each tool includes [M | `contentrain_apply` | Extract or reuse | Two-phase normalize: extract content or patch source files | | `contentrain_bulk` | Batch operations | Bulk locale copy, status updates, and deletes | +### Media Tools (Provider Media Facet) + +A deterministic passthrough to the provider's optional media stack (`RepoProvider.media`) — registered **only when the provider exposes one** (e.g. Studio MCP Cloud). The flow: list assets → pick a `media/...` path → reference it via `contentrain_content_save` (normalized to absolute delivery URLs when `mediaBaseUrl` is set). + +| Tool | Purpose | Description | +|------|---------|-------------| +| `contentrain_media_list` | Discover assets | Search, tag filter, cursor pagination — returns storage paths + delivery URLs | +| `contentrain_media_get` | Inspect one asset | Path, URL, mime, size, alt, tags, provider metadata | +| `contentrain_media_ingest` | Add from URL | Provider fetches the URL server-side under its own SSRF/MIME/size policy (MCP never fetches; the only `openWorldHint: true` tool) | +| `contentrain_media_update` | Edit metadata | Alt text, tags, filename — never touches the binary | +| `contentrain_media_delete` | Remove asset | Destructive; content references are NOT rewritten | + ## Key Principles ### 1. Deterministic Infrastructure @@ -185,7 +202,7 @@ Agent drivers treat `capability_required` as a retry signal. See [Providers & Tr - **stdio** — `contentrain serve --stdio` or `npx contentrain-mcp`. IDE agents (Claude Code, Cursor, Windsurf) connect over stdin/stdout. - **HTTP** — `contentrain serve --mcpHttp --authToken $TOKEN` or the programmatic `startHttpMcpServer({...})` / `startHttpMcpServerWith({ provider })` exports. Streamable HTTP at `POST /mcp` with secure-by-default Bearer auth. See the [HTTP Transport guide](/guides/http-transport). -Both transports serve the same 19 tools and the same JSON response shapes. +Both transports serve the same tool surface and the same JSON response shapes. ## Providers @@ -317,7 +334,7 @@ This auto-creates the correct MCP config file and installs AI rules/skills. -Once connected, the agent has access to all 19 MCP tools and can manage your content through natural language. +Once connected, the agent has access to the full MCP tool surface and can manage your content through natural language. ### OpenAI Codex diff --git a/docs/packages/rules.md b/docs/packages/rules.md index 5b3bf5d..17c3b77 100644 --- a/docs/packages/rules.md +++ b/docs/packages/rules.md @@ -94,7 +94,7 @@ import { } from '@contentrain/rules' // Check if a tool exists -console.log(MCP_TOOLS.length) // 19 +console.log(MCP_TOOLS.length) // 24 console.log(MCP_TOOLS.includes('contentrain_validate')) // true console.log(MCP_TOOLS.includes('contentrain_merge')) // true console.log(MCP_TOOLS.includes('contentrain_doctor')) // true diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 392f52e..dc96f18 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -71,9 +71,9 @@ All write operations are designed around git-backed safety: ## Tool Surface -19 MCP tools with [annotations](https://spec.modelcontextprotocol.io/specification/2025-03-26/server/tools/#annotations) (`readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint: false` — every tool operates on the configured repository only) for client safety hints. +24 MCP tools — 19 core + 5 media — with [annotations](https://spec.modelcontextprotocol.io/specification/2025-03-26/server/tools/#annotations) (`readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint: false` everywhere except `contentrain_media_ingest`, which fetches a caller-supplied URL server-side) for client safety hints. -**Tool listing is capability-aware.** `tools/list` only advertises tools the resolved provider + `projectRoot` pair can actually satisfy. A local stdio server lists all 19; a session driven by a remote provider (GitHub/GitLab, no local checkout) lists only the remote-safe subset — `status`, `describe`, `describe_format`, `model_save`, `model_delete`, `content_save`, `content_delete`, `content_list`, `validate`. The requirement map lives in `TOOL_REQUIREMENTS` (`@contentrain/mcp/tools/availability`). +**Tool listing is capability-aware.** `tools/list` only advertises tools the resolved provider + `projectRoot` pair can actually satisfy. A local stdio server lists the 19 core tools; a session driven by a remote provider (GitHub/GitLab, no local checkout) lists only the remote-safe subset — `status`, `describe`, `describe_format`, `model_save`, `model_delete`, `content_save`, `content_delete`, `content_list`, `validate`. The requirement map lives in `TOOL_REQUIREMENTS` (`@contentrain/mcp/tools/availability`). | Tool | Purpose | Read-only | Destructive | | --- | --- | --- | --- | @@ -96,6 +96,13 @@ All write operations are designed around git-backed safety: | `contentrain_scan` | Graph- and candidate-based hardcoded string scan | Yes | — | | `contentrain_apply` | Normalize extract/reuse execution with dry-run support | — | — | | `contentrain_bulk` | Bulk locale copy, status updates, and deletes | — | — | +| `contentrain_media_list` | List media assets (search, tag filter, cursor pagination) | Yes | — | +| `contentrain_media_get` | Get one media asset by id | Yes | — | +| `contentrain_media_ingest` | Ingest an asset from a source URL (provider fetches server-side) | — | — | +| `contentrain_media_update` | Update asset metadata (alt, tags, filename) | — | — | +| `contentrain_media_delete` | Delete an asset from the media stack | — | **Yes** | + +The five `contentrain_media_*` tools are a deterministic passthrough to the provider's optional media facet (`RepoProvider.media`) and are registered **only when the provider exposes one** (e.g. Studio MCP Cloud). Local stdio servers and plain GitHub/GitLab providers never list them. Ingest is URL-based (MCP has no binary channel); the provider implementation owns SSRF/MIME/size policy for the fetch. ## Quick Start @@ -257,7 +264,7 @@ These are intended for Contentrain tooling and advanced integrations, not for di Key design decisions in this package: - local-first **by default** — stdio transport + LocalProvider works without any network dependency -- provider-agnostic engine — the same 19 tools run over LocalProvider, GitHubProvider, or GitLabProvider behind a single `RepoProvider` contract +- provider-agnostic engine — the same core tools run over LocalProvider, GitHubProvider, or GitLabProvider behind a single `RepoProvider` contract; media tools ride the provider's optional media facet - remote provider SDKs (`@octokit/rest`, `@gitbeaker/rest`) are optional peer dependencies — pulled in only when their provider is used - JSON-only content storage - git-backed write workflow (worktree transaction locally, single atomic commit over the Git Data / REST APIs remotely) diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 30a7df9..f805ff5 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -3,7 +3,7 @@ "version": "1.9.0", "mcpName": "io.github.Contentrain/contentrain", "license": "MIT", - "description": "Local-first MCP server for AI-generated content governance — 19 deterministic tools, stdio + HTTP transports, Local / GitHub / GitLab providers", + "description": "Local-first MCP server for AI-generated content governance — 24 deterministic tools (19 core + 5 media), stdio + HTTP transports, Local / GitHub / GitLab providers", "type": "module", "repository": { "type": "git", diff --git a/packages/mcp/server.json b/packages/mcp/server.json index ef7070e..446e830 100644 --- a/packages/mcp/server.json +++ b/packages/mcp/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.Contentrain/contentrain", "title": "Contentrain", - "description": "Git-native content governance for AI agents — 19 deterministic tools for models, content, validation, normalize, i18n, and review branches.", + "description": "Git-native content governance for AI agents — 24 deterministic MCP tools over stdio and HTTP.", "websiteUrl": "https://ai.contentrain.io/packages/mcp", "repository": { "url": "https://github.com/Contentrain/ai", diff --git a/packages/mcp/src/core/contracts/index.ts b/packages/mcp/src/core/contracts/index.ts index 5612f53..2a63c38 100644 --- a/packages/mcp/src/core/contracts/index.ts +++ b/packages/mcp/src/core/contracts/index.ts @@ -12,6 +12,12 @@ export type { CommitAuthor, FileChange, FileDiff, + MediaAsset, + MediaIngestInput, + MediaListOptions, + MediaListResult, + MediaProvider, + MediaUpdateInput, MergeResult, ProviderCapabilities, RepoProvider, diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 3ba75a1..08911ca 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -19,6 +19,7 @@ import { registerWorkflowTools } from './tools/workflow.js' import { registerNormalizeTools } from './tools/normalize.js' import { registerBulkTools } from './tools/bulk.js' import { registerDoctorTools } from './tools/doctor.js' +import { registerMediaTools } from './tools/media.js' import packageJson from '../package.json' with { type: 'json' } /** @@ -134,6 +135,7 @@ export function createServer(input: string | CreateServerOptions): McpServer { registerNormalizeTools(server, provider, projectRoot) registerBulkTools(server, provider, projectRoot) registerDoctorTools(server, provider, projectRoot) + registerMediaTools(server, provider, projectRoot) return server } diff --git a/packages/mcp/src/tools/annotations.ts b/packages/mcp/src/tools/annotations.ts index 5569c5c..ffad2a2 100644 --- a/packages/mcp/src/tools/annotations.ts +++ b/packages/mcp/src/tools/annotations.ts @@ -3,9 +3,9 @@ import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js' /** * Centralized tool annotations registry. * MCP clients use these hints to distinguish read-only vs. write vs. destructive tools. - * `openWorldHint` is `false` across the board: every tool operates on the - * configured content repository only — none reaches out to arbitrary - * external systems. + * `openWorldHint` is `false` everywhere except `contentrain_media_ingest`, + * which fetches a caller-supplied external URL server-side; every other + * tool operates on the configured content repository only. * * Also serves as the **single source of truth for the tool name list**. Consumers * that need to enumerate every registered tool (e.g. parity tests in @@ -159,6 +159,43 @@ export const TOOL_ANNOTATIONS: Record = { idempotentHint: false, openWorldHint: false, }, + + // ─── Media (provider media facet — registered only when RepoProvider.media is present) ─── + contentrain_media_list: { + title: 'List Media Assets', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + contentrain_media_get: { + title: 'Get Media Asset', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + contentrain_media_ingest: { + title: 'Ingest Media From URL', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + contentrain_media_update: { + title: 'Update Media Metadata', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + contentrain_media_delete: { + title: 'Delete Media Asset', + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }, } /** diff --git a/packages/mcp/src/tools/availability.ts b/packages/mcp/src/tools/availability.ts index 4310c48..d4f9377 100644 --- a/packages/mcp/src/tools/availability.ts +++ b/packages/mcp/src/tools/availability.ts @@ -19,6 +19,8 @@ export interface ToolRequirements { projectRoot?: boolean /** Provider capability flags that must all be `true`. */ capabilities?: readonly (keyof ProviderCapabilities)[] + /** Tool needs the provider's optional media facet (`RepoProvider.media`). */ + media?: boolean } export const TOOL_REQUIREMENTS: Readonly> = { @@ -35,6 +37,13 @@ export const TOOL_REQUIREMENTS: Readonly> = { // additionally needs sourceWrite, which stays a call-time check because // it depends on the `mode` input. contentrain_apply: { projectRoot: true, capabilities: ['sourceRead'] }, + // Media tools exist only where the provider exposes a media stack + // (Studio MCP Cloud). Local and plain remote providers never list them. + contentrain_media_list: { media: true }, + contentrain_media_get: { media: true }, + contentrain_media_ingest: { media: true }, + contentrain_media_update: { media: true }, + contentrain_media_delete: { media: true }, } /** @@ -45,11 +54,12 @@ export const TOOL_REQUIREMENTS: Readonly> = { */ export function isToolAvailable( name: string, - provider: { capabilities: ProviderCapabilities }, + provider: { capabilities: ProviderCapabilities, media?: unknown }, projectRoot: string | undefined, ): boolean { const req = TOOL_REQUIREMENTS[name] if (!req) return true if (req.projectRoot && !projectRoot) return false + if (req.media && !provider.media) return false return (req.capabilities ?? []).every(cap => provider.capabilities[cap]) } diff --git a/packages/mcp/src/tools/media.ts b/packages/mcp/src/tools/media.ts new file mode 100644 index 0000000..414eabf --- /dev/null +++ b/packages/mcp/src/tools/media.ts @@ -0,0 +1,167 @@ +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { ToolProvider } from '../server.js' +import { TOOL_ANNOTATIONS } from './annotations.js' +import { normalizeOperationError } from '../git/errors.js' + +/** + * Media tools — a deterministic passthrough to the provider's optional + * media facet (`RepoProvider.media`). Registration is capability-aware: + * `createServer` skips all five tools when the provider has no media + * stack, so the guard below is defense in depth only. + * + * The facet closes the discovery loop for external agents: list assets → + * pick a `media/...` path → reference it via `contentrain_content_save` + * (which normalizes it to an absolute delivery URL when `mediaBaseUrl` + * is set). Ingest is URL-based — MCP has no binary channel — and the + * provider implementation owns SSRF/MIME/size policy for the fetch. + */ + +function mediaUnavailable(tool: string) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ + error: `${tool} requires a provider with a media stack.`, + capability_required: 'media', + hint: 'Media tools are available when MCP is driven by a media-capable provider (e.g. Studio MCP Cloud). Local and plain git providers do not expose one.', + }) }], + isError: true as const, + } +} + +function mediaError(error: unknown, stage: string) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ + ...normalizeOperationError(error, stage), + }) }], + isError: true as const, + } +} + +function ok(payload: unknown) { + return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }] } +} + +export function registerMediaTools( + server: McpServer, + provider: ToolProvider, + _projectRoot: string | undefined, +): void { + // ─── contentrain_media_list ─── + server.tool( + 'contentrain_media_list', + 'List media assets from the provider media stack. Returns storage paths (media/...) usable in media/image/file fields via contentrain_content_save, plus delivery URLs when available. Supports search, tag filter, and cursor pagination.', + { + search: z.string().optional().describe('Substring match on filename/path/alt'), + tag: z.string().optional().describe('Only assets carrying this tag'), + limit: z.number().int().min(1).max(100).optional().describe('Page size. Default: provider-defined'), + cursor: z.string().optional().describe('Opaque cursor from a previous response'), + }, + TOOL_ANNOTATIONS['contentrain_media_list']!, + async (input) => { + if (!provider.media) return mediaUnavailable('contentrain_media_list') + try { + const result = await provider.media.list(input) + return ok({ + assets: result.assets, + next_cursor: result.nextCursor, + total: result.total, + }) + } catch (error) { + return mediaError(error, 'media_list') + } + }, + ) + + // ─── contentrain_media_get ─── + server.tool( + 'contentrain_media_get', + 'Get a single media asset by id, including its storage path, delivery URL, and metadata.', + { + id: z.string().describe('Asset id (from contentrain_media_list)'), + }, + TOOL_ANNOTATIONS['contentrain_media_get']!, + async (input) => { + if (!provider.media) return mediaUnavailable('contentrain_media_get') + try { + const asset = await provider.media.get(input.id) + if (!asset) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ + error: `Media asset not found: ${input.id}`, + hint: 'Use contentrain_media_list to discover asset ids.', + }) }], + isError: true as const, + } + } + return ok({ asset }) + } catch (error) { + return mediaError(error, 'media_get') + } + }, + ) + + // ─── contentrain_media_ingest ─── + server.tool( + 'contentrain_media_ingest', + 'Ingest a media asset from a source URL. The provider fetches the URL server-side under its own SSRF/MIME/size policy and stores the asset in the media stack. Returns the stored asset with its media/... path.', + { + url: z.string().url().describe('Source URL to fetch server-side'), + filename: z.string().optional().describe('Target filename override'), + alt: z.string().optional().describe('Alt text for the asset'), + tags: z.array(z.string()).max(20).optional().describe('Tags to attach'), + }, + TOOL_ANNOTATIONS['contentrain_media_ingest']!, + async (input) => { + if (!provider.media) return mediaUnavailable('contentrain_media_ingest') + try { + const asset = await provider.media.ingest(input) + return ok({ status: 'ingested', asset }) + } catch (error) { + return mediaError(error, 'media_ingest') + } + }, + ) + + // ─── contentrain_media_update ─── + server.tool( + 'contentrain_media_update', + 'Update metadata (alt text, tags, filename) of an existing media asset. Does not touch the binary.', + { + id: z.string().describe('Asset id to update'), + alt: z.string().optional().describe('New alt text'), + tags: z.array(z.string()).max(20).optional().describe('Replacement tag list'), + filename: z.string().optional().describe('New filename'), + }, + TOOL_ANNOTATIONS['contentrain_media_update']!, + async (input) => { + if (!provider.media) return mediaUnavailable('contentrain_media_update') + try { + const { id, ...patch } = input + const asset = await provider.media.update(id, patch) + return ok({ status: 'updated', asset }) + } catch (error) { + return mediaError(error, 'media_update') + } + }, + ) + + // ─── contentrain_media_delete ─── + server.tool( + 'contentrain_media_delete', + 'Delete a media asset from the media stack. Content entries referencing its path are NOT rewritten — check references before deleting.', + { + id: z.string().describe('Asset id to delete'), + confirm: z.literal(true).describe('Must be true to confirm deletion'), + }, + TOOL_ANNOTATIONS['contentrain_media_delete']!, + async (input) => { + if (!provider.media) return mediaUnavailable('contentrain_media_delete') + try { + await provider.media.delete(input.id) + return ok({ status: 'deleted', id: input.id }) + } catch (error) { + return mediaError(error, 'media_delete') + } + }, + ) +} diff --git a/packages/mcp/tests/registry-manifest.test.ts b/packages/mcp/tests/registry-manifest.test.ts new file mode 100644 index 0000000..ca0fe2c --- /dev/null +++ b/packages/mcp/tests/registry-manifest.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +/** + * MCP Registry manifest sanity. The registry enforces limits at publish + * time (release CI failed on 2026-07-13 with `expected length <= 100` for + * `description`) — catch violations here instead of on main after merge. + */ + +const PKG_ROOT = join(import.meta.dirname, '..') +const serverJson = JSON.parse(readFileSync(join(PKG_ROOT, 'server.json'), 'utf-8')) as { + name: string + title?: string + description: string + packages: Array<{ identifier: string, registryType: string }> +} +const packageJson = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf-8')) as { + name: string + mcpName?: string +} + +describe('server.json (MCP Registry manifest)', () => { + it('description fits the registry limit (≤ 100 chars)', () => { + expect(serverJson.description.length).toBeLessThanOrEqual(100) + }) + + it('registry name matches package.json mcpName', () => { + expect(serverJson.name).toBe(packageJson.mcpName) + }) + + it('npm package entry points at this package', () => { + const npmEntry = serverJson.packages.find(p => p.registryType === 'npm') + expect(npmEntry?.identifier).toBe(packageJson.name) + }) +}) diff --git a/packages/mcp/tests/server/tool-availability.test.ts b/packages/mcp/tests/server/tool-availability.test.ts index c13583c..9a7327a 100644 --- a/packages/mcp/tests/server/tool-availability.test.ts +++ b/packages/mcp/tests/server/tool-availability.test.ts @@ -63,11 +63,28 @@ const REMOTE_SAFE_TOOLS = [ 'contentrain_validate', ] +const MEDIA_TOOLS = TOOL_NAMES.filter(name => name.startsWith('contentrain_media_')) +const NON_MEDIA_TOOLS = TOOL_NAMES.filter(name => !name.startsWith('contentrain_media_')) + describe('capability-aware tool registration', () => { - it('registers all 19 tools for a local project root', async () => { + it('registers all 19 core tools for a local project root (media facet absent)', async () => { const client = await connectedClient(join(tmpdir(), 'cr-availability-local')) const names = await listedNames(client) - expect(names.toSorted()).toEqual([...TOOL_NAMES].toSorted()) + expect(names.toSorted()).toEqual([...NON_MEDIA_TOOLS].toSorted()) + await client.close() + }) + + it('adds the media tools when the provider exposes a media facet', async () => { + const provider = { ...makeProvider(), media: { + list: async () => ({ assets: [] }), + get: async () => null, + ingest: async () => ({ id: 'x', path: 'media/x' }), + update: async () => ({ id: 'x', path: 'media/x' }), + delete: async () => {}, + } } + const client = await connectedClient({ provider }) + const names = await listedNames(client) + expect(names.toSorted()).toEqual([...REMOTE_SAFE_TOOLS, ...MEDIA_TOOLS].toSorted()) await client.close() }) @@ -145,9 +162,10 @@ describe('server instructions', () => { }) describe('tool annotations', () => { - it('every tool declares openWorldHint: false', () => { + it('every tool declares openWorldHint — false everywhere except media ingest', () => { for (const [name, annotation] of Object.entries(TOOL_ANNOTATIONS)) { - expect(annotation.openWorldHint, `${name} openWorldHint`).toBe(false) + const expected = name === 'contentrain_media_ingest' + expect(annotation.openWorldHint, `${name} openWorldHint`).toBe(expected) } }) @@ -155,7 +173,8 @@ describe('tool annotations', () => { const client = await connectedClient({ provider: makeProvider() }) const tools = await client.listTools() for (const tool of tools.tools) { - expect(tool.annotations?.openWorldHint, `${tool.name} openWorldHint`).toBe(false) + const expected = tool.name === 'contentrain_media_ingest' + expect(tool.annotations?.openWorldHint, `${tool.name} openWorldHint`).toBe(expected) } await client.close() }) diff --git a/packages/mcp/tests/tools/media.test.ts b/packages/mcp/tests/tools/media.test.ts new file mode 100644 index 0000000..efde297 --- /dev/null +++ b/packages/mcp/tests/tools/media.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it, vi } from 'vitest' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { createServer } from '../../src/server.js' + +vi.setConfig({ testTimeout: 30_000, hookTimeout: 30_000 }) + +/** + * Media tools are a deterministic passthrough to the provider's media + * facet. These tests drive them over a real MCP client against a stubbed + * facet: inputs must arrive at the facet verbatim, outputs must come back + * as structured JSON, and the tools must vanish when the facet is absent. + */ + +const HERO_ASSET = { + id: 'ast_1', + path: 'media/original/hero.webp', + url: 'https://cdn.example.com/proj/media/original/hero.webp', + mime: 'image/webp', + size: 51234, + alt: 'Hero image', + tags: ['landing'], +} + +function makeMediaFacet() { + return { + list: vi.fn(async () => ({ assets: [HERO_ASSET], nextCursor: 'cur_2', total: 41 })), + get: vi.fn(async (id: string) => (id === HERO_ASSET.id ? HERO_ASSET : null)), + ingest: vi.fn(async () => HERO_ASSET), + update: vi.fn(async () => ({ ...HERO_ASSET, alt: 'Updated' })), + delete: vi.fn(async () => {}), + } +} + +function makeProvider(media?: ReturnType) { + return { + capabilities: { + localWorktree: false, + sourceRead: false, + sourceWrite: false, + pushRemote: false, + branchProtection: false, + pullRequestFallback: false, + astScan: false, + }, + media, + async readFile() { throw new Error('no reads expected') }, + async listDirectory() { return [] }, + async fileExists() { return false }, + } +} + +async function connectedClient(media?: ReturnType): Promise { + const server = createServer({ provider: makeProvider(media) }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'test-client', version: '1.0.0' }) + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]) + return client +} + +function parseResult(result: unknown): Record { + const content = (result as { content: Array<{ text: string }> }).content + return JSON.parse(content[0]!.text) as Record +} + +describe('contentrain_media_list', () => { + it('passes filters through and returns assets with pagination', async () => { + const media = makeMediaFacet() + const client = await connectedClient(media) + + const result = await client.callTool({ + name: 'contentrain_media_list', + arguments: { search: 'hero', tag: 'landing', limit: 10, cursor: 'cur_1' }, + }) + const data = parseResult(result) + + expect(media.list).toHaveBeenCalledWith({ search: 'hero', tag: 'landing', limit: 10, cursor: 'cur_1' }) + expect(data['assets']).toEqual([HERO_ASSET]) + expect(data['next_cursor']).toBe('cur_2') + expect(data['total']).toBe(41) + await client.close() + }) +}) + +describe('contentrain_media_get', () => { + it('returns the asset by id', async () => { + const media = makeMediaFacet() + const client = await connectedClient(media) + + const result = await client.callTool({ name: 'contentrain_media_get', arguments: { id: 'ast_1' } }) + expect(parseResult(result)['asset']).toEqual(HERO_ASSET) + await client.close() + }) + + it('errors with a hint for an unknown id', async () => { + const client = await connectedClient(makeMediaFacet()) + + const result = await client.callTool({ name: 'contentrain_media_get', arguments: { id: 'nope' } }) + expect(result.isError).toBe(true) + const data = parseResult(result) + expect(data['error']).toContain('not found') + expect(data['hint']).toContain('contentrain_media_list') + await client.close() + }) +}) + +describe('contentrain_media_ingest', () => { + it('forwards the ingest input verbatim and reports the stored asset', async () => { + const media = makeMediaFacet() + const client = await connectedClient(media) + + const result = await client.callTool({ + name: 'contentrain_media_ingest', + arguments: { url: 'https://example.com/hero.webp', filename: 'hero.webp', alt: 'Hero image', tags: ['landing'] }, + }) + const data = parseResult(result) + + expect(media.ingest).toHaveBeenCalledWith({ + url: 'https://example.com/hero.webp', + filename: 'hero.webp', + alt: 'Hero image', + tags: ['landing'], + }) + expect(data['status']).toBe('ingested') + expect(data['asset']).toEqual(HERO_ASSET) + await client.close() + }) + + it('rejects a non-URL source at the schema layer', async () => { + const media = makeMediaFacet() + const client = await connectedClient(media) + + const result = await client.callTool({ + name: 'contentrain_media_ingest', + arguments: { url: 'not-a-url' }, + }) + expect(result.isError).toBe(true) + expect(media.ingest).not.toHaveBeenCalled() + await client.close() + }) + + it('surfaces provider policy rejections as structured errors', async () => { + const media = makeMediaFacet() + media.ingest.mockRejectedValueOnce(new Error('URL blocked by SSRF policy')) + const client = await connectedClient(media) + + const result = await client.callTool({ + name: 'contentrain_media_ingest', + arguments: { url: 'https://169.254.169.254/latest' }, + }) + expect(result.isError).toBe(true) + const data = parseResult(result) + expect(data['error']).toContain('SSRF policy') + expect(data['stage']).toBe('media_ingest') + await client.close() + }) +}) + +describe('contentrain_media_update', () => { + it('splits id from the metadata patch', async () => { + const media = makeMediaFacet() + const client = await connectedClient(media) + + const result = await client.callTool({ + name: 'contentrain_media_update', + arguments: { id: 'ast_1', alt: 'Updated', tags: ['landing', 'hero'] }, + }) + const data = parseResult(result) + + expect(media.update).toHaveBeenCalledWith('ast_1', { alt: 'Updated', tags: ['landing', 'hero'] }) + expect(data['status']).toBe('updated') + expect((data['asset'] as Record)['alt']).toBe('Updated') + await client.close() + }) +}) + +describe('contentrain_media_delete', () => { + it('requires confirm: true at the schema layer', async () => { + const media = makeMediaFacet() + const client = await connectedClient(media) + + const result = await client.callTool({ name: 'contentrain_media_delete', arguments: { id: 'ast_1' } }) + expect(result.isError).toBe(true) + expect(media.delete).not.toHaveBeenCalled() + await client.close() + }) + + it('deletes with confirmation and reports the id', async () => { + const media = makeMediaFacet() + const client = await connectedClient(media) + + const result = await client.callTool({ + name: 'contentrain_media_delete', + arguments: { id: 'ast_1', confirm: true }, + }) + const data = parseResult(result) + + expect(media.delete).toHaveBeenCalledWith('ast_1') + expect(data['status']).toBe('deleted') + expect(data['id']).toBe('ast_1') + await client.close() + }) +}) + +describe('registration without a media facet', () => { + it('lists no media tools and rejects forced calls as unknown tools', async () => { + const client = await connectedClient(undefined) + + const tools = await client.listTools() + const mediaTools = tools.tools.filter(t => t.name.startsWith('contentrain_media_')) + expect(mediaTools).toEqual([]) + + const result = await client.callTool({ name: 'contentrain_media_list', arguments: {} }) + expect(result.isError).toBe(true) + const text = (result.content as Array<{ text: string }>)[0]!.text + expect(text).toMatch(/not found/i) + await client.close() + }) +}) diff --git a/packages/rules/README.md b/packages/rules/README.md index ee71463..c589ae2 100644 --- a/packages/rules/README.md +++ b/packages/rules/README.md @@ -75,7 +75,7 @@ The package root exports constants for tooling: - `FIELD_TYPES` — 27 flat field types - `MODEL_KINDS` — `singleton`, `collection`, `document`, `dictionary` -- `MCP_TOOLS` — 19 MCP tool names (includes `contentrain_merge`, branch lifecycle tools, and `contentrain_doctor`) +- `MCP_TOOLS` — 24 MCP tool names: 19 core + 5 media (`contentrain_media_*`, registered only on media-capable providers) - `ESSENTIAL_RULES_FILE` — path to essential guardrails markdown - `STACKS` — supported framework stacks @@ -84,7 +84,7 @@ The package root exports constants for tooling: ```ts import { MCP_TOOLS, ESSENTIAL_RULES_FILE, FIELD_TYPES } from '@contentrain/rules' -console.log(MCP_TOOLS.length) // 19 +console.log(MCP_TOOLS.length) // 24 console.log(MCP_TOOLS.includes('contentrain_merge')) // true console.log(MCP_TOOLS.includes('contentrain_doctor')) // true console.log(ESSENTIAL_RULES_FILE) // 'essential/contentrain-essentials.md' diff --git a/packages/rules/essential/contentrain-essentials.md b/packages/rules/essential/contentrain-essentials.md index 448a7b6..4e708fc 100644 --- a/packages/rules/essential/contentrain-essentials.md +++ b/packages/rules/essential/contentrain-essentials.md @@ -57,6 +57,13 @@ MCP is **deterministic infrastructure**. The agent (you) is the **intelligence l | `contentrain_branch_delete` | Delete a stale/failed `cr/*` branch (the contentrain branch is protected) | | `contentrain_bulk` | Batch operations (copy_locale/update_status/delete_entries) | | `contentrain_doctor` | Project health report (env + structure + orphan content + branch pressure + SDK freshness) | +| `contentrain_media_list` | List media assets (media-capable providers only, e.g. Studio MCP Cloud) | +| `contentrain_media_get` | Get one media asset by id | +| `contentrain_media_ingest` | Ingest an asset from a source URL (provider fetches server-side) | +| `contentrain_media_update` | Update asset metadata (alt/tags/filename) | +| `contentrain_media_delete` | Delete an asset (references in content are NOT rewritten) | + +Media tools appear only when the provider exposes a media stack; discover paths with `contentrain_media_list`, then reference them in media/image/file fields via `contentrain_content_save`. ## Mandatory Protocols diff --git a/packages/rules/src/index.ts b/packages/rules/src/index.ts index fa5432d..b32c53b 100644 --- a/packages/rules/src/index.ts +++ b/packages/rules/src/index.ts @@ -23,7 +23,7 @@ export type FieldType = (typeof FIELD_TYPES)[number] export const MODEL_KINDS = ['singleton', 'collection', 'document', 'dictionary'] as const export type ModelKind = (typeof MODEL_KINDS)[number] -// ─── MCP Tools (19 tools) ─── +// ─── MCP Tools (24 tools: 19 core + 5 media) ─── export const MCP_TOOLS = [ 'contentrain_status', 'contentrain_describe', 'contentrain_describe_format', @@ -36,6 +36,10 @@ export const MCP_TOOLS = [ 'contentrain_branch_list', 'contentrain_branch_delete', 'contentrain_bulk', 'contentrain_doctor', + // Media tools — registered only when the provider exposes a media stack + // (Studio MCP Cloud); absent from local stdio servers. + 'contentrain_media_list', 'contentrain_media_get', 'contentrain_media_ingest', + 'contentrain_media_update', 'contentrain_media_delete', ] as const export type McpTool = (typeof MCP_TOOLS)[number] diff --git a/packages/rules/tests/validate-rules.test.ts b/packages/rules/tests/validate-rules.test.ts index cfdccf0..39f44fd 100644 --- a/packages/rules/tests/validate-rules.test.ts +++ b/packages/rules/tests/validate-rules.test.ts @@ -25,7 +25,7 @@ describe('essential rules', () => { describe('constants', () => { it('FIELD_TYPES has 27 entries', () => { expect(FIELD_TYPES).toHaveLength(27) }) it('MODEL_KINDS has 4 entries', () => { expect(MODEL_KINDS).toHaveLength(4) }) - it('MCP_TOOLS has 19 entries', () => { expect(MCP_TOOLS).toHaveLength(19) }) + it('MCP_TOOLS has 24 entries (19 core + 5 media)', () => { expect(MCP_TOOLS).toHaveLength(24) }) it('all MCP tools match pattern', () => { for (const t of MCP_TOOLS) expect(t).toMatch(/^contentrain_/) }) diff --git a/packages/skills/README.md b/packages/skills/README.md index 1fd72f1..44f139f 100644 --- a/packages/skills/README.md +++ b/packages/skills/README.md @@ -127,7 +127,7 @@ This reduces always-loaded context from thousands of lines to just the essential `@contentrain/skills` is kept in lockstep with the MCP tool registry via cross-package parity tests (`tests/mcp-parity.test.ts`): -- `skills/contentrain/references/mcp-tools.md` must have an `### ` heading for every tool in the MCP `TOOL_NAMES` registry (currently 19). +- `skills/contentrain/references/mcp-tools.md` must have an `### ` heading for every tool in the MCP `TOOL_NAMES` registry (currently 24: 19 core + 5 media). - Key skills (normalize, translate) must not reference legacy `contentrain/{operation}/...` branch prefixes — MCP now emits `cr/*`. When MCP's surface changes, these tests fail until the skill docs catch up. diff --git a/packages/skills/skills/contentrain/references/mcp-tools.md b/packages/skills/skills/contentrain/references/mcp-tools.md index d1f4fca..083f35e 100644 --- a/packages/skills/skills/contentrain/references/mcp-tools.md +++ b/packages/skills/skills/contentrain/references/mcp-tools.md @@ -357,3 +357,67 @@ Run git-backed batch operations on existing content entries. - `copy_locale` MUST NOT be used on non-i18n models - Bulk operations create branches and commits like other write tools - Validate afterward when content shape may have changed + +## Media Tools + +Media tools are a deterministic passthrough to the provider's media stack. They are **registered only when the provider exposes one** (e.g. Studio MCP Cloud) — local stdio servers and plain GitHub/GitLab providers never list them. The flow: list assets → pick a `media/...` path → reference it in media/image/file fields via `contentrain_content_save` (paths are normalized to absolute delivery URLs when the provider sets `mediaBaseUrl`). + +### contentrain_media_list + +List media assets with optional filtering and cursor pagination. Read-only. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `search` | string | No | Substring match on filename/path/alt | +| `tag` | string | No | Only assets carrying this tag | +| `limit` | number | No | Page size (1–100, provider may clamp) | +| `cursor` | string | No | Opaque cursor from a previous response | + +Returns `{ assets[], next_cursor?, total? }`. Each asset carries `id`, `path` (`media/...`), optional `url`, `mime`, `size`, `alt`, `tags`, `createdAt`, `meta`. + +### contentrain_media_get + +Get a single media asset by id. Read-only. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | string | Yes | Asset id from `contentrain_media_list` | + +Returns `{ asset }` or an error with a discovery hint for unknown ids. + +### contentrain_media_ingest + +Ingest an asset from a source URL. The provider fetches the URL server-side under its own SSRF/MIME/size policy — MCP never fetches it. The only open-world tool (`openWorldHint: true`). + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `url` | string | Yes | Source URL to fetch server-side | +| `filename` | string | No | Target filename override | +| `alt` | string | No | Alt text | +| `tags` | string[] | No | Tags to attach (max 20) | + +Returns `{ status: "ingested", asset }` — use `asset.path` in content fields afterward. + +### contentrain_media_update + +Update asset metadata. Never touches the binary. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | string | Yes | Asset id to update | +| `alt` | string | No | New alt text | +| `tags` | string[] | No | Replacement tag list (max 20) | +| `filename` | string | No | New filename | + +Returns `{ status: "updated", asset }`. + +### contentrain_media_delete + +Delete an asset from the media stack. Destructive — content entries referencing its path are NOT rewritten; check references first with `contentrain_content_list`. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | string | Yes | Asset id to delete | +| `confirm` | boolean | Yes | Must be `true` to confirm | + +Returns `{ status: "deleted", id }`. diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 9b09400..61cbeba 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -862,6 +862,12 @@ export type { CommitAuthor, FileChange, FileDiff, + MediaAsset, + MediaIngestInput, + MediaListOptions, + MediaListResult, + MediaProvider, + MediaUpdateInput, MergeResult, ProviderCapabilities, RepoProvider, diff --git a/packages/types/src/provider.ts b/packages/types/src/provider.ts index 8674d6b..53f89a0 100644 --- a/packages/types/src/provider.ts +++ b/packages/types/src/provider.ts @@ -188,6 +188,79 @@ export interface MergeResult { } } +// ─── Media (optional provider facet) ─── + +/** + * One media asset as the provider's media stack reports it. `path` is the + * repo-relative storage path content fields reference (`media/...`); `url` + * is the absolute delivery URL when the provider can resolve one (see + * `RepoProvider.mediaBaseUrl`). `meta` carries provider-defined extras + * (dimensions, blurhash, variants) without widening this contract. + */ +export interface MediaAsset { + id: string + path: string + url?: string + mime?: string + size?: number + alt?: string + tags?: string[] + createdAt?: string + meta?: Record +} + +export interface MediaListOptions { + /** Substring match on filename/path/alt. */ + search?: string + /** Filter to assets carrying this tag. */ + tag?: string + /** Page size — implementations may clamp. */ + limit?: number + /** Opaque cursor from a previous `MediaListResult.nextCursor`. */ + cursor?: string +} + +export interface MediaListResult { + assets: MediaAsset[] + nextCursor?: string + total?: number +} + +/** + * URL-based ingest — MCP has no binary channel, so the source is always a + * URL the provider fetches server-side. Implementations MUST enforce their + * own SSRF, MIME, and size policies before fetching; MCP passes the input + * through verbatim and never fetches the URL itself. + */ +export interface MediaIngestInput { + url: string + filename?: string + alt?: string + tags?: string[] +} + +export interface MediaUpdateInput { + alt?: string + tags?: string[] + filename?: string +} + +/** + * Optional media facet of a provider. When present on `RepoProvider.media`, + * the MCP server registers the `contentrain_media_*` tools; when absent + * (LocalProvider, plain GitHub/GitLab providers), those tools are not + * listed at all. Hosted providers (Studio MCP Cloud) implement this by + * delegating to their media stack — deterministic passthrough, no content + * decisions in MCP. + */ +export interface MediaProvider { + list(opts?: MediaListOptions): Promise + get(id: string): Promise + ingest(input: MediaIngestInput): Promise + update(id: string, patch: MediaUpdateInput): Promise + delete(id: string): Promise +} + // ─── Provider (full surface) ─── /** @@ -219,6 +292,13 @@ export interface RepoProvider extends RepoReader, RepoWriter { */ readonly mediaBaseUrl?: string + /** + * Optional media facet. Present only on providers whose backend exposes a + * media stack (Studio MCP Cloud); drives the `contentrain_media_*` MCP + * tools, which are not registered when this is absent. + */ + readonly media?: MediaProvider + listBranches(prefix?: string): Promise createBranch(name: string, fromRef?: string): Promise deleteBranch(name: string): Promise