From 925ecc1f2604e0e1edf8eef7b7e8ea0dcb40a9c2 Mon Sep 17 00:00:00 2001 From: Molecule AI SDK-Dev Date: Tue, 21 Apr 2026 20:33:23 +0000 Subject: [PATCH] feat(mcp): add Zod schema validation to all tool handlers Add src/utils/validation.ts with validate() helper and InvalidArgumentsError. Update all tool files (agents, approvals, discovery, files, plugins, secrets) to use explicit Zod schemas with .describe() for tool input validation. Every handler now calls validate(args, Schema) before any business logic, throwing INVALID_ARGUMENTS (MCP error -32602) on schema failure. Also: add node_modules/, dist/, build/ to .gitignore; update package-lock.json. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 5 ++ package-lock.json | 11 +--- src/tools/agents.ts | 102 +++++++++++++++++++++++------- src/tools/approvals.ts | 67 ++++++++++++++------ src/tools/discovery.ts | 134 ++++++++++++++++++++++++++++++++-------- src/tools/files.ts | 99 ++++++++++++++++++++++------- src/tools/plugins.ts | 87 +++++++++++++++++++------- src/tools/secrets.ts | 81 +++++++++++++++++------- src/utils/validation.ts | 115 ++++++++++++++++++++++++++++++++++ 9 files changed, 558 insertions(+), 143 deletions(-) create mode 100644 src/utils/validation.ts 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; +}