- {composeReady
- ? form.provider === "template"
- ? "Ready to test the workflow. Local sample mode is deterministic and intentionally limited; choose a model provider for production-quality content."
- : "SignalFlow has enough context to build editable drafts. You remain in control of every output and publishing step."
- : "Add a brief, public link, repository, or extractable text file. Keep the first run simple; advanced model controls can stay closed."}
+ {!sourceAndChannelsReady
+ ? "Add product evidence and select at least one destination."
+ : providerReadiness.ready
+ ? "SignalFlow has enough context and a real model route to build editable drafts."
+ : providerReadiness.reason}
{sourceSignals}source signals
diff --git a/frontend/app/studio-product.css b/frontend/app/studio-product.css
new file mode 100644
index 0000000..ba191ae
--- /dev/null
+++ b/frontend/app/studio-product.css
@@ -0,0 +1,331 @@
+/* Final product layout for the three-step Studio workflow. Loaded last. */
+
+.app-shell .studio-page[data-stage="source"],
+.app-shell .studio-page[data-stage="destinations"] {
+ width: min(88rem, calc(100% - clamp(2rem, 7vw, 7rem)));
+}
+
+.app-shell .studio-page[data-stage="source"] .composer-panel {
+ display: grid;
+ grid-template-columns: minmax(0, 1.18fr) minmax(21rem, 0.82fr);
+ column-gap: clamp(2rem, 4vw, 4.25rem);
+ row-gap: 0;
+ align-items: start;
+}
+
+.app-shell .studio-page[data-stage="source"] .composer-panel > .panel-kicker {
+ grid-column: 1 / -1;
+}
+
+.app-shell .studio-page[data-stage="source"] .composer-panel > .field:not(.field--large) {
+ grid-column: 1;
+}
+
+.app-shell .studio-page[data-stage="source"] .composer-panel > .field--large {
+ grid-column: 1;
+}
+
+.app-shell .studio-page[data-stage="source"] .field--large textarea {
+ min-height: clamp(18rem, 34vh, 25rem);
+}
+
+.app-shell .studio-page[data-stage="source"] .source-grid {
+ grid-column: 2;
+ grid-row: 2 / span 2;
+ grid-template-columns: minmax(0, 1fr);
+ align-content: start;
+ gap: 0;
+ padding: 1.25rem;
+ border: 0.0625rem solid var(--app-line);
+ border-radius: 0.75rem;
+ background: var(--app-surface-subtle);
+}
+
+.app-shell .studio-page[data-stage="source"] .source-grid::before {
+ content: "Supporting evidence";
+ margin-bottom: 1rem;
+ color: var(--app-accent);
+ font-size: 0.6875rem;
+ font-weight: 750;
+ letter-spacing: 0.11em;
+ text-transform: uppercase;
+}
+
+.app-shell .studio-page[data-stage="source"] .source-grid .field:last-child {
+ margin-bottom: 0;
+}
+
+.app-shell .studio-page[data-stage="source"] .upload-zone,
+.app-shell .studio-page[data-stage="source"] .file-list {
+ grid-column: 2;
+}
+
+.app-shell .studio-page[data-stage="source"] .upload-zone {
+ min-height: 7.5rem;
+ margin-top: 1rem;
+ padding: 1.25rem;
+ border-radius: 0.75rem;
+ background: var(--app-surface-subtle);
+}
+
+.app-shell .studio-page[data-stage="source"] .upload-zone__icon {
+ background: var(--app-accent-soft);
+ color: var(--app-accent);
+}
+
+.app-shell .studio-page[data-stage="destinations"] .output-panel {
+ display: grid;
+ grid-template-columns: minmax(0, 1.45fr) minmax(21rem, 0.72fr);
+ gap: 1.75rem 2.25rem;
+ align-items: start;
+}
+
+.app-shell .studio-page[data-stage="destinations"] .output-panel > .panel-kicker {
+ grid-column: 1 / -1;
+ margin-bottom: 0.25rem;
+}
+
+.app-shell .studio-page[data-stage="destinations"] .channel-groups {
+ grid-column: 1;
+ grid-row: 2 / span 2;
+ min-width: 0;
+}
+
+.app-shell .studio-page[data-stage="destinations"] .channel-group {
+ padding: 1.15rem 0;
+ border-top: 0.0625rem solid var(--app-line);
+}
+
+.app-shell .studio-page[data-stage="destinations"] .channel-group:first-child {
+ padding-top: 0;
+ border-top: 0;
+}
+
+.app-shell .studio-page[data-stage="destinations"] .channel-picker {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.app-shell .model-route-panel {
+ grid-column: 2;
+ grid-row: 2;
+ position: sticky;
+ top: calc(var(--app-header) + 1.25rem);
+ min-width: 0;
+ padding: 1.25rem;
+ border: 0.0625rem solid var(--app-line);
+ border-radius: 0.75rem;
+ background: var(--app-surface-subtle);
+}
+
+.app-shell .model-route-panel__header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+
+.app-shell .model-route-panel__eyebrow {
+ color: var(--app-accent);
+ font-size: 0.6875rem;
+ font-weight: 750;
+ letter-spacing: 0.11em;
+ text-transform: uppercase;
+}
+
+.app-shell .model-route-panel h3 {
+ margin: 0.35rem 0 0;
+ font-family: "Playfair Display", serif;
+ font-size: 1.4rem;
+ font-weight: 500;
+}
+
+.app-shell .model-route-status {
+ flex: 0 0 auto;
+ padding: 0.35rem 0.55rem;
+ border-radius: 999px;
+ background: var(--app-warning-soft);
+ color: var(--app-warning);
+ font-size: 0.625rem;
+ font-weight: 750;
+}
+
+.app-shell .model-route-status.is-ready {
+ background: var(--app-success-soft);
+ color: var(--app-success);
+}
+
+.app-shell .model-provider-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.5rem;
+ margin-bottom: 1.1rem;
+}
+
+.app-shell .model-provider-option {
+ min-height: 3.25rem;
+ padding: 0.65rem 0.75rem;
+ border: 0.0625rem solid var(--app-line);
+ border-radius: 0.55rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ text-align: left;
+ background: var(--app-surface);
+ color: var(--app-muted);
+ font-size: 0.75rem;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.app-shell .model-provider-option:hover {
+ border-color: var(--app-line-strong);
+ color: var(--app-ink);
+}
+
+.app-shell .model-provider-option.is-selected {
+ border-color: var(--app-accent);
+ background: var(--app-accent-soft);
+ color: var(--app-ink);
+}
+
+.app-shell .model-provider-option small {
+ width: 0.45rem;
+ height: 0.45rem;
+ border-radius: 50%;
+ background: var(--app-line-strong);
+}
+
+.app-shell .model-provider-option small.is-configured {
+ background: var(--app-success);
+}
+
+.app-shell .model-route-fields {
+ padding-top: 1rem;
+ border-top: 0.0625rem solid var(--app-line);
+}
+
+.app-shell .model-route-fields .field {
+ margin-bottom: 1rem;
+}
+
+.app-shell .model-route-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin-top: 0.25rem;
+}
+
+.app-shell .model-route-actions .button {
+ min-height: 2.5rem;
+ padding-inline: 1rem;
+ font-size: 0.75rem;
+}
+
+.app-shell .model-route-message {
+ margin: 0.8rem 0 0;
+ color: var(--app-muted);
+ font-size: 0.75rem;
+ line-height: 1.55;
+}
+
+.app-shell .model-route-message.is-error {
+ color: var(--app-danger);
+}
+
+.app-shell .model-route-note {
+ margin: 1rem 0 0;
+ padding-top: 1rem;
+ border-top: 0.0625rem solid var(--app-line);
+ color: var(--app-faint);
+ font-size: 0.6875rem;
+ line-height: 1.55;
+}
+
+.app-shell .studio-page[data-stage="destinations"] .output-empty {
+ grid-column: 2;
+ grid-row: 3;
+ min-height: 0;
+ padding: 0;
+ align-items: stretch;
+ justify-content: flex-start;
+ text-align: left;
+}
+
+.app-shell .compose-readiness {
+ width: 100%;
+ padding: 1.1rem;
+ border: 0.0625rem solid var(--app-line);
+ border-radius: 0.75rem;
+ background: var(--app-surface);
+}
+
+.app-shell .studio-actionbar {
+ max-width: none;
+ margin-top: 0;
+ padding: 0.9rem 1.1rem;
+ border: 0.0625rem solid var(--app-line);
+ border-top: 0;
+ border-radius: 0 0 var(--app-radius) var(--app-radius);
+ background: var(--app-surface);
+ color: var(--app-ink);
+ box-shadow: 0 0.75rem 2rem rgba(24, 24, 20, 0.035);
+}
+
+.app-shell .studio-actionbar__summary {
+ color: var(--app-muted);
+}
+
+.app-shell .studio-actionbar .button--outline {
+ background: var(--app-surface);
+}
+
+@media (max-width: 68rem) {
+ .app-shell .studio-page[data-stage="source"],
+ .app-shell .studio-page[data-stage="destinations"] {
+ width: min(100% - 2rem, 76rem);
+ }
+
+ .app-shell .studio-page[data-stage="source"] .composer-panel,
+ .app-shell .studio-page[data-stage="destinations"] .output-panel {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .app-shell .studio-page[data-stage="source"] .composer-panel > *,
+ .app-shell .studio-page[data-stage="destinations"] .output-panel > * {
+ grid-column: 1;
+ grid-row: auto;
+ }
+
+ .app-shell .studio-page[data-stage="source"] .source-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 1rem;
+ }
+
+ .app-shell .studio-page[data-stage="source"] .source-grid::before {
+ grid-column: 1 / -1;
+ }
+
+ .app-shell .model-route-panel {
+ position: static;
+ }
+}
+
+@media (max-width: 44rem) {
+ .app-shell .studio-page[data-stage="source"] .source-grid,
+ .app-shell .studio-page[data-stage="destinations"] .channel-picker,
+ .app-shell .model-provider-grid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .app-shell .studio-actionbar {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .app-shell .studio-actionbar__summary {
+ display: flex;
+ flex-wrap: wrap;
+ }
+}
diff --git a/frontend/lib/ai/generateStudioPackage.js b/frontend/lib/ai/generateStudioPackage.js
index 95ecd8a..8d880f4 100644
--- a/frontend/lib/ai/generateStudioPackage.js
+++ b/frontend/lib/ai/generateStudioPackage.js
@@ -5,6 +5,7 @@ import { normalizePackage } from "../package/normalizePackage";
import { generateJSON } from "./generateJSON";
import { buildMarkdown } from "../export/markdown";
import { PROVIDERS } from "./types";
+import { assertModelGenerationProvider } from "./generationPolicy.mjs";
import {
CHANNEL_CONTRACTS,
assessChannelDraft,
@@ -110,7 +111,7 @@ function packageDraft(pkg, channel) {
function normalizeDestinationDraft(rawDraft, channel, generationInputs) {
const packageKey = packageKeyForChannel(channel);
- const normalized = normalizePackage({ posts: { [packageKey]: rawDraft } }, generationInputs);
+ const normalized = normalizePackage({ posts: { [packageKey]: rawDraft } }, generationInputs, { allowTemplateFallback: false });
return normalized.posts[packageKey];
}
@@ -123,6 +124,39 @@ function statusForTemplate(channels) {
}]));
}
+function emptyDestinationDraft(channel) {
+ switch (channel) {
+ case "linkedin": return { title: "", body: "", hashtags: [], cta: "" };
+ case "x": return { mode: "post_or_thread", posts: [] };
+ case "instagram": return { caption: "", hashtags: [], visualDirection: "" };
+ case "reddit": return { title: "", body: "", subredditSuggestions: [] };
+ case "youtube": return { title: "", description: "", tags: [] };
+ case "tiktok": return { caption: "", hook: "", shotList: [] };
+ case "hackernews": return { title: "", body: "" };
+ case "newsletter": return { subject: "", preview: "", body: "" };
+ case "blog": return { title: "", outline: [], draft: "" };
+ case "release_notes": return { title: "", sections: [] };
+ default: return { body: "" };
+ }
+}
+
+function emptyPackagePosts() {
+ return {
+ linkedin: emptyDestinationDraft("linkedin"),
+ x: emptyDestinationDraft("x"),
+ instagram: emptyDestinationDraft("instagram"),
+ reddit: emptyDestinationDraft("reddit"),
+ facebook: emptyDestinationDraft("facebook"),
+ threads: emptyDestinationDraft("threads"),
+ youtube: emptyDestinationDraft("youtube"),
+ tiktok: emptyDestinationDraft("tiktok"),
+ hackernews: emptyDestinationDraft("hackernews"),
+ newsletter: emptyDestinationDraft("newsletter"),
+ blog: emptyDestinationDraft("blog"),
+ releaseNotes: emptyDestinationDraft("release_notes"),
+ };
+}
+
async function generateDestination({
channel,
context,
@@ -193,13 +227,12 @@ async function generateDestination({
},
};
} catch (error) {
- const fallback = normalizePackage({}, generationInputs).posts[packageKey];
return {
channel,
packageKey,
- draft: fallback,
+ draft: emptyDestinationDraft(channel),
status: {
- status: "template_fallback",
+ status: "failed",
attempts: firstDraft ? 1 : 0,
qualityScore: firstQuality?.score ?? null,
issues: [`Destination generation failed: ${error.message}`],
@@ -229,12 +262,14 @@ export async function generateStudioPackage(inputs) {
mediaItems = [],
selectedChannels = [],
selectedOutputs = [],
- generator = "template",
+ generator: requestedGenerator = "",
model_name = "",
appUrl = "",
config = {},
} = inputs;
+ const generator = assertModelGenerationProvider(requestedGenerator);
+
const generationInputs = {
projectName,
notes,
@@ -253,50 +288,9 @@ export async function generateStudioPackage(inputs) {
const contextWarnings = Array.isArray(context.warnings) ? context.warnings : [];
const campaignBriefPrompt = buildCampaignBriefPrompt(context);
- if (generator === "prompt") {
- const result = templateResult(
- generationInputs,
- "Prompt route selected. The prompt now separates product strategy from destination writing instead of requesting one compressed campaign object.",
- contextWarnings,
- );
- return {
- ...result,
- providerUsed: "prompt",
- fallbackUsed: true,
- generation_status: statusForTemplate(channels),
- chatbot_prompt: buildPromptBundle(context, result.package, channels),
- };
- }
-
- if (generator === "template" || generator === "offline") {
- const result = templateResult(
- generationInputs,
- "Local template route created deterministic fallback copy. Connect a capable model for staged destination-specific editorial generation.",
- contextWarnings,
- );
- return {
- ...result,
- providerUsed: "template",
- fallbackUsed: true,
- generation_status: statusForTemplate(channels),
- chatbot_prompt: buildPromptBundle(context, result.package, channels),
- };
- }
-
const providerMeta = PROVIDERS[generator];
if (!providerMeta) {
- const result = templateResult(
- generationInputs,
- `Unknown provider "${generator}". The local template route was used instead.`,
- contextWarnings,
- );
- return {
- ...result,
- providerUsed: "template",
- fallbackUsed: true,
- generation_status: statusForTemplate(channels),
- chatbot_prompt: buildPromptBundle(context, result.package, channels),
- };
+ throw new Error(`Unsupported model provider: ${generator}.`);
}
const temporaryKey = Boolean(config?.apiKey);
@@ -313,18 +307,7 @@ export async function generateStudioPackage(inputs) {
const requirement = generator === "custom"
? "an OpenAI-compatible base URL"
: (providerMeta.requiredEnv || []).join(" or ") || "provider credentials";
- const result = templateResult(
- generationInputs,
- `${providerMeta.label} is not configured. Add ${requirement} or a temporary personal key; the deterministic template route was used.`,
- contextWarnings,
- );
- return {
- ...result,
- providerUsed: generator,
- fallbackUsed: true,
- generation_status: statusForTemplate(channels),
- chatbot_prompt: buildPromptBundle(context, result.package, channels),
- };
+ throw new Error(`${providerMeta.label} is not configured. Add ${requirement} or a temporary personal key.`);
}
const modelOverride = model_name || config?.modelName || providerMeta.defaultModel;
@@ -337,8 +320,9 @@ export async function generateStudioPackage(inputs) {
config,
});
- const pkg = normalizePackage(rawBrief, generationInputs);
+ const pkg = normalizePackage(rawBrief, generationInputs, { allowTemplateFallback: false });
pkg.strategy.destinationAngles = rawBrief?.strategy?.destinationAngles || {};
+ pkg.posts = emptyPackagePosts();
const generatedDestinations = await Promise.all(channels.map((channel) => generateDestination({
channel,
@@ -353,15 +337,20 @@ export async function generateStudioPackage(inputs) {
const generationStatus = {};
const generationWarnings = [];
for (const result of generatedDestinations) {
- pkg.posts[result.packageKey] = result.draft;
+ if (result.status.status !== "failed") pkg.posts[result.packageKey] = result.draft;
generationStatus[result.channel] = result.status;
- if (result.status.status === "template_fallback") {
- generationWarnings.push(`${result.channel}: model generation failed, so deterministic fallback copy is shown.`);
+ if (result.status.status === "failed") {
+ generationWarnings.push(`${result.channel}: model generation failed and no substitute copy was inserted.`);
} else if (result.status.status === "needs_review") {
generationWarnings.push(`${result.channel}: the best draft still failed one or more editorial quality checks.`);
}
}
+ const failedDestinations = generatedDestinations.filter((item) => item.status.status === "failed");
+ if (failedDestinations.length === generatedDestinations.length) {
+ throw new Error(`Every selected destination failed: ${failedDestinations.map((item) => item.channel).join(", ")}.`);
+ }
+
pkg.generation = {
mode: "staged_agent",
provider: generator,
@@ -375,13 +364,13 @@ export async function generateStudioPackage(inputs) {
const description = pkg.project.description || notes || pkg.project.oneLine || "Review-ready campaign draft";
const fileSlug = slug(name);
const svgContent = buildCampaignCard(name, description);
- const fallbackUsed = generatedDestinations.some((item) => item.status.status === "template_fallback");
+ const partialFailureUsed = failedDestinations.length > 0;
return {
ok: true,
providerUsed: generator,
- fallbackUsed,
- partialFallbackUsed: fallbackUsed,
+ fallbackUsed: false,
+ partialFailureUsed,
generation_status: generationStatus,
chatbot_prompt: campaignBriefPrompt,
warnings: Array.from(new Set([...contextWarnings, ...generationWarnings])),
@@ -415,17 +404,7 @@ export async function generateStudioPackage(inputs) {
},
};
} catch (error) {
- const result = templateResult(
- generationInputs,
- `${providerMeta.label} campaign strategy generation failed: ${error.message}. Deterministic fallback copy was created instead.`,
- contextWarnings,
- );
- return {
- ...result,
- providerUsed: generator,
- fallbackUsed: true,
- generation_status: statusForTemplate(channels),
- chatbot_prompt: campaignBriefPrompt,
- };
+ throw new Error(`${providerMeta.label} campaign generation failed: ${error.message}`);
+
}
}
diff --git a/frontend/lib/ai/generationPolicy.mjs b/frontend/lib/ai/generationPolicy.mjs
new file mode 100644
index 0000000..58c0571
--- /dev/null
+++ b/frontend/lib/ai/generationPolicy.mjs
@@ -0,0 +1,37 @@
+export const MODEL_GENERATION_PROVIDERS = new Set([
+ "gemini",
+ "openai",
+ "claude",
+ "openrouter",
+ "groq",
+ "custom",
+ "ollama",
+ "lmstudio",
+]);
+
+export function normalizeGenerationProvider(value) {
+ return String(value || "").trim().toLowerCase();
+}
+
+export function assertModelGenerationProvider(value) {
+ const provider = normalizeGenerationProvider(value);
+
+ if (["", "template", "offline", "prompt"].includes(provider)) {
+ throw new Error(
+ "SignalFlow requires a real model provider. Local template and prompt-only generation are not available in the product workflow.",
+ );
+ }
+
+ if (!MODEL_GENERATION_PROVIDERS.has(provider)) {
+ throw new Error(`Unsupported model provider: ${provider || "missing provider"}.`);
+ }
+
+ return provider;
+}
+
+export function canUseServerProviderConfiguration({
+ publicHosted = process.env.SIGNALFLOW_PUBLIC_HOSTED === "true",
+ allowServerKey = false,
+} = {}) {
+ return !publicHosted || Boolean(allowServerKey);
+}
diff --git a/frontend/lib/ai/types.js b/frontend/lib/ai/types.js
index f0e7573..acba607 100644
--- a/frontend/lib/ai/types.js
+++ b/frontend/lib/ai/types.js
@@ -83,7 +83,9 @@ export const PROVIDERS = {
description: "Local model running via Ollama Desktop. Requires local server running.",
isLocal: true,
isFree: true,
- isConfigured: () => true, // Ollama doesn't require a key
+ isConfigured: () => Boolean(process.env.OLLAMA_BASE_URL) || (
+ !process.env.VERCEL && process.env.SIGNALFLOW_PUBLIC_HOSTED !== "true"
+ ),
defaultModel: process.env.DEFAULT_MODEL_NAME || "llama3",
requiredEnv: ["OLLAMA_BASE_URL"],
canTest: true,
@@ -95,7 +97,9 @@ export const PROVIDERS = {
description: "Local model running via LM Studio client. Requires local server running.",
isLocal: true,
isFree: true,
- isConfigured: () => true, // LM Studio doesn't require a key
+ isConfigured: () => Boolean(process.env.LMSTUDIO_BASE_URL) || (
+ !process.env.VERCEL && process.env.SIGNALFLOW_PUBLIC_HOSTED !== "true"
+ ),
defaultModel: process.env.DEFAULT_MODEL_NAME || "any",
requiredEnv: ["LMSTUDIO_BASE_URL"],
canTest: true,
@@ -111,23 +115,24 @@ export const PROVIDERS = {
defaultModel: process.env.DEFAULT_MODEL_NAME || "custom-model",
requiredEnv: ["CUSTOM_OPENAI_BASE_URL", "CUSTOM_OPENAI_API_KEY"],
canTest: true,
+ supportsTemporaryKey: true
}
};
export function getProviderApiKey(providerKey, config = {}) {
- // If public hosted mode is enabled, strictly require client-supplied keys
- if (process.env.SIGNALFLOW_PUBLIC_HOSTED === "true") {
- return config.apiKey || "";
- }
-
- // Otherwise, allow fallback to server environment variables
+ const temporaryKey = String(config.apiKey || "").trim();
+ if (temporaryKey) return temporaryKey;
+
+ const publicHosted = process.env.SIGNALFLOW_PUBLIC_HOSTED === "true";
+ if (publicHosted && !config.allowServerKey) return "";
+
switch (providerKey) {
- case "openai": return config.apiKey || process.env.OPENAI_API_KEY || "";
- case "claude": return config.apiKey || process.env.CLAUDE_API_KEY || process.env.ANTHROPIC_API_KEY || "";
- case "gemini": return config.apiKey || process.env.GEMINI_API_KEY || "";
- case "groq": return config.apiKey || process.env.GROQ_API_KEY || "";
- case "openrouter": return config.apiKey || process.env.OPENROUTER_API_KEY || "";
- case "custom": return config.apiKey || process.env.CUSTOM_OPENAI_API_KEY || "";
- default: return config.apiKey || "";
+ case "openai": return process.env.OPENAI_API_KEY || "";
+ case "claude": return process.env.CLAUDE_API_KEY || process.env.ANTHROPIC_API_KEY || "";
+ case "gemini": return process.env.GEMINI_API_KEY || "";
+ case "groq": return process.env.GROQ_API_KEY || "";
+ case "openrouter": return process.env.OPENROUTER_API_KEY || "";
+ case "custom": return process.env.CUSTOM_OPENAI_API_KEY || "";
+ default: return "";
}
}
diff --git a/frontend/lib/package/normalizePackage.js b/frontend/lib/package/normalizePackage.js
index 599f7b2..42de40a 100644
--- a/frontend/lib/package/normalizePackage.js
+++ b/frontend/lib/package/normalizePackage.js
@@ -144,9 +144,49 @@ function normalizeReleaseNotes(value, fallback) {
};
}
-/** Ensures every provider response is complete and safe for the active UI. */
-export function normalizePackage(rawPackage, inputs) {
- const baseline = generateLocalTemplatePackage(inputs).package;
+function strictBaseline(inputs = {}) {
+ return {
+ project: {
+ name: String(inputs.projectName || "SignalFlow campaign"),
+ oneLine: "",
+ description: String(inputs.notes || ""),
+ audience: String(inputs.audience || ""),
+ category: "",
+ stage: "",
+ },
+ context: {
+ confirmedFacts: [], inferredFacts: [], missingContext: [], features: [], techStack: [],
+ repoInsights: [], docsInsights: [], linkInsights: [], mediaInsights: [],
+ },
+ strategy: {
+ coreAngle: "", positioning: "", hooks: [], proofPoints: [], risks: [], safeClaims: [], avoidClaims: [],
+ },
+ posts: {
+ linkedin: { title: "", body: "", hashtags: [], cta: "" },
+ x: { mode: "post_or_thread", posts: [] },
+ instagram: { caption: "", hashtags: [], visualDirection: "" },
+ reddit: { title: "", body: "", subredditSuggestions: [] },
+ facebook: { body: "" },
+ threads: { body: "" },
+ youtube: { title: "", description: "", tags: [] },
+ tiktok: { caption: "", hook: "", shotList: [] },
+ hackernews: { title: "", body: "" },
+ blog: { title: "", outline: [], draft: "" },
+ newsletter: { subject: "", preview: "", body: "" },
+ releaseNotes: { title: "", sections: [] },
+ },
+ media: {
+ screenshotPlan: [], videoScript: [], voiceoverScript: [], shotList: [], recordingGuide: [],
+ carouselPlan: [], thumbnailIdeas: [], videoTimeline: [], altText: [], assetChecklist: [],
+ videoPrompt: "", thumbnailPrompt: "",
+ },
+ publishing: { platformChecklist: [], manualPostingSteps: [], apiPublishingNotes: "", warnings: [] },
+ };
+}
+
+/** Ensures provider responses match the active UI contract. */
+export function normalizePackage(rawPackage, inputs, { allowTemplateFallback = true } = {}) {
+ const baseline = allowTemplateFallback ? generateLocalTemplatePackage(inputs).package : strictBaseline(inputs);
const raw = object(rawPackage);
if (!raw) return baseline;
@@ -222,7 +262,7 @@ export function normalizePackage(rawPackage, inputs) {
},
};
- if (!normalized.media.videoPrompt) {
+ if (allowTemplateFallback && !normalized.media.videoPrompt) {
normalized.media.videoPrompt = buildVideoPrompt(normalized);
}
diff --git a/frontend/lib/studio/providerReadiness.mjs b/frontend/lib/studio/providerReadiness.mjs
new file mode 100644
index 0000000..0ec9cb1
--- /dev/null
+++ b/frontend/lib/studio/providerReadiness.mjs
@@ -0,0 +1,100 @@
+export const MODEL_PROVIDERS = [
+ "gemini",
+ "openai",
+ "claude",
+ "openrouter",
+ "groq",
+ "custom",
+ "ollama",
+ "lmstudio",
+];
+
+const LOCAL_PROVIDERS = new Set(["ollama", "lmstudio"]);
+const FORBIDDEN_GENERATION_MODES = new Set(["", "template", "offline", "prompt"]);
+
+export function isModelProvider(provider) {
+ return MODEL_PROVIDERS.includes(String(provider || "").trim().toLowerCase());
+}
+
+export function isForbiddenGenerationMode(provider) {
+ return FORBIDDEN_GENERATION_MODES.has(String(provider || "").trim().toLowerCase());
+}
+
+export function evaluateProviderReadiness({ provider, apiKey = "", baseUrl = "", status = null } = {}) {
+ const id = String(provider || "").trim().toLowerCase();
+ const hasApiKey = Boolean(String(apiKey || "").trim());
+ const hasBaseUrl = Boolean(String(baseUrl || "").trim());
+ const serverConfigured = Boolean(status?.configured);
+
+ if (!id || isForbiddenGenerationMode(id) || !isModelProvider(id)) {
+ return {
+ ready: false,
+ reason: "Choose a supported model provider before building the campaign.",
+ source: "missing",
+ };
+ }
+
+ if (serverConfigured) {
+ return {
+ ready: true,
+ reason: "Configured securely on the SignalFlow server.",
+ source: "server",
+ };
+ }
+
+ if (LOCAL_PROVIDERS.has(id)) {
+ if (status?.requiresBaseUrl && !hasBaseUrl) {
+ return {
+ ready: false,
+ reason: "This hosted deployment cannot reach your laptop automatically. Add a reachable trusted endpoint or run SignalFlow locally.",
+ source: "missing",
+ };
+ }
+ return {
+ ready: true,
+ reason: hasBaseUrl
+ ? "Model endpoint supplied. Test it before generation."
+ : "Uses the provider default local endpoint. Test it before generation.",
+ source: "local",
+ };
+ }
+
+ if (id === "custom") {
+ if (!hasBaseUrl) {
+ return {
+ ready: false,
+ reason: "Add the OpenAI-compatible base URL for this provider.",
+ source: "missing",
+ };
+ }
+ return {
+ ready: true,
+ reason: hasApiKey
+ ? "Custom endpoint and temporary key are ready for this request."
+ : "Custom endpoint supplied. Add a key when the endpoint requires one.",
+ source: "temporary",
+ };
+ }
+
+ if (hasApiKey) {
+ return {
+ ready: true,
+ reason: "Temporary key will be used only for this generation request.",
+ source: "temporary",
+ };
+ }
+
+ return {
+ ready: false,
+ reason: "Add a temporary API key or configure this provider on the server.",
+ source: "missing",
+ };
+}
+
+export function pickRecommendedProvider({ defaultProvider = "", statuses = {}, fallback = "gemini" } = {}) {
+ const preferred = String(defaultProvider || "").trim().toLowerCase();
+ if (isModelProvider(preferred) && statuses?.[preferred]?.configured) return preferred;
+
+ const configured = MODEL_PROVIDERS.find((provider) => statuses?.[provider]?.configured);
+ return configured || (isModelProvider(fallback) ? fallback : "gemini");
+}
diff --git a/frontend/tests/providerReadiness.test.mjs b/frontend/tests/providerReadiness.test.mjs
new file mode 100644
index 0000000..9a9d5d6
--- /dev/null
+++ b/frontend/tests/providerReadiness.test.mjs
@@ -0,0 +1,88 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import {
+ evaluateProviderReadiness,
+ isForbiddenGenerationMode,
+ pickRecommendedProvider,
+} from "../lib/studio/providerReadiness.mjs";
+import {
+ assertModelGenerationProvider,
+ canUseServerProviderConfiguration,
+} from "../lib/ai/generationPolicy.mjs";
+
+test("template and prompt-only modes are rejected", () => {
+ for (const provider of ["", "template", "offline", "prompt"]) {
+ assert.equal(isForbiddenGenerationMode(provider), true);
+ assert.throws(() => assertModelGenerationProvider(provider), /real model provider/i);
+ }
+});
+
+test("configured server providers are ready without exposing a key", () => {
+ const result = evaluateProviderReadiness({
+ provider: "openai",
+ status: { configured: true },
+ });
+ assert.equal(result.ready, true);
+ assert.equal(result.source, "server");
+});
+
+test("hosted server keys remain owner-only", () => {
+ assert.equal(
+ canUseServerProviderConfiguration({ publicHosted: true, allowServerKey: false }),
+ false,
+ );
+ assert.equal(
+ canUseServerProviderConfiguration({ publicHosted: true, allowServerKey: true }),
+ true,
+ );
+ assert.equal(
+ canUseServerProviderConfiguration({ publicHosted: false, allowServerKey: false }),
+ true,
+ );
+});
+
+test("cloud providers require server configuration or a temporary key", () => {
+ assert.equal(evaluateProviderReadiness({ provider: "gemini" }).ready, false);
+ assert.equal(
+ evaluateProviderReadiness({ provider: "gemini", apiKey: "temporary-key" }).ready,
+ true,
+ );
+});
+
+test("hosted local providers require a reachable endpoint", () => {
+ assert.equal(
+ evaluateProviderReadiness({ provider: "ollama", status: { requiresBaseUrl: true } }).ready,
+ false,
+ );
+ assert.equal(
+ evaluateProviderReadiness({
+ provider: "ollama",
+ baseUrl: "https://trusted-model.example/v1",
+ status: { requiresBaseUrl: true },
+ }).ready,
+ true,
+ );
+});
+
+test("custom provider requires a base URL", () => {
+ assert.equal(evaluateProviderReadiness({ provider: "custom", apiKey: "key" }).ready, false);
+ assert.equal(
+ evaluateProviderReadiness({ provider: "custom", baseUrl: "https://models.example.com/v1" }).ready,
+ true,
+ );
+});
+
+test("recommended provider prefers configured deployment routes", () => {
+ assert.equal(
+ pickRecommendedProvider({
+ defaultProvider: "openai",
+ statuses: { openai: { configured: true }, gemini: { configured: true } },
+ }),
+ "openai",
+ );
+ assert.equal(
+ pickRecommendedProvider({ statuses: { groq: { configured: true } } }),
+ "groq",
+ );
+});
diff --git a/mcp/README.md b/mcp/README.md
new file mode 100644
index 0000000..f877759
--- /dev/null
+++ b/mcp/README.md
@@ -0,0 +1,55 @@
+# SignalFlow MCP server
+
+This stdio MCP server lets compatible AI clients create SignalFlow campaigns through the same `/api/launch_kit` product API used by Studio.
+
+## Start
+
+```bash
+cd mcp
+npm start
+```
+
+The server uses standard input/output for MCP JSON-RPC. Logs are written only to standard error.
+
+## Environment
+
+```bash
+SIGNALFLOW_BASE_URL=https://signal-flow-studio.vercel.app
+SIGNALFLOW_ACCESS_KEY=optional-owner-workspace-key
+
+# Configure only the providers you use:
+SIGNALFLOW_GEMINI_API_KEY=...
+SIGNALFLOW_OPENAI_API_KEY=...
+SIGNALFLOW_ANTHROPIC_API_KEY=...
+SIGNALFLOW_OPENROUTER_API_KEY=...
+SIGNALFLOW_GROQ_API_KEY=...
+SIGNALFLOW_CUSTOM_API_KEY=...
+SIGNALFLOW_CUSTOM_BASE_URL=https://provider.example/v1
+```
+
+Provider secrets stay in the MCP process environment and are not part of tool arguments or model-visible prompts.
+
+## Client configuration example
+
+```json
+{
+ "mcpServers": {
+ "signalflow": {
+ "command": "node",
+ "args": ["/absolute/path/to/SignalFlow-Studio/mcp/server.mjs"],
+ "env": {
+ "SIGNALFLOW_BASE_URL": "http://localhost:3000",
+ "SIGNALFLOW_GEMINI_API_KEY": "your-key"
+ }
+ }
+ }
+}
+```
+
+## Tools
+
+- `signalflow_provider_status`
+- `signalflow_test_provider`
+- `signalflow_create_campaign`
+
+Campaign creation requires a real model provider. The MCP server does not expose or request the retired local template route.
diff --git a/mcp/lib/config.mjs b/mcp/lib/config.mjs
new file mode 100644
index 0000000..8c5b94d
--- /dev/null
+++ b/mcp/lib/config.mjs
@@ -0,0 +1,41 @@
+const PROVIDER_KEY_ENV = {
+ gemini: ["SIGNALFLOW_GEMINI_API_KEY", "GEMINI_API_KEY"],
+ openai: ["SIGNALFLOW_OPENAI_API_KEY", "OPENAI_API_KEY"],
+ claude: ["SIGNALFLOW_ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY", "CLAUDE_API_KEY"],
+ openrouter: ["SIGNALFLOW_OPENROUTER_API_KEY", "OPENROUTER_API_KEY"],
+ groq: ["SIGNALFLOW_GROQ_API_KEY", "GROQ_API_KEY"],
+ custom: ["SIGNALFLOW_CUSTOM_API_KEY", "CUSTOM_OPENAI_API_KEY"],
+};
+
+export function getSignalFlowBaseUrl(env = process.env) {
+ return String(env.SIGNALFLOW_BASE_URL || "http://localhost:3000").trim().replace(/\/+$/, "");
+}
+
+export function getSignalFlowAccessKey(env = process.env) {
+ return String(env.SIGNALFLOW_ACCESS_KEY || "").trim();
+}
+
+export function getProviderApiKey(provider, env = process.env) {
+ const candidates = PROVIDER_KEY_ENV[String(provider || "").toLowerCase()] || [];
+ for (const key of candidates) {
+ const value = String(env[key] || "").trim();
+ if (value) return value;
+ }
+ return "";
+}
+
+export function getProviderBaseUrl(provider, suppliedBaseUrl = "", env = process.env) {
+ const explicit = String(suppliedBaseUrl || "").trim();
+ if (explicit) return explicit;
+
+ switch (String(provider || "").toLowerCase()) {
+ case "custom":
+ return String(env.SIGNALFLOW_CUSTOM_BASE_URL || env.CUSTOM_OPENAI_BASE_URL || "").trim();
+ case "ollama":
+ return String(env.SIGNALFLOW_OLLAMA_BASE_URL || env.OLLAMA_BASE_URL || "").trim();
+ case "lmstudio":
+ return String(env.SIGNALFLOW_LMSTUDIO_BASE_URL || env.LMSTUDIO_BASE_URL || "").trim();
+ default:
+ return "";
+ }
+}
diff --git a/mcp/lib/httpClient.mjs b/mcp/lib/httpClient.mjs
new file mode 100644
index 0000000..b977f69
--- /dev/null
+++ b/mcp/lib/httpClient.mjs
@@ -0,0 +1,83 @@
+import {
+ getProviderApiKey,
+ getProviderBaseUrl,
+ getSignalFlowAccessKey,
+ getSignalFlowBaseUrl,
+} from "./config.mjs";
+
+function safeJsonParse(value) {
+ try {
+ return JSON.parse(value);
+ } catch {
+ return null;
+ }
+}
+
+export async function signalFlowRequest(path, {
+ method = "GET",
+ body,
+ provider,
+ providerBaseUrl,
+ env = process.env,
+ fetchImpl = globalThis.fetch,
+ timeoutMs = 120000,
+} = {}) {
+ if (typeof fetchImpl !== "function") {
+ throw new Error("This Node runtime does not provide fetch(). Use Node 20 or newer.");
+ }
+
+ const baseUrl = getSignalFlowBaseUrl(env);
+ const accessKey = getSignalFlowAccessKey(env);
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
+
+ const headers = { Accept: "application/json" };
+ if (body !== undefined) headers["Content-Type"] = "application/json";
+ if (accessKey) headers["x-signalflow-access-key"] = accessKey;
+
+ let requestBody = body;
+ if (requestBody && typeof requestBody === "object" && !Array.isArray(requestBody)) {
+ requestBody = { ...requestBody };
+ const resolvedProvider = String(provider || requestBody.generator || "").toLowerCase();
+ const providerKey = getProviderApiKey(resolvedProvider, env);
+ const resolvedBaseUrl = getProviderBaseUrl(
+ resolvedProvider,
+ providerBaseUrl || requestBody.providerBaseUrl,
+ env,
+ );
+ if (providerKey && !requestBody.providerApiKey && !requestBody.temporaryApiKey) {
+ if (path.includes("provider_test")) requestBody.temporaryApiKey = providerKey;
+ else requestBody.providerApiKey = providerKey;
+ }
+ if (resolvedBaseUrl && !requestBody.providerBaseUrl && !requestBody.baseUrl) {
+ if (path.includes("provider_test")) requestBody.baseUrl = resolvedBaseUrl;
+ else requestBody.providerBaseUrl = resolvedBaseUrl;
+ }
+ }
+
+ try {
+ const response = await fetchImpl(`${baseUrl}${path}`, {
+ method,
+ headers,
+ body: requestBody === undefined ? undefined : JSON.stringify(requestBody),
+ signal: controller.signal,
+ });
+ const text = await response.text();
+ const data = safeJsonParse(text);
+
+ if (!data || typeof data !== "object") {
+ throw new Error(`SignalFlow returned an unreadable response (HTTP ${response.status}).`);
+ }
+ if (!response.ok || data.ok === false) {
+ throw new Error(data.error || `SignalFlow request failed (HTTP ${response.status}).`);
+ }
+ return data;
+ } catch (error) {
+ if (error?.name === "AbortError") {
+ throw new Error(`SignalFlow request timed out after ${Math.round(timeoutMs / 1000)} seconds.`);
+ }
+ throw error;
+ } finally {
+ clearTimeout(timeout);
+ }
+}
diff --git a/mcp/lib/tools.mjs b/mcp/lib/tools.mjs
new file mode 100644
index 0000000..c9d9b7f
--- /dev/null
+++ b/mcp/lib/tools.mjs
@@ -0,0 +1,189 @@
+import { signalFlowRequest } from "./httpClient.mjs";
+
+const CHANNELS = [
+ "linkedin",
+ "x",
+ "instagram",
+ "facebook",
+ "threads",
+ "reddit",
+ "hackernews",
+ "youtube",
+ "tiktok",
+ "newsletter",
+ "blog",
+ "release_notes",
+];
+
+const PROVIDERS = ["gemini", "openai", "claude", "openrouter", "groq", "custom", "ollama", "lmstudio"];
+
+export const TOOL_DEFINITIONS = [
+ {
+ name: "signalflow_provider_status",
+ description: "Inspect which real model providers are configured for the connected SignalFlow workspace.",
+ inputSchema: {
+ type: "object",
+ properties: {},
+ additionalProperties: false,
+ },
+ },
+ {
+ name: "signalflow_test_provider",
+ description: "Test the selected SignalFlow model route before generating a campaign. Secrets are read from the MCP server environment, not model context.",
+ inputSchema: {
+ type: "object",
+ required: ["provider"],
+ properties: {
+ provider: { type: "string", enum: PROVIDERS },
+ modelName: { type: "string" },
+ baseUrl: { type: "string" },
+ },
+ additionalProperties: false,
+ },
+ },
+ {
+ name: "signalflow_create_campaign",
+ description: "Create a staged, destination-specific SignalFlow campaign from product evidence. This requires a real model provider and never uses local template copy.",
+ inputSchema: {
+ type: "object",
+ required: ["projectName", "notes", "provider", "channels"],
+ properties: {
+ projectName: { type: "string", minLength: 1 },
+ notes: { type: "string", minLength: 1 },
+ audience: { type: "string" },
+ links: {
+ description: "Public documentation, landing pages, or research URLs separated by spaces or new lines.",
+ type: "string",
+ },
+ repository: { type: "string" },
+ provider: { type: "string", enum: PROVIDERS },
+ modelName: { type: "string" },
+ baseUrl: { type: "string" },
+ channels: {
+ type: "array",
+ minItems: 1,
+ uniqueItems: true,
+ items: { type: "string", enum: CHANNELS },
+ },
+ documentText: {
+ type: "array",
+ items: { type: "string" },
+ },
+ },
+ additionalProperties: false,
+ },
+ },
+];
+
+function textContent(text) {
+ return [{ type: "text", text }];
+}
+
+function requireString(value, label) {
+ const normalized = String(value || "").trim();
+ if (!normalized) throw new Error(`${label} is required.`);
+ return normalized;
+}
+
+function requireProvider(value) {
+ const provider = requireString(value, "provider").toLowerCase();
+ if (!PROVIDERS.includes(provider)) {
+ throw new Error(`Unsupported model provider: ${provider}.`);
+ }
+ return provider;
+}
+
+function requireChannels(value) {
+ const channels = Array.isArray(value)
+ ? Array.from(new Set(value.map((item) => String(item || "").trim().toLowerCase()).filter(Boolean)))
+ : [];
+ if (!channels.length) throw new Error("At least one destination channel is required.");
+ const unknown = channels.filter((channel) => !CHANNELS.includes(channel));
+ if (unknown.length) throw new Error(`Unsupported destination channels: ${unknown.join(", ")}.`);
+ return channels;
+}
+
+export async function executeTool(name, args = {}, options = {}) {
+ if (name === "signalflow_provider_status") {
+ const data = await signalFlowRequest("/api/provider_status", options);
+ const configured = Object.values(data.providers || {})
+ .filter((provider) => provider?.configured)
+ .map((provider) => provider.label || provider.id);
+ return {
+ content: textContent(
+ configured.length
+ ? `Configured SignalFlow model routes: ${configured.join(", ")}.`
+ : "No server model route is configured. Add provider credentials to the MCP environment or SignalFlow deployment.",
+ ),
+ structuredContent: data,
+ };
+ }
+
+ if (name === "signalflow_test_provider") {
+ const provider = requireProvider(args.provider);
+ const data = await signalFlowRequest("/api/provider_test", {
+ ...options,
+ method: "POST",
+ provider,
+ providerBaseUrl: args.baseUrl,
+ body: {
+ provider,
+ modelName: String(args.modelName || "").trim(),
+ baseUrl: String(args.baseUrl || "").trim(),
+ },
+ timeoutMs: 70000,
+ });
+ return {
+ content: textContent(data.ok ? `${provider} connection succeeded.` : `${provider} connection failed.`),
+ structuredContent: data,
+ isError: !data.ok,
+ };
+ }
+
+ if (name === "signalflow_create_campaign") {
+ const projectName = requireString(args.projectName, "projectName");
+ const notes = requireString(args.notes, "notes");
+ const provider = requireProvider(args.provider);
+ const channels = requireChannels(args.channels);
+
+ const data = await signalFlowRequest("/api/launch_kit", {
+ ...options,
+ method: "POST",
+ provider,
+ providerBaseUrl: args.baseUrl,
+ timeoutMs: 240000,
+ body: {
+ project_name: projectName,
+ notes,
+ audience: String(args.audience || "Founders, builders, and early users").trim(),
+ docs_url: String(args.links || "").trim(),
+ repo: String(args.repository || "").trim(),
+ channels,
+ output_types: ["posts", "media_plan", "markdown", "json"],
+ generator: provider,
+ providerModelName: String(args.modelName || "").trim(),
+ providerBaseUrl: String(args.baseUrl || "").trim(),
+ document_text: Array.isArray(args.documentText) ? args.documentText : [],
+ media_items: [],
+ },
+ });
+
+ const generated = Object.entries(data.generation_status || {})
+ .filter(([, status]) => ["generated", "regenerated", "needs_review"].includes(status?.status))
+ .map(([channel]) => channel);
+ const failed = Object.entries(data.generation_status || {})
+ .filter(([, status]) => status?.status === "failed")
+ .map(([channel]) => channel);
+
+ return {
+ content: textContent(
+ `SignalFlow created ${generated.length} destination draft${generated.length === 1 ? "" : "s"} for ${projectName}` +
+ `${failed.length ? `; ${failed.length} destination${failed.length === 1 ? "" : "s"} failed and contain no template substitute` : ""}.`,
+ ),
+ structuredContent: data,
+ isError: generated.length === 0,
+ };
+ }
+
+ throw new Error(`Unknown SignalFlow MCP tool: ${name}.`);
+}
diff --git a/mcp/package.json b/mcp/package.json
new file mode 100644
index 0000000..b5a53ce
--- /dev/null
+++ b/mcp/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "@signalflow/mcp-server",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "description": "Model Context Protocol server for creating SignalFlow campaigns through the tested product API.",
+ "bin": {
+ "signalflow-mcp": "./server.mjs"
+ },
+ "scripts": {
+ "start": "node server.mjs",
+ "test": "node --test tests/*.test.mjs"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+}
diff --git a/mcp/server.mjs b/mcp/server.mjs
new file mode 100644
index 0000000..20eff05
--- /dev/null
+++ b/mcp/server.mjs
@@ -0,0 +1,124 @@
+#!/usr/bin/env node
+
+import readline from "node:readline";
+import { executeTool, TOOL_DEFINITIONS } from "./lib/tools.mjs";
+
+const SERVER_INFO = { name: "signalflow-studio", version: "0.1.0" };
+const SUPPORTED_PROTOCOLS = new Set([
+ "2025-11-25",
+ "2025-06-18",
+ "2025-03-26",
+ "2024-11-05",
+]);
+const LATEST_PROTOCOL = "2025-11-25";
+const TOOL_NAMES = new Set(TOOL_DEFINITIONS.map((tool) => tool.name));
+
+let initializeCompleted = false;
+let initialized = false;
+
+function writeMessage(message) {
+ process.stdout.write(`${JSON.stringify(message)}\n`);
+}
+
+function result(id, payload) {
+ writeMessage({ jsonrpc: "2.0", id, result: payload });
+}
+
+function error(id, code, message, data) {
+ writeMessage({
+ jsonrpc: "2.0",
+ id: id ?? null,
+ error: { code, message, ...(data === undefined ? {} : { data }) },
+ });
+}
+
+async function handleRequest(message) {
+ if (!message || message.jsonrpc !== "2.0" || typeof message.method !== "string") {
+ error(message?.id, -32600, "Invalid JSON-RPC request.");
+ return;
+ }
+
+ const { id, method, params = {} } = message;
+ const notification = id === undefined;
+
+ if (notification) {
+ if (method === "notifications/initialized" && initializeCompleted) initialized = true;
+ return;
+ }
+
+ if (method === "initialize") {
+ const requested = String(params.protocolVersion || "");
+ const protocolVersion = SUPPORTED_PROTOCOLS.has(requested) ? requested : LATEST_PROTOCOL;
+ initializeCompleted = true;
+ initialized = false;
+ result(id, {
+ protocolVersion,
+ capabilities: { tools: { listChanged: false } },
+ serverInfo: SERVER_INFO,
+ instructions:
+ "Use SignalFlow to create evidence-grounded, destination-specific campaigns. Configure provider secrets in the MCP process environment; never place API keys in model prompts.",
+ });
+ return;
+ }
+
+ if (!initialized) {
+ error(id, -32002, "SignalFlow MCP is not initialized. Send initialize and notifications/initialized first.");
+ return;
+ }
+
+ if (method === "ping") {
+ result(id, {});
+ return;
+ }
+
+ if (method === "tools/list") {
+ result(id, { tools: TOOL_DEFINITIONS });
+ return;
+ }
+
+ if (method === "tools/call") {
+ if (!TOOL_NAMES.has(params.name)) {
+ error(id, -32602, `Unknown tool: ${String(params.name || "missing tool name")}`);
+ return;
+ }
+ try {
+ const toolResult = await executeTool(params.name, params.arguments || {});
+ result(id, toolResult);
+ } catch (toolError) {
+ result(id, {
+ content: [{ type: "text", text: toolError.message || "SignalFlow MCP tool failed." }],
+ isError: true,
+ });
+ }
+ return;
+ }
+
+ error(id, -32601, `Method not found: ${method}`);
+}
+
+const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
+let chain = Promise.resolve();
+
+input.on("line", (line) => {
+ if (!line.trim()) return;
+ chain = chain.then(async () => {
+ let message;
+ try {
+ message = JSON.parse(line);
+ } catch {
+ error(null, -32700, "Parse error.");
+ return;
+ }
+ await handleRequest(message);
+ }).catch((unexpectedError) => {
+ console.error("SignalFlow MCP request failure:", unexpectedError);
+ });
+});
+
+input.on("close", () => {
+ void chain.finally(() => process.exit(0));
+});
+
+process.on("SIGINT", () => input.close());
+process.on("SIGTERM", () => input.close());
+console.error("SignalFlow MCP server listening on stdio");
diff --git a/mcp/tests/server.test.mjs b/mcp/tests/server.test.mjs
new file mode 100644
index 0000000..dea9b32
--- /dev/null
+++ b/mcp/tests/server.test.mjs
@@ -0,0 +1,74 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { spawn } from "node:child_process";
+import readline from "node:readline";
+import { fileURLToPath } from "node:url";
+import path from "node:path";
+
+const currentDir = path.dirname(fileURLToPath(import.meta.url));
+const serverPath = path.resolve(currentDir, "../server.mjs");
+
+function waitForLine(lines, predicate, timeoutMs = 5000) {
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => reject(new Error("Timed out waiting for MCP response.")), timeoutMs);
+ const check = () => {
+ const match = lines.find(predicate);
+ if (match) {
+ clearTimeout(timeout);
+ resolve(match);
+ return;
+ }
+ setTimeout(check, 10);
+ };
+ check();
+ });
+}
+
+function send(child, message) {
+ child.stdin.write(`${JSON.stringify(message)}\n`);
+}
+
+test("stdio server enforces lifecycle, initializes, and lists SignalFlow tools", async (t) => {
+ const child = spawn(process.execPath, [serverPath], {
+ stdio: ["pipe", "pipe", "pipe"],
+ env: { ...process.env, SIGNALFLOW_BASE_URL: "http://localhost:3000" },
+ });
+ t.after(() => child.kill("SIGTERM"));
+
+ const lines = [];
+ const output = readline.createInterface({ input: child.stdout });
+ output.on("line", (line) => lines.push(JSON.parse(line)));
+
+ send(child, { jsonrpc: "2.0", id: 0, method: "tools/list", params: {} });
+ const beforeInitialize = await waitForLine(lines, (message) => message.id === 0);
+ assert.equal(beforeInitialize.error.code, -32002);
+
+ send(child, {
+ jsonrpc: "2.0",
+ id: 1,
+ method: "initialize",
+ params: { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "test", version: "1" } },
+ });
+
+ const initializeResponse = await waitForLine(lines, (message) => message.id === 1);
+ assert.equal(initializeResponse.result.protocolVersion, "2025-11-25");
+ assert.equal(initializeResponse.result.serverInfo.name, "signalflow-studio");
+ assert.equal(initializeResponse.result.capabilities.tools.listChanged, false);
+
+ send(child, { jsonrpc: "2.0", method: "notifications/initialized", params: {} });
+ send(child, { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
+
+ const toolList = await waitForLine(lines, (message) => message.id === 2);
+ assert.equal(toolList.result.tools.length, 3);
+ assert.equal(toolList.result.tools[2].name, "signalflow_create_campaign");
+
+ send(child, {
+ jsonrpc: "2.0",
+ id: 3,
+ method: "tools/call",
+ params: { name: "missing_tool", arguments: {} },
+ });
+ const unknownTool = await waitForLine(lines, (message) => message.id === 3);
+ assert.equal(unknownTool.error.code, -32602);
+ assert.match(unknownTool.error.message, /unknown tool/i);
+});
diff --git a/mcp/tests/tools.test.mjs b/mcp/tests/tools.test.mjs
new file mode 100644
index 0000000..4b12493
--- /dev/null
+++ b/mcp/tests/tools.test.mjs
@@ -0,0 +1,74 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { executeTool, TOOL_DEFINITIONS } from "../lib/tools.mjs";
+
+test("MCP exposes provider status, provider test, and campaign creation tools", () => {
+ assert.deepEqual(
+ TOOL_DEFINITIONS.map((tool) => tool.name),
+ ["signalflow_provider_status", "signalflow_test_provider", "signalflow_create_campaign"],
+ );
+});
+
+test("campaign tool refuses template generation", async () => {
+ await assert.rejects(
+ executeTool("signalflow_create_campaign", {
+ projectName: "SignalFlow",
+ notes: "Evidence",
+ provider: "template",
+ channels: ["linkedin"],
+ }),
+ /unsupported model provider/i,
+ );
+});
+
+test("campaign tool forwards provider secrets from environment, not tool arguments", async () => {
+ const calls = [];
+ const fetchImpl = async (url, options) => {
+ calls.push({ url, options, body: JSON.parse(options.body) });
+ return new Response(JSON.stringify({
+ ok: true,
+ providerUsed: "gemini",
+ generation_status: { linkedin: { status: "generated" } },
+ posts: { linkedin: "A generated draft" },
+ }), { status: 200, headers: { "Content-Type": "application/json" } });
+ };
+
+ const result = await executeTool("signalflow_create_campaign", {
+ projectName: "SignalFlow",
+ notes: "A real product brief",
+ provider: "gemini",
+ channels: ["linkedin"],
+ }, {
+ fetchImpl,
+ env: {
+ SIGNALFLOW_BASE_URL: "https://signalflow.example",
+ SIGNALFLOW_ACCESS_KEY: "workspace-secret",
+ SIGNALFLOW_GEMINI_API_KEY: "provider-secret",
+ },
+ });
+
+ assert.equal(result.isError, false);
+ assert.equal(calls.length, 1);
+ assert.equal(calls[0].url, "https://signalflow.example/api/launch_kit");
+ assert.equal(calls[0].options.headers["x-signalflow-access-key"], "workspace-secret");
+ assert.equal(calls[0].body.providerApiKey, "provider-secret");
+ assert.equal(calls[0].body.generator, "gemini");
+});
+
+test("API failures become MCP errors instead of fake campaign output", async () => {
+ const fetchImpl = async () => new Response(
+ JSON.stringify({ ok: false, error: "Provider connection failed" }),
+ { status: 400, headers: { "Content-Type": "application/json" } },
+ );
+
+ await assert.rejects(
+ executeTool("signalflow_create_campaign", {
+ projectName: "SignalFlow",
+ notes: "A real product brief",
+ provider: "openai",
+ channels: ["blog"],
+ }, { fetchImpl, env: { SIGNALFLOW_BASE_URL: "https://signalflow.example" } }),
+ /provider connection failed/i,
+ );
+});