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