From 09222615ba31740bc4d56c23083b0d86a2980c1a Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:56:31 +0530 Subject: [PATCH 01/29] feat: add explicit model route readiness --- frontend/lib/studio/providerReadiness.mjs | 93 +++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 frontend/lib/studio/providerReadiness.mjs diff --git a/frontend/lib/studio/providerReadiness.mjs b/frontend/lib/studio/providerReadiness.mjs new file mode 100644 index 0000000..4f73e98 --- /dev/null +++ b/frontend/lib/studio/providerReadiness.mjs @@ -0,0 +1,93 @@ +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)) { + return { + ready: true, + reason: hasBaseUrl + ? "Local 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"); +} From 8611805295eb0a0df9b851cbfce9165b3b719d9a Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:56:56 +0530 Subject: [PATCH 02/29] feat: require real model generation routes --- frontend/lib/ai/generationPolicy.mjs | 30 ++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 frontend/lib/ai/generationPolicy.mjs diff --git a/frontend/lib/ai/generationPolicy.mjs b/frontend/lib/ai/generationPolicy.mjs new file mode 100644 index 0000000..105a175 --- /dev/null +++ b/frontend/lib/ai/generationPolicy.mjs @@ -0,0 +1,30 @@ +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; +} From 4955124f49c593e5f63c2379c65bf7dca0c7f148 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:57:26 +0530 Subject: [PATCH 03/29] test: cover model route readiness --- frontend/tests/providerReadiness.test.mjs | 55 +++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 frontend/tests/providerReadiness.test.mjs diff --git a/frontend/tests/providerReadiness.test.mjs b/frontend/tests/providerReadiness.test.mjs new file mode 100644 index 0000000..71285a0 --- /dev/null +++ b/frontend/tests/providerReadiness.test.mjs @@ -0,0 +1,55 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + evaluateProviderReadiness, + isForbiddenGenerationMode, + pickRecommendedProvider, +} from "../lib/studio/providerReadiness.mjs"; +import { assertModelGenerationProvider } 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("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("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", + ); +}); From f648e54805bc5f8474ac78589162ec388d0062d5 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:58:32 +0530 Subject: [PATCH 04/29] feat: use the studio workspace deliberately --- frontend/app/studio-product.css | 331 ++++++++++++++++++++++++++++++++ 1 file changed, 331 insertions(+) create mode 100644 frontend/app/studio-product.css 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; + } +} From 033b45dadc3f4fbc3ce00440800787e3871fce38 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:59:16 +0530 Subject: [PATCH 05/29] feat: add SignalFlow MCP server package --- mcp/package.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 mcp/package.json 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" + } +} From c39fc7c31fc64709865a86e188af391ed3eacbfb Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:59:44 +0530 Subject: [PATCH 06/29] feat: configure MCP provider secrets outside model context --- mcp/lib/config.mjs | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 mcp/lib/config.mjs 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 ""; + } +} From e62010e7b1c6e09fa8894ced115bb8826dfe47e8 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:00:13 +0530 Subject: [PATCH 07/29] feat: add MCP HTTP client for SignalFlow API --- mcp/lib/httpClient.mjs | 83 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 mcp/lib/httpClient.mjs 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); + } +} From a4d7ce21c2641ba3e7dbd77c6e5ed83001d424e1 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:01:06 +0530 Subject: [PATCH 08/29] feat: expose campaign creation through MCP tools --- mcp/lib/tools.mjs | 189 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 mcp/lib/tools.mjs 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}.`); +} From 22e546d0d503eec6899213ee24d6bdb010d4ad71 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:01:43 +0530 Subject: [PATCH 09/29] feat: implement dependency-free SignalFlow MCP stdio server --- mcp/server.mjs | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 mcp/server.mjs diff --git a/mcp/server.mjs b/mcp/server.mjs new file mode 100644 index 0000000..b720d19 --- /dev/null +++ b/mcp/server.mjs @@ -0,0 +1,99 @@ +#!/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_PROTOCOL = "2025-11-25"; + +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; + + if (method === "notifications/initialized" || method.startsWith("notifications/")) { + return; + } + + if (method === "initialize") { + result(id, { + protocolVersion: params.protocolVersion || SUPPORTED_PROTOCOL, + 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 (method === "ping") { + result(id, {}); + return; + } + + if (method === "tools/list") { + result(id, { tools: TOOL_DEFINITIONS }); + return; + } + + if (method === "tools/call") { + 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"); From 8ef7d0864c6f02b234e9e94a579747cdfbbc0334 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:02:18 +0530 Subject: [PATCH 10/29] test: cover SignalFlow MCP tools --- mcp/tests/tools.test.mjs | 74 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 mcp/tests/tools.test.mjs 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, + ); +}); From a80ac749a07eb8eba2ee8769d7e1bd45521af463 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:02:45 +0530 Subject: [PATCH 11/29] test: smoke test MCP stdio protocol --- mcp/tests/server.test.mjs | 55 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 mcp/tests/server.test.mjs diff --git a/mcp/tests/server.test.mjs b/mcp/tests/server.test.mjs new file mode 100644 index 0000000..8b39353 --- /dev/null +++ b/mcp/tests/server.test.mjs @@ -0,0 +1,55 @@ +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(); + }); +} + +test("stdio server 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))); + + child.stdin.write(`${JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "test", version: "1" } }, + })}\n`); + + const initialized = await waitForLine(lines, (message) => message.id === 1); + assert.equal(initialized.result.serverInfo.name, "signalflow-studio"); + assert.equal(initialized.result.capabilities.tools.listChanged, false); + + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} })}\n`); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} })}\n`); + + 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"); +}); From 633e39d4abdd1fb831460681e1979f8c24c35251 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:03:09 +0530 Subject: [PATCH 12/29] docs: document SignalFlow MCP server setup --- mcp/README.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 mcp/README.md 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. From d9ffd7825636babb24c25bc5e8224135ac606e66 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:07:49 +0530 Subject: [PATCH 13/29] chore: add one-time studio and MCP product migration --- .github/scripts/apply-studio-product-mcp.py | 724 ++++++++++++++++++++ 1 file changed, 724 insertions(+) create mode 100644 .github/scripts/apply-studio-product-mcp.py diff --git a/.github/scripts/apply-studio-product-mcp.py b/.github/scripts/apply-studio-product-mcp.py new file mode 100644 index 0000000..5fb89d8 --- /dev/null +++ b/.github/scripts/apply-studio-product-mcp.py @@ -0,0 +1,724 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def read(path): + return (ROOT / path).read_text() + + +def write(path, value): + (ROOT / path).write_text(value) + + +def replace_required(path, old, new, label): + text = read(path) + if old not in text: + raise RuntimeError(f"Missing {label} in {path}") + write(path, text.replace(old, new, 1)) + + +def replace_between(path, start, end, replacement, label): + text = read(path) + start_index = text.find(start) + end_index = text.find(end, start_index + len(start)) + if start_index < 0 or end_index < 0: + raise RuntimeError(f"Missing {label} boundaries in {path}") + write(path, text[:start_index] + replacement + text[end_index:]) + + +PAGE = "frontend/app/page.js" + +replace_required( + PAGE, + '} from "../lib/studio/clientReliability.mjs";', + '} from "../lib/studio/clientReliability.mjs";\nimport {\n evaluateProviderReadiness,\n pickRecommendedProvider,\n} from "../lib/studio/providerReadiness.mjs";', + "provider readiness import", +) + +replace_between( + PAGE, + "const PROVIDERS = [", + "const FAQS = [", + '''const PROVIDERS = [ + { id: "gemini", label: "Gemini", hint: "Use a temporary Google AI Studio key or the securely configured server route." }, + { id: "openai", label: "OpenAI", hint: "Use a temporary OpenAI key or the securely configured server route." }, + { id: "claude", label: "Claude", hint: "Use a temporary Anthropic key or the securely configured server route." }, + { id: "openrouter", label: "OpenRouter", hint: "Route generation through a model available in your OpenRouter account." }, + { id: "groq", label: "Groq", hint: "Use a Groq key for fast hosted generation." }, + { id: "custom", label: "Custom gateway", hint: "Use an OpenAI-compatible endpoint and model." }, + { id: "ollama", label: "Ollama", hint: "Use a reachable Ollama endpoint in local or trusted self-hosted deployments." }, + { id: "lmstudio", label: "LM Studio", hint: "Use a reachable LM Studio endpoint in local or trusted self-hosted deployments." }, +]; + +''', + "model provider list", +) + +replace_required( + PAGE, + 'question: "Can I use my own AI model or no AI at all?",\n answer:\n "Yes. SignalFlow includes a deterministic local template route and supports Gemini, OpenAI, Claude, Groq, Ollama, LM Studio, and custom OpenAI-compatible endpoints.",', + 'question: "Can I bring my own model provider?",\n answer:\n "Yes. SignalFlow supports Gemini, OpenAI, Claude, OpenRouter, Groq, Ollama, LM Studio, and custom OpenAI-compatible endpoints. Campaign generation requires a real model route.",', + "landing FAQ model answer", +) + +replace_required(PAGE, 'provider: "template",', 'provider: "gemini",', "default provider") +replace_required( + PAGE, + ' const [connectionsLoading, setConnectionsLoading] = useState(false);\n const [accessToken, setAccessToken] = useState("");', + ' const [connectionsLoading, setConnectionsLoading] = useState(false);\n const [providerStatuses, setProviderStatuses] = useState({});\n const [providerStatusLoading, setProviderStatusLoading] = useState(true);\n const [providerTest, setProviderTest] = useState({ status: "idle", message: "" });\n const [accessToken, setAccessToken] = useState("");', + "provider status state", +) +replace_required(PAGE, ' const [showAdvanced, setShowAdvanced] = useState(false);\n', '', "retired advanced state") + +replace_required( + PAGE, + ' const composeReady = sourceSignals > 0 && channels.length > 0;', + ''' const providerReadiness = evaluateProviderReadiness({ + provider: form.provider, + apiKey: form.apiKey, + baseUrl: form.baseUrl, + status: providerStatuses[form.provider], + }); + const sourceAndChannelsReady = sourceSignals > 0 && channels.length > 0; + const composeReady = sourceAndChannelsReady && providerReadiness.ready;''', + "provider-aware campaign readiness", +) + +replace_required( + PAGE, + ' useEffect(() => {\n if (!entered) return;\n refreshConnections();\n }, [entered, accessToken]);', + ' useEffect(() => {\n if (!entered) return;\n refreshConnections();\n refreshProviderStatus();\n }, [entered, accessToken]);', + "provider status refresh effect", +) + +replace_required( + PAGE, + ' async function syncOwnerSession() {', + ''' async function refreshProviderStatus() { + setProviderStatusLoading(true); + try { + const response = await fetch("/api/provider_status"); + const data = await readJsonResponse(response, "SignalFlow could not read model provider status."); + const statuses = data.providers || {}; + setProviderStatuses(statuses); + const recommended = pickRecommendedProvider({ + defaultProvider: data.defaultProvider, + statuses, + fallback: form.provider, + }); + setForm((previous) => { + if (previous.apiKey.trim() || previous.baseUrl.trim() || statuses[previous.provider]?.configured) return previous; + return previous.provider === recommended ? previous : { ...previous, provider: recommended }; + }); + } catch { + setProviderStatuses({}); + } finally { + setProviderStatusLoading(false); + } + } + + async function testProviderConnection() { + if (!providerReadiness.ready) { + setProviderTest({ status: "error", message: providerReadiness.reason }); + return; + } + setProviderTest({ status: "testing", message: "Testing model route…" }); + try { + const response = await fetch("/api/provider_test", { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ + provider: form.provider, + modelName: form.model.trim(), + baseUrl: form.baseUrl.trim(), + temporaryApiKey: form.apiKey.trim(), + }), + }); + const data = await readJsonResponse(response, "SignalFlow returned an unreadable provider test response."); + if (!response.ok || !data.ok) throw new Error(data.error || "Model route test failed."); + setProviderTest({ status: "success", message: data.message || "Model route connected successfully." }); + void refreshProviderStatus(); + } catch (error) { + setProviderTest({ status: "error", message: error.message }); + } + } + + async function syncOwnerSession() {''', + "provider functions", +) + +replace_required( + PAGE, + ' setBusy(true);\n setMessage(null);', + ''' if (!providerReadiness.ready) { + setMessage({ type: "error", text: providerReadiness.reason }); + navigateStudioFlow("destinations"); + return; + } + + setBusy(true); + setMessage(null);''', + "provider guard before generation", +) + +replace_required( + PAGE, + ''' setMessage({ + type: data.fallbackUsed ? "warning" : "success", + text: data.fallbackUsed + ? "Campaign created with a fallback route. Review the generation note before publishing." + : `Campaign generated with ${data.providerUsed || provider.label}.`, + });''', + ''' if (data.fallbackUsed) { + throw new Error("SignalFlow refused the response because it contained retired template fallback content."); + } + const failedChannels = Object.entries(data.generation_status || {}) + .filter(([, item]) => item?.status === "failed") + .map(([channel]) => channel); + setMessage({ + type: failedChannels.length ? "warning" : "success", + text: failedChannels.length + ? `Campaign generated with ${data.providerUsed || provider.label}; ${failedChannels.join(", ")} failed without template substitution.` + : `Campaign generated with ${data.providerUsed || provider.label}.`, + });''', + "honest generation result", +) + +replace_required( + PAGE, + '\n {accessToken ? "Owner session" : "Local mode"}', + '\n {providerReadiness.ready ? `${accessToken ? "Owner · " : ""}${provider.label}` : "Model setup needed"}', + "header model status", +) + +replace_between( + PAGE, + ' +
+
+
Generation engine
+

Choose the model route

+
+ + {providerStatusLoading ? "Checking" : providerReadiness.ready ? "Ready" : "Setup needed"} + +
+ +
+ {PROVIDERS.map((item) => { + const configured = Boolean(providerStatuses[item.id]?.configured); + return ( + + ); + })} +
+ +
+ + {!['ollama', 'lmstudio'].includes(form.provider) && ( + + )} + {['ollama', 'lmstudio', 'custom'].includes(form.provider) && ( + + )} + +
+ +
+ +
+

+ {providerTest.status === "idle" ? providerReadiness.reason : providerTest.message} +

+

+ Temporary keys are sent only with this request. SignalFlow does not save them in the campaign library. +

+ + +''', + "always-visible model route panel", +) + +replace_required( + PAGE, + '

{composeReady ? "Ready to shape the campaign." : "Bring one strong source signal."}

', + '

{!sourceAndChannelsReady ? "Choose source and destinations." : providerReadiness.ready ? "Ready to shape the campaign." : "Connect a model route."}

', + "readiness heading", +) +replace_required( + PAGE, + '{composeReady ? "Ready" : "Needs source"}', + '{composeReady ? "Ready" : providerReadiness.ready ? "Needs source" : "Needs model"}', + "readiness badge", +) +replace_required( + PAGE, + '''{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}''', + "readiness description", +) + +LAYOUT = "frontend/app/layout.js" +replace_required( + LAYOUT, + 'import "../app/ui-containment.css";', + 'import "../app/ui-containment.css";\nimport "../app/studio-product.css";', + "final studio stylesheet import", +) +replace_required( + LAYOUT, + 'question: "Can I use my own AI model or no AI at all?",\n answer:\n "Yes. SignalFlow includes a deterministic local template route and supports Gemini, OpenAI, Claude, Groq, Ollama, LM Studio, and custom OpenAI-compatible endpoints.",', + 'question: "Can I bring my own model provider?",\n answer:\n "Yes. SignalFlow supports Gemini, OpenAI, Claude, OpenRouter, Groq, Ollama, LM Studio, and custom OpenAI-compatible endpoints. Campaign generation requires a real model route.",', + "structured FAQ model answer", +) +replace_required( + LAYOUT, + 'It supports local templates, bring-your-own-model generation, browser-local campaign saving, Markdown and JSON exports, and confirmed-only publishing through configured official connectors.', + 'It supports real model-provider generation, browser-local campaign saving, Markdown and JSON exports, an MCP server for AI agents, and confirmed-only publishing through configured official connectors.', + "software description", +) +replace_required( + LAYOUT, + ' "Use deterministic local templates without an API key",\n', + ' "Use a real configured or bring-your-own model provider",\n "Create campaigns directly through the SignalFlow MCP server",\n', + "schema feature list", +) + +write( + "frontend/app/api/provider_status/route.js", + '''import { getProviderConfigurationStatus } from "../../../lib/ai/providerStatus"; +import { MODEL_GENERATION_PROVIDERS } from "../../../lib/ai/generationPolicy.mjs"; + +export async function GET() { + try { + const allProviders = getProviderConfigurationStatus(); + const providers = Object.fromEntries( + Object.entries(allProviders).filter(([id]) => MODEL_GENERATION_PROVIDERS.has(id)), + ); + const requestedDefault = String(process.env.DEFAULT_MODEL_PROVIDER || "").trim().toLowerCase(); + const defaultProvider = MODEL_GENERATION_PROVIDERS.has(requestedDefault) ? requestedDefault : ""; + const recommendedProvider = + (defaultProvider && providers[defaultProvider]?.configured ? defaultProvider : "") || + Object.keys(providers).find((id) => providers[id]?.configured) || + "gemini"; + + return new Response(JSON.stringify({ providers, defaultProvider, recommendedProvider }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } +} +''', +) + +replace_required( + "frontend/app/api/launch_kit/route.js", + 'import { generateStudioPackage } from "../../../lib/ai/generateStudioPackage";', + 'import { generateStudioPackage } from "../../../lib/ai/generateStudioPackage";\nimport { assertModelGenerationProvider } from "../../../lib/ai/generationPolicy.mjs";', + "launch route generation policy import", +) +replace_required( + "frontend/app/api/launch_kit/route.js", + ' const generator = normalizeTextInput(body.generator) || "template";\n const providerApiKey = normalizeTextInput(body.providerApiKey);', + ''' const requestedGenerator = normalizeTextInput(body.generator) || normalizeTextInput(process.env.DEFAULT_MODEL_PROVIDER); + let generator; + try { + generator = assertModelGenerationProvider(requestedGenerator); + } catch (error) { + return new Response(JSON.stringify({ ok: false, error: error.message }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + const providerApiKey = normalizeTextInput(body.providerApiKey);''', + "launch route provider requirement", +) +replace_required( + "frontend/app/api/launch_kit/route.js", + ' if (generator !== "template" && generator !== "offline" && !providerApiKey) {', + ' if (!providerApiKey) {', + "hosted BYO provider gate", +) + +write( + "frontend/app/api/provider_test/route.js", + '''import { requireOwnerAccess } from "../_auth"; +import { generateText } from "../../../lib/ai/generateText"; +import { assertModelGenerationProvider } from "../../../lib/ai/generationPolicy.mjs"; + +const OWNER_ONLY_ENDPOINT_PROVIDERS = new Set(["custom", "ollama", "lmstudio"]); + +export async function POST(request) { + let body = {}; + try { + body = await request.json(); + } catch { + body = {}; + } + + let provider; + try { + provider = assertModelGenerationProvider(body.provider); + } catch (error) { + return new Response(JSON.stringify({ ok: false, error: error.message }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const temporaryApiKey = String(body.temporaryApiKey || "").trim(); + const accessError = requireOwnerAccess(request); + if (accessError && (OWNER_ONLY_ENDPOINT_PROVIDERS.has(provider) || !temporaryApiKey)) { + return accessError; + } + + const modelName = String(body.modelName || "").trim(); + const baseUrl = String(body.baseUrl || "").trim(); + const config = { apiKey: temporaryApiKey, baseUrl, modelName, maxTokens: 64 }; + + try { + await generateText({ + provider, + prompt: 'Return only JSON: {"ok":true}', + modelOverride: modelName, + config, + }); + return new Response(JSON.stringify({ + ok: true, + provider, + configured: true, + modelUsed: modelName, + message: "Connection successful.", + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } catch (error) { + return new Response(JSON.stringify({ + ok: false, + provider, + modelUsed: modelName, + error: error.message, + setupHint: getSetupHint(provider), + }), { status: 400, headers: { "Content-Type": "application/json" } }); + } +} + +function getSetupHint(provider) { + switch (provider) { + case "gemini": return "Add a temporary Gemini key or configure GEMINI_API_KEY."; + case "openai": return "Add a temporary OpenAI key or configure OPENAI_API_KEY."; + case "claude": return "Add a temporary Anthropic key or configure ANTHROPIC_API_KEY."; + case "groq": return "Add a temporary Groq key or configure GROQ_API_KEY."; + case "openrouter": return "Add a temporary OpenRouter key or configure OPENROUTER_API_KEY."; + case "custom": return "Configure a trusted OpenAI-compatible base URL and credentials."; + case "ollama": return "Run SignalFlow where it can reach Ollama and load the requested model."; + case "lmstudio": return "Run SignalFlow where it can reach LM Studio and load the requested model."; + default: return "Configure the selected model provider."; + } +} +''', +) + +NORMALIZE = "frontend/lib/package/normalizePackage.js" +replace_required( + NORMALIZE, + '/** Ensures every provider response is complete and safe for the active UI. */\nexport function normalizePackage(rawPackage, inputs) {\n 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);''', + "strict normalization baseline", +) +replace_required( + NORMALIZE, + ' if (!normalized.media.videoPrompt) {\n normalized.media.videoPrompt = buildVideoPrompt(normalized);\n }', + ' if (allowTemplateFallback && !normalized.media.videoPrompt) {\n normalized.media.videoPrompt = buildVideoPrompt(normalized);\n }', + "strict media fallback", +) + +GEN = "frontend/lib/ai/generateStudioPackage.js" +replace_required( + GEN, + 'import { PROVIDERS } from "./types";', + 'import { PROVIDERS } from "./types";\nimport { assertModelGenerationProvider } from "./generationPolicy.mjs";', + "generation policy import", +) +replace_required( + GEN, + ' const normalized = normalizePackage({ posts: { [packageKey]: rawDraft } }, generationInputs);', + ' const normalized = normalizePackage({ posts: { [packageKey]: rawDraft } }, generationInputs, { allowTemplateFallback: false });', + "strict destination normalization", +) +replace_required( + GEN, + 'async function generateDestination({', + '''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({''', + "empty destination structures", +) +replace_required( + GEN, + ''' } catch (error) { + const fallback = normalizePackage({}, generationInputs).posts[packageKey]; + return { + channel, + packageKey, + draft: fallback, + status: { + status: "template_fallback", + attempts: firstDraft ? 1 : 0, + qualityScore: firstQuality?.score ?? null, + issues: [`Destination generation failed: ${error.message}`], + }, + }; + }''', + ''' } catch (error) { + return { + channel, + packageKey, + draft: emptyDestinationDraft(channel), + status: { + status: "failed", + attempts: firstDraft ? 1 : 0, + qualityScore: firstQuality?.score ?? null, + issues: [`Destination generation failed: ${error.message}`], + }, + }; + }''', + "destination failure without template", +) +replace_required(GEN, ' generator = "template",', ' generator: requestedGenerator = "",', "strict generator input") +replace_required( + GEN, + ' const generationInputs = {', + ' const generator = assertModelGenerationProvider(requestedGenerator);\n\n const generationInputs = {', + "resolved generation provider", +) +replace_between( + GEN, + ' if (generator === "prompt") {', + ' const temporaryKey = Boolean(config?.apiKey);', + ''' const providerMeta = PROVIDERS[generator]; + if (!providerMeta) { + throw new Error(`Unsupported model provider: ${generator}.`); + } + +''', + "retired prompt and template routes", +) +replace_between( + GEN, + ' if (!configured) {', + ' const modelOverride = model_name || config?.modelName || providerMeta.defaultModel;', + ''' if (!configured) { + const requirement = generator === "custom" + ? "an OpenAI-compatible base URL" + : (providerMeta.requiredEnv || []).join(" or ") || "provider credentials"; + throw new Error(`${providerMeta.label} is not configured. Add ${requirement} or a temporary personal key.`); + } + +''', + "unconfigured provider failure", +) +replace_required( + GEN, + ' const pkg = normalizePackage(rawBrief, generationInputs);\n pkg.strategy.destinationAngles = rawBrief?.strategy?.destinationAngles || {};', + ' const pkg = normalizePackage(rawBrief, generationInputs, { allowTemplateFallback: false });\n pkg.strategy.destinationAngles = rawBrief?.strategy?.destinationAngles || {};\n pkg.posts = emptyPackagePosts();', + "strict campaign package", +) +replace_required( + GEN, + ' pkg.posts[result.packageKey] = result.draft;\n generationStatus[result.channel] = result.status;\n if (result.status.status === "template_fallback") {\n generationWarnings.push(`${result.channel}: model generation failed, so deterministic fallback copy is shown.`);\n } else if (result.status.status === "needs_review") {', + ' if (result.status.status !== "failed") pkg.posts[result.packageKey] = result.draft;\n generationStatus[result.channel] = result.status;\n if (result.status.status === "failed") {\n generationWarnings.push(`${result.channel}: model generation failed and no substitute copy was inserted.`);\n } else if (result.status.status === "needs_review") {', + "honest destination status", +) +replace_required( + GEN, + ' pkg.generation = {', + ''' 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 = {''', + "all destination failure gate", +) +replace_required( + GEN, + ' const fallbackUsed = generatedDestinations.some((item) => item.status.status === "template_fallback");', + ' const partialFailureUsed = failedDestinations.length > 0;', + "partial failure flag", +) +replace_required( + GEN, + ' fallbackUsed,\n partialFallbackUsed: fallbackUsed,', + ' fallbackUsed: false,\n partialFailureUsed,', + "no fallback response", +) +replace_between( + GEN, + ' } catch (error) {\n const result = templateResult(', + '\n }\n}', + ''' } catch (error) { + throw new Error(`${providerMeta.label} campaign generation failed: ${error.message}`); +''', + "strategy failure without template", +) + +CI = ".github/workflows/ci.yml" +ci = read(CI) +if "mcp-tests:" not in ci: + marker = "jobs:\n" + job = '''jobs: + mcp-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Run MCP protocol and tool tests + working-directory: mcp + run: npm test + +''' + if marker not in ci: + raise RuntimeError("Missing CI jobs marker") + ci = ci.replace(marker, job, 1) + write(CI, ci) + +print("Applied Studio product, strict model generation, and MCP integration changes.") From 130bd1718844b36470edaa9f9fc264e28a156fd3 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:08:50 +0530 Subject: [PATCH 14/29] chore: apply tested Studio and MCP product migration --- .github/workflows/ci.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 870e476..3603735 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,40 @@ on: pull_request: branches: [main, master] +permissions: + contents: write + jobs: + apply-studio-product-migration: + if: github.event_name == 'pull_request' && github.event.pull_request.head.ref == 'product/studio-end-to-end-mcp' && github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: product/studio-end-to-end-mcp + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Apply Studio, strict generation, and MCP migration + run: python .github/scripts/apply-studio-product-mcp.py + - name: Remove one-time migration and commit product source + run: | + python - <<'PY' + from pathlib import Path + path = Path('.github/workflows/ci.yml') + text = path.read_text() + start = text.index(' apply-studio-product-migration:') + end = text.index(' python-tests:', start) + path.write_text(text[:start] + text[end:]) + PY + rm .github/scripts/apply-studio-product-mcp.py + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "feat: rebuild Studio model workflow and add MCP server" + git push origin HEAD:product/studio-end-to-end-mcp + python-tests: runs-on: ubuntu-latest steps: From 264e6b95026ed319732848f417fb585e25844b9f Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:12:25 +0530 Subject: [PATCH 15/29] chore: separate product migration from workflow cleanup --- .github/workflows/ci.yml | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3603735..f8e0263 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,18 @@ permissions: contents: write jobs: + mcp-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Run MCP protocol and tool tests + working-directory: mcp + run: npm test + apply-studio-product-migration: if: github.event_name == 'pull_request' && github.event.pull_request.head.ref == 'product/studio-end-to-end-mcp' && github.actor != 'github-actions[bot]' runs-on: ubuntu-latest @@ -23,21 +35,12 @@ jobs: python-version: "3.10" - name: Apply Studio, strict generation, and MCP migration run: python .github/scripts/apply-studio-product-mcp.py - - name: Remove one-time migration and commit product source + - name: Commit only migrated product source run: | - python - <<'PY' - from pathlib import Path - path = Path('.github/workflows/ci.yml') - text = path.read_text() - start = text.index(' apply-studio-product-migration:') - end = text.index(' python-tests:', start) - path.write_text(text[:start] + text[end:]) - PY - rm .github/scripts/apply-studio-product-mcp.py git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "feat: rebuild Studio model workflow and add MCP server" + git add frontend + git commit -m "feat: rebuild Studio model workflow and strict generation" git push origin HEAD:product/studio-end-to-end-mcp python-tests: From 4a1ab330e23df5a50a8ea9fdbc3ef1fd44ff7f41 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:42:35 +0000 Subject: [PATCH 16/29] feat: rebuild Studio model workflow and strict generation --- frontend/app/api/launch_kit/route.js | 14 +- frontend/app/api/provider_status/route.js | 29 ++- frontend/app/api/provider_test/route.js | 116 +++++------ frontend/app/layout.js | 10 +- frontend/app/page.js | 243 +++++++++++++++------- frontend/lib/ai/generateStudioPackage.js | 135 +++++------- frontend/lib/package/normalizePackage.js | 48 ++++- 7 files changed, 357 insertions(+), 238 deletions(-) diff --git a/frontend/app/api/launch_kit/route.js b/frontend/app/api/launch_kit/route.js index 635ffa5..f4b99ea 100644 --- a/frontend/app/api/launch_kit/route.js +++ b/frontend/app/api/launch_kit/route.js @@ -8,6 +8,7 @@ import { ingestGitHubRepo } from "../../../lib/context/github"; import { ingestLocalRepo } from "../../../lib/context/localRepo"; import { fetchUrlContent } from "../../../lib/context/linkFetcher"; import { generateStudioPackage } from "../../../lib/ai/generateStudioPackage"; +import { assertModelGenerationProvider } from "../../../lib/ai/generationPolicy.mjs"; export const maxDuration = 60; @@ -20,11 +21,20 @@ export async function POST(request) { const body = parsedBody && typeof parsedBody === "object" && !Array.isArray(parsedBody) ? parsedBody : {}; - const generator = normalizeTextInput(body.generator) || "template"; + const requestedGenerator = normalizeTextInput(body.generator) || normalizeTextInput(process.env.DEFAULT_MODEL_PROVIDER); + let generator; + try { + generator = assertModelGenerationProvider(requestedGenerator); + } catch (error) { + return new Response(JSON.stringify({ ok: false, error: error.message }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } const providerApiKey = normalizeTextInput(body.providerApiKey); if (!isOwner && Boolean(process.env.SIGNALFLOW_ACCESS_KEY)) { - if (generator !== "template" && generator !== "offline" && !providerApiKey) { + if (!providerApiKey) { return new Response( JSON.stringify({ error: "This hosted workspace is private. Enter the owner's access key or supply your own personal API key in settings to use cloud providers.", diff --git a/frontend/app/api/provider_status/route.js b/frontend/app/api/provider_status/route.js index f71daac..cd5216b 100644 --- a/frontend/app/api/provider_status/route.js +++ b/frontend/app/api/provider_status/route.js @@ -1,20 +1,27 @@ import { getProviderConfigurationStatus } from "../../../lib/ai/providerStatus"; +import { MODEL_GENERATION_PROVIDERS } from "../../../lib/ai/generationPolicy.mjs"; -export async function GET(request) { +export async function GET() { try { - const status = getProviderConfigurationStatus(); - const defaultProvider = process.env.DEFAULT_MODEL_PROVIDER || ""; - return new Response(JSON.stringify({ - providers: status, - defaultProvider: defaultProvider - }), { + const allProviders = getProviderConfigurationStatus(); + const providers = Object.fromEntries( + Object.entries(allProviders).filter(([id]) => MODEL_GENERATION_PROVIDERS.has(id)), + ); + const requestedDefault = String(process.env.DEFAULT_MODEL_PROVIDER || "").trim().toLowerCase(); + const defaultProvider = MODEL_GENERATION_PROVIDERS.has(requestedDefault) ? requestedDefault : ""; + const recommendedProvider = + (defaultProvider && providers[defaultProvider]?.configured ? defaultProvider : "") || + Object.keys(providers).find((id) => providers[id]?.configured) || + "gemini"; + + return new Response(JSON.stringify({ providers, defaultProvider, recommendedProvider }), { status: 200, - headers: { "Content-Type": "application/json" } + headers: { "Content-Type": "application/json" }, }); - } catch (err) { - return new Response(JSON.stringify({ error: err.message }), { + } catch (error) { + return new Response(JSON.stringify({ error: error.message }), { status: 500, - headers: { "Content-Type": "application/json" } + headers: { "Content-Type": "application/json" }, }); } } diff --git a/frontend/app/api/provider_test/route.js b/frontend/app/api/provider_test/route.js index d37ac98..f218847 100644 --- a/frontend/app/api/provider_test/route.js +++ b/frontend/app/api/provider_test/route.js @@ -1,88 +1,72 @@ import { requireOwnerAccess } from "../_auth"; import { generateText } from "../../../lib/ai/generateText"; +import { assertModelGenerationProvider } from "../../../lib/ai/generationPolicy.mjs"; + +const OWNER_ONLY_ENDPOINT_PROVIDERS = new Set(["custom", "ollama", "lmstudio"]); -/** - * Endpoint to test connectivity to a model provider. - * Accepts optional temporary API keys that are used only for this request and never persisted. - */ export async function POST(request) { - const accessError = requireOwnerAccess(request); - if (accessError) { - return accessError; + let body = {}; + try { + body = await request.json(); + } catch { + body = {}; } + let provider; try { - const { provider, modelName, baseUrl, temporaryApiKey } = await request.json(); - - if (!provider) { - return new Response(JSON.stringify({ ok: false, error: "Missing provider parameter" }), { - status: 400, - headers: { "Content-Type": "application/json" } - }); - } + provider = assertModelGenerationProvider(body.provider); + } catch (error) { + return new Response(JSON.stringify({ ok: false, error: error.message }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } - const testPrompt = 'Return only JSON: {"ok":true}'; - const config = { - apiKey: temporaryApiKey || "", - baseUrl: baseUrl || "", - modelName: modelName || "" - }; + const temporaryApiKey = String(body.temporaryApiKey || "").trim(); + const accessError = requireOwnerAccess(request); + if (accessError && (OWNER_ONLY_ENDPOINT_PROVIDERS.has(provider) || !temporaryApiKey)) { + return accessError; + } - let responseText; - try { - responseText = await generateText({ - provider, - prompt: testPrompt, - modelOverride: modelName, - config - }); - } catch (err) { - return new Response(JSON.stringify({ - ok: false, - provider, - modelUsed: modelName, - error: err.message, - setupHint: getSetupHint(provider) - }), { - status: 200, - headers: { "Content-Type": "application/json" } - }); - } + const modelName = String(body.modelName || "").trim(); + const baseUrl = String(body.baseUrl || "").trim(); + const config = { apiKey: temporaryApiKey, baseUrl, modelName, maxTokens: 64 }; + try { + await generateText({ + provider, + prompt: 'Return only JSON: {"ok":true}', + modelOverride: modelName, + config, + }); return new Response(JSON.stringify({ ok: true, provider, configured: true, modelUsed: modelName, - message: "Connection successful." - }), { - status: 200, - headers: { "Content-Type": "application/json" } - }); - - } catch (err) { - return new Response(JSON.stringify({ ok: false, error: err.message }), { - status: 500, - headers: { "Content-Type": "application/json" } - }); + message: "Connection successful.", + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } catch (error) { + return new Response(JSON.stringify({ + ok: false, + provider, + modelUsed: modelName, + error: error.message, + setupHint: getSetupHint(provider), + }), { status: 400, headers: { "Content-Type": "application/json" } }); } } function getSetupHint(provider) { switch (provider) { - case "gemini": - return "Add GEMINI_API_KEY in .env.local or Vercel Environment Variables."; - case "groq": - return "Add GROQ_API_KEY in .env.local or Vercel Environment Variables."; - case "openrouter": - return "Add OPENROUTER_API_KEY in .env.local or Vercel Environment Variables."; - case "custom": - return "Add CUSTOM_OPENAI_BASE_URL and CUSTOM_OPENAI_API_KEY in .env.local or Vercel."; - case "ollama": - return "Make sure Ollama is running locally (default: http://localhost:11434) and the requested model is pulled."; - case "lmstudio": - return "Make sure LM Studio is running locally (default: http://localhost:1234) and the requested model is loaded."; - default: - return "Configure the required environment variables or endpoint URL."; + case "gemini": return "Add a temporary Gemini key or configure GEMINI_API_KEY."; + case "openai": return "Add a temporary OpenAI key or configure OPENAI_API_KEY."; + case "claude": return "Add a temporary Anthropic key or configure ANTHROPIC_API_KEY."; + case "groq": return "Add a temporary Groq key or configure GROQ_API_KEY."; + case "openrouter": return "Add a temporary OpenRouter key or configure OPENROUTER_API_KEY."; + case "custom": return "Configure a trusted OpenAI-compatible base URL and credentials."; + case "ollama": return "Run SignalFlow where it can reach Ollama and load the requested model."; + case "lmstudio": return "Run SignalFlow where it can reach LM Studio and load the requested model."; + default: return "Configure the selected model provider."; } } diff --git a/frontend/app/layout.js b/frontend/app/layout.js index 78dc863..2abce53 100644 --- a/frontend/app/layout.js +++ b/frontend/app/layout.js @@ -5,6 +5,7 @@ import "../app/living-ui-tuning.css"; import "../app/professional-polish.css"; import "../app/app-workspace.css"; import "../app/ui-containment.css"; +import "../app/studio-product.css"; import SessionBridge from "../components/SessionBridge"; const siteUrl = "https://signal-flow-studio.vercel.app"; @@ -32,9 +33,9 @@ const faq = [ "Saved campaigns remain in the current browser. Social OAuth tokens are encrypted in HTTP-only cookies and are not exposed to page JavaScript.", }, { - question: "Can I use my own AI model or no AI at all?", + question: "Can I bring my own model provider?", answer: - "Yes. SignalFlow includes a deterministic local template route and supports Gemini, OpenAI, Claude, Groq, Ollama, LM Studio, and custom OpenAI-compatible endpoints.", + "Yes. SignalFlow supports Gemini, OpenAI, Claude, OpenRouter, Groq, Ollama, LM Studio, and custom OpenAI-compatible endpoints. Campaign generation requires a real model route.", }, ]; @@ -76,7 +77,7 @@ const schema = { license: `${repositoryUrl}/blob/master/LICENSE`, creator: { "@id": `${siteUrl}/#organization` }, description: - "SignalFlow Studio turns product notes, public links, GitHub repository context, and uploaded text files into editable channel-specific campaign drafts. It supports local templates, bring-your-own-model generation, browser-local campaign saving, Markdown and JSON exports, and confirmed-only publishing through configured official connectors.", + "SignalFlow Studio turns product notes, public links, GitHub repository context, and uploaded text files into editable channel-specific campaign drafts. It supports real model-provider generation, browser-local campaign saving, Markdown and JSON exports, an MCP server for AI agents, and confirmed-only publishing through configured official connectors.", keywords: [ "campaign creation software", "product launch content generator", @@ -91,7 +92,8 @@ const schema = { "Create editable drafts for twelve publishing destinations", "Extract context from public links and GitHub repositories", "Read uploaded text, Markdown, CSV, JSON, and code files in the browser", - "Use deterministic local templates without an API key", + "Use a real configured or bring-your-own model provider", + "Create campaigns directly through the SignalFlow MCP server", "Use Gemini, OpenAI, Claude, Groq, Ollama, LM Studio, or custom compatible endpoints", "Save campaign packages in the current browser", "Export approved campaigns as Markdown and JSON", diff --git a/frontend/app/page.js b/frontend/app/page.js index 487dee4..b982124 100644 --- a/frontend/app/page.js +++ b/frontend/app/page.js @@ -8,6 +8,10 @@ import { restoreSourceSnapshot, selectAcceptedFiles, } from "../lib/studio/clientReliability.mjs"; +import { + evaluateProviderReadiness, + pickRecommendedProvider, +} from "../lib/studio/providerReadiness.mjs"; const LEGACY_ACCESS_TOKEN_KEY = "signalflow_owner_token"; const LIBRARY_KEY = "signalflow_recovery_library"; @@ -147,14 +151,14 @@ const CHANNEL_GROUPS = [ ]; const PROVIDERS = [ - { id: "template", label: "Local sample template", hint: "Deterministic sample copy for testing the workflow. Choose a model provider for production-quality content." }, - { id: "gemini", label: "Gemini", hint: "Paste your Gemini API key or use the server configuration." }, - { id: "openai", label: "OpenAI", hint: "Paste your OpenAI key or use the server configuration." }, - { id: "claude", label: "Claude", hint: "Paste your Anthropic key or use the server configuration." }, - { id: "groq", label: "Groq", hint: "Fast hosted generation with your own key." }, - { id: "ollama", label: "Ollama", hint: "Runs against your local Ollama endpoint." }, - { id: "lmstudio", label: "LM Studio", hint: "Runs against your local LM Studio endpoint." }, - { id: "custom", label: "Custom provider", hint: "Use an OpenAI-compatible endpoint and model." }, + { id: "gemini", label: "Gemini", hint: "Use a temporary Google AI Studio key or the securely configured server route." }, + { id: "openai", label: "OpenAI", hint: "Use a temporary OpenAI key or the securely configured server route." }, + { id: "claude", label: "Claude", hint: "Use a temporary Anthropic key or the securely configured server route." }, + { id: "openrouter", label: "OpenRouter", hint: "Route generation through a model available in your OpenRouter account." }, + { id: "groq", label: "Groq", hint: "Use a Groq key for fast hosted generation." }, + { id: "custom", label: "Custom gateway", hint: "Use an OpenAI-compatible endpoint and model." }, + { id: "ollama", label: "Ollama", hint: "Use a reachable Ollama endpoint in local or trusted self-hosted deployments." }, + { id: "lmstudio", label: "LM Studio", hint: "Use a reachable LM Studio endpoint in local or trusted self-hosted deployments." }, ]; const FAQS = [ @@ -179,9 +183,9 @@ const FAQS = [ "Saved campaigns remain in the current browser. Social OAuth tokens are encrypted in HTTP-only cookies and are not exposed to page JavaScript.", }, { - question: "Can I use my own AI model or no AI at all?", + question: "Can I bring my own model provider?", answer: - "Yes. SignalFlow includes a deterministic local template route and supports Gemini, OpenAI, Claude, Groq, Ollama, LM Studio, and custom OpenAI-compatible endpoints.", + "Yes. SignalFlow supports Gemini, OpenAI, Claude, OpenRouter, Groq, Ollama, LM Studio, and custom OpenAI-compatible endpoints. Campaign generation requires a real model route.", }, ]; @@ -526,7 +530,7 @@ export default function Home() { audience: "Founders, builders, and early users", links: "", repo: "", - provider: "template", + provider: "gemini", apiKey: "", model: "", baseUrl: "", @@ -542,9 +546,11 @@ export default function Home() { const [library, setLibrary] = useState([]); const [connections, setConnections] = useState({}); const [connectionsLoading, setConnectionsLoading] = useState(false); + const [providerStatuses, setProviderStatuses] = useState({}); + const [providerStatusLoading, setProviderStatusLoading] = useState(true); + const [providerTest, setProviderTest] = useState({ status: "idle", message: "" }); const [accessToken, setAccessToken] = useState(""); const [ownerKey, setOwnerKey] = useState(""); - const [showAdvanced, setShowAdvanced] = useState(false); const [publishOptions, setPublishOptions] = useState({ reddit: { subreddit: "", title: "" }, }); @@ -582,7 +588,14 @@ export default function Home() { form.repo.trim(), ...documentText, ].filter(Boolean).length; - const composeReady = sourceSignals > 0 && channels.length > 0; + const providerReadiness = evaluateProviderReadiness({ + provider: form.provider, + apiKey: form.apiKey, + baseUrl: form.baseUrl, + status: providerStatuses[form.provider], + }); + const sourceAndChannelsReady = sourceSignals > 0 && channels.length > 0; + const composeReady = sourceAndChannelsReady && providerReadiness.ready; const connectedOfficialCount = Array.from(OFFICIAL_CONNECTORS).filter( (id) => connections[id]?.connected && !connections[id]?.expired, ).length; @@ -612,6 +625,7 @@ export default function Home() { useEffect(() => { if (!entered) return; refreshConnections(); + refreshProviderStatus(); }, [entered, accessToken]); useEffect(() => { @@ -627,6 +641,55 @@ export default function Home() { }); }, [entered, section]); + async function refreshProviderStatus() { + setProviderStatusLoading(true); + try { + const response = await fetch("/api/provider_status"); + const data = await readJsonResponse(response, "SignalFlow could not read model provider status."); + const statuses = data.providers || {}; + setProviderStatuses(statuses); + const recommended = pickRecommendedProvider({ + defaultProvider: data.defaultProvider, + statuses, + fallback: form.provider, + }); + setForm((previous) => { + if (previous.apiKey.trim() || previous.baseUrl.trim() || statuses[previous.provider]?.configured) return previous; + return previous.provider === recommended ? previous : { ...previous, provider: recommended }; + }); + } catch { + setProviderStatuses({}); + } finally { + setProviderStatusLoading(false); + } + } + + async function testProviderConnection() { + if (!providerReadiness.ready) { + setProviderTest({ status: "error", message: providerReadiness.reason }); + return; + } + setProviderTest({ status: "testing", message: "Testing model route…" }); + try { + const response = await fetch("/api/provider_test", { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ + provider: form.provider, + modelName: form.model.trim(), + baseUrl: form.baseUrl.trim(), + temporaryApiKey: form.apiKey.trim(), + }), + }); + const data = await readJsonResponse(response, "SignalFlow returned an unreadable provider test response."); + if (!response.ok || !data.ok) throw new Error(data.error || "Model route test failed."); + setProviderTest({ status: "success", message: data.message || "Model route connected successfully." }); + void refreshProviderStatus(); + } catch (error) { + setProviderTest({ status: "error", message: error.message }); + } + } + async function syncOwnerSession() { try { const response = await fetch("/api/session"); @@ -771,6 +834,12 @@ export default function Home() { return; } + if (!providerReadiness.ready) { + setMessage({ type: "error", text: providerReadiness.reason }); + navigateStudioFlow("destinations"); + return; + } + setBusy(true); setMessage(null); try { @@ -809,10 +878,16 @@ export default function Home() { setPosts(generatedPosts); setActiveChannel(channels.find((channel) => generatedPosts[channel]) || channels[0]); setStage("review"); + if (data.fallbackUsed) { + throw new Error("SignalFlow refused the response because it contained retired template fallback content."); + } + const failedChannels = Object.entries(data.generation_status || {}) + .filter(([, item]) => item?.status === "failed") + .map(([channel]) => channel); setMessage({ - type: data.fallbackUsed ? "warning" : "success", - text: data.fallbackUsed - ? "Campaign created with a fallback route. Review the generation note before publishing." + type: failedChannels.length ? "warning" : "success", + text: failedChannels.length + ? `Campaign generated with ${data.providerUsed || provider.label}; ${failedChannels.join(", ")} failed without template substitution.` : `Campaign generated with ${data.providerUsed || provider.label}.`, }); } catch (error) { @@ -1143,8 +1218,8 @@ export default function Home() { ))}
- - {accessToken ? "Owner session" : "Local mode"} + + {providerReadiness.ready ? `${accessToken ? "Owner · " : ""}${provider.label}` : "Model setup needed"}
@@ -1361,17 +1436,39 @@ export default function Home() { - + {stage === "destinations" ? (
@@ -1435,16 +1532,16 @@ export default function Home() {
Campaign readiness -

{composeReady ? "Ready to shape the campaign." : "Bring one strong source signal."}

+

{!sourceAndChannelsReady ? "Choose source and destinations." : providerReadiness.ready ? "Ready to shape the campaign." : "Connect a model route."}

- {composeReady ? "Ready" : "Needs source"} + {composeReady ? "Ready" : providerReadiness.ready ? "Needs source" : "Needs model"}

- {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/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/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); } From add7751f40ede8c214768817a6e2fb9a4e5b2855 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:14:12 +0530 Subject: [PATCH 17/29] ci: validate Studio, MCP, frontend, and backend together --- .github/workflows/ci.yml | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8e0263..769f8b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,9 +6,6 @@ on: pull_request: branches: [main, master] -permissions: - contents: write - jobs: mcp-tests: runs-on: ubuntu-latest @@ -22,27 +19,6 @@ jobs: working-directory: mcp run: npm test - apply-studio-product-migration: - if: github.event_name == 'pull_request' && github.event.pull_request.head.ref == 'product/studio-end-to-end-mcp' && github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: product/studio-end-to-end-mcp - fetch-depth: 0 - - uses: actions/setup-python@v5 - with: - python-version: "3.10" - - name: Apply Studio, strict generation, and MCP migration - run: python .github/scripts/apply-studio-product-mcp.py - - name: Commit only migrated product source - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add frontend - git commit -m "feat: rebuild Studio model workflow and strict generation" - git push origin HEAD:product/studio-end-to-end-mcp - python-tests: runs-on: ubuntu-latest steps: From 9d9c6d41559fd1259bafea4c3487480f669d76bc Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:14:44 +0530 Subject: [PATCH 18/29] chore: remove completed one-time product migration --- .github/scripts/apply-studio-product-mcp.py | 724 -------------------- 1 file changed, 724 deletions(-) delete mode 100644 .github/scripts/apply-studio-product-mcp.py diff --git a/.github/scripts/apply-studio-product-mcp.py b/.github/scripts/apply-studio-product-mcp.py deleted file mode 100644 index 5fb89d8..0000000 --- a/.github/scripts/apply-studio-product-mcp.py +++ /dev/null @@ -1,724 +0,0 @@ -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] - - -def read(path): - return (ROOT / path).read_text() - - -def write(path, value): - (ROOT / path).write_text(value) - - -def replace_required(path, old, new, label): - text = read(path) - if old not in text: - raise RuntimeError(f"Missing {label} in {path}") - write(path, text.replace(old, new, 1)) - - -def replace_between(path, start, end, replacement, label): - text = read(path) - start_index = text.find(start) - end_index = text.find(end, start_index + len(start)) - if start_index < 0 or end_index < 0: - raise RuntimeError(f"Missing {label} boundaries in {path}") - write(path, text[:start_index] + replacement + text[end_index:]) - - -PAGE = "frontend/app/page.js" - -replace_required( - PAGE, - '} from "../lib/studio/clientReliability.mjs";', - '} from "../lib/studio/clientReliability.mjs";\nimport {\n evaluateProviderReadiness,\n pickRecommendedProvider,\n} from "../lib/studio/providerReadiness.mjs";', - "provider readiness import", -) - -replace_between( - PAGE, - "const PROVIDERS = [", - "const FAQS = [", - '''const PROVIDERS = [ - { id: "gemini", label: "Gemini", hint: "Use a temporary Google AI Studio key or the securely configured server route." }, - { id: "openai", label: "OpenAI", hint: "Use a temporary OpenAI key or the securely configured server route." }, - { id: "claude", label: "Claude", hint: "Use a temporary Anthropic key or the securely configured server route." }, - { id: "openrouter", label: "OpenRouter", hint: "Route generation through a model available in your OpenRouter account." }, - { id: "groq", label: "Groq", hint: "Use a Groq key for fast hosted generation." }, - { id: "custom", label: "Custom gateway", hint: "Use an OpenAI-compatible endpoint and model." }, - { id: "ollama", label: "Ollama", hint: "Use a reachable Ollama endpoint in local or trusted self-hosted deployments." }, - { id: "lmstudio", label: "LM Studio", hint: "Use a reachable LM Studio endpoint in local or trusted self-hosted deployments." }, -]; - -''', - "model provider list", -) - -replace_required( - PAGE, - 'question: "Can I use my own AI model or no AI at all?",\n answer:\n "Yes. SignalFlow includes a deterministic local template route and supports Gemini, OpenAI, Claude, Groq, Ollama, LM Studio, and custom OpenAI-compatible endpoints.",', - 'question: "Can I bring my own model provider?",\n answer:\n "Yes. SignalFlow supports Gemini, OpenAI, Claude, OpenRouter, Groq, Ollama, LM Studio, and custom OpenAI-compatible endpoints. Campaign generation requires a real model route.",', - "landing FAQ model answer", -) - -replace_required(PAGE, 'provider: "template",', 'provider: "gemini",', "default provider") -replace_required( - PAGE, - ' const [connectionsLoading, setConnectionsLoading] = useState(false);\n const [accessToken, setAccessToken] = useState("");', - ' const [connectionsLoading, setConnectionsLoading] = useState(false);\n const [providerStatuses, setProviderStatuses] = useState({});\n const [providerStatusLoading, setProviderStatusLoading] = useState(true);\n const [providerTest, setProviderTest] = useState({ status: "idle", message: "" });\n const [accessToken, setAccessToken] = useState("");', - "provider status state", -) -replace_required(PAGE, ' const [showAdvanced, setShowAdvanced] = useState(false);\n', '', "retired advanced state") - -replace_required( - PAGE, - ' const composeReady = sourceSignals > 0 && channels.length > 0;', - ''' const providerReadiness = evaluateProviderReadiness({ - provider: form.provider, - apiKey: form.apiKey, - baseUrl: form.baseUrl, - status: providerStatuses[form.provider], - }); - const sourceAndChannelsReady = sourceSignals > 0 && channels.length > 0; - const composeReady = sourceAndChannelsReady && providerReadiness.ready;''', - "provider-aware campaign readiness", -) - -replace_required( - PAGE, - ' useEffect(() => {\n if (!entered) return;\n refreshConnections();\n }, [entered, accessToken]);', - ' useEffect(() => {\n if (!entered) return;\n refreshConnections();\n refreshProviderStatus();\n }, [entered, accessToken]);', - "provider status refresh effect", -) - -replace_required( - PAGE, - ' async function syncOwnerSession() {', - ''' async function refreshProviderStatus() { - setProviderStatusLoading(true); - try { - const response = await fetch("/api/provider_status"); - const data = await readJsonResponse(response, "SignalFlow could not read model provider status."); - const statuses = data.providers || {}; - setProviderStatuses(statuses); - const recommended = pickRecommendedProvider({ - defaultProvider: data.defaultProvider, - statuses, - fallback: form.provider, - }); - setForm((previous) => { - if (previous.apiKey.trim() || previous.baseUrl.trim() || statuses[previous.provider]?.configured) return previous; - return previous.provider === recommended ? previous : { ...previous, provider: recommended }; - }); - } catch { - setProviderStatuses({}); - } finally { - setProviderStatusLoading(false); - } - } - - async function testProviderConnection() { - if (!providerReadiness.ready) { - setProviderTest({ status: "error", message: providerReadiness.reason }); - return; - } - setProviderTest({ status: "testing", message: "Testing model route…" }); - try { - const response = await fetch("/api/provider_test", { - method: "POST", - headers: authHeaders({ "Content-Type": "application/json" }), - body: JSON.stringify({ - provider: form.provider, - modelName: form.model.trim(), - baseUrl: form.baseUrl.trim(), - temporaryApiKey: form.apiKey.trim(), - }), - }); - const data = await readJsonResponse(response, "SignalFlow returned an unreadable provider test response."); - if (!response.ok || !data.ok) throw new Error(data.error || "Model route test failed."); - setProviderTest({ status: "success", message: data.message || "Model route connected successfully." }); - void refreshProviderStatus(); - } catch (error) { - setProviderTest({ status: "error", message: error.message }); - } - } - - async function syncOwnerSession() {''', - "provider functions", -) - -replace_required( - PAGE, - ' setBusy(true);\n setMessage(null);', - ''' if (!providerReadiness.ready) { - setMessage({ type: "error", text: providerReadiness.reason }); - navigateStudioFlow("destinations"); - return; - } - - setBusy(true); - setMessage(null);''', - "provider guard before generation", -) - -replace_required( - PAGE, - ''' setMessage({ - type: data.fallbackUsed ? "warning" : "success", - text: data.fallbackUsed - ? "Campaign created with a fallback route. Review the generation note before publishing." - : `Campaign generated with ${data.providerUsed || provider.label}.`, - });''', - ''' if (data.fallbackUsed) { - throw new Error("SignalFlow refused the response because it contained retired template fallback content."); - } - const failedChannels = Object.entries(data.generation_status || {}) - .filter(([, item]) => item?.status === "failed") - .map(([channel]) => channel); - setMessage({ - type: failedChannels.length ? "warning" : "success", - text: failedChannels.length - ? `Campaign generated with ${data.providerUsed || provider.label}; ${failedChannels.join(", ")} failed without template substitution.` - : `Campaign generated with ${data.providerUsed || provider.label}.`, - });''', - "honest generation result", -) - -replace_required( - PAGE, - '\n {accessToken ? "Owner session" : "Local mode"}', - '\n {providerReadiness.ready ? `${accessToken ? "Owner · " : ""}${provider.label}` : "Model setup needed"}', - "header model status", -) - -replace_between( - PAGE, - ' -
-
-
Generation engine
-

Choose the model route

-
- - {providerStatusLoading ? "Checking" : providerReadiness.ready ? "Ready" : "Setup needed"} - -
- -
- {PROVIDERS.map((item) => { - const configured = Boolean(providerStatuses[item.id]?.configured); - return ( - - ); - })} -
- -
- - {!['ollama', 'lmstudio'].includes(form.provider) && ( - - )} - {['ollama', 'lmstudio', 'custom'].includes(form.provider) && ( - - )} - -
- -
- -
-

- {providerTest.status === "idle" ? providerReadiness.reason : providerTest.message} -

-

- Temporary keys are sent only with this request. SignalFlow does not save them in the campaign library. -

- - -''', - "always-visible model route panel", -) - -replace_required( - PAGE, - '

{composeReady ? "Ready to shape the campaign." : "Bring one strong source signal."}

', - '

{!sourceAndChannelsReady ? "Choose source and destinations." : providerReadiness.ready ? "Ready to shape the campaign." : "Connect a model route."}

', - "readiness heading", -) -replace_required( - PAGE, - '{composeReady ? "Ready" : "Needs source"}', - '{composeReady ? "Ready" : providerReadiness.ready ? "Needs source" : "Needs model"}', - "readiness badge", -) -replace_required( - PAGE, - '''{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}''', - "readiness description", -) - -LAYOUT = "frontend/app/layout.js" -replace_required( - LAYOUT, - 'import "../app/ui-containment.css";', - 'import "../app/ui-containment.css";\nimport "../app/studio-product.css";', - "final studio stylesheet import", -) -replace_required( - LAYOUT, - 'question: "Can I use my own AI model or no AI at all?",\n answer:\n "Yes. SignalFlow includes a deterministic local template route and supports Gemini, OpenAI, Claude, Groq, Ollama, LM Studio, and custom OpenAI-compatible endpoints.",', - 'question: "Can I bring my own model provider?",\n answer:\n "Yes. SignalFlow supports Gemini, OpenAI, Claude, OpenRouter, Groq, Ollama, LM Studio, and custom OpenAI-compatible endpoints. Campaign generation requires a real model route.",', - "structured FAQ model answer", -) -replace_required( - LAYOUT, - 'It supports local templates, bring-your-own-model generation, browser-local campaign saving, Markdown and JSON exports, and confirmed-only publishing through configured official connectors.', - 'It supports real model-provider generation, browser-local campaign saving, Markdown and JSON exports, an MCP server for AI agents, and confirmed-only publishing through configured official connectors.', - "software description", -) -replace_required( - LAYOUT, - ' "Use deterministic local templates without an API key",\n', - ' "Use a real configured or bring-your-own model provider",\n "Create campaigns directly through the SignalFlow MCP server",\n', - "schema feature list", -) - -write( - "frontend/app/api/provider_status/route.js", - '''import { getProviderConfigurationStatus } from "../../../lib/ai/providerStatus"; -import { MODEL_GENERATION_PROVIDERS } from "../../../lib/ai/generationPolicy.mjs"; - -export async function GET() { - try { - const allProviders = getProviderConfigurationStatus(); - const providers = Object.fromEntries( - Object.entries(allProviders).filter(([id]) => MODEL_GENERATION_PROVIDERS.has(id)), - ); - const requestedDefault = String(process.env.DEFAULT_MODEL_PROVIDER || "").trim().toLowerCase(); - const defaultProvider = MODEL_GENERATION_PROVIDERS.has(requestedDefault) ? requestedDefault : ""; - const recommendedProvider = - (defaultProvider && providers[defaultProvider]?.configured ? defaultProvider : "") || - Object.keys(providers).find((id) => providers[id]?.configured) || - "gemini"; - - return new Response(JSON.stringify({ providers, defaultProvider, recommendedProvider }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } catch (error) { - return new Response(JSON.stringify({ error: error.message }), { - status: 500, - headers: { "Content-Type": "application/json" }, - }); - } -} -''', -) - -replace_required( - "frontend/app/api/launch_kit/route.js", - 'import { generateStudioPackage } from "../../../lib/ai/generateStudioPackage";', - 'import { generateStudioPackage } from "../../../lib/ai/generateStudioPackage";\nimport { assertModelGenerationProvider } from "../../../lib/ai/generationPolicy.mjs";', - "launch route generation policy import", -) -replace_required( - "frontend/app/api/launch_kit/route.js", - ' const generator = normalizeTextInput(body.generator) || "template";\n const providerApiKey = normalizeTextInput(body.providerApiKey);', - ''' const requestedGenerator = normalizeTextInput(body.generator) || normalizeTextInput(process.env.DEFAULT_MODEL_PROVIDER); - let generator; - try { - generator = assertModelGenerationProvider(requestedGenerator); - } catch (error) { - return new Response(JSON.stringify({ ok: false, error: error.message }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); - } - const providerApiKey = normalizeTextInput(body.providerApiKey);''', - "launch route provider requirement", -) -replace_required( - "frontend/app/api/launch_kit/route.js", - ' if (generator !== "template" && generator !== "offline" && !providerApiKey) {', - ' if (!providerApiKey) {', - "hosted BYO provider gate", -) - -write( - "frontend/app/api/provider_test/route.js", - '''import { requireOwnerAccess } from "../_auth"; -import { generateText } from "../../../lib/ai/generateText"; -import { assertModelGenerationProvider } from "../../../lib/ai/generationPolicy.mjs"; - -const OWNER_ONLY_ENDPOINT_PROVIDERS = new Set(["custom", "ollama", "lmstudio"]); - -export async function POST(request) { - let body = {}; - try { - body = await request.json(); - } catch { - body = {}; - } - - let provider; - try { - provider = assertModelGenerationProvider(body.provider); - } catch (error) { - return new Response(JSON.stringify({ ok: false, error: error.message }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); - } - - const temporaryApiKey = String(body.temporaryApiKey || "").trim(); - const accessError = requireOwnerAccess(request); - if (accessError && (OWNER_ONLY_ENDPOINT_PROVIDERS.has(provider) || !temporaryApiKey)) { - return accessError; - } - - const modelName = String(body.modelName || "").trim(); - const baseUrl = String(body.baseUrl || "").trim(); - const config = { apiKey: temporaryApiKey, baseUrl, modelName, maxTokens: 64 }; - - try { - await generateText({ - provider, - prompt: 'Return only JSON: {"ok":true}', - modelOverride: modelName, - config, - }); - return new Response(JSON.stringify({ - ok: true, - provider, - configured: true, - modelUsed: modelName, - message: "Connection successful.", - }), { status: 200, headers: { "Content-Type": "application/json" } }); - } catch (error) { - return new Response(JSON.stringify({ - ok: false, - provider, - modelUsed: modelName, - error: error.message, - setupHint: getSetupHint(provider), - }), { status: 400, headers: { "Content-Type": "application/json" } }); - } -} - -function getSetupHint(provider) { - switch (provider) { - case "gemini": return "Add a temporary Gemini key or configure GEMINI_API_KEY."; - case "openai": return "Add a temporary OpenAI key or configure OPENAI_API_KEY."; - case "claude": return "Add a temporary Anthropic key or configure ANTHROPIC_API_KEY."; - case "groq": return "Add a temporary Groq key or configure GROQ_API_KEY."; - case "openrouter": return "Add a temporary OpenRouter key or configure OPENROUTER_API_KEY."; - case "custom": return "Configure a trusted OpenAI-compatible base URL and credentials."; - case "ollama": return "Run SignalFlow where it can reach Ollama and load the requested model."; - case "lmstudio": return "Run SignalFlow where it can reach LM Studio and load the requested model."; - default: return "Configure the selected model provider."; - } -} -''', -) - -NORMALIZE = "frontend/lib/package/normalizePackage.js" -replace_required( - NORMALIZE, - '/** Ensures every provider response is complete and safe for the active UI. */\nexport function normalizePackage(rawPackage, inputs) {\n 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);''', - "strict normalization baseline", -) -replace_required( - NORMALIZE, - ' if (!normalized.media.videoPrompt) {\n normalized.media.videoPrompt = buildVideoPrompt(normalized);\n }', - ' if (allowTemplateFallback && !normalized.media.videoPrompt) {\n normalized.media.videoPrompt = buildVideoPrompt(normalized);\n }', - "strict media fallback", -) - -GEN = "frontend/lib/ai/generateStudioPackage.js" -replace_required( - GEN, - 'import { PROVIDERS } from "./types";', - 'import { PROVIDERS } from "./types";\nimport { assertModelGenerationProvider } from "./generationPolicy.mjs";', - "generation policy import", -) -replace_required( - GEN, - ' const normalized = normalizePackage({ posts: { [packageKey]: rawDraft } }, generationInputs);', - ' const normalized = normalizePackage({ posts: { [packageKey]: rawDraft } }, generationInputs, { allowTemplateFallback: false });', - "strict destination normalization", -) -replace_required( - GEN, - 'async function generateDestination({', - '''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({''', - "empty destination structures", -) -replace_required( - GEN, - ''' } catch (error) { - const fallback = normalizePackage({}, generationInputs).posts[packageKey]; - return { - channel, - packageKey, - draft: fallback, - status: { - status: "template_fallback", - attempts: firstDraft ? 1 : 0, - qualityScore: firstQuality?.score ?? null, - issues: [`Destination generation failed: ${error.message}`], - }, - }; - }''', - ''' } catch (error) { - return { - channel, - packageKey, - draft: emptyDestinationDraft(channel), - status: { - status: "failed", - attempts: firstDraft ? 1 : 0, - qualityScore: firstQuality?.score ?? null, - issues: [`Destination generation failed: ${error.message}`], - }, - }; - }''', - "destination failure without template", -) -replace_required(GEN, ' generator = "template",', ' generator: requestedGenerator = "",', "strict generator input") -replace_required( - GEN, - ' const generationInputs = {', - ' const generator = assertModelGenerationProvider(requestedGenerator);\n\n const generationInputs = {', - "resolved generation provider", -) -replace_between( - GEN, - ' if (generator === "prompt") {', - ' const temporaryKey = Boolean(config?.apiKey);', - ''' const providerMeta = PROVIDERS[generator]; - if (!providerMeta) { - throw new Error(`Unsupported model provider: ${generator}.`); - } - -''', - "retired prompt and template routes", -) -replace_between( - GEN, - ' if (!configured) {', - ' const modelOverride = model_name || config?.modelName || providerMeta.defaultModel;', - ''' if (!configured) { - const requirement = generator === "custom" - ? "an OpenAI-compatible base URL" - : (providerMeta.requiredEnv || []).join(" or ") || "provider credentials"; - throw new Error(`${providerMeta.label} is not configured. Add ${requirement} or a temporary personal key.`); - } - -''', - "unconfigured provider failure", -) -replace_required( - GEN, - ' const pkg = normalizePackage(rawBrief, generationInputs);\n pkg.strategy.destinationAngles = rawBrief?.strategy?.destinationAngles || {};', - ' const pkg = normalizePackage(rawBrief, generationInputs, { allowTemplateFallback: false });\n pkg.strategy.destinationAngles = rawBrief?.strategy?.destinationAngles || {};\n pkg.posts = emptyPackagePosts();', - "strict campaign package", -) -replace_required( - GEN, - ' pkg.posts[result.packageKey] = result.draft;\n generationStatus[result.channel] = result.status;\n if (result.status.status === "template_fallback") {\n generationWarnings.push(`${result.channel}: model generation failed, so deterministic fallback copy is shown.`);\n } else if (result.status.status === "needs_review") {', - ' if (result.status.status !== "failed") pkg.posts[result.packageKey] = result.draft;\n generationStatus[result.channel] = result.status;\n if (result.status.status === "failed") {\n generationWarnings.push(`${result.channel}: model generation failed and no substitute copy was inserted.`);\n } else if (result.status.status === "needs_review") {', - "honest destination status", -) -replace_required( - GEN, - ' pkg.generation = {', - ''' 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 = {''', - "all destination failure gate", -) -replace_required( - GEN, - ' const fallbackUsed = generatedDestinations.some((item) => item.status.status === "template_fallback");', - ' const partialFailureUsed = failedDestinations.length > 0;', - "partial failure flag", -) -replace_required( - GEN, - ' fallbackUsed,\n partialFallbackUsed: fallbackUsed,', - ' fallbackUsed: false,\n partialFailureUsed,', - "no fallback response", -) -replace_between( - GEN, - ' } catch (error) {\n const result = templateResult(', - '\n }\n}', - ''' } catch (error) { - throw new Error(`${providerMeta.label} campaign generation failed: ${error.message}`); -''', - "strategy failure without template", -) - -CI = ".github/workflows/ci.yml" -ci = read(CI) -if "mcp-tests:" not in ci: - marker = "jobs:\n" - job = '''jobs: - mcp-tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Set up Node - uses: actions/setup-node@v4 - with: - node-version: "22" - - name: Run MCP protocol and tool tests - working-directory: mcp - run: npm test - -''' - if marker not in ci: - raise RuntimeError("Missing CI jobs marker") - ci = ci.replace(marker, job, 1) - write(CI, ci) - -print("Applied Studio product, strict model generation, and MCP integration changes.") From b3b5c1851b9a6cf2bb65b960d964393d4d7573c9 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:18:32 +0530 Subject: [PATCH 19/29] fix: align hosted provider readiness with owner access --- frontend/lib/ai/generationPolicy.mjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/lib/ai/generationPolicy.mjs b/frontend/lib/ai/generationPolicy.mjs index 105a175..58c0571 100644 --- a/frontend/lib/ai/generationPolicy.mjs +++ b/frontend/lib/ai/generationPolicy.mjs @@ -28,3 +28,10 @@ export function assertModelGenerationProvider(value) { return provider; } + +export function canUseServerProviderConfiguration({ + publicHosted = process.env.SIGNALFLOW_PUBLIC_HOSTED === "true", + allowServerKey = false, +} = {}) { + return !publicHosted || Boolean(allowServerKey); +} From 656a1cb706fb07d1691cd42f8f170d783fa9891c Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:19:07 +0530 Subject: [PATCH 20/29] fix: keep hosted server keys owner-only --- frontend/lib/ai/types.js | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/frontend/lib/ai/types.js b/frontend/lib/ai/types.js index f0e7573..bd79efb 100644 --- a/frontend/lib/ai/types.js +++ b/frontend/lib/ai/types.js @@ -83,7 +83,7 @@ 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: () => true, defaultModel: process.env.DEFAULT_MODEL_NAME || "llama3", requiredEnv: ["OLLAMA_BASE_URL"], canTest: true, @@ -95,7 +95,7 @@ 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: () => true, defaultModel: process.env.DEFAULT_MODEL_NAME || "any", requiredEnv: ["LMSTUDIO_BASE_URL"], canTest: true, @@ -111,23 +111,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 ""; } } From ce117d885cbacadb6f9802c6c3ca0bdf07583d5b Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:20:09 +0530 Subject: [PATCH 21/29] fix: allow server model keys only for owner sessions --- frontend/app/api/launch_kit/route.js | 71 ++++++++++------------------ 1 file changed, 26 insertions(+), 45 deletions(-) diff --git a/frontend/app/api/launch_kit/route.js b/frontend/app/api/launch_kit/route.js index f4b99ea..3255f2b 100644 --- a/frontend/app/api/launch_kit/route.js +++ b/frontend/app/api/launch_kit/route.js @@ -33,21 +33,18 @@ export async function POST(request) { } const providerApiKey = normalizeTextInput(body.providerApiKey); - if (!isOwner && Boolean(process.env.SIGNALFLOW_ACCESS_KEY)) { - if (!providerApiKey) { - return new Response( - JSON.stringify({ - error: "This hosted workspace is private. Enter the owner's access key or supply your own personal API key in settings to use cloud providers.", - }), - { - status: 401, - headers: { "Content-Type": "application/json" }, - } - ); - } + if (!isOwner && Boolean(process.env.SIGNALFLOW_ACCESS_KEY) && !providerApiKey) { + return new Response( + JSON.stringify({ + error: "This hosted workspace is private. Enter the owner's access key or supply your own personal API key to use a cloud provider.", + }), + { + status: 401, + headers: { "Content-Type": "application/json" }, + }, + ); } - // 1. Validate inputs const validation = validateGenerationInputs(body); if (!validation.valid) { return new Response(JSON.stringify({ @@ -85,10 +82,8 @@ export async function POST(request) { let repoContext = null; const linksContext = []; const mediaItems = Array.isArray(body.media_items) ? [...body.media_items] : []; - const githubToken = normalizeTextInput(body.github_token) || normalizeTextInput(body.githubToken); - // 2. Perform Repository Ingestion if repo URL or local path provided if (repoUrl) { try { const isLocal = !repoUrl.includes("github.com") && @@ -99,45 +94,36 @@ export async function POST(request) { repoUrl.startsWith(".") || (!repoUrl.includes("http://") && !repoUrl.includes("https://"))); - if (isLocal) { - repoContext = await ingestLocalRepo(repoUrl); - } else { - repoContext = await ingestGitHubRepo(repoUrl, githubToken); - } - if (repoContext?.warnings?.length) { - warnings.push(...repoContext.warnings); - } - } catch (err) { - warnings.push(`Repository ingestion failed: ${err.message}. Generating with available inputs.`); + repoContext = isLocal + ? await ingestLocalRepo(repoUrl) + : await ingestGitHubRepo(repoUrl, githubToken); + + if (repoContext?.warnings?.length) warnings.push(...repoContext.warnings); + } catch (error) { + warnings.push(`Repository ingestion failed: ${error.message}. Generating with available inputs.`); } } - // 3. Perform Docs/Links scraping if urls provided if (docsUrl) { - // Split by spaces or newlines to support multiple links const urls = docsUrl.split(/\s+/).filter(Boolean); for (const url of urls) { try { const fetchResult = await fetchUrlContent(url); if (fetchResult) { linksContext.push(fetchResult); - if (fetchResult.warnings?.length) { - warnings.push(...fetchResult.warnings); - } + if (fetchResult.warnings?.length) warnings.push(...fetchResult.warnings); } - } catch (err) { - warnings.push(`Scraping docs link "${url}" failed: ${err.message}.`); + } catch (error) { + warnings.push(`Scraping docs link "${url}" failed: ${error.message}.`); } } } - // 4. In V1, automated screenshot capture is disabled in the main flow. if (appUrl) { - warnings.push("Automatic app capture is disabled in main flow. Upload screenshots or record manually."); + warnings.push("Automatic app capture is disabled in the main flow. Upload screenshots or record manually."); } void enableAutoCapture; - // 5. Build context & generate package (supports AI routes & templates fallbacks) const result = await generateStudioPackage({ projectName, notes, @@ -156,25 +142,20 @@ export async function POST(request) { apiKey: providerApiKey, baseUrl: providerBaseUrl, modelName: providerModelName, + allowServerKey: isOwner, }, }); - // Merge API warnings with generation warnings const allWarnings = Array.from(new Set([...warnings, ...(result.warnings || [])])); - - return new Response(JSON.stringify({ - ...result, - warnings: allWarnings, - }), { + return new Response(JSON.stringify({ ...result, warnings: allWarnings }), { status: 200, headers: { "Content-Type": "application/json" }, }); - - } catch (err) { + } catch (error) { return new Response(JSON.stringify({ ok: false, - error: `Server failed to assemble kit: ${err.message}`, - warnings: [err.message], + error: `Server failed to assemble kit: ${error.message}`, + warnings: [error.message], }), { status: 500, headers: { "Content-Type": "application/json" }, From 0f41b25d76e3e5cdceae818b81382ffb84583505 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:21:22 +0530 Subject: [PATCH 22/29] fix: require reachable endpoints for hosted local models --- frontend/lib/studio/providerReadiness.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/lib/studio/providerReadiness.mjs b/frontend/lib/studio/providerReadiness.mjs index 4f73e98..0ec9cb1 100644 --- a/frontend/lib/studio/providerReadiness.mjs +++ b/frontend/lib/studio/providerReadiness.mjs @@ -43,10 +43,17 @@ export function evaluateProviderReadiness({ provider, apiKey = "", baseUrl = "", } 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 - ? "Local endpoint supplied. Test it before generation." + ? "Model endpoint supplied. Test it before generation." : "Uses the provider default local endpoint. Test it before generation.", source: "local", }; From f389202e33850d2a7311490a388c02568391088a Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:21:48 +0530 Subject: [PATCH 23/29] fix: report only model routes available to the current session --- frontend/app/api/provider_status/route.js | 26 ++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/frontend/app/api/provider_status/route.js b/frontend/app/api/provider_status/route.js index cd5216b..04cc8c6 100644 --- a/frontend/app/api/provider_status/route.js +++ b/frontend/app/api/provider_status/route.js @@ -1,12 +1,32 @@ +import { requireOwnerAccess } from "../_auth"; import { getProviderConfigurationStatus } from "../../../lib/ai/providerStatus"; -import { MODEL_GENERATION_PROVIDERS } from "../../../lib/ai/generationPolicy.mjs"; +import { + MODEL_GENERATION_PROVIDERS, + canUseServerProviderConfiguration, +} from "../../../lib/ai/generationPolicy.mjs"; -export async function GET() { +const LOCAL_PROVIDERS = new Set(["ollama", "lmstudio"]); + +export async function GET(request) { try { + const isOwner = requireOwnerAccess(request) === null; + const publicHosted = process.env.SIGNALFLOW_PUBLIC_HOSTED === "true" || Boolean(process.env.VERCEL); + const canUseServerConfiguration = canUseServerProviderConfiguration({ + publicHosted, + allowServerKey: isOwner, + }); + const allProviders = getProviderConfigurationStatus(); const providers = Object.fromEntries( - Object.entries(allProviders).filter(([id]) => MODEL_GENERATION_PROVIDERS.has(id)), + Object.entries(allProviders) + .filter(([id]) => MODEL_GENERATION_PROVIDERS.has(id)) + .map(([id, provider]) => [id, { + ...provider, + configured: Boolean(provider.configured && canUseServerConfiguration), + requiresBaseUrl: LOCAL_PROVIDERS.has(id) && publicHosted && !provider.configured, + }]), ); + const requestedDefault = String(process.env.DEFAULT_MODEL_PROVIDER || "").trim().toLowerCase(); const defaultProvider = MODEL_GENERATION_PROVIDERS.has(requestedDefault) ? requestedDefault : ""; const recommendedProvider = From b0fc21f47420286a04bd950f91e2e49140145d61 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:23:18 +0530 Subject: [PATCH 24/29] fix: test server model keys only inside owner sessions --- frontend/app/api/provider_test/route.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/app/api/provider_test/route.js b/frontend/app/api/provider_test/route.js index f218847..7e9f615 100644 --- a/frontend/app/api/provider_test/route.js +++ b/frontend/app/api/provider_test/route.js @@ -24,13 +24,20 @@ export async function POST(request) { const temporaryApiKey = String(body.temporaryApiKey || "").trim(); const accessError = requireOwnerAccess(request); - if (accessError && (OWNER_ONLY_ENDPOINT_PROVIDERS.has(provider) || !temporaryApiKey)) { + const isOwner = accessError === null; + if (!isOwner && (OWNER_ONLY_ENDPOINT_PROVIDERS.has(provider) || !temporaryApiKey)) { return accessError; } const modelName = String(body.modelName || "").trim(); const baseUrl = String(body.baseUrl || "").trim(); - const config = { apiKey: temporaryApiKey, baseUrl, modelName, maxTokens: 64 }; + const config = { + apiKey: temporaryApiKey, + baseUrl, + modelName, + maxTokens: 64, + allowServerKey: isOwner, + }; try { await generateText({ From 133155508d31bc4c0cb73143389b10e5ee582683 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:24:27 +0530 Subject: [PATCH 25/29] fix: do not advertise localhost models on hosted deployments --- frontend/lib/ai/types.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/lib/ai/types.js b/frontend/lib/ai/types.js index bd79efb..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, + 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, + 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, From 2dba5a23a22fde6b83efe0cf9e86fa6faee19805 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:26:19 +0530 Subject: [PATCH 26/29] test: cover hosted provider and local endpoint boundaries --- frontend/tests/providerReadiness.test.mjs | 35 ++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/frontend/tests/providerReadiness.test.mjs b/frontend/tests/providerReadiness.test.mjs index 71285a0..9a9d5d6 100644 --- a/frontend/tests/providerReadiness.test.mjs +++ b/frontend/tests/providerReadiness.test.mjs @@ -6,7 +6,10 @@ import { isForbiddenGenerationMode, pickRecommendedProvider, } from "../lib/studio/providerReadiness.mjs"; -import { assertModelGenerationProvider } from "../lib/ai/generationPolicy.mjs"; +import { + assertModelGenerationProvider, + canUseServerProviderConfiguration, +} from "../lib/ai/generationPolicy.mjs"; test("template and prompt-only modes are rejected", () => { for (const provider of ["", "template", "offline", "prompt"]) { @@ -24,6 +27,21 @@ test("configured server providers are ready without exposing a key", () => { 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( @@ -32,6 +50,21 @@ test("cloud providers require server configuration or a temporary key", () => { ); }); +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( From 38e2853fd874a846969d00f5f434c9c57c37c6ac Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:27:12 +0530 Subject: [PATCH 27/29] security: keep custom and local model endpoints owner-only --- frontend/app/api/launch_kit/route.js | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/frontend/app/api/launch_kit/route.js b/frontend/app/api/launch_kit/route.js index 3255f2b..22eb276 100644 --- a/frontend/app/api/launch_kit/route.js +++ b/frontend/app/api/launch_kit/route.js @@ -10,6 +10,8 @@ import { fetchUrlContent } from "../../../lib/context/linkFetcher"; import { generateStudioPackage } from "../../../lib/ai/generateStudioPackage"; import { assertModelGenerationProvider } from "../../../lib/ai/generationPolicy.mjs"; +const OWNER_ONLY_ENDPOINT_PROVIDERS = new Set(["custom", "ollama", "lmstudio"]); + export const maxDuration = 60; export async function POST(request) { @@ -33,6 +35,16 @@ export async function POST(request) { } const providerApiKey = normalizeTextInput(body.providerApiKey); + if (!isOwner && OWNER_ONLY_ENDPOINT_PROVIDERS.has(generator)) { + return accessError || new Response(JSON.stringify({ + ok: false, + error: "Custom and local model endpoints require an authenticated owner session.", + }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + if (!isOwner && Boolean(process.env.SIGNALFLOW_ACCESS_KEY) && !providerApiKey) { return new Response( JSON.stringify({ @@ -78,6 +90,22 @@ export async function POST(request) { const providerBaseUrl = normalizeTextInput(body.providerBaseUrl); const documentText = normalizeDocumentText(body.document_text); + const publicHosted = process.env.SIGNALFLOW_PUBLIC_HOSTED === "true" || Boolean(process.env.VERCEL); + const configuredLocalBaseUrl = generator === "ollama" + ? normalizeTextInput(process.env.OLLAMA_BASE_URL) + : generator === "lmstudio" + ? normalizeTextInput(process.env.LMSTUDIO_BASE_URL) + : ""; + if (["ollama", "lmstudio"].includes(generator) && publicHosted && !providerBaseUrl && !configuredLocalBaseUrl) { + return new Response(JSON.stringify({ + ok: false, + error: "This hosted deployment needs a reachable model base URL. A browser-local localhost endpoint cannot be reached from the server.", + }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + const warnings = []; let repoContext = null; const linksContext = []; From 1556c84b1dacbe766be73c08d2fa22ee31cc0620 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:30:46 +0530 Subject: [PATCH 28/29] fix: enforce MCP lifecycle and protocol errors --- mcp/server.mjs | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/mcp/server.mjs b/mcp/server.mjs index b720d19..20eff05 100644 --- a/mcp/server.mjs +++ b/mcp/server.mjs @@ -4,7 +4,17 @@ 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_PROTOCOL = "2025-11-25"; +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`); @@ -29,14 +39,20 @@ async function handleRequest(message) { } const { id, method, params = {} } = message; + const notification = id === undefined; - if (method === "notifications/initialized" || method.startsWith("notifications/")) { + 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: params.protocolVersion || SUPPORTED_PROTOCOL, + protocolVersion, capabilities: { tools: { listChanged: false } }, serverInfo: SERVER_INFO, instructions: @@ -45,6 +61,11 @@ async function handleRequest(message) { return; } + if (!initialized) { + error(id, -32002, "SignalFlow MCP is not initialized. Send initialize and notifications/initialized first."); + return; + } + if (method === "ping") { result(id, {}); return; @@ -56,6 +77,10 @@ async function handleRequest(message) { } 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); From a204e88aad22b906bdd26bff2cb31387845d4e92 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:31:35 +0530 Subject: [PATCH 29/29] test: cover MCP lifecycle and unknown tool errors --- mcp/tests/server.test.mjs | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/mcp/tests/server.test.mjs b/mcp/tests/server.test.mjs index 8b39353..dea9b32 100644 --- a/mcp/tests/server.test.mjs +++ b/mcp/tests/server.test.mjs @@ -24,7 +24,11 @@ function waitForLine(lines, predicate, timeoutMs = 5000) { }); } -test("stdio server initializes and lists SignalFlow tools", async (t) => { +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" }, @@ -35,21 +39,36 @@ test("stdio server initializes and lists SignalFlow tools", async (t) => { const output = readline.createInterface({ input: child.stdout }); output.on("line", (line) => lines.push(JSON.parse(line))); - child.stdin.write(`${JSON.stringify({ + 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" } }, - })}\n`); + }); - const initialized = await waitForLine(lines, (message) => message.id === 1); - assert.equal(initialized.result.serverInfo.name, "signalflow-studio"); - assert.equal(initialized.result.capabilities.tools.listChanged, false); + 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); - child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} })}\n`); - child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} })}\n`); + 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); });