molecule-core/mcp-server/src/tools/discovery.ts
Hongming Wang af931aa8da refactor(mcp-server): DRY envelopes, typed apiCall, explicit re-exports
Second-pass cleanup after the monolith split. Addresses every issue
from the code-review pass.

Core additions in src/api.ts:
- toMcpResult(data) + toMcpText(text): single source of truth for the
  MCP text-content envelope (was ~87 duplicated literals)
- ApiError type + isApiError(v) guard: typed discriminated-union for
  the error-by-value pattern; replaces open-coded shape checks
- apiCall<T = unknown>: generic so callers can document expected
  response shape without unchecked "as" casts

Bulk cleanups across all 12 tools/*.ts:
- Every handler now returns toMcpResult(data) or toMcpText(text)
- Open-coded "typeof obj === 'object' && 'error' in obj" in
  remote_agents.ts replaced with isApiError(v)
- Extracted initialCanvasPosition() helper out of
  handleCreateWorkspace; explains why random seeding exists
- Added runtime/workspace_dir/workspace_access to create_workspace
  zod schema (previously accepted by handler but hidden from clients)

src/index.ts:
- Replaced "export * from" with explicit named re-exports so the
  public surface is auditable and future name collisions fail loudly

Tests:
- createServer() smoke test that records every srv.tool(...) call and
  asserts 87 registered tools unique by name. Catches future PRs that
  forget to wire a registerXxxTools(srv).

Docs:
- Fix broken relative links in sdk/python/molecule_agent/README.md
  (was ../../examples/ from inside sdk/python/, should be ../examples/)
- Update stale "61 tools" -> "87 tools" in CLAUDE.md + main() log

Verification:
- npm run build clean
- npx jest -> 97/97 passed (was 96; +1 smoke test)
- grep "content: [{ type: \"text\" as const" src/tools/ -> 0 matches
- No file over 216 lines

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:26:17 -07:00

174 lines
5.1 KiB
TypeScript

import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { apiCall, toMcpResult } from "../api.js";
export async function handleListPeers(params: { workspace_id: string }) {
const data = await apiCall("GET", `/registry/${params.workspace_id}/peers`);
return toMcpResult(data);
}
export async function handleDiscoverWorkspace(params: { workspace_id: string }) {
const data = await apiCall("GET", `/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 });
return toMcpResult(data);
}
export async function handleListEvents(params: { workspace_id?: string }) {
const path = params.workspace_id ? `/events/${params.workspace_id}` : "/events";
const data = await apiCall("GET", path);
return toMcpResult(data);
}
export async function handleListTemplates() {
const data = await apiCall("GET", "/templates");
return toMcpResult(data);
}
export async function handleListOrgTemplates() {
const data = await apiCall("GET", "/org/templates");
return toMcpResult(data);
}
export async function handleImportOrg(params: { dir: string }) {
const data = await apiCall("POST", "/org/import", { dir: params.dir });
return toMcpResult(data);
}
export async function handleImportTemplate(params: { name: string; files: Record<string, string> }) {
const { name, files } = params;
const data = await apiCall("POST", `/templates/import`, { name, files });
return toMcpResult(data);
}
export async function handleExportBundle(params: { workspace_id: string }) {
const data = await apiCall("GET", `/bundles/export/${params.workspace_id}`);
return toMcpResult(data);
}
export async function handleImportBundle(params: { bundle: Record<string, unknown> }) {
const data = await apiCall("POST", `/bundles/import`, params.bundle);
return toMcpResult(data);
}
export async function handleGetViewport() {
const data = await apiCall("GET", "/canvas/viewport");
return toMcpResult(data);
}
export async function handleSetViewport(params: { x: number; y: number; zoom: number }) {
const data = await apiCall("PUT", "/canvas/viewport", params);
return toMcpResult(data);
}
export async function handleExpandTeam(params: { workspace_id: string }) {
const data = await apiCall("POST", `/workspaces/${params.workspace_id}/expand`, {});
return toMcpResult(data);
}
export async function handleCollapseTeam(params: { workspace_id: string }) {
const data = await apiCall("POST", `/workspaces/${params.workspace_id}/collapse`, {});
return toMcpResult(data);
}
export function registerDiscoveryTools(srv: McpServer) {
srv.tool(
"list_peers",
"List reachable peer workspaces (siblings, children, parent)",
{ workspace_id: z.string() },
handleListPeers
);
srv.tool(
"discover_workspace",
"Resolve a workspace URL by ID (for A2A communication)",
{ workspace_id: z.string() },
handleDiscoverWorkspace
);
srv.tool(
"check_access",
"Check if two workspaces can communicate",
{ caller_id: z.string(), target_id: z.string() },
handleCheckAccess
);
srv.tool(
"list_events",
"List structure events (global or per workspace)",
{ workspace_id: z.string().optional().describe("Filter to workspace, or omit for all") },
handleListEvents
);
srv.tool("list_templates", "List available workspace templates", {}, handleListTemplates);
srv.tool("list_org_templates", "List available org templates", {}, handleListOrgTemplates);
srv.tool(
"import_org",
"Import an org template to create an entire workspace hierarchy",
{ dir: z.string().describe("Org template directory name (e.g., 'molecule-dev')") },
handleImportOrg
);
srv.tool(
"import_template",
"Import agent files as a new workspace template",
{
name: z.string().describe("Template name"),
files: z.record(z.string()).describe("Map of file path → content"),
},
handleImportTemplate
);
srv.tool(
"export_bundle",
"Export a workspace as a portable .bundle.json",
{ workspace_id: z.string() },
handleExportBundle
);
srv.tool(
"import_bundle",
"Import a workspace from a bundle JSON object",
{ bundle: z.record(z.unknown()).describe("Bundle JSON object") },
handleImportBundle
);
srv.tool(
"get_canvas_viewport",
"Get the current canvas viewport (x, y, zoom) persisted per-user.",
{},
handleGetViewport,
);
srv.tool(
"set_canvas_viewport",
"Persist the canvas viewport (x, y, zoom).",
{
x: z.number(),
y: z.number(),
zoom: z.number(),
},
handleSetViewport,
);
srv.tool(
"expand_team",
"Expand a workspace into a team of sub-workspaces",
{ workspace_id: z.string().describe("Workspace ID to expand") },
handleExpandTeam
);
srv.tool(
"collapse_team",
"Collapse a team back to a single workspace",
{ workspace_id: z.string().describe("Workspace ID to collapse") },
handleCollapseTeam
);
}