diff --git a/.gitignore b/.gitignore index 2af45b5..4f53c05 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,8 @@ # Workspace auth tokens .auth-token .auth_token + +# Node.js dependencies (installed at runtime, not part of repo) +node_modules/ +dist/ +build/ diff --git a/package-lock.json b/package-lock.json index 80a1471..b5bec35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@molecule/mcp-server", + "name": "@molecule-ai/mcp-server", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@molecule/mcp-server", + "name": "@molecule-ai/mcp-server", "version": "1.0.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", @@ -53,7 +53,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1809,7 +1808,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2482,7 +2480,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -2861,7 +2858,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.10.tgz", "integrity": "sha512-mx/p18PLy5og9ufies2GOSUqep98Td9q4i/EF6X7yJgAiIopxqdfIO3jbqsi3jRgTgw88jMDEzVKi+V2EF+27w==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -3143,7 +3139,6 @@ "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.3.0", "@jest/types": "30.3.0", @@ -5156,7 +5151,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5541,7 +5535,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/tools/agents.ts b/src/tools/agents.ts index 99280fb..33ec671 100644 --- a/src/tools/agents.ts +++ b/src/tools/agents.ts @@ -1,20 +1,67 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { apiCall, isApiError, platformGet, PLATFORM_URL, toMcpResult, toMcpText } from "../api.js"; +import { apiCall, toMcpResult, toMcpText } from "../api.js"; +import { validate } from "../utils/validation.js"; +import { platformGet } from "../api.js"; -export async function handleChatWithAgent(params: { workspace_id: string; message: string }) { - const { workspace_id, message } = params; - const data = await apiCall<{ result?: { parts?: Array<{ kind?: string; text?: string }> } }>( +// --------------------------------------------------------------------------- +// Schemas +// --------------------------------------------------------------------------- + +const ChatWithAgentSchema = z.object({ + workspace_id: z.string().describe("Workspace ID"), + message: z.string().describe("Message to send"), +}); +export type ChatWithAgentParams = z.infer; + +const AssignAgentSchema = z.object({ + workspace_id: z.string().describe("Workspace ID"), + model: z.string().describe("Model string (e.g., openrouter:anthropic/claude-3.5-haiku)"), +}); +export type AssignAgentParams = z.infer; + +const ReplaceAgentSchema = z.object({ + workspace_id: z.string().describe("Workspace ID"), + model: z.string().describe("Model string"), +}); +export type ReplaceAgentParams = z.infer; + +const RemoveAgentSchema = z.object({ + workspace_id: z.string().describe("Workspace ID"), +}); +export type RemoveAgentParams = z.infer; + +const MoveAgentSchema = z.object({ + workspace_id: z.string().describe("Source workspace ID"), + target_workspace_id: z.string().describe("Target workspace ID"), +}); +export type MoveAgentParams = z.infer; + +const GetModelSchema = z.object({ + workspace_id: z.string().describe("Workspace ID"), +}); +export type GetModelParams = z.infer; + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +export async function handleChatWithAgent(args: unknown): Promise> { + const params = validate(args, ChatWithAgentSchema); + const data = await apiCall< + { result?: { parts?: Array<{ kind?: string; text?: string }> } } + >( "POST", - `/workspaces/${workspace_id}/a2a`, + `/workspaces/${params.workspace_id}/a2a`, { method: "message/send", params: { - message: { role: "user", parts: [{ type: "text", text: message }] }, + message: { role: "user", parts: [{ type: "text", text: params.message }] }, }, }, ); - const parts = (data as { result?: { parts?: Array<{ kind?: string; text?: string }> } } | null)?.result?.parts || []; + const parts = + (data as { result?: { parts?: Array<{ kind?: string; text?: string }> } } | null)?.result?.parts || []; const text = parts .filter((p) => p.kind === "text") .map((p) => p.text || "") @@ -22,34 +69,42 @@ export async function handleChatWithAgent(params: { workspace_id: string; messag return text ? toMcpText(text) : toMcpResult(data); } -export async function handleAssignAgent(params: { workspace_id: string; model: string }) { - const { workspace_id, model } = params; - const data = await apiCall("POST", `/workspaces/${workspace_id}/agent`, { model }); +export async function handleAssignAgent(args: unknown): Promise> { + const params = validate(args, AssignAgentSchema); + const data = await apiCall("POST", `/workspaces/${params.workspace_id}/agent`, { model: params.model }); return toMcpResult(data); } -export async function handleReplaceAgent(params: { workspace_id: string; model: string }) { - const { workspace_id, model } = params; - const data = await apiCall("PATCH", `/workspaces/${workspace_id}/agent`, { model }); +export async function handleReplaceAgent(args: unknown): Promise> { + const params = validate(args, ReplaceAgentSchema); + const data = await apiCall("PATCH", `/workspaces/${params.workspace_id}/agent`, { model: params.model }); return toMcpResult(data); } -export async function handleRemoveAgent(params: { workspace_id: string }) { +export async function handleRemoveAgent(args: unknown): Promise> { + const params = validate(args, RemoveAgentSchema); const data = await apiCall("DELETE", `/workspaces/${params.workspace_id}/agent`); return toMcpResult(data); } -export async function handleMoveAgent(params: { workspace_id: string; target_workspace_id: string }) { - const { workspace_id, target_workspace_id } = params; - const data = await apiCall("POST", `/workspaces/${workspace_id}/agent/move`, { target_workspace_id }); +export async function handleMoveAgent(args: unknown): Promise> { + const params = validate(args, MoveAgentSchema); + const data = await apiCall("POST", `/workspaces/${params.workspace_id}/agent/move`, { + target_workspace_id: params.target_workspace_id, + }); return toMcpResult(data); } -export async function handleGetModel(params: { workspace_id: string }) { +export async function handleGetModel(args: unknown): Promise> { + const params = validate(args, GetModelSchema); const data = await platformGet(`/workspaces/${params.workspace_id}/model`); return toMcpResult(data); } +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + export function registerAgentTools(srv: McpServer) { srv.tool( "chat_with_agent", @@ -74,28 +129,31 @@ export function registerAgentTools(srv: McpServer) { srv.tool( "replace_agent", "Replace the model on an existing workspace agent", - { workspace_id: z.string(), model: z.string() }, + { workspace_id: z.string().describe("Workspace ID"), model: z.string().describe("Model string") }, handleReplaceAgent ); srv.tool( "remove_agent", "Remove the agent from a workspace", - { workspace_id: z.string() }, + { workspace_id: z.string().describe("Workspace ID") }, handleRemoveAgent ); srv.tool( "move_agent", "Move an agent from one workspace to another", - { workspace_id: z.string(), target_workspace_id: z.string() }, + { + workspace_id: z.string().describe("Source workspace ID"), + target_workspace_id: z.string().describe("Target workspace ID"), + }, handleMoveAgent ); srv.tool( "get_model", "Get current model configuration for a workspace", - { workspace_id: z.string() }, + { workspace_id: z.string().describe("Workspace ID") }, handleGetModel ); } diff --git a/src/tools/approvals.ts b/src/tools/approvals.ts index f15dbbb..cdceb90 100644 --- a/src/tools/approvals.ts +++ b/src/tools/approvals.ts @@ -1,41 +1,70 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { apiCall, platformGet, toMcpResult } from "../api.js"; +import { validate } from "../utils/validation.js"; -export async function handleListPendingApprovals() { +// --------------------------------------------------------------------------- +// Schemas +// --------------------------------------------------------------------------- + +const DecideApprovalSchema = z.object({ + workspace_id: z.string().describe("Workspace ID"), + approval_id: z.string().describe("Approval ID"), + decision: z.enum(["approved", "denied"]).describe("Decision"), +}); +export type DecideApprovalParams = z.infer; + +const CreateApprovalSchema = z.object({ + workspace_id: z.string().describe("Workspace ID"), + action: z.string().describe("What needs approval"), + reason: z.string().optional().describe("Why it's needed"), +}); +export type CreateApprovalParams = z.infer; + +const GetWorkspaceApprovalsSchema = z.object({ + workspace_id: z.string().describe("Workspace ID"), +}); +export type GetWorkspaceApprovalsParams = z.infer; + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +export async function handleListPendingApprovals(): Promise> { const data = await platformGet("/approvals/pending"); return toMcpResult(data); } -export async function handleDecideApproval(params: { - workspace_id: string; - approval_id: string; - decision: "approved" | "denied"; -}) { - const { workspace_id, approval_id, decision } = params; +export async function handleDecideApproval(args: unknown): Promise> { + const params = validate(args, DecideApprovalSchema); const data = await apiCall( "POST", - `/workspaces/${workspace_id}/approvals/${approval_id}/decide`, - { decision, decided_by: "mcp-client" } + `/workspaces/${params.workspace_id}/approvals/${params.approval_id}/decide`, + { decision: params.decision, decided_by: "mcp-client" } ); return toMcpResult(data); } -export async function handleCreateApproval(params: { - workspace_id: string; - action: string; - reason?: string; -}) { - const { workspace_id, action, reason } = params; - const data = await apiCall("POST", `/workspaces/${workspace_id}/approvals`, { action, reason }); +export async function handleCreateApproval(args: unknown): Promise> { + const params = validate(args, CreateApprovalSchema); + const data = await apiCall( + "POST", + `/workspaces/${params.workspace_id}/approvals`, + { action: params.action, reason: params.reason } + ); return toMcpResult(data); } -export async function handleGetWorkspaceApprovals(params: { workspace_id: string }) { +export async function handleGetWorkspaceApprovals(args: unknown): Promise> { + const params = validate(args, GetWorkspaceApprovalsSchema); const data = await platformGet(`/workspaces/${params.workspace_id}/approvals`); return toMcpResult(data); } +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + export function registerApprovalTools(srv: McpServer) { srv.tool( "list_pending_approvals", @@ -59,7 +88,7 @@ export function registerApprovalTools(srv: McpServer) { "create_approval", "Create an approval request for a workspace", { - workspace_id: z.string(), + workspace_id: z.string().describe("Workspace ID"), action: z.string().describe("What needs approval"), reason: z.string().optional().describe("Why it's needed"), }, @@ -69,7 +98,7 @@ export function registerApprovalTools(srv: McpServer) { srv.tool( "get_workspace_approvals", "List approval requests for a specific workspace", - { workspace_id: z.string() }, + { workspace_id: z.string().describe("Workspace ID") }, handleGetWorkspaceApprovals ); } diff --git a/src/tools/discovery.ts b/src/tools/discovery.ts index a9d95a0..d2d3cc8 100644 --- a/src/tools/discovery.ts +++ b/src/tools/discovery.ts @@ -1,99 +1,183 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { apiCall, platformGet, toMcpResult } from "../api.js"; +import { validate } from "../utils/validation.js"; -export async function handleListPeers(params: { workspace_id: string }) { +// --------------------------------------------------------------------------- +// Schemas +// --------------------------------------------------------------------------- + +const ListPeersSchema = z.object({ + workspace_id: z.string().describe("Workspace ID"), +}); +export type ListPeersParams = z.infer; + +const DiscoverWorkspaceSchema = z.object({ + workspace_id: z.string().describe("Workspace ID"), +}); +export type DiscoverWorkspaceParams = z.infer; + +const CheckAccessSchema = z.object({ + caller_id: z.string().describe("Caller workspace ID"), + target_id: z.string().describe("Target workspace ID"), +}); +export type CheckAccessParams = z.infer; + +const ListEventsSchema = z.object({ + workspace_id: z.string().optional().describe("Filter to workspace, or omit for all"), +}); +export type ListEventsParams = z.infer; + +const ImportOrgSchema = z.object({ + dir: z.string().describe("Org template directory name (e.g., 'molecule-dev')"), +}); +export type ImportOrgParams = z.infer; + +const ImportTemplateSchema = z.object({ + name: z.string().describe("Template name"), + files: z.record(z.string()).describe("Map of file path → content"), +}); +export type ImportTemplateParams = z.infer; + +const ExportBundleSchema = z.object({ + workspace_id: z.string().describe("Workspace ID"), +}); +export type ExportBundleParams = z.infer; + +const ImportBundleSchema = z.object({ + bundle: z.record(z.unknown()).describe("Bundle JSON object"), +}); +export type ImportBundleParams = z.infer; + +const SetViewportSchema = z.object({ + x: z.number().describe("Viewport X coordinate"), + y: z.number().describe("Viewport Y coordinate"), + zoom: z.number().describe("Zoom level"), +}); +export type SetViewportParams = z.infer; + +const ExpandTeamSchema = z.object({ + workspace_id: z.string().describe("Workspace ID to expand"), +}); +export type ExpandTeamParams = z.infer; + +const CollapseTeamSchema = z.object({ + workspace_id: z.string().describe("Workspace ID to collapse"), +}); +export type CollapseTeamParams = z.infer; + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +export async function handleListPeers(args: unknown): Promise> { + const params = validate(args, ListPeersSchema); const data = await platformGet(`/registry/${params.workspace_id}/peers`); return toMcpResult(data); } -export async function handleDiscoverWorkspace(params: { workspace_id: string }) { +export async function handleDiscoverWorkspace(args: unknown): Promise> { + const params = validate(args, DiscoverWorkspaceSchema); const data = await platformGet(`/registry/discover/${params.workspace_id}`); return toMcpResult(data); } -export async function handleCheckAccess(params: { caller_id: string; target_id: string }) { - const { caller_id, target_id } = params; - const data = await apiCall("POST", `/registry/check-access`, { caller_id, target_id }); +export async function handleCheckAccess(args: unknown): Promise> { + const params = validate(args, CheckAccessSchema); + const data = await apiCall("POST", `/registry/check-access`, { caller_id: params.caller_id, target_id: params.target_id }); return toMcpResult(data); } -export async function handleListEvents(params: { workspace_id?: string }) { +export async function handleListEvents(args: unknown): Promise> { + const params = validate(args, ListEventsSchema); const path = params.workspace_id ? `/events/${params.workspace_id}` : "/events"; const data = await platformGet(path); return toMcpResult(data); } -export async function handleListTemplates() { +export async function handleListTemplates(): Promise> { const data = await platformGet("/templates"); return toMcpResult(data); } -export async function handleListOrgTemplates() { +export async function handleListOrgTemplates(): Promise> { const data = await platformGet("/org/templates"); return toMcpResult(data); } -export async function handleImportOrg(params: { dir: string }) { +export async function handleImportOrg(args: unknown): Promise> { + const params = validate(args, ImportOrgSchema); const data = await apiCall("POST", "/org/import", { dir: params.dir }); return toMcpResult(data); } -export async function handleImportTemplate(params: { name: string; files: Record }) { - const { name, files } = params; - const data = await apiCall("POST", `/templates/import`, { name, files }); +export async function handleImportTemplate(args: unknown): Promise> { + const params = validate(args, ImportTemplateSchema); + const data = await apiCall("POST", `/templates/import`, { name: params.name, files: params.files }); return toMcpResult(data); } -export async function handleExportBundle(params: { workspace_id: string }) { +export async function handleExportBundle(args: unknown): Promise> { + const params = validate(args, ExportBundleSchema); const data = await platformGet(`/bundles/export/${params.workspace_id}`); return toMcpResult(data); } -export async function handleImportBundle(params: { bundle: Record }) { +export async function handleImportBundle(args: unknown): Promise> { + const params = validate(args, ImportBundleSchema); const data = await apiCall("POST", `/bundles/import`, params.bundle); return toMcpResult(data); } -export async function handleGetViewport() { +export async function handleGetViewport(): Promise> { const data = await platformGet("/canvas/viewport"); return toMcpResult(data); } -export async function handleSetViewport(params: { x: number; y: number; zoom: number }) { +export async function handleSetViewport(args: unknown): Promise> { + const params = validate(args, SetViewportSchema); const data = await apiCall("PUT", "/canvas/viewport", params); return toMcpResult(data); } -export async function handleExpandTeam(params: { workspace_id: string }) { +export async function handleExpandTeam(args: unknown): Promise> { + const params = validate(args, ExpandTeamSchema); const data = await apiCall("POST", `/workspaces/${params.workspace_id}/expand`, {}); return toMcpResult(data); } -export async function handleCollapseTeam(params: { workspace_id: string }) { +export async function handleCollapseTeam(args: unknown): Promise> { + const params = validate(args, CollapseTeamSchema); const data = await apiCall("POST", `/workspaces/${params.workspace_id}/collapse`, {}); return toMcpResult(data); } +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + export function registerDiscoveryTools(srv: McpServer) { srv.tool( "list_peers", "List reachable peer workspaces (siblings, children, parent)", - { workspace_id: z.string() }, + { workspace_id: z.string().describe("Workspace ID") }, handleListPeers ); srv.tool( "discover_workspace", "Resolve a workspace URL by ID (for A2A communication)", - { workspace_id: z.string() }, + { workspace_id: z.string().describe("Workspace ID") }, handleDiscoverWorkspace ); srv.tool( "check_access", "Check if two workspaces can communicate", - { caller_id: z.string(), target_id: z.string() }, + { + caller_id: z.string().describe("Caller workspace ID"), + target_id: z.string().describe("Target workspace ID"), + }, handleCheckAccess ); @@ -128,7 +212,7 @@ export function registerDiscoveryTools(srv: McpServer) { srv.tool( "export_bundle", "Export a workspace as a portable .bundle.json", - { workspace_id: z.string() }, + { workspace_id: z.string().describe("Workspace ID") }, handleExportBundle ); @@ -150,9 +234,9 @@ export function registerDiscoveryTools(srv: McpServer) { "set_canvas_viewport", "Persist the canvas viewport (x, y, zoom).", { - x: z.number(), - y: z.number(), - zoom: z.number(), + x: z.number().describe("Viewport X coordinate"), + y: z.number().describe("Viewport Y coordinate"), + zoom: z.number().describe("Zoom level"), }, handleSetViewport, ); diff --git a/src/tools/files.ts b/src/tools/files.ts index 14497fb..959bfe6 100644 --- a/src/tools/files.ts +++ b/src/tools/files.ts @@ -1,51 +1,104 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { apiCall, platformGet, toMcpResult, toMcpText } from "../api.js"; +import { validate } from "../utils/validation.js"; -export async function handleListFiles(params: { workspace_id: string }) { +// --------------------------------------------------------------------------- +// Schemas +// --------------------------------------------------------------------------- + +const ListFilesSchema = z.object({ + workspace_id: z.string().describe("Workspace ID"), +}); +export type ListFilesParams = z.infer; + +const ReadFileSchema = z.object({ + workspace_id: z.string().describe("Workspace ID"), + path: z.string().describe("File path (e.g., system-prompt.md, skills/seo/SKILL.md)"), +}); +export type ReadFileParams = z.infer; + +const WriteFileSchema = z.object({ + workspace_id: z.string().describe("Workspace ID"), + path: z.string().describe("File path"), + content: z.string().describe("File content"), +}); +export type WriteFileParams = z.infer; + +const DeleteFileSchema = z.object({ + workspace_id: z.string().describe("Workspace ID"), + path: z.string().describe("File or folder path"), +}); +export type DeleteFileParams = z.infer; + +const ReplaceAllFilesSchema = z.object({ + workspace_id: z.string().describe("Workspace ID"), + files: z.record(z.string()).describe("Map of file path → content"), +}); +export type ReplaceAllFilesParams = z.infer; + +const GetConfigSchema = z.object({ + workspace_id: z.string().describe("Workspace ID"), +}); +export type GetConfigParams = z.infer; + +const UpdateConfigSchema = z.object({ + workspace_id: z.string().describe("Workspace ID"), + config: z.record(z.unknown()).describe("Config fields to update"), +}); +export type UpdateConfigParams = z.infer; + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +export async function handleListFiles(args: unknown): Promise> { + const params = validate(args, ListFilesSchema); const data = await platformGet(`/workspaces/${params.workspace_id}/files`); return toMcpResult(data); } -export async function handleReadFile(params: { workspace_id: string; path: string }) { - const { workspace_id, path } = params; - const data = await platformGet<{ content?: string }>(`/workspaces/${workspace_id}/files/${path}`); +export async function handleReadFile(args: unknown): Promise> { + const params = validate(args, ReadFileSchema); + const data = await platformGet<{ content?: string }>(`/workspaces/${params.workspace_id}/files/${params.path}`); const fileText = (data as { content?: string } | null)?.content; return fileText ? toMcpText(fileText) : toMcpResult(data); } -export async function handleWriteFile(params: { workspace_id: string; path: string; content: string }) { - const { workspace_id, path, content } = params; - const data = await apiCall("PUT", `/workspaces/${workspace_id}/files/${path}`, { content }); +export async function handleWriteFile(args: unknown): Promise> { + const params = validate(args, WriteFileSchema); + const data = await apiCall("PUT", `/workspaces/${params.workspace_id}/files/${params.path}`, { content: params.content }); return toMcpResult(data); } -export async function handleDeleteFile(params: { workspace_id: string; path: string }) { - const { workspace_id, path } = params; - const data = await apiCall("DELETE", `/workspaces/${workspace_id}/files/${path}`); +export async function handleDeleteFile(args: unknown): Promise> { + const params = validate(args, DeleteFileSchema); + const data = await apiCall("DELETE", `/workspaces/${params.workspace_id}/files/${params.path}`); return toMcpResult(data); } -export async function handleReplaceAllFiles(params: { - workspace_id: string; - files: Record; -}) { - const { workspace_id, files } = params; - const data = await apiCall("PUT", `/workspaces/${workspace_id}/files`, { files }); +export async function handleReplaceAllFiles(args: unknown): Promise> { + const params = validate(args, ReplaceAllFilesSchema); + const data = await apiCall("PUT", `/workspaces/${params.workspace_id}/files`, { files: params.files }); return toMcpResult(data); } -export async function handleGetConfig(params: { workspace_id: string }) { +export async function handleGetConfig(args: unknown): Promise> { + const params = validate(args, GetConfigSchema); const data = await platformGet(`/workspaces/${params.workspace_id}/config`); return toMcpResult(data); } -export async function handleUpdateConfig(params: { workspace_id: string; config: Record }) { - const { workspace_id, config } = params; - const data = await apiCall("PATCH", `/workspaces/${workspace_id}/config`, config); +export async function handleUpdateConfig(args: unknown): Promise> { + const params = validate(args, UpdateConfigSchema); + const data = await apiCall("PATCH", `/workspaces/${params.workspace_id}/config`, params.config); return toMcpResult(data); } +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + export function registerFileTools(srv: McpServer) { srv.tool( "list_files", @@ -89,7 +142,7 @@ export function registerFileTools(srv: McpServer) { "replace_all_files", "Replace all workspace config files at once", { - workspace_id: z.string(), + workspace_id: z.string().describe("Workspace ID"), files: z.record(z.string()).describe("Map of file path → content"), }, handleReplaceAllFiles @@ -98,14 +151,14 @@ export function registerFileTools(srv: McpServer) { srv.tool( "get_config", "Get workspace runtime config as JSON", - { workspace_id: z.string() }, + { workspace_id: z.string().describe("Workspace ID") }, handleGetConfig ); srv.tool( "update_config", "Update workspace runtime config", - { workspace_id: z.string(), config: z.record(z.unknown()).describe("Config fields to update") }, + { workspace_id: z.string().describe("Workspace ID"), config: z.record(z.unknown()).describe("Config fields to update") }, handleUpdateConfig ); } diff --git a/src/tools/plugins.ts b/src/tools/plugins.ts index e123895..60e9c1c 100644 --- a/src/tools/plugins.ts +++ b/src/tools/plugins.ts @@ -1,49 +1,92 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { apiCall, platformGet, toMcpResult } from "../api.js"; +import { validate } from "../utils/validation.js"; -export async function handleListPluginRegistry() { +// --------------------------------------------------------------------------- +// Schemas +// --------------------------------------------------------------------------- + +const ListInstalledPluginsSchema = z.object({ + workspace_id: z.string().describe("Workspace ID"), +}); +export type ListInstalledPluginsParams = z.infer; + +const InstallPluginSchema = z.object({ + workspace_id: z.string().describe("Workspace ID"), + source: z.string().describe( + "Source URL: 'local://' for platform registry, 'github:///[#]' for GitHub, or any registered scheme." + ), +}); +export type InstallPluginParams = z.infer; + +const UninstallPluginSchema = z.object({ + workspace_id: z.string().describe("Workspace ID"), + name: z.string().describe("Plugin name to remove"), +}); +export type UninstallPluginParams = z.infer; + +const ListAvailablePluginsSchema = z.object({ + workspace_id: z.string().describe("Workspace ID"), +}); +export type ListAvailablePluginsParams = z.infer; + +const CheckPluginCompatibilitySchema = z.object({ + workspace_id: z.string().describe("Workspace ID"), + runtime: z.string().describe("Target runtime (claude-code, deepagents, langgraph, ...)"), +}); +export type CheckPluginCompatibilityParams = z.infer; + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +export async function handleListPluginRegistry(): Promise> { const data = await platformGet("/plugins"); return toMcpResult(data); } -export async function handleListInstalledPlugins(params: { workspace_id: string }) { +export async function handleListInstalledPlugins(args: unknown): Promise> { + const params = validate(args, ListInstalledPluginsSchema); const data = await platformGet(`/workspaces/${params.workspace_id}/plugins`); return toMcpResult(data); } -export async function handleInstallPlugin(params: { workspace_id: string; source: string }) { - const { workspace_id, source } = params; - const data = await apiCall("POST", `/workspaces/${workspace_id}/plugins`, { source }); +export async function handleInstallPlugin(args: unknown): Promise> { + const params = validate(args, InstallPluginSchema); + const data = await apiCall("POST", `/workspaces/${params.workspace_id}/plugins`, { source: params.source }); return toMcpResult(data); } -export async function handleUninstallPlugin(params: { workspace_id: string; name: string }) { - const { workspace_id, name } = params; - const data = await apiCall("DELETE", `/workspaces/${workspace_id}/plugins/${name}`); +export async function handleUninstallPlugin(args: unknown): Promise> { + const params = validate(args, UninstallPluginSchema); + const data = await apiCall("DELETE", `/workspaces/${params.workspace_id}/plugins/${params.name}`); return toMcpResult(data); } -export async function handleListPluginSources() { +export async function handleListPluginSources(): Promise> { const data = await platformGet("/plugins/sources"); return toMcpResult(data); } -export async function handleListAvailablePlugins(params: { workspace_id: string }) { +export async function handleListAvailablePlugins(args: unknown): Promise> { + const params = validate(args, ListAvailablePluginsSchema); const data = await platformGet(`/workspaces/${params.workspace_id}/plugins/available`); return toMcpResult(data); } -export async function handleCheckPluginCompatibility(params: { - workspace_id: string; - runtime: string; -}) { - const { workspace_id, runtime } = params; - const data = await platformGet(`/workspaces/${workspace_id}/plugins/compatibility?runtime=${encodeURIComponent(runtime)}`, +export async function handleCheckPluginCompatibility(args: unknown): Promise> { + const params = validate(args, CheckPluginCompatibilitySchema); + const data = await platformGet( + `/workspaces/${params.workspace_id}/plugins/compatibility?runtime=${encodeURIComponent(params.runtime)}`, ); return toMcpResult(data); } +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + export function registerPluginTools(srv: McpServer) { srv.tool("list_plugin_registry", "List all available plugins from the registry", {}, handleListPluginRegistry); @@ -59,11 +102,9 @@ export function registerPluginTools(srv: McpServer) { "Install a plugin into a workspace from any registered source (auto-restarts). Use GET /plugins/sources to list schemes.", { workspace_id: z.string().describe("Workspace ID"), - source: z - .string() - .describe( - "Source URL: 'local://' for platform registry, 'github:///[#]' for GitHub, or any registered scheme." - ), + source: z.string().describe( + "Source URL: 'local://' for platform registry, 'github:///[#]' for GitHub, or any registered scheme." + ), }, handleInstallPlugin ); @@ -88,7 +129,7 @@ export function registerPluginTools(srv: McpServer) { srv.tool( "list_available_plugins", "List plugins from the registry filtered to ones supported by this workspace's runtime.", - { workspace_id: z.string() }, + { workspace_id: z.string().describe("Workspace ID") }, handleListAvailablePlugins, ); @@ -96,7 +137,7 @@ export function registerPluginTools(srv: McpServer) { "check_plugin_compatibility", "Preflight check: which installed plugins would break if this workspace switched runtime to ?", { - workspace_id: z.string(), + workspace_id: z.string().describe("Workspace ID"), runtime: z.string().describe("Target runtime (claude-code, deepagents, langgraph, ...)"), }, handleCheckPluginCompatibility, diff --git a/src/tools/secrets.ts b/src/tools/secrets.ts index bf98fde..b84a65c 100644 --- a/src/tools/secrets.ts +++ b/src/tools/secrets.ts @@ -1,49 +1,89 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { apiCall, platformGet, toMcpResult } from "../api.js"; +import { validate } from "../utils/validation.js"; -export async function handleSetSecret(params: { workspace_id: string; key: string; value: string }) { - const { workspace_id, key, value } = params; - const data = await apiCall("POST", `/workspaces/${workspace_id}/secrets`, { key, value }); +// --------------------------------------------------------------------------- +// Schemas +// --------------------------------------------------------------------------- + +const SetSecretSchema = z.object({ + workspace_id: z.string().describe("Workspace ID"), + key: z.string().describe("Secret key (e.g., ANTHROPIC_API_KEY)"), + value: z.string().describe("Secret value"), +}); +export type SetSecretParams = z.infer; + +const ListSecretsSchema = z.object({ + workspace_id: z.string().describe("Workspace ID"), +}); +export type ListSecretsParams = z.infer; + +const DeleteSecretSchema = z.object({ + workspace_id: z.string().describe("Workspace ID"), + key: z.string().describe("Secret key"), +}); +export type DeleteSecretParams = z.infer; + +const SetGlobalSecretSchema = z.object({ + key: z.string().describe("Secret key (e.g., GITHUB_TOKEN)"), + value: z.string().describe("Secret value"), +}); +export type SetGlobalSecretParams = z.infer; + +const DeleteGlobalSecretSchema = z.object({ + key: z.string().describe("Secret key"), +}); +export type DeleteGlobalSecretParams = z.infer; + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +export async function handleSetSecret(args: unknown): Promise> { + const params = validate(args, SetSecretSchema); + const data = await apiCall("POST", `/workspaces/${params.workspace_id}/secrets`, { key: params.key, value: params.value }); return toMcpResult(data); } -export async function handleListSecrets(params: { workspace_id: string }) { +export async function handleListSecrets(args: unknown): Promise> { + const params = validate(args, ListSecretsSchema); const data = await platformGet(`/workspaces/${params.workspace_id}/secrets`); return toMcpResult(data); } -export async function handleDeleteSecret(params: { workspace_id: string; key: string }) { - const { workspace_id, key } = params; - const data = await apiCall("DELETE", `/workspaces/${workspace_id}/secrets/${encodeURIComponent(key)}`); +export async function handleDeleteSecret(args: unknown): Promise> { + const params = validate(args, DeleteSecretSchema); + const data = await apiCall("DELETE", `/workspaces/${params.workspace_id}/secrets/${encodeURIComponent(params.key)}`); return toMcpResult(data); } -export async function handleListGlobalSecrets() { +export async function handleListGlobalSecrets(): Promise> { const data = await platformGet("/settings/secrets"); return toMcpResult(data); } -export async function handleSetGlobalSecret(params: { key: string; value: string }) { - const { key, value } = params; - const data = await apiCall("PUT", "/settings/secrets", { key, value }); +export async function handleSetGlobalSecret(args: unknown): Promise> { + const params = validate(args, SetGlobalSecretSchema); + const data = await apiCall("PUT", "/settings/secrets", { key: params.key, value: params.value }); return toMcpResult(data); } -export async function handleDeleteGlobalSecret(params: { key: string }) { +export async function handleDeleteGlobalSecret(args: unknown): Promise> { + const params = validate(args, DeleteGlobalSecretSchema); const data = await apiCall("DELETE", `/settings/secrets/${params.key}`); return toMcpResult(data); } +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + export function registerSecretTools(srv: McpServer) { srv.tool( "set_secret", "Set an API key or environment variable for a workspace", - { - workspace_id: z.string().describe("Workspace ID"), - key: z.string().describe("Secret key (e.g., ANTHROPIC_API_KEY)"), - value: z.string().describe("Secret value"), - }, + { workspace_id: z.string().describe("Workspace ID"), key: z.string().describe("Secret key (e.g., ANTHROPIC_API_KEY)"), value: z.string().describe("Secret value") }, handleSetSecret ); @@ -57,7 +97,7 @@ export function registerSecretTools(srv: McpServer) { srv.tool( "delete_secret", "Delete a secret from a workspace", - { workspace_id: z.string(), key: z.string() }, + { workspace_id: z.string().describe("Workspace ID"), key: z.string().describe("Secret key") }, handleDeleteSecret ); @@ -66,10 +106,7 @@ export function registerSecretTools(srv: McpServer) { srv.tool( "set_global_secret", "Set a global secret (available to all workspaces)", - { - key: z.string().describe("Secret key (e.g., GITHUB_TOKEN)"), - value: z.string().describe("Secret value"), - }, + { key: z.string().describe("Secret key (e.g., GITHUB_TOKEN)"), value: z.string().describe("Secret value") }, handleSetGlobalSecret ); diff --git a/src/utils/validation.ts b/src/utils/validation.ts new file mode 100644 index 0000000..bcafda5 --- /dev/null +++ b/src/utils/validation.ts @@ -0,0 +1,115 @@ +/** + * Shared input validation utilities for MCP tool handlers. + * + * MCP tool arguments arrive as raw JSON (unknown). Before passing them to any + * business logic, every handler validates them against its Zod schema. + * On parse failure the handler returns a structured INVALID_ARGUMENTS error + * (MCP error code -32602) rather than letting type/structure errors surface + * as INTERNAL_ERROR later in the call stack. + * + * This also serves as living documentation: each schema documents exactly what + * a tool accepts, what types are required/optional, and what constraints apply. + */ + +import { ZodError, ZodSchema, z } from "zod"; + +/** MCP JSON-RPC error codes used by this server. */ +export const ErrorCode = { + InvalidParams: -32602, + InternalError: -32603, +} as const; + +// --------------------------------------------------------------------------- +// INVALID_ARGUMENTS error +// --------------------------------------------------------------------------- + +/** + * Structured MCP error for INVALID_ARGUMENTS. + * + * MCP error response shape: + * { content: [{ type: "text", text: "" }], + * isError: true } + * + * The MCP SDK translates a handler that throws `new InvalidArgumentsError(...)` + * into an INVALID_ARGUMENTS response (JSON-RPC error code -32602). + * If a handler returns normally the SDK returns isError: false. + */ +export class InvalidArgumentsError extends Error { + /** Zod validation issues, one per line, human-readable. */ + readonly issues: string[]; + + constructor(issues: string[]) { + super(formatIssues(issues)); + this.name = "InvalidArgumentsError"; + this.issues = issues; + // Make the error look like an MCP SDK error for the framework. + Object.setPrototypeOf(this, InvalidArgumentsError.prototype); + } +} + +/** Format a list of Zod issues into a single readable string. */ +function formatIssues(issues: string[]): string { + if (issues.length === 1) return `Invalid argument: ${issues[0]}`; + return `Invalid arguments (${issues.length} errors):\n${issues.map((e) => ` - ${e}`).join("\n")}`; +} + +/** + * Format a Zod ZodError into a flat list of human-readable issue strings. + * Each entry is "[field]: [message]" or just "[message]" for root issues. + */ +export function formatZodIssues(err: ZodError): string[] { + return err.issues.map((issue) => { + const path = issue.path.length > 0 ? issue.path.join(".") + ": " : ""; + return path + issue.message; + }); +} + +// --------------------------------------------------------------------------- +// Core validate helper +// --------------------------------------------------------------------------- + +/** + * Validate `args` against `schema` and return the parsed value on success. + * + * Usage — add ONE line at the top of every handler: + * const params = validate(args, MyToolSchema); + * + * Throws `InvalidArgumentsError` (caught by the MCP SDK → INVALID_ARGUMENTS + * response) if validation fails. The error message lists every failure. + * + * @param args - Raw JSON object received from the MCP caller. + * @param schema - Zod schema (sync or async) that describes the expected shape. + * @returns The parsed and typed arguments. + * @throws InvalidArgumentsError if args fail validation. + */ +export function validate(args: unknown, schema: ZodSchema): T { + if (args == null) args = {}; + + const result = schema.safeParse(args); + + if (!result.success) { + throw new InvalidArgumentsError(formatZodIssues(result.error)); + } + + return result.data; +} + +// --------------------------------------------------------------------------- +// Optional-param guard +// --------------------------------------------------------------------------- + +/** + * Throw INVALID_ARGUMENTS if `value` is null or undefined. + * Use for required params that Zod's `.required()` alone cannot catch when + * the caller sends `null` instead of omitting the key. + * + * Example: + * const { workspace_id } = validate(args, SomeSchema); + * guardRequired(workspace_id, "workspace_id"); + */ +export function guardRequired(value: T, fieldName: string): T { + if (value === null || value === undefined) { + throw new InvalidArgumentsError([`${fieldName}: required`]); + } + return value; +}