Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions vtex/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 18 additions & 0 deletions vtex/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions vtex/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
7 changes: 7 additions & 0 deletions vtex/server/lib/tool-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down
95 changes: 95 additions & 0 deletions vtex/server/lib/write-mode.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
53 changes: 53 additions & 0 deletions vtex/server/lib/write-mode.ts
Original file line number Diff line number Diff line change
@@ -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).`,
);
}
5 changes: 5 additions & 0 deletions vtex/server/tools/custom/reorder-collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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 ?? [];

Expand Down
5 changes: 5 additions & 0 deletions vtex/server/tools/custom/update-product-specifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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);

Expand Down
6 changes: 6 additions & 0 deletions vtex/server/types/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof StateSchema>;
Expand Down
Loading