forked from molecule-ai/molecule-core
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>
173 lines
7.1 KiB
TypeScript
173 lines
7.1 KiB
TypeScript
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
import { z } from "zod";
|
|
import { apiCall, PLATFORM_URL, toMcpResult, isApiError } from "../api.js";
|
|
|
|
// Fetch the workspace list, filter to runtime='external'. The platform
|
|
// has no dedicated /remote-agents endpoint — we filter client-side
|
|
// because the workspace list is small (tens to low-hundreds, never
|
|
// pagination scale) and adding a server endpoint would be a separate PR.
|
|
export async function handleListRemoteAgents() {
|
|
const data = await apiCall("GET", "/workspaces");
|
|
if (!Array.isArray(data)) {
|
|
return toMcpResult(data);
|
|
}
|
|
const remote = data
|
|
.filter((w: { runtime?: string }) => w.runtime === "external")
|
|
.map((w: Record<string, unknown>) => ({
|
|
id: w.id,
|
|
name: w.name,
|
|
status: w.status,
|
|
url: w.url,
|
|
last_heartbeat_at: w.last_heartbeat_at,
|
|
uptime_seconds: w.uptime_seconds,
|
|
tier: w.tier,
|
|
}));
|
|
return toMcpResult({ count: remote.length, agents: remote });
|
|
}
|
|
|
|
// Phase 30.4 — token-gated; from MCP we don't have a workspace bearer
|
|
// (we're an operator surface), so we hit the lightweight unauthenticated
|
|
// /workspaces/:id endpoint and project the same shape. Still useful as
|
|
// a focused tool that doesn't dump the full workspace blob.
|
|
export async function handleGetRemoteAgentState(params: { workspace_id: string }) {
|
|
const data = await apiCall("GET", `/workspaces/${params.workspace_id}`);
|
|
if (isApiError(data)) {
|
|
return toMcpResult(data);
|
|
}
|
|
const w = data as Record<string, unknown>;
|
|
const projected = {
|
|
workspace_id: w.id,
|
|
status: w.status,
|
|
paused: w.status === "paused",
|
|
deleted: w.status === "removed",
|
|
runtime: w.runtime,
|
|
last_heartbeat_at: w.last_heartbeat_at,
|
|
};
|
|
return toMcpResult(projected);
|
|
}
|
|
|
|
export async function handleGetRemoteAgentSetupCommand(params: {
|
|
workspace_id: string;
|
|
platform_url_override?: string;
|
|
}) {
|
|
// Verify the workspace exists and is runtime='external' before generating
|
|
// the command — saves the operator from pasting a bash line that will
|
|
// fail because the workspace was a Docker workspace they typed by mistake.
|
|
const ws = await apiCall("GET", `/workspaces/${params.workspace_id}`);
|
|
if (isApiError(ws)) {
|
|
return toMcpResult(ws);
|
|
}
|
|
const w = ws as { id: string; name: string; runtime?: string };
|
|
if (w.runtime !== "external") {
|
|
return toMcpResult({
|
|
error: "workspace is not external; setup command only applies to runtime='external'",
|
|
workspace_id: w.id,
|
|
actual_runtime: w.runtime,
|
|
});
|
|
}
|
|
|
|
// The MCP server's PLATFORM_URL is whatever Claude Desktop / the host
|
|
// injected — usually localhost when an operator runs us locally. That
|
|
// URL is useless inside a remote-agent shell on a different machine.
|
|
// If the caller passes platform_url_override we use it; otherwise we
|
|
// detect localhost and surface a warning so the operator knows to
|
|
// substitute the real public URL before pasting the command.
|
|
const targetUrl = params.platform_url_override?.trim() || PLATFORM_URL;
|
|
const isLocalhost = /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|\/|$)/.test(targetUrl);
|
|
const warnings: string[] = [];
|
|
if (isLocalhost && !params.platform_url_override) {
|
|
warnings.push(
|
|
`PLATFORM_URL is ${targetUrl} — this only works if the remote agent is on the same machine as the platform. ` +
|
|
`Pass platform_url_override with the agent-reachable URL (e.g. https://your-platform.example.com) before pasting on a different host.`
|
|
);
|
|
}
|
|
|
|
const setupCmd = [
|
|
`# Run on the remote machine where the agent will live.`,
|
|
`# Requires Python 3.11+ and bash (the SDK invokes setup.sh via bash).`,
|
|
`pip install molecule-sdk # (or: pip install -e <molecule-checkout>/sdk/python)`,
|
|
``,
|
|
`WORKSPACE_ID=${w.id} \\`,
|
|
`PLATFORM_URL=${targetUrl} \\`,
|
|
`python3 -c "from molecule_agent import RemoteAgentClient; \\`,
|
|
` c = RemoteAgentClient.register_from_env(); \\`,
|
|
` c.pull_secrets(); \\`,
|
|
` c.run_heartbeat_loop()"`,
|
|
``,
|
|
`# For a richer demo (logging, graceful shutdown) see`,
|
|
`# sdk/python/examples/remote-agent/run.py in the molecule-monorepo checkout.`,
|
|
`# The agent will register, mint its bearer token (cached at`,
|
|
`# ~/.molecule/${w.id}/.auth_token), pull secrets, then heartbeat.`,
|
|
].join("\n");
|
|
return toMcpResult({
|
|
workspace_id: w.id,
|
|
workspace_name: w.name,
|
|
platform_url: targetUrl,
|
|
setup_command: setupCmd,
|
|
...(warnings.length > 0 ? { warnings } : {}),
|
|
});
|
|
}
|
|
|
|
export async function handleCheckRemoteAgentFreshness(params: {
|
|
workspace_id: string;
|
|
threshold_seconds?: number;
|
|
}) {
|
|
const ws = await apiCall("GET", `/workspaces/${params.workspace_id}`);
|
|
if (isApiError(ws)) {
|
|
return toMcpResult(ws);
|
|
}
|
|
const w = ws as { last_heartbeat_at?: string; status?: string; runtime?: string };
|
|
const threshold = params.threshold_seconds ?? 90;
|
|
const heartbeatStr = w.last_heartbeat_at;
|
|
let secondsSince: number | null = null;
|
|
if (heartbeatStr) {
|
|
const heartbeatMs = Date.parse(heartbeatStr);
|
|
if (!isNaN(heartbeatMs)) {
|
|
secondsSince = Math.floor((Date.now() - heartbeatMs) / 1000);
|
|
}
|
|
}
|
|
const fresh = secondsSince !== null && secondsSince <= threshold;
|
|
return toMcpResult({
|
|
workspace_id: params.workspace_id,
|
|
status: w.status,
|
|
runtime: w.runtime,
|
|
last_heartbeat_at: heartbeatStr,
|
|
seconds_since_heartbeat: secondsSince,
|
|
threshold_seconds: threshold,
|
|
fresh,
|
|
});
|
|
}
|
|
|
|
export function registerRemoteAgentTools(srv: McpServer) {
|
|
srv.tool(
|
|
"list_remote_agents",
|
|
"List all workspaces with runtime='external' (Phase 30 remote agents). Returns id, name, status, last_heartbeat_at, url. Useful for spotting offline remote agents from a Claude session.",
|
|
{},
|
|
handleListRemoteAgents,
|
|
);
|
|
|
|
srv.tool(
|
|
"get_remote_agent_state",
|
|
"Phase 30.4 lightweight state poll for a remote workspace. Returns {status, paused, deleted}. Faster than get_workspace because it doesn't include config/agent_card. Useful when you only need to know whether a remote agent is alive.",
|
|
{ workspace_id: z.string() },
|
|
handleGetRemoteAgentState,
|
|
);
|
|
|
|
srv.tool(
|
|
"get_remote_agent_setup_command",
|
|
"Build a one-shot bash command an operator can paste into a remote machine to register an agent against this Molecule AI platform. Returns a string like `WORKSPACE_ID=... PLATFORM_URL=... python3 -m molecule_agent.bootstrap`. Pass platform_url_override when the MCP server's PLATFORM_URL is localhost (the agent will live on a different host and needs the platform's public URL). The workspace must exist and be runtime='external'.",
|
|
{
|
|
workspace_id: z.string(),
|
|
platform_url_override: z.string().optional(),
|
|
},
|
|
handleGetRemoteAgentSetupCommand,
|
|
);
|
|
|
|
srv.tool(
|
|
"check_remote_agent_freshness",
|
|
"Compare a remote workspace's last_heartbeat_at against now. Returns {seconds_since_heartbeat, fresh, threshold_seconds} where `fresh` is true if the agent heartbeated within the platform's stale-after window. Useful for pre-flight checks before delegating work.",
|
|
{ workspace_id: z.string(), threshold_seconds: z.number().optional() },
|
|
handleCheckRemoteAgentFreshness,
|
|
);
|
|
}
|