diff --git a/docs/generated/chat-system-prompt.txt b/docs/generated/chat-system-prompt.txt index af8c67770..e98309971 100644 --- a/docs/generated/chat-system-prompt.txt +++ b/docs/generated/chat-system-prompt.txt @@ -31,7 +31,7 @@ Separator(orientation?: "horizontal" | "vertical", decorative?: boolean) — Vis ### Tables Table(columns: Col[]) — Data table — column-oriented. Each Col holds its own data array. -Col(label: string, data, type?: "string" | "number" | "action") — Column definition — holds label + data array +Col(label: string, data: any, type?: "string" | "number" | "action") — Column definition — holds label + data array ### Charts (2D) BarChart(labels: string[], series: Series[], variant?: "grouped" | "stacked", xLabel?: string, yLabel?: string) — Vertical bars; use for comparing values across categories with one or more series @@ -53,20 +53,20 @@ ScatterSeries(name: string, points: Point[]) — Named dataset Point(x: number, y: number, z?: number) — Data point with numeric coordinates ### Forms -Form(name: string, buttons: Buttons, fields) — Form container with fields and explicit action buttons +Form(name: string, buttons: Buttons, fields?: FormControl[]) — Form container with fields and explicit action buttons FormControl(label: string, input: Input | TextArea | Select | DatePicker | Slider | CheckBoxGroup | RadioGroup, hint?: string) — Field with label, input component, and optional hint text Label(text: string) — Text label Input(name: string, placeholder?: string, type?: "text" | "email" | "password" | "number" | "url", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) TextArea(name: string, placeholder?: string, rows?: number, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) Select(name: string, items: SelectItem[], placeholder?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) SelectItem(value: string, label: string) — Option for Select -DatePicker(name: string, mode?: "single" | "range", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?) +DatePicker(name: string, mode?: "single" | "range", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) Slider(name: string, variant: "continuous" | "discrete", min: number, max: number, step?: number, defaultValue?: number[], label?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) — Numeric slider input; supports continuous and discrete (stepped) variants -CheckBoxGroup(name: string, items: CheckBoxItem[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?) +CheckBoxGroup(name: string, items: CheckBoxItem[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding>) CheckBoxItem(label: string, description: string, name: string, defaultChecked?: boolean) RadioGroup(name: string, items: RadioItem[], defaultValue?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) RadioItem(label: string, description: string, value: string) -SwitchGroup(name: string, items: SwitchItem[], variant?: "clear" | "card" | "sunk", value?) — Group of switch toggles +SwitchGroup(name: string, items: SwitchItem[], variant?: "clear" | "card" | "sunk", value?: $binding>) — Group of switch toggles SwitchItem(label?: string, description?: string, name: string, defaultChecked?: boolean) — Individual switch toggle - Define EACH FormControl as its own reference — do NOT inline all controls in one array. - NEVER nest Form inside Form. diff --git a/docs/generated/playground-component-spec.json b/docs/generated/playground-component-spec.json index d36586a0b..a1001e93c 100644 --- a/docs/generated/playground-component-spec.json +++ b/docs/generated/playground-component-spec.json @@ -46,7 +46,7 @@ "description": "Data table — column-oriented. Each Col holds its own data array." }, "Col": { - "signature": "Col(label: string, data, type?: \"string\" | \"number\" | \"action\")", + "signature": "Col(label: string, data: any, type?: \"string\" | \"number\" | \"action\")", "description": "Column definition — holds label + data array" }, "BarChart": { @@ -102,7 +102,7 @@ "description": "Data point with numeric coordinates" }, "Form": { - "signature": "Form(name: string, buttons: Buttons, fields)", + "signature": "Form(name: string, buttons: Buttons, fields?: FormControl[])", "description": "Form container with fields and explicit action buttons" }, "FormControl": { @@ -130,7 +130,7 @@ "description": "Option for Select" }, "DatePicker": { - "signature": "DatePicker(name: string, mode?: \"single\" | \"range\", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?)", + "signature": "DatePicker(name: string, mode?: \"single\" | \"range\", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding)", "description": "" }, "Slider": { @@ -138,7 +138,7 @@ "description": "Numeric slider input; supports continuous and discrete (stepped) variants" }, "CheckBoxGroup": { - "signature": "CheckBoxGroup(name: string, items: CheckBoxItem[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?)", + "signature": "CheckBoxGroup(name: string, items: CheckBoxItem[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding>)", "description": "" }, "CheckBoxItem": { @@ -154,7 +154,7 @@ "description": "" }, "SwitchGroup": { - "signature": "SwitchGroup(name: string, items: SwitchItem[], variant?: \"clear\" | \"card\" | \"sunk\", value?)", + "signature": "SwitchGroup(name: string, items: SwitchItem[], variant?: \"clear\" | \"card\" | \"sunk\", value?: $binding>)", "description": "Group of switch toggles" }, "SwitchItem": { @@ -170,7 +170,7 @@ "description": "Group of Button components. direction: \"row\" (default) | \"column\"." }, "Stack": { - "signature": "Stack([children], direction?: \"row\" | \"column\", gap?: \"none\" | \"xs\" | \"s\" | \"m\" | \"l\" | \"xl\" | \"2xl\", align?: \"start\" | \"center\" | \"end\" | \"stretch\" | \"baseline\", justify?: \"start\" | \"center\" | \"end\" | \"between\" | \"around\" | \"evenly\", wrap?: boolean)", + "signature": "Stack(children: any[], direction?: \"row\" | \"column\", gap?: \"none\" | \"xs\" | \"s\" | \"m\" | \"l\" | \"xl\" | \"2xl\", align?: \"start\" | \"center\" | \"end\" | \"stretch\" | \"baseline\", justify?: \"start\" | \"center\" | \"end\" | \"between\" | \"around\" | \"evenly\", wrap?: boolean)", "description": "Flex container. direction: \"row\"|\"column\" (default \"column\"). gap: \"none\"|\"xs\"|\"s\"|\"m\"|\"l\"|\"xl\"|\"2xl\" (default \"m\"). align: \"start\"|\"center\"|\"end\"|\"stretch\"|\"baseline\". justify: \"start\"|\"center\"|\"end\"|\"between\"|\"around\"|\"evenly\"." }, "Tabs": { @@ -265,7 +265,10 @@ }, { "name": "Tables", - "components": ["Table", "Col"], + "components": [ + "Table", + "Col" + ], "notes": [ "- Table is COLUMN-oriented: Table([Col(\"Label\", dataArray), Col(\"Count\", countArray, \"number\")]). Use array pluck for data: data.rows.fieldName", "- Col data can be component arrays for styled cells: Col(\"Status\", @Each(data.rows, \"item\", Tag(item.status, null, \"sm\", item.status == \"open\" ? \"success\" : \"danger\")))", @@ -295,7 +298,12 @@ }, { "name": "Charts (1D)", - "components": ["PieChart", "RadialChart", "SingleStackedBarChart", "Slice"], + "components": [ + "PieChart", + "RadialChart", + "SingleStackedBarChart", + "Slice" + ], "notes": [ "- PieChart and BarChart need NUMBERS, not objects. For list data, use @Count(@Filter(...)) to aggregate:", "- PieChart from list: `PieChart([\"Low\", \"Med\", \"High\"], [@Count(@Filter(data.rows, \"priority\", \"==\", \"low\")), @Count(@Filter(data.rows, \"priority\", \"==\", \"medium\")), @Count(@Filter(data.rows, \"priority\", \"==\", \"high\"))], \"donut\")`", @@ -304,7 +312,11 @@ }, { "name": "Charts (Scatter)", - "components": ["ScatterChart", "ScatterSeries", "Point"] + "components": [ + "ScatterChart", + "ScatterSeries", + "Point" + ] }, { "name": "Forms", @@ -338,14 +350,20 @@ }, { "name": "Buttons", - "components": ["Button", "Buttons"], + "components": [ + "Button", + "Buttons" + ], "notes": [ "- Toggle in @Each: @Each(rows, \"t\", Button(t.status == \"open\" ? \"Close\" : \"Reopen\", Action([...])))" ] }, { "name": "Data Display", - "components": ["TagBlock", "Tag"], + "components": [ + "TagBlock", + "Tag" + ], "notes": [ "- Color-mapped Tag: Tag(value, null, \"sm\", value == \"high\" ? \"danger\" : value == \"medium\" ? \"warning\" : \"neutral\")" ] diff --git a/docs/generated/playground-system-prompt.txt b/docs/generated/playground-system-prompt.txt index ba91045c0..9124f44a6 100644 --- a/docs/generated/playground-system-prompt.txt +++ b/docs/generated/playground-system-prompt.txt @@ -18,7 +18,7 @@ Props typed `ActionExpression` accept an Action([@steps...]) expression. See the Props marked `$binding` accept a `$variable` reference for two-way binding. ### Layout -Stack([children], direction?: "row" | "column", gap?: "none" | "xs" | "s" | "m" | "l" | "xl" | "2xl", align?: "start" | "center" | "end" | "stretch" | "baseline", justify?: "start" | "center" | "end" | "between" | "around" | "evenly", wrap?: boolean) — Flex container. direction: "row"|"column" (default "column"). gap: "none"|"xs"|"s"|"m"|"l"|"xl"|"2xl" (default "m"). align: "start"|"center"|"end"|"stretch"|"baseline". justify: "start"|"center"|"end"|"between"|"around"|"evenly". +Stack(children: any[], direction?: "row" | "column", gap?: "none" | "xs" | "s" | "m" | "l" | "xl" | "2xl", align?: "start" | "center" | "end" | "stretch" | "baseline", justify?: "start" | "center" | "end" | "between" | "around" | "evenly", wrap?: boolean) — Flex container. direction: "row"|"column" (default "column"). gap: "none"|"xs"|"s"|"m"|"l"|"xl"|"2xl" (default "m"). align: "start"|"center"|"end"|"stretch"|"baseline". justify: "start"|"center"|"end"|"between"|"around"|"evenly". Tabs(items: TabItem[]) — Tabbed container TabItem(value: string, trigger: string, content: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps)[]) — value is unique id, trigger is tab label, content is array of components Accordion(items: AccordionItem[]) — Collapsible sections @@ -53,7 +53,7 @@ CodeBlock(language: string, codeString: string) — Syntax-highlighted code bloc ### Tables Table(columns: Col[]) — Data table — column-oriented. Each Col holds its own data array. -Col(label: string, data, type?: "string" | "number" | "action") — Column definition — holds label + data array +Col(label: string, data: any, type?: "string" | "number" | "action") — Column definition — holds label + data array - Table is COLUMN-oriented: Table([Col("Label", dataArray), Col("Count", countArray, "number")]). Use array pluck for data: data.rows.fieldName - Col data can be component arrays for styled cells: Col("Status", @Each(data.rows, "item", Tag(item.status, null, "sm", item.status == "open" ? "success" : "danger"))) - Row actions: Col("Actions", @Each(data.rows, "t", Button("Edit", Action([@Set($showEdit, true), @Set($editId, t.id)])))) @@ -89,20 +89,20 @@ ScatterSeries(name: string, points: Point[]) — Named dataset Point(x: number, y: number, z?: number) — Data point with numeric coordinates ### Forms -Form(name: string, buttons: Buttons, fields) — Form container with fields and explicit action buttons +Form(name: string, buttons: Buttons, fields?: FormControl[]) — Form container with fields and explicit action buttons FormControl(label: string, input: Input | TextArea | Select | DatePicker | Slider | CheckBoxGroup | RadioGroup, hint?: string) — Field with label, input component, and optional hint text Label(text: string) — Text label Input(name: string, placeholder?: string, type?: "text" | "email" | "password" | "number" | "url", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) TextArea(name: string, placeholder?: string, rows?: number, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) Select(name: string, items: SelectItem[], placeholder?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) SelectItem(value: string, label: string) — Option for Select -DatePicker(name: string, mode?: "single" | "range", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?) +DatePicker(name: string, mode?: "single" | "range", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) Slider(name: string, variant: "continuous" | "discrete", min: number, max: number, step?: number, defaultValue?: number[], label?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) — Numeric slider input; supports continuous and discrete (stepped) variants -CheckBoxGroup(name: string, items: CheckBoxItem[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?) +CheckBoxGroup(name: string, items: CheckBoxItem[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding>) CheckBoxItem(label: string, description: string, name: string, defaultChecked?: boolean) RadioGroup(name: string, items: RadioItem[], defaultValue?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) RadioItem(label: string, description: string, value: string) -SwitchGroup(name: string, items: SwitchItem[], variant?: "clear" | "card" | "sunk", value?) — Group of switch toggles +SwitchGroup(name: string, items: SwitchItem[], variant?: "clear" | "card" | "sunk", value?: $binding>) — Group of switch toggles SwitchItem(label?: string, description?: string, name: string, defaultChecked?: boolean) — Individual switch toggle - For Form fields, define EACH FormControl as its own reference — do NOT inline all controls in one array. This allows progressive field-by-field streaming. - NEVER nest Form inside Form — each Form should be a standalone container. diff --git a/examples/openui-dashboard/README.md b/examples/openui-dashboard/README.md index ad2d96af8..e45ad452b 100644 --- a/examples/openui-dashboard/README.md +++ b/examples/openui-dashboard/README.md @@ -5,10 +5,10 @@ A live dashboard builder powered by [OpenUI](https://openui.com) and openui-lang ## Features - **Conversational dashboard building** — describe what you want, get a live dashboard -- **MCP tool integration** — Query live data sources (PostHog, server health, tickets) +- **MCP tool integration** — Linear workspace data via hosted MCP (`https://mcp.linear.app/mcp`) - **Streaming rendering** — dashboards appear progressively as the LLM generates code - **Edit support** — refine dashboards through follow-up messages -- **16 built-in tools** — analytics, monitoring, ticket management, and more +- **Linear MCP tools** — issues, projects, teams, and more (tool list comes from Linear at runtime) ## Getting Started @@ -20,6 +20,10 @@ export OPENAI_API_KEY=sk-... # export LLM_BASE_URL=https://openrouter.ai/api/v1 # export LLM_MODEL=your-model +# Linear MCP — API key or OAuth access token (server-only; used by /api/mcp and /api/chat) +# See https://linear.app/docs/mcp +export LINEAR_API_KEY=lin_api_... + # Install dependencies pnpm install @@ -29,15 +33,6 @@ pnpm dev Open [http://localhost:3000](http://localhost:3000) to start building dashboards. -## Optional: PostHog Integration - -For real analytics data, set PostHog credentials: - -```bash -export POSTHOG_API_KEY=phx_... -export POSTHOG_PROJECT_ID=12345 -``` - ## Learn More - [OpenUI Documentation](https://openui.com/docs) diff --git a/examples/openui-dashboard/src/app/api/chat/route.ts b/examples/openui-dashboard/src/app/api/chat/route.ts index b2bcaaeaf..2e56b43bd 100644 --- a/examples/openui-dashboard/src/app/api/chat/route.ts +++ b/examples/openui-dashboard/src/app/api/chat/route.ts @@ -3,15 +3,14 @@ import OpenAI from "openai"; import type { ChatCompletionMessageParam } from "openai/resources/chat/completions.mjs"; import { generatePrompt } from "@openuidev/lang-core"; import { promptSpec } from "@/prompt-config"; -import { tools as toolDefs } from "@/tools"; import { sseResponseFromRunner } from "@/lib/sse-stream"; +import { createConnectedLinearMcpClient } from "@/lib/linear-mcp"; +import { linearMcpToolsToOpenAI } from "@/lib/linear-openai-tools"; -const tools = toolDefs.map((t) => t.toOpenAITool()); - -function buildSystemPrompt(): string { +function buildSystemPrompt(toolSpecs: ReturnType["toolSpecs"]): string { return generatePrompt({ ...promptSpec, - tools: toolDefs.map((t) => t.toToolSpec()), + tools: toolSpecs, }); } @@ -29,14 +28,43 @@ export async function POST(req: NextRequest) { ); } + let mcpClient; + try { + mcpClient = await createConnectedLinearMcpClient(); + } catch (e) { + const msg = e instanceof Error ? e.message : "Linear MCP connection failed"; + return new Response(JSON.stringify({ error: msg }), { status: 500 }); + } + + const { tools: mcpTools } = await mcpClient.listTools(); + const { openaiTools, toolSpecs } = linearMcpToolsToOpenAI(mcpTools, mcpClient); + const client = new OpenAI({ apiKey, baseURL }); const runner = client.chat.completions.runTools({ model, - messages: [{ role: "system" as const, content: buildSystemPrompt() }, ...messages], - tools, + messages: [{ role: "system" as const, content: buildSystemPrompt(toolSpecs) }, ...messages], + tools: openaiTools, stream: true, - // reasoning: { effort: "low" }, + stream_options: { include_usage: true }, }); + runner.on("totalUsage", (usage) => { + console.log("[chat] Token usage (total):", usage); + }); + runner.on("chatCompletion", (completion) => { + if (completion.usage) { + console.log("[chat] Token usage (round):", completion.usage); + } + }); + + let mcpClosed = false; + const closeMcp = () => { + if (mcpClosed) return; + mcpClosed = true; + void mcpClient.close(); + }; + runner.on("end", closeMcp); + runner.on("error", closeMcp); + return sseResponseFromRunner(runner); } diff --git a/examples/openui-dashboard/src/app/api/mcp/route.ts b/examples/openui-dashboard/src/app/api/mcp/route.ts index d12e4b3d5..d8f437261 100644 --- a/examples/openui-dashboard/src/app/api/mcp/route.ts +++ b/examples/openui-dashboard/src/app/api/mcp/route.ts @@ -1,60 +1,66 @@ /** - * MCP Server — exposes all tools via MCP protocol. + * Proxies MCP (Streamable HTTP) to Linear's hosted server. + * Adds Authorization: Bearer from LINEAR_API_KEY / LINEAR_TOKEN / LINEAR_OAUTH_TOKEN + * so the token never ships to the browser. * - * Tool definitions live in src/tools.ts (shared with /api/chat). - * This file only sets up the MCP transport and registers tools. + * @see https://linear.app/docs/mcp */ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; -import { tools } from "@/tools"; - -// ── MCP Server factory ─────────────────────────────────────────────────────── - -function createServer(): McpServer { - const server = new McpServer( - { name: "openui-tools", version: "1.0.0" }, - ); - - for (const tool of tools) { - server.registerTool(tool.name, { - description: tool.description, - inputSchema: tool.inputSchema, - }, async (args) => ({ - content: [{ type: "text" as const, text: JSON.stringify(await tool.execute(args)) }], - })); - } +import { LINEAR_MCP_URL, getLinearBearerToken } from "@/lib/linear-mcp"; + +const HOP_BY_HOP = new Set([ + "connection", + "content-length", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); - return server; +function forwardRequestHeaders(req: Request, bearer: string): Headers { + const out = new Headers(); + req.headers.forEach((value, key) => { + if (!HOP_BY_HOP.has(key.toLowerCase())) out.append(key, value); + }); + out.set("Authorization", `Bearer ${bearer}`); + return out; } -// ── Request handler ────────────────────────────────────────────────────────── +async function proxyToLinear(req: Request): Promise { + const token = getLinearBearerToken(); + if (!token) { + return new Response( + JSON.stringify({ + error: "Set LINEAR_API_KEY (or LINEAR_TOKEN / LINEAR_OAUTH_TOKEN) to use Linear MCP.", + }), + { status: 500, headers: { "Content-Type": "application/json" } }, + ); + } -async function handleMcpRequest(request: Request): Promise { - const transport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: undefined, // stateless - enableJsonResponse: true, - }); - const server = createServer(); - await server.connect(transport); - - try { - return await transport.handleRequest(request); - } finally { - await transport.close(); - await server.close(); + const headers = forwardRequestHeaders(req, token); + const init: RequestInit & { duplex?: "half" } = { + method: req.method, + headers, + }; + if (req.method !== "GET" && req.method !== "HEAD" && req.body) { + init.body = req.body; + init.duplex = "half"; } -} -// ── Next.js route exports ──────────────────────────────────────────────────── + return fetch(LINEAR_MCP_URL, init); +} export async function POST(req: Request) { - return handleMcpRequest(req); + return proxyToLinear(req); } export async function GET(req: Request) { - return handleMcpRequest(req); + return proxyToLinear(req); } export async function DELETE(req: Request) { - return handleMcpRequest(req); + return proxyToLinear(req); } diff --git a/examples/openui-dashboard/src/app/dashboard/page.tsx b/examples/openui-dashboard/src/app/dashboard/page.tsx index ad7911616..233261b9d 100644 --- a/examples/openui-dashboard/src/app/dashboard/page.tsx +++ b/examples/openui-dashboard/src/app/dashboard/page.tsx @@ -5,5 +5,5 @@ import { OpenUIDashboard } from "@/components/OpenUIDashboard"; import { STARTERS } from "@/starters"; export default function DashboardPage() { - return ; + return ; } diff --git a/examples/openui-dashboard/src/lib/linear-mcp.ts b/examples/openui-dashboard/src/lib/linear-mcp.ts new file mode 100644 index 000000000..ac0d28d1f --- /dev/null +++ b/examples/openui-dashboard/src/lib/linear-mcp.ts @@ -0,0 +1,31 @@ +/** + * Linear hosted MCP — https://mcp.linear.app/mcp + * Auth: Authorization: Bearer + * (see https://linear.app/docs/mcp) + */ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +export const LINEAR_MCP_URL = "https://mcp.linear.app/mcp"; + +/** Personal API key, OAuth access token, or restricted key — never expose to the browser. */ +export function getLinearBearerToken(): string | undefined { + return process.env.LINEAR_API_KEY ?? process.env.LINEAR_TOKEN ?? process.env.LINEAR_OAUTH_TOKEN; +} + +export async function createConnectedLinearMcpClient(): Promise { + const token = getLinearBearerToken(); + if (!token) { + throw new Error("Set LINEAR_API_KEY (or LINEAR_TOKEN / LINEAR_OAUTH_TOKEN) for Linear MCP."); + } + const client = new Client({ name: "openui-dashboard", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport(new URL(LINEAR_MCP_URL), { + requestInit: { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + }); + await client.connect(transport); + return client; +} diff --git a/examples/openui-dashboard/src/lib/linear-openai-tools.ts b/examples/openui-dashboard/src/lib/linear-openai-tools.ts new file mode 100644 index 000000000..973b176e2 --- /dev/null +++ b/examples/openui-dashboard/src/lib/linear-openai-tools.ts @@ -0,0 +1,60 @@ +/** + * Map Linear MCP tools to OpenAI runTools() shape + lang-core ToolSpec for prompts. + */ +import type { Tool as McpTool } from "@modelcontextprotocol/sdk/types.js"; +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { ToolSpec } from "@openuidev/lang-core"; +import { extractToolResult, McpToolError } from "@openuidev/lang-core"; +import type { RunnableToolFunction } from "openai/lib/RunnableFunction"; +import type { JSONSchema } from "openai/lib/jsonschema"; + +function mcpToolToToolSpec(t: McpTool): ToolSpec { + return { + name: t.name, + description: t.description, + inputSchema: (t.inputSchema ?? {}) as Record, + outputSchema: {}, + }; +} + +function mcpToolToRunnable( + t: McpTool, + client: Client, +): RunnableToolFunction> { + return { + type: "function", + function: { + name: t.name, + description: t.description ?? "", + parameters: (t.inputSchema ?? { type: "object", properties: {} }) as JSONSchema, + function: async (args: Record) => { + try { + const result = await client.callTool({ name: t.name, arguments: args }); + const data = extractToolResult( + result as Parameters[0], + ); + return typeof data === "string" ? data : JSON.stringify(data); + } catch (e) { + if (e instanceof McpToolError) { + return JSON.stringify({ error: e.toolErrorText }); + } + throw e; + } + }, + parse: JSON.parse, + }, + }; +} + +export function linearMcpToolsToOpenAI( + mcpTools: McpTool[], + client: Client, +): { + openaiTools: RunnableToolFunction>[]; + toolSpecs: ToolSpec[]; +} { + return { + openaiTools: mcpTools.map((tool) => mcpToolToRunnable(tool, client)), + toolSpecs: mcpTools.map(mcpToolToToolSpec), + }; +} diff --git a/examples/openui-dashboard/src/lib/llm-stream.ts b/examples/openui-dashboard/src/lib/llm-stream.ts index afbadd8e2..1389a904f 100644 --- a/examples/openui-dashboard/src/lib/llm-stream.ts +++ b/examples/openui-dashboard/src/lib/llm-stream.ts @@ -48,6 +48,9 @@ export async function streamChat( if (tc.status === "calling") tc.status = "done"; } onToolCall([...activeCalls]); + if (lastUsage) { + console.log("[llm] Token usage:", lastUsage); + } onDone(lastUsage); return; } @@ -83,5 +86,8 @@ export async function streamChat( } catch { /* skip malformed chunks */ } } } + if (lastUsage) { + console.log("[llm] Token usage:", lastUsage); + } onDone(lastUsage); } diff --git a/examples/openui-dashboard/src/lib/sse-stream.ts b/examples/openui-dashboard/src/lib/sse-stream.ts index aedfe4c50..f93993689 100644 --- a/examples/openui-dashboard/src/lib/sse-stream.ts +++ b/examples/openui-dashboard/src/lib/sse-stream.ts @@ -92,8 +92,10 @@ export function sseResponseFromRunner( runner.on("chunk", (chunk) => { const choice = chunk.choices?.[0]; - if (!choice?.delta) return; - if (choice.delta.content || choice.finish_reason === "stop") { + const hasContent = + choice?.delta && (Boolean(choice.delta.content) || choice.finish_reason === "stop"); + // Include usage (stream_options.include_usage) so the client can log tokens. + if (chunk.usage || hasContent) { send(sseEvent(chunk)); } }); diff --git a/examples/openui-dashboard/src/prompt-config.ts b/examples/openui-dashboard/src/prompt-config.ts index 3247b19f1..711f8bec1 100644 --- a/examples/openui-dashboard/src/prompt-config.ts +++ b/examples/openui-dashboard/src/prompt-config.ts @@ -12,67 +12,29 @@ export const promptSpec: PromptSpec = { editMode: true, inlineMode: true, toolExamples: [ - `Example — Usage Overview Dashboard: -root = Stack([header, controls, kpiRow, trendCard]) -header = CardHeader("Usage Overview", "Events, users and errors over time") -$days = "14" -controls = Stack([filterRow], "row", "m", "end", "between") -filterRow = FormControl("Date Range", Select("days", [r7, r14, r30], null, null, $days)) -r7 = SelectItem("7", "Last 7 days") -r14 = SelectItem("14", "Last 14 days") -r30 = SelectItem("30", "Last 30 days") -metrics = Query("get_usage_metrics", {dateRange: $days}, {totalEvents: 0, totalUsers: 0, totalErrors: 0, totalCost: 0, data: []}) -kpiRow = Stack([kpi1, kpi2, kpi3, kpi4], "row", "m", "stretch", "start", true) -kpi1 = Card([TextContent("Total Events", "small"), TextContent("" + metrics.totalEvents, "large-heavy")]) -kpi2 = Card([TextContent("Total Users", "small"), TextContent("" + metrics.totalUsers, "large-heavy")]) -kpi3 = Card([TextContent("Errors", "small"), TextContent("" + metrics.totalErrors, "large-heavy")]) -kpi4 = Card([TextContent("Cost", "small"), TextContent("$" + @Round(metrics.totalCost, 2), "large-heavy")]) -trendCard = Card([CardHeader("Daily Trend"), LineChart(metrics.data.day, [Series("Events", metrics.data.events), Series("Users", metrics.data.users), Series("Errors", metrics.data.errors)])])`, + `Example — Linear issues overview (use real tool names from the injected tool list): +root = Stack([header, tableCard]) +header = CardHeader("My issues", "From Linear MCP — adjust Query() name/args to match your workspace tools") +issues = Query("", { }, { issues: [] }) +tableCard = Card([CardHeader("Issues"), Table([Col("Title", issues.issues.title), Col("State", issues.issues.state), Col("Team", issues.issues.team)])])`, - `Example — Server Health Dashboard: -root = Stack([header, kpiRow, trendCard]) -header = CardHeader("Server Monitoring", "Auto-refreshes every 30 seconds") -health = Query("get_server_health", {}, {cpu: 0, memory: 0, latencyP95: 0, errorRate: 0, timeseries: []}, 30) -kpiRow = Stack([cpuCard, memCard, latCard, errCard], "row", "m", "stretch", "start", true) -cpuCard = Card([TextContent("CPU", "small"), TextContent("" + @Round(health.cpu, 1) + "%", "large-heavy")]) -memCard = Card([TextContent("Memory", "small"), TextContent("" + @Round(health.memory, 1) + "%", "large-heavy")]) -latCard = Card([TextContent("Latency P95", "small"), TextContent("" + @Round(health.latencyP95, 0) + " ms", "large-heavy")]) -errCard = Card([TextContent("Error Rate", "small"), TextContent("" + @Round(health.errorRate, 2) + "%", "large-heavy")]) -trendCard = Card([CardHeader("24-Hour Trend"), LineChart(health.timeseries.time, [Series("CPU", health.timeseries.cpu), Series("Memory", health.timeseries.memory), Series("Latency", health.timeseries.latencyP95)])])`, - - `Example — Analytics deep-dive (endpoints, errors, geo, funnel): -root = Stack([header, row1, row2]) -header = CardHeader("Analytics Deep-Dive", "Endpoints, errors, geography and conversion") -endpoints = Query("get_top_endpoints", {limit: 10}, {endpoints: []}) -errors = Query("get_error_breakdown", {}, {errors: []}) -geo = Query("get_geo_usage", {}, {regions: []}) -funnel = Query("get_funnel_metrics", {}, {steps: []}) -row1 = Stack([endpointsCard, errorsCard], "row", "m", "stretch") -endpointsCard = Card([CardHeader("Top Endpoints"), Table([Col("Path", endpoints.endpoints.path), Col("Requests", endpoints.endpoints.requests, "number"), Col("Avg Latency (ms)", endpoints.endpoints.avgLatency, "number"), Col("Error Rate", endpoints.endpoints.errorRate, "number")])]) -errorsCard = Card([CardHeader("Error Breakdown"), PieChart(errors.errors.category, errors.errors.count)]) -row2 = Stack([geoCard, funnelCard], "row", "m", "stretch") -geoCard = Card([CardHeader("Geographic Usage"), Table([Col("Region", geo.regions.region), Col("Users", geo.regions.users, "number"), Col("Events", geo.regions.events, "number")])]) -funnelCard = Card([CardHeader("Conversion Funnel"), BarChart(funnel.steps.step, [Series("Users", funnel.steps.users)])])`, + `Example — Single issue KPIs (shape depends on tool response — use defaults that match the schema): +root = Stack([header, row]) +header = CardHeader("Sprint snapshot", "Pull data with Query then bind to cards") +data = Query("", { }, { count: 0, completed: 0 }) +row = Stack([c1, c2], "row", "m", "stretch", "start", true) +c1 = Card([TextContent("Open", "small"), TextContent("" + data.count, "large-heavy")]) +c2 = Card([TextContent("Done", "small"), TextContent("" + data.completed, "large-heavy")])`, ], additionalRules: [ - "For time-series analytics, use get_usage_metrics with dateRange as a numeric string (\"7\", \"14\", \"30\")", - "For server monitoring / CPU / memory / latency, use get_server_health — it returns a 24h timeseries", - "For endpoint performance, use get_top_endpoints; for error categories, use get_error_breakdown", - "For geographic breakdown, use get_geo_usage; for conversion funnel, use get_funnel_metrics", - "For A/B testing results, use get_experiment_results; for resource cost breakdown, use get_resource_breakdown", - "Prefer including a date range selector for dashboards that use get_usage_metrics or get_top_endpoints", - 'dateRange values are numeric strings: "7", "14", "30", "90". Do not use suffixes like "d" or "h".', + "Tools come from Linear MCP — use the exact tool names and inputSchema from the injected tool list in the system prompt.", + "Call Query() with arguments that satisfy the Linear tool schema (IDs, filters, team keys, etc.).", + "Prefer read-only or safe operations unless the user explicitly asks to create or update Linear data.", + "If a tool returns nested objects or arrays, align Table/Chart column paths with the actual response shape.", ], preamble: `You are an AI assistant that builds dashboards using openui-lang, a declarative UI language. -## Available Tools +## Linear MCP -- **get_usage_metrics** — events, users, errors, cost over time. Returns \`totalEvents\`, \`totalUsers\`, \`totalErrors\`, \`totalCost\`, and \`data[]\` (per-day rows with \`day\`, \`events\`, \`users\`, \`errors\`, \`cost\`). Accepts optional \`dateRange\` (numeric string of days). -- **get_top_endpoints** — top API endpoints by request count. Returns \`endpoints[]\` with \`path\`, \`requests\`, \`avgLatency\`, \`errorRate\`. Accepts optional \`limit\` and \`dateRange\`. -- **get_resource_breakdown** — usage split by resource type (API, Web App, Mobile, Webhook). Returns \`resources[]\` with \`name\`, \`events\`, \`users\`, \`cost\`. -- **get_error_breakdown** — errors by category. Returns \`errors[]\` with \`category\` and \`count\`. -- **get_server_health** — current CPU, memory, latency, error rate plus 24h timeseries. Returns scalar fields and \`timeseries[]\` with \`time\`, \`cpu\`, \`memory\`, \`latencyP95\`. -- **get_experiment_results** — A/B test variants. Returns \`variants[]\` with \`variant\`, \`conversionRate\`, \`users\`. -- **get_geo_usage** — usage by region. Returns \`regions[]\` with \`region\`, \`users\`, \`events\`. -- **get_funnel_metrics** — conversion funnel steps. Returns \`steps[]\` with \`step\` and \`users\`.`, +Workspace data is provided by **Linear's hosted MCP** (issues, projects, teams, comments, and related operations). Tool names and parameters are listed in the system prompt — follow them exactly when using Query() or Mutation().`, }; diff --git a/examples/openui-dashboard/src/tools.ts b/examples/openui-dashboard/src/tools.ts index 6b1871536..42ae5df4a 100644 --- a/examples/openui-dashboard/src/tools.ts +++ b/examples/openui-dashboard/src/tools.ts @@ -1,235 +1,10 @@ /** - * Shared tool registry — single source of truth for all tools. + * Legacy shared tool registry — the dashboard now uses Linear's hosted MCP + * (`https://mcp.linear.app/mcp`) for both the browser client and the chat API. * - * Consumed by: - * - /api/mcp/route.ts (MCP server, uses Zod inputSchema directly) - * - /api/chat/route.ts (OpenAI function-calling, via toOpenAITool()) + * - `/api/mcp` proxies to Linear with `Authorization: Bearer` from env (see `lib/linear-mcp.ts`). + * - `/api/chat` lists tools from Linear MCP and forwards tool calls to the same server. + * + * `ToolDef` remains exported for any custom tooling you add alongside Linear. */ -import { z } from "zod"; -import { ToolDef } from "./lib/tool-def"; - -// ── Helpers ────────────────────────────────────────────────────────────────── - -function dateOffset(daysAgo: number): string { - const d = new Date(); - d.setDate(d.getDate() + daysAgo); - return d.toISOString().slice(0, 10); -} - -function generateTimeseries(count: number, fn: (index: number) => T): T[] { - return Array.from({ length: count }, (_, i) => fn(i)); -} - -// ── Mock data generators ───────────────────────────────────────────────────── - -function getUsageMetrics(args: Record) { - const days = Number(args.dateRange ?? args.days ?? 14); - return { - totalEvents: 48200 + Math.floor(days * 120), - totalUsers: 3200 + Math.floor(days * 40), - totalErrors: 142 + Math.floor(days * 3), - totalCost: 1250.5 + days * 15, - data: generateTimeseries(days, (i) => ({ - day: dateOffset(-days + i), - events: 2800 + Math.floor(Math.random() * 1200), - users: 180 + Math.floor(Math.random() * 80), - errors: 5 + Math.floor(Math.random() * 15), - cost: 70 + Math.random() * 30, - })), - }; -} - -function getTopEndpoints(args: Record) { - const limit = Number(args.limit ?? 10); - const paths = ["/api/users", "/api/events", "/api/auth", "/api/data", "/api/search", "/api/upload", "/api/export", "/api/notify", "/api/billing", "/api/health"]; - return { - endpoints: Array.from({ length: limit }, (_, i) => ({ - path: paths[i % 10], - requests: 12000 - i * 900 + Math.floor(Math.random() * 400), - avgLatency: 45 + i * 12 + Math.floor(Math.random() * 20), - errorRate: Math.round((0.5 + i * 0.3 + Math.random() * 0.5) * 100) / 100, - })), - }; -} - -function getResourceBreakdown() { - return { - resources: [ - { name: "API", events: 22000, users: 1800, cost: 450 }, - { name: "Web App", events: 18000, users: 2400, cost: 380 }, - { name: "Mobile", events: 8200, users: 900, cost: 220 }, - { name: "Webhook", events: 3500, users: 120, cost: 95 }, - ], - }; -} - -function getErrorBreakdown() { - return { - errors: [ - { category: "TimeoutError", count: 45 }, - { category: "AuthError", count: 32 }, - { category: "RateLimitError", count: 28 }, - { category: "ValidationError", count: 22 }, - { category: "NotFoundError", count: 15 }, - ], - }; -} - -function getServerHealth() { - return { - cpu: 42 + Math.floor(Math.random() * 20), - memory: 68 + Math.floor(Math.random() * 15), - latencyP95: 120 + Math.floor(Math.random() * 60), - errorRate: Math.round((1.2 + Math.random() * 0.8) * 100) / 100, - timeseries: generateTimeseries(24, (i) => ({ - time: `${String(i).padStart(2, "0")}:00`, - cpu: 35 + Math.floor(Math.random() * 30), - memory: 60 + Math.floor(Math.random() * 20), - latencyP95: 80 + Math.floor(Math.random() * 80), - })), - }; -} - -function getExperimentResults() { - return { - variants: [ - { variant: "Control", conversionRate: 3.2, users: 5200 }, - { variant: "Variant A", conversionRate: 4.1, users: 5150 }, - { variant: "Variant B", conversionRate: 3.8, users: 5100 }, - ], - }; -} - -function getGeoUsage() { - return { - regions: [ - { region: "North America", users: 4200, events: 18000 }, - { region: "Europe", users: 3100, events: 14000 }, - { region: "Asia Pacific", users: 1800, events: 8000 }, - { region: "Latin America", users: 600, events: 2800 }, - { region: "Africa", users: 200, events: 900 }, - ], - }; -} - -function getFunnelMetrics() { - return { - steps: [ - { step: "Visit", users: 10000 }, - { step: "Sign Up", users: 3200 }, - { step: "Activate", users: 1800 }, - { step: "Subscribe", users: 450 }, - { step: "Retain (30d)", users: 320 }, - ], - }; -} - -// ── Tool registry ─────────────────────────────────────────────────────────── - export { ToolDef } from "./lib/tool-def"; - -export const tools: ToolDef[] = [ - new ToolDef({ - name: "get_usage_metrics", - description: "Get usage metrics for the specified date range", - inputSchema: z.object({ - dateRange: z.string().optional(), - days: z.string().optional(), - resource: z.string().optional(), - }), - outputSchema: z.object({ - totalEvents: z.number(), - totalUsers: z.number(), - totalErrors: z.number(), - totalCost: z.number(), - data: z.array( - z.object({ - day: z.string(), - events: z.number(), - users: z.number(), - errors: z.number(), - cost: z.number(), - }), - ), - }), - execute: async (args) => getUsageMetrics(args), - }), - new ToolDef({ - name: "get_top_endpoints", - description: "Get top API endpoints by request count", - inputSchema: z.object({ limit: z.number().optional(), dateRange: z.string().optional() }), - outputSchema: z.object({ - endpoints: z.array( - z.object({ - path: z.string(), - requests: z.number(), - avgLatency: z.number(), - errorRate: z.number(), - }), - ), - }), - execute: async (args) => getTopEndpoints(args), - }), - new ToolDef({ - name: "get_resource_breakdown", - description: "Get resource usage breakdown by type", - inputSchema: z.object({}), - outputSchema: z.object({ - resources: z.array( - z.object({ name: z.string(), events: z.number(), users: z.number(), cost: z.number() }), - ), - }), - execute: async () => getResourceBreakdown(), - }), - new ToolDef({ - name: "get_error_breakdown", - description: "Get error breakdown by category", - inputSchema: z.object({}), - outputSchema: z.object({ - errors: z.array(z.object({ category: z.string(), count: z.number() })), - }), - execute: async () => getErrorBreakdown(), - }), - new ToolDef({ - name: "get_server_health", - description: "Get current server health metrics (CPU, memory, latency)", - inputSchema: z.object({}), - outputSchema: z.object({ - cpu: z.number(), - memory: z.number(), - latencyP95: z.number(), - errorRate: z.number(), - timeseries: z.array( - z.object({ time: z.string(), cpu: z.number(), memory: z.number(), latencyP95: z.number() }), - ), - }), - execute: async () => getServerHealth(), - }), - new ToolDef({ - name: "get_experiment_results", - description: "Get A/B experiment results with conversion rates", - inputSchema: z.object({}), - outputSchema: z.object({ - variants: z.array( - z.object({ variant: z.string(), conversionRate: z.number(), users: z.number() }), - ), - }), - execute: async () => getExperimentResults(), - }), - new ToolDef({ - name: "get_geo_usage", - description: "Get geographic usage breakdown by region", - inputSchema: z.object({}), - outputSchema: z.object({ - regions: z.array(z.object({ region: z.string(), users: z.number(), events: z.number() })), - }), - execute: async () => getGeoUsage(), - }), - new ToolDef({ - name: "get_funnel_metrics", - description: "Get conversion funnel metrics", - inputSchema: z.object({}), - outputSchema: z.object({ steps: z.array(z.object({ step: z.string(), users: z.number() })) }), - execute: async () => getFunnelMetrics(), - }), -];