Skip to main content
Glama
README.md
# strapi-plugin-mcp-suite

> **Model Context Protocol server for Strapi v5**
> Expose your Strapi instance to LLM clients (Claude, Cursor, any MCP-compatible) for generic
> content management, visual layout configuration, schema authoring, media uploads and GraphQL
> testing β€” with native Strapi API token auth, multi-layer rate limiting and a forensic audit
> trail for every operation.

πŸ‡ͺπŸ‡Έ [Leer en espaΓ±ol](./README.es.md)

---

## TL;DR

Drop this plugin into any Strapi v5 project, create an API token, point your MCP client at
`/api/strapi-mcp-suite/stream`. Your LLM can now read/write entries, reorganize admin UI layouts, generate
components and content-types (opt-in), upload media (opt-in) and execute GraphQL queries (opt-in) β€”
all through native Strapi APIs (`strapi.documents()`, lifecycle hooks, validation, draft & publish).

The plugin ships with hardened defaults: path traversal blocking, SSRF protection (AWS IMDS /
RFC1918 / DNS rebinding), rate limiting in 3 layers (per-token / per-user / per-IP), a fail-closed
production mode for schema authoring, and a forensic audit trail (token lifecycle + every operation)
with delete-permission enforcement on tokens.

---

## Coexistence with Strapi's native MCP server (5.47+)

Strapi 5.47 introduced a **native MCP server** (`/mcp`) that generates content CRUD tools from your
schema. This plugin is designed to **complement, not compete with** it: the native server owns the
commodity CRUD, while this plugin focuses on what the native server does **not** do β€” schema
authoring, visual layout configuration, media uploads, GraphQL, and a forensic audit trail.

Control how they coexist with the `coexistence` setting (or the `MCP_COEXISTENCE` env override):

| Mode | Behavior |
|---|---|
| **`auto`** *(default)* | If the native MCP is detected serving (Strapi β‰₯ 5.47 **and** `server.mcp.enabled === true`), the plugin **auto-suppresses its CRUD tools** (`find_entries`, `create_entry`, …) so the LLM never sees duplicates. If there's no native MCP, the plugin serves everything. |
| **`standalone`** | Ignores the native server; the plugin serves all enabled tools on its own endpoint (use this if you want the plugin's CRUD even alongside the native one). |
| **`extend-native`** | Registers the plugin's **differentiators into the native server** (`strapi.ai.mcp`), so a single `/mcp` endpoint serves native CRUD **+** schema authoring + layout + media/graphql. One endpoint, one auth (the native admin tokens). |

> **Note on auth:** the native MCP authenticates with **admin tokens** (created in *Settings β†’ Admin
> Tokens*), while this plugin's standalone endpoint uses **content API tokens**. They are not
> interchangeable. In `extend-native` mode the bridged tools run under the native admin-token auth.

```ts
// config/plugins.ts
export default {
  'strapi-mcp-suite': {
    enabled: true,
    config: {
      coexistence: 'auto',       // 'auto' | 'standalone' | 'extend-native'
      contentOps: true,          // CRUD tools (auto-suppressed when coexisting)
      schemaAuthoring: false,
      upload: false,
      graphql: false,
      customTokenWrite: 'strict', // 'strict' | 'endpoint' β€” see below
    },
  },
};
```

**`customTokenWrite`** controls how a *custom* API token is authorized to write via
content-ops:
- **`strict`** (default): the token must hold the matching `${uid}.${action}` Strapi permission.
  Strapi freezes a token's permissions at creation time, so content-types added later aren't
  covered β€” and if a content-type doesn't expose content-api routes, the permission isn't even
  grantable.
- **`endpoint`**: any custom token that reached the endpoint (it already has `stream.handle`) may
  write to **any** content-type β€” scalable, no per-type re-granting. The plugin writes through
  `strapi.documents()`, below the route layer, so it works even for content-types without
  content-api routes. `read-only` tokens stay blocked in both modes; `full-access` is unchanged.

Gating is **config-driven** (precedence: built-in defaults β†’ `config/plugins.ts` β†’ env override).
The env vars (`CONTENT_OPS_ENABLED`, `SCHEMA_AUTHORING_ENABLED`, `UPLOAD_ENABLED`, `GRAPHQL_ENABLED`,
`MCP_COEXISTENCE`, `MCP_CUSTOM_TOKEN_WRITE`) still work and win over config.

---

## Features

### Built-in tools (33 total, organized by capability)

| Category | Tools | Notes |
|---|---|---|
| **Content ops** | `list_content_types`, `get_content_type_schema`, `find_entries`, `get_entry`, `create_entry`, `update_entry`, `delete_entry`, `publish_entry`, `unpublish_entry` | Generic CRUD on any content-type. Delegates to `strapi.documents()`. Always available. |
| **Visual layout** | `get_visual_layout`, `set_field_layout`, `set_field_metadata`, `set_view_settings` | Modifies the Content Manager UI config (widths, labels, ordering). Stored in `strapi_core_store_settings`. **No restart required.** |
| **Schema authoring** | `list_existing_schemas`, `read_schema`, `validate_schema_proposal`, `create_component`, `create_content_type`, `add_field_to_schema`, `delete_field_from_schema` | Writes `.json` schemas and `.ts` stubs to filesystem. Requires Strapi restart for new schemas to load. **Gated by `SCHEMA_AUTHORING_ENABLED=true`.** Refused in production. |
| **Media / upload** | `list_media`, `get_media`, `upload_media_from_url`, `update_media_metadata`, `delete_media`, `link_media_to_entry` | URL-based uploads. Works with any Strapi provider (local, S3, Cloudinary, R2, etc.). **Gated by `UPLOAD_ENABLED=true`.** SSRF-protected. |
| **GraphQL** | `graphql_introspect`, `graphql_query`, `graphql_generate_query` | Test GraphQL queries, introspect the schema, generate queries from a content-type UID. Mutations require explicit `allow_mutations:true`. **Gated by `GRAPHQL_ENABLED=true`** and requires `@strapi/plugin-graphql` installed. |
| **Diagnostics** | `__health`, `__list_registered_tools` | Health ping (use after schema-authoring to confirm Strapi restarted) + registry inventory. Always available. |
| **Audit** | `__audit_token_creators`, `__audit_log_query` | Read-only views over the forensic audit tables (who created each token, every tool invocation with redacted args). **Requires super-admin.** Always exposed but always denies non-super-admin callers. |

### Extensibility

Custom tools registered from your project bootstrap appear alongside the built-ins:

```ts
strapi.plugin('strapi-mcp-suite').service('registry').registerTool({
  name: 'my_custom_tool',
  description: '...',
  inputSchema: { ... },
  handler: async (ctx, args) => { ... },
  testCases: [ ... ],   // optional, run automatically in dev bootstrap
  tags: ['read'],
});
```

The registry validates structure (snake_case naming, JSON Schema validity, no built-in name
collision) and optionally runs `testCases` to give you confidence before exposing the tool to the
LLM.

---

## Requirements

- **Strapi**: 5.0.0+ (5.45+ recommended for full anti-impersonation)
- **Node.js**: 20+ (uses built-in `fetch`, `crypto.subtle`, etc.)
- **MCP client**: Claude Desktop, Claude Code CLI, or any MCP-compatible client supporting
  streamable HTTP transport

---

## Installation

```bash
pnpm add strapi-plugin-mcp-suite
```

Strapi v5 auto-discovers the plugin β€” no `config/plugins` entry is required.
Restart Strapi. You should see:

```
[strapi-mcp] plugin loaded β€” endpoint /api/strapi-mcp-suite/stream | strapi=5.46.0 | env=development | schema_authoring=disabled | upload=disabled | graphql=disabled
```

> Developing the plugin from source instead? See [CONTRIBUTING.md](CONTRIBUTING.md) for the
> local-clone setup.

---

## Quick start

### 1. Create an API token

In the Strapi admin: **Settings β†’ API Tokens β†’ Create new API Token**.

- **Name**: include your email, e.g. `youremail@example.com - mcp client`. The plugin uses the email
  in the name (combined with `adminUserOwner`) for anti-impersonation and to attribute `createdBy` /
  `updatedBy` in entries created via MCP.
- **Token type**: `Full access` (recommended) or `Custom` with the content-types you want exposed.
- **Lifespan**: as your team requires.

Copy the token β€” it's shown only once.

### 2. Configure your MCP client

#### Claude Code (CLI)

Edit `~/.claude.json` or your client's config:

```json
{
  "mcpServers": {
    "strapi-local": {
      "url": "http://localhost:1337/api/strapi-mcp-suite/stream",
      "headers": {
        "Authorization": "Bearer YOUR_TOKEN_HERE"
      }
    }
  }
}
```

#### Claude Desktop (Windows / macOS)

Claude Desktop only supports stdio transport, so you need
[`mcp-remote`](https://www.npmjs.com/package/mcp-remote) as a bridge. In
`%APPDATA%\Claude\claude_desktop_config.json` (Windows) or `~/Library/Application
Support/Claude/claude_desktop_config.json` (macOS):

```json
{
  "mcpServers": {
    "strapi-local": {
      "command": "npx.cmd",
      "args": [
        "-y",
        "mcp-remote",
        "http://localhost:1337/api/strapi-mcp-suite/stream",
        "--header",
        "Authorization:${AUTH_HEADER}"
      ],
      "env": {
        "AUTH_HEADER": "Bearer YOUR_TOKEN_HERE"
      }
    }
  }
}
```

On Windows, use `npx.cmd` (not `npx`). After editing, fully quit Claude Desktop (system tray β†’ Quit)
and reopen.

### 3. Try a first tool call

In Claude:

> *"List the content types in my Strapi instance and show me the fields of each."*

This invokes `list_content_types` and you should see your CTs (article, author, etc.) with their
attributes.

---

## Configuration

All configuration is via environment variables. See `.env.example` in the repo root for the complete
annotated list. Quick reference:

| Variable | Default | Purpose |
|---|---|---|
| `SCHEMA_AUTHORING_ENABLED` | `false` | Exposes the 7 schema-authoring tools (writes `.json` schemas to filesystem). Refused if `NODE_ENV` is production-ish. |
| `UPLOAD_ENABLED` | `false` | Exposes the 6 media library tools. Requires an upload provider configured. |
| `GRAPHQL_ENABLED` | `false` | Exposes the 3 GraphQL tools. Requires `@strapi/plugin-graphql` installed. |
| `MCP_RATE_LIMIT_PER_MIN` | `60` | Per-token rate limit (sliding window). |
| `MCP_RATE_LIMIT_PER_USER_PER_MIN` | `120` | Per-admin-user rate limit (sums all tokens of the same owner). Requires Strapi 5.45+. |
| `MCP_RATE_LIMIT_PER_IP_PER_MIN` | `300` | Per-origin-IP rate limit. Requires `proxy: true` in `config/server.ts` if behind a reverse proxy. |
| `MCP_RATE_LIMIT_WINDOW_MS` | `60000` | Sliding window size (milliseconds). |
| `UPLOAD_URL_ALLOWED_HOSTS` | (empty) | Strict allowlist for `upload_media_from_url`. If set, only these hosts can be downloaded. |
| `UPLOAD_URL_ALLOWED_DOMAIN_SUFFIXES` | (empty) | Same as above but matches domain suffixes (e.g. `.amazonaws.com`). |
| `UPLOAD_URL_EXTRA_BLOCKED_HOSTS` | (empty) | Additional hosts to block (extends the hardcoded blocklist). |
| `UPLOAD_URL_EXTRA_BLOCKED_CIDRS` | (empty) | Additional IPv4 CIDR ranges to block. |
| `MCP_AUDIT_RETENTION_DAYS` | `90` | `op-log` rows older than this are deleted. `0` disables the age pass. |
| `MCP_AUDIT_MAX_ROWS` | `100000` | Cap on `op-log` rows. Oldest are trimmed first. `0` disables the cap. |
| `MCP_AUDIT_CLEANUP_INTERVAL_HOURS` | `24` | How often the cleanup job runs. Minimum `1`. |

---

## Security model

The plugin is designed assuming the LLM is **untrusted input** β€” prompt injection, poisoning, or
jailbreak could turn it into an adversary. Defenses:

### Authentication & granular permissions

- **Native Strapi API tokens** β€” no custom auth scheme to break. Reuses Strapi's hashing and
  storage.
- **Granular permission enforcement** β€” Custom tokens must have `plugin::strapi-mcp-suite.stream.handle`
  explicitly marked. Tokens of type `Custom` without the MCP permission marked are rejected with
  `401 Custom token missing MCP permission`. `Full Access` and `Read Only` tokens pass by design
  (broader scope).
- **Best-effort attribution** β€” if the token has `adminUserOwner` populated (only happens for
  `kind='admin'` tokens with the experimental `features.future.adminTokens` flag), the plugin
  attributes `createdBy`/`updatedBy` on entries. For standard `content-api` tokens, attribution is
  null (see [Known limitations](#known-limitations)).

### Path traversal (schema authoring)

- All UID segments validated against `^[a-z][a-z0-9-]*$` before being used in `path.join`.
- Defense in depth: `assertWithinAllowedRoot()` ensures the resolved absolute path is under
  `src/api/` or `src/components/`.
- `writeFiles` performs a final containment check before any disk write.
- Backups go to `.strapi-mcp-backups/` (gitignored by default), preserving relative paths.

### SSRF (`upload_media_from_url`)

- Protocol allowlist: only `http://` and `https://`. Blocked: `file://`, `gopher://`, `javascript:`,
  `data:`, etc.
- IPv4 blocklist: loopback, RFC1918, CGNAT, link-local (including AWS IMDS `169.254.169.254`),
  Alibaba metadata (`100.100.100.0/24`), reserved ranges.
- IPv6 blocklist: `::1`, `fc00::/7` (ULA), `fe80::/10` (link-local), multicast, IPv4-mapped
  variants.
- DNS rebinding defense: hostnames are resolved and **all returned IPs** validated.
- Redirect chasing: `fetch` uses `redirect: 'manual'` and re-validates each hop (max 3 redirects).
- Per-environment override via `UPLOAD_URL_ALLOWED_HOSTS` (strict mode) or
  `UPLOAD_URL_EXTRA_BLOCKED_HOSTS` / `_CIDRS`.

### Rate limiting (3 layers)

| Layer | Default | Defends against |
|---|---|---|
| Per-token (SHA-256 of bearer) | 60 req/min | Leaked token abuse |
| Per-admin-user | 120 req/min | A user creating N tokens to bypass per-token |
| Per-IP | 300 req/min | Independent secondary layer; handles NAT'd teams |

Each layer is a sliding window. Any layer hitting its limit returns `429` with `Retry-After` and
`details.layer` identifying which limit fired.

### Production guardrails

- `isProduction()` is **fail-closed**: if `NODE_ENV` is not explicitly `development`, `test` or
  `dev`, schema authoring is refused. Docker containers without `NODE_ENV` get safe defaults.
- Schema authoring tools are hidden from `tools/list` unless `SCHEMA_AUTHORING_ENABLED=true`. Even
  if enabled, writers refuse in production.
- GraphQL mutations require explicit `allow_mutations: true` per call.
- Destructive operations (`delete_*`) require `confirm: true`.

### Audit trail (v0.4.0)

The plugin maintains two internal tables (hidden from Content Manager and Content-Type Builder, not
exposed via REST/GraphQL):

- **`mcp_token_audits`** β€” one row per API token. Captures `creator_id`, `creator_email`,
  `created_at_real`, and on deletion `deleter_id`, `deleter_email`, `deleted_at`. Tokens that
  existed before the plugin was installed are backfilled with `creator_email='unknown'` and
  `is_legacy=true`.
- **`mcp_op_logs`** β€” one row per `tools/call` over the MCP endpoint. Captures: `tool_name`,
  `status` (ok/error), `duration_ms`, `token_id`, `admin_user_id`, `admin_email`, `ip`,
  `user_agent`, `args_redacted` (args with secret-shaped keys replaced by `[REDACTED]`),
  `result_summary` (small extraction β€” `documentId`, `count`, `uid` β€” **never** the full payload),
  and `error_message` for failures.

**Delete-permission enforcement on `admin::api-token`:** a `beforeDelete` lifecycle hook blocks
deletion unless the caller is the original creator OR a super-admin. Legacy tokens require
super-admin. The deletion itself is recorded by `afterDelete`, so even authorized deletions leave a
trace.

**Retention:** `op-log` is bounded by both an age window (`MCP_AUDIT_RETENTION_DAYS`, default 90)
and a row cap (`MCP_AUDIT_MAX_ROWS`, default 100k). A cleanup job runs every
`MCP_AUDIT_CLEANUP_INTERVAL_HOURS` (default 24) in batches of 1000. Setting either limit to `0`
disables that pass β€” useful for tests, not recommended in production.

**Querying the audit:**

```jsonc
// Tool: __audit_token_creators
// Args: { include_deleted?: boolean = true, limit?: number (cap 500) }
// Returns: { count, tokens: [{token_id, token_name, token_type, creator_id, creator_email, created_at, deleter_id?, deleter_email?, deleted_at?, is_legacy}] }

// Tool: __audit_log_query
// Args: { token_id?, admin_user_id?, tool_name?, status?, since? (ISO), until? (ISO), limit?, include_payloads?: boolean = false }
// Returns: { count, filters, include_payloads, rows: [...] }
```

**Both tools require a super-admin caller.** Since standard `content-api` tokens have no admin user
resolved (see [Known limitations](#known-limitations)), invoking these tools in practice requires:
1. Strapi 5.45+ with `features.future.adminTokens: true` in `config/admin.ts`.
2. A token created from a super-admin session (so `adminUserOwner` populates with that user).

If your setup doesn't meet those conditions, you can still query the tables directly via SQL β€” the
data is captured regardless of whether the introspection tools are usable. Example:

```sql
SELECT tool_name, status, duration_ms, admin_email, ip, ts
FROM mcp_op_logs
WHERE ts > datetime('now', '-1 day')
ORDER BY ts DESC
LIMIT 100;
```

**What the audit does NOT do:** it does not *prevent* impersonation β€” that's structurally impossible
in standard Strapi 5.x (see Known limitations). It provides **forensic evidence** so an incident can
be reconstructed after the fact, and it raises the cost of "delete the evidence then deny" since the
delete itself is logged.

### What this plugin does NOT protect against

- **Compromise of the Strapi admin user that creates tokens** β€” out of scope; if the admin is
  compromised, the attacker can create tokens anyway. The audit will record the creation under that
  user, which helps post-incident.
- **Egress firewall bypass** β€” if your server can reach `169.254.169.254`, the plugin blocks but
  ideally your VPC also blocks. Defense in depth.
- **Distributed attacks across multiple instances** β€” rate limit is in-memory per instance. Use a
  CDN/proxy or Redis backend for cluster-wide limits.

---

## Extensibility: `registerTool`

Custom tools live in your project's `src/index.ts` bootstrap:

```ts
export default {
  register() {},
  bootstrap({ strapi }) {
    strapi.plugin('strapi-mcp-suite').service('registry').registerTool({
      name: 'feature_article',
      description: 'Marks an article as featured (sets is_featured=true and featured_at=now). Useful when an editor wants to highlight content without opening the admin.',
      inputSchema: {
        type: 'object',
        properties: {
          documentId: { type: 'string' },
          unfeature: { type: 'boolean', default: false },
        },
        required: ['documentId'],
        additionalProperties: false,
      },
      handler: async ({ strapi }, args) => {
        const uid = 'api::article.article';
        const current = await strapi.documents(uid).findOne({ documentId: args.documentId });
        if (!current) throw new Error(`Article ${args.documentId} not found`);

        return strapi.documents(uid).update({
          documentId: args.documentId,
          data: args.unfeature
            ? { is_featured: false, featured_at: null }
            : { is_featured: true, featured_at: new Date().toISOString() },
        });
      },
      testCases: [
        { name: 'rejects unknown', args: { documentId: 'does-not-exist' }, expect: { errorMatches: /not found/ } },
      ],
      tags: ['write'],
    });
  },
};
```

The registry enforces:

- `name` is snake_case, 3-64 chars, doesn't collide with a built-in
- `description` is β‰₯30 chars (helps the LLM choose when to invoke)
- `inputSchema` is a valid JSON Schema with `additionalProperties: false`
- `required` references only fields present in `properties`
- `handler` is an async function
- `testCases` (optional) follow the expected shape

If validation fails, `registerTool` throws on boot with a detailed error message.

### Use `__list_registered_tools`

Call this tool from your MCP client to see what's registered and the results of the last self-test
run for each custom tool. Useful for debugging.

---

## Testing

### Unit tests (Node built-in test runner)

```bash
cd src/plugins/strapi-mcp
npm test
```

145+ tests covering: URL safety (SSRF), schema validator (9 rules), path-lock (concurrency), writer
(path traversal defenses), registry (tool definition validation), rate limiting (sliding window,
multi-layer), schema derivation, content-ops handlers.

### Security smoke test (script against running Strapi)

```bash
export STRAPI_MCP_TOKEN=<your-token>
bash src/plugins/strapi-mcp/scripts/smoke-test.sh
```

Windows:

```powershell
$env:STRAPI_MCP_TOKEN = "<your-token>"
pwsh src/plugins/strapi-mcp/scripts/security-test.ps1
```

The security test exercises 18+ regression cases for C1 (path traversal), C3 (SSRF), H1 (GraphQL
auth), M1 (find_entries cap, GraphQL query bombs), rate limit, plus manual instructions for C2
(token impersonation) and H3 (backups location).

---

## Troubleshooting

| Symptom | Cause | Fix |
|---|---|---|
| Tool not appearing in client | Client cached `tools/list` from a previous session | Fully quit MCP client (system tray β†’ Quit), reopen |
| `Tool "X" no encontrada` from new tool | Strapi runs the old plugin dist | Restart Strapi (`Ctrl+C` + `pnpm dev`); the plugin doesn't hot-reload |
| `401 Token name email mismatch` | Token name contains an email different from `adminUserOwner` email | Rename token to match the owner's email, or remove email from name |
| `URL_BLOCKED` on a legitimate URL | URL caught by SSRF blocklist | Add to `UPLOAD_URL_EXTRA_BLOCKED_HOSTS` exception list, or switch to allowlist mode |
| `429 Too Many Requests` | Rate limit hit | Wait 60s, or raise `MCP_RATE_LIMIT_PER_MIN` in dev. Restart Strapi to wipe counters |
| Schema authoring fails with `SCHEMA_AUTHORING_DISABLED_IN_PRODUCTION` even in dev | `NODE_ENV` not set | Explicitly set `NODE_ENV=development` in your `.env` |
| `Tool "graphql_query" no encontrada` | `GRAPHQL_ENABLED=false` or `@strapi/plugin-graphql` not installed | Enable + install the plugin |
| Behind a CDN/proxy, per-IP rate limit triggers immediately | All requests appear to come from the proxy IP | Set `proxy: true` in `config/server.ts` |
| `403 [strapi-mcp audit] Delete bloqueado` when deleting an API token | Caller is not the original creator and not super-admin | Log in as the creator or as a super-admin. If the token is legacy (created before v0.4.0), only super-admin can delete it. |
| `__audit_token_creators` returns `AUDIT_REQUIRES_SUPER_ADMIN` | Token has no admin user resolved (standard content-api tokens) | Activate `features.future.adminTokens: true` in `config/admin.ts` and create the audit-query token from a super-admin session; or query the `mcp_token_audits` / `mcp_op_logs` tables directly via SQL. |

---

## Deep population on reads (v0.5.0)

`find_entries` and `get_entry` accept two extra args to materialize a recursive populate tree
without you having to hand-craft it:

```jsonc
{
  "uid": "api::page.page",
  "populate_deep": true,
  "populate_depth": 4   // default 4, hard cap 6
}
```

When `populate_deep: true`, the plugin walks the live schema and builds a populate object that
expands every relation, component, dynamiczone and media field, recursing up to `populate_depth`
levels. Cycles are protected by a `visited` Set β€” bidirectional relations don't spin forever.

**Trade-offs:**
- Queries become larger and slower. Use only when you genuinely need the full context (e.g.
  rendering a page with all its dynzone sections expanded).
- The `pageSize` cap of 200 still applies, so worst-case is ~200 entries Γ— the branching at each
  depth level.
- `populate` (the explicit object) is ignored when `populate_deep: true`. The response carries a
  `warning` field if you accidentally pass both.

System models (`admin::user`, `plugin::users-permissions.*`) are treated as shallow β€” large trees,
rarely useful from an MCP client.

## Schema strategies on writes (v0.5.0)

Strapi's Content-Type Builder UI doesn't allow editing a component that nests another component more
than 1 level deep. Before v0.5.0, the validator caught proposals exceeding this and returned an
error. Now it returns **strategies** β€” concrete alternatives the LLM can pick from.

When `create_component` receives a proposal triggering `NESTED_COMPONENT_DEPTH_EXCEEDED`, the
response shape is:

```jsonc
{
  "success": false,
  "validation": { ... },
  "strategies": [
    { "name": "flat", "available": true, "schema": { ... }, "trade_offs": [...] },
    { "name": "modular", "available": true, "schema": { ... }, "wiring_instructions": "...", "trade_offs": [...] },
    { "name": "dynamiczone", "available": false, "unavailable_reason": "..." }
  ],
  "hint": "Elige una estrategia (flat | modular | dynamiczone) y vuelve a llamar con `strategy: '<nombre>'`."
}
```

The three strategies:

| Strategy | What it does | When it's unavailable |
|---|---|---|
| `flat` | Inlines the nested component's attributes into the parent with a `${attrName}_` prefix. One file, no manual wiring. | Parent attr is `repeatable: true`, nested component doesn't exist, or prefixed names would collide. |
| `modular` | Writes the parent without the nested ref. Returns `wiring_instructions` with the JSON snippet the user must paste into the parent's schema manually. Maximum reusability. | Always available. |
| `dynamiczone` | Converts the offending attribute to a `dynamiczone` (resets Strapi's depth counter). | Not applicable when the proposal is a component (dynzones only live in content-types). |
| `as-proposed` (escape hatch) | Writes the schema EXACTLY as proposed, preserving the depth. The CTB UI rejects opening this component for editing, but Strapi's backend (DB, REST, GraphQL, lifecycle, populate) handles deeper nesting fine. | Always available β€” for users who know the limitation and prefer JSON-only editing. |

To materialize, re-call `create_component` with `strategy: 'flat' | 'modular' | 'dynamiczone' |
'as-proposed'`. The plugin applies the strategy, re-validates, and writes.

For a pure dry-run analysis without committing, use **`propose_schema_strategy`** β€” same input, no
disk writes, returns the same strategy list.

### Batch field additions: `add_fields_to_schema`

The singular `add_field_to_schema` triggers a Strapi restart per call (~12s downtime each). When
adding 2+ fields to the same schema, use the new **`add_fields_to_schema`** (plural) tool: it reads
the schema once, merges all fields, validates, and writes once β†’ **one restart total**.

```jsonc
{
  "uid": "api::page.page",
  "fields": [
    { "field_name": "subtitle", "field": { "type": "string" } },
    { "field_name": "slug",     "field": { "type": "uid", "targetField": "subtitle" } },
    { "field_name": "cover",    "field": { "type": "media" } }
  ]
}
```

Atomic: if any field collides (within the batch or against existing attributes), the entire
operation aborts without writing. No partial states.

**Note**: Strategy support currently lives in `create_component` only. `create_content_type` and
`add_field_to_schema` will get the same fork in a future release.

### Full schema mutation in one restart: `modify_schema` (v0.6.0)

`modify_schema` is the most powerful schema tool β€” it combines **remove + add + update** into one
atomic write β†’ **a single Strapi restart** instead of N:

```jsonc
{
  "uid": "molecules.feature-item",
  "remove": ["legacy_field"],
  "update": [{ "field_name": "count", "field": { "type": "biginteger" } }],
  "add":    [{ "field_name": "slug",  "field": { "type": "uid", "targetField": "title" } }]
}
```

- `remove[]` β€” field names to delete (refuses if a relation in another schema depends on them via
  `inversedBy`/`mappedBy`)
- `update[]` β€” replace a field's full definition. The way to change a field's `type` (e.g. `text β†’
  string`) without orchestrating delete-then-add.
- `add[]` β€” new fields (collision-checked)

Applied in order `remove β†’ update β†’ add`, validated as a whole, written once. Any failure aborts
everything β€” no partial states. Cross-list conflicts (a name in both `remove` and `add`, duplicates,
etc.) are caught before the filesystem is touched.

### Proactive atomization: `suggest_reusable_atoms` (v0.6.0)

Read-only analysis tool. Walks every component and content-type, counts repeated `(fieldName, type)`
patterns, and flags scalar fields worth promoting to reusable atom components β€” the classic case of
`title: string` copy-pasted into 8 sections.

```jsonc
{ "scope": "all", "min_occurrences": 3 }
```

For each strong candidate it returns the `used_in` list, a **starter atom schema** (with built-in
enrichment for known names β€” `title β†’ atoms.heading` with tag/align, `icon β†’ atoms.icon` with
size/color), and an `execution_plan` of concrete `create_component` + `modify_schema` calls you can
run after review. It also surfaces depth warnings (when a consumer is itself nested) and a
data-migration note. Never writes.

## Known limitations

### Schema-authoring chains can hang Claude Desktop via mcp-remote

When you call `add_field_to_schema` (or any schema-authoring tool), Strapi restarts in dev mode and
the MCP endpoint is unreachable for ~10-15s. Two compounding issues make this fragile when chaining
multiple operations from Claude Desktop:

1. **The LLM frequently ignores `restart_info.estimated_downtime_seconds`.** Observed in live
   testing: Claude received `estimated_downtime_seconds: 12` and called `__health` just 2 seconds
   later, hitting Strapi mid-restart. The plugin can only emit hints in the tool response β€” the
   protocol has no mechanism to block the next call for N seconds.
2. **The `mcp-remote` bridge gives up after 2 reconnection attempts.** Claude Desktop uses
   [mcp-remote](https://www.npmjs.com/package/mcp-remote) as an stdio↔HTTP bridge. When the endpoint
   returns `ECONNREFUSED` during the restart window, mcp-remote tries twice and then throws `Maximum
   reconnection attempts (2) exceeded`. Even when Strapi comes back, the session stays dead until
   Claude Desktop is fully restarted.

**Workarounds:**

- **Prefer batch operations.** Use `add_fields_to_schema` (plural) to apply N fields in one restart
  instead of N restarts. Same logic applies to `create_content_type` with all attributes defined up
  front. Each restart is one chance to lose the session β€” minimize the count.
- **Restart Claude Desktop completely** (system tray β†’ Quit, NOT just close the window) if a chain
  fails mid-way. Reopening clears the dead bridge.
- **For projects with slow boot** (TypeScript types + many plugins + WSL/VMs), `restart_info` may
  underestimate. After a schema operation, wait ~25s manually before any next MCP interaction.
- **Consider Claude Code instead of Claude Desktop** for heavy schema-authoring sessions. Claude
  Code talks to the MCP server over HTTP directly (no stdio bridge) and handles `ECONNREFUSED` more
  gracefully.

This is a Claude Desktop + mcp-remote compatibility issue, not a plugin bug β€” the endpoint behaves
identically to any other HTTP service during restart. The audit log shows the operation succeeded
server-side even when the client sees a hung session.

### Anti-impersonation via `adminUserOwner` is not implemented

An earlier security audit identified a scenario: if a user with permission to create API tokens
names their token `"ceo@company.com - mcp"`, all writes via that token would be attributed to the
CEO via the email-in-token-name convention. The initial fix in 0.3.0 tried to mitigate this by
verifying the token's `adminUserOwner` field against the email in the name.

**Investigation in 0.3.1 revealed the mitigation is not feasible** in standard Strapi 5.x. The
`adminUserOwner` field is only populated for tokens of `kind='admin'` (a feature gated behind
`features.future.adminTokens: true` in `config/admin.ts` β€” experimental, not enabled by default).
Standard `content-api` tokens (the ones created from `Settings β†’ API Tokens`) have `adminUserOwner`
forced to `null` by Strapi's admin service ([see
source](https://github.com/strapi/strapi/blob/main/packages/core/admin/server/src/services/api-token.ts)).

The check was removed in 0.3.1 because keeping it would generate false confidence β€” for the vast
majority of users, the policy would degrade to "no attribution" anyway without rejecting impostor
tokens.

**Workarounds if you need strict attribution per user:**

1. **Activate the experimental flag** in `config/admin.ts`:
   ```ts
   features: {
     future: { adminTokens: true }
   }
   ```
   Then create tokens via the `/admin/admin-tokens` REST endpoint (not the UI). Those tokens have `adminUserOwner` populated and the plugin attributes correctly.

2. **Enforce token naming convention via process**: have a policy where developers MUST include
   their own email in tokens they create. Audit token names periodically against admin user roster.
   This is process-based, not technical.

3. **Wait for Strapi to stabilize the feature**: when `adminTokens` graduates from "future" to a
   stable API, the plugin can rely on it for all installations.

### Other known limitations

- Rate limit is in-memory per instance. Multi-instance setups behind a load balancer don't share
  counters. Use a CDN/proxy rate limit or Redis backend (planned for future release).
- Schema authoring requires Strapi restart to take effect. The plugin tells the LLM to wait via
  `restart_info`, but cannot eliminate the restart itself (Strapi loads schemas in boot).
- GraphQL tools depend on `@strapi/plugin-graphql` being installed and `GRAPHQL_ENABLED=true`. If
  absent, tools are not exposed.

## Roadmap

- [x] npm publish: [strapi-plugin-mcp-suite](https://www.npmjs.com/package/strapi-plugin-mcp-suite)
- [x] Forensic audit trail (token lifecycle + operations) β€” v0.4.0
- [x] Deep populate on reads + progressive schema strategies on writes β€” v0.5.0
- [ ] Strapi marketplace approval
- [ ] Redis backend for rate limiting (multi-instance support)
- [ ] Admin UI panel for browsing the audit log
- [ ] Token rotation hooks
- [ ] More i18n-specific tools (`clone_entry_to_locale`, `list_locales`)
- [ ] `delete_content_type` with multi-step confirmation
- [ ] Re-implement anti-impersonation when Strapi stabilizes `adminTokens` future flag

---

## Contributing

See [CONTRIBUTING.md](./CONTRIBUTING.md).

---

## License

[MIT](./LICENSE) β€” Amilcar Coronado, 2026.