Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion api/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { withRuntime } from "@decocms/runtime";
import { prompts } from "./prompts/index.ts";
import { canvasStateResource } from "./resources/canvas-state.ts";
import { helloAppResource } from "./resources/hello.ts";
import { leanCanvasAppResource } from "./resources/lean-canvas.ts";
import { saveCanvasResource } from "./resources/save-canvas.ts";
import { saveIdeaResource } from "./resources/save-idea.ts";
import { tools } from "./tools/index.ts";
import { type Env, StateSchema } from "./types/env.ts";

Expand Down Expand Up @@ -42,7 +46,13 @@ const runtime = withRuntime<Env, typeof StateSchema>({
},
tools,
prompts,
resources: [helloAppResource],
resources: [
helloAppResource,
leanCanvasAppResource,
canvasStateResource,
saveCanvasResource,
saveIdeaResource,
],
});

function withLogging(fetcher: Fetcher): Fetcher {
Expand Down
3 changes: 2 additions & 1 deletion api/prompts/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { helloPrompt } from "./hello.ts";
import { leanCanvasPrompt } from "./lean-canvas.ts";

export const prompts = [helloPrompt];
export const prompts = [helloPrompt, leanCanvasPrompt];
70 changes: 70 additions & 0 deletions api/prompts/lean-canvas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { createPublicPrompt } from "@decocms/runtime/tools";
import { z } from "zod";
import { IDEA_STORE_KEY } from "../resources/save-idea.ts";
import { store } from "../storage/index.ts";
import type { Env } from "../types/env.ts";

export const leanCanvasPrompt = (_env: Env) =>
createPublicPrompt({
name: "lean-canvas",
title: "Gerador de Lean Canvas",
description:
"Construa um modelo de negócios Lean Canvas de forma interativa. Descreva sua ideia de startup e a IA vai te ajudar a estruturá-la em um Lean Canvas completo, atuando como um parceiro de negócios.",
argsSchema: {
idea: z
.string()
.optional()
.describe("Descrição breve da sua ideia de startup ou negócio"),
},
execute: async ({ args }) => {
// Check for idea from prompt args first, then from the stored idea (set by UI)
const savedIdea = await store.get<string>(IDEA_STORE_KEY);
const idea = args.idea || savedIdea;
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Use nullish coalescing instead of || so empty-string input does not incorrectly fall back to previously saved idea.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At api/prompts/lean-canvas.ts, line 22:

<comment>Use nullish coalescing instead of `||` so empty-string input does not incorrectly fall back to previously saved idea.</comment>

<file context>
@@ -0,0 +1,70 @@
+		execute: async ({ args }) => {
+			// Check for idea from prompt args first, then from the stored idea (set by UI)
+			const savedIdea = await store.get<string>(IDEA_STORE_KEY);
+			const idea = args.idea || savedIdea;
+			const ideaContext = idea
+				? `\n\nA ideia inicial do usuário: "${idea}"`
</file context>
Fix with Cubic

const ideaContext = idea
? `\n\nA ideia inicial do usuário: "${idea}"`
: "";

return {
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: `Você é um consultor de startups construindo um Lean Canvas. Responda sempre em português brasileiro.${ideaContext}

REGRAS CRÍTICAS:
- NUNCA peça permissão, NUNCA apresente planos, NUNCA pergunte "posso começar/seguir?".
- NUNCA faça perguntas sobre o método Lean Canvas ou sobre o processo de construção. O usuário não precisa saber como o canvas funciona.
- Suas perguntas devem ser SEMPRE sobre o NEGÓCIO do usuário: dores dos clientes, mercado, concorrência, modelo de receita, diferenciais, etc.
- Você é um parceiro de negócios que ajuda a pensar criticamente sobre a solução. Desafie suposições e faça perguntas provocativas sobre o negócio.

COMO CONSTRUIR O CANVAS:
1. Ao receber a ideia, NÃO preencha nenhuma seção ainda. Primeiro faça 2-3 perguntas estratégicas sobre o negócio para entender melhor o contexto (ex: "Quem são as pessoas que mais sofrem com esse problema?", "Como elas resolvem isso hoje?", "O que te motivou a pensar nessa solução?"). Só preencha Problema depois que o usuário responder e você tiver contexto real.
2. NUNCA preencha mais de 1-2 seções por vez. Construa progressivamente.
3. Após cada resposta do usuário, preencha NO MÁXIMO 1-2 seções novas e faça perguntas sobre o negócio para avançar.
4. Trabalhe nesta ordem: Problema → Segmentos de Clientes → Proposta de Valor → Solução → Canais → Receita → Custos → Métricas → Vantagem Competitiva.
5. ANTES de chamar lean_canvas, leia o resource "data://mcp-app/canvas-state" para obter o estado atual do canvas (o usuário pode ter editado seções manualmente pela UI). Use esse estado como base e adicione suas atualizações em cima. SEMPRE inclua TODAS as seções.
6. Mantenha os itens concisos — bullet points curtos, não parágrafos.
7. Revise seções anteriores conforme novos insights surgirem.
8. Quando o usuário editar diretamente uma seção pela UI, considere como decisão dele.
9. Após completar o canvas, ofereça uma revisão holística e sugira refinamentos.

EXEMPLOS DE PERGUNTAS BOAS (sobre o negócio):
- "Quem são os early adopters ideais? Empresas de qual porte/setor?"
- "Como essas empresas resolvem esse problema hoje? Quais ferramentas usam?"
- "O que faria alguém trocar a solução atual pela sua?"
- "Qual seria o modelo de cobrança ideal: assinatura, por uso, freemium?"

EXEMPLOS DE PERGUNTAS PROIBIDAS (sobre o processo/metodologia):
- "Vamos começar pelo problema?" ❌
- "Gostaria que eu preenchesse a seção de segmentos?" ❌
- "Quer que eu sugira canais de distribuição?" ❌
- "Posso preencher a proposta de valor?" ❌

Se a ideia já foi descrita, faça perguntas sobre o negócio para aprofundar antes de preencher qualquer seção. Se não foi descrita, pergunte sobre a ideia de negócio.`,
},
},
],
};
},
});
24 changes: 24 additions & 0 deletions api/resources/canvas-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { createPublicResource } from "@decocms/runtime/tools";
import { store } from "../storage/index.ts";
import {
CANVAS_STATE_RESOURCE_URI,
CANVAS_STORE_KEY,
} from "../tools/lean-canvas.ts";
import type { Env } from "../types/env.ts";

export const canvasStateResource = (_env: Env) =>
createPublicResource({
uri: CANVAS_STATE_RESOURCE_URI,
name: "Lean Canvas State",
description:
"Current Lean Canvas state as JSON, read by the side panel UI via polling",
mimeType: "application/json",
read: async () => {
const data = await store.get(CANVAS_STORE_KEY);
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Canvas state is stored and read under a single global key, so concurrent users can read/overwrite each other’s Lean Canvas data.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At api/resources/canvas-state.ts, line 17:

<comment>Canvas state is stored and read under a single global key, so concurrent users can read/overwrite each other’s Lean Canvas data.</comment>

<file context>
@@ -0,0 +1,24 @@
+			"Current Lean Canvas state as JSON, read by the side panel UI via polling",
+		mimeType: "application/json",
+		read: async () => {
+			const data = await store.get(CANVAS_STORE_KEY);
+			return {
+				uri: CANVAS_STATE_RESOURCE_URI,
</file context>
Fix with Cubic

return {
uri: CANVAS_STATE_RESOURCE_URI,
mimeType: "application/json",
text: JSON.stringify(data ?? null),
};
},
});
29 changes: 29 additions & 0 deletions api/resources/lean-canvas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { createPublicResource } from "@decocms/runtime/tools";
import { LEAN_CANVAS_RESOURCE_URI } from "../tools/lean-canvas.ts";
import type { Env } from "../types/env.ts";

const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";

function getDistPath(): string {
const IS_PRODUCTION = process.env.NODE_ENV === "production";
const projectRoot = join(import.meta.dir, IS_PRODUCTION ? "../.." : "../..");
return join(projectRoot, "dist", "client", "index.html");
}

export const leanCanvasAppResource = (_env: Env) =>
createPublicResource({
uri: LEAN_CANVAS_RESOURCE_URI,
name: "Lean Canvas UI",
description: "Construtor interativo de Lean Canvas powered by MCP Apps",
mimeType: RESOURCE_MIME_TYPE,
read: async () => {
const html = await readFile(getDistPath(), "utf-8");
return {
uri: LEAN_CANVAS_RESOURCE_URI,
mimeType: RESOURCE_MIME_TYPE,
text: html,
};
},
});
40 changes: 40 additions & 0 deletions api/resources/save-canvas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { createPublicResource } from "@decocms/runtime/tools";
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { store } from "../storage/index.ts";
import { CANVAS_STORE_KEY } from "../tools/lean-canvas.ts";
import type { Env } from "../types/env.ts";

/**
* Resource template that saves the canvas state via the MCP protocol.
* The UI calls readServerResource({ uri: "data://mcp-app/save-canvas/<encoded-json>" })
* to persist manual edits back to the store.
*/
export const saveCanvasResource = (_env: Env) =>
createPublicResource({
uri: new ResourceTemplate("data://mcp-app/save-canvas/{data}", {
list: undefined,
}) as unknown as string,
name: "Save Canvas State",
description:
"Saves the current canvas state from the UI (manual edits, additions, deletions)",
mimeType: "application/json",
read: async ({ uri }) => {
const uriStr = uri.toString();
const marker = "/save-canvas/";
const idx = uriStr.indexOf(marker);
if (idx !== -1) {
const encoded = uriStr.slice(idx + marker.length);
if (encoded) {
const canvasData = JSON.parse(decodeURIComponent(encoded));
await store.set(CANVAS_STORE_KEY, canvasData);
}
}

const current = await store.get(CANVAS_STORE_KEY);
return {
uri: "data://mcp-app/save-canvas",
mimeType: "application/json",
text: JSON.stringify(current ?? null),
};
},
});
48 changes: 48 additions & 0 deletions api/resources/save-idea.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { createPublicResource } from "@decocms/runtime/tools";
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { store } from "../storage/index.ts";
import type { Env } from "../types/env.ts";

export const IDEA_STORE_KEY = "user-idea";
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Using a fixed global storage key causes cross-session data overwrites and can leak one user’s idea to another.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At api/resources/save-idea.ts, line 6:

<comment>Using a fixed global storage key causes cross-session data overwrites and can leak one user’s idea to another.</comment>

<file context>
@@ -0,0 +1,48 @@
+import { store } from "../storage/index.ts";
+import type { Env } from "../types/env.ts";
+
+export const IDEA_STORE_KEY = "user-idea";
+
+/**
</file context>
Fix with Cubic


/**
* Resource template that saves the user's idea via the MCP protocol.
* The UI calls readServerResource({ uri: "data://mcp-app/save-idea/<encoded>" })
* and the read handler extracts the idea from the URI and saves it to the store.
*
* This is a workaround: the side panel cannot use sendMessage, but CAN use
* readServerResource — so we encode the data in the URI.
*/
export const saveIdeaResource = (_env: Env) =>
createPublicResource({
// Pass ResourceTemplate as uri — @decocms/runtime forwards it to
// server.resource() which detects it's not a string and registers it
// as a template with URI pattern matching.
uri: new ResourceTemplate("data://mcp-app/save-idea/{idea}", {
list: undefined,
}) as unknown as string,
name: "Save User Idea",
description:
"Saves the user's initial business idea for the Lean Canvas prompt",
mimeType: "application/json",
read: async ({ uri }) => {
// uri is the full URL: data://mcp-app/save-idea/encoded-idea-text
const uriStr = uri.toString();
const marker = "/save-idea/";
const idx = uriStr.indexOf(marker);
if (idx !== -1) {
const encoded = uriStr.slice(idx + marker.length);
if (encoded) {
const idea = decodeURIComponent(encoded);
await store.set(IDEA_STORE_KEY, idea);
}
}

const savedIdea = await store.get<string>(IDEA_STORE_KEY);
return {
uri: "data://mcp-app/save-idea",
mimeType: "application/json",
text: JSON.stringify({ idea: savedIdea ?? null }),
};
},
});
22 changes: 22 additions & 0 deletions api/storage/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Simple key-value storage interface.
* Swap the implementation (Redis, SQLite, S3, etc.) by changing the export.
*/
export interface Store {
get<T = unknown>(key: string): Promise<T | null>;
set<T = unknown>(key: string, value: T): Promise<void>;
}

class MemoryStore implements Store {
private data = new Map<string, unknown>();

async get<T = unknown>(key: string): Promise<T | null> {
return (this.data.get(key) as T) ?? null;
}

async set<T = unknown>(key: string, value: T): Promise<void> {
this.data.set(key, value);
}
}

export const store: Store = new MemoryStore();
3 changes: 2 additions & 1 deletion api/tools/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { helloTool } from "./hello.ts";
import { leanCanvasTool } from "./lean-canvas.ts";

export const tools = [helloTool];
export const tools = [helloTool, leanCanvasTool];
114 changes: 114 additions & 0 deletions api/tools/lean-canvas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { createTool } from "@decocms/runtime/tools";
import { z } from "zod";
import { store } from "../storage/index.ts";
import type { Env } from "../types/env.ts";

export const CANVAS_STORE_KEY = "canvas:latest";

export const LEAN_CANVAS_RESOURCE_URI = "ui://mcp-app/lean-canvas";
export const CANVAS_STATE_RESOURCE_URI = "data://mcp-app/canvas-state";

const canvasSectionSchema = z.object({
items: z
.array(z.string())
.default([])
.describe("Itens (bullet points) desta seção"),
note: z.string().optional().describe("Nota ou explicação breve opcional"),
});

export const leanCanvasInputSchema = z.object({
projectName: z
.string()
.default("Projeto sem título")
.describe("Nome da startup ou projeto"),
problem: canvasSectionSchema
.optional()
.describe("Os 1-3 principais problemas que seus clientes enfrentam"),
customerSegments: canvasSectionSchema
.optional()
.describe("Clientes-alvo e early adopters"),
uniqueValueProposition: canvasSectionSchema
.optional()
.describe(
"Mensagem única e clara que explica por que você é diferente e merece atenção",
),
solution: canvasSectionSchema
.optional()
.describe("As 1-3 principais funcionalidades que resolvem os problemas"),
channels: canvasSectionSchema
.optional()
.describe("Caminhos para alcançar seus clientes"),
revenueStreams: canvasSectionSchema
.optional()
.describe("Como o negócio gera receita"),
costStructure: canvasSectionSchema
.optional()
.describe(
"Principais custos: aquisição de clientes, distribuição, hospedagem, pessoas, etc.",
),
keyMetrics: canvasSectionSchema
.optional()
.describe("Números-chave que indicam como o negócio está performando"),
unfairAdvantage: canvasSectionSchema
.optional()
.describe("Algo que não pode ser facilmente copiado ou comprado"),
});

export type LeanCanvasInput = z.infer<typeof leanCanvasInputSchema>;

export const leanCanvasOutputSchema = z.object({
message: z.string().describe("Mensagem de confirmação da atualização"),
filledSections: z.array(z.string()).describe("Seções que foram preenchidas"),
});

export type LeanCanvasOutput = z.infer<typeof leanCanvasInputSchema>;
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: LeanCanvasOutput is inferred from the input schema, not the declared output schema, causing a schema/type contract mismatch.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At api/tools/lean-canvas.ts, line 64:

<comment>`LeanCanvasOutput` is inferred from the input schema, not the declared output schema, causing a schema/type contract mismatch.</comment>

<file context>
@@ -0,0 +1,114 @@
+	filledSections: z.array(z.string()).describe("Seções que foram preenchidas"),
+});
+
+export type LeanCanvasOutput = z.infer<typeof leanCanvasInputSchema>;
+export type LeanCanvasToolOutput = z.infer<typeof leanCanvasOutputSchema>;
+
</file context>
Suggested change
export type LeanCanvasOutput = z.infer<typeof leanCanvasInputSchema>;
export type LeanCanvasOutput = z.infer<typeof leanCanvasOutputSchema>;
Fix with Cubic

export type LeanCanvasToolOutput = z.infer<typeof leanCanvasOutputSchema>;

export const leanCanvasTool = (_env: Env) =>
createTool({
id: "lean_canvas",
description:
"Construa e atualize um modelo de negócios Lean Canvas visualmente. Chame esta ferramenta sempre que tiver novas informações para adicionar ao canvas. Passe o estado COMPLETO do canvas incluindo todas as seções preenchidas anteriormente mais as novas ou atualizadas. A UI renderizará o canvas como um grid interativo.",
inputSchema: leanCanvasInputSchema,
outputSchema: leanCanvasOutputSchema,
_meta: { ui: { resourceUri: LEAN_CANVAS_RESOURCE_URI } },
annotations: {
readOnlyHint: true,
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: readOnlyHint is set to true, but execute mutates storage via store.set, so the tool is not read-only.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At api/tools/lean-canvas.ts, line 76:

<comment>`readOnlyHint` is set to `true`, but `execute` mutates storage via `store.set`, so the tool is not read-only.</comment>

<file context>
@@ -0,0 +1,114 @@
+		outputSchema: leanCanvasOutputSchema,
+		_meta: { ui: { resourceUri: LEAN_CANVAS_RESOURCE_URI } },
+		annotations: {
+			readOnlyHint: true,
+			destructiveHint: false,
+			idempotentHint: true,
</file context>
Fix with Cubic

destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
execute: async ({ context }) => {
await store.set(CANVAS_STORE_KEY, { ...context });

const sectionNames: Record<string, string> = {
problem: "Problema",
customerSegments: "Segmentos de Clientes",
uniqueValueProposition: "Proposta de Valor Única",
solution: "Solução",
channels: "Canais",
revenueStreams: "Fontes de Receita",
costStructure: "Estrutura de Custos",
keyMetrics: "Métricas-Chave",
unfairAdvantage: "Vantagem Competitiva",
};

const filled = Object.entries(sectionNames)
.filter(([key]) => {
const section = context[key as keyof typeof context];
return (
section &&
typeof section === "object" &&
"items" in section &&
Array.isArray(section.items) &&
section.items.length > 0
);
})
.map(([, label]) => label);

return {
message: `Canvas "${context.projectName ?? "Projeto sem título"}" atualizado com ${filled.length} de 9 seções.`,
filledSections: filled,
};
},
});
Loading
Loading