diff --git a/vtex/.env.example b/vtex/.env.example index 604a225b..7fcbf43e 100644 --- a/vtex/.env.example +++ b/vtex/.env.example @@ -2,3 +2,7 @@ VTEX_ACCOUNT_NAME=your-account-name VTEX_APP_KEY=your-app-key VTEX_APP_TOKEN=your-app-token + +# Optional — allow write operations (create/update/delete). +# Defaults to read-only when unset. +VTEX_WRITE_MODE=true diff --git a/vtex/README.md b/vtex/README.md index a4c773c3..671766e6 100644 --- a/vtex/README.md +++ b/vtex/README.md @@ -70,6 +70,22 @@ When connecting through the MCP URL, provide: | `accountName` | Your VTEX account name | | `appKey` | Your VTEX App Key | | `appToken` | Your VTEX App Token | +| `writeMode` | Opt-in to write operations. Defaults to `false` (read-only) | + +### Read-only by default (write mode) + +The MCP is **read-only by default**. Read tools (those annotated +`readOnlyHint: true` — `GET` / `LIST` / `SEARCH`) always run; any +create/update/delete tool is refused with a clear error unless the connection +opts in by setting `writeMode: true` in its configuration state (or +`VTEX_WRITE_MODE=true` for local development). + +A tool is treated as a write **unless** it is explicitly annotated +`readOnlyHint: true`, so an un-annotated mutation can never slip through the +gate. The gate is enforced per-request at execution time — not by hiding tools +from `tools/list` — because `@decocms/runtime` caches tool registrations for the +process lifetime while configuration state is delivered per-request +(multi-tenant). See `server/lib/write-mode.ts`. ### Environment Variables @@ -79,6 +95,8 @@ For local development, create a `.env` file: VTEX_ACCOUNT_NAME=your-account-name VTEX_APP_KEY=your-app-key VTEX_APP_TOKEN=your-app-token +# Optional — allow write operations locally (default read-only) +VTEX_WRITE_MODE=true ``` ### Internal endpoints (VtexId session-token auth) diff --git a/vtex/app.json b/vtex/app.json index 4719f9ae..5ffdd6d4 100644 --- a/vtex/app.json +++ b/vtex/app.json @@ -23,6 +23,12 @@ "title": "App Token", "description": "Your VTEX App Token for API authentication", "format": "password" + }, + "writeMode": { + "type": "boolean", + "title": "Enable write mode", + "description": "Opt-in to write operations. When off (default), the MCP is read-only: only read tools run and any create/update/delete tool is refused.", + "default": false } }, "required": ["accountName", "appKey", "appToken"] diff --git a/vtex/server/lib/tool-adapter.ts b/vtex/server/lib/tool-adapter.ts index fbabc8e4..3d28295d 100644 --- a/vtex/server/lib/tool-adapter.ts +++ b/vtex/server/lib/tool-adapter.ts @@ -11,6 +11,7 @@ import { resolveCredentials, } from "./client-factory.ts"; import { applyParamDescriptions } from "./param-descriptions.ts"; +import { assertWriteModeEnabled, isReadOnlyTool } from "./write-mode.ts"; // ────────────────────────────────────────────────────────────────────────────── // Schema introspection helpers @@ -312,6 +313,11 @@ export function createToolFromOperation(config: ToolFromOperationConfig) { // start would poison every subsequent state read with `state: {}`. // Read per-request env from `runtimeContext` instead — the runtime fills // it from AsyncLocalStorage on every execute call. + // Read-only unless the operation is explicitly annotated readOnlyHint: true. + // Un-annotated operations are treated as writes so a mutation can never slip + // through the read-only gate. + const readOnly = isReadOnlyTool(config.annotations); + return (_env: Env) => createTool({ id: config.id, @@ -322,6 +328,7 @@ export function createToolFromOperation(config: ToolFromOperationConfig) { const meshCtx = (runtimeContext.env as Env).MESH_REQUEST_CONTEXT; const creds = resolveCredentials(meshCtx?.state); assertValidCredentials(creds, config.id); + if (!readOnly) assertWriteModeEnabled(meshCtx?.state, config.id); const factory = config.clientFactory ?? createVtexClient; const client = factory(creds); const structured = unflattenToStructured( diff --git a/vtex/server/lib/write-mode.test.ts b/vtex/server/lib/write-mode.test.ts new file mode 100644 index 00000000..77672d2d --- /dev/null +++ b/vtex/server/lib/write-mode.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + assertWriteModeEnabled, + isReadOnlyTool, + resolveWriteMode, +} from "./write-mode.ts"; + +// ── resolveWriteMode ─────────────────────────────────────────────────────────── + +describe("resolveWriteMode", () => { + const originalEnv = process.env.VTEX_WRITE_MODE; + + beforeEach(() => { + delete process.env.VTEX_WRITE_MODE; + }); + + afterEach(() => { + if (originalEnv === undefined) delete process.env.VTEX_WRITE_MODE; + else process.env.VTEX_WRITE_MODE = originalEnv; + }); + + test("read-only when state is undefined and no env override", () => { + expect(resolveWriteMode(undefined)).toBe(false); + }); + + test("read-only when writeMode is omitted from state", () => { + expect(resolveWriteMode({ accountName: "acme" })).toBe(false); + }); + + test("writes allowed when state.writeMode is true", () => { + expect(resolveWriteMode({ writeMode: true })).toBe(true); + }); + + test("explicit state.writeMode:false wins over env override", () => { + process.env.VTEX_WRITE_MODE = "true"; + expect(resolveWriteMode({ writeMode: false })).toBe(false); + }); + + test("env override enables writes when state is silent", () => { + process.env.VTEX_WRITE_MODE = "true"; + expect(resolveWriteMode(undefined)).toBe(true); + expect(resolveWriteMode({ accountName: "acme" })).toBe(true); + }); + + test("non-boolean writeMode values do not enable writes", () => { + expect(resolveWriteMode({ writeMode: "true" })).toBe(false); + expect(resolveWriteMode({ writeMode: 1 })).toBe(false); + }); +}); + +// ── isReadOnlyTool ────────────────────────────────────────────────────────────── + +describe("isReadOnlyTool", () => { + test("true only for explicit readOnlyHint: true", () => { + expect(isReadOnlyTool({ readOnlyHint: true })).toBe(true); + }); + + test("false for missing annotations (treated as write)", () => { + expect(isReadOnlyTool(undefined)).toBe(false); + expect(isReadOnlyTool({})).toBe(false); + }); + + test("false for destructive/non-read annotations", () => { + expect(isReadOnlyTool({ destructiveHint: true })).toBe(false); + expect(isReadOnlyTool({ destructiveHint: false })).toBe(false); + expect(isReadOnlyTool({ readOnlyHint: false })).toBe(false); + }); +}); + +// ── assertWriteModeEnabled ────────────────────────────────────────────────────── + +describe("assertWriteModeEnabled", () => { + const originalEnv = process.env.VTEX_WRITE_MODE; + + beforeEach(() => { + delete process.env.VTEX_WRITE_MODE; + }); + + afterEach(() => { + if (originalEnv === undefined) delete process.env.VTEX_WRITE_MODE; + else process.env.VTEX_WRITE_MODE = originalEnv; + }); + + test("throws in read-only mode, naming the tool", () => { + expect(() => + assertWriteModeEnabled({ accountName: "acme" }, "VTEX_UPDATE_PRODUCT"), + ).toThrow(/VTEX_UPDATE_PRODUCT.*read-only mode/s); + }); + + test("does not throw when write mode is enabled", () => { + expect(() => + assertWriteModeEnabled({ writeMode: true }, "VTEX_UPDATE_PRODUCT"), + ).not.toThrow(); + }); +}); diff --git a/vtex/server/lib/write-mode.ts b/vtex/server/lib/write-mode.ts new file mode 100644 index 00000000..66ba67b4 --- /dev/null +++ b/vtex/server/lib/write-mode.ts @@ -0,0 +1,53 @@ +/** + * Write-mode gate. + * + * The VTEX MCP is READ-ONLY by default. Write operations (create/update/delete) + * only run when the connection opts in via `writeMode: true` in the + * configuration state (or the `VTEX_WRITE_MODE=true` env var for local dev). + * + * Enforced per-request at execute time — NOT by filtering the tool list. + * @decocms/runtime resolves and caches tool registrations once for the process + * lifetime, while configuration state is delivered per-request (multi-tenant). + * Reading `writeMode` at registration time would freeze whatever the first + * request happened to send for every subsequent tenant — the same hazard the + * comment in `tool-adapter.ts` describes for credentials. So the gate reads + * state per-call, exactly like `resolveCredentials`. + */ +import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Resolve whether write operations are allowed for the current request. + * + * Precedence: explicit `state.writeMode` (true/false) wins; when it is absent + * we fall back to `VTEX_WRITE_MODE=true` (handy for local development); default + * is read-only. + */ +export function resolveWriteMode(state: unknown): boolean { + const fromState = (state as { writeMode?: unknown } | undefined)?.writeMode; + if (fromState === true) return true; + if (fromState === false) return false; + return process.env.VTEX_WRITE_MODE === "true"; +} + +/** + * A tool counts as a read operation ONLY when it is explicitly annotated with + * `readOnlyHint: true`. Anything else — including tools with no annotations — + * is treated as a write, so an un-annotated mutation can never slip through the + * gate in read-only mode. + */ +export function isReadOnlyTool(annotations?: ToolAnnotations): boolean { + return annotations?.readOnlyHint === true; +} + +/** + * Throw a clear, actionable error when a write tool is invoked while the MCP is + * in read-only mode. No-op when write mode is enabled. + */ +export function assertWriteModeEnabled(state: unknown, toolId: string): void { + if (resolveWriteMode(state)) return; + throw new Error( + `${toolId} is a write operation, but this VTEX MCP is running in read-only mode. ` + + `Enable writes by setting "writeMode": true in the connection configuration ` + + `(or VTEX_WRITE_MODE=true for local development).`, + ); +} diff --git a/vtex/server/tools/custom/reorder-collection.ts b/vtex/server/tools/custom/reorder-collection.ts index 1fb8ca47..7183657b 100644 --- a/vtex/server/tools/custom/reorder-collection.ts +++ b/vtex/server/tools/custom/reorder-collection.ts @@ -4,6 +4,7 @@ import { assertValidCredentials, resolveCredentials, } from "../../lib/client-factory.ts"; +import { assertWriteModeEnabled } from "../../lib/write-mode.ts"; import type { Env } from "../../types/env.ts"; /** @@ -282,6 +283,10 @@ export const reorderCollection = (_env: Env) => const reorderPromise = (async () => { const credentials = resolveCredentials(env.MESH_REQUEST_CONTEXT?.state); assertValidCredentials(credentials, "VTEX_REORDER_COLLECTION"); + assertWriteModeEnabled( + env.MESH_REQUEST_CONTEXT?.state, + "VTEX_REORDER_COLLECTION", + ); const directSkuIds = context.skuIds ?? []; const productIds = context.productIds ?? []; diff --git a/vtex/server/tools/custom/update-product-specifications.ts b/vtex/server/tools/custom/update-product-specifications.ts index 77a1f305..68d0dda2 100644 --- a/vtex/server/tools/custom/update-product-specifications.ts +++ b/vtex/server/tools/custom/update-product-specifications.ts @@ -4,6 +4,7 @@ import { assertValidCredentials, resolveCredentials, } from "../../lib/client-factory.ts"; +import { assertWriteModeEnabled } from "../../lib/write-mode.ts"; import type { Env } from "../../types/env.ts"; const inputSchema = z.object({ @@ -53,6 +54,10 @@ export const updateProductSpecifications = (_env: Env) => const env = runtimeContext.env as Env; const credentials = resolveCredentials(env.MESH_REQUEST_CONTEXT?.state); assertValidCredentials(credentials, "VTEX_UPDATE_PRODUCT_SPECIFICATIONS"); + assertWriteModeEnabled( + env.MESH_REQUEST_CONTEXT?.state, + "VTEX_UPDATE_PRODUCT_SPECIFICATIONS", + ); const url = `https://${credentials.accountName}.vtexcommercestable.com.br/api/catalog_system/pvt/products/${context.productId}/specification`; console.log("[VTEX] POST", url); diff --git a/vtex/server/types/env.ts b/vtex/server/types/env.ts index 80e3c81b..3cc9c830 100644 --- a/vtex/server/types/env.ts +++ b/vtex/server/types/env.ts @@ -24,6 +24,12 @@ export const StateSchema = z.object({ .describe( "Store currency for analytics endpoints, e.g. BRL or USD (default BRL)", ), + writeMode: z + .boolean() + .optional() + .describe( + "Opt-in to write operations. When false or omitted (default), the MCP is read-only: only read tools (GET/LIST/SEARCH) run and any create/update/delete tool is refused. Set to true to allow writes.", + ), }); export type Env = DefaultEnv;