diff --git a/.ai/PLAN.md b/.ai/PLAN.md deleted file mode 100644 index 3896183b..00000000 --- a/.ai/PLAN.md +++ /dev/null @@ -1,82 +0,0 @@ -# ENG-101: Frontend: Tool Mode & Agent Node UI Implementation Plan - -## Overview -This plan details the frontend implementation for supporting Agentic workflows, including a Tool Mode toggle for nodes, an MCP Server node type, and enhancements to the Run Timeline to visualize agent execution and reasoning. - -## User Review Required -> [!IMPORTANT] -> - Confirm if `core.mcp.server` component definition exists in the backend or if it needs to be mocked/created in frontend for now. -> - Clarify if "MCP Server node type in palette" implies a specific UI for browsing a catalog (e.g., distinct from normal list). We will implement it as a distinct category in the existing Sidebar for now. - -## Proposed Changes - -### 1. Tool Mode Toggle -**Goal**: Allow users to toggle nodes into "Tool Mode", changing their visual representation and port exposure. - -#### [MODIFY] [WorkflowNode.tsx](file:///Users/betterclever/shipsec/shipsec-studio/frontend/src/components/workflow/WorkflowNode.tsx) -- Add a "Tool Mode" toggle button to the node header (visible for agent-callable nodes). -- **State**: Track `isToolMode` state (likely in `node.data.config.isToolMode` or similar). -- **Rendering**: - - When `isToolMode` is active: - - Show "Exposed" inputs/outputs (the ones the Agent sees). - - Hide internal wiring ports not relevant to the Agent? Or show them differently? - - Apply a distinct visual style (e.g., "Tool" badge, different border color). - - **Visual Distinction**: "Visual distinction for tool calls vs normal nodes". - - Add a "Tool" icon/badge. - - Change border style (e.g., dashed vs solid, or a specific color like purple/indigo for tools). - -### 2. MCP Server Node & Palette -**Goal**: Add MCP Server nodes to the palette with catalog selection. - -#### [MODIFY] [Sidebar.tsx](file:///Users/betterclever/shipsec/shipsec-studio/frontend/src/components/layout/Sidebar.tsx) -- Add `mcp_server` to `categoryOrder` and `categoryColors`. -- Ensure MCP Server components are correctly categorized and displayed. -- **Catalog Selection**: - - If a specific Catalog UI is needed, we might need a "Add from Catalog" button in the `mcp_server` section or a separate view. - - *Plan*: Integrate into the existing accordion list for now, ensuring `mcp_server` category is prominent. - -#### [NEW] [MCPServerNode.tsx] (Optional) -- If MCP Server nodes require special rendering (e.g., connection status to external server), create a custom node type. -- *Default*: Use `WorkflowNode` but with "MCP" styling. - -### 3. Agent Node & Tools Port -**Goal**: Enhance the Agent Node UI to support multi-connection tools port. - -#### [MODIFY] [WorkflowNode.tsx](file:///Users/betterclever/shipsec/shipsec-studio/frontend/src/components/workflow/WorkflowNode.tsx) -- **Tools Port**: - - Ensure the `tools` input port (anchor) handles multiple connections visually. - - ReactFlow `Handle` supports `isConnectable={true}` (default). - - **Visual**: Style the "Tools" port distinctly (e.g., different shape or color) to indicate it's a "Tool Collection" port. - -### 4. Run Timeline Enhancements -**Goal**: Visualize agent execution, reasoning, and tool calls. - -#### [MODIFY] [ExecutionTimeline.tsx](file:///Users/betterclever/shipsec/shipsec-studio/frontend/src/components/timeline/ExecutionTimeline.tsx) -- **Expandable Tool Calls**: - - Show Agent events (steps) on the timeline track. - - Allow clicking an Agent event to expand/focus it. - - `agentMarkers` seem implemented. Ensure they are fully wired up to show sub-steps. - -#### [MODIFY] [AgentTracePanel.tsx](file:///Users/betterclever/shipsec/shipsec-studio/frontend/src/components/timeline/AgentTracePanel.tsx) -- **Thinking/Reasoning**: - - Ensure `step.thought` is displayed prominently (it is currently `ExpandableText`). - - **Tool Calls**: - - `AgentStepCard` shows tool calls. Improve visual distinction. - - Add "Thinking" section (e.g., "Agent is thinking..." animation or collapsible "Reasoning" block). - -## Verification Plan - -### Manual Verification -1. **Tool Mode**: - - Drag a component (e.g., "Recursive Web Scraper") to canvas. - - Toggle "Tool Mode". Verify visual change (border/badge) and port changes. - - Connect it to an Agent node's "Tools" port. -2. **MCP Server**: - - Check Palette for "MCP Servers" category. - - Drag an MCP Server node to canvas. -3. **Run Timeline**: - - Run a workflow with an Agent. - - Open "Execution" view. - - Check Timeline for Agent markers. - - Click Agent node -> Check "Agent Trace" panel. - - Verify "Thinking" and "Tool Calls" are shown clearly. diff --git a/backend/src/components/utils/categorization.ts b/backend/src/components/utils/categorization.ts index a2f8d11b..f3081054 100644 --- a/backend/src/components/utils/categorization.ts +++ b/backend/src/components/utils/categorization.ts @@ -12,6 +12,7 @@ const SUPPORTED_CATEGORIES: readonly ComponentCategory[] = [ 'input', 'transform', 'ai', + 'mcp', 'security', 'it_ops', 'notification', @@ -41,6 +42,13 @@ const COMPONENT_CATEGORY_CONFIG: Record(); + private readonly registeredToolNames = new Map>(); constructor( private readonly toolRegistry: ToolRegistryService, @@ -65,12 +66,42 @@ export class McpGatewayService { version: '1.0.0', }); - await this.registerTools(server, runId, allowedTools, allowedNodeIds); + const toolSet = new Set(); + this.registeredToolNames.set(cacheKey, toolSet); + await this.registerTools(server, runId, allowedTools, allowedNodeIds, toolSet); this.servers.set(cacheKey, server); return server; } + /** + * Refresh tool registrations for any cached servers for a run. + * This is used when tools register after an MCP session has already initialized. + */ + async refreshServersForRun(runId: string): Promise { + const matchingEntries = Array.from(this.servers.entries()).filter( + ([key]) => key === runId || key.startsWith(`${runId}:`), + ); + + if (matchingEntries.length === 0) { + return; + } + + this.logger.log( + `Refreshing MCP servers for run ${runId} (${matchingEntries.length} instance(s))`, + ); + + await Promise.all( + matchingEntries.map(async ([cacheKey, server]) => { + const allowedNodeIds = + cacheKey === runId ? undefined : cacheKey.split(':').slice(1).join(':').split(','); + const toolSet = this.registeredToolNames.get(cacheKey) ?? new Set(); + this.registeredToolNames.set(cacheKey, toolSet); + await this.registerTools(server, runId, undefined, allowedNodeIds, toolSet); + }), + ); + } + private async validateRunAccess(runId: string, organizationId?: string | null) { const run = await this.workflowRunRepository.findByRunId(runId); if (!run) { @@ -124,8 +155,15 @@ export class McpGatewayService { runId: string, allowedTools?: string[], allowedNodeIds?: string[], + registeredToolNames?: Set, ) { + this.logger.log( + `Registering tools for run ${runId} (allowedNodeIds=${allowedNodeIds?.join(',') ?? 'none'}, allowedTools=${allowedTools?.join(',') ?? 'none'})`, + ); const allRegistered = await this.toolRegistry.getToolsForRun(runId, allowedNodeIds); + this.logger.log( + `Tool registry returned ${allRegistered.length} tool(s) for run ${runId}: ${allRegistered.map((t) => `${t.toolName}:${t.type}`).join(', ') || 'none'}`, + ); // Filter by allowed tools if specified if (allowedTools && allowedTools.length > 0) { @@ -137,11 +175,19 @@ export class McpGatewayService { // 1. Register Internal Tools const internalTools = allRegistered.filter((t) => t.type === 'component'); + this.logger.log(`Registering ${internalTools.length} internal tool(s) for run ${runId}`); for (const tool of internalTools) { if (allowedTools && allowedTools.length > 0 && !allowedTools.includes(tool.toolName)) { + this.logger.log(`Skipping internal tool ${tool.toolName} (not in allowedTools)`); + continue; + } + + if (registeredToolNames?.has(tool.toolName)) { + this.logger.log(`Skipping internal tool ${tool.toolName} (already registered)`); continue; } + this.logger.log(`Registering internal tool ${tool.toolName} (node=${tool.nodeId})`); const component = tool.componentId ? componentRegistry.get(tool.componentId) : null; const inputShape = component ? getToolInputShape(component) : undefined; @@ -217,22 +263,37 @@ export class McpGatewayService { } }, ); + registeredToolNames?.add(tool.toolName); } // 2. Register External Tools (Proxied) const externalSources = allRegistered.filter((t) => t.type !== 'component'); + this.logger.log( + `Registering ${externalSources.length} external MCP source(s) for run ${runId}`, + ); for (const source of externalSources) { try { + this.logger.log( + `Fetching tools from external source ${source.toolName} (type=${source.type}, endpoint=${source.endpoint ?? 'missing'})`, + ); const tools = await this.fetchExternalTools(source); const prefix = source.toolName; + this.logger.log(`External source ${source.toolName} returned ${tools.length} tool(s)`); for (const t of tools) { const proxiedName = `${prefix}__${t.name}`; if (allowedTools && allowedTools.length > 0 && !allowedTools.includes(proxiedName)) { + this.logger.log(`Skipping proxied tool ${proxiedName} (not in allowedTools)`); + continue; + } + + if (registeredToolNames?.has(proxiedName)) { + this.logger.log(`Skipping proxied tool ${proxiedName} (already registered)`); continue; } + this.logger.log(`Registering proxied tool ${proxiedName} (source=${source.toolName})`); server.registerTool( proxiedName, { @@ -261,6 +322,7 @@ export class McpGatewayService { } }, ); + registeredToolNames?.add(proxiedName); } } catch (error) { this.logger.error(`Failed to fetch tools from external source ${source.toolName}:`, error); @@ -272,21 +334,58 @@ export class McpGatewayService { * Fetches tools from an external MCP source */ private async fetchExternalTools(source: RegisteredTool): Promise { - if (!source.endpoint) return []; + if (!source.endpoint) { + this.logger.warn(`Missing endpoint for external source ${source.toolName}`); + return []; + } - const transport = new StreamableHTTPClientTransport(new URL(source.endpoint)); - const client = new Client( - { name: 'shipsec-gateway-client', version: '1.0.0' }, - { capabilities: {} }, - ); + const MAX_RETRIES = 5; + const RETRY_DELAY_MS = 1000; + let lastError: unknown; - await client.connect(transport); - try { - const response = await client.listTools(); - return response.tools; - } finally { - await client.close(); + for (let attempt = 1; attempt <= MAX_RETRIES; attempt += 1) { + const sessionId = `stdio-proxy-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const transport = new StreamableHTTPClientTransport(new URL(source.endpoint), { + requestInit: { + headers: { + 'Mcp-Session-Id': sessionId, + }, + }, + }); + const client = new Client( + { name: 'shipsec-gateway-client', version: '1.0.0' }, + { capabilities: {} }, + ); + + this.logger.log( + `Connecting to external MCP source ${source.toolName} at ${source.endpoint} (attempt ${attempt}/${MAX_RETRIES})`, + ); + + try { + await client.connect(transport); + const response = await client.listTools(); + this.logger.log( + `listTools from ${source.toolName} returned ${response.tools?.length ?? 0} tool(s)`, + ); + return response.tools; + } catch (error) { + lastError = error; + this.logger.warn( + `listTools failed for ${source.toolName} (attempt ${attempt}/${MAX_RETRIES}): ${error instanceof Error ? error.message : String(error)}`, + ); + if (attempt < MAX_RETRIES) { + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); + } + } finally { + this.logger.log(`Closing external MCP client for ${source.toolName}`); + await client.close(); + } + } + + if (lastError) { + throw lastError; } + return []; } /** @@ -310,7 +409,14 @@ export class McpGatewayService { let lastError: unknown; for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { - const transport = new StreamableHTTPClientTransport(new URL(source.endpoint)); + const sessionId = `stdio-proxy-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const transport = new StreamableHTTPClientTransport(new URL(source.endpoint), { + requestInit: { + headers: { + 'Mcp-Session-Id': sessionId, + }, + }, + }); const client = new Client( { name: 'shipsec-gateway-client', version: '1.0.0' }, { capabilities: {} }, diff --git a/backend/src/mcp/mcp.module.ts b/backend/src/mcp/mcp.module.ts index 2435cf89..d92053f7 100644 --- a/backend/src/mcp/mcp.module.ts +++ b/backend/src/mcp/mcp.module.ts @@ -19,6 +19,11 @@ import { ApiKeysModule } from '../api-keys/api-keys.module'; useFactory: () => { // Use the same Redis URL as terminal or a dedicated one const url = process.env.TOOL_REGISTRY_REDIS_URL ?? process.env.TERMINAL_REDIS_URL; + if (!url) { + console.warn('[MCP] Redis URL not set; tool registry disabled'); + } else { + console.info(`[MCP] Tool registry Redis URL: ${url}`); + } if (!url) { return null; } diff --git a/backend/src/mcp/tool-registry.service.ts b/backend/src/mcp/tool-registry.service.ts index 6788d544..e1805354 100644 --- a/backend/src/mcp/tool-registry.service.ts +++ b/backend/src/mcp/tool-registry.service.ts @@ -211,6 +211,7 @@ export class ToolRegistryService implements OnModuleDestroy { async getToolsForRun(runId: string, nodeIds?: string[]): Promise { if (!this.redis) { + this.logger.warn('Redis not configured, tool registry disabled'); return []; } diff --git a/bun.lock b/bun.lock index cd2e8e58..e34fe06c 100644 --- a/bun.lock +++ b/bun.lock @@ -271,7 +271,7 @@ "@types/adm-zip": "^0.5.7", "@types/js-yaml": "^4.0.9", "@types/minio": "^7.1.1", - "@types/node": "^20.19.30", + "@types/node": "^25.1.0", "@types/pg": "^8.16.0", "@typescript-eslint/eslint-plugin": "^8.53.1", "@typescript-eslint/parser": "^8.53.1", @@ -3095,7 +3095,7 @@ "@shipsec/studio-worker/@googleapis/admin": ["@googleapis/admin@29.0.0", "", { "dependencies": { "googleapis-common": "^8.0.0" } }, "sha512-lujnbfmDn1aetoJBDeExH4IDKniMZs7Ga8hGagN/lecO8hd0fy9hu+osROp0HFk6u2wCAiA89oXi5qHVXupbOQ=="], - "@shipsec/studio-worker/@types/node": ["@types/node@20.19.30", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g=="], + "@shipsec/studio-worker/@types/node": ["@types/node@25.1.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="], "@temporalio/worker/@swc/core": ["@swc/core@1.15.8", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.8", "@swc/core-darwin-x64": "1.15.8", "@swc/core-linux-arm-gnueabihf": "1.15.8", "@swc/core-linux-arm64-gnu": "1.15.8", "@swc/core-linux-arm64-musl": "1.15.8", "@swc/core-linux-x64-gnu": "1.15.8", "@swc/core-linux-x64-musl": "1.15.8", "@swc/core-win32-arm64-msvc": "1.15.8", "@swc/core-win32-ia32-msvc": "1.15.8", "@swc/core-win32-x64-msvc": "1.15.8" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-T8keoJjXaSUoVBCIjgL6wAnhADIb09GOELzKg10CjNg+vLX48P93SME6jTfte9MZIm5m+Il57H3rTSk/0kzDUw=="], @@ -3449,8 +3449,6 @@ "@shipsec/studio-frontend/ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], - "@shipsec/studio-worker/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "@temporalio/worker/@swc/core/@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-M9cK5GwyWWRkRGwwCbREuj6r8jKdES/haCZ3Xckgkl8MUQJZA3XB7IXXK1IXRNeLjg6m7cnoMICpXv1v1hlJOg=="], "@temporalio/worker/@swc/core/@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.15.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-j47DasuOvXl80sKJHSi2X25l44CMc3VDhlJwA7oewC1nV1VsSzwX+KOwE5tLnfORvVJJyeiXgJORNYg4jeIjYQ=="], diff --git a/current-state.md b/current-state.md deleted file mode 100644 index a03328ae..00000000 --- a/current-state.md +++ /dev/null @@ -1,117 +0,0 @@ -# OpenCode Agent E2E Testing - RESOLVED - -## Progress Summary - -### ✅ All Completed -1. **Z.AI Provider Added** - `zai-coding-plan` provider added to LLMProviderSchema -2. **Component Fixes** - OpenCode component updated with: - - Proper model string format: `zai-coding-plan/glm-4.7` - - Provider config with `apiKey` in `provider.options.apiKey` - - MCP config fix: `type: "remote"` instead of `transport: "http"` -3. **E2E Tests Fixed and Passing** - All issues resolved: - - **`--quiet` flag doesn't exist** in opencode 1.1.34, changed to `--log-level ERROR` - - **Wrapper script approach** to handle prompt file reading inside container - - **Entry point override** using `/bin/sh` to execute wrapper script - - **Test assertions fixed** - changed from `output?.report` to `outputSummary?.report` - ---- - -## Root Cause - The `--quiet` Flag Issue - -### Discovery -The `--quiet` flag **does not exist** in opencode version 1.1.34. When used, opencode shows help text instead of executing the command. - -### Manual Verification -```bash -# This shows help (wrong): -docker run ... ghcr.io/anomalyco/opencode run --quiet "hello" - -# This works (correct): -docker run ... ghcr.io/anomalyco/opencode run --log-level ERROR "hello" -``` - ---- - -## Final Implementation - -### Wrapper Script Approach -The prompt is written to `prompt.txt` and read by a wrapper script inside the container: - -```typescript -// Write wrapper script that reads prompt from file -const wrapperScript = '#!/bin/sh\nopencode run --log-level ERROR "$(cat /workspace/prompt.txt)"\n'; - -await volume.initialize({ - 'context.json': contextJson, - 'opencode.jsonc': JSON.stringify(opencodeConfig, null, 2), - 'prompt.txt': finalPrompt, - 'run.sh': wrapperScript, -}); - -// Execute with /bin/sh entrypoint -const runnerConfig = { - ...definition.runner, - entrypoint: '/bin/sh', - command: ['/workspace/run.sh'], - network: 'host' as const, - volumes: [volume.getVolumeConfig('/workspace', false)], - workingDir: '/workspace', -}; -``` - -### Why This Works -1. **`entrypoint: '/bin/sh'`** overrides the image's default `opencode` entrypoint -2. **Wrapper script** runs inside the container, so `$(cat /workspace/prompt.txt)` is evaluated by the container shell -3. **`--log-level ERROR`** suppresses verbose logging (replaces the non-existent `--quiet` flag) - ---- - -## Test Results - -### E2E Tests: ✅ PASSING -``` -bun test e2e-tests/opencode.test.ts - - 2 pass - 0 fail - 10 expect() calls -Ran 2 tests across 1 file. [30.31s] -``` - -### Tests Implemented -1. **Basic Test** - OpenCode agent runs with Z.AI GLM-4.7 -2. **Context Test** - OpenCode agent uses context from input (JSON data) - ---- - -## Key Findings - -### OpenCode Configuration -1. **Z.AI Native Provider**: `zai-coding-plan` is a first-class provider in OpenCode -2. **Model Format**: `zai-coding-plan/glm-4.7` (provider/modelId) -3. **API Key**: Goes in `provider.zai-coding-plan.options.apiKey` -4. **MCP Format**: `mcp.{name}: {type: 'remote', url: '...'}` -5. **No `--quiet` flag**: Use `--log-level ERROR` instead - -### Docker Execution Pattern -For complex commands with shell expansion: -- Write a wrapper script to a file -- Use `/bin/sh` as entrypoint -- Execute the wrapper script as the command - ---- - -## Environment - -- **ZAI_API_KEY**: `aa8e1ccdcb48463aa3def6939a959a5c.GK2rlnuBm76aHRaI` -- **GLM Model**: `zai-coding-plan/glm-4.7` -- **Studio API**: Running on `http://127.0.0.1:3211` -- **OpenCode Version**: 1.1.34 - ---- - -## Files Modified - -1. `packages/contracts/src/index.ts` - Added `zai-coding-plan` provider schema -2. `worker/src/components/ai/opencode.ts` - Fixed command execution and removed `--quiet` flag -3. `e2e-tests/opencode.test.ts` - Created E2E tests and fixed assertions diff --git a/docker/mcp-aws-cloudtrail/Dockerfile b/docker/mcp-aws-cloudtrail/Dockerfile new file mode 100644 index 00000000..43a0ccff --- /dev/null +++ b/docker/mcp-aws-cloudtrail/Dockerfile @@ -0,0 +1,11 @@ +ARG BASE_IMAGE=shipsec/mcp-stdio-proxy:latest +FROM ${BASE_IMAGE} + +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 python3-pip \ + && rm -rf /var/lib/apt/lists/* \ + && pip install --no-cache-dir --break-system-packages uv \ + && uv pip install --system --break-system-packages awslabs-cloudtrail-mcp-server + +ENV MCP_COMMAND=awslabs.cloudtrail-mcp-server +ENV MCP_ARGS='[]' diff --git a/docker/mcp-aws-cloudtrail/README.md b/docker/mcp-aws-cloudtrail/README.md new file mode 100644 index 00000000..7828a5d6 --- /dev/null +++ b/docker/mcp-aws-cloudtrail/README.md @@ -0,0 +1,22 @@ +# AWS CloudTrail MCP Proxy Image + +This image extends the MCP stdio proxy and installs the CloudTrail MCP server. + +## Build + +```bash +docker build -t shipsec/mcp-aws-cloudtrail:latest docker/mcp-aws-cloudtrail +``` + +## Run (example) + +```bash +docker run --rm -p 8080:8080 \ + -e AWS_ACCESS_KEY_ID=... \ + -e AWS_SECRET_ACCESS_KEY=... \ + -e AWS_SESSION_TOKEN=... \ + -e AWS_REGION=us-east-1 \ + shipsec/mcp-aws-cloudtrail:latest +``` + +The proxy exposes MCP on `http://localhost:8080/mcp`. diff --git a/docker/mcp-aws-cloudwatch/Dockerfile b/docker/mcp-aws-cloudwatch/Dockerfile new file mode 100644 index 00000000..4283d8c5 --- /dev/null +++ b/docker/mcp-aws-cloudwatch/Dockerfile @@ -0,0 +1,14 @@ +ARG BASE_IMAGE=shipsec/mcp-stdio-proxy:latest +FROM ${BASE_IMAGE} + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + python3 python3-pip python3-dev \ + build-essential gfortran \ + libopenblas-dev liblapack-dev \ + && rm -rf /var/lib/apt/lists/* \ + && pip install --no-cache-dir --break-system-packages uv \ + && uv pip install --system --break-system-packages awslabs-cloudwatch-mcp-server + +ENV MCP_COMMAND=awslabs.cloudwatch-mcp-server +ENV MCP_ARGS='[]' diff --git a/docker/mcp-aws-cloudwatch/README.md b/docker/mcp-aws-cloudwatch/README.md new file mode 100644 index 00000000..4b8f57d0 --- /dev/null +++ b/docker/mcp-aws-cloudwatch/README.md @@ -0,0 +1,22 @@ +# AWS CloudWatch MCP Proxy Image + +This image extends the MCP stdio proxy and installs the CloudWatch MCP server. + +## Build + +```bash +docker build -t shipsec/mcp-aws-cloudwatch:latest docker/mcp-aws-cloudwatch +``` + +## Run (example) + +```bash +docker run --rm -p 8080:8080 \ + -e AWS_ACCESS_KEY_ID=... \ + -e AWS_SECRET_ACCESS_KEY=... \ + -e AWS_SESSION_TOKEN=... \ + -e AWS_REGION=us-east-1 \ + shipsec/mcp-aws-cloudwatch:latest +``` + +The proxy exposes MCP on `http://localhost:8080/mcp`. diff --git a/docker/mcp-stdio-proxy/Dockerfile b/docker/mcp-stdio-proxy/Dockerfile new file mode 100644 index 00000000..cfaef59b --- /dev/null +++ b/docker/mcp-stdio-proxy/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-slim + +WORKDIR /app + +COPY package.json ./ +RUN npm install --omit=dev + +COPY server.mjs ./ + +ENV PORT=8080 +EXPOSE 8080 + +CMD ["node", "server.mjs"] diff --git a/docker/mcp-stdio-proxy/README.md b/docker/mcp-stdio-proxy/README.md new file mode 100644 index 00000000..9785829b --- /dev/null +++ b/docker/mcp-stdio-proxy/README.md @@ -0,0 +1,31 @@ +# MCP Stdio Proxy + +This image wraps a stdio-based MCP server and exposes it over Streamable HTTP. + +## Build + +```bash +docker build -t shipsec/mcp-stdio-proxy:latest docker/mcp-stdio-proxy +``` + +## Run + +```bash +docker run --rm -p 8080:8080 \ + -e MCP_COMMAND=uvx \ + -e MCP_ARGS='["awslabs-cloudwatch-mcp-server"]' \ + shipsec/mcp-stdio-proxy:latest +``` + +The proxy will expose MCP on `http://localhost:8080/mcp` and a basic health endpoint at `/health`. + +## Environment + +- `MCP_COMMAND` (required): Command to launch the stdio MCP server. +- `MCP_ARGS` (optional): JSON array or space-delimited list of arguments. +- `PORT` / `MCP_PORT` (optional): Port for the HTTP server (default: 8080). + +## Notes + +- The proxy lists tools once at startup and registers them. Restart the container if tools change. +- Make sure the stdio server binary is present in the image. For third-party tools, build a derived image that installs them. diff --git a/docker/mcp-stdio-proxy/package.json b/docker/mcp-stdio-proxy/package.json new file mode 100644 index 00000000..e766511d --- /dev/null +++ b/docker/mcp-stdio-proxy/package.json @@ -0,0 +1,14 @@ +{ + "name": "mcp-stdio-proxy", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "HTTP proxy for stdio-based MCP servers", + "scripts": { + "start": "node server.mjs" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.3", + "express": "^5.2.1" + } +} diff --git a/docker/mcp-stdio-proxy/server.mjs b/docker/mcp-stdio-proxy/server.mjs new file mode 100644 index 00000000..b465bf6d --- /dev/null +++ b/docker/mcp-stdio-proxy/server.mjs @@ -0,0 +1,122 @@ +import express from 'express'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { + CallToolRequestSchema, + InitializeRequestSchema, + InitializedNotificationSchema, + ListToolsRequestSchema, + LATEST_PROTOCOL_VERSION, +} from '@modelcontextprotocol/sdk/types.js'; + +function parseArgs(raw) { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) return parsed.map(String); + } catch { + // fall through + } + return raw + .split(' ') + .map((entry) => entry.trim()) + .filter(Boolean); +} + +const command = process.env.MCP_COMMAND; +const args = parseArgs(process.env.MCP_ARGS || ''); +const port = Number.parseInt(process.env.PORT || process.env.MCP_PORT || '8080', 10); + +if (!command) { + console.error('MCP_COMMAND is required to start the stdio MCP server.'); + process.exit(1); +} + +const client = new Client({ name: 'shipsec-mcp-stdio-proxy', version: '1.0.0' }); +const clientTransport = new StdioClientTransport({ + command, + args, +}); + +await client.connect(clientTransport); + +const server = new Server( + { + name: 'shipsec-mcp-stdio-proxy', + version: '1.0.0', + }, + { + capabilities: client.getServerCapabilities() ?? { + tools: { listChanged: false }, + }, + }, +); + +server.setRequestHandler(InitializeRequestSchema, async () => { + return { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: client.getServerCapabilities() ?? {}, + serverInfo: client.getServerVersion() ?? { + name: 'shipsec-mcp-stdio-proxy', + version: '1.0.0', + }, + instructions: client.getInstructions?.(), + }; +}); + +server.setNotificationHandler(InitializedNotificationSchema, () => { + // no-op +}); + +server.setRequestHandler(ListToolsRequestSchema, async () => { + return await client.listTools(); +}); + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + return await client.callTool({ + name: request.params.name, + arguments: request.params.arguments ?? {}, + }); +}); + +const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, +}); + +await server.connect(transport); + +const app = express(); +app.use(express.json({ limit: '2mb' })); + +app.all('/mcp', async (req, res) => { + console.log('[mcp-proxy] incoming request', { + method: req.method, + path: req.path, + headers: { + 'mcp-session-id': req.headers['mcp-session-id'], + accept: req.headers['accept'], + 'content-type': req.headers['content-type'], + }, + body: req.body, + }); + try { + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('[mcp-proxy] Failed to handle MCP request', error); + if (!res.headersSent) { + res.status(500).send('MCP proxy error'); + } + } +}); + +app.get('/health', (_req, res) => { + res.json({ status: 'ok', toolCount: tools.length }); +}); + +app.listen(port, '0.0.0.0', () => { + console.log(`MCP stdio proxy listening on http://0.0.0.0:${port}/mcp`); + console.log(`Proxied MCP command: ${command} ${args.join(' ')}`); +}); diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index de7b391f..5f9b78af 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -217,6 +217,7 @@ export function Sidebar({ canManageWorkflows = true }: SidebarProps) { input: 'text-blue-600 dark:text-blue-400', transform: 'text-orange-600 dark:text-orange-400', ai: 'text-purple-600 dark:text-purple-400', + mcp: 'text-teal-600 dark:text-teal-400', security: 'text-red-600 dark:text-red-400', it_ops: 'text-cyan-600 dark:text-cyan-400', notification: 'text-pink-600 dark:text-pink-400', @@ -302,6 +303,7 @@ export function Sidebar({ canManageWorkflows = true }: SidebarProps) { 'output', 'notification', 'security', + 'mcp', 'ai', 'transform', 'it_ops', diff --git a/frontend/src/components/workflow/WorkflowNode.tsx b/frontend/src/components/workflow/WorkflowNode.tsx index 45e9a198..ebc12f55 100644 --- a/frontend/src/components/workflow/WorkflowNode.tsx +++ b/frontend/src/components/workflow/WorkflowNode.tsx @@ -446,6 +446,11 @@ export const WorkflowNode = ({ data, selected, id }: NodeProps) => { const MAX_TEXT_HEIGHT = 1200; const DEFAULT_TEXT_WIDTH = 320; const DEFAULT_TEXT_HEIGHT = 300; + const TOOL_MODE_ONLY_COMPONENTS = new Set([ + 'core.mcp.server', + 'security.aws-cloudtrail-mcp', + 'security.aws-cloudwatch-mcp', + ]); const [textSize, setTextSize] = useState<{ width: number; height: number }>(() => { const uiSize = (data as any)?.ui?.size as { width?: number; height?: number } | undefined; return { @@ -488,6 +493,7 @@ export const WorkflowNode = ({ data, selected, id }: NodeProps) => { const component = getComponent(componentRef); const isTextBlock = component?.id === 'core.ui.text'; const isEntryPoint = component?.id === 'core.workflow.entrypoint'; + const isToolModeOnly = component?.id ? TOOL_MODE_ONLY_COMPONENTS.has(component.id) : false; // Detect dark mode using theme store (reacts to theme changes) const theme = useThemeStore((state) => state.theme); @@ -496,6 +502,7 @@ export const WorkflowNode = ({ data, selected, id }: NodeProps) => { // Get component category (default to 'input' for entry points) const componentCategory: ComponentCategory = (component?.category as ComponentCategory) || (isEntryPoint ? 'input' : 'input'); + const showMcpBadge = componentCategory === 'mcp' || isToolModeOnly; // Entry Point Helper Data // Get workflowId from store first, then from node data (passed from Canvas), then from route params @@ -517,8 +524,32 @@ export const WorkflowNode = ({ data, selected, id }: NodeProps) => { // Tool Mode State const isToolMode = (nodeData.config as any)?.isToolMode || false; + useEffect(() => { + if (!isToolModeOnly || isToolMode) return; + setNodes((nds) => + nds.map((n) => { + if (n.id !== id) return n; + const currentConfig = (n.data as any).config || {}; + return { + ...n, + data: { + ...n.data, + config: { + ...currentConfig, + isToolMode: true, + }, + }, + }; + }), + ); + markDirty(); + }, [id, isToolMode, isToolModeOnly, markDirty, setNodes]); + const toggleToolMode = (e: React.MouseEvent) => { e.stopPropagation(); + if (isToolModeOnly) { + return; + } setNodes((nds) => nds.map((n) => { if (n.id !== id) return n; @@ -1058,7 +1089,14 @@ export const WorkflowNode = ({ data, selected, id }: NodeProps) => { !isEntryPoint && mode === 'design' ? 'Double-click to rename' : undefined } > -

{displayLabel}

+
+

{displayLabel}

+ {showMcpBadge && ( + + MCP + + )} +
{hasCustomLabel && ( {component.name} @@ -1074,13 +1112,21 @@ export const WorkflowNode = ({ data, selected, id }: NodeProps) => {