Compare commits

...

4 Commits

Author SHA1 Message Date
core-devops 8e64f9f107 feat(workspaces): fold apply-role-config + read-back-assert into provision_workspace
CI / test (pull_request) Successful in 1m7s
Extends the fail-closed provision_workspace tool with an optional
role_config { model, config_yaml } block so "create + apply-role-config
+ read-back-assert" is ONE fail-closed operation instead of two
separate, skippable steps.

Motivation (#218 prod-team defect): the 5 prod-team workspaces were
provisioned with the correct 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 (source-verified against molecule-core workspace-server):
- model      -> PUT /workspaces/:id/model (writes MODEL_PROVIDER
  workspace_secret; authoritative over config.yaml runtime_config.model
  per the claude-code adapter resolution order; auto-restarts). The
  effective model is read back via GET /workspaces/:id/model and
  ASSERTED == requested; a write-ack is never trusted as success.
- config.yaml -> PUT /workspaces/:id/files/config.yaml (name,
  description/charter, runtime_config.model, required_env; written via
  EIC to the workspace EC2 + auto-restarts). NOT read-back-asserted
  due to the documented PUT/GET path asymmetry (molecule-core
  tests/e2e/test_staging_full_saas.sh) — the model read-back is the
  authoritative effective-config gate.

Fail-closed surface: ROLE_CONFIG_FAILED (write error, with phase),
ROLE_CONFIG_MODEL_MISMATCH (effective model != requested after
read-back). role_config_applied is always present in the result so a
caller cannot mistake a runtime-only provision for a fully-configured
role.

Tests: +3 (success path, model-mismatch fail-closed, role_config
absent). Full suite green: 136 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 02:10:12 -07:00
core-devops 549c15c594 feat(workspaces): add fail-closed provision_workspace MCP tool
CI / test (pull_request) Successful in 57s
Adds a `provision_workspace` MCP tool so an agent can provision a
workspace with a GUARANTEED runtime (claude-code/codex/hermes/openclaw/
langgraph/autogen/crewai/deepagents) via the correct PRODUCT create path
(POST /workspaces with template+runtime) — not the CP-direct
/cp/workspaces/provision path the orchestrator was forced to use.

Enforces the same fail-closed contract as molecule-controlplane#188 on
the agent-facing surface:
  1. Validate runtime against the supported set BEFORE any side effect.
  2. Create via the product path (template drives config/image).
  3. Read the workspace back and assert resolved runtime == requested;
     return a structured RUNTIME_MISMATCH/PROVISION_UNVERIFIED error
     (NOT a success) if the platform silently fell back to langgraph.

This makes the agent surface honest now; it does NOT replace the
required platform-side hard-gate (controlplane#188 + its workspace-
server sibling — each adapter stays runtime-specific, the platform is
the unified SSOT that must error+notify, never silent-advisory).

Refs: molecule-controlplane#188, #184 (CP-direct vs product-create
fidelity gap).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 01:19:48 -07:00
hongming-pc2 e267875fb3 Merge PR #18 via Gitea merge queue
CI / test (push) Successful in 36s
Serialized merge by gitea-merge-queue after current-main, SOP, and required CI checks were green.
2026-05-17 19:13:13 +00:00
sdk-dev 68e0505d9f docs(mcp): fix stale SDK reference in Platform Integration section
[Do] Manual gate post
sop-checklist / all-items-acked Manual gate post
CI / test (pull_request) Successful in 19s
PR #17 updated known-issues.md but dropped the CLAUDE.md fix.
The Platform Integration section incorrectly claimed the server uses
the Python SDK (molecule-sdk-python). The MCP server has its own
TypeScript client in src/api.ts — the Python SDK is for remote agents.

Also fixed "reads data via the platform SDK" → "reads data via the
platform REST API" in the Postgres section.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 00:14:04 +00:00
4 changed files with 526 additions and 6 deletions
+5 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",