Skip to content
Merged
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
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ Service ports are fixed (`3000/3100/3200/3300/4000`); container-to-container URL

- **Modular monolith over microservices** — one database, one deploy unit set, hard module boundaries enforced by convention (service interfaces only, no shared repositories). Scale comes from splitting *binaries*, not codebases.
- **Transactional outbox** for anything that must not be lost; plain pub/sub for anything that may be.
- **Opinionated workflow model** — no loops, no sub-workflows, no expression language. Flows stay simple, readable, and predictable for non-developer users; that constraint is a feature.
- **Opinionated workflow model** — batch is the default: every node takes an array in and returns an array out, and references resolve per item (`$node.field`), by position (`$node[2].field`) or across the whole set (`$node[*].field`). What the canvas deliberately lacks is control-flow machinery — no loop construct, no sub-workflows, no expression language. Flows stay simple, readable, and predictable for non-developer users; that constraint is a feature.
- **stdlib-first Go** — `log/slog`, `database/sql`, small focused packages in `go-packages` instead of frameworks.
- **Runtime env for the UI** — one image per release, environment decided at container start.
- **Scale-out infrastructure is opt-in** — Redis buys cross-instance coordination, which a fresh single-instance install does not need. Every Redis-backed subsystem ships an in-process provider and defaults to it; Compose only starts what is configured.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ It is built for end users, not just developers: flows stay simple, readable and

## Highlights

- **Visual flow builder** — compose workflows on an intuitive drag-and-drop canvas. Flows are deliberately simple: no loops, no sub-workflows, no expression language to learn.
- **Visual flow builder** — compose workflows on an intuitive drag-and-drop canvas. Nodes take arrays in and return arrays out, so a node fed ten items runs ten times and emits ten results — batch work needs no loop construct. What is missing is deliberate: no `while`/`for` on the canvas, no sub-workflows, no expression language to learn.
- **Describe it, don't configure it** — give a node plain-language instructions and an LLM fills in its parameters at run time. Anything you set explicitly always wins over what the model infers. This one and flow generation are the only features that need a key of your own (Gemini); both are off by default. Everything else — the canvas, triggers, the MCP server, every integration node — works on a fresh install.
- **Built-in MCP server** — every integration node doubles as an [MCP](https://modelcontextprotocol.io) tool. Point Claude (or any MCP client) at your BlockNext server with an API key and use your connected services from chat.
- **AI-powered nodes** — LLMs and generative AI (text, image, audio, video) as first-class building blocks, alongside integrations for the tools you already use.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,11 +247,11 @@ ALWAYS emit the full default block on every executable (core) node:
{
"maxRetries": 0,
"retryDelay": 1000,
"timeout": 30000,
"timeout": 0,
"continueOnError": false,
"disabled": false
}
Override individual values only when the user explicitly asks for retries, longer timeouts, or "keep running on error". Never omit the block.
"timeout": 0 means no time limit, and that is the default: generation nodes (video, music, image) legitimately run for minutes and a timeout would kill them mid-job. Override individual values only when the user explicitly asks for retries, a time limit, or "keep running on error". Never omit the block.
The starter node does NOT carry settings.

=== credentials FIELD ===
Expand Down Expand Up @@ -361,7 +361,7 @@ Correct output:
"settings": {
"maxRetries": 0,
"retryDelay": 1000,
"timeout": 30000,
"timeout": 0,
"continueOnError": false,
"disabled": false
},
Expand All @@ -381,7 +381,7 @@ Correct output:
"settings": {
"maxRetries": 0,
"retryDelay": 1000,
"timeout": 30000,
"timeout": 0,
"continueOnError": false,
"disabled": false
},
Expand Down
4 changes: 3 additions & 1 deletion apps/platform/public/locales/en/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,8 @@
"ui.text.maxSupply": "Max Supply",
"ui.text.maxSupplyHelp": "Enter the maximum supply in wei (18 decimals)",
"ui.text.mcpConnectionUrl": "Connection URL",
"ui.text.mcpClientConfig": "Client config",
"ui.text.mcpApiKeyHint": "Create an API key with the mcp:invoke scope and put it in X-API-Key.",
"ui.text.mcpCopyUrl": "Copy URL",
"ui.text.mcpNoSearchResults": "No servers match your search.",
"ui.text.mcpNoServersAvailable": "No MCP servers available yet.",
Expand Down Expand Up @@ -824,7 +826,7 @@
"ui.text.theme": "Theme",
"ui.text.thisItemIsFree": "This item is free",
"ui.text.timeout": "Timeout (ms)",
"ui.text.timeoutDescription": "Maximum execution time in milliseconds",
"ui.text.timeoutDescription": "Maximum execution time in milliseconds; 0 means no limit",
"ui.text.timeset": "Timeset",
"ui.text.timezone": "Timezone",
"ui.text.title": "Title",
Expand Down
36 changes: 36 additions & 0 deletions apps/platform/src/components/shared/code-block.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Check, Copy } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { useCopy } from '@/hooks/use-copy'

export function CodeBlock({ code }: { code: string }) {
const { t } = useTranslation()
const { copied, copy } = useCopy()
const label = t('ui.text.copy', 'Copy')

return (
<div className="overflow-hidden rounded-lg border bg-muted/50">
<div className="flex items-center justify-end border-b bg-muted/50 px-2 py-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => copy(code)}
aria-label={label}
title={label}
className="h-6 gap-1.5 px-2 text-xs text-muted-foreground"
>
{copied ? (
<Check className="size-3.5 text-green-500" />
) : (
<Copy className="size-3.5" />
)}
{label}
</Button>
</div>
<pre className="max-h-[55vh] overflow-auto p-3 font-mono text-xs leading-relaxed">
<code>{code}</code>
</pre>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Check, Copy } from 'lucide-react'
import {
Sheet,
SheetContent,
Expand All @@ -9,47 +8,14 @@ import {
SheetTitle,
} from '@/components/ui/sheet'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { useCopy } from '@/hooks/use-copy'
import { CodeBlock } from '@/components/shared/code-block'
import { config } from '@/lib/config'

const API_KEY_PLACEHOLDER = 'YOUR_API_KEY'
const CREDENTIAL_PLACEHOLDER = '<credential-ui-key>'
const RUNTIME_PROMPT_PLACEHOLDER = 'YOUR_RUNTIME_PROMPT'
const RUNTIME_INSTRUCTION_PLACEHOLDER = 'YOUR_RUNTIME_INSTRUCTION'

function CodeBlock({ code }: { code: string }) {
const { t } = useTranslation()
const { copied, copy } = useCopy()
const label = t('ui.text.copy', 'Copy')

return (
<div className="overflow-hidden rounded-lg border bg-muted/50">
<div className="flex items-center justify-end border-b bg-muted/50 px-2 py-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => copy(code)}
aria-label={label}
title={label}
className="h-6 gap-1.5 px-2 text-xs text-muted-foreground"
>
{copied ? (
<Check className="size-3.5 text-green-500" />
) : (
<Copy className="size-3.5" />
)}
{label}
</Button>
</div>
<pre className="max-h-[55vh] overflow-auto p-3 font-mono text-xs leading-relaxed">
<code>{code}</code>
</pre>
</div>
)
}

function buildRequestBody(nodes: any[], apiNodes: any[]) {
const credentialKeyFor = (nodeId: string) =>
apiNodes.find((n) => n.id === nodeId)?.supportedCredentials?.[0] ?? null
Expand Down Expand Up @@ -99,13 +65,16 @@ export function FlowApiSheet({ open, onOpenChange, flowId, nodes, apiNodes }) {
` },\n` +
` body: JSON.stringify(${bodyJson.replace(/\n/g, '\n ')}),\n` +
`})\n\n` +
`if (!response.ok) {\n` +
` throw new Error(\`Trigger failed: ${'${response.status}'}\`)\n` +
`}\n\n` +
`const data = await response.json()`,
[endpoint, bodyJson],
)

return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-3xl">
<SheetContent className="data-[side=right]:sm:max-w-3xl">
<SheetHeader>
<SheetTitle>{t('ui.text.apiTrigger', 'API Trigger')}</SheetTitle>
<SheetDescription>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import type {
const DEFAULT_SETTINGS: NodeSettings = {
maxRetries: 0,
retryDelay: 1000,
timeout: 30000,
timeout: 0,
continueOnError: false,
disabled: false,
}
Expand Down
152 changes: 101 additions & 51 deletions apps/platform/src/features/mcp/components/mcp-server-detail-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,37 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { CopyField } from '@/components/shared/copy-field'
import { CodeBlock } from '@/components/shared/code-block'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useIconResolver } from '@/features/flow-editor/icons'
import { McpToolSchemaSection } from '@/features/mcp/components/mcp-tool-schema'
import type { IconSource } from '@/features/flow-editor/types'
import type { McpServer } from '@/features/mcp/services/mcp'

const API_KEY_PLACEHOLDER = 'YOUR_API_KEY'

const configSnippet = (name: string, url: string) =>
JSON.stringify(
{
mcpServers: {
[`blocknext-${name}`]: {
type: 'http',
url,
headers: { 'X-API-Key': API_KEY_PLACEHOLDER },
},
},
},
null,
2,
)

const curlSnippet = (url: string) =>
`curl --location '${url}' \\\n` +
`--header 'X-API-Key: ${API_KEY_PLACEHOLDER}' \\\n` +
`--header 'Content-Type: application/json' \\\n` +
`--header 'Accept: application/json, text/event-stream' \\\n` +
`--data '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'`

type McpServerDetailDialogProps = {
server: McpServer | null
open: boolean
Expand Down Expand Up @@ -44,7 +70,7 @@ const McpServerDetailDialog = ({

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogContent className="flex max-h-[85vh] flex-col overflow-y-auto sm:max-w-5xl md:overflow-hidden">
{server && (
<>
<DialogHeader>
Expand All @@ -68,64 +94,88 @@ const McpServerDetailDialog = ({
</div>
)}

<div className="flex flex-col gap-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('ui.text.tools')} ({server.tools?.length ?? 0})
</h3>
<div className="flex flex-col gap-4 md:min-h-0 md:flex-1 md:flex-row">
<div className="flex flex-col gap-2 md:min-h-0 md:flex-1">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('ui.text.tools')} ({server.tools?.length ?? 0})
</h3>

{!server.tools?.length ? (
<p className="text-sm text-muted-foreground">—</p>
) : (
<div className="flex flex-col gap-2">
{server.tools.map((tool) => (
<div
key={tool.id}
className="flex items-start gap-2.5 rounded-lg border bg-card p-3 ring-1 ring-foreground/5 transition-colors hover:border-foreground/20"
>
<McpToolIcon icon={tool.icon} />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate font-mono text-sm font-medium">
{tool.name}
</span>
<Badge
variant="outline"
className="font-mono text-[10px]"
>
v{tool.version}
</Badge>
</div>
{tool.description && (
<p className="line-clamp-2 text-xs text-muted-foreground">
{tool.description}
</p>
)}
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
{tool.supportedCredentials?.map((cred) => (
{!server.tools?.length ? (
<p className="text-sm text-muted-foreground">—</p>
) : (
<div className="flex flex-col gap-2 md:overflow-y-auto md:pr-1">
{server.tools.map((tool) => (
<div
key={tool.id}
className="flex items-start gap-2.5 rounded-lg border bg-card p-3 ring-1 ring-foreground/5 transition-colors hover:border-foreground/20"
>
<McpToolIcon icon={tool.icon} />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate font-mono text-sm font-medium">
{tool.name}
</span>
<Badge
key={cred}
variant="outline"
className="gap-1 text-[10px] font-normal"
className="font-mono text-[10px]"
>
<KeyRound className="size-3 text-muted-foreground" />
{cred}
v{tool.version}
</Badge>
))}
</div>
</div>
{tool.description && (
<p className="line-clamp-2 text-xs text-muted-foreground">
{tool.description}
</p>
)}
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
{tool.supportedCredentials?.map((cred) => (
<Badge
key={cred}
variant="outline"
className="gap-1 text-[10px] font-normal"
>
<KeyRound className="size-3 text-muted-foreground" />
{cred}
</Badge>
))}
</div>

<div className="flex flex-col gap-1.5 pt-1">
<McpToolSchemaSection
label={t('ui.text.inputs')}
schema={tool.inputSchema}
/>
<McpToolSchemaSection
label={t('ui.text.outputs')}
schema={tool.outputSchema}
/>
<div className="flex flex-col gap-1.5 pt-1">
<McpToolSchemaSection
label={t('ui.text.inputs')}
schema={tool.inputSchema}
/>
<McpToolSchemaSection
label={t('ui.text.outputs')}
schema={tool.outputSchema}
/>
</div>
</div>
</div>
</div>
))}
))}
</div>
)}
</div>

{server.url && (
<div className="flex shrink-0 flex-col gap-2 md:sticky md:top-0 md:w-96 md:self-start">
<Tabs defaultValue="config" className="gap-3">
<TabsList>
<TabsTrigger value="config">
{t('ui.text.mcpClientConfig')}
</TabsTrigger>
<TabsTrigger value="curl">cURL</TabsTrigger>
</TabsList>
<TabsContent value="config">
<CodeBlock code={configSnippet(server.id, server.url)} />
</TabsContent>
<TabsContent value="curl">
<CodeBlock code={curlSnippet(server.url)} />
</TabsContent>
</Tabs>
<p className="text-xs text-muted-foreground">
{t('ui.text.mcpApiKeyHint')}
</p>
</div>
)}
</div>
Expand Down