#!/usr/bin/env node /** * Product OS MCP Adapter Server * * Exposes Control Plane tools to Continue and other MCP clients. * * Architecture: * Continue (MCP client) → This Adapter → Control Plane → Gemini + Executors * * This keeps: * - Control Plane as the canonical API * - Auth/billing/policies centralized * - MCP as a compatibility layer */ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, ToolSchema, } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; // Control Plane URL (configurable via env) const CONTROL_PLANE_URL = process.env.CONTROL_PLANE_URL || "http://localhost:8080"; const TENANT_ID = process.env.TENANT_ID || "t_mcp"; // ============================================================================ // Tool Definitions // ============================================================================ const TOOLS: ToolSchema[] = [ { name: "deploy_service", description: "Deploy a Cloud Run service to GCP. Use when the user wants to deploy, ship, or launch code to staging or production.", inputSchema: { type: "object", properties: { service_name: { type: "string", description: "Name of the service to deploy" }, repo: { type: "string", description: "Git repository URL (optional, defaults to current workspace)" }, ref: { type: "string", description: "Git branch, tag, or commit (optional, defaults to main)" }, env: { type: "string", enum: ["dev", "staging", "prod"], description: "Target environment" } }, required: ["service_name"] } }, { name: "get_service_status", description: "Check the status of a deployed Cloud Run service. Use when the user asks about service health or deployment status.", inputSchema: { type: "object", properties: { service_name: { type: "string", description: "Name of the service to check" }, region: { type: "string", description: "GCP region (defaults to us-central1)" } }, required: ["service_name"] } }, { name: "get_funnel_analytics", description: "Get funnel conversion metrics and drop-off analysis. Use when the user asks about funnels, conversions, or user journey.", inputSchema: { type: "object", properties: { funnel_name: { type: "string", description: "Name of the funnel to analyze (optional)" }, range_days: { type: "integer", description: "Number of days to analyze (defaults to 30)" } } } }, { name: "get_top_drivers", description: "Identify top factors driving a metric. Use when the user asks why something changed or what drives conversions/retention.", inputSchema: { type: "object", properties: { metric: { type: "string", description: "The metric to analyze (e.g., 'conversion', 'retention', 'churn')" }, range_days: { type: "integer", description: "Number of days to analyze (defaults to 30)" } }, required: ["metric"] } }, { name: "generate_marketing_posts", description: "Generate social media posts for a marketing campaign. Use when the user wants to create content for X, LinkedIn, etc.", inputSchema: { type: "object", properties: { goal: { type: "string", description: "Campaign goal (e.g., 'product launch', 'feature announcement', 'engagement')" }, product: { type: "string", description: "Product or feature name" }, channels: { type: "array", items: { type: "string" }, description: "Social channels to generate for (e.g., ['x', 'linkedin'])" }, tone: { type: "string", description: "Tone of voice (e.g., 'professional', 'casual', 'excited')" } }, required: ["goal"] } }, { name: "chat_with_gemini", description: "Have a conversation with Gemini AI about your product, code, or anything else. Use for general questions, code explanation, or ideation.", inputSchema: { type: "object", properties: { message: { type: "string", description: "The message or question to send to Gemini" }, context: { type: "string", description: "Additional context (e.g., code snippet, file contents)" } }, required: ["message"] } } ]; // ============================================================================ // Tool Name Mapping (MCP tool name → Control Plane tool name) // ============================================================================ const TOOL_MAPPING: Record = { "deploy_service": "cloudrun.deploy_service", "get_service_status": "cloudrun.get_service_status", "get_funnel_analytics": "analytics.funnel_summary", "get_top_drivers": "analytics.top_drivers", "generate_marketing_posts": "marketing.generate_channel_posts", }; // ============================================================================ // Control Plane Client // ============================================================================ async function invokeControlPlaneTool(tool: string, input: any): Promise { // Step 1: Invoke the tool const invokeResponse = await fetch(`${CONTROL_PLANE_URL}/tools/invoke`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tool, tenant_id: TENANT_ID, input, dry_run: false }) }); if (!invokeResponse.ok) { const text = await invokeResponse.text(); throw new Error(`Control Plane error: ${invokeResponse.status} - ${text}`); } const invokeResult = await invokeResponse.json(); // Step 2: Fetch the full run to get the output const runResponse = await fetch(`${CONTROL_PLANE_URL}/runs/${invokeResult.run_id}`); if (!runResponse.ok) { // Fall back to the invoke result if we can't fetch the run return invokeResult; } return runResponse.json(); } async function chatWithControlPlane(message: string, context?: string): Promise { const messages = []; if (context) { messages.push({ role: "user", content: `Context:\n${context}` }); } messages.push({ role: "user", content: message }); const response = await fetch(`${CONTROL_PLANE_URL}/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages, autoExecuteTools: true }) }); if (!response.ok) { const text = await response.text(); throw new Error(`Control Plane chat error: ${response.status} - ${text}`); } return response.json(); } // ============================================================================ // MCP Server // ============================================================================ const server = new Server( { name: "productos-mcp", version: "0.1.0", }, { capabilities: { tools: {}, }, } ); // List available tools server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: TOOLS }; }); // Handle tool calls server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; console.error(`[MCP] Tool called: ${name}`, args); try { // Special handling for chat if (name === "chat_with_gemini") { const result = await chatWithControlPlane( (args as any).message, (args as any).context ); return { content: [ { type: "text", text: result.message || JSON.stringify(result, null, 2) } ] }; } // Map MCP tool name to Control Plane tool name const controlPlaneTool = TOOL_MAPPING[name]; if (!controlPlaneTool) { throw new Error(`Unknown tool: ${name}`); } // Invoke the Control Plane const run = await invokeControlPlaneTool(controlPlaneTool, args); // Format the response let responseText = ""; if (run.status === "succeeded") { responseText = `✅ **${name}** completed successfully\n\n`; responseText += "**Result:**\n```json\n"; responseText += JSON.stringify(run.output, null, 2); responseText += "\n```"; } else if (run.status === "failed") { responseText = `❌ **${name}** failed\n\n`; responseText += `**Error:** ${run.error?.message || "Unknown error"}`; } else { responseText = `⏳ **${name}** is ${run.status}\n\n`; responseText += `**Run ID:** ${run.run_id}`; } return { content: [ { type: "text", text: responseText } ] }; } catch (error: any) { console.error(`[MCP] Error:`, error); return { content: [ { type: "text", text: `❌ Error executing ${name}: ${error.message}` } ], isError: true }; } }); // ============================================================================ // Start Server // ============================================================================ async function main() { console.error("[MCP] Product OS MCP Adapter starting..."); console.error(`[MCP] Control Plane URL: ${CONTROL_PLANE_URL}`); const transport = new StdioServerTransport(); await server.connect(transport); console.error("[MCP] Server connected and ready"); } main().catch((error) => { console.error("[MCP] Fatal error:", error); process.exit(1); });