content-studio
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@content-studioRead my ideas and turn the first one into a community post."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
content-studio (nom provisoire)
Studio de contenu open source (AGPL-3.0). Une idée entre, elle ressort déclinée par canal, persona et direction artistique — écrite par TON agent IA (Claude Code ou autre) connecté en MCP. L'outil n'appelle jamais de modèle : ton abonnement IA travaille, l'outil orchestre.
Self-host
git clone <repo> && cd content-studio
BETTER_AUTH_SECRET=$(openssl rand -hex 32) docker compose up -d --build
# http://localhost:3003 → créer un compte (workspace + 3 canaux créés automatiquement)Related MCP server: creatorforge
Interface
/ est un shell un-écran : sidebar de buckets à gauche, vue liste ou board au
centre, volet détail à droite (ou tiroir par-dessus, selon le contexte).
Cliquer un item change le volet, jamais la page — aucune navigation ne
recharge le site.
Sidebar — 4 buckets avec compteur vivant (À traiter, En rédaction, Publiés, Écartés) puis les réglages (Jauges, Tokens MCP, Lanes).
< lgelle se replie en une barre compacte (logo + bouton menu) qui l'ouvre en overlay par-dessus le contenu (Échap ou clic hors sidebar referme).Vue liste (défaut) — une ligne par item du bucket courant, plus « Nouvelle idée » en tête. Sur desktop (
≥ lg) le volet détail reste inline à droite ; en dessous, la liste prend toute la largeur et le même volet s'ouvre en tiroir plein écran. Clavier :j/kdéplacent la sélection dans la liste (ignorés pendant une saisie dans un champ, ou tiroir ouvert).Vue board — switch en tête de colonne, 5 colonnes par étape (Proposé, Rédaction, Relecture, Prêt, Publié — Écarté n'a pas de colonne, il reste accessible via le bucket du même nom en vue liste), scroll horizontal sur petit écran. Cliquer une carte ouvre le même volet détail (fiche idée ou fiche contenu) dans un tiroir par-dessus le board.
Deep-linking — tout l'état visible vit dans l'URL de
/:?view=list|board,?bucket=todo|writing|published|discarded,?item=idea:<uuid>ou?item=content:<uuid>. Recharger ou partager l'URL reproduit l'écran exact ; une valeur absente ou inconnue retombe sur le défaut (vue liste, bucket « À traiter », pas d'item ouvert).Anciennes routes —
/ideas/[id]et/contents/[id]redirigent vers l'équivalent/?item=idea:<id>//?item=content:<id>: les liens déjà envoyés par le worker (« Brouillon prêt → ouvrir ») ou mis en favori continuent de marcher.
Sécurité / déploiement
Par défaut, l'app n'écoute QUE sur 127.0.0.1 — npm run dev et npm run start
(exécution bare, hors Docker) bindent explicitement le loopback. Un déploiement via
docker compose reste identique à l'usage (le conteneur écoute en interne sur toutes ses
interfaces, requis par le NAT de publication de port de Docker) mais le port publié côté
hôte, docker-compose.yml, est lui aussi restreint à 127.0.0.1:3003:3003 — même schéma que
postgres juste au-dessus dans le même fichier. Dans les deux cas, l'app est injoignable
depuis le réseau/internet tant qu'aucune étape supplémentaire n'a été prise.
Exposer l'instance au-delà de ta propre machine = deux étapes, en conscience :
Un reverse-proxy (nginx, Caddy, Traefik…) devant
127.0.0.1:3003, avec TLS. Ne jamais remapper le port Docker sur0.0.0.0directement.DISABLE_SIGNUP=1(outrue) dans l'environnement, une fois ton compte owner créé. Signup reste ouvert par défaut (pratique en dev local mono-utilisateur) : combiné à un port joignable, un signup ouvert permet à n'importe qui de créer un workspace puis de configurerlaneCommand(Réglages → Lanes) — voir l'avertissement ci-dessous, c'est l'exécution de commande à distance. Le tout premier compte créé sur une base encore vide reste toujours autorisé, mêmeDISABLE_SIGNUP=1posé avant le premier démarrage (bootstrap : impossible sinon de créer le compte owner lui-même).
Rappel qui vaut pour toute la section Lanes ci-dessous : les lanes exécutent des commandes
sur l'hôte qui fait tourner le serveur. Sur cette version, self-host, l'accès à l'instance
(un compte + un token, ou une session) EST un accès shell à cette machine, au travers de
laneCommand. Ce n'est pas un bug à corriger plus tard, c'est l'architecture (cf. section
Lanes, « Self-host uniquement ») — raison de plus pour ne jamais exposer une instance en
signup ouvert.
Dev
docker compose up -d postgres
cp .env.example .env.local # éditer BETTER_AUTH_SECRET
npm i && npm run db:migrate && npm run devBrancher ton agent (MCP)
UI → Tokens MCP → Créer (le token n'est montré qu'une fois)
claude mcp add --transport http content-studio http://localhost:3003/api/mcp --header "Authorization: Bearer cs_…"Dans ta session : « lis mes idées et décline la première en post communauté »
Outils exposés (26) :
Idées & contenu : list_ideas, create_idea, get_idea, list_channels, list_personas, get_art_direction, create_content_draft, get_content, update_content.
Sources : list_sources, get_source, add_source, attach_extraction, register_asset (réservé v1.1 — v1 est texte uniquement).
Relecture : list_comments, resolve_comment.
Jobs (worker externe, détail juste en dessous) : list_jobs, claim_job, heartbeat_job, complete_job, fail_job, set_content_status, update_idea.
Publications : list_publications, link_publication, mark_synced.
Pendant que tu édites dans l'UI, une écriture de l'agent devient une « proposition » avec diff à accepter — jamais d'écrasement silencieux.
Un worker externe : jobs, publications, relecture
Trois briques qui font de l'outil le poste de pilotage d'un worker externe, connecté en MCP — l'outil n'appelle jamais de modèle et ne publie jamais lui-même.
Le modèle
L'UI pose des jobs (bouton « Rédiger », « Publier », « Appliquer les commentaires »,
ou le hook de re-synchronisation). Un worker branché en MCP tourne en boucle :
list_jobs({status: "queued"}) → claim_job (atomique : un seul worker gagne) → il
travaille → complete_job ou fail_job. Sur un travail long, heartbeat_job toutes les
60 secondes — sans battement pendant 10 minutes, le job repasse automatiquement failed
(« agent silencieux »), ça libère la cible pour un nouveau job.
Un workspace sans worker branché voit simplement ses jobs rester « en attente d'un agent ». Rien ne bloque, rien ne timeout côté UI — l'humain garde la main pour relancer depuis les boutons.
Pas de réessai automatique. Un job failed reste failed : c'est le bouton
« Réessayer » de l'UI (POST /api/jobs/:id/retry) qui le repasse queued, jamais un
retry silencieux côté serveur.
Les kinds intégrés
kind | à la création (UI) | à la complétion |
| idée → | worker pose |
| contenu → | worker pose |
| jamais par un bouton direct — créé par le hook de re-sync, ou par « Re-synchroniser » sur la carte publication | worker pose |
| rien | worker |
| jamais par un bouton — créé par la route de dictée | seule complétion qui écrit ailleurs que dans le job : |
Tout autre kind est libre : l'outil l'accepte, le range dans la file, et laisse le
worker et l'UI convenir de son sens (pastille générique, pas d'effet automatique).
Publications
Après une publication réussie, le worker appelle link_publication(content_id, target, external_id, url?, meta?, body_hash) — upsert sur (contenu, cible). La page contenu
affiche alors une carte « Publication » avec lien vers l'externe et statut de fraîcheur.
Hook « publié puis modifié » : dès qu'une révision devient la version courante d'un
contenu déjà publié, si son hash diffère du corps publié, un job sync est posé
automatiquement (coalescé — un seul sync en attente par publication, via
dedupe_key dans le payload). Le worker republie le corps courant puis appelle
mark_synced(publication_id, body_hash), qui efface last_error.
Relecture
Onglet Relire sur la page contenu : l'humain surligne un passage, écrit une remarque
ou la dicte. Le worker lit list_comments({status: "open"}) — chaque entrée porte
quote/prefix/suffix (ancrage), body, et position déjà recalculée sur le markdown
courant — réécrit uniquement les passages visés, puis resolve_comment(comment_id, {status: "applied"}).
Divergence d'ancrage à connaître : quote/prefix/suffix proviennent du texte
rendu (sans marqueurs markdown), alors que position est recalculée sur le
markdown source — elle peut être null (passage formaté, ou à cheval sur deux blocs)
ou une première occurrence (level: 3). Toujours vérifier position.level (1 = exact,
2 = normalisé, 3 = quote seule) avant de réécrire à l'offset ; en cas de doute, retrouver
le passage par la quote plutôt que par start/end.
Une remarque dictée part en POST /api/contents/:id/comments/audio, qui crée le
commentaire et un job transcribe sur cette cible. Le worker récupère l'audio par
la seule route REST binaire ouverte au token MCP :
GET /api/jobs/:id/audio
Authorization: Bearer cs_…Elle rend l'audio brut (content-type = mime d'origine) si et seulement si le job, dans le
workspace du token, est un transcribe ciblant un commentaire — 404 sinon (job d'un autre
workspace, mauvais kind, audio déjà purgé après transcription). Le worker transcrit, puis
complete_job(job_id, {result: {text: "..."}}) — l'outil bascule lui-même le commentaire
en body = text, transcription: "done".
Squelette de worker
// worker.mjs — sonde le studio toutes les 30 s, un job à la fois (client MCP = @modelcontextprotocol/sdk)
const tool = async (name, args) => JSON.parse((await client.callTool({ name, arguments: args })).content[0].text);
while (true) {
const [job] = await tool("list_jobs", { status: "queued" });
if (!job) { await sleep(30_000); continue; }
const claimed = await tool("claim_job", { job_id: job.id, worker_label: "mon-worker" });
if (claimed.error) continue; // un autre worker l'a pris
const hb = setInterval(() => tool("heartbeat_job", { job_id: job.id }), 60_000);
try {
switch (job.kind) {
case "write": /* enquête + rédaction → create_content_draft, update_content, set_content_status(review) */ break;
case "publish": /* POST vers ta cible → link_publication, set_content_status(published), update_idea(done) */ break;
case "sync": /* re-publie le corps courant → mark_synced */ break;
case "revise": /* list_comments(open) → réécrit → update_content → resolve_comment(applied) */ break;
case "transcribe": /* GET /api/jobs/:id/audio (Bearer) → whisper → complete_job({ text }) */ break;
default: throw new Error(`kind inconnu : ${job.kind}`);
}
await tool("complete_job", { job_id: job.id, result: { /* … */ } });
} catch (e) {
await tool("fail_job", { job_id: job.id, error: String(e.message).slice(0, 2000) });
} finally { clearInterval(hb); }
}Sécurité et bornes
Aucune exécution côté serveur ici (contrairement aux Lanes, ci-dessous) : jobs,
publications et commentaires ne sont que des lignes en base, un worker externe fait tout
le travail. GET /api/jobs/:id/audio est la seule route REST binaire ouverte au token
MCP — tout le reste passe par les outils MCP JSON ci-dessus, bornés au workspace du token.
Bornes fixes, mêmes pour l'UI et pour MCP : message d'erreur de job 2000 caractères, payload et result de job 64 Ko chacun, audio de commentaire 16 Mo, citation (quote) 2000 caractères, corps de commentaire 10 000 caractères.
Extension Chrome (clipper)
Une page ou une sélection de texte → une idée + une source dans l'inbox, en un clic. MV3, vanilla JS, zéro build.
Installation (mode développeur)
chrome://extensions→ activer Mode développeur (en haut à droite)Charger l'extension non empaquetée → sélectionner le dossier
extension/de ce repoÉpingler l'icône (facultatif) pour l'avoir dans la barre d'outils
Configuration
Cliquer sur l'icône de l'extension → popup
Renseigner l'URL de l'instance (ex.
http://localhost:3003) et le token MCP (cs_…, généré via UI → Réglages → Tokens MCP)Enregistrer — une permission d'accès à cette origine est demandée à ce moment précis (
chrome.permissions.request, cf. « Choix technique » ci-dessous). Accepter la demande.
Usage
Bouton popup « Clipper cette page » : envoie l'URL + le titre de l'onglet actif, et le texte sélectionné s'il y en a un (lu via
chrome.scripting.executeScript)Menu contextuel « Clipper la sélection » : sélectionner du texte sur n'importe quelle page → clic droit → « Clipper la sélection ». Un badge ✓ (succès) ou ✗ (échec) s'affiche 3s sur l'icône
Le popup affiche l'état : succès avec lien direct vers l'idée (
/ideas/<id>), ou erreur lisible (« Token invalide », « Instance injoignable », etc.)
Choix technique : permissions minimales, pas de host_permissions large
Le manifest ne déclare que activeTab, scripting, storage, contextMenus — jamais un
host_permissions couvrant tout le web. Le fetch cross-origin vers l'instance configurée
(depuis le popup et le service worker) est rendu possible par optional_host_permissions
(http://*/*, https://*/*) demandée à la volée uniquement au moment où l'utilisateur
enregistre l'URL de SON instance — jamais au chargement de l'extension.
Filet de secours côté serveur : si la permission est refusée (ou pour tout appel qui déclenche
quand même un preflight), src/app/api/clip/route.ts répond aux requêtes OPTIONS et pose
Access-Control-Allow-* sur ses réponses, mais seulement quand l'Origin commence par
chrome-extension:// — jamais un Access-Control-Allow-Origin: *, le reste de l'app reste
same-origin only.
Limite connue de la vérification automatisée
Chrome a retiré le flag --load-extension des builds Chrome (branded) sur canal stable — un
unpacked ne peut plus être chargé en ligne de commande pour un test scripté classique. La
commande CDP Extensions.loadUnpacked existe mais charge l'extension dans un contexte isolé
de l'automatisation, invisible depuis une fenêtre normale (chrome-extension://<id>/... répond
net::ERR_BLOCKED_BY_CLIENT hors de ce contexte) — piloter le vrai popup via CDP n'est donc pas
possible dans cet environnement. La vérification réelle effectuée : le POST exact de clip.js
(mêmes headers/body) rejoué en Node contre le serveur de dev réel avec un token réel a créé une
idée + source vérifiées en base ; l'installation manuelle (chrome://extensions → mode
développeur → charger l'extension non empaquetée) reste à faire une fois par l'utilisateur pour
valider le popup et le menu contextuel eux-mêmes.
Jauges (abonnements IA multi-comptes)
Le header affiche l'état des abonnements IA que TES agents consomment. L'outil n'appelle
jamais de modèle et ne connaît aucun provider : il interroge des endpoints que tu
configures toi-même (Réglages → Jauges), rien d'autre.
Contrat de payload
Chaque source répond en JSON, sur ce contrat (documenté ici, volontairement minimal) :
{
"accounts": [
{ "id": "compte-1", "usedPercent": 87, "resetAt": "2026-08-10T00:00:00Z", "available": true },
{ "id": "compte-2", "usedPercent": 12, "resetAt": "2026-08-12T00:00:00Z", "available": true }
],
"costMonthlyEur": 378
}accounts[](optionnel, 50 entrées max) — unid(obligatoire),usedPercent(0–100),resetAt(chaîne parseable en date, ex. ISO 8601 — un format non parseable n'affiche simplement pas de date, jamais d'erreur) etavailable(booléen), tous optionnels saufid.costMonthlyEur(optionnel, nombre) : coût mensuel de CETTE source.
Multi-comptes natif : chaque entrée de accounts[] devient un segment de jauge séparé —
un /health de bridge local qui expose plusieurs comptes en pool (ex. un pool round-robin de
clés) s'affiche tel quel, un segment par compte. Le chiffre affiché à côté de la barre est le
compte disponible le plus consommé (celui qui bloque en premier) ; un compte available: false s'affiche hachuré et sort du calcul.
La tuile Coût / mois du header additionne les costMonthlyEur de toutes les sources
kind: "cost" activées.
Un payload qui ne respecte pas ce contrat (mauvais type, usedPercent hors [0,100], plus de
50 comptes, champ requis absent…) est un échec de parsing : la source passe grise
(« injoignable », erreur visible au survol), jamais une erreur bloquante pour le reste du
cockpit. Champs inconnus dans le payload : ignorés, pas rejetés.
kind
quota: source de comptes/segments de jauge.cost: source de coût mensuel (n'affiche pas de segments, seulementcostMonthlyEur).
Polling
Le serveur interroge chaque source activée à l'affichage de la page, avec un cache de 5 minutes (timeout réseau : 4 s) — pas de cron, pas de polling en tâche de fond. Le header rafraîchit lui-même toutes les 5 minutes, plus un bouton manuel (↻).
localhost autorisé — la vraie protection SaaS est l'egress réseau
En self-host, les bridges locaux de l'utilisateur SONT le cas d'usage : localhost,
127.0.0.1 et [::1] sont explicitement autorisés comme cible de jauge. Le reste de
127.0.0.0/8, 10.*, 192.168.*, 169.254.*, 172.16.0.0/12 (172.16.* à 172.31.*),
100.64.0.0/10 (100.64.* à 100.127.*, plage CGNAT) et 0.0.0.0 écrits en clair dans l'URL
sont refusés à la création — mais cette liste ne couvre QUE les adresses IP littérales : elle
n'empêche pas un nom d'hôte qui résout vers une IP privée (DNS rebinding). Ce n'est pas la
protection réelle d'un déploiement SaaS multi-tenant — celle-là est le firewall d'egress
réseau du déploiement, pas cette validation applicative côté app. Aucune redirection (3xx)
n'est suivie non plus : un endpoint approuvé à la création qui répondrait plus tard par un 302
vers une cible interne, header custom embarqué, ne fait rien passer.
Lanes (conversations agent — self-host uniquement)
Un onglet de conversation avec TON CLI agent local, directement dans l'outil, à côté du
contenu (bouton « 💬 Chat » sur une page contenu, ou l'icône du header). @ dans l'input
référence une idée ou un contenu existant : la sélection insère son contexte (titre + corps,
tronqué à 2000 caractères) dans le message envoyé au CLI. Chaque révision écrite par l'agent
PENDANT qu'une lane est active porte lane:<id> — le panneau révisions du contenu affiche
alors « ouvrir la conversation » dessus.
Self-host uniquement
Contrainte architecturale, pas un choix arbitraire. En self-host, le serveur tourne sur TA
machine : il peut donc spawn ton CLI local (claude -p, codex exec…) — c'est ton abonnement
qui travaille, zéro inférence faite par l'outil lui-même. En SaaS, le serveur ne peut PAS faire
ça (spawn un process sur une machine qu'il ne possède pas) : les lanes y exigeraient un
compagnon local, hors périmètre de cette version.
laneCommand — configurable, jamais en dur
Réglages → Lanes (/settings/workspace) expose un seul champ, laneCommand, par défaut
claude -p --output-format stream-json --verbose. Chaque tour de conversation spawn CETTE
commande, avec le message utilisateur en dernier argument positionnel (précédé de --resume <session_id> dès la 2e conversation, si le CLI le supporte).
Avertissement : cette commande tourne sur TA machine. Le serveur ne fait que
l'orchestrer — il ne l'inspecte pas, ne la sandbox pas. Configurer laneCommand avec une
commande qu'on n'a pas écrite ou vérifiée soi-même revient à exécuter un process arbitraire
côté serveur, avec les droits du process serveur.
Le séparateur -- : un message n'est jamais un flag
Le message est toujours transmis après un -- littéral ([..., "--", message], ou
["--resume", id, "--", message] en reprise) — sans lui, un message commençant par - (ex.
--dangerously-skip-permissions) serait lu comme une OPTION par le parseur d'arguments du CLI
cible (commander.js et consorts), pas comme du texte. Le spawn passe aussi par sh -c '<laneCommand> "$@"' sh ...args : la chaîne interprétée par le shell est FIGÉE (la commande
des réglages) + "$@" littéral — le message arrive en argv séparé, jamais concaténé dans du
texte exécuté par le shell, donc incapable d'en ouvrir une nouvelle commande.
Timeout et cap
Timeout dur : 120 secondes. Au-delà, l'arbre de process entier est tué (SIGTERM puis SIGKILL après 5 s de grâce si le premier signal est ignoré), la lane passe en statut
error.Cap anti-DoS : 2 MiB, sur stdout ET stderr indépendamment. Un CLI qui inonde l'un ou l'autre flux est coupé — la LECTURE s'arrête au cap, pas seulement un kill demandé : même un process qui ignore SIGTERM pendant toute la fenêtre de grâce ne fait pas grossir le buffer au-delà.
Le verrou (une seule exécution à la fois par lane, 409 sur un 2e message concurrent) est TOUJOURS relâché en sortie, quel que soit le chemin (succès, erreur CLI, timeout, cap dépassé).
Tests
npm run test # vitest single-run, db content_studio_testLimites connues (v1)
Le Dockerfile self-host copie
.next+node_modulesen entier (pas deoutput: "standalone"dansnext.config.ts) : image plus grosse que nécessaire, assumé pour rester simple en v1.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- AlicenseNot gradedqualityBmaintenanceOpen-source MCP server that turns any CMS backend into an AI-agent-ready content management system. It provides tools for content CRUD, idea management, and publication tracking, with safety features requiring human approval for publishing.MIT
- FlicenseNot gradedqualityAmaintenanceMCP server for AI content generation, enabling agents to learn a voice, generate content ideas, write scripts, and produce multi-platform content deterministically.
- AlicenseAqualityBmaintenanceA content studio MCP server for coding agents that generates media, renders videos and carousels deterministically, and publishes with a draft-first outbox.14594MIT
- AlicenseAqualityBmaintenanceMCP server to manage social media accounts from AI assistants, enabling post creation, scheduling, publishing, and media uploads across multiple platforms.131661MIT
Related MCP Connectors
MCP server for QPost — lets AI agents publish video and image posts to YouTube, TikTok, Instagram.
MCP server for AI agents to plan, verify, and deploy Cloudflare-native apps.
MCP server for generating rough-draft project plans from natural-language prompts.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/MattiooFR/content-studio'
If you have feedback or need assistance with the MCP directory API, please join our Discord server