Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e64f9f107 | |||
| 549c15c594 | |||
| e267875fb3 | |||
| 68e0505d9f |
@@ -96,7 +96,10 @@ The workflow:
|
||||
|
||||
### APIs Connected
|
||||
|
||||
The server connects to the Molecule AI platform REST API. See the platform SDK (`../molecule-sdk-python`) for the underlying API client used.
|
||||
The server connects to the Molecule AI platform REST API via its own TypeScript
|
||||
client (`src/api.ts`). It does not use the Python SDK (`molecule-sdk-python`) —
|
||||
the Python SDK is for remote agents that run outside the platform; this server
|
||||
runs as an MCP bridge *on* the operator side.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
@@ -110,7 +113,7 @@ For local development, copy `.env.example` → `.env` and fill in values.
|
||||
|
||||
### Postgres
|
||||
|
||||
Platform data lives in Postgres (source of truth). The server reads data via the platform SDK — it does not connect to Postgres directly.
|
||||
Platform data lives in Postgres (source of truth). The server reads data via the platform REST API — it does not connect to Postgres directly.
|
||||
|
||||
## TypeScript Conventions
|
||||
|
||||
|
||||
+183
-2
@@ -26,6 +26,7 @@ import {
|
||||
PLATFORM_URL,
|
||||
handleListWorkspaces,
|
||||
handleCreateWorkspace,
|
||||
handleProvisionWorkspace,
|
||||
handleGetWorkspace,
|
||||
handleDeleteWorkspace,
|
||||
handleRestartWorkspace,
|
||||
@@ -119,6 +120,186 @@ function expectJsonContent(result: { content: Array<{ type: string; text: string
|
||||
expect(parsed).toEqual(expected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fetch mock that returns a different JSON body on each
|
||||
* successive call (call 1 -> responses[0], call 2 -> responses[1], ...).
|
||||
* Used by provision_workspace tests where the handler does a POST
|
||||
* (create) followed by a GET (read-back) and the two responses differ.
|
||||
*/
|
||||
function mockFetchSequence(responses: Array<{ payload: unknown; ok?: boolean; status?: number }>) {
|
||||
const fn = jest.fn();
|
||||
for (const r of responses) {
|
||||
fn.mockResolvedValueOnce({
|
||||
ok: r.ok ?? true,
|
||||
status: r.status ?? 200,
|
||||
text: jest.fn().mockResolvedValue(JSON.stringify(r.payload)),
|
||||
});
|
||||
}
|
||||
return fn;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// provision_workspace (fail-closed) tests
|
||||
// ============================================================
|
||||
|
||||
describe("handleProvisionWorkspace (fail-closed contract)", () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
test("rejects an unsupported runtime BEFORE any platform call", async () => {
|
||||
const fetchMock = jest.fn();
|
||||
global.fetch = fetchMock;
|
||||
const result = await handleProvisionWorkspace({
|
||||
name: "bad",
|
||||
runtime: "gpt-5.5-turbo",
|
||||
});
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.error).toBe("UNSUPPORTED_RUNTIME");
|
||||
expect(parsed.provisioned).toBe(false);
|
||||
// No side effect — fail-closed must not have touched the platform.
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns RUNTIME_MISMATCH when platform silently falls back (the #184 footgun)", async () => {
|
||||
// create returns id; read-back shows langgraph instead of codex.
|
||||
global.fetch = mockFetchSequence([
|
||||
{ payload: { id: "ws-9", status: "provisioning" } },
|
||||
{ payload: { id: "ws-9", runtime: "langgraph" } },
|
||||
]);
|
||||
const result = await handleProvisionWorkspace({
|
||||
name: "codex-dev",
|
||||
runtime: "codex",
|
||||
});
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.error).toBe("RUNTIME_MISMATCH");
|
||||
expect(parsed.provisioned).toBe(false);
|
||||
expect(parsed.requested_runtime).toBe("codex");
|
||||
expect(parsed.resolved_runtime).toBe("langgraph");
|
||||
expect(parsed.workspace_id).toBe("ws-9");
|
||||
});
|
||||
|
||||
test("returns ok=true only when resolved runtime matches the request", async () => {
|
||||
global.fetch = mockFetchSequence([
|
||||
{ payload: { id: "ws-7", status: "provisioning" } },
|
||||
{ payload: { id: "ws-7", runtime: "claude-code" } },
|
||||
]);
|
||||
const result = await handleProvisionWorkspace({
|
||||
name: "cc-dev",
|
||||
runtime: "claude-code",
|
||||
});
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.provisioned).toBe(true);
|
||||
expect(parsed.requested_runtime).toBe("claude-code");
|
||||
expect(parsed.resolved_runtime).toBe("claude-code");
|
||||
});
|
||||
|
||||
test("returns PROVISION_UNVERIFIED when the runtime cannot be read back", async () => {
|
||||
global.fetch = mockFetchSequence([
|
||||
{ payload: { id: "ws-3", status: "provisioning" } },
|
||||
{ payload: { id: "ws-3" } }, // no runtime field echoed
|
||||
]);
|
||||
const result = await handleProvisionWorkspace({
|
||||
name: "hermes-dev",
|
||||
runtime: "hermes",
|
||||
});
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.error).toBe("PROVISION_UNVERIFIED");
|
||||
expect(parsed.provisioned).toBe(false);
|
||||
});
|
||||
|
||||
test("BYO runtime (external) is not failed on a normalized runtime label", async () => {
|
||||
global.fetch = mockFetchSequence([
|
||||
{ payload: { id: "ws-x", status: "awaiting_agent" } },
|
||||
{ payload: { id: "ws-x", runtime: "external" } },
|
||||
]);
|
||||
const result = await handleProvisionWorkspace({
|
||||
name: "byo",
|
||||
runtime: "external",
|
||||
});
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.provisioned).toBe(true);
|
||||
});
|
||||
|
||||
// Call-indexed fetch mock. provision_workspace with role_config makes
|
||||
// up to 5 sequential calls (POST create, GET runtime, PUT config.yaml,
|
||||
// PUT model, GET model); a per-call implementation is the robust mock
|
||||
// for a multi-call handler (mockResolvedValueOnce chains are brittle
|
||||
// across reset ordering once the call count exceeds ~2).
|
||||
function mockFetchCalls(seq: unknown[]) {
|
||||
let i = 0;
|
||||
return jest.fn().mockImplementation(() => {
|
||||
const payload = seq[Math.min(i, seq.length - 1)];
|
||||
i += 1;
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify(payload)),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("role_config: applies config.yaml + model and read-back-asserts the effective model", async () => {
|
||||
// POST create → GET runtime → PUT config.yaml → PUT model → GET model
|
||||
global.fetch = mockFetchCalls([
|
||||
{ id: "ws-pm", status: "provisioning" },
|
||||
{ id: "ws-pm", runtime: "claude-code" },
|
||||
{ status: "saved", path: "config.yaml" },
|
||||
{ status: "saved", model: "opus" },
|
||||
{ model: "opus", source: "workspace_secrets" },
|
||||
]) as unknown as typeof fetch;
|
||||
const result = await handleProvisionWorkspace({
|
||||
name: "prod-PM",
|
||||
runtime: "claude-code",
|
||||
role_config: { model: "opus", config_yaml: "name: prod-PM\nruntime: claude-code\n" },
|
||||
});
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.role_config_applied).toBe(true);
|
||||
expect(parsed.applied.model).toBe("opus");
|
||||
expect(parsed.applied.config_yaml).toBe("written");
|
||||
});
|
||||
|
||||
test("role_config: fails closed when the effective model does not match the requested model", async () => {
|
||||
// model write acks, but read-back still shows the template default.
|
||||
global.fetch = mockFetchCalls([
|
||||
{ id: "ws-bad", status: "provisioning" },
|
||||
{ id: "ws-bad", runtime: "claude-code" },
|
||||
{ status: "saved", path: "config.yaml" },
|
||||
{ status: "saved", model: "opus" },
|
||||
{ model: "sonnet", source: "workspace_secrets" },
|
||||
]) as unknown as typeof fetch;
|
||||
const result = await handleProvisionWorkspace({
|
||||
name: "prod-PM",
|
||||
runtime: "claude-code",
|
||||
role_config: { model: "opus", config_yaml: "name: prod-PM\n" },
|
||||
});
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.error).toBe("ROLE_CONFIG_MODEL_MISMATCH");
|
||||
expect(parsed.role_config_applied).toBe(false);
|
||||
expect(parsed.requested_model).toBe("opus");
|
||||
expect(parsed.effective_model).toBe("sonnet");
|
||||
// The workspace still exists (runtime was honored) — surface that.
|
||||
expect(parsed.provisioned).toBe(true);
|
||||
});
|
||||
|
||||
test("role_config absent → role_config_applied:false, runtime still verified", async () => {
|
||||
global.fetch = mockFetchCalls([
|
||||
{ id: "ws-n", status: "provisioning" },
|
||||
{ id: "ws-n", runtime: "codex" },
|
||||
]) as unknown as typeof fetch;
|
||||
const result = await handleProvisionWorkspace({
|
||||
name: "plain",
|
||||
runtime: "codex",
|
||||
});
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.role_config_applied).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// apiCall() tests
|
||||
// ============================================================
|
||||
@@ -859,12 +1040,12 @@ describe("createServer()", () => {
|
||||
// and each tool() call is recorded by the mocked McpServer above. If a
|
||||
// future PR adds a tool file but forgets to call its registerXxxTools
|
||||
// from createServer(), this count drops and the test fails. We assert
|
||||
// the concrete current tool count (87) rather than a lower bound so a
|
||||
// the concrete current tool count (88) rather than a lower bound so a
|
||||
// silently-dropped handler is also caught.
|
||||
test("registers all tools (count is stable across registerXxxTools wiring)", () => {
|
||||
const server = createServer() as unknown as { registeredToolNames: string[] };
|
||||
const names = server.registeredToolNames;
|
||||
expect(names.length).toBe(87);
|
||||
expect(names.length).toBe(88);
|
||||
// Names must be unique — a duplicate registration would indicate a
|
||||
// copy-paste mistake in one of the registerXxxTools() calls.
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
|
||||
+2
-1
@@ -38,6 +38,7 @@ export {
|
||||
registerWorkspaceTools,
|
||||
handleListWorkspaces,
|
||||
handleCreateWorkspace,
|
||||
handleProvisionWorkspace,
|
||||
handleGetWorkspace,
|
||||
handleDeleteWorkspace,
|
||||
handleRestartWorkspace,
|
||||
@@ -212,7 +213,7 @@ async function main() {
|
||||
const server = createServer();
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
logInfo("Molecule AI MCP server running on stdio (87 tools available)", { transport: "stdio", toolCount: 87 });
|
||||
logInfo("Molecule AI MCP server running on stdio (88 tools available)", { transport: "stdio", toolCount: 88 });
|
||||
}
|
||||
|
||||
// Only auto-start when run directly (not when imported for testing).
|
||||
|
||||
+336
-1
@@ -1,6 +1,43 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { apiCall, platformGet, toMcpResult } from "../api.js";
|
||||
import { apiCall, platformGet, toMcpResult, isApiError } from "../api.js";
|
||||
|
||||
// Supported runtimes the platform provisioner will honor. Mirrors the
|
||||
// workspace-server allowlist (`internal/handlers/runtime_registry.go`
|
||||
// fallbackRuntimes + the template-derived set). This is the *client-side*
|
||||
// fail-closed guard for the provision_workspace tool: the orchestrator
|
||||
// gets a clear INVALID_ARGUMENTS instead of the platform silently
|
||||
// coercing an unknown/empty runtime to langgraph (the #184 / control-
|
||||
// plane #188 footgun). It is intentionally NOT the authoritative list —
|
||||
// the platform must still hard-gate (controlplane#188) — but it stops
|
||||
// the most common caller mistake (typo / omitted runtime) at the door.
|
||||
export const SUPPORTED_RUNTIMES = [
|
||||
"claude-code",
|
||||
"codex",
|
||||
"hermes",
|
||||
"openclaw",
|
||||
"langgraph",
|
||||
"autogen",
|
||||
"crewai",
|
||||
"deepagents",
|
||||
"kimi",
|
||||
"kimi-cli",
|
||||
"external",
|
||||
] as const;
|
||||
|
||||
// Canonical default template per runtime. The product "New Workspace"
|
||||
// dialog sends a `template` (e.g. "claude-code-default"); the workspace-
|
||||
// server derives the runtime from the template's config.yaml. Sending
|
||||
// BOTH (template + runtime) is the most robust call: template drives the
|
||||
// correct config/image, runtime is the assertion target for the
|
||||
// request==delivered echo-back check below.
|
||||
function defaultTemplateFor(runtime: string): string {
|
||||
// BYO-compute meta-runtimes have no template repo.
|
||||
if (runtime === "external" || runtime === "kimi" || runtime === "kimi-cli") {
|
||||
return "";
|
||||
}
|
||||
return `${runtime}-default`;
|
||||
}
|
||||
|
||||
export async function handleListWorkspaces() {
|
||||
const data = await platformGet("/workspaces");
|
||||
@@ -32,6 +69,261 @@ export async function handleCreateWorkspace(params: {
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* provision_workspace — agent-facing, fail-closed workspace provisioning.
|
||||
*
|
||||
* Why this exists (separate from create_workspace): the orchestrator needs
|
||||
* to bring up the production agent team with a SPECIFIC runtime
|
||||
* (claude-code / codex / hermes / openclaw / ...). Both the CP-direct
|
||||
* path AND the raw create path can return success while silently
|
||||
* delivering a langgraph workspace when the runtime can't be resolved
|
||||
* (#184 / molecule-controlplane#188). A "201 but wrong runtime" is a
|
||||
* contract violation, not a degraded success.
|
||||
*
|
||||
* This tool enforces the same fail-closed contract on the client side:
|
||||
* 1. Validate `runtime` against SUPPORTED_RUNTIMES — reject unknown
|
||||
* BEFORE any platform call (the SDK schema enum also enforces this;
|
||||
* this is defense-in-depth + a clearer error).
|
||||
* 2. Call the correct PRODUCT create path (POST /workspaces with both
|
||||
* `template` and `runtime`), NOT the CP-direct
|
||||
* /cp/workspaces/provision path the orchestrator had been forced to
|
||||
* use. Template drives the correct config/image; runtime is the
|
||||
* assertion target.
|
||||
* 3. Read the created workspace back and assert resolved runtime ==
|
||||
* requested runtime. On mismatch (or no runtime echoed) return a
|
||||
* structured FAILED-CLOSED error with the resolved value so the
|
||||
* caller can NOT mistake a langgraph fallback for success.
|
||||
*
|
||||
* The platform-side hard-gate is still required (controlplane#188 +
|
||||
* its workspace-server sibling) — this tool does not substitute for it,
|
||||
* it makes the agent-facing surface honest in the meantime.
|
||||
*/
|
||||
export async function handleProvisionWorkspace(params: {
|
||||
name: string;
|
||||
runtime: string;
|
||||
template?: string;
|
||||
tier?: number;
|
||||
role?: string;
|
||||
parent_id?: string;
|
||||
workspace_dir?: string;
|
||||
workspace_access?: "none" | "read_only" | "read_write";
|
||||
role_config?: {
|
||||
model?: string;
|
||||
config_yaml?: string;
|
||||
};
|
||||
}) {
|
||||
const { name, runtime, tier, role, parent_id, workspace_dir, workspace_access } = params;
|
||||
|
||||
// (1) Fail-closed runtime validation BEFORE any side effect.
|
||||
if (!(SUPPORTED_RUNTIMES as readonly string[]).includes(runtime)) {
|
||||
return toMcpResult({
|
||||
error: "UNSUPPORTED_RUNTIME",
|
||||
detail: `runtime "${runtime}" is not supported; supported: ${SUPPORTED_RUNTIMES.join(", ")}`,
|
||||
requested_runtime: runtime,
|
||||
provisioned: false,
|
||||
});
|
||||
}
|
||||
|
||||
// (2) Resolve template. Caller may override; default is the canonical
|
||||
// "<runtime>-default" template the product UI uses. Sending both
|
||||
// template + runtime is the most robust call (template → correct
|
||||
// config/image, runtime → assertion target).
|
||||
const template = params.template ?? defaultTemplateFor(runtime);
|
||||
|
||||
const created = await apiCall("POST", "/workspaces", {
|
||||
name,
|
||||
role,
|
||||
template: template || undefined,
|
||||
tier,
|
||||
parent_id,
|
||||
runtime,
|
||||
workspace_dir,
|
||||
workspace_access,
|
||||
canvas: initialCanvasPosition(),
|
||||
});
|
||||
|
||||
if (isApiError(created)) {
|
||||
return toMcpResult({
|
||||
error: "PROVISION_FAILED",
|
||||
detail: created,
|
||||
requested_runtime: runtime,
|
||||
provisioned: false,
|
||||
});
|
||||
}
|
||||
|
||||
const createdObj = (created ?? {}) as Record<string, unknown>;
|
||||
const workspaceId =
|
||||
typeof createdObj.id === "string" ? createdObj.id : undefined;
|
||||
|
||||
if (!workspaceId) {
|
||||
return toMcpResult({
|
||||
error: "PROVISION_FAILED",
|
||||
detail: "create succeeded but no workspace id returned; cannot verify resolved runtime",
|
||||
requested_runtime: runtime,
|
||||
create_response: created,
|
||||
provisioned: false,
|
||||
});
|
||||
}
|
||||
|
||||
// (3) Read back and assert request == delivered. The create response
|
||||
// does not always echo the persisted runtime, so re-fetch the row.
|
||||
const fetched = await platformGet(`/workspaces/${workspaceId}`);
|
||||
let resolvedRuntime: string | undefined;
|
||||
if (!isApiError(fetched) && fetched && typeof fetched === "object") {
|
||||
const f = fetched as Record<string, unknown>;
|
||||
if (typeof f.runtime === "string") resolvedRuntime = f.runtime;
|
||||
}
|
||||
|
||||
// BYO-compute runtimes may be normalized (e.g. "" -> "external");
|
||||
// treat the requested value as authoritative for those.
|
||||
const requestedIsByo =
|
||||
runtime === "external" || runtime === "kimi" || runtime === "kimi-cli";
|
||||
|
||||
if (resolvedRuntime === undefined) {
|
||||
return toMcpResult({
|
||||
error: "PROVISION_UNVERIFIED",
|
||||
detail:
|
||||
"workspace was created but its resolved runtime could not be read back; " +
|
||||
"treat as NOT verified — do not assume the requested runtime was honored",
|
||||
workspace_id: workspaceId,
|
||||
requested_runtime: runtime,
|
||||
provisioned: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (!requestedIsByo && resolvedRuntime !== runtime) {
|
||||
return toMcpResult({
|
||||
error: "RUNTIME_MISMATCH",
|
||||
detail:
|
||||
`requested runtime "${runtime}" but the platform provisioned ` +
|
||||
`"${resolvedRuntime}" (silent fallback — this is the #184 / ` +
|
||||
`controlplane#188 contract violation). The workspace exists but ` +
|
||||
`is the WRONG runtime; delete it and escalate (platform hard-gate ` +
|
||||
`not yet shipped).`,
|
||||
workspace_id: workspaceId,
|
||||
requested_runtime: runtime,
|
||||
resolved_runtime: resolvedRuntime,
|
||||
provisioned: false,
|
||||
});
|
||||
}
|
||||
|
||||
// (4) Optional role-config application + read-back-assert. Runtime is
|
||||
// verified above; now fold in the per-role config so "create" and
|
||||
// "apply-role-config" are ONE fail-closed operation instead of two
|
||||
// (the #218 prod-team defect: workspaces provisioned with the right
|
||||
// runtime but template-default role config — generic name, Sonnet
|
||||
// instead of the role's model, empty charter — because per-role
|
||||
// config was never applied as part of provisioning).
|
||||
//
|
||||
// Mechanism (canonical, source-verified against molecule-core
|
||||
// workspace-server):
|
||||
// - model → PUT /workspaces/:id/model (writes the MODEL_PROVIDER
|
||||
// workspace_secret; AUTHORITATIVE over config.yaml's
|
||||
// runtime_config.model per the claude-code adapter resolution
|
||||
// order; auto-restarts). Read back via GET /workspaces/:id/model
|
||||
// and ASSERT effective == requested — never trust the write-ack.
|
||||
// - config.yaml (name/description/charter/required_env) → PUT
|
||||
// /workspaces/:id/files/config.yaml (writes via EIC to the
|
||||
// workspace EC2 + auto-restarts). NOTE: the GET-back of
|
||||
// config.yaml resolves a DIFFERENT host/path than the PUT
|
||||
// (documented asymmetry — molecule-core
|
||||
// tests/e2e/test_staging_full_saas.sh), so config.yaml content is
|
||||
// NOT read-back-asserted here; the model read-back is the
|
||||
// authoritative effective-config gate.
|
||||
if (params.role_config) {
|
||||
const rc = params.role_config;
|
||||
const applied: Record<string, unknown> = {};
|
||||
|
||||
if (typeof rc.config_yaml === "string" && rc.config_yaml.length > 0) {
|
||||
const w = await apiCall(
|
||||
"PUT",
|
||||
`/workspaces/${workspaceId}/files/config.yaml`,
|
||||
{ content: rc.config_yaml }
|
||||
);
|
||||
if (isApiError(w)) {
|
||||
return toMcpResult({
|
||||
error: "ROLE_CONFIG_FAILED",
|
||||
detail: w,
|
||||
phase: "config.yaml",
|
||||
workspace_id: workspaceId,
|
||||
requested_runtime: runtime,
|
||||
resolved_runtime: resolvedRuntime,
|
||||
provisioned: true,
|
||||
role_config_applied: false,
|
||||
});
|
||||
}
|
||||
applied.config_yaml = "written";
|
||||
}
|
||||
|
||||
if (typeof rc.model === "string" && rc.model.length > 0) {
|
||||
const m = await apiCall("PUT", `/workspaces/${workspaceId}/model`, {
|
||||
model: rc.model,
|
||||
});
|
||||
if (isApiError(m)) {
|
||||
return toMcpResult({
|
||||
error: "ROLE_CONFIG_FAILED",
|
||||
detail: m,
|
||||
phase: "model",
|
||||
workspace_id: workspaceId,
|
||||
requested_runtime: runtime,
|
||||
resolved_runtime: resolvedRuntime,
|
||||
provisioned: true,
|
||||
role_config_applied: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Read-back-assert the EFFECTIVE model — not the write-ack.
|
||||
const mb = await platformGet(`/workspaces/${workspaceId}/model`);
|
||||
let effectiveModel: string | undefined;
|
||||
if (!isApiError(mb) && mb && typeof mb === "object") {
|
||||
const v = (mb as Record<string, unknown>).model;
|
||||
if (typeof v === "string") effectiveModel = v;
|
||||
}
|
||||
if (effectiveModel !== rc.model) {
|
||||
return toMcpResult({
|
||||
error: "ROLE_CONFIG_MODEL_MISMATCH",
|
||||
detail:
|
||||
`requested model "${rc.model}" but read-back returned ` +
|
||||
`"${effectiveModel ?? "<unreadable>"}" — the role's model was ` +
|
||||
`NOT applied; treat as NOT configured (do not assume the ` +
|
||||
`requested model is in effect).`,
|
||||
workspace_id: workspaceId,
|
||||
requested_model: rc.model,
|
||||
effective_model: effectiveModel ?? null,
|
||||
requested_runtime: runtime,
|
||||
resolved_runtime: resolvedRuntime,
|
||||
provisioned: true,
|
||||
role_config_applied: false,
|
||||
});
|
||||
}
|
||||
applied.model = effectiveModel;
|
||||
}
|
||||
|
||||
return toMcpResult({
|
||||
ok: true,
|
||||
provisioned: true,
|
||||
role_config_applied: true,
|
||||
workspace_id: workspaceId,
|
||||
requested_runtime: runtime,
|
||||
resolved_runtime: resolvedRuntime,
|
||||
template: template || null,
|
||||
applied,
|
||||
status: createdObj.status ?? "provisioning",
|
||||
});
|
||||
}
|
||||
|
||||
return toMcpResult({
|
||||
ok: true,
|
||||
provisioned: true,
|
||||
role_config_applied: false,
|
||||
workspace_id: workspaceId,
|
||||
requested_runtime: runtime,
|
||||
resolved_runtime: resolvedRuntime,
|
||||
template: template || null,
|
||||
status: createdObj.status ?? "provisioning",
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleGetWorkspace(params: { workspace_id: string }) {
|
||||
const data = await platformGet(`/workspaces/${params.workspace_id}`);
|
||||
return toMcpResult(data);
|
||||
@@ -90,6 +382,49 @@ export function registerWorkspaceTools(srv: McpServer) {
|
||||
handleCreateWorkspace
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"provision_workspace",
|
||||
"Provision a workspace with a SPECIFIC runtime (claude-code, codex, hermes, openclaw, langgraph, autogen, crewai, deepagents) via the correct product create path. Fail-closed: validates the runtime, then reads the created workspace back and returns an error (not a success) if the platform silently fell back to a different runtime. Use this — not create_workspace — when the runtime must be guaranteed.",
|
||||
{
|
||||
name: z.string().describe("Workspace name"),
|
||||
runtime: z
|
||||
.enum(SUPPORTED_RUNTIMES)
|
||||
.describe("Required runtime — provisioning fails closed if it cannot be honored"),
|
||||
template: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Template name (defaults to '<runtime>-default'); overrides runtime-derived template"),
|
||||
tier: z.number().min(1).max(4).optional().describe("Tier (1=basic, 2=browser, 3=desktop, 4=VM). SaaS forces T4."),
|
||||
role: z.string().optional().describe("Role description"),
|
||||
parent_id: z.string().optional().describe("Parent workspace ID for nesting"),
|
||||
workspace_dir: z.string().optional().describe("Host path to bind-mount at /workspace"),
|
||||
workspace_access: z
|
||||
.enum(["none", "read_only", "read_write"])
|
||||
.optional()
|
||||
.describe("Filesystem access mode for /workspace"),
|
||||
role_config: z
|
||||
.object({
|
||||
model: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Effective model slug for this role (e.g. 'opus', 'kimi-for-coding', 'MiniMax-M2.7', 'gpt-5.5'). Applied via PUT /model (authoritative over config.yaml) and read-back-asserted — provisioning fails closed if the effective model does not match."
|
||||
),
|
||||
config_yaml: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Full config.yaml content for the role (name, description/charter, runtime_config.model, required_env). Written via the Files API; preserve the template's providers registry. NOT read-back-asserted (PUT/GET path asymmetry) — the model read-back is the effective-config gate."
|
||||
),
|
||||
})
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional per-role config applied + verified as part of the SAME fail-closed provision op. Without this, a workspace can be the right runtime but carry template-default role config (the #218 defect)."
|
||||
),
|
||||
},
|
||||
handleProvisionWorkspace
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"get_workspace",
|
||||
"Get detailed information about a specific workspace",
|
||||
|
||||
Reference in New Issue
Block a user