diff --git a/.github/workflows/ad-hoc-docker-image.yml b/.github/workflows/ad-hoc-docker-image.yml index 740ceda965ce..539c18f35b59 100644 --- a/.github/workflows/ad-hoc-docker-image.yml +++ b/.github/workflows/ad-hoc-docker-image.yml @@ -48,8 +48,15 @@ jobs: with: branch: ${{ inputs.branch }} + mcp-build: + name: mcp-build + uses: ./.github/workflows/mcp-build.yml + secrets: inherit + with: + branch: ${{ inputs.branch }} + package: - needs: [server-build, client-build, rts-build] + needs: [server-build, client-build, rts-build, mcp-build] runs-on: ubuntu-latest # Set permissions since we're using OIDC token authentication between Depot and GitHub permissions: @@ -94,6 +101,18 @@ jobs: echo "Cleaning up the tar files" rm app/client/packages/rts/dist/rts-dist.tar + - name: Download the MCP build artifact + uses: actions/download-artifact@v4 + with: + name: mcp-dist + path: app/client/packages/mcp/dist + + - name: Untar the MCP folder + run: | + tar -xvf app/client/packages/mcp/dist/mcp-dist.tar -C app/client/packages/mcp/ + echo "Cleaning up the MCP tar files" + rm app/client/packages/mcp/dist/mcp-dist.tar + - name: Generate info.json id: info_json run: | diff --git a/.github/workflows/build-client-server-count.yml b/.github/workflows/build-client-server-count.yml index 4934f5582811..5b3fc61285ba 100644 --- a/.github/workflows/build-client-server-count.yml +++ b/.github/workflows/build-client-server-count.yml @@ -147,8 +147,17 @@ jobs: with: pr: ${{fromJson(needs.file-check.outputs.pr)}} + mcp-build: + name: mcp-build + needs: [file-check] + if: success() && needs.file-check.outputs.runId == 0 + uses: ./.github/workflows/mcp-build.yml + secrets: inherit + with: + pr: ${{fromJson(needs.file-check.outputs.pr)}} + build-docker-image: - needs: [file-check, client-build, server-build, rts-build] + needs: [file-check, client-build, server-build, rts-build, mcp-build] # Only run if the build step is successful if: success() && needs.file-check.outputs.runId == '0' name: build-docker-image diff --git a/.github/workflows/build-client-server.yml b/.github/workflows/build-client-server.yml index cecc31922a49..68b9546cb049 100644 --- a/.github/workflows/build-client-server.yml +++ b/.github/workflows/build-client-server.yml @@ -115,8 +115,17 @@ jobs: with: pr: ${{fromJson(needs.file-check.outputs.pr)}} + mcp-build: + name: mcp-build + needs: [file-check] + if: success() && needs.file-check.outputs.runId == '0' + uses: ./.github/workflows/mcp-build.yml + secrets: inherit + with: + pr: ${{fromJson(needs.file-check.outputs.pr)}} + build-docker-image: - needs: [file-check, client-build, server-build, rts-build] + needs: [file-check, client-build, server-build, rts-build, mcp-build] # Only run if the build step is successful if: success() && needs.file-check.outputs.runId == '0' name: build-docker-image diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index d39804a027c5..2fef16101c9d 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -80,6 +80,19 @@ jobs: echo "Cleaning up the tar files" rm app/client/packages/rts/dist/rts-dist.tar + - name: Download the MCP build artifact + if: steps.run_result.outputs.run_result != 'success' + uses: actions/download-artifact@v4 + with: + name: mcp-dist + path: app/client/packages/mcp/dist + + - name: Un-tar the MCP folder + run: | + tar -xvf app/client/packages/mcp/dist/mcp-dist.tar -C app/client/packages/mcp/ + echo "Cleaning up the MCP tar files" + rm app/client/packages/mcp/dist/mcp-dist.tar + - name: Generate info.json id: info_json run: | diff --git a/.github/workflows/docs/test-build-docker-image.md b/.github/workflows/docs/test-build-docker-image.md index f9297e46c405..d8d6c8bf7b13 100644 --- a/.github/workflows/docs/test-build-docker-image.md +++ b/.github/workflows/docs/test-build-docker-image.md @@ -32,9 +32,13 @@ The `client-build` job builds the client-side codebase. It uses the configuratio The `rts-build` job builds the "rts" (real-time suggestions) package of the client-side codebase. It uses the configuration defined in `.github/workflows/rts-build.yml` and inherits secrets from the repository. +### `mcp-build` + +This job builds and tests the MCP service, then supplies its bundled artifact to the Docker-image jobs. + ### `build-docker-image` -This job, named `build-docker-image`, creates and pushes the Docker image for the application. It is dependent on the success of the `client-build`, `server-build`, and `rts-build` jobs. The Docker image is built with two platforms: `linux/arm64` and `linux/amd64`. +This job, named `build-docker-image`, creates and pushes the Docker image for the application. It is dependent on the success of the `client-build`, `server-build`, `rts-build`, and `mcp-build` jobs. The Docker image is built with two platforms: `linux/arm64` and `linux/amd64`. ### `ci-test` diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml index cfd677e14b2e..7af2d73bbd9a 100644 --- a/.github/workflows/github-release.yml +++ b/.github/workflows/github-release.yml @@ -202,8 +202,16 @@ jobs: path: app/client/packages/rts/rts-dist.tar overwrite: true + mcp-build: + needs: + - prelude + uses: ./.github/workflows/mcp-build.yml + secrets: inherit + with: + branch: ${{ github.ref }} + package: - needs: [prelude, client-build, server-build, rts-build] + needs: [prelude, client-build, server-build, rts-build, mcp-build] runs-on: ubuntu-latest permissions: @@ -248,6 +256,18 @@ jobs: echo "Cleaning up the tar files" rm app/client/packages/rts/dist/rts-dist.tar + - name: Download the MCP build artifact + uses: actions/download-artifact@v4 + with: + name: mcp-dist + path: app/client/packages/mcp/dist + + - name: Untar the MCP folder + run: | + tar -xvf app/client/packages/mcp/dist/mcp-dist.tar -C app/client/packages/mcp/ + echo "Cleaning up the MCP tar files" + rm app/client/packages/mcp/dist/mcp-dist.tar + - name: Generate info.json id: info_json run: | diff --git a/.github/workflows/mcp-build.yml b/.github/workflows/mcp-build.yml new file mode 100644 index 000000000000..b8871118581f --- /dev/null +++ b/.github/workflows/mcp-build.yml @@ -0,0 +1,142 @@ +name: Build MCP Workflow + +on: + workflow_dispatch: + workflow_call: + inputs: + pr: + description: "PR number when building a pull-request merge commit" + required: false + type: number + skip-tests: + description: "Skip unit tests for image-only builds" + required: false + type: string + default: "false" + branch: + description: "Branch to build when pr is 0" + required: false + type: string + + pull_request: + branches: [release, master] + paths: + - "app/client/packages/mcp/**" + +defaults: + run: + working-directory: app/client/packages/mcp + shell: bash + +jobs: + build: + runs-on: ubuntu-latest + if: | + github.event.pull_request.head.repo.full_name == github.repository || + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + github.event_name == 'repository_dispatch' || + github.event_name == 'schedule' || + github.event_name == 'release' + # Mongo + Redis back the governance store's distributed-lock / confirmation-atomicity safety net. Without these + # service containers store.integration.test.ts stays describe.skip and that safety net never runs in CI. + services: + mongodb: + image: mongo:6.0 + ports: + - 27017:27017 + options: >- + --health-cmd "mongosh --quiet --eval 'db.runCommand({ ping: 1 }).ok'" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + steps: + - name: Checkout the merged pull-request commit + if: inputs.pr != 0 + uses: actions/checkout@v4 + with: + fetch-tags: true + persist-credentials: false + ref: refs/pull/${{ inputs.pr }}/merge + + - name: Checkout the specified branch + if: inputs.pr == 0 && inputs.branch != '' + uses: actions/checkout@v4 + with: + fetch-tags: true + persist-credentials: false + ref: ${{ inputs.branch }} + + - name: Checkout the head commit + if: inputs.pr == 0 && inputs.branch == '' + uses: actions/checkout@v4 + with: + fetch-tags: true + persist-credentials: false + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version-file: app/client/package.json + + # actions/setup-node@v4 does not restore Yarn 3 caches from a subdirectory. + - name: Restore Yarn cache + uses: actions/cache@v4 + with: + path: app/client/.yarn/cache + key: v1-yarn3-${{ hashFiles('app/client/yarn.lock') }} + restore-keys: | + v1-yarn3- + + - name: Install dependencies + run: | + corepack enable + yarn install --immutable + + - name: Check types + run: yarn check-types + + - name: Lint + run: yarn lint + + - name: Check formatting + run: yarn prettier + + - name: Run unit tests + if: inputs.skip-tests != 'true' + run: yarn test:unit + + # Runs ONLY the governance integration suite against the mongo+redis service containers (kept separate so the + # unit run above stays fast). With these env vars set, store.integration.test.ts's describe.skip flips to + # describe and the Redis-lock / confirmation-atomicity assertions actually execute. + - name: Run governance integration tests + if: inputs.skip-tests != 'true' + env: + APPSMITH_MONGODB_URI: mongodb://127.0.0.1:27017 + APPSMITH_REDIS_URL: redis://127.0.0.1:6379 + # Forces the anti-silent-no-op guard: if the mongo/redis wiring ever drifts, the step FAILS instead of the + # suite reverting to describe.skip and exiting 0 green. + MCP_REQUIRE_INTEGRATION: "1" + run: yarn test:unit src/governance/store.integration.test.ts + + - name: Build + run: yarn build + + - name: Tar the MCP bundle + run: tar -cvf mcp-dist.tar dist + + - name: Upload MCP build bundle + uses: actions/upload-artifact@v4 + with: + name: mcp-dist + path: app/client/packages/mcp/mcp-dist.tar + overwrite: true diff --git a/.github/workflows/on-demand-build-docker-image-deploy-preview.yml b/.github/workflows/on-demand-build-docker-image-deploy-preview.yml index ba5e8aee984b..069f6c2736d8 100644 --- a/.github/workflows/on-demand-build-docker-image-deploy-preview.yml +++ b/.github/workflows/on-demand-build-docker-image-deploy-preview.yml @@ -148,8 +148,18 @@ jobs: body: bodyLines.join("\n"), }) + mcp-build: + needs: [resolve-params] + name: mcp-build + if: github.event.client_payload.slash_command.args.named.env != 'release' + uses: ./.github/workflows/mcp-build.yml + secrets: inherit + with: + pr: ${{ fromJSON(needs.resolve-params.outputs.pr_number) }} + skip-tests: ${{ github.event.client_payload.slash_command.args.named.skip-tests }} + push-image: - needs: [resolve-params, client-build, rts-build, server-build] + needs: [resolve-params, client-build, rts-build, mcp-build, server-build] runs-on: ubuntu-latest permissions: contents: read @@ -201,6 +211,18 @@ jobs: echo "Cleaning up the tar files" rm app/client/packages/rts/dist/rts-dist.tar + - name: Download the MCP build artifact + uses: actions/download-artifact@v4 + with: + name: mcp-dist + path: app/client/packages/mcp/dist + + - name: Untar the MCP folder + run: | + tar -xvf app/client/packages/mcp/dist/mcp-dist.tar -C app/client/packages/mcp/ + echo "Cleaning up the MCP tar files" + rm app/client/packages/mcp/dist/mcp-dist.tar + - name: Generate info.json id: info_json run: | diff --git a/.github/workflows/playwright-e2e.yml b/.github/workflows/playwright-e2e.yml index 0bc4d8462b3b..64e30ea875b6 100644 --- a/.github/workflows/playwright-e2e.yml +++ b/.github/workflows/playwright-e2e.yml @@ -141,8 +141,17 @@ jobs: pr: ${{ fromJSON(format('{0}', inputs.pr)) }} branch: ${{ inputs.branch }} + mcp-build: + needs: path-filter + if: success() && needs.path-filter.outputs.not-ci == 'true' && inputs.docker_image_name == '' + uses: ./.github/workflows/mcp-build.yml + secrets: inherit + with: + pr: ${{ fromJSON(format('{0}', inputs.pr)) }} + branch: ${{ inputs.branch }} + build-docker-image: - needs: [client-build, server-build, rts-build, path-filter] + needs: [client-build, server-build, rts-build, mcp-build, path-filter] if: success() && needs.path-filter.outputs.not-ci == 'true' && inputs.docker_image_name == '' uses: ./.github/workflows/build-docker-image.yml secrets: inherit @@ -151,7 +160,7 @@ jobs: branch: ${{ inputs.branch }} playwright-e2e: - needs: [path-filter, client-build, server-build, rts-build, build-docker-image] + needs: [path-filter, client-build, server-build, rts-build, mcp-build, build-docker-image] if: | always() && needs.path-filter.result == 'success' && diff --git a/.github/workflows/pr-cypress.yml b/.github/workflows/pr-cypress.yml index a70d14bc8695..9cd6e27633e5 100644 --- a/.github/workflows/pr-cypress.yml +++ b/.github/workflows/pr-cypress.yml @@ -58,8 +58,16 @@ jobs: pr: ${{ github.event.number }} is-pg-build: ${{ github.event.pull_request.base.ref == 'pg' }} + mcp-build: + if: success() + name: mcp-build + uses: ./.github/workflows/mcp-build.yml + secrets: inherit + with: + pr: ${{ github.event.number }} + build-docker-image: - needs: [client-build, server-build, rts-build] + needs: [client-build, server-build, rts-build, mcp-build] # Only run if the build step is successful if: success() name: build-docker-image diff --git a/.github/workflows/test-build-docker-image.yml b/.github/workflows/test-build-docker-image.yml index e040a89f3928..8e1ed319227a 100644 --- a/.github/workflows/test-build-docker-image.yml +++ b/.github/workflows/test-build-docker-image.yml @@ -89,8 +89,17 @@ jobs: with: pr: 0 + mcp-build: + needs: [setup] + if: success() + name: mcp-build + uses: ./.github/workflows/mcp-build.yml + secrets: inherit + with: + pr: 0 + build-docker-image: - needs: [client-build, server-build, rts-build] + needs: [client-build, server-build, rts-build, mcp-build] # Only run if the build step is successful if: success() name: build-docker-image @@ -338,6 +347,18 @@ jobs: echo "Cleaning up the tar files" rm app/client/packages/rts/dist/rts-dist.tar + - name: Download the MCP build artifact + uses: actions/download-artifact@v4 + with: + name: mcp-dist + path: app/client/packages/mcp/dist + + - name: Untar the MCP folder + run: | + tar -xvf app/client/packages/mcp/dist/mcp-dist.tar -C app/client/packages/mcp/ + echo "Cleaning up the MCP tar files" + rm app/client/packages/mcp/dist/mcp-dist.tar + - name: Generate info.json id: info_json run: | @@ -432,6 +453,18 @@ jobs: echo "Cleaning up the tar files" rm app/client/packages/rts/dist/rts-dist.tar + - name: Download the MCP build artifact + uses: actions/download-artifact@v4 + with: + name: mcp-dist + path: app/client/packages/mcp/dist + + - name: Untar the MCP folder + run: | + tar -xvf app/client/packages/mcp/dist/mcp-dist.tar -C app/client/packages/mcp/ + echo "Cleaning up the MCP tar files" + rm app/client/packages/mcp/dist/mcp-dist.tar + - name: Generate info.json run: | if [[ -f scripts/generate_info_json.sh ]]; then diff --git a/.gitignore b/.gitignore index 0aaa639fdca9..6a53ec198d13 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,5 @@ mongodb* !deploy/helm/**/mongodb* # ignore the task file as it will be different for different project implementations. -TASKS.md \ No newline at end of file +TASKS.md +.gitnexus diff --git a/Dockerfile b/Dockerfile index 1d60a180ada9..10bd72209ec1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,6 +33,9 @@ COPY ./app/client/build editor/ # Add RTS - Application Layer COPY ./app/client/packages/rts/dist rts/ +# Add MCP - Application Layer +COPY ./app/client/packages/mcp/dist mcp/ + # Create the git-storage directory with group writeable permissions so non-root users can write to it. RUN mkdir --mode 775 "/dev/shm/git-storage" diff --git a/app/client/cypress/e2e/Regression/ClientSide/UserProfile/UpdateUsersName_spec.js b/app/client/cypress/e2e/Regression/ClientSide/UserProfile/UpdateUsersName_spec.js index 9625b1fc74e8..670c5d5bce66 100644 --- a/app/client/cypress/e2e/Regression/ClientSide/UserProfile/UpdateUsersName_spec.js +++ b/app/client/cypress/e2e/Regression/ClientSide/UserProfile/UpdateUsersName_spec.js @@ -37,8 +37,9 @@ describe("Update a user's name", { tags: ["@tag.Settings"] }, function () { // Waiting as the input onchange has a debounce // eslint-disable-next-line cypress/no-unnecessary-waiting cy.wait(2000); - cy.get(".ads-v2-input__input-section-input") - .last() + // Target the email field by its stable test id rather than the last input on the page — other sections on the + // profile page (e.g. MCP tokens) also render inputs, so a positional `.last()` selector is fragile. + cy.get("[data-testid=t--user-name]") .invoke("val") .then((text) => { expect(text).to.equal(Cypress.env("USERNAME")); diff --git a/app/client/packages/mcp/.env.example b/app/client/packages/mcp/.env.example new file mode 100644 index 000000000000..63c5231c0443 --- /dev/null +++ b/app/client/packages/mcp/.env.example @@ -0,0 +1,63 @@ +# The MCP server, its data layer, and restricted JS objects are all ON by default in shipped deployments +# (docker.env.sh sets each =true, and entrypoint.sh backfills them on upgrade). That default is intentional: the +# authenticated MCP surface is a first-class CE feature, and the data-layer security review it was gated behind is +# complete (it is this body of work). Each gate remains an Admin off-switch — set a var below to 0/false to disable +# that layer for an instance. This file is the template for running the package standalone in local development. +APPSMITH_MCP_ENABLED=1 +APPSMITH_MCP_PORT=8092 +APPSMITH_API_BASE_URL=http://127.0.0.1:8080 + +# Data layer (gate). Enables datasource discovery, structured SQL/REST action creation, and action reads. On by +# default; set to 0 to run the MCP server for spec authoring / reads only. +APPSMITH_MCP_DATA_ENABLED=1 + +# Restricted JS objects (gate). Enables the declarative, restricted JS-object tools. Requires the data layer and +# governance. On by default; set to 0 to disable. +APPSMITH_MCP_JS_ENABLED=1 + +# Session limits (all optional; invalid/unset values fall back to these defaults). At the per-user cap the +# server evicts that user's own least-recently-active session rather than rejecting the new one, so stale +# sessions from unclean disconnects never lock a user out. The instance-wide cap is a hard limit (503). +# APPSMITH_MCP_MAX_SESSIONS=100 +# APPSMITH_MCP_MAX_SESSIONS_PER_USER=25 +# APPSMITH_MCP_SESSION_TTL_MS=900000 + +# Optional absolute public origin (bare scheme + host[:port], no path/query/credentials) for the editorUrl/viewerUrl +# links build_application returns. Hardening: when set, agent-facing absolute URLs never derive from request headers, +# so a forged Host/X-Forwarded-Proto on direct port access can never plant a phishing-grade link in the agent channel. +# Unset: the origin is derived per session from validated forwarded headers, else URLs fall back to root-relative. +# APPSMITH_MCP_PUBLIC_ORIGIN=https://apps.example.com + +# Optional Host-header allowlist (comma-separated hostnames, port ignored) — a +# defense-in-depth DNS-rebinding check on /mcp, complementing the always-on +# Origin rejection. Leave EMPTY for a Caddy-fronted deployment: Caddy preserves +# the original (public) Host upstream, so a fixed loopback list would reject +# legitimate traffic. Set it only for a loopback-direct or host-pinned setup, +# to the exact hostname(s) the server receives (e.g. 127.0.0.1,localhost). +# APPSMITH_MCP_ALLOWED_HOSTS= + +# Optional deploy-stamped build time, echoed verbatim (with the package version) by /health and get_capabilities so +# operators can tell which build an instance is running. +# APPSMITH_MCP_BUILD_TIME= + +# REQUIRED to authenticate mcp_ tokens. The MCP service stamps this value as the X-Appsmith-Mcp-Internal marker on +# every /api/v1 call; the Java server's McpTokenAuthenticationConverter rejects any mcp_ bearer whose marker does not +# match APPSMITH_MCP_INTERNAL_SECRET (decision D2 — a raw mcp_ token sent straight to /api/v1 has no marker and falls +# through to anonymous). The server FAILS CLOSED when the secret is empty, so an mcp_ token cannot authenticate at all +# until BOTH this service AND the Java server are started with the SAME non-empty value. In production entrypoint.sh +# generates it automatically; for LOCAL dev you must set the same value here and in the Java server's environment, +# then restart both — otherwise tokens fail with "invalid bearer token". +# APPSMITH_MCP_INTERNAL_SECRET= + +# Governance (Mongo + Redis). REQUIRED for governed mutations, destructive +# operations (delete/publish), audit history, and rollback. When these are not +# set, the MCP server starts WITHOUT governed/destructive tools (read + spec +# authoring only) — it never falls back to an in-memory store. If a URL is set +# but unreachable, the server fails to start (loud, not silent degradation). +# - Mongo audit/rollback records live in the MCP-owned `mcp_changes` collection. +# - Redis holds MCP-namespaced locks (appsmith:mcp:lock:*) and one-time +# confirmation tokens (appsmith:mcp:confirm:*). +# APPSMITH_MONGODB_URI takes precedence over APPSMITH_DB_URL if both are set. +APPSMITH_MONGODB_URI= +# APPSMITH_DB_URL=mongodb://localhost:27017/appsmith +APPSMITH_REDIS_URL= diff --git a/app/client/packages/mcp/.eslintignore b/app/client/packages/mcp/.eslintignore new file mode 100644 index 000000000000..061101f0a7c8 --- /dev/null +++ b/app/client/packages/mcp/.eslintignore @@ -0,0 +1,2 @@ +build.js +dist/ diff --git a/app/client/packages/mcp/.eslintrc.cjs b/app/client/packages/mcp/.eslintrc.cjs new file mode 100644 index 000000000000..0e289210dc7d --- /dev/null +++ b/app/client/packages/mcp/.eslintrc.cjs @@ -0,0 +1,22 @@ +// This package is excluded from the client root tsconfig (it has its own tsc, like rts), so type-aware lint rules +// need the package tsconfig. tsconfigRootDir pins resolution to this directory regardless of eslint's cwd — the +// pre-commit hook lints from app/client while `yarn g:lint` lints from the package, and both must resolve the same +// project. (.cjs because the package is "type": "module" and eslint rc configs must be CommonJS.) +module.exports = { + extends: ["../../.eslintrc.base.json"], + parserOptions: { + tsconfigRootDir: __dirname, + project: "./tsconfig.json", + }, + ignorePatterns: ["dist"], + // Same relaxations as packages/rts and app/client's own .eslintrc.js: these strict rules are not enforced + // anywhere else in the repo, and this package predates the config. The testing-library naming rule also + // misfires here — these tests exercise an HTTP server, not a DOM render. + rules: { + "@typescript-eslint/prefer-nullish-coalescing": "off", + "@typescript-eslint/strict-boolean-expressions": "off", + "@typescript-eslint/no-explicit-any": "off", + "testing-library/no-debugging-utils": "off", + "testing-library/render-result-naming-convention": "off", + }, +}; diff --git a/app/client/packages/mcp/README.md b/app/client/packages/mcp/README.md new file mode 100644 index 000000000000..a48f000daccd --- /dev/null +++ b/app/client/packages/mcp/README.md @@ -0,0 +1,173 @@ +# Appsmith MCP server + +An embedded [Model Context Protocol](https://modelcontextprotocol.io) server that lets an MCP client (e.g. an LLM +agent) build and edit Appsmith applications through a safe, structured API. It runs on `127.0.0.1:8092`, is fronted by +Caddy at `/mcp`, and authenticates callers with per-user bearer tokens (`mcp_…`) issued from **Profile → MCP tokens**. + +Agents never author raw widget DSL, SQL, JS, or `{{ }}` bindings — they call tools and the server compiles them under +the caller's own permissions (the closed-vocabulary invariant). Every tool call stays bounded by the caller's ACL, and +mutating/destructive operations go through a prepare/confirm handshake. + +## Release notes + +### Authenticated MCP surface — on by default + +The MCP server, its **data layer** (datasource discovery + structured SQL/REST query creation + action reads), and +**restricted JS objects** are all **enabled by default** on new and upgraded deployments. The data-layer security +review these were previously gated behind is complete. The authenticated surface is reachable only with a valid, +user-scoped `mcp_…` token that the user explicitly creates. + +**How to disable.** Each layer stays an Admin off-switch (Admin Settings → Configuration, or the environment variables +below). Disabling `APPSMITH_MCP_ENABLED` is enforced server-side: the auth filter is evaluated per request, so once the +setting change is applied, already-issued `mcp_…` tokens are rejected (401) rather than merely losing the `/mcp` route. +(Applying the change restarts the backend process, which re-reads the configuration.) + +| Variable | Default | Effect when disabled | +| --------------------------- | ------- | ------------------------------------------------------------------------------- | +| `APPSMITH_MCP_ENABLED` | on | The `/mcp` route is dropped and `mcp_…` bearer tokens are rejected server-side. | +| `APPSMITH_MCP_DATA_ENABLED` | on | Datasource/query tools are unregistered; spec authoring + reads remain. | +| `APPSMITH_MCP_JS_ENABLED` | on | Restricted JS-object tools are unregistered. | + +Governed and destructive tools additionally require a MongoDB + Redis backend (`APPSMITH_MONGODB_URI` / +`APPSMITH_DB_URL` and `APPSMITH_REDIS_URL`); without them the server starts with read + spec-authoring tools only. + +### Session limits + +Sessions are bounded per instance and per user, with an idle TTL. When a user starts a session beyond their cap, the +server evicts that user's own least-recently-active session instead of rejecting the new one — so clients that +reconnect without cleanly closing (dropped SSE streams, proxy timeouts) are never locked out by their own stale +sessions. The instance-wide cap stays a hard limit (HTTP 503), since evicting across users would let one user +displace another's sessions. All three limits are tunable; invalid or unset values fall back to the defaults, and +values above the sanity ceilings (10000 sessions, 24 h TTL) are clamped — both with a startup warning. + +| Variable | Default | Effect | +| ------------------------------------ | --------------- | -------------------------------------------------------------------- | +| `APPSMITH_MCP_MAX_SESSIONS` | 100 | Instance-wide cap on concurrent MCP sessions (hard 503 when full). | +| `APPSMITH_MCP_MAX_SESSIONS_PER_USER` | 25 | Per-user cap; at the cap the user's oldest session is evicted. | +| `APPSMITH_MCP_SESSION_TTL_MS` | 900000 (15 min) | Idle session lifetime; every request on a session refreshes its TTL. | + +### Auto-publish on creation, and application URLs + +`build_application` **automatically publishes (deploys) the app it just created** and returns an `editorUrl` and +`viewerUrl` for the default page, so the agent can hand the user a working link immediately. + +- **New apps only.** Auto-publish applies solely to the app created by that same call — a brand-new app has no + existing deployed state to clobber. **Re-publishing** an existing app (including that app after later edits) keeps + the governed `prepare_publish` → `confirm_publish` flow (Mongo + Redis backend required), which also refuses to + publish git-connected apps. When governance is configured, the auto-publish itself is recorded as a change record; + on ungoverned deployments it emits a structured `appsmith_mcp_auto_publish` log event, so a deploy is never silent. +- **Publishing does not make the app public.** `isPublic` is a separate ACL switch; publishing only materializes the + viewer copy for users who already have access to the app. Admins should note that newly created (possibly + half-built) apps therefore become visible in view mode to workspace members with access — the agent is instructed + to finish wiring and re-publish before sharing the viewer link, since the auto-published copy is just a scaffold. +- **Publish failures never fail the create.** The app is still created; the result carries a + `warnings: ["created but not deployed: …"]` entry with a coarse failure class (never the raw error). + +The absolute origin for the returned URLs resolves in this order, failing closed to root-relative paths +(`/app//-/edit`): + +1. `APPSMITH_MCP_PUBLIC_ORIGIN`, when set to a valid bare http(s) origin (scheme + host + optional port; no path, + query, fragment, or credentials — an invalid value is dropped with a startup warning). +2. Otherwise, per session from the initialize request's `X-Forwarded-Proto` (must be exactly `http`/`https`) and + `Host` headers (charset-checked, and required to be in `APPSMITH_MCP_ALLOWED_HOSTS` when that allowlist is set). +3. Otherwise root-relative URLs — never a guessed absolute origin. If the import response lacks slugs, the URLs are + omitted and the ids are still returned. + +| Variable | Default | Effect | +| ---------------------------- | ------- | ---------------------------------------------------------------------------------------- | +| `APPSMITH_MCP_PUBLIC_ORIGIN` | unset | Preferred absolute origin for `editorUrl`/`viewerUrl` (e.g. `https://apps.example.com`). | + +### Git awareness (M7): status reads, a branch gate, agent branches, and governed commits + +**BREAKING for agents editing git-connected apps.** Every mutating tool that targets an application (layout edits, +events, theme/page changes, query/action/JS-object mutations) now takes a `branch` parameter. For a git-connected +app it is **required** and must equal the target app's current branch; a missing or mismatched value fails with +`git_branch_required` / `git_branch_changed` carrying the current branch, so recovery is a single retry. The gate +read **fails closed**: when the app's git state cannot be read, the mutation is rejected (`git_state_unknown`) +instead of proceeding. Previously, edits on git apps proceeded with only an advisory warning. Non-git apps are +unaffected — the parameter is optional and ignored. + +- **`read_git_status` (always on).** A whitelist projection of the app's git state: connection, current/default + branch, clean/dirty with modified-entity counts, protected branches, and the remote **host only** — never + `gitAuth`, keys, or the full remote URL. `compareRemote: true` (default **false**) opts into a remote fetch for + `aheadCount`/`behindCount`. +- **`create_branch` (governed).** Creates an agent branch under the **reserved `mcp/` namespace** (byte-exact + prefix; remainder `[A-Za-z0-9_-]{1,60}`; at most 5 `mcp/` branches per application). **Creating a branch PUSHES + the new ref to the customer's git remote under the instance deploy key** — remote CI/webhooks configured to run + on branch pushes will run on `mcp/*` refs; operators should account for that egress. Appsmith is + branch-per-application and has no checkout, so the result returns the **new branched `applicationId`**; all + subsequent edits for that branch must target it. A human branch named `mcp/*` falls inside the reserved agent + namespace — keep human branches out of it. Agents never delete branches; humans remove stale `mcp/` branches via + Appsmith's branch UI. +- **`prepare_commit` / `confirm_commit` (governed): commit implies push.** Appsmith's commit API **always pushes to + the customer's git remote after the local commit** — there is no commit-without-push and no separate push + endpoint. Because of that, MCP commits are allowed **only on `mcp/` agent branches**: both tools refuse unless a + fresh, fail-closed read shows the target app's branch is byte-exact `mcp/`-prefixed (`confirm_commit` re-reads at + confirm time; any other branch fails with `git_commit_branch_forbidden`). The commit message is one printable + line (max 200 characters; control and Unicode bidi/format characters rejected; must not start with `[`) and the + server prepends a non-strippable `[mcp] ` marker, so agent-authored commits are always identifiable in git + history. Author/committer identity always derives from the session user server-side — the MCP never sends author + fields — and amend is pinned off. A missing git author profile fails with an error telling the user to set it in + Appsmith. + + **Human approval and elicitation (all destructive operations).** `prepare_commit` returns a one-time confirmation + (5-minute TTL, bound to the app, branch, message, and current content revision — drift between prepare and + confirm fails) plus relay text the agent must show the user. On MCP clients that declare **elicitation** support, + `confirm_commit` prompts the human directly ("Commit ALL current changes on branch mcp/… and PUSH to the + remote?"); only an explicit accept proceeds, and decline/cancel/timeout leave the confirmation unconsumed + (bounded at 3 prompts per confirmation, after which it is invalidated). The same elicitation layer guards + **every** destructive confirm tool — `confirm_delete_page`, `confirm_delete_action`, `confirm_delete_js_object`, + `confirm_publish`, `confirm_rollback`, `confirm_run_action`, and `confirm_commit`: each prompt states the + operation's load-bearing facts (what is deleted/deployed/run, with any agent-controlled text quoted and + sanitized), a declined or unanswered prompt never consumes the one-time confirmation (the same token remains + usable after the user approves), and each `prepare_*` tool returns relay text for the fallback posture. + Prompting is also bounded **per session**: at most 20 approval prompts in total (across all confirmations) — + beyond that budget, elicitation-capable confirms refuse with `elicitation_budget_exhausted` instead of prompting + (closing the re-prepare prompt-fatigue loop; a new session resets the budget). + **Honest fallback:** without an elicitation-capable client the confirmation + prompt depends on the agent relaying it — the one-time token and relay text are the fallback posture (the + **relay posture**), and no server rule (mcp/-only, TTL, one-time token, content binding) relaxes either way. + Client support for elicitation varies (e.g. VS Code and some desktop MCP clients support it; many others do not + yet) — operators should assume the fallback posture unless they know their client. Note for operators: an + approved commit pushes to your remote under the instance deploy key, so remote CI/webhooks watching branch + pushes will run on `mcp/*` commits. + + **Non-accept reasons (tool-result contract).** When a prompt does not end in approval, the confirm tool's + result carries a `reason` field naming what actually happened, alongside the stable `code` and + `attemptsRemaining`: `declined` (the user said no — an accept whose optional boolean came back `false` also + counts as declined), `cancelled` (the user dismissed the prompt), `timeout` (no answer within the wait + window), `accepted_without_confirm` (the client accepted but returned a malformed confirm value — never + approval), and `client_error` (the client failed to deliver or answer the prompt — the human never saw it; a + sanitized, untrusted-marked `detail` string carries the client's error text). An explicit accept counts as + approval even when the client's UI returns no boolean at all — bare accept/decline button UIs are common. + + **Broken-client degradation.** A `client_error` outcome does not charge the per-confirmation or per-session + prompt budgets (the human never took part), and it switches the rest of the session to the relay posture: the + refusal instructs the agent to show the user the `prepare_*` relay text, obtain explicit approval, and call + the confirm tool again, which then proceeds without another prompt — a client that declares elicitation but + cannot render it no longer dead-ends every destructive operation. Each prompt outcome is also logged to + stderr with the client name/version from the MCP initialize handshake + (`Appsmith MCP elicitation tool=… outcome=… client=…`), so broken client fleets are identifiable from server + logs. + + **Operator knobs.** + + | Env var | Default | Effect | + | ------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `APPSMITH_MCP_DISABLE_ELICITATION` | off | Forces the relay posture for every session; no elicitation prompts are sent. | + | `APPSMITH_MCP_STRICT_ELICITATION` | off | Requires in-band prompts: non-elicitation clients are refused (`elicitation_required`) and a `client_error` never degrades to the relay posture. Wins over the disable knob. | + | `APPSMITH_MCP_ELICITATION_TIMEOUT_MS` | 120000 | How long a confirm waits for the human's answer before a `timeout` outcome (clamped at 10 minutes). | + + Clients that do not reset their tool-call timeout on MCP progress notifications may abort the confirm call + before the default 120s elicitation wait ends (the server sends keep-alive progress pings only when the caller + supplied a `progressToken`). If your client times out mid-prompt, lower + `APPSMITH_MCP_ELICITATION_TIMEOUT_MS` below the client's tool-call timeout or set + `APPSMITH_MCP_DISABLE_ELICITATION` and rely on the relay posture. + + Publishing from MCP remains disabled for git apps: the deliverable is the `mcp/` branch and its branch-scoped + review URL — the user reviews on the branch, merges via Appsmith's branch UI or a pull request on the remote, + then deletes the `mcp/` branch. + +## Local development + +Copy `.env.example` to `.env` and run the package standalone. See that file for the full set of variables. diff --git a/app/client/packages/mcp/build.js b/app/client/packages/mcp/build.js new file mode 100644 index 000000000000..15adab26a619 --- /dev/null +++ b/app/client/packages/mcp/build.js @@ -0,0 +1,18 @@ +import esbuild from "esbuild"; + +await esbuild.build({ + entryPoints: ["src/server.ts"], + bundle: true, + format: "esm", + minify: true, + platform: "node", + sourcemap: true, + target: `node${process.versions.node.split(".")[0]}`, + // ESM output has no `require`, but bundled CommonJS deps (e.g. the mongodb driver) dynamically + // require Node built-ins like "timers/promises". Recreate `require` from import.meta.url so those + // resolve at runtime instead of throwing "Dynamic require of X is not supported". + banner: { + js: "import { createRequire as __createRequire } from 'module'; const require = __createRequire(import.meta.url);", + }, + outdir: "dist/bundle", +}); diff --git a/app/client/packages/mcp/build.sh b/app/client/packages/mcp/build.sh new file mode 100755 index 000000000000..5f1e2762633d --- /dev/null +++ b/app/client/packages/mcp/build.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -o errexit + +cd "$(dirname "$0")" + +yarn install --immutable +yarn run check-types + +rm -rf dist +node build.js diff --git a/app/client/packages/mcp/jest.config.cjs b/app/client/packages/mcp/jest.config.cjs new file mode 100644 index 000000000000..ad7d685ec664 --- /dev/null +++ b/app/client/packages/mcp/jest.config.cjs @@ -0,0 +1,12 @@ +module.exports = { + roots: ["/src"], + transform: { + "^.+\\.ts$": ["ts-jest", { useESM: true }], + }, + extensionsToTreatAsEsm: [".ts"], + testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.ts$", + moduleFileExtensions: ["ts", "js", "json", "node"], + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.js$": "$1", + }, +}; diff --git a/app/client/packages/mcp/live-harness.mjs b/app/client/packages/mcp/live-harness.mjs new file mode 100644 index 000000000000..ec436f7bf029 --- /dev/null +++ b/app/client/packages/mcp/live-harness.mjs @@ -0,0 +1,260 @@ +// Live authenticated test harness against the local MCP server (http://127.0.0.1:8092/mcp). +// Run from packages/mcp: MCP_TOKEN=mcp_xxx node live-harness.mjs +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +const TOKEN = process.env.MCP_TOKEN; +if (!TOKEN) { + console.error("Set MCP_TOKEN=mcp_..."); + process.exit(2); +} + +const results = []; +const rec = (name, ok, detail = "") => { + results.push({ name, ok }); + console.log( + `${ok ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail : ""}`, + ); +}; + +const transport = new StreamableHTTPClientTransport( + new URL("http://127.0.0.1:8092/mcp"), + { requestInit: { headers: { Authorization: `Bearer ${TOKEN}` } } }, +); +const client = new Client( + { name: "live-harness", version: "1.0.0" }, + { capabilities: {} }, +); +await client.connect(transport); + +const textOf = (r) => r?.content?.[0]?.text ?? ""; +// Call a tool and return its parsed JSON result (or {} if not JSON). +async function J(name, args) { + const r = await client.callTool({ name, arguments: args }); + try { + return JSON.parse(textOf(r)); + } catch { + return { _raw: textOf(r) }; + } +} +const has = (obj, re) => re.test(JSON.stringify(obj)); + +try { + // 1. tool discovery + const tools = (await client.listTools()).tools.map((t) => t.name); + const want = [ + "get_guide", + "read_git_status", + "create_branch", + "prepare_commit", + "confirm_commit", + "build_application", + "patch_widgets", + "wire_event", + "inspect_page", + "get_capabilities", + ]; + const missing = want.filter((w) => !tools.includes(w)); + rec( + `tool discovery (${tools.length} tools registered)`, + missing.length === 0, + missing.length ? "MISSING: " + missing : "all present", + ); + + // 2. capabilities — build stamp + patchSpec.tableData vocabulary + const caps = await J("get_capabilities", {}); + rec( + "capabilities.build.version present", + !!caps.build?.version, + JSON.stringify(caps.build || {}), + ); + rec( + "capabilities.patchSpec.tableData documents query/store", + /query|store/.test(caps.patchSpec?.updateProps?.tableData || ""), + (caps.patchSpec?.updateProps?.tableData || "").slice(0, 55), + ); + + // 3. get_guide — tools-only-client feature + const guide = await J("get_guide", { slug: "zip-lookup" }); + rec( + "get_guide zip-lookup renders markdown", + (guide.markdown || guide._raw || "").includes("appendToStore"), + ); + const badGuide = await J("get_guide", { slug: "no-such-guide" }); + rec( + "get_guide unknown slug -> error + available list", + Array.isArray(badGuide.available), + (badGuide.error || "").slice(0, 50), + ); + + // 4. workspace discovery + const ws = await J("list_workspaces", {}); + const workspaceId = ws.workspaces?.[0]?.id; + rec( + "list_workspaces returns >=1", + !!workspaceId, + (ws.workspaces || []) + .map((w) => w.name) + .join(", ") + .slice(0, 60), + ); + + // 5a. CONTROL: a clean, well-formed spec must VALIDATE (proves the arg shape is right, so an injection + // rejection below is about the binding, not the envelope). + const clean = await J("validate_app_spec", { + app: { + name: "clean", + pages: [{ name: "P", widgets: [{ type: "text", text: "hello world" }] }], + }, + }); + const cleanOk = !/-32602|error|invalid|reject/i.test(JSON.stringify(clean)); + rec( + "CONTROL: clean spec validates", + cleanOk, + JSON.stringify(clean).slice(0, 70), + ); + + // 5b. ADVERSARIAL closed-vocabulary: same well-formed envelope, but a raw {{ }} / backtick in a FIELD must be + // rejected by the compiler (a structured validation rejection, NOT the -32602 shape error). + const inj1 = await J("validate_app_spec", { + app: { + name: "i1", + pages: [ + { name: "P", widgets: [{ type: "text", text: "{{ alert(1) }}" }] }, + ], + }, + }); + rec( + "REJECT raw {{ }} in text field", + !cleanOk + ? false + : has(inj1, /binding|template|reject|invalid|must not|not allowed/i) && + !has(inj1, /-32602/), + JSON.stringify(inj1).slice(0, 90), + ); + const inj2 = await J("validate_app_spec", { + app: { + name: "i2", + pages: [ + { + name: "P", + widgets: [{ type: "button", text: "x", onClick: "{{ evil() }}" }], + }, + ], + }, + }); + rec( + "REJECT raw binding in onClick", + has(inj2, /binding|template|reject|invalid|expect|must/i) && + !has(inj2, /-32602/), + JSON.stringify(inj2).slice(0, 90), + ); + const inj3 = await J("validate_app_spec", { + app: { + name: "i3", + pages: [ + { name: "P", widgets: [{ type: "text", text: "safe", name: "a`b" }] }, + ], + }, + }); + rec( + "REJECT backtick in a widget name", + has(inj3, /name|reject|invalid|expect|must|match|template/i) && + !has(inj3, /-32602/), + JSON.stringify(inj3).slice(0, 90), + ); + + // 6. build the ZIP-lookup skeleton end-to-end + if (workspaceId) { + const built = await J("build_application", { + workspaceId, + app: { + name: `LiveTest ${Date.now()}`, + pages: [ + { + name: "Lookup", + widgets: [ + { + type: "input", + name: "ZipInput", + label: "ZIP", + validation: { format: "zipcode" }, + }, + { type: "button", name: "LookupBtn", text: "Look up" }, + { type: "button", name: "ClearBtn", text: "Clear" }, + { + type: "table", + name: "ZipResults", + data: [{ zip: "", city: "" }], + }, + ], + }, + ], + }, + }); + const appId = built.applicationId || built.application?.id; + rec( + "build_application creates app", + !!appId, + "warnings=" + JSON.stringify(built.warnings || null), + ); + rec( + "build_application returns URL / auto-publish", + !!(built.editorUrl || built.viewerUrl || built.warnings), + (built.editorUrl || "").slice(0, 70), + ); + + const pageId = built.pages?.[0]?.id; + const layoutId = built.pages?.[0]?.layoutId; + if (appId && pageId && layoutId) { + const page = await J("read_semantic_page", { + applicationId: appId, + pageId, + layoutId, + }); + const revision = page.revision; + rec("read_semantic_page returns revision", !!revision); + + if (revision) { + // overlap repair: move ClearBtn onto another widget's cells + const moved = await J("patch_widgets", { + applicationId: appId, + pageId, + layoutId, + revision, + patch: { + operations: [ + { + kind: "move", + name: "ClearBtn", + position: { topRow: 0, leftColumn: 0 }, + }, + ], + }, + }); + rec( + "patch_widgets move handles overlap (no crash)", + !!(moved.changes || moved.code), + moved.code || "applied", + ); + } + + const gs = await J("read_git_status", { applicationId: appId }); + // The projection nests state under `git` (git.connected / git.branchName), never at the top level. + rec( + "read_git_status returns a boolean git.connected", + typeof gs.git?.connected === "boolean", + "git.connected=" + gs.git?.connected, + ); + rec( + "read_git_status leaks no keys/auth", + !/privateKey|gitAuth|ssh-rsa|BEGIN .* PRIVATE/.test(JSON.stringify(gs)), + ); + } + } + + const passed = results.filter((r) => r.ok).length; + console.log(`\n==== ${passed}/${results.length} passed ====`); +} finally { + await client.close(); +} diff --git a/app/client/packages/mcp/package.json b/app/client/packages/mcp/package.json new file mode 100644 index 000000000000..92863c25331c --- /dev/null +++ b/app/client/packages/mcp/package.json @@ -0,0 +1,30 @@ +{ + "name": "appsmith-mcp", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "build": "./build.sh", + "check-types": "tsc --noEmit", + "lint": "yarn g:lint", + "prettier": "yarn g:prettier", + "start": "./start-server.sh", + "test:unit": "jest -b --colors --no-cache --silent" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "mongodb": "^7.5.0", + "redis": "^6.1.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@types/jest": "^29.5.3", + "@types/node": "*", + "@types/supertest": "^6.0.2", + "esbuild": "^0.25.0", + "jest": "^29.6.1", + "supertest": "^6.3.3", + "ts-jest": "^29.1.0", + "typescript": "^5.5.4" + } +} diff --git a/app/client/packages/mcp/src/app.test.ts b/app/client/packages/mcp/src/app.test.ts new file mode 100644 index 000000000000..e2a1c7aedd52 --- /dev/null +++ b/app/client/packages/mcp/src/app.test.ts @@ -0,0 +1,6571 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import supertest from "supertest"; +import { + MAX_ARTIFACT_BYTES, + MCP_BUILD_INFO, + READ_PAGES_LAYOUT_CONCURRENCY, + createAppsmithApi, + createMcpHttpServer, + hostHeaderName, + looksLikeWriteAction, + sessionOriginFromHeaders, + writeActionNames, + type AppsmithApi, +} from "./app.js"; +import { INSTRUCTION_DOCS } from "./builder/instructions.js"; +import { fingerprintDsl } from "./builder/semantic.js"; +import { McpGovernanceCoordinator } from "./governance/coordinator.js"; +import type { + McpChangeRecord, + McpGovernanceStore, + PreparedConfirmation, +} from "./governance/store.js"; + +const API_BASE_URL = "https://appsmith.example"; + +function successfulFetch() { + return jest.fn, [RequestInfo | URL, RequestInit?]>( + async () => { + return new Response(JSON.stringify({ data: { ok: true } }), { + status: 200, + }); + }, + ); +} + +describe("Appsmith API client", () => { + it("forwards bearer and correlation headers for read and artifact import requests", async () => { + const fetchFn = successfulFetch(); + const api = createAppsmithApi( + "user-token", + API_BASE_URL, + fetchFn as unknown as typeof fetch, + { + "X-Appsmith-Request-Id": "internal-request-id", + "X-Request-Id": "client-request-id", + }, + ); + + await api.listWorkspaces(); + await api.listApplications("workspace-1"); + await api.getApplicationContext("application-1", "page-1", "layout-1"); + await api.importApplicationArtifact("workspace-1", { + application: { name: "New application" }, + }); + await api.importPartialApplicationArtifact( + "workspace-1", + "application-1", + "page-1", + { widgets: {} }, + ); + + expect(fetchFn.mock.calls.map(([url]) => String(url))).toEqual([ + `${API_BASE_URL}/api/v1/workspaces/home`, + `${API_BASE_URL}/api/v1/applications/home?workspaceId=workspace-1`, + `${API_BASE_URL}/api/v1/pages?applicationId=application-1`, + `${API_BASE_URL}/api/v1/pages/page-1`, + `${API_BASE_URL}/api/v1/layouts/layout-1/pages/page-1`, + `${API_BASE_URL}/api/v1/applications/import/workspace-1`, + `${API_BASE_URL}/api/v1/applications/import/partial/workspace-1/application-1?pageId=page-1`, + ]); + expect( + fetchFn.mock.calls.every( + ([, init]) => + new Headers(init?.headers).get("Authorization") === + "Bearer user-token", + ), + ).toBe(true); + // Every upstream call carries the CSRF-exemption header so multipart (import) POSTs aren't denied. + expect( + fetchFn.mock.calls.every( + ([, init]) => + new Headers(init?.headers).get("X-Requested-By") === "Appsmith", + ), + ).toBe(true); + expect( + fetchFn.mock.calls.every( + ([, init]) => + new Headers(init?.headers).get("X-Appsmith-Request-Id") === + "internal-request-id" && + new Headers(init?.headers).get("X-Request-Id") === + "client-request-id", + ), + ).toBe(true); + + for (const callIndex of [5, 6]) { + const init = fetchFn.mock.calls[callIndex][1]; + const formData = init?.body as FormData; + const file = formData.get("file") as File; + + expect(init?.method).toBe("POST"); + expect(new Headers(init?.headers).get("Content-Type")).toBeNull(); + expect(file.name).toBe("appsmith-artifact.json"); + expect(file.type).toBe("application/json"); + } + + const fullArtifact = (fetchFn.mock.calls[5][1]?.body as FormData).get( + "file", + ) as File; + const partialArtifact = (fetchFn.mock.calls[6][1]?.body as FormData).get( + "file", + ) as File; + + await expect(fullArtifact.text()).resolves.toBe( + JSON.stringify({ application: { name: "New application" } }), + ); + await expect(partialArtifact.text()).resolves.toBe( + JSON.stringify({ widgets: {} }), + ); + }); + + it("stamps the internal marker header when APPSMITH_MCP_INTERNAL_SECRET is set", async () => { + const previous = process.env.APPSMITH_MCP_INTERNAL_SECRET; + + process.env.APPSMITH_MCP_INTERNAL_SECRET = "internal-marker-secret"; + try { + const fetchFn = successfulFetch(); + const api = createAppsmithApi( + "user-token", + API_BASE_URL, + fetchFn as unknown as typeof fetch, + ); + + await api.listWorkspaces(); + + expect( + new Headers(fetchFn.mock.calls[0][1]?.headers).get( + "X-Appsmith-Mcp-Internal", + ), + ).toBe("internal-marker-secret"); + } finally { + if (previous === undefined) { + delete process.env.APPSMITH_MCP_INTERNAL_SECRET; + } else { + process.env.APPSMITH_MCP_INTERNAL_SECRET = previous; + } + } + }); + + it("omits the internal marker header when APPSMITH_MCP_INTERNAL_SECRET is unset", async () => { + const previous = process.env.APPSMITH_MCP_INTERNAL_SECRET; + + delete process.env.APPSMITH_MCP_INTERNAL_SECRET; + try { + const fetchFn = successfulFetch(); + const api = createAppsmithApi( + "user-token", + API_BASE_URL, + fetchFn as unknown as typeof fetch, + ); + + await api.listWorkspaces(); + + expect( + new Headers(fetchFn.mock.calls[0][1]?.headers).has( + "X-Appsmith-Mcp-Internal", + ), + ).toBe(false); + } finally { + if (previous !== undefined) { + process.env.APPSMITH_MCP_INTERNAL_SECRET = previous; + } + } + }); + + it("sends exactly the env marker value on a body-carrying request (never a caller-influenced value)", async () => { + // Constraint 3: the marker is sourced ONLY from this process's env and is spread LAST in the header object, so no + // request body/shape or internal call site can override or inject it. Exercise the multipart/body path (createAction). + const previous = process.env.APPSMITH_MCP_INTERNAL_SECRET; + + process.env.APPSMITH_MCP_INTERNAL_SECRET = "env-only-secret"; + try { + const fetchFn = successfulFetch(); + const api = createAppsmithApi( + "user-token", + API_BASE_URL, + fetchFn as unknown as typeof fetch, + ); + + await api.createAction({ name: "q1", pageId: "p1" }); + + expect( + new Headers(fetchFn.mock.calls[0][1]?.headers).get( + "X-Appsmith-Mcp-Internal", + ), + ).toBe("env-only-secret"); + } finally { + if (previous === undefined) { + delete process.env.APPSMITH_MCP_INTERNAL_SECRET; + } else { + process.env.APPSMITH_MCP_INTERNAL_SECRET = previous; + } + } + }); + + it("getAction fetches by applicationId and selects the action by id (no GET /actions/{id})", async () => { + const fetchFn = jest.fn< + Promise, + [RequestInfo | URL, RequestInit?] + >(async () => + Response.json({ + responseMeta: { success: true }, + data: [ + { id: "other", name: "Other" }, + { id: "act1", name: "Target" }, + ], + }), + ); + const api = createAppsmithApi( + "user-token", + API_BASE_URL, + fetchFn as unknown as typeof fetch, + ); + + const action = (await api.getAction("app1", "act1")) as { name: string }; + + // It lists the application's actions (the single-action GET does not exist) and filters by id. + expect(String(fetchFn.mock.calls[0][0])).toBe( + `${API_BASE_URL}/api/v1/actions?applicationId=app1`, + ); + expect(action.name).toBe("Target"); + }); + + it("getAction throws a clear error when the action is not in the application", async () => { + const fetchFn = jest.fn< + Promise, + [RequestInfo | URL, RequestInit?] + >(async () => + Response.json({ + responseMeta: { success: true }, + data: [{ id: "other" }], + }), + ); + const api = createAppsmithApi( + "user-token", + API_BASE_URL, + fetchFn as unknown as typeof fetch, + ); + + await expect(api.getAction("app1", "missing")).rejects.toThrow( + /action missing was not found in application app1/, + ); + }); + + it("surfaces Appsmith API errors without obscuring their status", async () => { + const fetchFn = jest.fn< + Promise, + [RequestInfo | URL, RequestInit?] + >(async () => { + return new Response("forbidden", { status: 403 }); + }); + const api = createAppsmithApi( + "user-token", + API_BASE_URL, + fetchFn as unknown as typeof fetch, + ); + + await expect(api.listWorkspaces()).rejects.toThrow( + "Appsmith API request failed (403)", + ); + }); + + it("rejects oversized artifacts before issuing an API request", async () => { + const fetchFn = successfulFetch(); + const api = createAppsmithApi( + "user-token", + API_BASE_URL, + fetchFn as unknown as typeof fetch, + ); + + await expect( + api.importApplicationArtifact("workspace-1", { + content: "x".repeat(MAX_ARTIFACT_BYTES), + }), + ).rejects.toThrow("Artifact must not exceed"); + await expect( + api.importApplicationArtifact( + "workspace-1", + [] as unknown as Record, + ), + ).rejects.toThrow(); + await expect( + api.importApplicationArtifact("workspace-1", {}), + ).rejects.toThrow("Artifact must not be empty"); + await expect( + api.importApplicationArtifact("workspace-1", { + invalid: new Date(), + }), + ).rejects.toThrow("Artifact must contain only plain JSON objects"); + expect(fetchFn).not.toHaveBeenCalled(); + }); +}); + +function createApi( + // Typed as the real wrapper (() => Promise) so a test can pass a custom profile mock without every + // field — the default carries a realistic organizationId so governed audit reads are tenant-scoped. + validateToken: AppsmithApi["validateToken"] = jest.fn(async () => ({ + username: "user@appsmith.com", + isAnonymous: false, + organizationId: "org-default", + })), +) { + return (): AppsmithApi => ({ + getApplicationContext: jest.fn(), + importApplicationArtifact: jest.fn(), + importPartialApplicationArtifact: jest.fn(), + listApplications: jest.fn(), + listWorkspaces: jest.fn(), + updateLayout: jest.fn(), + listDatasources: jest.fn(), + createDatasource: jest.fn(), + getDatasourceStructure: jest.fn(), + triggerDatasource: jest.fn(), + getApplicationPages: jest.fn(async () => ({ workspaceId: "ws1" })), + // Default: no layouts, so F3 layoutId surfacing degrades to undefined unless a test overrides it. + getPage: jest.fn(async () => ({})), + // Default: a non-git application (no gitApplicationMetadata) so layout-edit tests see no git warning and the + // M7 branch gate passes without a branch parameter. + getApplication: jest.fn(async () => ({})), + getGitStatus: jest.fn(async () => ({})), + getGitProtectedBranches: jest.fn(async () => []), + listGitBranches: jest.fn(async () => []), + createGitBranch: jest.fn(async () => ({})), + commitGitApplication: jest.fn(async () => ({})), + listActions: jest.fn(), + createAction: jest.fn(), + getAction: jest.fn(), + updateAction: jest.fn(), + deleteAction: jest.fn(), + executeAction: jest.fn(), + getCurrentTheme: jest.fn(), + updateTheme: jest.fn(), + createPage: jest.fn(), + updatePage: jest.fn(), + deletePage: jest.fn(), + publishApplication: jest.fn(), + listPlugins: jest.fn(), + listActionCollections: jest.fn(), + createActionCollection: jest.fn(), + updateActionCollection: jest.fn(), + deleteActionCollection: jest.fn(), + validateToken, + }); +} + +// The Streamable HTTP transport (MCP SDK 1.29+) returns request responses as an +// SSE stream when the client accepts text/event-stream, so the JSON-RPC payload +// arrives on a `data:` line rather than as a parsed JSON body. Handle both shapes. +function parseJsonRpc(response: { body?: unknown; text?: string }): { + result: { tools: { name: string }[]; content: { text: string }[] }; +} { + const body = response.body as { result?: unknown } | undefined; + + if (body && body.result !== undefined) { + return body as { + result: { tools: { name: string }[]; content: { text: string }[] }; + }; + } + + const dataLine = (response.text ?? "") + .split("\n") + .find((line) => line.startsWith("data:")); + + if (!dataLine) { + throw new Error(`No JSON-RPC payload in response: ${response.text}`); + } + + return JSON.parse(dataLine.slice("data:".length).trim()); +} + +const initializeRequest = { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }, +}; + +describe("MCP HTTP server", () => { + it("rejects MCP requests without a bearer token", async () => { + const response = await supertest(createMcpHttpServer(API_BASE_URL)) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .send({}); + + expect(response.status).toBe(401); + expect(response.headers["www-authenticate"]).toBe("Bearer"); + }); + + it("/health carries the build identity (package.json version), unauthenticated", async () => { + const response = await supertest(createMcpHttpServer(API_BASE_URL)).get( + "/health", + ); + + expect(response.status).toBe(200); + expect(response.body.status).toBe("ok"); + // Read once at startup from package.json — the answer to "which build is this instance running". + expect(response.body.version).toBe(MCP_BUILD_INFO.version); + expect(response.body.version).toBe( + // Independent read so the assertion is not a tautology against the same constant. + JSON.parse(readFileSync(resolve(__dirname, "../package.json"), "utf8")) + .version, + ); + }); + + it("returns a safe response for malformed JSON and oversized requests", async () => { + const server = createMcpHttpServer(API_BASE_URL, createApi()); + + const malformed = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .set("Content-Type", "application/json") + .send("{not-json"); + const oversized = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .set("Content-Type", "application/json") + .send(`{"content":"${"x".repeat(2 * 1024 * 1024)}"}`); + + expect(malformed).toMatchObject({ + status: 400, + body: { error: "invalid JSON request body" }, + }); + expect(oversized).toMatchObject({ + status: 413, + body: { error: "request body too large" }, + }); + }); + + // M4-T4 adoption telemetry: the structured request event must carry the coarse success/error CLASS and the + // gate state, while still never leaking the bearer token, tool arguments, or request bodies. + it("emits a request telemetry event with statusClass + gates and no token/args", async () => { + const events: Record[] = []; + const stderrSpy = jest + .spyOn(process.stderr, "write") + .mockImplementation((chunk: string | Uint8Array) => { + const line = typeof chunk === "string" ? chunk : chunk.toString(); + + try { + events.push(JSON.parse(line)); + } catch { + // Non-JSON writes are irrelevant to this assertion. + } + + return true; + }); + + try { + const server = createMcpHttpServer(API_BASE_URL, createApi(), { + dataEnabled: true, + jsEnabled: true, + }); + + // A malformed body deterministically yields a 4xx without needing a full session; the telemetry event + // is still emitted on response finish. + await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-secret-token") + .set("Content-Type", "application/json") + .send("{not-json"); + + const requestEvent = events.find( + (event) => event.event === "appsmith_mcp_request", + ); + + expect(requestEvent).toBeDefined(); + expect(requestEvent).toMatchObject({ + statusClass: "client_error", + gates: "data,js", + }); + + // Proof no secret/args/body leak: the serialized event must not contain the token secret or request body, + // and must expose no argument/body-bearing fields. + const serialized = JSON.stringify(requestEvent); + + expect(serialized).not.toContain("user-secret-token"); + expect(serialized).not.toContain("not-json"); + expect(requestEvent).not.toHaveProperty("arguments"); + expect(requestEvent).not.toHaveProperty("args"); + expect(requestEvent).not.toHaveProperty("body"); + expect(requestEvent).not.toHaveProperty("params"); + // The caller's identity is only ever recorded as a hash, never in plaintext. + expect(requestEvent).not.toHaveProperty("username"); + } finally { + stderrSpy.mockRestore(); + } + }); + + // The telemetry event must correlate per user WITHOUT logging the user's email in plaintext: the username is + // recorded only as a stable SHA-256 hash (matching the server-side DigestUtils.sha256Hex posture). + it("records the caller's username as a SHA-256 hash, never in plaintext", async () => { + const email = "user@appsmith.com"; + const events: Record[] = []; + const stderrSpy = jest + .spyOn(process.stderr, "write") + .mockImplementation((chunk: string | Uint8Array) => { + const line = typeof chunk === "string" ? chunk : chunk.toString(); + + try { + events.push(JSON.parse(line)); + } catch { + // Non-JSON writes are irrelevant to this assertion. + } + + return true; + }); + + try { + const validateToken = jest.fn(async () => ({ + username: email, + isAnonymous: false, + })); + const server = createMcpHttpServer( + API_BASE_URL, + createApi(validateToken), + ); + + await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .send(initializeRequest); + + const requestEvent = events.find( + (event) => + event.event === "appsmith_mcp_request" && + event.usernameHash !== undefined, + ); + + expect(requestEvent).toBeDefined(); + expect(requestEvent?.usernameHash).toBe( + createHash("sha256").update(email).digest("hex"), + ); + // The plaintext email must never appear anywhere in the serialized event. + expect(JSON.stringify(requestEvent)).not.toContain(email); + } finally { + stderrSpy.mockRestore(); + } + }); + + it("binds sessions to the bearer token and revalidates reuse", async () => { + const validateToken = jest.fn(async () => ({ + username: "user@appsmith.com", + isAnonymous: false, + })); + const server = createMcpHttpServer(API_BASE_URL, createApi(validateToken)); + + const initialized = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .send(initializeRequest); + const sessionId = initialized.headers["mcp-session-id"] as string; + + expect(initialized.status).toBe(200); + expect(sessionId).toBeTruthy(); + expect(validateToken).toHaveBeenCalledTimes(1); + + const mismatchedToken = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_other-token") + .set("mcp-session-id", sessionId) + .send({ jsonrpc: "2.0", method: "notifications/initialized" }); + + expect(mismatchedToken).toMatchObject({ + status: 401, + body: { error: "invalid MCP session" }, + }); + expect(validateToken).toHaveBeenCalledTimes(1); + }); + + it("exposes only bounded artifact write tools", async () => { + const server = createMcpHttpServer(API_BASE_URL, createApi()); + const initialized = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .send(initializeRequest); + const sessionId = initialized.headers["mcp-session-id"] as string; + + await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .set("mcp-session-id", sessionId) + .send({ jsonrpc: "2.0", method: "notifications/initialized" }); + const tools = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .set("mcp-session-id", sessionId) + .send({ jsonrpc: "2.0", id: 2, method: "tools/list" }); + const payload = parseJsonRpc(tools); + const names = payload.result.tools.map( + (tool: { name: string }) => tool.name, + ); + + expect(names).toEqual( + expect.arrayContaining([ + "list_workspaces", + "list_applications", + "get_application_context", + "get_capabilities", + "list_presets", + "get_preset", + "validate_app_spec", + "build_application", + "edit_page", + ]), + ); + + // The raw artifact-import tools and the removed raw-DSL tools must not be exposed. + // Check each individually: not.arrayContaining passes if even one is absent, so it + // would miss a case where some (but not all) excluded tools leaked back in. + for (const excluded of [ + "create_application", + "update_layout", + "import_application_artifact", + "import_partial_application_artifact", + ]) { + expect(names).not.toContain(excluded); + } + }); + + it("build_application compiles a page spec and imports the artifact", async () => { + const importApplicationArtifact = jest.fn< + Promise<{ id: string }>, + [string, Record] + >(async () => ({ id: "app-1" })); + const api: AppsmithApi = { + ...createApi()(), + importApplicationArtifact, + }; + const server = createMcpHttpServer(API_BASE_URL, () => api); + const initialized = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .send(initializeRequest); + const sessionId = initialized.headers["mcp-session-id"] as string; + + await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .set("mcp-session-id", sessionId) + .send({ jsonrpc: "2.0", method: "notifications/initialized" }); + const call = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .set("mcp-session-id", sessionId) + .send({ + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { + name: "build_application", + arguments: { + workspaceId: "ws1", + app: { + name: "Demo", + pages: [ + { name: "Home", widgets: [{ type: "text", text: "Hi" }] }, + ], + }, + }, + }, + }); + + expect(importApplicationArtifact).toHaveBeenCalledTimes(1); + const [workspaceId, artifact] = importApplicationArtifact.mock.calls[0]; + + expect(workspaceId).toBe("ws1"); + expect(artifact.clientSchemaVersion).toBe(2); + expect(artifact.actionList).toEqual([]); + expect(artifact.pageList).toHaveLength(1); + + // Structural diagnostics are returned inline so the agent gets feedback without a second call. + const body = JSON.parse(parseJsonRpc(call).result.content[0].text); + + expect(body.diagnostics).toEqual({ + errors: 0, + warnings: 0, + pages: expect.any(Object), + }); + }); + + it("inspect_page lints a live page read-back and reports structural issues", async () => { + // A page whose two widgets overlap on the same canvas — the lint must surface it. + const overlappingDsl = { + widgetId: "0", + widgetName: "MainContainer", + type: "CANVAS_WIDGET", + topRow: 0, + bottomRow: 380, + leftColumn: 0, + rightColumn: 640, + children: [ + { + widgetId: "a", + widgetName: "A", + type: "INPUT_WIDGET_V2", + topRow: 0, + bottomRow: 10, + leftColumn: 0, + rightColumn: 30, + }, + { + widgetId: "b", + widgetName: "B", + type: "INPUT_WIDGET_V2", + topRow: 5, + bottomRow: 15, + leftColumn: 10, + rightColumn: 40, + }, + ], + }; + const getApplicationContext = jest.fn(async () => ({ + pages: [], + page: {}, + layout: { dsl: overlappingDsl }, + })); + const updateLayout = jest.fn(); + const api: AppsmithApi = { + ...createApi()(), + getApplicationContext, + updateLayout, + }; + const server = createMcpHttpServer(API_BASE_URL, () => api); + const initialized = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .send(initializeRequest); + const sessionId = initialized.headers["mcp-session-id"] as string; + + await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .set("mcp-session-id", sessionId) + .send({ jsonrpc: "2.0", method: "notifications/initialized" }); + const call = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .set("mcp-session-id", sessionId) + .send({ + jsonrpc: "2.0", + id: 5, + method: "tools/call", + params: { + name: "inspect_page", + arguments: { applicationId: "app1", pageId: "p1", layoutId: "l1" }, + }, + }); + + // Read-only: inspecting must never write the layout back. + expect(updateLayout).not.toHaveBeenCalled(); + expect(getApplicationContext).toHaveBeenCalledTimes(1); + const body = JSON.parse(parseJsonRpc(call).result.content[0].text); + + expect(body.diagnostics.warnings).toBeGreaterThan(0); + expect( + body.diagnostics.issues.map((i: { rule: string }) => i.rule), + ).toContain("overlap"); + }); + + it("build_application rejects an invalid spec without importing", async () => { + const importApplicationArtifact = jest.fn< + Promise<{ id: string }>, + [string, Record] + >(async () => ({ id: "app-1" })); + const api: AppsmithApi = { + ...createApi()(), + importApplicationArtifact, + }; + const server = createMcpHttpServer(API_BASE_URL, () => api); + const initialized = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .send(initializeRequest); + const sessionId = initialized.headers["mcp-session-id"] as string; + + await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .set("mcp-session-id", sessionId) + .send({ jsonrpc: "2.0", method: "notifications/initialized" }); + const call = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .set("mcp-session-id", sessionId) + .send({ + jsonrpc: "2.0", + id: 4, + method: "tools/call", + params: { + name: "build_application", + arguments: { + workspaceId: "ws1", + app: { name: "Bad", pages: [{ name: "P", widgets: [] }] }, + }, + }, + }); + const text = parseJsonRpc(call).result.content[0].text; + + expect(importApplicationArtifact).not.toHaveBeenCalled(); + expect(text).toContain('"valid": false'); + }); + + const existingPageDsl = { + widgetId: "0", + widgetName: "MainContainer", + type: "CANVAS_WIDGET", + topRow: 0, + bottomRow: 380, + leftColumn: 0, + rightColumn: 640, + children: [ + { + widgetId: "email1", + widgetName: "Email", + type: "INPUT_WIDGET_V2", + topRow: 0, + bottomRow: 7, + leftColumn: 0, + rightColumn: 24, + }, + ], + }; + + async function initSession(server: ReturnType) { + const initialized = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .send(initializeRequest); + const sessionId = initialized.headers["mcp-session-id"] as string; + + await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .set("mcp-session-id", sessionId) + .send({ jsonrpc: "2.0", method: "notifications/initialized" }); + + return sessionId; + } + + it("edit_page appends to the existing DSL and writes it back", async () => { + const getApplicationContext = jest.fn(async () => ({ + pages: [], + page: {}, + layout: { dsl: existingPageDsl }, + })); + const updateLayout = jest.fn< + Promise<{ ok: boolean }>, + [string, string, string, Record] + >(async () => ({ ok: true })); + const api: AppsmithApi = { + ...createApi()(), + getApplicationContext, + updateLayout, + }; + const server = createMcpHttpServer(API_BASE_URL, () => api); + const sessionId = await initSession(server); + + const call = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .set("mcp-session-id", sessionId) + .send({ + jsonrpc: "2.0", + id: 5, + method: "tools/call", + params: { + name: "edit_page", + arguments: { + applicationId: "app1", + pageId: "p1", + layoutId: "l1", + edit: { add: [{ type: "button", text: "Save" }] }, + }, + }, + }); + + expect(parseJsonRpc(call).result.content[0].text).not.toContain( + '"valid": false', + ); + expect(updateLayout).toHaveBeenCalledTimes(1); + const [, , , writtenDsl] = updateLayout.mock.calls[0]; + const children = writtenDsl.children as { widgetName: string }[]; + + expect(children).toHaveLength(2); + expect(children.map((c) => c.widgetName)).toContain("Email"); + + // edit_page returns structural diagnostics inline, same as build_application. + const body = JSON.parse(parseJsonRpc(call).result.content[0].text); + + expect(body.diagnostics).toEqual({ + errors: 0, + warnings: 0, + issues: [], + }); + }); + + it("edit_page reports a read failure without writing when the layout is missing", async () => { + const getApplicationContext = jest.fn(async () => ({ + pages: [], + page: {}, + layout: {}, + })); + const updateLayout = jest.fn(async () => ({ ok: true })); + const api: AppsmithApi = { + ...createApi()(), + getApplicationContext, + updateLayout, + }; + const server = createMcpHttpServer(API_BASE_URL, () => api); + const sessionId = await initSession(server); + + const call = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .set("mcp-session-id", sessionId) + .send({ + jsonrpc: "2.0", + id: 6, + method: "tools/call", + params: { + name: "edit_page", + arguments: { + applicationId: "app1", + pageId: "p1", + layoutId: "l1", + edit: { add: [{ type: "button", text: "Save" }] }, + }, + }, + }); + + expect(updateLayout).not.toHaveBeenCalled(); + expect(parseJsonRpc(call).result.content[0].text).toContain( + "could not read the current page layout", + ); + }); + + it("does not evict a session when a mismatched token is presented", async () => { + const server = createMcpHttpServer(API_BASE_URL, createApi()); + const sessionId = await initSession(server); + + const attacker = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_attacker-token") + .set("mcp-session-id", sessionId) + .send({ jsonrpc: "2.0", method: "notifications/initialized" }); + + expect(attacker.status).toBe(401); + + // The real owner's session must survive the mismatch — otherwise a leaked session id is a targeted DoS. + const owner = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .set("mcp-session-id", sessionId) + .send({ jsonrpc: "2.0", method: "notifications/initialized" }); + + expect(owner.status).not.toBe(404); + }); + + it("enforces the per-user session cap under concurrent initialize requests", async () => { + const server = createMcpHttpServer(API_BASE_URL, createApi(), { + maxSessionsPerUser: 1, + }); + + // Listen once so concurrent requests share one address (supertest otherwise re-listens per call). + await new Promise((resolve) => server.listen(0, resolve)); + + try { + const fire = () => + supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .send(initializeRequest); + const responses = await Promise.all([ + fire(), + fire(), + fire(), + fire(), + fire(), + ]); + const acceptedIds = responses + .map((r) => r.headers["mcp-session-id"] as string | undefined) + .filter((id): id is string => Boolean(id)); + const rejected = responses.filter((r) => r.status === 429).length; + + // Every request either gets a session or hits the cap — nothing slips through unaccounted. An initialize + // that lands while another is still a pending reservation is rejected (reservations are not evictable); + // one that lands after a session registered evicts it and takes its place. Timing decides the split, so + // assert the invariants rather than an exact count. + expect(acceptedIds.length).toBeGreaterThanOrEqual(1); + expect(acceptedIds.length + rejected).toBe(5); + + // The cap itself is the hard invariant: at most one of the issued sessions is still alive afterwards. + const probes = await Promise.all( + acceptedIds.map((id) => + supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .set("mcp-session-id", id) + .send({ jsonrpc: "2.0", method: "notifications/initialized" }), + ), + ); + const alive = probes.filter((probe) => probe.status !== 404).length; + + expect(alive).toBe(1); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it("fails safely when session token revalidation fails", async () => { + const validateToken = jest + .fn, []>() + .mockResolvedValueOnce({ + username: "user@appsmith.com", + isAnonymous: false, + }) + .mockRejectedValueOnce(new Error("token must not leak")); + const server = createMcpHttpServer(API_BASE_URL, createApi(validateToken)); + const initialized = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .send(initializeRequest); + const sessionId = initialized.headers["mcp-session-id"] as string; + + const reused = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .set("mcp-session-id", sessionId) + .send({ jsonrpc: "2.0", method: "notifications/initialized" }); + + expect(reused).toMatchObject({ + status: 401, + body: { error: "invalid bearer token" }, + }); + }); + + it("removes expired sessions before dispatching a request", async () => { + let time = 0; + const server = createMcpHttpServer(API_BASE_URL, createApi(), { + now: () => time, + sessionTtlMs: 1, + }); + const initialized = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .send(initializeRequest); + const sessionId = initialized.headers["mcp-session-id"] as string; + + time = 1; + const expired = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .set("mcp-session-id", sessionId) + .send({ jsonrpc: "2.0", method: "notifications/initialized" }); + + // 404, not 400: the spec-defined signal on which a compliant client re-initializes transparently. + expect(expired).toMatchObject({ + status: 404, + body: { error: "session not found; initialize a new MCP session" }, + }); + }); + + it("answers 400 only when NO session id accompanies a non-initialize request", async () => { + const server = createMcpHttpServer(API_BASE_URL, createApi()); + const response = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .send({ jsonrpc: "2.0", method: "notifications/initialized" }); + + expect(response).toMatchObject({ + status: 400, + body: { error: "initialize the MCP session first" }, + }); + }); + + it("rejects new sessions when the configured session limit is reached", async () => { + const server = createMcpHttpServer(API_BASE_URL, createApi(), { + maxSessions: 0, + }); + + const response = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_user-token") + .send(initializeRequest); + + expect(response).toMatchObject({ + status: 503, + body: { error: "MCP session limit reached" }, + }); + }); + + it("rejects bearer tokens that are not MCP tokens", async () => { + const server = createMcpHttpServer(API_BASE_URL, createApi()); + + const response = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer not-an-mcp-token") + .send(initializeRequest); + + expect(response.status).toBe(401); + expect(response.headers["www-authenticate"]).toBe("Bearer"); + }); + + it("rejects cross-origin requests", async () => { + const response = await supertest( + createMcpHttpServer(API_BASE_URL, createApi()), + ) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Origin", "https://evil.example") + .set("Authorization", "Bearer mcp_user-token") + .send(initializeRequest); + + expect(response).toMatchObject({ + status: 403, + body: { error: "cross-origin requests are not allowed" }, + }); + }); + + it("rejects a Host not in the configured allowlist before any session/auth work", async () => { + const validateToken = jest.fn(); + const server = createMcpHttpServer(API_BASE_URL, createApi(validateToken), { + allowedHosts: ["127.0.0.1", "localhost"], + }); + const rejected = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Host", "attacker.example") + .set("Authorization", "Bearer mcp_user-token") + .send(initializeRequest); + + expect(rejected).toMatchObject({ + status: 403, + body: { error: "host not allowed" }, + }); + // Rejected before authentication — no session is created and the token is never validated. + expect(validateToken).not.toHaveBeenCalled(); + + // A loopback Host (what supertest and Caddy-to-loopback send) is allowed through to normal processing. + const allowed = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Host", "127.0.0.1") + .set("Authorization", "Bearer mcp_user-token") + .send(initializeRequest); + + expect(allowed.status).not.toBe(403); + }); + + it("leaves the Host header unchecked when no allowlist is configured (default)", async () => { + const server = createMcpHttpServer(API_BASE_URL, createApi()); + const response = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Host", "any-public-domain.example") + .set("Authorization", "Bearer mcp_user-token") + .send(initializeRequest); + + // No allowlist -> Host is not a rejection reason (Caddy-fronted deployments preserve the public Host). + expect(response.status).not.toBe(403); + }); + + it("allows a listed host that arrives with a port (loopback deployments send host:port)", async () => { + const server = createMcpHttpServer(API_BASE_URL, createApi(), { + allowedHosts: ["127.0.0.1"], + }); + const response = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Host", "127.0.0.1:9000") + .set("Authorization", "Bearer mcp_user-token") + .send(initializeRequest); + + // The port is stripped before the allowlist check, so 127.0.0.1:9000 matches "127.0.0.1". + expect(response.status).not.toBe(403); + }); + + describe("hostHeaderName", () => { + it("extracts the lowercased hostname without the port", () => { + expect(hostHeaderName("127.0.0.1")).toBe("127.0.0.1"); + expect(hostHeaderName("127.0.0.1:8092")).toBe("127.0.0.1"); + expect(hostHeaderName("LocalHost:8092")).toBe("localhost"); + expect(hostHeaderName(" Example.COM:443 ")).toBe("example.com"); + // Bracketed IPv6 keeps the address and drops the port. + expect(hostHeaderName("[::1]:8092")).toBe("::1"); + expect(hostHeaderName("[::1]")).toBe("::1"); + }); + + it("returns an empty string for a missing or non-string header (fails closed)", () => { + expect(hostHeaderName(undefined)).toBe(""); + expect(hostHeaderName("")).toBe(""); + expect(hostHeaderName(["127.0.0.1"])).toBe(""); + // A malformed bracket does not silently become a loopback match. + expect(hostHeaderName("[::1")).not.toBe("::1"); + }); + }); + + // M5 — the session-scoped origin for build_application's URLs, derived from the initialize request's headers. + describe("sessionOriginFromHeaders", () => { + it("builds an origin only from an exact http/https proto and a charset-clean host", () => { + expect( + sessionOriginFromHeaders( + { "x-forwarded-proto": "https", host: "Apps.Example.Com:8443" }, + new Set(), + ), + ).toBe("https://apps.example.com:8443"); + expect( + sessionOriginFromHeaders( + { "x-forwarded-proto": "http", host: "localhost:8080" }, + new Set(), + ), + ).toBe("http://localhost:8080"); + }); + + it("fails closed on a forged/missing/duplicated proto or a hostile host", () => { + const noAllowlist = new Set(); + + expect( + sessionOriginFromHeaders({ host: "apps.example.com" }, noAllowlist), + ).toBeUndefined(); + expect( + sessionOriginFromHeaders( + { "x-forwarded-proto": "javascript", host: "apps.example.com" }, + noAllowlist, + ), + ).toBeUndefined(); + // Node joins duplicated X-Forwarded-Proto headers with a comma; the literal comparison rejects that too. + expect( + sessionOriginFromHeaders( + { "x-forwarded-proto": "https, http", host: "apps.example.com" }, + noAllowlist, + ), + ).toBeUndefined(); + expect( + sessionOriginFromHeaders( + { "x-forwarded-proto": ["https"], host: "apps.example.com" }, + noAllowlist, + ), + ).toBeUndefined(); + expect( + sessionOriginFromHeaders( + { "x-forwarded-proto": "https", host: "evil.com" }, + { type: "video", url: "file:///etc/passwd" }, + { type: "audio", url: "vbscript:msgbox(1)" }, + // Embedded credentials would leak into the DSL and the request. + { type: "video", url: "https://user:pass@example.com/a.mp4" }, + // Not a URL at all. + { type: "documentviewer", url: "not a url" }, + // Binding syntax must never survive into a URL prop. + { type: "iframe", source: "https://example.com/{{Query1.data}}" }, + // The URL is required. + { type: "iframe", title: "no source" }, + // WHATWG-normalization guarantees (council regression pins): leading/mid-scheme whitespace and case + // variants of javascript:, protocol-relative, and other non-http schemes must all still be rejected. + { type: "iframe", source: "\tjavascript:alert(1)" }, + { type: "iframe", source: "JavaScript:alert(1)" }, + { type: "iframe", source: "java\tscript:alert(1)" }, + { type: "iframe", source: "//evil.com/embed" }, + { type: "documentviewer", url: "mailto:a@b.com" }, + { type: "video", url: "ftp://example.com/a.mp4" }, + ]; + + for (const widget of bad) { + expect( + appSpecSchema.safeParse({ + name: "App", + pages: [{ name: "Home", widgets: [widget] }], + }).success, + ).toBe(false); + } + }); + + it("compiles richtext (plain-text seed) and jsonform (auto-generated from data)", () => { + const artifact = compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { + type: "richtext", + name: "Notes", + label: "Notes", + defaultValue: "Type here", + }, + { + type: "jsonform", + name: "Signup", + title: "Sign up", + data: { name: "", age: 0 }, + submitLabel: "Register", + }, + ], + }, + ], + }, + ids(), + ); + const children = rootOf(artifact).children as WidgetNode[]; + const by = (name: string) => children.find((w) => w.widgetName === name)!; + + expect(by("Notes").type).toBe("RICH_TEXT_EDITOR_WIDGET"); + expect(by("Notes").defaultText).toBe("Type here"); + expect(by("Notes").inputType).toBe("html"); + + const form = by("Signup"); + + expect(form.type).toBe("JSON_FORM_WIDGET"); + expect(form.autoGenerateForm).toBe(true); + // sourceData is a literal JSON string the client derives fields from. + expect(form.sourceData).toBe(JSON.stringify({ name: "", age: 0 })); + expect(form.submitButtonLabel).toBe("Register"); + }); + + it("compiles statbox as a STATBOX_WIDGET container with caption/value children", () => { + const artifact = compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { + type: "statbox", + name: "Kpi", + title: "Page Views", + value: "2.6 M", + subtext: "21% up", + icon: "arrow-top-right", + }, + ], + }, + ], + }, + ids(), + ); + const statbox = (rootOf(artifact).children as WidgetNode[])[0]; + + expect(statbox.type).toBe("STATBOX_WIDGET"); + // The composite's parts live in its inner canvas. + const canvas = (statbox.children as WidgetNode[])[0]; + const parts = canvas.children as WidgetNode[]; + const texts = parts.filter((w) => w.type === "TEXT_WIDGET"); + + expect(texts.map((t) => t.text)).toEqual(["Page Views", "2.6 M", "21% up"]); + expect(parts.some((w) => w.type === "ICON_BUTTON_WIDGET")).toBe(true); + }); + + it("rejects richtext HTML tags and binding syntax", () => { + const bad = [ + { type: "richtext", defaultValue: "" }, + { type: "richtext", defaultValue: "bold" }, + { type: "richtext", defaultValue: "{{Query1.data}}" }, + { type: "statbox", title: "KPI {{evil}}" }, + ]; + + for (const widget of bad) { + expect( + appSpecSchema.safeParse({ + name: "App", + pages: [{ name: "Home", widgets: [widget] }], + }).success, + ).toBe(false); + } + }); + + it("T1: compiles explicit table columns (type/label/hidden + computed) into primaryColumns", () => { + const artifact = compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { + type: "table", + name: "Orders", + data: [{ price: 10, qty: 2, "sign up": "y" }], + columns: [ + { key: "price", type: "number", label: "Unit Price" }, + { key: "sign up", type: "date", hidden: true }, + { + key: "total", + computed: { + op: "mul", + args: [{ cell: "price" }, { cell: "qty" }], + }, + }, + ], + }, + ], + }, + ], + }, + ids(), + ); + const table = (rootOf(artifact).children as WidgetNode[])[0]; + + expect(table.type).toBe("TABLE_WIDGET_V2"); + const primaryColumns = table.primaryColumns as Record< + string, + Record + >; + + // A space in the key is sanitized to an underscore for the column id; originalId/alias keep the raw field. + expect(table.columnOrder).toEqual(["price", "sign_up", "total"]); + expect(primaryColumns.price.columnType).toBe("number"); + expect(primaryColumns.price.label).toBe("Unit Price"); + expect(primaryColumns.sign_up.originalId).toBe("sign up"); + expect(primaryColumns.sign_up.columnType).toBe("date"); + expect(primaryColumns.sign_up.isVisible).toBe(false); + + // The data column reads currentRow by the raw field name and self-references the table name. + expect(primaryColumns.price.isDerived).toBe(false); + expect(primaryColumns.price.computedValue).toContain( + "Orders.processedTableData", + ); + expect(primaryColumns.price.computedValue).toContain('currentRow["price"]'); + + // The computed column is derived and evaluates the per-row formula (finite-guarded). + expect(primaryColumns.total.isDerived).toBe(true); + expect(primaryColumns.total.columnType).toBe("number"); + expect(primaryColumns.total.computedValue).toContain( + '(Number(currentRow["price"]) * Number(currentRow["qty"]))', + ); + + // Each column's computedValue is registered as a dynamic binding. + const paths = (table.dynamicBindingPathList as { key: string }[]).map( + (p) => p.key, + ); + + expect(paths).toContain("primaryColumns.total.computedValue"); + expect(paths).toContain("primaryColumns.price.computedValue"); + }); + + it("T1: rejects a { cell } leaf outside a computed column, and bad computed formulas", () => { + const bad = [ + // A cell leaf has no meaning in a text widget's value formula (no currentRow) — must be rejected. + { + widgets: [{ type: "text", value: { formula: { cell: "price" } } }], + }, + // Bad arity inside a computed column formula. + { + widgets: [ + { + type: "table", + data: [{ a: 1 }], + columns: [ + { key: "x", computed: { op: "sub", args: [{ cell: "a" }] } }, + ], + }, + ], + }, + // Column key charset (a quote would break out of currentRow["..."]). + { + widgets: [ + { type: "table", data: [{ a: 1 }], columns: [{ key: 'a"]//' }] }, + ], + }, + // SECURITY [council]: a table name with a metacharacter is emitted unescaped into the computedValue binding, + // so nameField must reject it — this locks the injection invariant the emitters depend on. + { + widgets: [ + { + type: "table", + name: 'T"];alert(1)//', + data: [{ a: 1 }], + columns: [{ key: "a" }], + }, + ], + }, + // Formula depth/node limits stay enforced through cell leaves (deeply nested add of cells). + { + widgets: [ + { + type: "table", + data: [{ a: 1 }], + columns: [ + { + key: "deep", + computed: { + op: "add", + args: [ + { cell: "a" }, + { + op: "add", + args: [ + { cell: "a" }, + { + op: "add", + args: [ + { cell: "a" }, + { + op: "add", + args: [ + { cell: "a" }, + { + op: "add", + args: [ + { cell: "a" }, + { + op: "add", + args: [ + { cell: "a" }, + { op: "add", args: [{ cell: "a" }, 1] }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + }, + ], + }, + ], + }, + ]; + + for (const page of bad) { + expect( + appSpecSchema.safeParse({ + name: "App", + pages: [{ name: "Home", widgets: page.widgets }], + }).success, + ).toBe(false); + } + }); + + it("T1: omitting `columns` preserves auto-derive (primaryColumns stays empty)", () => { + const artifact = compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: [{ type: "table", name: "Auto", data: [{ a: 1, b: 2 }] }], + }, + ], + }, + ids(), + ); + const table = (rootOf(artifact).children as WidgetNode[])[0]; + + expect(table.primaryColumns).toEqual({}); + expect(table.columnOrder).toEqual([]); + expect(table.dynamicBindingPathList).toEqual([]); + }); + + it("T1: duplicate keys that sanitize to the same id get distinct suffixed ids", () => { + const artifact = compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { + type: "table", + name: "Dup", + data: [{ "a b": 1 }], + // "a b" and "a_b" both sanitize to "a_b" → the second is disambiguated. + columns: [{ key: "a b" }, { key: "a_b" }], + }, + ], + }, + ], + }, + ids(), + ); + const table = (rootOf(artifact).children as WidgetNode[])[0]; + + expect(table.columnOrder).toEqual(["a_b", "a_b1"]); + const primaryColumns = table.primaryColumns as Record< + string, + Record + >; + + // originalId keeps each raw key even though the ids were disambiguated. + expect(primaryColumns.a_b.originalId).toBe("a b"); + expect(primaryColumns.a_b1.originalId).toBe("a_b"); + }); + + it("Ch1: compiles a multi-series query chart with axis/label config", () => { + const artifact = compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { + type: "chart", + name: "Sales", + chartType: "COLUMN_CHART", + source: [ + { query: "Q1", x: "month", y: "revenue", name: "Revenue" }, + { query: "Q2", x: "month", y: "cost", name: "Cost" }, + ], + xAxisLabel: "Month", + yAxisLabel: "USD", + labelOrientation: "slant", + showDataLabels: true, + }, + ], + }, + ], + }, + ids(), + ); + const chart = (rootOf(artifact).children as WidgetNode[])[0]; + + expect(chart.type).toBe("CHART_WIDGET"); + expect(chart.chartType).toBe("COLUMN_CHART"); + expect(chart.xAxisName).toBe("Month"); + expect(chart.yAxisName).toBe("USD"); + expect(chart.labelOrientation).toBe("slant"); + expect(chart.showDataPointLabel).toBe(true); + + const chartData = chart.chartData as Record< + string, + { seriesName: string; data: string } + >; + + expect(Object.keys(chartData)).toEqual(["series1", "series2"]); + expect(chartData.series1.seriesName).toBe("Revenue"); + expect(chartData.series2.seriesName).toBe("Cost"); + // Each query series' data is a compiler-authored binding over that query's rows. + expect(chartData.series1.data).toContain("Q1.data"); + expect(chartData.series2.data).toContain("Q2.data"); + + // Both series' data are registered as dynamic bindings. + const paths = (chart.dynamicBindingPathList as { key: string }[]).map( + (p) => p.key, + ); + + expect(paths).toEqual(["chartData.series1.data", "chartData.series2.data"]); + }); + + it("Ch1: a single query source still yields one series named after the title", () => { + const artifact = compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { + type: "chart", + name: "One", + title: "Trend", + source: { query: "Q1", x: "d", y: "v" }, + }, + ], + }, + ], + }, + ids(), + ); + const chart = (rootOf(artifact).children as WidgetNode[])[0]; + const chartData = chart.chartData as Record; + + expect(Object.keys(chartData)).toEqual(["series1"]); + expect(chartData.series1.seriesName).toBe("Trend"); + }); + + it("Ch1: rejects unsafe chart props (bad type, binding axis label, series+source)", () => { + const bad = [ + { type: "chart", chartType: "CUSTOM_ECHART" }, + { type: "chart", xAxisLabel: "X {{evil}}" }, + { type: "chart", labelOrientation: "sideways" }, + { type: "chart", source: [{ query: "Q1", x: 'x"]//', y: "y" }] }, + ]; + + for (const widget of bad) { + expect( + appSpecSchema.safeParse({ + name: "App", + pages: [{ name: "Home", widgets: [widget] }], + }).success, + ).toBe(false); + } + + // series + source together is rejected at compile time (mutually exclusive). + expect(() => + compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { + type: "chart", + series: [{ name: "S", points: [{ x: 1, y: 2 }] }], + source: { query: "Q1", x: "d", y: "v" }, + }, + ], + }, + ], + }, + ids(), + ), + ).toThrow(/cannot set both/); + }); + + it("compiles selected-row display bindings for text and input", () => { + const artifact = compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { type: "table", name: "Users", data: [{ id: 1 }] }, + { + type: "text", + name: "EmailDetail", + source: { table: "Users", column: "email" }, + }, + { + type: "input", + name: "EmailInput", + defaultValue: { table: "Users", column: "user email" }, + }, + ], + }, + ], + }, + ids(), + ); + const children = rootOf(artifact).children as WidgetNode[]; + const text = children.find((w) => w.widgetName === "EmailDetail")!; + const input = children.find((w) => w.widgetName === "EmailInput")!; + + expect(text.text).toBe('{{ Users.selectedRow["email"] }}'); + expect(text.dynamicBindingPathList).toEqual([{ key: "text" }]); + expect(input.defaultText).toBe('{{ Users.selectedRow["user email"] }}'); + expect(input.dynamicBindingPathList).toEqual([{ key: "defaultText" }]); + }); + + it("binds a table to a query's data, optionally into a nested response field", () => { + const artifact = compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { type: "table", name: "Whole", source: { query: "getRow" } }, + { + type: "table", + name: "Nested", + source: { query: "lookupZip", field: "places" }, + }, + ], + }, + ], + }, + ids(), + ); + const children = rootOf(artifact).children as WidgetNode[]; + const whole = children.find((w) => w.widgetName === "Whole")!; + const nested = children.find((w) => w.widgetName === "Nested")!; + + // Optional chaining + `?? []` keeps the table valid before the query runs (no "Data is undefined" error). + expect(whole.tableData).toBe("{{ getRow.data ?? [] }}"); + expect(nested.tableData).toBe("{{ lookupZip.data?.places ?? [] }}"); + expect(nested.dynamicBindingPathList).toEqual([{ key: "tableData" }]); + }); + + it("keeps unbound text/input static with no dynamic paths", () => { + const artifact = compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { type: "text", text: "Hello" }, + { type: "input", label: "Name" }, + ], + }, + ], + }, + ids(), + ); + const children = rootOf(artifact).children as WidgetNode[]; + + expect(children[0].dynamicBindingPathList).toEqual([]); + expect(children[1].defaultText).toBe(""); + expect(children[1].dynamicBindingPathList).toEqual([]); + }); + + it("rejects a text widget setting both text and source", () => { + expect(() => + compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { + type: "text", + text: "static", + source: { table: "Users", column: "email" }, + }, + ], + }, + ], + }, + ids(), + ), + ).toThrow(/cannot set both 'text' and 'source'/); + }); + + it("binds a text and an image to a query response field (scalar display bindings)", () => { + const artifact = compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { + type: "text", + name: "Temperature", + source: { query: "getWeather", field: "current.temp" }, + }, + { type: "text", name: "WholeBody", source: { query: "getDay" } }, + { + type: "image", + name: "Photo", + source: { query: "getProfile", field: "avatarUrl" }, + }, + ], + }, + ], + }, + ids(), + ); + const children = rootOf(artifact).children as WidgetNode[]; + const temperature = children.find((w) => w.widgetName === "Temperature")!; + const wholeBody = children.find((w) => w.widgetName === "WholeBody")!; + const photo = children.find((w) => w.widgetName === "Photo")!; + + // Optional chaining + `?? ""` keeps the widget blank before the query has run. + expect(temperature.text).toBe('{{ getWeather.data?.current.temp ?? "" }}'); + expect(temperature.dynamicBindingPathList).toEqual([{ key: "text" }]); + expect(wholeBody.text).toBe('{{ getDay.data ?? "" }}'); + expect(photo.image).toBe('{{ getProfile.data?.avatarUrl ?? "" }}'); + expect(photo.dynamicBindingPathList).toEqual([{ key: "image" }]); + }); + + it("compiles computed-value tokens on text: now, count, and concat", () => { + const artifact = compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { + type: "text", + name: "Today", + value: { now: { format: "dayOfWeek" } }, + }, + { + type: "text", + name: "Matches", + value: { count: { query: "getUsers", field: "items" } }, + }, + { + type: "text", + name: "FullName", + value: { + concat: [ + { table: "Users", column: "first" }, + { literal: " " }, + { table: "Users", column: "last" }, + ], + }, + }, + ], + }, + ], + }, + ids(), + ); + const children = rootOf(artifact).children as WidgetNode[]; + const today = children.find((w) => w.widgetName === "Today")!; + const matches = children.find((w) => w.widgetName === "Matches")!; + const fullName = children.find((w) => w.widgetName === "FullName")!; + + // The agent picks a NAMED format; the compiler owns the moment format literal. + expect(today.text).toBe("{{ moment().format('dddd') }}"); + expect(today.dynamicBindingPathList).toEqual([{ key: "text" }]); + expect(matches.text).toBe("{{ (getUsers.data?.items ?? []).length }}"); + // Literals are JSON-escaped at compile time so agent text stays inert. + expect(fullName.text).toBe( + '{{ [Users.selectedRow["first"], " ", Users.selectedRow["last"]].join("") }}', + ); + }); + + it("compiles EVERY named now-format and each round-trips through semantic read-back", () => { + // Table-driven over the emitter's own map (mirrors the input-validation formats test): a typo in any + // format literal, or drift between the emitter and the read-back reverse map, fails here. + for (const name of Object.keys(COMPUTED_NOW_FORMATS)) { + const artifact = compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { + type: "text", + name: "Now", + value: { + now: { format: name as keyof typeof COMPUTED_NOW_FORMATS }, + }, + }, + ], + }, + ], + }, + ids(), + ); + const text = (rootOf(artifact).children as WidgetNode[])[0]; + + expect(text.text).toBe( + `{{ moment().format('${COMPUTED_NOW_FORMATS[name as keyof typeof COMPUTED_NOW_FORMATS]}') }}`, + ); + + const projected = projectSemanticPage(rootOf(artifact)).widgets.find( + (w) => w.name === "Now", + )!; + + expect(projected.bindings).toEqual({ text: { now: { format: name } } }); + } + }); + + it("compiles concat query-field parts and pins JSON escaping of hostile literals", () => { + const artifact = compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { + type: "text", + name: "Combo", + value: { + concat: [ + { query: "getUser", field: "name" }, + { literal: ' says "hi" \\ ' }, + { query: "getDay" }, + ], + }, + }, + { + type: "text", + name: "Total", + value: { count: { query: "getUsers" } }, + }, + ], + }, + ], + }, + ids(), + ); + const children = rootOf(artifact).children as WidgetNode[]; + const combo = children.find((w) => w.widgetName === "Combo")!; + const total = children.find((w) => w.widgetName === "Total")!; + + // Quotes and backslashes in the agent literal arrive JSON-escaped — inert inside the expression. + expect(combo.text).toBe( + '{{ [getUser.data?.name, " says \\"hi\\" \\\\ ", getDay.data].join("") }}', + ); + // Count without a field reads the whole response array. + expect(total.text).toBe("{{ (getUsers.data ?? []).length }}"); + }); + + it("compiles a formula value: Fahrenheit conversion with rounding, finite-guarded", () => { + const artifact = compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { + type: "text", + name: "TempF", + value: { + formula: { + op: "round", + args: [ + { + op: "add", + args: [ + { + op: "mul", + args: [ + { query: "getWeather", field: "current.temp" }, + 1.8, + ], + }, + 32, + ], + }, + 1, + ], + }, + }, + }, + { + type: "text", + name: "Half", + value: { + formula: { + op: "div", + args: [{ table: "Users", column: "amount" }, 2], + }, + }, + }, + ], + }, + ], + }, + ids(), + ); + const children = rootOf(artifact).children as WidgetNode[]; + const tempF = children.find((w) => w.widgetName === "TempF")!; + const half = children.find((w) => w.widgetName === "Half")!; + + // The whole expression is finite-guarded (blank instead of NaN/Infinity), refs are coerced via + // Number(), and every compound is parenthesized — all compiler-owned characters. + // round always parenthesizes its operand (a bare literal would otherwise emit `1.toFixed(...)`, + // a syntax error), so compound operands carry doubled parens — harmless and uniform. + expect(tempF.text).toBe( + '{{ ((v) => Number.isFinite(v) ? v : "")(Number((((Number(getWeather.data?.current.temp) * 1.8) + 32)).toFixed(1))) }}', + ); + expect(tempF.dynamicBindingPathList).toEqual([{ key: "text" }]); + expect(half.text).toBe( + '{{ ((v) => Number.isFinite(v) ? v : "")((Number(Users.selectedRow["amount"]) / 2)) }}', + ); + }); + + it("rejects malformed formulas: bad ops, arity, depth, size, and non-finite literals", () => { + const deep = (levels: number): unknown => + levels === 0 ? 1 : { op: "abs", args: [deep(levels - 1)] }; + const wide = { + op: "add", + args: [1, 2], + } as { op: string; args: unknown[] }; + let node: { op: string; args: unknown[] } = wide; + + for (let i = 0; i < 30; i += 1) { + node.args[1] = { op: "add", args: [1, 2] }; + node = node.args[1] as { op: string; args: unknown[] }; + } + + const badFormulas = [ + { op: "evil()", args: [1, 2] }, + { op: "sub", args: [1, 2, 3] }, + { op: "abs", args: [1, 2] }, + { op: "round", args: [1, 9] }, + { op: "round", args: [1, { op: "abs", args: [1] }] }, + { op: "div", args: [{ query: "get(); x" }, 2] }, + deep(10), + wide, + // Variadic ops need at least 2 arguments. + { op: "add", args: [1] }, + // Width limit: 9 args rejects (schema max is 8). + { op: "add", args: [1, 2, 3, 4, 5, 6, 7, 8, 9] }, + // Node cap isolated: wide and SHALLOW (depth 3) but 28 nodes > 25 — only the node check can catch it. + { + op: "add", + args: [ + { op: "add", args: [1, 2, 3, 4, 5, 6, 7, 8] }, + { op: "add", args: [1, 2, 3, 4, 5, 6, 7, 8] }, + { op: "add", args: [1, 2, 3, 4, 5, 6, 7, 8] }, + ], + }, + Number.POSITIVE_INFINITY, + ]; + + for (const formula of badFormulas) { + expect( + appSpecSchema.safeParse({ + name: "App", + pages: [ + { name: "Home", widgets: [{ type: "text", value: { formula } }] }, + ], + }).success, + ).toBe(false); + } + }); + + it("pins every formula op branch, bare leaves, mixed refs, and boundary acceptance", () => { + const WRAP = (expr: string) => + `{{ ((v) => Number.isFinite(v) ? v : "")(${expr}) }}`; + const cases: { name: string; formula: unknown; expected: string }[] = [ + { + name: "Sub", + formula: { op: "sub", args: [10, 4] }, + expected: WRAP("(10 - 4)"), + }, + { + name: "Min", + formula: { op: "min", args: [1, 2, 3] }, + expected: WRAP("Math.min(1, 2, 3)"), + }, + { + name: "Max", + formula: { op: "max", args: [3, 4] }, + expected: WRAP("Math.max(3, 4)"), + }, + { + name: "Abs", + formula: { op: "abs", args: [-5] }, + expected: WRAP("Math.abs(-5)"), + }, + // round arity-1 takes the Math.round branch, not toFixed. + { + name: "RoundWhole", + formula: { op: "round", args: [{ op: "div", args: [10, 3] }] }, + expected: WRAP("Math.round((10 / 3))"), + }, + { name: "BareNumber", formula: 42, expected: WRAP("42") }, + { + name: "BareRef", + formula: { query: "getX", field: "n" }, + expected: WRAP("Number(getX.data?.n)"), + }, + { + name: "MixedLeaves", + formula: { + op: "add", + args: [ + { query: "getX", field: "n" }, + { table: "Users", column: "amt" }, + ], + }, + expected: WRAP( + '(Number(getX.data?.n) + Number(Users.selectedRow["amt"]))', + ), + }, + // Boundary ACCEPTANCE: exactly 8 args; exactly 25 nodes (1 + 8×3); max valid depth (5 ops + leaf = 6). + { + name: "Wide8", + formula: { op: "add", args: [1, 2, 3, 4, 5, 6, 7, 8] }, + expected: WRAP("(1 + 2 + 3 + 4 + 5 + 6 + 7 + 8)"), + }, + { + name: "Nodes25", + formula: { + op: "add", + args: Array.from({ length: 8 }, () => ({ op: "add", args: [1, 2] })), + }, + expected: WRAP( + `(${Array.from({ length: 8 }, () => "(1 + 2)").join(" + ")})`, + ), + }, + { + name: "Depth6", + formula: { + op: "abs", + args: [ + { + op: "abs", + args: [ + { + op: "abs", + args: [{ op: "abs", args: [{ op: "abs", args: [1] }] }], + }, + ], + }, + ], + }, + expected: WRAP("Math.abs(Math.abs(Math.abs(Math.abs(Math.abs(1)))))"), + }, + ]; + const artifact = compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: cases.map((c) => ({ + type: "text", + name: c.name, + value: { formula: c.formula }, + })) as never, + }, + ], + }, + ids(), + ); + const children = rootOf(artifact).children as WidgetNode[]; + + for (const c of cases) { + expect(children.find((w) => w.widgetName === c.name)?.text).toBe( + c.expected, + ); + } + }); + + it("emitted formulas are parseable JS and evaluate correctly (all 8 ops)", () => { + const compiled = compileComputedValue({ + formula: { + op: "round", + args: [ + { + op: "add", + args: [ + { op: "mul", args: [2, 3] }, + { op: "sub", args: [10, { op: "div", args: [8, 4] }] }, + { op: "min", args: [1, 2] }, + { op: "max", args: [3, 4] }, + { op: "abs", args: [-2] }, + ], + }, + 1, + ], + }, + } as never); + // Strip the {{ }} shell and execute the pure-numeric expression: 6 + 8 + 1 + 4 + 2 = 21. + const inner = compiled.slice(3, -3); + // eslint-disable-next-line @typescript-eslint/no-implied-eval + const evaluate = new Function(`return ${inner}`) as () => number; + + expect(evaluate()).toBe(21); + }); + + it("rejects source together with value on the build path", () => { + expect(() => + compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { + type: "text", + source: { query: "getDay" }, + value: { now: { format: "date" } }, + }, + ], + }, + ], + }, + ids(), + ), + ).toThrow(/cannot set 'value' together with/); + }); + + it("rejects a text widget combining value with text or source, and unsafe computed refs", () => { + const base = { name: "App" }; + + expect(() => + compileApp( + { + ...base, + pages: [ + { + name: "Home", + widgets: [ + { + type: "text", + text: "static", + value: { now: { format: "dayOfWeek" } }, + }, + ], + }, + ], + }, + ids(), + ), + ).toThrow(/cannot set/); + + // Unknown format names, injection in concat literals, and bad refs are schema-rejected. + const badValues = [ + { now: { format: "'; evil() //" } }, + { count: { query: "get(); x" } }, + { concat: [{ literal: "a{{evil}}" }, { literal: "b" }] }, + { concat: [{ literal: "only one part" }] }, + ]; + + for (const value of badValues) { + expect( + appSpecSchema.safeParse({ + ...base, + pages: [{ name: "Home", widgets: [{ type: "text", value }] }], + }).success, + ).toBe(false); + } + }); + + it("rejects an image widget setting both image and source", () => { + expect(() => + compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { + type: "image", + image: "https://example.com/x.png", + source: { query: "getProfile", field: "avatarUrl" }, + }, + ], + }, + ], + }, + ids(), + ), + ).toThrow(/cannot set both 'image' and 'source'/); + }); + + it("schema rejects injection through query-field refs", () => { + const cases = [ + { query: "get(); evil//", field: "a" }, + { query: "getX", field: "a{{evil}}" }, + { query: "getX", field: 'a"b' }, + { query: "getX", field: "a b" }, + // Malformed dotted paths would compile to a JS syntax error in the eval worker. + { query: "getX", field: "a." }, + { query: "getX", field: "a..b" }, + { query: "getX", field: ".a" }, + ]; + + for (const source of cases) { + expect( + appSpecSchema.safeParse({ + name: "App", + pages: [ + { + name: "Home", + widgets: [{ type: "text", source }], + }, + ], + }).success, + ).toBe(false); + } + }); + + it("schema rejects injection through selected-row refs", () => { + const cases = [ + { table: "Users']; evil()//", column: "email" }, + { table: "Users", column: 'em"ail' }, + { table: "Users", column: "x{{evil}}" }, + { table: "Users", column: "a`b" }, + ]; + + for (const source of cases) { + expect( + appSpecSchema.safeParse({ + name: "App", + pages: [ + { + name: "Home", + widgets: [{ type: "text", source }], + }, + ], + }).success, + ).toBe(false); + } + }); + + it("compiles every preset without error", () => { + for (const preset of Object.values(PRESETS)) { + const artifact = compileApp({ name: "P", pages: [preset.spec] }, ids()); + + expect( + (rootOf(artifact).children as WidgetNode[]).length, + ).toBeGreaterThan(0); + } + }); + + it("every preset spec passes appSpecSchema — get_preset output feeds validate_app_spec verbatim", () => { + for (const preset of Object.values(PRESETS)) { + const parsed = appSpecSchema.safeParse({ + name: "P", + pages: [preset.spec], + }); + + expect(parsed.success).toBe(true); + } + }); +}); + +describe("applyEdit — append to an existing page", () => { + function baseDsl(): WidgetNode { + const artifact = compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { type: "input", name: "Email", label: "Email" }, + { + type: "container", + name: "Details", + children: [{ type: "text", name: "Heading", text: "Details" }], + }, + ], + }, + ], + }, + ids(), + ); + + return rootOf(artifact); + } + + it("appends below existing content when no placement is given", () => { + const dsl = baseDsl(); + const before = (dsl.children as WidgetNode[]).length; + const maxBottom = Math.max( + ...(dsl.children as WidgetNode[]).map((c) => c.bottomRow), + ); + const { dsl: edited, notes } = applyEdit( + dsl, + { add: [{ type: "button", text: "Save" }] }, + ids(), + ); + const children = edited.children as WidgetNode[]; + + expect(children).toHaveLength(before + 1); + expect(children[children.length - 1].topRow).toBeGreaterThanOrEqual( + maxBottom, + ); + expect(notes).toHaveLength(0); + }); + + it("places a widget after a named widget", () => { + const dsl = baseDsl(); + const email = (dsl.children as WidgetNode[]).find( + (c) => c.widgetName === "Email", + )!; + const { dsl: edited } = applyEdit( + dsl, + { + add: [{ type: "input", label: "Phone", placement: { after: "Email" } }], + }, + ids(), + ); + const added = (edited.children as WidgetNode[]).find( + (c) => c.widgetName === "Input", + )!; + + expect(added.topRow).toBeGreaterThanOrEqual(email.bottomRow); + }); + + it("places a widget inside a named container's inner canvas", () => { + const dsl = baseDsl(); + // Add enough widgets to overflow the container's initial height, so growth is exercised (not just fit). + const { dsl: edited } = applyEdit( + dsl, + { + add: Array.from({ length: 12 }, (_, i) => ({ + type: "input" as const, + label: `Note ${i}`, + placement: { inside: "Details" }, + })), + }, + ids(), + ); + const originalContainer = (dsl.children as WidgetNode[]).find( + (c) => c.widgetName === "Details", + )!; + const container = (edited.children as WidgetNode[]).find( + (c) => c.widgetName === "Details", + )!; + const inner = (container.children as WidgetNode[])[0]; + const names = (inner.children as WidgetNode[]).map((c) => c.type); + + expect(names).toContain("INPUT_WIDGET_V2"); + // The container must grow to encompass the inner canvas's new extent, not clip it, and must maintain the + // build-time invariant container.bottomRow = container.topRow + innerCanvas.bottomRow. + expect(container.bottomRow).toBeGreaterThan(originalContainer.bottomRow); + expect(container.bottomRow).toBe(container.topRow + inner.bottomRow); + }); + + it("falls back with a note when placement.inside targets a non-container", () => { + const dsl = baseDsl(); + const { notes } = applyEdit( + dsl, + { add: [{ type: "text", text: "x", placement: { inside: "Email" } }] }, + ids(), + ); + + expect(notes.join(" ")).toContain("Email"); + }); + + it("falls back to append and reports a note when a target is missing", () => { + const dsl = baseDsl(); + const { notes } = applyEdit( + dsl, + { add: [{ type: "text", text: "x", placement: { after: "Nope" } }] }, + ids(), + ); + + expect(notes.join(" ")).toContain("Nope"); + }); + + it("never mutates the caller's DSL and preserves existing widgets", () => { + const dsl = baseDsl(); + const snapshot = JSON.stringify(dsl); + const { dsl: edited } = applyEdit( + dsl, + { add: [{ type: "button", text: "New" }] }, + ids(), + ); + + expect(JSON.stringify(dsl)).toBe(snapshot); // input untouched + const names = (edited.children as WidgetNode[]).map((c) => c.widgetName); + + expect(names).toContain("Email"); + expect(names).toContain("Details"); + }); + + // M6 CRITICAL regression case (design section D): applyEdit already grows containers — adding a widget inside a + // container that must grow PAST a widget below it must keep working under the delta overlap gate: the container + // grows AND the widget below is pushed down, so the final DSL introduces no overlapping pair. + it("grows a container past the widget below it and pushes that widget down (no introduced overlap)", () => { + const artifact = compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { + type: "container", + name: "Details", + children: [{ type: "text", name: "Heading", text: "Hi" }], + }, + { type: "button", name: "Below", text: "Save" }, + ], + }, + ], + }, + ids(), + ); + const dsl = rootOf(artifact); + const { dsl: edited, notes } = applyEdit( + dsl, + { + add: Array.from({ length: 12 }, (_, i) => ({ + type: "input" as const, + label: `Note ${i}`, + placement: { inside: "Details" }, + })), + }, + ids(), + ); + const container = (edited.children as WidgetNode[]).find( + (c) => c.widgetName === "Details", + )!; + const below = (edited.children as WidgetNode[]).find( + (c) => c.widgetName === "Below", + )!; + + // The container grew well past the button's original rows... + const originalBelow = (dsl.children as WidgetNode[]).find( + (c) => c.widgetName === "Below", + )!; + + expect(container.bottomRow).toBeGreaterThan(originalBelow.topRow); + // ...and the button was pushed below the grown container instead of being overlapped. + expect(below.topRow).toBeGreaterThanOrEqual(container.bottomRow); + expect(notes.join(" ")).toContain('"Below"'); + + // The exact invariant the commitLayout gate enforces: no overlapping sibling pairs were introduced. + expect(overlapDelta(dsl, edited).introduced).toEqual([]); + expect(overlappingPairs(edited.children as WidgetNode[])).toEqual([]); + }); + + it("pushes existing widgets down when an `after` placement inserts into the middle of the page", () => { + const dsl = baseDsl(); // Email (rows 0..7) then Details below it + const { dsl: edited, notes } = applyEdit( + dsl, + { + add: [{ type: "input", label: "Phone", placement: { after: "Email" } }], + }, + ids(), + ); + const children = edited.children as WidgetNode[]; + const added = children.find((c) => c.widgetName === "Input")!; + const details = children.find((c) => c.widgetName === "Details")!; + + // The new input landed right below Email — where Details used to be — and Details moved down. + expect(added.topRow).toBeGreaterThanOrEqual( + children.find((c) => c.widgetName === "Email")!.bottomRow, + ); + expect(details.topRow).toBeGreaterThanOrEqual(added.bottomRow); + expect(notes.join(" ")).toContain('"Details"'); + expect(overlapDelta(dsl, edited).introduced).toEqual([]); + expect(overlappingPairs(children)).toEqual([]); + }); +}); + +describe("spec validation", () => { + it("rejects an unknown widget type", () => { + const result = appSpecSchema.safeParse({ + name: "A", + pages: [{ name: "P", widgets: [{ type: "carousel" }] }], + }); + + expect(result.success).toBe(false); + }); + + it("rejects an empty page", () => { + const result = appSpecSchema.safeParse({ + name: "A", + pages: [{ name: "P", widgets: [] }], + }); + + expect(result.success).toBe(false); + }); + + it("accepts a valid edit spec with placement", () => { + const result = editSpecSchema.safeParse({ + add: [{ type: "input", label: "X", placement: { after: "Y" } }], + }); + + expect(result.success).toBe(true); + }); + + it("rejects a placement that sets both after and inside", () => { + const result = editSpecSchema.safeParse({ + add: [ + { type: "input", label: "X", placement: { after: "Y", inside: "Z" } }, + ], + }); + + expect(result.success).toBe(false); + }); + + it("accepts a placement that sets only inside", () => { + const result = editSpecSchema.safeParse({ + add: [{ type: "input", label: "X", placement: { inside: "Z" } }], + }); + + expect(result.success).toBe(true); + }); +}); + +describe("security — agents never author raw expressions", () => { + const rawStrings = [ + "{{ malicious() }}", + "trailing }} brace", + "template ${injection}", + "backtick `literal`", + ]; + + it.each(rawStrings)( + "rejects binding/template syntax in text: %s", + (value) => { + const result = appSpecSchema.safeParse({ + name: "App", + pages: [{ name: "P", widgets: [{ type: "text", text: value }] }], + }); + + expect(result.success).toBe(false); + }, + ); + + it("rejects binding syntax in a button label and an image url", () => { + expect( + appSpecSchema.safeParse({ + name: "App", + pages: [{ name: "P", widgets: [{ type: "button", text: "{{x}}" }] }], + }).success, + ).toBe(false); + expect( + appSpecSchema.safeParse({ + name: "App", + pages: [{ name: "P", widgets: [{ type: "image", image: "{{x}}" }] }], + }).success, + ).toBe(false); + }); + + it("no longer accepts table data as a free string (must be literal rows)", () => { + const asString = appSpecSchema.safeParse({ + name: "App", + pages: [{ name: "P", widgets: [{ type: "table", data: "{{q.data}}" }] }], + }); + const asRows = appSpecSchema.safeParse({ + name: "App", + pages: [{ name: "P", widgets: [{ type: "table", data: [{ id: 1 }] }] }], + }); + + expect(asString.success).toBe(false); + expect(asRows.success).toBe(true); + }); + + it("rejects binding syntax hidden inside a literal table cell", () => { + const result = appSpecSchema.safeParse({ + name: "App", + pages: [ + { + name: "P", + widgets: [{ type: "table", data: [{ note: "{{ steal() }}" }] }], + }, + ], + }); + + expect(result.success).toBe(false); + }); + + it("rejects a malicious table COLUMN KEY (not just the value)", () => { + // Column keys are embedded into a generated {{ }} binding on the client, so a hostile key must be rejected. + const result = appSpecSchema.safeParse({ + name: "App", + pages: [ + { + name: "P", + widgets: [{ type: "table", data: [{ "}} {{evil()}} {{": 1 }] }], + }, + ], + }); + + expect(result.success).toBe(false); + }); + + it("rejects binding/template syntax in the application name", () => { + expect( + appSpecSchema.safeParse({ + name: "App {{evil()}}", + pages: [{ name: "P", widgets: [{ type: "text", text: "hi" }] }], + }).success, + ).toBe(false); + }); + + it("does not register static table data as a dynamic binding", () => { + const artifact = compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: [{ type: "table", name: "T", data: [{ id: 1 }] }], + }, + ], + }, + ids(), + ); + const table = (rootOf(artifact).children as WidgetNode[])[0]; + + expect(table.dynamicBindingPathList).toEqual([]); + expect(table.tableData).toBe(JSON.stringify([{ id: 1 }])); + }); +}); diff --git a/app/client/packages/mcp/src/builder/capabilities.ts b/app/client/packages/mcp/src/builder/capabilities.ts new file mode 100644 index 000000000000..eda60855f088 --- /dev/null +++ b/app/client/packages/mcp/src/builder/capabilities.ts @@ -0,0 +1,901 @@ +import { GRID_COLUMNS, ROW_HEIGHT } from "./layout.js"; +import { listPresets } from "./presets.js"; + +// A machine-readable catalog so the caller's agent can discover what it can build and how to shape a page spec, +// without reading source or guessing. + +// Single source of truth for the widget catalog. The `reference/widgets` MCP resource is generated from this — do +// not fork it. Field docs must reflect the real (safe) accepted shapes: agents never author raw `{{ }}` expressions, +// so no field advertises binding syntax. +export const WIDGET_CATALOG = [ + { + type: "text", + fields: { + text: "plain string (no binding/template syntax)", + source: + "{ table: '', column: '' } — show the selected row's column (detail views); or { query: '', field?: '' } — show one field of a query's response (scalar readout).", + value: + "computed value, no query needed: { now: { format: 'dayOfWeek'|'date'|'dateShort'|'time'|'dateTime'|'isoDate'|'monthYear' } } (current date/time), { count: { query, field? } } (row count), { concat: [ { literal }|{ table, column }|{ query, field? }, ... ] }, or { formula: } for arithmetic — expr is a number, { query, field? }, { table, column }, or { op: 'add'|'sub'|'mul'|'div'|'round'|'abs'|'min'|'max', args: [expr...] } (round takes an optional 0-6 decimals literal; renders blank if non-numeric). Set exactly ONE of text, source, value.", + }, + purpose: + "Static text / headings, a bound detail field, or a computed readout (dates, counts, concatenation).", + }, + { + type: "input", + fields: { + label: "string", + inputType: "TEXT | NUMBER | EMAIL | PASSWORD", + defaultValue: + "{ table: '', column: '' } — prefill from the selected row (edit forms)", + validation: + "{ format: 'zipcode'|'email'|'number'|'integer'|'usPhone', message? } — vetted regex + error message; makes the input required (never author a raw regex)", + }, + purpose: + "Single-line text entry. Pair validation with a button's disableWhenInvalid (patch_widgets) so bad input can't run a query.", + }, + { + type: "select", + fields: { + label: "string", + options: "{ label: string, value: string|number }[] — static options", + optionsSource: + "{ query: '', field?: '', label: '', value: '' } — bind the dropdown to a query's rows; label/value name the columns for each option's text/value. Use options OR optionsSource, not both (data layer).", + }, + purpose: "Dropdown selection (static options or query-bound).", + }, + { + type: "button", + fields: { + text: "string", + onClick: "{ run: '' } — runs a named query (data layer)", + }, + purpose: "Trigger an action.", + }, + { + type: "image", + fields: { image: "string url" }, + purpose: "Display an image.", + }, + { + type: "table", + fields: { + data: "{ [column]: string|number|boolean|null }[] — static literal rows", + source: + "{ query: '', field?: '', clearWhenEmpty?: '' } — bind to a query's data, optionally a nested array like 'places'; clearWhenEmpty gates the rows on an input holding text so resetting the input clears the table (data layer). OR { store: '' } — bind to rows accumulated in the Appsmith store by wire_event's appendToStore verb (accumulating-results tables; session-only, cleared by clearStoreKey or a reload).", + columns: + "{ key, label?, type?, hidden?, computed? }[] — explicit column config. key is the data field. type is one of text|number|date|image|video|url|boolean. hidden hides the column. computed is a per-row numeric formula (same formula AST as text `value.formula`, but with a { cell: '' } leaf = this row's cell) that ADDS a derived column — e.g. { key: 'total', computed: { op: 'mul', args: [ { cell: 'price' }, { cell: 'qty' } ] } }. Omit `columns` to let the table auto-derive columns from the data.", + }, + purpose: + "Tabular data with search/sort/pagination. Use `data` for static rows OR `source` to bind a query or a store key (not both). Style alternating rows via patch_widgets oddRowColor/evenRowColor. NOTE: when PATCHING an existing table, this binding is the `tableData` prop (see patchSpec) — `source` on patch_widgets means a selected-row ref.", + }, + { + type: "container", + fields: { children: "widget spec[]" }, + purpose: "Group widgets into a card; nest other widgets inside.", + }, + { + type: "form", + fields: { + submitLabel: "string (submit button text)", + children: "widget spec[] (the form fields)", + }, + purpose: "Group input fields with an auto-added submit button.", + }, + { + type: "modal", + fields: { + title: "string", + children: "widget spec[] (modal body)", + }, + purpose: "An overlay dialog for add/edit/confirm flows.", + }, + { + type: "datepicker", + fields: { label: "string" }, + purpose: "Pick a date/time value.", + }, + { + type: "checkbox", + fields: { + label: "string", + defaultChecked: "boolean — initial checked state (defaults to true)", + }, + purpose: "A single boolean checkbox.", + }, + { + type: "switch", + fields: { + label: "string", + defaultChecked: "boolean — initial on/off state (defaults to true)", + }, + purpose: "A boolean on/off toggle.", + }, + { + type: "radio", + fields: { + label: "string", + options: + "{ label: string, value: string|number }[] — static single-select options", + }, + purpose: "Single-select radio group (choose exactly one option).", + }, + { + type: "multiselect", + fields: { + label: "string", + options: "{ label: string, value: string|number }[] — static options", + }, + purpose: "Multi-select dropdown (choose several options).", + }, + { + type: "filepicker", + fields: { label: "string" }, + purpose: "Upload one or more files.", + }, + { + type: "divider", + fields: { orientation: "'horizontal' (default) | 'vertical'" }, + purpose: "A visual separator line.", + }, + { + type: "progress", + fields: { + value: "number 0-100 — percent complete (defaults to 50)", + infinite: "boolean — a busy/indeterminate bar with no fixed value", + }, + purpose: "A linear progress bar.", + }, + { + type: "circularprogress", + fields: { value: "number 0-100 — percent complete (defaults to 65)" }, + purpose: "A circular progress indicator.", + }, + { + type: "rate", + fields: { + max: "integer 1-10 — number of stars (defaults to 5)", + defaultRate: "number — initial rating", + allowHalf: "boolean — allow half-star values", + readOnly: "boolean — display-only, not editable", + }, + purpose: "A star-rating display/input.", + }, + { + type: "iconbutton", + fields: { + icon: "kebab-case Blueprint icon name (e.g. 'plus', 'trash')", + variant: "'PRIMARY' (default) | 'SECONDARY' | 'TERTIARY'", + }, + purpose: "A compact icon-only button (wire its click with wire_event).", + }, + { + type: "currencyinput", + fields: { + label: "string caption", + placeholder: "string shown when empty", + defaultValue: "number — initial amount", + decimals: "integer 0-6 — fractional digits (defaults to 0)", + currencyCode: "3-letter ISO code, e.g. 'USD' (default), 'EUR'", + }, + purpose: "A currency-formatted number input.", + }, + { + type: "phoneinput", + fields: { + label: "string caption", + placeholder: "string shown when empty", + defaultValue: "string — initial phone number", + allowFormatting: + "boolean — auto-format to national format (default true)", + }, + purpose: "A phone-number input with dial-code prefix.", + }, + { + type: "numberslider", + fields: { + label: "string caption", + min: "number — track start (default 0)", + max: "number — track end (default 100)", + step: "positive number — increment (default 1)", + defaultValue: "number — initial position", + }, + purpose: "A single-value numeric slider.", + }, + { + type: "categoryslider", + fields: { + label: "string caption", + options: "{ label, value }[] — ordered category stops (2-50)", + defaultValue: "string — initial option value (must match an option)", + }, + purpose: "A slider that snaps between named categories.", + }, + { + type: "rangeslider", + fields: { + label: "string caption", + min: "number — track start (default 0)", + max: "number — track end (default 100)", + step: "positive number — increment (default 1)", + defaultStart: "number — initial low handle", + defaultEnd: "number — initial high handle", + }, + purpose: "A two-handle slider selecting a numeric range.", + }, + { + type: "checkboxgroup", + fields: { + label: "string caption", + options: "{ label, value }[] — the checkboxes (1-200)", + defaultSelected: "string[] — option values checked on load", + inline: "boolean — lay out horizontally (default true)", + }, + purpose: "A group of checkboxes for multi-select.", + }, + { + type: "switchgroup", + fields: { + label: "string caption", + options: "{ label, value }[] — the switches (1-200)", + defaultSelected: "string[] — option values on at load", + inline: "boolean — lay out horizontally (default true)", + }, + purpose: "A group of toggle switches for multi-select.", + }, + { + type: "menubutton", + fields: { + label: "string — the button caption (defaults to 'Open Menu')", + variant: "'PRIMARY' (default) | 'SECONDARY' | 'TERTIARY'", + items: + "{ label }[] — menu entries (1-50); wire clicks later with wire_event", + }, + purpose: "A button that opens a dropdown menu of items.", + }, + { + type: "buttongroup", + fields: { + orientation: "'horizontal' (default) | 'vertical'", + variant: "'PRIMARY' (default) | 'SECONDARY' | 'TERTIARY'", + buttons: + "{ label, icon? }[] — the grouped buttons (1-50); icon is a kebab-case Blueprint name; wire clicks later with wire_event", + }, + purpose: "A row/column of related buttons.", + }, + { + type: "camera", + fields: { + mode: "'CAMERA' (photo, default) | 'VIDEO' (recording)", + mirrored: "boolean — mirror the live preview (default true)", + }, + purpose: "Capture a photo or video from the device camera.", + }, + { + type: "audiorecorder", + fields: {}, + purpose: "Record audio from the device microphone.", + }, + { + type: "codescanner", + fields: { + label: "string — the scan button/prompt caption", + scanMode: "'ALWAYS_ON' (default, live) | 'CLICK_TO_SCAN'", + }, + purpose: "Scan a QR code or barcode with the device camera.", + }, + { + type: "map", + fields: { + center: "{ lat, long } — initial map center", + zoom: "number 0-100 — initial zoom (default 50)", + markers: "{ lat, long, title? }[] — static pins (up to 200)", + enableSearch: "boolean — show the location search box (default true)", + allowZoom: "boolean — allow zooming (default true)", + }, + purpose: + "An interactive Google map (needs a tenant Google Maps API key configured in admin settings).", + }, + { + type: "mapchart", + fields: { + title: "string — chart title", + region: + "WORLD (default) | WORLD_WITH_ANTARCTICA | EUROPE | NORTH_AMERICA | SOURTH_AMERICA | ASIA | OCEANIA | AFRICA | USA", + data: "{ id, value, label? }[] — per-region values; id is a region code (e.g. 'NA', 'US-CA')", + }, + purpose: "A choropleth map colouring regions by value.", + }, + { + type: "singleselecttree", + fields: { + label: "string caption", + options: + "{ label, value, children? }[] — a nested option tree (children recurse)", + defaultValue: "string — option value selected on load (must match)", + expandAll: "boolean — expand every branch on load (default false)", + }, + purpose: "A dropdown that picks one node from a hierarchy.", + }, + { + type: "multiselecttree", + fields: { + label: "string caption", + options: + "{ label, value, children? }[] — a nested option tree (children recurse)", + defaultSelected: "string[] — option values selected on load", + expandAll: "boolean — expand every branch on load (default false)", + }, + purpose: "A dropdown that picks several nodes from a hierarchy.", + }, + { + type: "iframe", + fields: { + source: + "required http(s) URL to embed (raw HTML/srcDoc is not supported)", + title: "string caption shown above the frame", + }, + purpose: "Embed an external web page.", + }, + { + type: "video", + fields: { + url: "required http(s) URL to the video", + autoPlay: "boolean — start playing on load (default false)", + }, + purpose: "Play a video from a URL.", + }, + { + type: "audio", + fields: { + url: "required http(s) URL to the audio", + autoPlay: "boolean — start playing on load (default false)", + }, + purpose: "Play audio from a URL.", + }, + { + type: "documentviewer", + fields: { + url: "required http(s) URL to a PDF/DOCX/etc.", + }, + purpose: "Render a document (PDF, DOCX, ...) from a URL.", + }, + { + type: "richtext", + fields: { + label: "string caption", + defaultValue: + "plain-text seed content (no HTML tags — users author rich text at runtime)", + }, + purpose: "A rich-text (WYSIWYG) editor.", + }, + { + type: "jsonform", + fields: { + title: "string form title", + data: "{ [field]: string|number|boolean|null } — a flat object; the form auto-generates one field per key", + submitLabel: "string submit-button text", + }, + purpose: "A form whose fields are auto-generated from a data object.", + }, + { + type: "statbox", + fields: { + title: "string — the metric caption (e.g. 'Page Views')", + value: "string — the headline value (e.g. '2.6 M')", + subtext: "string — a trend/subtitle line", + icon: "kebab-case Blueprint icon name for the corner button", + }, + purpose: "A KPI stat card (caption + value + optional trend/icon).", + }, + { + type: "chart", + fields: { + title: "string", + chartType: + "LINE_CHART | BAR_CHART | PIE_CHART | COLUMN_CHART | AREA_CHART", + series: "{ name?: string, points?: { x, y }[] }[] — static series", + source: + "ONE query source { query, field?, x, y, name? } OR an ARRAY of them for a multi-series chart. x is the category/label column, y the numeric value column, name the series label. Use series OR source, not both (data layer).", + xAxisLabel: "string — x-axis title", + yAxisLabel: "string — y-axis title", + labelOrientation: "'auto' (default) | 'slant' | 'rotate' | 'stagger'", + showDataLabels: + "boolean — show value labels on data points (default false)", + }, + purpose: "Visualize static series data or one/many query series.", + }, + { + type: "tabs", + fields: { + tabs: "{ label: string, children?: widget spec[] }[]", + }, + purpose: "Organize content into tabs, each with its own canvas.", + }, + { + type: "list", + fields: { + source: + "{ query: string, field?: string } (bind to the same query as a table for a shared table/cards view)", + title: "item field name for the card title", + image: "item field name for the card image (optional)", + subtitle: "item field name for the card subtitle (optional)", + pageSize: "cards per page (optional)", + }, + purpose: + "A card grid: repeats an image + title + subtitle card over a data source's rows.", + }, +] as const; + +// Gate under which a tool is registered. Kept as the SINGLE source of truth so get_capabilities can never drift from +// what buildMcpServer actually registers (item J). When a tool is added/removed, update this list. +type ToolGate = + | "always" + | "governance" + | "data" + | "data_governance" + | "js" + | "js_governance"; + +export interface CapabilityGates { + data: boolean; + js: boolean; + governance: boolean; +} + +export const TOOL_CATALOG: { name: string; gate: ToolGate; summary: string }[] = + [ + // Always-on: discovery, spec authoring, and safe reads. + { + name: "list_workspaces", + gate: "always", + summary: "list accessible workspaces as { id, name }", + }, + { + name: "resolve_workspace", + gate: "always", + summary: "resolve a workspace name to its id", + }, + { + name: "list_applications", + gate: "always", + summary: "list applications in a workspace", + }, + { + name: "get_application_context", + gate: "always", + summary: "read a page + layout", + }, + { name: "get_capabilities", gate: "always", summary: "this catalog" }, + { + name: "get_guide", + gate: "always", + summary: + "read a built-in guide/recipe/reference doc as markdown by slug (the same content as the appsmith:// resources, for clients that cannot read MCP resources)", + }, + { name: "list_presets", gate: "always", summary: "ready page specs" }, + { name: "get_preset", gate: "always", summary: "get a preset page spec" }, + { + name: "validate_app_spec", + gate: "always", + summary: "dry-run compile a spec", + }, + { + name: "build_application", + gate: "always", + summary: + "create an app from a spec (auto-deployed on creation; returns editor/viewer URLs)", + }, + { name: "edit_page", gate: "always", summary: "append widgets to a page" }, + { + name: "patch_widgets", + gate: "always", + summary: "typed widget update/move/remove", + }, + { + name: "wire_event", + gate: "always", + summary: + "wire a widget event to a safe action or a 2-5 statement list (chainable onSuccess/onError; appendToStore/clearStoreKey accumulate query rows in the store)", + }, + { name: "inspect_page", gate: "always", summary: "lint a live page" }, + { + name: "read_semantic_page", + gate: "always", + summary: "safe page read + revision", + }, + { + name: "read_pages", + gate: "always", + summary: "safe page list + revision", + }, + { + name: "read_theme", + gate: "always", + summary: "safe theme tokens + revision", + }, + { + name: "read_publish_status", + gate: "always", + summary: "publish state + revision", + }, + { + name: "read_git_status", + gate: "always", + summary: + "safe git status projection (branch, clean/dirty + modified-entity counts, protected branches, remote host only; compareRemote opt-in for ahead/behind)", + }, + // Governed mutations + audit/rollback + publish (require Mongo+Redis governance). + { + name: "update_theme", + gate: "governance", + summary: "change theme tokens (governed)", + }, + { + name: "create_page", + gate: "governance", + summary: "create a blank page (governed)", + }, + { + name: "rename_page", + gate: "governance", + summary: "rename a page (governed)", + }, + { + name: "prepare_delete_page", + gate: "governance", + summary: "prepare page delete (confirmation)", + }, + { + name: "confirm_delete_page", + gate: "governance", + summary: + "delete a page with a token; prompts the user for approval via elicitation when the client supports it", + }, + { name: "list_changes", gate: "governance", summary: "audit history" }, + { + name: "list_all_changes", + gate: "governance", + summary: "admin-only cross-actor audit history", + }, + { + name: "get_any_change", + gate: "governance", + summary: "admin-only cross-actor audit record", + }, + { name: "get_change", gate: "governance", summary: "one audit record" }, + { + name: "get_change_diff", + gate: "governance", + summary: "semantic change summary", + }, + { + name: "prepare_rollback", + gate: "governance", + summary: "prepare a layout rollback", + }, + { + name: "confirm_rollback", + gate: "governance", + summary: + "roll back a layout change; prompts the user for approval via elicitation when the client supports it", + }, + { + name: "prepare_publish", + gate: "governance", + summary: "prepare re-publish of an existing app (confirmation)", + }, + { + name: "confirm_publish", + gate: "governance", + summary: + "re-deploy an existing app with a token; prompts the user for approval via elicitation when the client supports it", + }, + { + name: "create_branch", + gate: "governance", + summary: + "create an agent git branch under the reserved mcp/ namespace — PUSHES the new ref to the remote and returns the NEW branched applicationId (governed)", + }, + { + name: "prepare_commit", + gate: "governance", + summary: + "prepare a git commit on an mcp/ agent branch: one-time confirmation bound to the app, branch, message, and content revision — the commit API always PUSHES to the remote (governed)", + }, + { + name: "confirm_commit", + gate: "governance", + summary: + "commit AND PUSH with a confirmation token after a fresh mcp/-branch re-check; prompts the user for approval via elicitation when the client supports it (governed)", + }, + // Data layer (APPSMITH_MCP_DATA_ENABLED). + { name: "list_datasources", gate: "data", summary: "discover datasources" }, + { + name: "create_datasource", + gate: "data", + summary: + "create a DB (Postgres/MySQL/MSSQL/Mongo, unconfigured) or REST (base URL) datasource; no credentials (Sheets needs UI OAuth)", + }, + { + name: "get_datasource_structure", + gate: "data", + summary: "datasource tables/columns", + }, + { name: "list_actions", gate: "data", summary: "safe action metadata" }, + { name: "create_query", gate: "data", summary: "structured SQL query" }, + { + name: "create_mongo_query", + gate: "data", + summary: "structured MongoDB find/insert query", + }, + { + name: "create_sheets_query", + gate: "data", + summary: "structured Google Sheets read/append query", + }, + { + name: "create_redis_query", + gate: "data", + summary: "structured Redis command (closed vocabulary)", + }, + { + name: "create_ai_query", + gate: "data", + summary: "structured OpenAI/Anthropic/Google AI chat query", + }, + { + name: "create_s3_query", + gate: "data", + summary: "structured Amazon S3 file action (list/read/upload/delete)", + }, + { + name: "create_graphql_query", + gate: "data", + summary: "structured GraphQL query/mutation (parameterized variables)", + }, + { + name: "create_rest_api", + gate: "data", + summary: "structured REST action", + }, + { + name: "get_action", + gate: "data", + summary: "safe action read + revision", + }, + { + name: "run_action", + gate: "data", + summary: "run a read-only action", + }, + // Data + governance. + { + name: "update_action", + gate: "data_governance", + summary: "update an action (governed)", + }, + { + name: "duplicate_action", + gate: "data_governance", + summary: "duplicate an action (governed)", + }, + { + name: "prepare_delete_action", + gate: "data_governance", + summary: "prepare action delete", + }, + { + name: "confirm_delete_action", + gate: "data_governance", + summary: + "delete an action with a token; prompts the user for approval via elicitation when the client supports it", + }, + { + name: "prepare_run_action", + gate: "data_governance", + summary: "prepare a confirmed action run", + }, + { + name: "confirm_run_action", + gate: "data_governance", + summary: + "run an action with a token; prompts the user for approval via elicitation when the client supports it", + }, + // Restricted JS objects (APPSMITH_MCP_JS_ENABLED). + { + name: "read_js_object", + gate: "js", + summary: "safe JS-object metadata + revisions", + }, + { + name: "create_js_object", + gate: "js_governance", + summary: "create a restricted JS object (governed)", + }, + { + name: "update_js_object", + gate: "js_governance", + summary: "update a restricted JS object (governed)", + }, + { + name: "prepare_delete_js_object", + gate: "js_governance", + summary: "prepare JS-object delete", + }, + { + name: "confirm_delete_js_object", + gate: "js_governance", + summary: + "delete a JS object with a token; prompts the user for approval via elicitation when the client supports it", + }, + ]; + +function gateActive(gate: ToolGate, gates: CapabilityGates): boolean { + switch (gate) { + case "always": + return true; + case "governance": + return gates.governance; + case "data": + return gates.data; + case "data_governance": + return gates.data && gates.governance; + case "js": + return gates.js; + case "js_governance": + return gates.js && gates.governance; + } +} + +// What each (non-always) gate requires to be enabled, and what it unlocks — so an agent can SEE a capability that is +// currently off and tell the user exactly which setting turns it on, instead of assuming the server simply can't do it. +// `requires` is phrased as a HUMAN-facing instruction (so an agent relaying it gives an end user something they can +// act on), with the exact self-hosted env var / backend in parentheses. On Appsmith Cloud the end user cannot set +// these — the ask still correctly points at "your Appsmith administrator". +const GATE_REQUIREMENTS: Record< + Exclude, + { requires: string; provides: string } +> = { + governance: { + requires: + "ask your Appsmith administrator to configure MCP governance (a Mongo + Redis backend)", + // Publish-on-create is NOT gated here: build_application auto-deploys the app it just created on every + // deployment. Governance gates RE-publishing existing apps (prepare_publish/confirm_publish). + provides: + "governed edits, page create/delete, re-publish of existing apps (new apps auto-deploy on creation), agent git branches (create_branch), git commits on mcp/ branches (prepare_commit/confirm_commit), audit history, rollback", + }, + data: { + requires: + "ask your Appsmith administrator to enable the MCP data layer (self-hosted: env APPSMITH_MCP_DATA_ENABLED)", + provides: "datasources, SQL/REST queries, action reads and read-only runs", + }, + data_governance: { + requires: + "ask your Appsmith administrator to enable the MCP data layer and configure governance (self-hosted: env APPSMITH_MCP_DATA_ENABLED plus a Mongo + Redis backend)", + provides: "governed action edits/deletes and confirmed action runs", + }, + js: { + requires: + "ask your Appsmith administrator to enable MCP JS objects (self-hosted: env APPSMITH_MCP_JS_ENABLED)", + provides: "restricted (declarative) JS objects", + }, + js_governance: { + requires: + "ask your Appsmith administrator to enable MCP JS objects and configure governance (self-hosted: env APPSMITH_MCP_JS_ENABLED plus a Mongo + Redis backend)", + provides: "governed JS-object edits/deletes", + }, +}; + +// The capability groups that are NOT registered under the current gates, grouped by the setting that enables them, +// each listing the exact tool names it would add. Surfacing this (rather than silently hiding the tools) lets an +// agent advise the user how to unlock data-backed, JS, or governed features. +function disabledCapabilities(gates: CapabilityGates) { + const groups = new Map< + string, + { requires: string; provides: string; tools: string[] } + >(); + + for (const tool of TOOL_CATALOG) { + if (tool.gate === "always" || gateActive(tool.gate, gates)) continue; + + const requirement = GATE_REQUIREMENTS[tool.gate]; + const group = groups.get(tool.gate) ?? { ...requirement, tools: [] }; + + group.tools.push(tool.name); + groups.set(tool.gate, group); + } + + return [...groups.values()]; +} + +export function getCapabilities( + gates: CapabilityGates = { data: false, js: false, governance: false }, +) { + const disabledGroups = disabledCapabilities(gates); + + return { + description: + "Build and safely modify Appsmith apps. The MCP layer auto-places widgets on a 64-column grid and compiles to " + + "real Appsmith artifacts via the ACL-enforced API. You never author raw widget DSL, SQL, or bindings.", + widgets: WIDGET_CATALOG, + common: { + name: "optional widget name (alphanumeric/underscore); auto-named if omitted", + placement: + "optional { after: '' } or { inside: '' }; omitted appends below existing content (best-effort)", + }, + pageSpec: { + name: "page name", + widgets: "widget spec[] (see `widgets`)", + }, + appSpec: { name: "application name", pages: "page spec[]" }, + editSpec: { add: "widget spec[] — appended to an existing page" }, + // The patch_widgets vocabulary. Deliberately enumerated here because the prop names differ from the + // build/edit spec in one place (tableData vs source) and agents are steered to this document first. + patchSpec: { + shape: + "{ operations: [{ kind: 'update', name: '', props: {...} } | { kind: 'move', name, parent?, position?, strict? } | { kind: 'resize', name, rows?, columns?, strict? } | { kind: 'remove', name }] }", + move: "parent: '' reparents; position: { topRow, leftColumn } places on the page grid (rows/columns, see `grid`) — set the same topRow as a sibling to sit widgets side by side. At least one of parent/position is required. NOT the build spec's placement { after/inside } — read the page first to learn current positions. Occupancy-aware: a position that lands on another widget is REPAIRED to the nearest free spot below (the change records requestedPosition vs the applied position, and the result carries a note) unless strict: true, which rejects with the colliding widget names and the nearest free position so one retry can succeed. Reparenting always lands at the nearest free spot in the destination canvas and the landing position is recorded in changes (it is no longer carried over blindly). A modal (or a subtree containing one) can never be moved under another modal's canvas — modals are page-level overlays.", + resize: + "{ kind: 'resize', name, rows?, columns?, strict? } — set a widget's span in grid units (integers >= 1; at least one of rows/columns). Growing into occupied cells pushes the colliding below-siblings down (strict: true rejects with their names instead); width growth past the canvas is rejected with the available columns; shrinking a container/form/tabs below its children is rejected with the smallest size that fits them. Modals translate rows into their pixel height (rows × grid.rowHeightPx) — modal columns are not resizable.", + overlapPolicy: + "Any layout mutation whose result would INTRODUCE overlapping widgets is rejected with code 'overlap_introduced', the offending name pairs, and a ready-to-apply suggestedFix ({ tool: 'patch_widgets', operations: [...] }). Pre-existing overlaps on a page never block edits. Containers/forms/tabs auto-grow to fit new content and push widgets below them down; every automatic adjustment is reported in changes/notes.", + updateProps: { + literals: + "text, label, inputType, options, title, image, chartType, chartName, defaultText, placeholderText, dateFormat, isRequired, isDisabled, isVisible, oddRowColor, evenRowColor, isVisibleSearch, enableClientSideSearch, isVisibleFilters, isSortable, isVisibleDownload, isVisiblePagination", + tableData: + "{ query: '', field?: '', clearWhenEmpty?: '' } OR { store: '' } — bind an EXISTING table's rows to a query, or to a store key accumulated by wire_event's appendToStore (session-only). This is the patch-path name for the build spec's `source`; read_semantic_page also reports it as tableData.", + source: + "{ table: '', column: '' } — selected-row display binding on a text widget. NOT the table data binding (use tableData for that).", + defaultValue: + "{ table: '
', column: '' } — selected-row prefill on an input widget", + visibleWhen: + "{ control: '', equals: } | { rowSelected: '
' } | { notEmpty: '' }", + validation: "input validation spec", + disableWhenInvalid: "boolean (button)", + }, + }, + presets: listPresets(), + grid: { columns: GRID_COLUMNS, rowHeightPx: ROW_HEIGHT }, + // Generated from the single TOOL_CATALOG so this list can never drift from what is actually registered. + tools: TOOL_CATALOG.filter((tool) => gateActive(tool.gate, gates)).map( + (tool) => `${tool.name} — ${tool.summary}`, + ), + gates: { + dataLayer: gates.data, + restrictedJsObjects: gates.js, + governance: gates.governance, + }, + // Capabilities that EXIST in this server but are not registered under the current configuration. If the user asks + // for something here (datasources, queries, JS objects, publish, ...), don't say it's impossible — relay each + // group's 'requires' instruction so they know how to unlock it. + disabledCapabilities: { + note: + disabledGroups.length === 0 + ? "All capability groups are enabled." + : "Not available under this deployment's configuration. To use one of these, follow the group's 'requires' instruction (ask your Appsmith administrator), then reconnect.", + groups: disabledGroups, + }, + governanceNote: gates.governance + ? "Mutations are locked, revision-checked, and audited; destructive/high-impact operations require a one-time confirmation token, and every confirm_* tool prompts the user for approval via elicitation when the client supports it (otherwise show the user the prepare_* relay text and get their approval first — a declined prompt never consumes the token). Publish-on-create is automatic (build_application deploys the app it just created, recorded in the audit trail); governance gates RE-publishing existing apps via prepare_publish/confirm_publish." + : "Governance (Mongo+Redis) is not configured, so governed and destructive tools are not registered. build_application still auto-deploys the app it just created; RE-publishing an existing app after edits requires governance.", + workflows: { + available: false, + note: "CE workflow tools are not available via MCP.", + }, + gitSync: { + available: true, + note: "read_git_status (always on) reads a safe git status projection. Every mutation on a git-connected app REQUIRES a 'branch' parameter equal to the target app's current branch (fail-closed; the error carries the current branch). create_branch (governed) creates an agent branch under the reserved mcp/ namespace — it PUSHES the new ref to the remote and returns the NEW branched applicationId to edit (branch-per-application; no checkout). prepare_commit/confirm_commit (governed) commit AND PUSH — allowed ONLY on mcp/ agent branches (verified by a fresh read at confirm time), with the user's approval (an elicitation prompt when the client supports it; otherwise you must relay the prepare_commit text and get approval first). Publishing from MCP stays disabled for git apps: the deliverable is the mcp/ branch + its review URL, which the user merges via Appsmith's branch UI or a PR on the remote. See appsmith://guide/git.", + }, + resources: [ + "appsmith://reference/widgets — the widget catalog as a resource", + "appsmith://guide/{placement,naming,bindings,git,screenshot} — technique guides (read the screenshot guide BEFORE recreating any screenshot/mockup the user shows you)", + "appsmith://recipe/{crud,form,table-detail,zip-lookup} — end-to-end build walkthroughs", + "Every doc above is also readable through the get_guide tool by slug (for clients that cannot read MCP resources).", + ], + prompts: [ + "scaffold_crud — guided workflow to build a CRUD page from an entity + fields", + "scaffold_form — guided workflow to build a form page", + "recreate_from_screenshot — guided workflow to rebuild a screenshot/mockup the user shared", + ], + }; +} diff --git a/app/client/packages/mcp/src/builder/catalog.test.ts b/app/client/packages/mcp/src/builder/catalog.test.ts new file mode 100644 index 000000000000..d10768449d76 --- /dev/null +++ b/app/client/packages/mcp/src/builder/catalog.test.ts @@ -0,0 +1,962 @@ +import { compileApp } from "./compile.js"; +import { sequentialIdGenerator, type WidgetNode } from "./layout.js"; +import { lintDsl } from "./lint.js"; +import type { WidgetSpec } from "./schema.js"; +import { + appSpecSchema, + compileChartDataBinding, + compileCurrentItemBinding, + compilePrimaryKeys, + compileTableDataBinding, + storeKeySchema, +} from "./schema.js"; + +function build(widgets: WidgetSpec[]): WidgetNode { + const artifact = compileApp( + { name: "App", pages: [{ name: "Home", widgets }] }, + sequentialIdGenerator(), + ); + const pageList = artifact.pageList as { + unpublishedPage: { layouts: { dsl: WidgetNode }[] }; + }[]; + + return pageList[0].unpublishedPage.layouts[0].dsl; +} + +function children(node: WidgetNode): WidgetNode[] { + return (node.children ?? []) as WidgetNode[]; +} + +describe("M3 catalog — new widgets", () => { + it("compiles an input with named-format validation (vetted regex + required)", () => { + const [input] = children( + build([ + { type: "input", label: "Zip", validation: { format: "zipcode" } }, + ]), + ); + + expect(input.type).toBe("INPUT_WIDGET_V2"); + expect(input.regex).toBe("^\\d{5}$"); + expect(input.errorMessage).toBe("Please enter a 5-digit zip code"); + expect(input.isRequired).toBe(true); + }); + + it("leaves an un-validated input optional (isRequired false, no regex)", () => { + const [input] = children(build([{ type: "input", label: "Name" }])); + + expect(input.type).toBe("INPUT_WIDGET_V2"); + expect(input.isRequired).toBe(false); + expect(input.regex).toBeUndefined(); + expect(input.errorMessage).toBeUndefined(); + }); + + it("compiles a datepicker leaf to the correct type/version", () => { + const [picker] = children(build([{ type: "datepicker", label: "When" }])); + + expect(picker.type).toBe("DATE_PICKER_WIDGET2"); + expect(picker.version).toBe(2); + expect(picker.label).toBe("When"); + // Leaf: no inner canvas. + expect(picker.children).toBeUndefined(); + }); + + it("compiles a modal as a container-like widget with an inner canvas", () => { + const [modal] = children( + build([ + { + type: "modal", + title: "Edit", + children: [{ type: "text", text: "Body" }], + }, + ]), + ); + + expect(modal.type).toBe("MODAL_WIDGET"); + expect(modal.title).toBe("Edit"); + const inner = children(modal)[0]; + + expect(inner.type).toBe("CANVAS_WIDGET"); + expect(children(inner).map((c) => c.type)).toContain("TEXT_WIDGET"); + // Height invariant (same as container): box encloses inner extent. + expect(modal.bottomRow).toBe(modal.topRow + inner.bottomRow); + }); + + it("compiles a form and auto-adds a submit button after the fields", () => { + const [form] = children( + build([ + { + type: "form", + submitLabel: "Save", + children: [{ type: "input", label: "Name" }], + }, + ]), + ); + + expect(form.type).toBe("FORM_WIDGET"); + const inner = children(form)[0]; + const kinds = children(inner).map((c) => c.type); + + expect(kinds).toEqual(["INPUT_WIDGET_V2", "BUTTON_WIDGET"]); + const button = children(inner).find((c) => c.type === "BUTTON_WIDGET")!; + + expect(button.text).toBe("Save"); + // Event stub: inert onClick, no dynamic trigger. + expect(button.onClick).toBe(""); + expect(form.bottomRow).toBe(form.topRow + inner.bottomRow); + }); + + it("produces lint-clean output for every new widget type", () => { + const dsl = build([ + { type: "datepicker", label: "When" }, + { type: "modal", title: "M", children: [{ type: "text", text: "x" }] }, + { + type: "form", + children: [{ type: "input", label: "A" }], + placement: { after: "DatePicker" }, + }, + ]); + const diagnostics = lintDsl(dsl); + + expect(diagnostics.errors).toBe(0); + expect(diagnostics.warnings).toBe(0); + }); + + it("rejects malformed props on the new arms (M3)", () => { + // form.submitLabel must be safe text + expect( + appSpecSchema.safeParse({ + name: "App", + pages: [ + { + name: "P", + widgets: [{ type: "form", submitLabel: "{{evil()}}" }], + }, + ], + }).success, + ).toBe(false); + // unknown prop on datepicker is rejected (strict object) + expect( + appSpecSchema.safeParse({ + name: "App", + pages: [ + { + name: "P", + widgets: [{ type: "datepicker", bogus: 1 }], + }, + ], + }).success, + ).toBe(false); + }); +}); + +describe("M3b catalog — chart, tabs, list", () => { + it("compiles a chart leaf with static series data", () => { + const [chart] = children( + build([ + { + type: "chart", + title: "Sales", + chartType: "BAR_CHART", + series: [{ name: "Q1", points: [{ x: "Jan", y: 10 }] }], + }, + ]), + ); + + expect(chart.type).toBe("CHART_WIDGET"); + expect(chart.chartType).toBe("BAR_CHART"); + expect(chart.children).toBeUndefined(); + const chartData = chart.chartData as Record< + string, + { seriesName: string; data: unknown[] } + >; + + expect(chartData.series1.seriesName).toBe("Q1"); + expect(chartData.series1.data).toEqual([{ x: "Jan", y: 10 }]); + }); + + it("compiles tabs into one inner canvas per tab plus a tabsObj", () => { + const [tabs] = children( + build([ + { + type: "tabs", + tabs: [ + { label: "One", children: [{ type: "text", text: "a" }] }, + { label: "Two", children: [{ type: "input", label: "b" }] }, + ], + }, + ]), + ); + + expect(tabs.type).toBe("TABS_WIDGET"); + const canvases = children(tabs); + + expect(canvases).toHaveLength(2); + expect(canvases.every((c) => c.type === "CANVAS_WIDGET")).toBe(true); + expect(canvases.map((c) => c.tabName)).toEqual(["One", "Two"]); + // tabsObj has one entry per tab, each pointing at its canvas. + const tabsObj = tabs.tabsObj as Record; + + expect(Object.keys(tabsObj)).toHaveLength(2); + expect(Object.values(tabsObj).map((t) => t.widgetId)).toEqual( + canvases.map((c) => c.widgetId), + ); + }); + + it("compiles a card grid (list) as the 4-level List-Widget-V2 structure", () => { + const [list] = children( + build([ + { + type: "list", + source: { query: "getUsers" }, + image: "avatar", + title: "name", + subtitle: "email", + }, + ]), + ); + + expect(list.type).toBe("LIST_WIDGET_V2"); + // list -> main canvas -> item container(isListItemContainer) -> inner canvas -> template widgets. + const mainCanvas = children(list)[0]; + + expect(mainCanvas.type).toBe("CANVAS_WIDGET"); + const itemContainer = children(mainCanvas)[0]; + + expect(itemContainer.type).toBe("CONTAINER_WIDGET"); + expect(itemContainer.isListItemContainer).toBe(true); + // ids cross-reference: list points at the generated main canvas + item container. + expect(list.mainCanvasId).toBe(mainCanvas.widgetId); + expect(list.mainContainerId).toBe(itemContainer.widgetId); + const innerCanvas = children(itemContainer)[0]; + + expect(innerCanvas.type).toBe("CANVAS_WIDGET"); + expect(children(innerCanvas).map((c) => c.type)).toEqual([ + "IMAGE_WIDGET", + "TEXT_WIDGET", + "TEXT_WIDGET", + ]); + // Per-item bindings are compiler-authored. + const [img, title] = children(innerCanvas); + + expect(img.image).toBe('{{ currentItem["avatar"] }}'); + expect(title.text).toBe('{{ currentItem["name"] }}'); + expect(list.listData).toBe("{{ getUsers.data ?? [] }}"); + }); + + it("is lint-clean for chart, tabs, and list", () => { + const dsl = build([ + { type: "chart", series: [{ name: "s", points: [{ x: 1, y: 2 }] }] }, + { + type: "tabs", + tabs: [{ label: "T", children: [{ type: "text", text: "x" }] }], + }, + { type: "list", source: { query: "getUsers" }, title: "name" }, + ]); + + expect(lintDsl(dsl).errors).toBe(0); + }); + + // A card grid binds its slots to `{{ currentItem["field"] }}`. In the real flow (lintLiveDsl) the linter runs with + // knownDataNames populated, which is when unknown binding heads are flagged as dangling. `currentItem` (and its + // list-scope siblings) must be recognized so the compiled card grid does not report false dangling-binding errors. + it("card grid is lint-clean under knownDataNames (currentItem is a valid binding head)", () => { + const dsl = build([ + { + type: "list", + source: { query: "getUsers", field: "users" }, + image: "image", + title: "firstName", + subtitle: "email", + }, + ]); + + // The masking case (no knownDataNames) AND the real case (knownDataNames populated) must both be clean. + expect(lintDsl(dsl).errors).toBe(0); + expect(lintDsl(dsl, { knownDataNames: ["getUsers"] }).errors).toBe(0); + }); + + it("compiles a title-only card grid (no image/subtitle) with just a title slot", () => { + const [list] = children( + build([{ type: "list", source: { query: "getUsers" }, title: "name" }]), + ); + const innerCanvas = children(children(children(list)[0])[0])[0]; + + expect(children(innerCanvas).map((c) => c.type)).toEqual(["TEXT_WIDGET"]); + expect(children(innerCanvas)[0].text).toBe('{{ currentItem["name"] }}'); + }); + + it("binds card slots by exact field (spaces allowed) and registers each as a dynamic path", () => { + const [list] = children( + build([ + { + type: "list", + source: { query: "getUsers", field: "data.items" }, + image: "Photo URL", + title: "Full Name", + }, + ]), + ); + + // source with a nested field path compiles to the optional-chained query binding. + expect(list.listData).toBe("{{ getUsers.data?.data.items ?? [] }}"); + // primaryKeys keys by row index, referencing only the list's own (allocated) name. + expect(list.primaryKeys).toBe( + compilePrimaryKeys(list.widgetName as string), + ); + + const innerCanvas = children(children(children(list)[0])[0])[0]; + const [img, title] = children(innerCanvas); + + // Field with a space compiles to a bracket-access binding and is registered as dynamic (else it renders literal). + expect(img.image).toBe('{{ currentItem["Photo URL"] }}'); + expect(img.dynamicBindingPathList).toEqual([{ key: "image" }]); + expect(title.text).toBe('{{ currentItem["Full Name"] }}'); + expect(title.dynamicBindingPathList).toEqual([{ key: "text" }]); + }); + + it("rejects a card item field containing binding-escape characters", () => { + for (const badField of ['a"]}}', "a`b", "a${b}", "a}b", "a]b"]) { + const result = appSpecSchema.safeParse({ + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { type: "list", source: { query: "q" }, title: badField }, + ], + }, + ], + }); + + expect(result.success).toBe(false); + } + }); + + it("compileCurrentItemBinding emits an un-escapable bracket-access binding", () => { + expect(compileCurrentItemBinding("first name")).toBe( + '{{ currentItem["first name"] }}', + ); + }); +}); + +describe("M3b theming — validated tokens applied to widgets", () => { + function buildThemed(theme: unknown, widgets: WidgetSpec[]): WidgetNode { + const artifact = compileApp( + { name: "App", theme, pages: [{ name: "Home", widgets }] } as never, + sequentialIdGenerator(), + ); + const pageList = artifact.pageList as { + unpublishedPage: { layouts: { dsl: WidgetNode }[] }; + }[]; + + return pageList[0].unpublishedPage.layouts[0].dsl; + } + + it("applies primaryColor to buttons, borderRadius broadly, fontFamily to text", () => { + const theme = { + primaryColor: "#123456", + borderRadius: "0.5rem", + fontFamily: "Inter", + }; + const [text, button] = children( + buildThemed(theme, [ + { type: "text", text: "Hi" }, + { type: "button", text: "Go" }, + ]), + ); + + expect(button.buttonColor).toBe("#123456"); + expect(button.borderRadius).toBe("0.5rem"); + expect(text.fontFamily).toBe("Inter"); + expect(text.borderRadius).toBe("0.5rem"); + }); + + it("rejects an invalid theme (non-hex color / raw expression)", () => { + expect( + appSpecSchema.safeParse({ + name: "App", + theme: { primaryColor: "red; {{evil}}" }, + pages: [{ name: "P", widgets: [{ type: "text", text: "x" }] }], + }).success, + ).toBe(false); + }); + + it("changes nothing when no theme is given", () => { + const [button] = children( + buildThemed(undefined, [{ type: "button", text: "Go" }]), + ); + + expect(button.buttonColor).toBeUndefined(); + }); +}); + +describe("M4 binding vocabulary — safe query bindings only", () => { + it("compiles table.source to a data binding and registers the path", () => { + const [table] = children( + build([{ type: "table", name: "T", source: { query: "getUsers" } }]), + ); + + expect(table.tableData).toBe("{{ getUsers.data ?? [] }}"); + expect(table.dynamicBindingPathList).toEqual([{ key: "tableData" }]); + }); + + it("compiles button.onClick to a run() trigger and registers the path", () => { + const [button] = children( + build([{ type: "button", text: "Save", onClick: { run: "insertRow" } }]), + ); + + expect(button.onClick).toBe("{{ insertRow.run() }}"); + expect(button.dynamicTriggerPathList).toEqual([{ key: "onClick" }]); + }); + + it("keeps an unbound button inert (stub, no trigger path)", () => { + const [button] = children(build([{ type: "button", text: "X" }])); + + expect(button.onClick).toBe(""); + expect(button.dynamicTriggerPathList).toEqual([]); + }); + + it("rejects a query reference that is not a plain identifier", () => { + for (const bad of ["{{evil}}", "a.b", "a b", "a()", "$x"]) { + expect( + appSpecSchema.safeParse({ + name: "App", + pages: [ + { + name: "P", + widgets: [{ type: "table", source: { query: bad } }], + }, + ], + }).success, + ).toBe(false); + } + }); + + it("rejects a table that sets both static data and a query source", () => { + // Schema accepts the shape; the compiler rejects the conflict. + expect(() => + build([ + { type: "table", data: [{ id: 1 }], source: { query: "getUsers" } }, + ]), + ).toThrow(/both 'data' and 'source'/); + }); + + it("emits a binding the dangling-binding lint accepts when the query is known", () => { + const dsl = build([{ type: "table", source: { query: "getUsers" } }]); + + expect(lintDsl(dsl, { knownDataNames: ["getUsers"] }).errors).toBe(0); + expect( + lintDsl(dsl, { knownDataNames: ["other"] }).issues.map((i) => i.rule), + ).toContain("dangling-binding"); + }); +}); + +describe("M4-T1 — chart & select query sources (compiler-authored, un-escapable)", () => { + it("compiles chart.source to a mapped chartData binding and registers chartData.series1.data", () => { + const [chart] = children( + build([ + { + type: "chart", + title: "Revenue", + chartType: "COLUMN_CHART", + source: { query: "getSales", x: "month", y: "revenue" }, + }, + ]), + ); + + expect(chart.type).toBe("CHART_WIDGET"); + const chartData = chart.chartData as Record< + string, + { seriesName: string; data: unknown } + >; + + // Only the series' data is a binding; seriesName is the static title. + expect(chartData.series1.seriesName).toBe("Revenue"); + expect(chartData.series1.data).toBe( + '{{ getSales.data?.map((row) => ({ x: row["month"], y: row["revenue"] })) ?? [] }}', + ); + // Registered at the widget's own nested binding-path format. + expect(chart.dynamicBindingPathList).toEqual([ + { key: "chartData.series1.data" }, + ]); + }); + + it("compiles chart.source with a nested response field via optional chaining", () => { + const [chart] = children( + build([ + { + type: "chart", + source: { + query: "getSales", + field: "data.rows", + x: "Month Name", + y: "Total", + }, + }, + ]), + ); + const chartData = chart.chartData as Record; + + expect(chartData.series1.data).toBe( + '{{ getSales.data?.data.rows?.map((row) => ({ x: row["Month Name"], y: row["Total"] })) ?? [] }}', + ); + }); + + it("keeps a static-series chart unbound (no dynamic path)", () => { + const [chart] = children( + build([ + { type: "chart", series: [{ name: "s", points: [{ x: 1, y: 2 }] }] }, + ]), + ); + + expect(chart.dynamicBindingPathList).toEqual([]); + }); + + it("rejects a chart that sets both static series and a query source", () => { + expect(() => + build([ + { + type: "chart", + series: [{ points: [{ x: 1, y: 2 }] }], + source: { query: "getSales", x: "a", y: "b" }, + }, + ]), + ).toThrow(/both 'series' and 'source'/); + }); + + it("compileChartDataBinding emits an un-escapable bracket-access map", () => { + expect( + compileChartDataBinding({ query: "q", x: "col a", y: "col b" }), + ).toBe( + '{{ q.data?.map((row) => ({ x: row["col a"], y: row["col b"] })) ?? [] }}', + ); + }); + + it("rejects a chart axis column containing binding-escape characters", () => { + for (const badCol of ['a"]}}', "a`b", "a${b}", "a}b", "a]b", "a\\b"]) { + const asX = appSpecSchema.safeParse({ + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { type: "chart", source: { query: "q", x: badCol, y: "y" } }, + ], + }, + ], + }); + const asY = appSpecSchema.safeParse({ + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { type: "chart", source: { query: "q", x: "x", y: badCol } }, + ], + }, + ], + }); + + expect(asX.success).toBe(false); + expect(asY.success).toBe(false); + } + }); + + it("compiles select.optionsSource to a sourceData binding + literal label/value props", () => { + const [select] = children( + build([ + { + type: "select", + label: "City", + optionsSource: { + query: "getCities", + label: "city name", + value: "id", + }, + }, + ]), + ); + + expect(select.type).toBe("SELECT_WIDGET"); + expect(select.sourceData).toBe("{{ getCities.data ?? [] }}"); + // Column names become literal props (not bindings). + expect(select.optionLabel).toBe("city name"); + expect(select.optionValue).toBe("id"); + // A query source is a real binding AND a JS-toggled property. + expect(select.dynamicBindingPathList).toEqual([{ key: "sourceData" }]); + expect(select.dynamicPropertyPathList).toEqual([{ key: "sourceData" }]); + }); + + it("compiles select.optionsSource with a nested response field", () => { + const [select] = children( + build([ + { + type: "select", + optionsSource: { + query: "getCities", + field: "results", + label: "name", + value: "id", + }, + }, + ]), + ); + + expect(select.sourceData).toBe("{{ getCities.data?.results ?? [] }}"); + }); + + it("keeps a static-options select unbound (JSON sourceData, no binding path)", () => { + const [select] = children( + build([ + { + type: "select", + options: [{ label: "A", value: "1" }], + }, + ]), + ); + + expect(JSON.parse(select.sourceData as string)).toEqual([ + { label: "A", value: "1" }, + ]); + expect(select.optionLabel).toBe("label"); + expect(select.dynamicBindingPathList).toEqual([]); + }); + + it("rejects a select that sets both static options and a query source", () => { + expect(() => + build([ + { + type: "select", + options: [{ label: "A", value: "1" }], + optionsSource: { query: "q", label: "l", value: "v" }, + }, + ]), + ).toThrow(/both 'options' and 'optionsSource'/); + }); + + it("rejects a select option column containing binding-escape characters", () => { + for (const badCol of ['a"]}}', "a`b", "a${b}", "a}b", "a]b", "a\\b"]) { + const asLabel = appSpecSchema.safeParse({ + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { + type: "select", + optionsSource: { query: "q", label: badCol, value: "v" }, + }, + ], + }, + ], + }); + const asValue = appSpecSchema.safeParse({ + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { + type: "select", + optionsSource: { query: "q", label: "l", value: badCol }, + }, + ], + }, + ], + }); + + expect(asLabel.success).toBe(false); + expect(asValue.success).toBe(false); + } + }); + + it("chart & select query sources are lint-clean under populated knownDataNames", () => { + const dsl = build([ + { + type: "chart", + source: { query: "getSales", x: "month", y: "revenue" }, + }, + { + type: "select", + optionsSource: { query: "getCities", label: "name", value: "id" }, + }, + ]); + + // No allowlisting was needed: the chart binding lives in the nested chartData object (not a scanned string leaf), + // and the select's sourceData binding head is the query identifier, resolved against knownDataNames. + expect(lintDsl(dsl).errors).toBe(0); + expect( + lintDsl(dsl, { knownDataNames: ["getSales", "getCities"] }).errors, + ).toBe(0); + // The select binding is genuinely checked: an unknown-only name set flags it as dangling. + expect( + lintDsl(dsl, { knownDataNames: ["other"] }).issues.map((i) => i.rule), + ).toContain("dangling-binding"); + }); +}); + +describe("M5 store accumulation — table store binding (build path)", () => { + it("compiles table.source = { store } to the store binding and registers the path", () => { + const [table] = children( + build([{ type: "table", name: "T", source: { store: "zipResults" } }]), + ); + + expect(table.tableData).toBe("{{ appsmith.store.zipResults ?? [] }}"); + expect(table.dynamicBindingPathList).toEqual([{ key: "tableData" }]); + }); + + it("compileTableDataBinding emits the store form directly", () => { + expect(compileTableDataBinding({ store: "zipResults" })).toBe( + "{{ appsmith.store.zipResults ?? [] }}", + ); + }); + + it("rejects prototype-polluting / digit-leading / malformed store keys at the schema", () => { + for (const badKey of [ + "__proto__", + "constructor", + "prototype", + "hasOwnProperty", + "valueOf", + "1zipResults", + "zip-results", + "a".repeat(65), + ]) { + expect(storeKeySchema.safeParse(badKey).success).toBe(false); + expect( + appSpecSchema.safeParse({ + name: "App", + pages: [ + { + name: "P", + widgets: [{ type: "table", source: { store: badKey } }], + }, + ], + }).success, + ).toBe(false); + } + + expect(storeKeySchema.safeParse("zipResults").success).toBe(true); + expect(storeKeySchema.safeParse("_zip_2").success).toBe(true); + }); + + it("rejects mixing the store form with query-form props (strict union arms)", () => { + for (const badSource of [ + { store: "zipResults", query: "getRows" }, + { store: "zipResults", clearWhenEmpty: "ZipInput" }, + { store: "zipResults", field: "places" }, + ]) { + expect( + appSpecSchema.safeParse({ + name: "App", + pages: [ + { + name: "P", + widgets: [{ type: "table", source: badSource }], + }, + ], + }).success, + ).toBe(false); + } + }); + + it("keeps the LIST widget query-only (store-bound card grids are out of scope in v1)", () => { + expect( + appSpecSchema.safeParse({ + name: "App", + pages: [ + { + name: "P", + widgets: [ + { type: "list", source: { store: "zipResults" }, title: "name" }, + ], + }, + ], + }).success, + ).toBe(false); + }); + + it("still rejects a table that sets both static data and a store source", () => { + expect(() => + build([ + { type: "table", data: [{ id: 1 }], source: { store: "zipResults" } }, + ]), + ).toThrow(/both 'data' and 'source'/); + }); + + it("is lint-clean under populated knownDataNames (appsmith is a global binding head)", () => { + const dsl = build([{ type: "table", source: { store: "zipResults" } }]); + + expect(lintDsl(dsl, { knownDataNames: ["LookupZip"] }).errors).toBe(0); + }); +}); + +describe("M4-T5 catalog — checkbox, switch, radio, multiselect, filepicker", () => { + it("compiles a checkbox leaf to the correct type/version with a boolean default", () => { + const [checkbox] = children( + build([{ type: "checkbox", label: "Agree", defaultChecked: false }]), + ); + + expect(checkbox.type).toBe("CHECKBOX_WIDGET"); + expect(checkbox.version).toBe(1); + expect(checkbox.label).toBe("Agree"); + expect(checkbox.defaultCheckedState).toBe(false); + // Leaf: no inner canvas. + expect(checkbox.children).toBeUndefined(); + }); + + it("defaults a checkbox to checked when defaultChecked is omitted", () => { + const [checkbox] = children(build([{ type: "checkbox", label: "On" }])); + + expect(checkbox.defaultCheckedState).toBe(true); + }); + + it("compiles a switch leaf to the correct type/version with a boolean default", () => { + const [toggle] = children( + build([{ type: "switch", label: "Notify", defaultChecked: false }]), + ); + + expect(toggle.type).toBe("SWITCH_WIDGET"); + expect(toggle.version).toBe(1); + expect(toggle.label).toBe("Notify"); + expect(toggle.defaultSwitchState).toBe(false); + expect(toggle.children).toBeUndefined(); + }); + + it("compiles a radio group with static options and pre-selects the first", () => { + const [radio] = children( + build([ + { + type: "radio", + label: "Size", + options: [ + { label: "Small", value: "S" }, + { label: "Large", value: "L" }, + ], + }, + ]), + ); + + expect(radio.type).toBe("RADIO_GROUP_WIDGET"); + expect(radio.version).toBe(1); + // Static options are a plain literal array (never a binding); no dynamic paths registered. + expect(radio.options).toEqual([ + { label: "Small", value: "S" }, + { label: "Large", value: "L" }, + ]); + expect(radio.defaultOptionValue).toBe("S"); + expect(radio.dynamicPropertyPathList).toBeUndefined(); + expect(radio.dynamicBindingPathList).toBeUndefined(); + }); + + it("gives a radio group default Yes/No options when none are supplied", () => { + const [radio] = children(build([{ type: "radio", label: "Choose" }])); + + expect(radio.options).toEqual([ + { label: "Yes", value: "Y" }, + { label: "No", value: "N" }, + ]); + expect(radio.defaultOptionValue).toBe("Y"); + }); + + it("compiles a multiselect using labelText + a plain sourceData array (no JS/dynamic path)", () => { + const [multi] = children( + build([ + { + type: "multiselect", + label: "Tags", + options: [ + { label: "Red", value: "r" }, + { label: "Blue", value: "b" }, + ], + }, + ]), + ); + + expect(multi.type).toBe("MULTI_SELECT_WIDGET_V2"); + expect(multi.version).toBe(1); + // MULTI_SELECT uses labelText, not label, for its caption. + expect(multi.labelText).toBe("Tags"); + // sourceData is a plain literal array (MULTI_SELECT defaults sourceData to non-JS mode) keyed by literal props. + expect(multi.sourceData).toEqual([ + { label: "Red", value: "r" }, + { label: "Blue", value: "b" }, + ]); + expect(multi.optionLabel).toBe("label"); + expect(multi.optionValue).toBe("value"); + // Unlike SELECT_WIDGET, no dynamicPropertyPathList / dynamicBindingPathList for static options. + expect(multi.dynamicPropertyPathList).toBeUndefined(); + expect(multi.dynamicBindingPathList).toBeUndefined(); + }); + + it("compiles a filepicker leaf to the correct type/version", () => { + const [picker] = children( + build([{ type: "filepicker", label: "Upload receipt" }]), + ); + + expect(picker.type).toBe("FILE_PICKER_WIDGET_V2"); + expect(picker.version).toBe(1); + expect(picker.label).toBe("Upload receipt"); + expect(picker.fileDataType).toBe("Base64"); + expect(picker.children).toBeUndefined(); + }); + + it("produces lint-clean output for a page mixing all five new widgets", () => { + const dsl = build([ + { type: "checkbox", label: "Agree" }, + { type: "switch", label: "Notify" }, + { + type: "radio", + label: "Size", + options: [{ label: "S", value: "s" }], + }, + { + type: "multiselect", + label: "Tags", + options: [{ label: "A", value: "a" }], + }, + { type: "filepicker", label: "Upload" }, + ]); + const diagnostics = lintDsl(dsl); + + expect(diagnostics.errors).toBe(0); + expect(diagnostics.warnings).toBe(0); + }); + + it("is lint-clean when the new widgets share a page with existing ones", () => { + const dsl = build([ + { type: "input", label: "Name" }, + { type: "checkbox", label: "Agree" }, + { type: "select", options: [{ label: "A", value: "1" }] }, + { type: "radio", options: [{ label: "Yes", value: "Y" }] }, + { type: "multiselect", options: [{ label: "X", value: "x" }] }, + { type: "switch", label: "On" }, + { type: "filepicker", label: "Upload" }, + { type: "button", text: "Save" }, + ]); + + expect(lintDsl(dsl).errors).toBe(0); + }); + + it("rejects malformed props on the new arms (M4-T5)", () => { + function accepts(widget: unknown): boolean { + return appSpecSchema.safeParse({ + name: "App", + pages: [{ name: "P", widgets: [widget] }], + }).success; + } + + // Binding/template syntax in a label is rejected on every new arm. + expect(accepts({ type: "checkbox", label: "{{evil()}}" })).toBe(false); + expect(accepts({ type: "switch", label: "${evil}" })).toBe(false); + expect(accepts({ type: "filepicker", label: "`evil`" })).toBe(false); + // defaultChecked must be a boolean, not a string. + expect(accepts({ type: "checkbox", defaultChecked: "yes" })).toBe(false); + // A radio option label carrying binding-escape syntax is rejected (reuses the select validator). + expect( + accepts({ type: "radio", options: [{ label: "{{x}}", value: "1" }] }), + ).toBe(false); + // Unknown prop is rejected (strict object). + expect(accepts({ type: "multiselect", bogus: 1 })).toBe(false); + }); +}); diff --git a/app/client/packages/mcp/src/builder/compile.ts b/app/client/packages/mcp/src/builder/compile.ts new file mode 100644 index 000000000000..2a909b2c53a3 --- /dev/null +++ b/app/client/packages/mcp/src/builder/compile.ts @@ -0,0 +1,912 @@ +import type { + AppSpec, + EditSpec, + PageSpec, + Theme, + WidgetSpec, + WidgetType, +} from "./schema.js"; +import { + buildTableColumns, + compileCurrentItemBinding, + compilePrimaryKeys, + compileTableDataBinding, +} from "./schema.js"; +import { WIDGET_TEMPLATES } from "./templates.js"; +import { + createInnerCanvas, + createRootCanvas, + type IdGenerator, + GRID_COLUMNS, + NameAllocator, + randomIdGenerator, + resolvePlacement, + ROOT_WIDGET_ID, + ROW_HEIGHT, + stampWidget, + type WidgetNode, +} from "./layout.js"; +import { cascadeFit } from "./occupancy.js"; +import { assertNoNewNestedModals } from "./modalGraph.js"; +import { + applyRoleStyling, + inferRoles, + planLayout, + type LayoutSlot, +} from "./design.js"; + +export const CLIENT_SCHEMA_VERSION = 2; +export const SERVER_SCHEMA_VERSION = 12; +const ROW_GAP = 1; + +// Aggregate guards: the per-array Zod caps bound each level only, so cap total nodes and nesting depth here to stop +// container-in-container amplification (each container expands to a widget plus an inner canvas). +export const MAX_CONTAINER_DEPTH = 5; +export const MAX_TOTAL_WIDGETS = 300; + +// Deep-clone via JSON. The DSL is pure JSON, and this guarantees plain-Object-prototype nodes so the downstream +// plain-JSON guard (serializeArtifact) accepts the result in every runtime/test realm. +function cloneJson(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +const DEFAULT_BASE_NAME: Record = { + text: "Text", + input: "Input", + select: "Select", + button: "Button", + image: "Image", + table: "Table", + container: "Container", + form: "Form", + modal: "Modal", + datepicker: "DatePicker", + chart: "Chart", + tabs: "Tabs", + list: "List", + checkbox: "Checkbox", + switch: "Switch", + radio: "RadioGroup", + multiselect: "MultiSelect", + filepicker: "FilePicker", + divider: "Divider", + progress: "Progress", + circularprogress: "CircularProgress", + rate: "Rating", + iconbutton: "IconButton", + currencyinput: "CurrencyInput", + phoneinput: "PhoneInput", + numberslider: "NumberSlider", + categoryslider: "CategorySlider", + rangeslider: "RangeSlider", + checkboxgroup: "CheckboxGroup", + switchgroup: "SwitchGroup", + menubutton: "MenuButton", + buttongroup: "ButtonGroup", + camera: "Camera", + audiorecorder: "AudioRecorder", + codescanner: "CodeScanner", + map: "Map", + mapchart: "MapChart", + singleselecttree: "SingleSelectTree", + multiselecttree: "MultiSelectTree", + iframe: "Iframe", + video: "Video", + audio: "Audio", + documentviewer: "DocumentViewer", + richtext: "RichText", + jsonform: "JSONForm", + statbox: "Statbox", +}; + +interface CompileContext { + idGen: IdGenerator; + names: NameAllocator; + remaining: { widgets: number }; + theme?: Theme; +} + +// Apply app-level theme tokens to a widget's props. Tokens are pre-validated (hex color, numeric radius, safe font +// charset) so nothing injectable reaches a style prop. borderRadius applies broadly; primaryColor themes buttons; +// fontFamily themes text. +function applyTheme( + props: Record, + appsmithType: string, + theme?: Theme, +): void { + if (!theme) return; + + if (theme.borderRadius !== undefined) props.borderRadius = theme.borderRadius; + + if (theme.primaryColor !== undefined && appsmithType === "BUTTON_WIDGET") { + props.buttonColor = theme.primaryColor; + } + + if (theme.fontFamily !== undefined && appsmithType === "TEXT_WIDGET") { + props.fontFamily = theme.fontFamily; + } +} + +function compileWidgetAt( + spec: WidgetSpec, + ctx: CompileContext, + parentId: string, + topRow: number, + availableColumns: number, + depth: number, + slot?: LayoutSlot, +): WidgetNode { + if (depth > MAX_CONTAINER_DEPTH) { + throw new Error( + `container nesting exceeds the maximum depth of ${MAX_CONTAINER_DEPTH}`, + ); + } + + ctx.remaining.widgets -= 1; + + if (ctx.remaining.widgets < 0) { + throw new Error(`spec exceeds the maximum of ${MAX_TOTAL_WIDGETS} widgets`); + } + + const template = WIDGET_TEMPLATES[spec.type]; + const built = template.build(spec); + + // Design pass: role typography first, then the app-spec theme so explicit theme tokens still win. + applyRoleStyling(built.props, template.appsmithType, slot?.role ?? "body"); + applyTheme(built.props, template.appsmithType, ctx.theme); + + // Slot geometry contract: planLayout computed slot.columns/leftColumn from the SAME availableColumns passed + // here, guaranteeing leftColumn + columns <= availableColumns for packed rows. The clamp below only guards the + // no-slot (template footprint) path. + const columns = Math.min( + slot?.columns ?? built.footprint.columns, + availableColumns, + ); + const leftColumn = slot?.leftColumn ?? 0; + const widgetId = ctx.idGen(); + const widgetName = ctx.names.allocate( + spec.name ?? DEFAULT_BASE_NAME[spec.type], + ); + + // T1: table columns self-reference the table's own name in their computedValue binding, so they can only be built + // once the name is allocated (the list-widget currentItem bindings have the same requirement). Replace the + // template's empty primaryColumns/columnOrder defaults and register each computedValue as a binding. + if (spec.type === "table" && spec.columns && spec.columns.length > 0) { + const compiled = buildTableColumns(spec.columns, widgetName); + + built.props.primaryColumns = compiled.primaryColumns; + built.props.columnOrder = compiled.columnOrder; + + const existing = + (built.props.dynamicBindingPathList as { key: string }[] | undefined) ?? + []; + + built.props.dynamicBindingPathList = [ + ...existing, + ...compiled.bindingPaths.map((key) => ({ key })), + ]; + } + + // Tabs is multi-canvas: one inner CANVAS_WIDGET per tab, plus a tabsObj describing them. Handled directly because + // the single-inner-canvas container path can't express it. + if (spec.type === "tabs") { + const tabSpecs = + spec.tabs && spec.tabs.length > 0 ? spec.tabs : [{ label: "Tab 1" }]; + const tabsObj: Record = {}; + const canvases: WidgetNode[] = []; + let maxRows = built.footprint.rows; + + tabSpecs.forEach((tab, index) => { + const tabId = ctx.idGen(); + const canvasId = ctx.idGen(); + const canvasName = ctx.names.allocate("Canvas"); + const inner = stackWidgets( + tab.children ?? [], + ctx, + canvasId, + columns, + 0, + depth + 1, + ); + const rows = Math.max(built.footprint.rows, inner.endRow); + + maxRows = Math.max(maxRows, rows); + + const canvas = createInnerCanvas( + canvasId, + canvasName, + widgetId, + columns, + rows, + inner.nodes, + ); + + canvas.tabId = tabId; + canvas.tabName = tab.label; + canvases.push(canvas); + tabsObj[tabId] = { + id: tabId, + label: tab.label, + widgetId: canvasId, + index, + isVisible: true, + }; + }); + + return stampWidget({ + widgetId, + widgetName, + appsmithType: template.appsmithType, + version: template.version, + parentId, + topRow, + leftColumn, + columns, + rows: maxRows, + props: { ...built.props, tabsObj, defaultTab: tabSpecs[0].label }, + children: canvases, + }); + } + + // List Widget V2 is a curated card grid. Its DSL needs a 4-level structure (list -> main canvas -> item container + // -> inner canvas -> template widgets) with cross-referenced ids and compiler-authored `{{ currentItem[...] }}` + // bindings — the single-inner-canvas container path can't express it, so it is built directly (like tabs). + if (spec.type === "list") { + return compileCardWidget(spec, ctx, { + widgetId, + widgetName, + appsmithType: template.appsmithType, + version: template.version, + parentId, + topRow, + columns, + baseProps: built.props, + }); + } + + // Any widget whose template returns `children` is container-like (container, form, modal): it gets an inner + // CANVAS_WIDGET that holds the nested widgets. + if (built.children !== undefined) { + const innerId = ctx.idGen(); + const innerName = ctx.names.allocate("Canvas"); + const inner = stackWidgets( + built.children ?? [], + ctx, + innerId, + columns, + 0, + depth + 1, + ); + const rows = Math.max(built.footprint.rows, inner.endRow); + const innerCanvas = createInnerCanvas( + innerId, + innerName, + widgetId, + columns, + rows, + inner.nodes, + ); + + return stampWidget({ + widgetId, + widgetName, + appsmithType: template.appsmithType, + version: template.version, + parentId, + topRow, + leftColumn, + columns, + rows, + props: built.props, + children: [innerCanvas], + }); + } + + return stampWidget({ + widgetId, + widgetName, + appsmithType: template.appsmithType, + version: template.version, + parentId, + topRow, + leftColumn, + columns, + rows: slot?.rows ?? built.footprint.rows, + props: built.props, + }); +} + +interface CardWidgetParams { + widgetId: string; + widgetName: string; + appsmithType: string; + version: number; + parentId: string; + topRow: number; + columns: number; + baseProps: Record; +} + +// Row heights (in 10px grid rows) for the fixed card template slots. +const CARD_IMAGE_ROWS = 18; +const CARD_TEXT_ROWS = 4; + +// Build a List-Widget-V2 "card grid": the 4-level DSL (list -> main CANVAS -> item CONTAINER(isListItemContainer) -> +// inner CANVAS -> template widgets) that the widget's runtime clones per data row. Every per-item binding +// (`{{ currentItem[...] }}`) and the `primaryKeys`/`listData` expressions are compiler-authored from schema-validated +// identifiers, so nothing agent-supplied can escape a binding. mainCanvasId/mainContainerId cross-reference the +// generated canvas/container ids — the MetaWidgetGenerator reads them to materialize each row. +// +// The structural props transcribed below (isListItemContainer, noContainerOffset, disabledWidgetFeatures, the main +// canvas's dropDisabled/detachFromLayout/noPad, etc.) mirror the widget's own blueprint — see the ListWidgetV2 default +// config at app/client/src/widgets/ListWidgetV2/widget/defaultProps.ts. If that blueprint changes upstream, diff this +// against it. +function compileCardWidget( + spec: Extract, + ctx: CompileContext, + params: CardWidgetParams, +): WidgetNode { + const { columns, widgetId, widgetName } = params; + const mainCanvasId = ctx.idGen(); + const containerId = ctx.idGen(); + const innerCanvasId = ctx.idGen(); + const mainCanvasName = ctx.names.allocate("Canvas"); + const containerName = ctx.names.allocate("Container"); + const innerCanvasName = ctx.names.allocate("Canvas"); + + // Fixed template: optional image on top, required title, optional subtitle — each bound to a currentItem field. + const templateChildren: WidgetNode[] = []; + let cursor = 0; + + if (spec.image !== undefined) { + const built = WIDGET_TEMPLATES.image.build({ type: "image" }); + const props = { + ...built.props, + image: compileCurrentItemBinding(spec.image), + dynamicBindingPathList: [{ key: "image" }], + }; + + applyTheme(props, "IMAGE_WIDGET", ctx.theme); + templateChildren.push( + stampWidget({ + widgetId: ctx.idGen(), + widgetName: ctx.names.allocate("Image"), + appsmithType: "IMAGE_WIDGET", + version: 1, + parentId: innerCanvasId, + topRow: cursor, + leftColumn: 0, + columns, + rows: CARD_IMAGE_ROWS, + props, + }), + ); + cursor += CARD_IMAGE_ROWS; + } + + const titleBuilt = WIDGET_TEMPLATES.text.build({ type: "text" }); + const titleProps = { + ...titleBuilt.props, + text: compileCurrentItemBinding(spec.title), + // Explicit weight: the card title must stay bold even if the text template's default weight changes (the + // design pass styles page texts by role; card slots are outside that pass). + fontStyle: "BOLD", + // Fixed-height slots: truncate long values with an ellipsis rather than overflowing into the next slot. + shouldTruncate: true, + dynamicBindingPathList: [{ key: "text" }], + }; + + applyTheme(titleProps, "TEXT_WIDGET", ctx.theme); + templateChildren.push( + stampWidget({ + widgetId: ctx.idGen(), + widgetName: ctx.names.allocate("Text"), + appsmithType: "TEXT_WIDGET", + version: 1, + parentId: innerCanvasId, + topRow: cursor, + leftColumn: 0, + columns, + rows: CARD_TEXT_ROWS, + props: titleProps, + }), + ); + cursor += CARD_TEXT_ROWS; + + if (spec.subtitle !== undefined) { + const built = WIDGET_TEMPLATES.text.build({ type: "text" }); + const props = { + ...built.props, + text: compileCurrentItemBinding(spec.subtitle), + // Subtitle reads as secondary text: normal weight, muted colour. + fontStyle: "NORMAL", + textColor: "#716E6E", + shouldTruncate: true, + dynamicBindingPathList: [{ key: "text" }], + }; + + applyTheme(props, "TEXT_WIDGET", ctx.theme); + templateChildren.push( + stampWidget({ + widgetId: ctx.idGen(), + widgetName: ctx.names.allocate("Text"), + appsmithType: "TEXT_WIDGET", + version: 1, + parentId: innerCanvasId, + topRow: cursor, + leftColumn: 0, + columns, + rows: CARD_TEXT_ROWS, + props, + }), + ); + cursor += CARD_TEXT_ROWS; + } + + const contentRows = cursor; + + // Count the card's template widgets against the shared widget budget (compileWidgetAt only charged the list node + // itself). The structural canvases/container are scaffolding, not user widgets, so they are not charged. + ctx.remaining.widgets -= templateChildren.length; + + if (ctx.remaining.widgets < 0) { + throw new Error(`spec exceeds the maximum of ${MAX_TOTAL_WIDGETS} widgets`); + } + + // Inner canvas holds the template widgets; fixed layout (not auto-layout). + const innerCanvas = createInnerCanvas( + innerCanvasId, + innerCanvasName, + containerId, + columns, + contentRows, + templateChildren, + ); + + innerCanvas.useAutoLayout = false; + innerCanvas.flexLayers = []; + + // The item-template container. `isListItemContainer` marks it as the per-row template the runtime clones. + const itemContainer = stampWidget({ + widgetId: containerId, + widgetName: containerName, + appsmithType: "CONTAINER_WIDGET", + version: 1, + parentId: mainCanvasId, + topRow: 0, + leftColumn: 0, + columns, + rows: contentRows, + props: { + isCanvas: true, + isListItemContainer: true, + dragDisabled: true, + isDeletable: false, + disallowCopy: true, + noContainerOffset: true, + positioning: "fixed", + shouldScrollContents: false, + dynamicHeight: "FIXED", + disabledWidgetFeatures: ["dynamicHeight"], + containerStyle: "card", + backgroundColor: "white", + borderColor: "#E0DEDE", + borderWidth: "1", + flexVerticalAlignment: "start", + dynamicBindingPathList: [], + dynamicTriggerPathList: [], + }, + children: [innerCanvas], + }); + + // The list's main canvas: a detached, drop-disabled canvas that holds only the item container. + const mainCanvas: WidgetNode = { + widgetName: mainCanvasName, + type: "CANVAS_WIDGET", + version: 1, + widgetId: mainCanvasId, + parentId: params.widgetId, + renderMode: "CANVAS", + isVisible: true, + containerStyle: "none", + canExtend: false, + detachFromLayout: true, + dropDisabled: true, + openParentPropertyPane: true, + noPad: true, + minHeight: 400, + topRow: 0, + bottomRow: contentRows, + leftColumn: 0, + rightColumn: columns, + parentColumnSpace: 1, + parentRowSpace: 1, + dynamicBindingPathList: [], + dynamicTriggerPathList: [], + flexLayers: [], + children: [itemContainer], + }; + + const pageSize = spec.pageSize ?? 3; + // Give the list a visible height for a few cards; the widget paginates/scrolls internally beyond that. + const visibleCards = Math.min(pageSize, 3); + const listRows = Math.max(30, visibleCards * (contentRows + 1) + 6); + + const listProps = { + ...params.baseProps, + listData: compileTableDataBinding(spec.source), + primaryKeys: compilePrimaryKeys(widgetName), + pageSize, + mainCanvasId, + mainContainerId: containerId, + templateBottomRow: contentRows, + dynamicBindingPathList: [ + { key: "currentItemsView" }, + { key: "selectedItemView" }, + { key: "triggeredItemView" }, + { key: "primaryKeys" }, + { key: "listData" }, + ], + }; + + return stampWidget({ + widgetId, + widgetName, + appsmithType: params.appsmithType, + version: params.version, + parentId: params.parentId, + topRow: params.topRow, + leftColumn: 0, + columns, + rows: listRows, + props: listProps, + children: [mainCanvas], + }); +} + +function stackWidgets( + specs: WidgetSpec[], + ctx: CompileContext, + parentId: string, + availableColumns: number, + startRow: number, + depth: number, + pageRoot = false, +): { nodes: WidgetNode[]; endRow: number } { + const roles = inferRoles(specs, { pageRoot }); + const rows = planLayout(specs, roles, availableColumns); + const nodes: WidgetNode[] = []; + let cursor = startRow; + + for (const row of rows) { + cursor += row.gapBefore; + + let rowBottom = cursor; + + for (const slot of row.slots) { + const node = compileWidgetAt( + specs[slot.index], + ctx, + parentId, + cursor, + availableColumns, + depth, + slot, + ); + + nodes.push(node); + rowBottom = Math.max(rowBottom, node.bottomRow); + } + + cursor = rowBottom; + } + + // Empty canvas: no trailing gap (preserves pre-design-pass container heights). + return { nodes, endRow: rows.length === 0 ? startRow : cursor + ROW_GAP }; +} + +function compilePageDsl(pageSpec: PageSpec, ctx: CompileContext): WidgetNode { + const root = createRootCanvas(); + const { endRow, nodes } = stackWidgets( + pageSpec.widgets, + ctx, + ROOT_WIDGET_ID, + GRID_COLUMNS, + 0, + 0, + true, + ); + + root.children = nodes; + root.bottomRow = Math.max(endRow * ROW_HEIGHT, 380); + root.minHeight = root.bottomRow; + // M6 modal discipline (structural): a fresh build may not nest a modal inside another modal's subtree. + assertNoNewNestedModals(undefined, root); + + return root; +} + +function compilePage( + pageSpec: PageSpec, + idGen: IdGenerator, + remaining: { widgets: number }, + theme?: Theme, +) { + const ctx: CompileContext = { + idGen, + names: new NameAllocator(), + remaining, + theme, + }; + const dsl = compilePageDsl(pageSpec, ctx); + const layout = { dsl, layoutOnLoadActions: [] as unknown[] }; + + return { + unpublishedPage: { + name: pageSpec.name, + slug: pageSpec.name.toLowerCase().replace(/\s+/g, "-"), + layouts: [layout], + }, + publishedPage: { + name: pageSpec.name, + slug: pageSpec.name.toLowerCase().replace(/\s+/g, "-"), + layouts: [{ dsl: cloneJson(dsl), layoutOnLoadActions: [] }], + }, + }; +} + +// The card palette and icon set the Applications UI draws from when creating an app (it picks randomly from +// each). Copied because this package has no dependency on the client bundle; palette from `appColors` in +// app/client/src/constants/DefaultTheme.tsx, icons from `AppIconCollection` in +// packages/design-system/ads-old/src/AppIcon/index.tsx. +const APP_CARD_COLORS = [ + "#FFEFDB", + "#D9E7FF", + "#FFDEDE", + "#E3DEFF", + "#C7F3E3", + "#F1DEFF", + "#F4FFDE", + "#C7F3F0", + "#C2DAF0", + "#F5D1D1", + "#ECECEC", + "#CCCCCC", + "#F3F1C7", + "#E4D8CC", + "#EAEDFB", + "#D6D1F2", + "#FBF4ED", + "#FFEBFB", +]; + +const APP_CARD_ICONS = [ + "bag", + "product", + "book", + "camera", + "file", + "chat", + "calender", + "flight", + "frame", + "globe", + "shopper", + "heart", + "alien", + "bar-graph", + "basketball", + "bicycle", + "bird", + "bitcoin", + "burger", + "bus", + "call", + "car", + "card", + "cat", + "chinese-remnibi", + "cloud", + "coding", + "couples", + "cricket", + "diamond", + "dog", + "dollar", + "earth", + "email", + "euros", + "family", + "flag", + "football", + "hat", + "headphones", + "hospital", + "joystick", + "laptop", + "line-chart", + "location", + "lotus", + "love", + "medal", + "medical", + "money", + "moon", + "mug", + "music", + "package", + "pants", + "pie-chart", + "pizza", + "plant", + "rainy-weather", + "restaurant", + "rocket", + "rose", + "rupee", + "saturn", + "server", + "server-line", + "shake-hands", + "shirt", + "shop", + "single-person", + "smartphone", + "snowy-weather", + "stars", + "steam-bowl", + "sunflower", + "system", + "team", + "tree", + "uk-pounds", + "website", + "yen", + "airplane", + "arrow-down", + "arrow-up", + "arrow-left", + "arrow-right", + "help", + "open-new-tab", + "workflows", +]; + +// FNV-1a over the app name: varied like the UI's random pick, but deterministic so compileApp stays a pure +// function of its spec (same spec -> byte-identical artifact). +function hashAppName(name: string): number { + let hash = 0x811c9dc5; + + for (let i = 0; i < name.length; i++) { + hash ^= name.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + + return hash; +} + +// Compile a full application spec into an Appsmith import artifact accepted by +// POST /api/v1/applications/import/{workspaceId}. +export function compileApp( + appSpec: AppSpec, + idGen: IdGenerator = randomIdGenerator(), +): Record { + // One shared widget budget across all pages so total node count can't be multiplied by page count. + const remaining = { widgets: MAX_TOTAL_WIDGETS }; + const pageList = appSpec.pages.map((page) => + compilePage(page, idGen, remaining, appSpec.theme), + ); + const pageNames = appSpec.pages.map((page) => page.name); + // The application's page list. At the declared serverSchemaVersion the server runs no import migrations, so the + // v4 migration that would derive these from pageOrder is skipped — we must emit them, or the imported app links + // to zero pages. The migration keys each ApplicationPage id on the page NAME and marks the first as default. + const applicationPages = pageNames.map((name) => ({ + id: name, + isDefault: name === pageNames[0], + })); + + return { + clientSchemaVersion: CLIENT_SCHEMA_VERSION, + serverSchemaVersion: SERVER_SCHEMA_VERSION, + artifactJsonType: "APPLICATION", + exportedApplication: { + name: appSpec.name, + slug: appSpec.name.toLowerCase().replace(/\s+/g, "-"), + color: + APP_CARD_COLORS[hashAppName(appSpec.name) % APP_CARD_COLORS.length], + icon: APP_CARD_ICONS[hashAppName(appSpec.name) % APP_CARD_ICONS.length], + isPublic: false, + unpublishedCustomJSLibs: [], + publishedCustomJSLibs: [], + pages: applicationPages.map((page) => ({ ...page })), + publishedPages: applicationPages.map((page) => ({ ...page })), + // The compiler emits fixed-grid geometry (topRow/leftColumn/...). Pin the app to FIXED positioning so the + // import renders it as designed instead of reflowing it through auto-layout (which stretches widgets full + // width and stacks them with large gaps). + unpublishedApplicationDetail: { appPositioning: { type: "FIXED" } }, + publishedApplicationDetail: { appPositioning: { type: "FIXED" } }, + }, + pageList, + actionList: [], + datasourceList: [], + actionCollectionList: [], + customJSLibList: [], + editModeTheme: { name: "Default", isSystemTheme: true }, + publishedTheme: { name: "Default", isSystemTheme: true }, + // Distinct array copies: sharing one reference between both fields is valid JSON but trips the + // artifact serializer's shared-reference guard. + pageOrder: [...pageNames], + publishedPageOrder: [...pageNames], + unpublishedDefaultPageName: pageNames[0], + publishedDefaultPageName: pageNames[0], + }; +} + +function collectNames(node: WidgetNode, into: string[]): void { + if (typeof node.widgetName === "string") into.push(node.widgetName); + + for (const child of node.children ?? []) collectNames(child, into); +} + +// Apply an edit (add widgets) to an existing page's root-canvas DSL. Returns the merged DSL plus notes describing how +// best-effort placement was resolved. Existing widgets are never modified — only appended to. +export function applyEdit( + currentDsl: WidgetNode, + edit: EditSpec, + idGen: IdGenerator = randomIdGenerator(), +): { dsl: WidgetNode; notes: string[] } { + const dsl = cloneJson(currentDsl); + const existingNames: string[] = []; + + collectNames(dsl, existingNames); + + const ctx: CompileContext = { + idGen, + names: new NameAllocator(existingNames), + remaining: { widgets: MAX_TOTAL_WIDGETS }, + }; + const notes: string[] = []; + + for (const spec of edit.add) { + const placement = resolvePlacement(dsl, spec.placement); + + if (placement.note) notes.push(placement.note); + + const availableColumns = + placement.canvas.widgetId === ROOT_WIDGET_ID + ? GRID_COLUMNS + : placement.canvas.rightColumn; + // Single-spec design pass: the same role inference as build_application (so a KPI text appended via edit_page + // styles identically to one built with the app), but no cross-widget packing and never a pageTitle — each + // `add` entry places independently against its own placement target. + const [role] = inferRoles([spec], { pageRoot: false }); + const slot = planLayout([spec], [role], availableColumns)[0]?.slots[0]; + const node = compileWidgetAt( + spec, + ctx, + placement.canvas.widgetId, + placement.topRow, + availableColumns, + 0, + slot, + ); + + placement.canvas.children = placement.canvas.children ?? []; + placement.canvas.children.push(node); + + // M6 container-fit cascade: push down anything the new widget landed on (e.g. an `after` placement into the + // middle of a page), grow the enclosing canvas, and grow the enclosing container/form/tabs chain (never + // shrink) so nothing is clipped — the pre-M6 container growth, now applied recursively with every adjustment + // reported as a note. The commitLayout delta gate verifies the final result introduces no overlaps. + const cascade = cascadeFit(dsl, node.widgetName); + + notes.push(...cascade.notes); + } + + // M6 modal discipline (structural, delta-aware): the edit may not INTRODUCE a nested modal — covers both a + // modal spec added inside a modal-resident container and a container subtree that smuggles one in. Pre-existing + // human-authored nesting stays editable. + assertNoNewNestedModals(currentDsl, dsl); + + return { dsl, notes }; +} diff --git a/app/client/packages/mcp/src/builder/design.test.ts b/app/client/packages/mcp/src/builder/design.test.ts new file mode 100644 index 000000000000..1282617b8971 --- /dev/null +++ b/app/client/packages/mcp/src/builder/design.test.ts @@ -0,0 +1,395 @@ +import { + applyRoleStyling, + inferRoles, + planLayout, + type WidgetRole, +} from "./design.js"; +import { applyEdit, compileApp } from "./compile.js"; +import { sequentialIdGenerator, type WidgetNode } from "./layout.js"; +import { overlappingPairs } from "./occupancy.js"; +import type { WidgetSpec } from "./schema.js"; + +const ids = () => sequentialIdGenerator(); + +function page(widgets: WidgetSpec[]) { + return { name: "App", pages: [{ name: "Home", widgets }] }; +} + +function rootChildren(artifact: Record) { + // pageList[0].unpublishedPage.layouts[0].dsl.children + const pageList = artifact.pageList as Array<{ + unpublishedPage: { layouts: Array<{ dsl: { children: unknown[] } }> }; + }>; + + return pageList[0].unpublishedPage.layouts[0].dsl.children as Array< + Record + >; +} + +describe("design pass — role inference", () => { + it("marks the first static text of a page as the page title, root only", () => { + const specs: WidgetSpec[] = [ + { type: "text", text: "Dashboard" }, + { type: "text", text: "Just prose" }, + ]; + + expect(inferRoles(specs, { pageRoot: true })).toEqual([ + "pageTitle", + "body", + ]); + expect(inferRoles(specs, { pageRoot: false })).toEqual(["body", "body"]); + }); + + it("marks a static text directly before a section-type widget as its header", () => { + const specs: WidgetSpec[] = [ + { type: "text", text: "Orders" }, + { type: "table", data: [{ id: 1 }] }, + ]; + + expect(inferRoles(specs, { pageRoot: false })).toEqual([ + "sectionHeader", + "body", + ]); + }); + + it("treats count/formula texts as KPIs but now/concat as body", () => { + const specs: WidgetSpec[] = [ + { type: "text", value: { count: { query: "getOrders" } } }, + { type: "text", value: { now: { format: "dayOfWeek" } } }, + ]; + + expect(inferRoles(specs, { pageRoot: false })).toEqual(["kpi", "body"]); + }); + + it("bound texts never become titles or headers", () => { + const specs: WidgetSpec[] = [ + { type: "text", source: { query: "getUser", field: "name" } }, + { type: "table", data: [{ id: 1 }] }, + ]; + + expect(inferRoles(specs, { pageRoot: true })).toEqual(["body", "body"]); + }); +}); + +describe("design pass — row planner", () => { + it("pairs two consecutive fields side by side with a gutter", () => { + const specs: WidgetSpec[] = [ + { type: "input", label: "First" }, + { type: "input", label: "Last" }, + ]; + const rows = planLayout(specs, inferRoles(specs, { pageRoot: false }), 64); + + expect(rows).toHaveLength(1); + expect(rows[0].slots).toEqual([ + expect.objectContaining({ index: 0, leftColumn: 0, columns: 31 }), + expect.objectContaining({ index: 1, leftColumn: 33, columns: 31 }), + ]); + }); + + it("does not pair fields on a canvas too narrow to split", () => { + const specs: WidgetSpec[] = [ + { type: "input", label: "First" }, + { type: "input", label: "Last" }, + ]; + const rows = planLayout(specs, inferRoles(specs, { pageRoot: false }), 30); + + expect(rows).toHaveLength(2); + }); + + it("splits three KPIs evenly across the row", () => { + const specs: WidgetSpec[] = [ + { type: "text", value: { count: { query: "a" } } }, + { type: "text", value: { count: { query: "b" } } }, + { type: "text", value: { count: { query: "c" } } }, + ]; + const rows = planLayout(specs, inferRoles(specs, { pageRoot: false }), 64); + + expect(rows).toHaveLength(1); + expect(rows[0].slots.map((slot) => slot.leftColumn)).toEqual([0, 22, 44]); + expect(rows[0].slots.every((slot) => slot.columns === 20)).toBe(true); + }); + + it("right-aligns buttons that follow fields, packs left otherwise", () => { + const afterFields: WidgetSpec[] = [ + { type: "input", label: "Name" }, + { type: "button", text: "Save" }, + ]; + const standalone: WidgetSpec[] = [{ type: "button", text: "Go" }]; + + const formRows = planLayout( + afterFields, + inferRoles(afterFields, { pageRoot: false }), + 64, + ); + + expect(formRows[1].slots[0].leftColumn).toBe(48); + + const soloRows = planLayout( + standalone, + inferRoles(standalone, { pageRoot: false }), + 64, + ); + + expect(soloRows[0].slots[0].leftColumn).toBe(0); + }); + + it("applies the spacing rhythm: gap after title, section gap before headers and bare sections", () => { + const specs: WidgetSpec[] = [ + { type: "text", text: "Dashboard" }, + { type: "text", text: "Orders" }, + { type: "table", data: [{ id: 1 }] }, + { + type: "chart", + chartType: "LINE_CHART", + source: { query: "q", x: "day", y: "total" }, + }, + ]; + const rows = planLayout(specs, inferRoles(specs, { pageRoot: true }), 64); + + // title, header, table, chart + expect(rows.map((row) => row.gapBefore)).toEqual([0, 2, 1, 3]); + }); +}); + +describe("design pass — compiled output", () => { + it("styles the page title and leaves body text normal weight", () => { + const artifact = compileApp( + page([ + { type: "text", text: "Dashboard" }, + { type: "text", text: "Welcome back" }, + ]), + ids(), + ); + const [title, body] = rootChildren(artifact); + + expect(title.fontSize).toBe("1.875rem"); + expect(title.fontStyle).toBe("BOLD"); + expect(body.fontSize).toBe("1rem"); + expect(body.fontStyle).toBe("NORMAL"); + }); + + it("places paired fields on the same row without overlap", () => { + const artifact = compileApp( + page([ + { type: "input", label: "First" }, + { type: "input", label: "Last" }, + ]), + ids(), + ); + const [first, last] = rootChildren(artifact); + + expect(first.topRow).toBe(last.topRow); + expect(first.rightColumn as number).toBeLessThanOrEqual( + last.leftColumn as number, + ); + }); + + it("only ever writes typography keys — the compiler-owned prop surface stays fixed", () => { + const roles: WidgetRole[] = [ + "pageTitle", + "sectionHeader", + "kpi", + "field", + "action", + "body", + ]; + const allowed = new Set(["fontSize", "fontStyle", "textAlign"]); + + for (const role of roles) { + const props: Record = {}; + + applyRoleStyling(props, "TEXT_WIDGET", role); + + for (const key in props) { + expect(allowed.has(key)).toBe(true); + } + } + }); + + it("keeps compileApp deterministic with the design pass active", () => { + const spec = page([ + { type: "text", text: "Tasks" }, + { type: "input", label: "Title" }, + { type: "input", label: "Owner" }, + { type: "button", text: "Add" }, + { type: "table", data: [{ id: 1 }] }, + ]); + + expect(JSON.stringify(compileApp(spec, ids()))).toBe( + JSON.stringify(compileApp(spec, ids())), + ); + }); +}); + +function rootDsl(artifact: Record): WidgetNode { + const pageList = artifact.pageList as Array<{ + unpublishedPage: { layouts: Array<{ dsl: WidgetNode }> }; + }>; + + return pageList[0].unpublishedPage.layouts[0].dsl; +} + +function widgetNamed(node: WidgetNode, name: string): WidgetNode | undefined { + if (node.widgetName === name) return node; + + for (const child of node.children ?? []) { + const found = widgetNamed(child, name); + + if (found) return found; + } + + return undefined; +} + +// Assert every canvas in the tree — root, container/form inner canvases, tab canvases — holds no overlapping +// widgets, using the same helper the occupancy delta gate relies on. +function assertNoOverlapsDeep(node: WidgetNode): void { + const children = node.children ?? []; + + if (node.type === "CANVAS_WIDGET" && children.length > 0) { + expect(overlappingPairs(children)).toEqual([]); + } + + for (const child of children) assertNoOverlapsDeep(child); +} + +describe("design pass — nested canvases and overlap safety", () => { + it("compiles a representative app with zero overlaps on any canvas", () => { + const artifact = compileApp( + page([ + { type: "text", text: "Ops Dashboard" }, + { type: "text", value: { count: { query: "a" } } }, + { type: "text", value: { count: { query: "b" } } }, + { type: "text", value: { count: { query: "c" } } }, + { type: "text", value: { count: { query: "d" } } }, + { type: "input", label: "One" }, + { type: "input", label: "Two" }, + { type: "input", label: "Three" }, + { type: "button", text: "Save" }, + { type: "button", text: "Reset" }, + { type: "button", text: "Export" }, + { + type: "form", + name: "OrderForm", + children: [ + { type: "input", label: "First" }, + { type: "input", label: "Last" }, + ], + }, + { + type: "modal", + name: "EditModal", + children: [ + { type: "input", label: "A" }, + { type: "input", label: "B" }, + ], + }, + { + type: "container", + name: "Nested", + children: [ + { type: "input", label: "X" }, + { type: "input", label: "Y" }, + { type: "input", label: "Z" }, + ], + }, + ]), + ids(), + ); + + assertNoOverlapsDeep(rootDsl(artifact)); + }); + + it("pairs fields and right-aligns the synthetic submit inside a form's 40-col canvas", () => { + const artifact = compileApp( + page([ + { + type: "form", + name: "OrderForm", + children: [ + { type: "input", name: "FirstInput", label: "First" }, + { type: "input", name: "LastInput", label: "Last" }, + ], + }, + ]), + ids(), + ); + const dsl = rootDsl(artifact); + const first = widgetNamed(dsl, "FirstInput")!; + const last = widgetNamed(dsl, "LastInput")!; + const submit = widgetNamed(dsl, "Button")!; + + expect(first.topRow).toBe(last.topRow); + expect(first.rightColumn).toBeLessThanOrEqual(last.leftColumn); + // Form inner canvas is 40 columns; the 16-col submit right-aligns to 40 - 16 = 24. + expect(submit.leftColumn).toBe(24); + }); + + it("falls back to full-width stacking inside a 32-col modal", () => { + const artifact = compileApp( + page([ + { + type: "modal", + name: "EditModal", + children: [ + { type: "input", name: "AInput", label: "A" }, + { type: "input", name: "BInput", label: "B" }, + ], + }, + ]), + ids(), + ); + const dsl = rootDsl(artifact); + const a = widgetNamed(dsl, "AInput")!; + const b = widgetNamed(dsl, "BInput")!; + + expect(a.leftColumn).toBe(0); + expect(b.leftColumn).toBe(0); + expect(b.topRow).toBeGreaterThanOrEqual(a.bottomRow); + }); + + it("keeps an empty container at its template height", () => { + const artifact = compileApp( + page([{ type: "container", name: "Empty", children: [] }]), + ids(), + ); + const empty = widgetNamed(rootDsl(artifact), "Empty")!; + + expect(empty.bottomRow - empty.topRow).toBe(30); + }); +}); + +describe("design pass — edit_page path", () => { + const basePage = () => + rootDsl(compileApp(page([{ type: "text", text: "Seed" }]), ids())); + + it("gives an appended static text body styling at full template width", () => { + const { dsl } = applyEdit( + basePage(), + { add: [{ type: "text", name: "Note", text: "hello" }] }, + ids(), + ); + const note = widgetNamed(dsl, "Note")!; + + expect(note.fontSize).toBe("1rem"); + expect(note.fontStyle).toBe("NORMAL"); + expect(note.leftColumn).toBe(0); + }); + + it("styles an appended KPI text exactly like the build path", () => { + const { dsl } = applyEdit( + basePage(), + { + add: [ + { type: "text", name: "Kpi", value: { count: { query: "orders" } } }, + ], + }, + ids(), + ); + const kpi = widgetNamed(dsl, "Kpi")!; + + expect(kpi.fontSize).toBe("1.5rem"); + expect(kpi.fontStyle).toBe("BOLD"); + expect(kpi.bottomRow - kpi.topRow).toBe(6); + }); +}); diff --git a/app/client/packages/mcp/src/builder/design.ts b/app/client/packages/mcp/src/builder/design.ts new file mode 100644 index 000000000000..efce07a76a28 --- /dev/null +++ b/app/client/packages/mcp/src/builder/design.ts @@ -0,0 +1,305 @@ +import type { WidgetSpec } from "./schema.js"; + +// The design pass: infer a visual role for each widget from what the agent already sends, then plan rows so the +// page gets typographic hierarchy, side-by-side packing, and spacing rhythm — with every style value owned by the +// compiler (nothing here is agent-supplied). Pure functions of the spec, so compileApp stays deterministic. + +export type WidgetRole = + | "pageTitle" + | "sectionHeader" + | "kpi" + | "field" + | "action" + | "body"; + +// Widget types that read as "a section" — a static text immediately before one of these is a section header. +const SECTION_TYPES = new Set([ + "table", + "chart", + "list", + "form", + "container", + "jsonform", + "tabs", +]); + +// Input-like widgets that pair two-per-row like a designed form. +const FIELD_TYPES = new Set([ + "input", + "select", + "datepicker", + "multiselect", + "currencyinput", + "phoneinput", +]); + +const GUTTER = 2; +// Below this slot width (in grid columns) packing stops helping; fall back to full-width stacking. +const MIN_PACKED_WIDTH = 16; +const MAX_KPIS_PER_ROW = 4; +const MAX_ACTIONS_PER_ROW = 3; +const BUTTON_COLUMNS = 16; + +// Spacing rhythm, in grid rows. +const GAP_WITHIN = 1; +const GAP_SECTION = 3; +const GAP_AFTER_TITLE = 2; + +// Typography scale applied to TEXT_WIDGET props by role. Values are compiler-owned constants; the agent cannot +// influence them beyond choosing what to build. +const TEXT_ROLE_STYLES: Partial>> = { + pageTitle: { fontSize: "1.875rem", fontStyle: "BOLD" }, + sectionHeader: { fontSize: "1.25rem", fontStyle: "BOLD" }, + kpi: { fontSize: "1.5rem", fontStyle: "BOLD", textAlign: "CENTER" }, + body: { fontSize: "1rem", fontStyle: "NORMAL" }, +}; + +function isStaticText(spec: WidgetSpec): boolean { + return ( + spec.type === "text" && + typeof spec.text === "string" && + spec.source === undefined && + spec.value === undefined + ); +} + +// A text bound to a numeric computed value (count or formula) reads as a KPI. `now`/`concat` values are prose +// (dates, joined names) and stay body text. +function isKpiText(spec: WidgetSpec): boolean { + return ( + spec.type === "text" && + spec.value !== undefined && + ("count" in spec.value || "formula" in spec.value) + ); +} + +export function inferRoles( + specs: WidgetSpec[], + options: { pageRoot: boolean }, +): WidgetRole[] { + return specs.map((spec, index) => { + if (isStaticText(spec)) { + if (index === 0 && options.pageRoot) return "pageTitle"; + + const next = specs[index + 1]; + + if (next !== undefined && SECTION_TYPES.has(next.type)) { + return "sectionHeader"; + } + + return "body"; + } + + if (isKpiText(spec)) return "kpi"; + + if (FIELD_TYPES.has(spec.type)) return "field"; + + if (spec.type === "button") return "action"; + + return "body"; + }); +} + +export interface LayoutSlot { + // Index into the spec array this slot places. + index: number; + role: WidgetRole; + leftColumn: number; + // Width override in grid columns; absent means "use the template footprint". + columns?: number; + // Height override in grid rows; absent means "use the template footprint". + rows?: number; +} + +export interface LayoutRow { + // Vertical gap (grid rows) between this row and the previous one. 0 for the first row. + gapBefore: number; + slots: LayoutSlot[]; +} + +// Equal-split slot width for n widgets across the available columns with fixed gutters. +function packedWidth(available: number, count: number): number { + return Math.floor((available - (count - 1) * GUTTER) / count); +} + +function takeRun( + roles: WidgetRole[], + start: number, + role: WidgetRole, + max: number, +): number { + let end = start; + + while (end < roles.length && roles[end] === role && end - start < max) + end += 1; + + return end; +} + +export function planLayout( + specs: WidgetSpec[], + roles: WidgetRole[], + availableColumns: number, +): LayoutRow[] { + const rows: LayoutRow[] = []; + let index = 0; + + const gapFor = (rowSlots: LayoutSlot[]): number => { + if (rows.length === 0) return 0; + + const previous = rows[rows.length - 1].slots; + + if (previous.some((slot) => slot.role === "pageTitle")) { + return GAP_AFTER_TITLE; + } + + const first = rowSlots[0]; + + if (first.role === "sectionHeader") return GAP_SECTION; + + // A section-type widget gets breathing room unless its own header directly precedes it. + if ( + SECTION_TYPES.has(specs[first.index].type) && + !previous.some((slot) => slot.role === "sectionHeader") + ) { + return GAP_SECTION; + } + + return GAP_WITHIN; + }; + + const push = (slots: LayoutSlot[]) => { + rows.push({ gapBefore: gapFor(slots), slots }); + }; + + while (index < specs.length) { + const role = roles[index]; + + if (role === "field") { + const end = takeRun(roles, index, "field", 2); + const count = end - index; + const width = packedWidth(availableColumns, count); + + if (count === 2 && width >= MIN_PACKED_WIDTH) { + push([ + { index, role, leftColumn: 0, columns: width }, + { + index: index + 1, + role, + leftColumn: width + GUTTER, + columns: width, + }, + ]); + index = end; + continue; + } + + push([{ index, role, leftColumn: 0 }]); + index += 1; + continue; + } + + if (role === "kpi") { + const end = takeRun(roles, index, "kpi", MAX_KPIS_PER_ROW); + const count = end - index; + const width = packedWidth(availableColumns, count); + + if (count >= 2 && width >= MIN_PACKED_WIDTH - 4) { + const slots: LayoutSlot[] = []; + + for (let i = 0; i < count; i++) { + slots.push({ + index: index + i, + role, + leftColumn: i * (width + GUTTER), + columns: width, + rows: 6, + }); + } + + push(slots); + index = end; + continue; + } + + push([{ index, role, leftColumn: 0, rows: 6 }]); + index += 1; + continue; + } + + if (role === "action") { + const end = takeRun(roles, index, "action", MAX_ACTIONS_PER_ROW); + const count = end - index; + const total = count * BUTTON_COLUMNS + (count - 1) * GUTTER; + // Actions right-align (form-footer look) when they follow fields; otherwise they pack from the left. + const followsFields = index > 0 && roles[index - 1] === "field"; + const start = + followsFields && total <= availableColumns + ? availableColumns - total + : 0; + + if (total <= availableColumns) { + const slots: LayoutSlot[] = []; + + for (let i = 0; i < count; i++) { + slots.push({ + index: index + i, + role, + leftColumn: start + i * (BUTTON_COLUMNS + GUTTER), + columns: BUTTON_COLUMNS, + }); + } + + push(slots); + index = end; + continue; + } + + // The run doesn't fit side by side on this canvas: stack one per row at template width. + push([{ index, role, leftColumn: 0 }]); + index += 1; + continue; + } + + if (role === "pageTitle") { + push([ + { + index, + role, + leftColumn: 0, + columns: Math.min(48, availableColumns), + rows: 5, + }, + ]); + index += 1; + continue; + } + + if (role === "sectionHeader") { + push([ + { index, role, leftColumn: 0, columns: Math.min(40, availableColumns) }, + ]); + index += 1; + continue; + } + + push([{ index, role, leftColumn: 0 }]); + index += 1; + } + + return rows; +} + +// Apply the role's typography to a text widget's props. Only TEXT_WIDGET carries role styling in v1; other widget +// types keep their template defaults. Called before applyTheme so explicit theme tokens still win. +export function applyRoleStyling( + props: Record, + appsmithType: string, + role: WidgetRole, +): void { + if (appsmithType !== "TEXT_WIDGET") return; + + const style = TEXT_ROLE_STYLES[role]; + + if (style) Object.assign(props, style); +} diff --git a/app/client/packages/mcp/src/builder/dsl-version.test.ts b/app/client/packages/mcp/src/builder/dsl-version.test.ts new file mode 100644 index 000000000000..251d0b34dffe --- /dev/null +++ b/app/client/packages/mcp/src/builder/dsl-version.test.ts @@ -0,0 +1,20 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { LATEST_DSL_VERSION } from "./layout.js"; + +// The compiler stamps the page DSL version on the root canvas. It MUST equal Appsmith's live LATEST_DSL_VERSION so +// the client's migrateDSL runs zero migrations on load — otherwise it rescales/rewrites our widgets (e.g. flips a +// single-line input to MULTI_LINE_TEXT). We can't import @shared/dsl here (it bundles the whole migration graph), so +// read the constant straight from its source. If Appsmith bumps the version, this test fails and points here. +describe("DSL version stays current with Appsmith", () => { + it("matches @shared/dsl's LATEST_DSL_VERSION source constant", () => { + const source = readFileSync( + resolve(__dirname, "../../../dsl/src/migrate/index.ts"), + "utf8", + ); + const match = source.match(/export const LATEST_DSL_VERSION\s*=\s*(\d+)/); + + expect(match).not.toBeNull(); + expect(LATEST_DSL_VERSION).toBe(Number(match![1])); + }); +}); diff --git a/app/client/packages/mcp/src/builder/editPatch.test.ts b/app/client/packages/mcp/src/builder/editPatch.test.ts new file mode 100644 index 000000000000..350c52d4ce7e --- /dev/null +++ b/app/client/packages/mcp/src/builder/editPatch.test.ts @@ -0,0 +1,2024 @@ +import { z } from "zod"; +import type { WidgetNode } from "./layout.js"; +import { applyWidgetPatch, widgetPatchSchema } from "./editPatch.js"; +import { + compileInputValidation, + INPUT_VALIDATION_FORMATS, + type InputValidationFormat, +} from "./schema.js"; + +function node( + overrides: Partial & + Pick, +): WidgetNode { + return { + topRow: 0, + bottomRow: 4, + leftColumn: 0, + rightColumn: 24, + ...overrides, + }; +} + +function page(): WidgetNode { + const text = node({ + widgetId: "text", + widgetName: "Greeting", + type: "TEXT_WIDGET", + text: "Hello", + }); + const innerCanvas = node({ + widgetId: "detailsCanvas", + widgetName: "DetailsCanvas", + type: "CANVAS_WIDGET", + children: [], + }); + const container = node({ + widgetId: "details", + widgetName: "Details", + type: "CONTAINER_WIDGET", + children: [innerCanvas], + }); + + return node({ + widgetId: "0", + widgetName: "MainContainer", + type: "CANVAS_WIDGET", + children: [text, container], + }); +} + +describe("applyWidgetPatch", () => { + it("updates only allowlisted literal properties and returns semantic details", () => { + const original = page(); + const { changes, dsl } = applyWidgetPatch(original, { + operations: [ + { + kind: "update", + name: "Greeting", + props: { text: "Welcome", isVisible: false }, + }, + ], + }); + const greeting = dsl.children![0]; + + expect(greeting).toMatchObject({ text: "Welcome", isVisible: false }); + expect(original.children?.[0]).toMatchObject({ text: "Hello" }); + expect(changes).toEqual([ + { + kind: "update", + widgetName: "Greeting", + changedProps: ["text", "isVisible"], + }, + ]); + }); + + it("sets literal table row-striping colors (oddRowColor/evenRowColor)", () => { + const withTable = page(); + + withTable.children!.push( + node({ widgetId: "t1", widgetName: "Results", type: "TABLE_WIDGET_V2" }), + ); + const { changes, dsl } = applyWidgetPatch(withTable, { + operations: [ + { + kind: "update", + name: "Results", + props: { oddRowColor: "#ffffff", evenRowColor: "#e6f2ff" }, + }, + ], + }); + const table = dsl.children![2]; + + expect(table).toMatchObject({ + oddRowColor: "#ffffff", + evenRowColor: "#e6f2ff", + }); + // Literal style props are NOT dynamic bindings. + expect(table.dynamicBindingPathList).toBeUndefined(); + expect(changes[0].changedProps).toEqual(["oddRowColor", "evenRowColor"]); + }); + + it("toggles table interactivity props (search/filter/sort/pagination)", () => { + const withTable = page(); + + withTable.children!.push( + node({ widgetId: "t1", widgetName: "Results", type: "TABLE_WIDGET_V2" }), + ); + const { dsl } = applyWidgetPatch(withTable, { + operations: [ + { + kind: "update", + name: "Results", + props: { + isVisibleSearch: true, + enableClientSideSearch: true, + isVisibleFilters: true, + isSortable: true, + }, + }, + ], + }); + + expect(dsl.children![2]).toMatchObject({ + isVisibleSearch: true, + enableClientSideSearch: true, + isVisibleFilters: true, + isSortable: true, + }); + }); + + it("binds an image's src to a table's selected row (imageSource)", () => { + const withImage = page(); + + withImage.children!.push( + node({ widgetId: "t1", widgetName: "People", type: "TABLE_WIDGET_V2" }), + node({ widgetId: "img1", widgetName: "Photo", type: "IMAGE_WIDGET" }), + ); + const { dsl } = applyWidgetPatch(withImage, { + operations: [ + { + kind: "update", + name: "Photo", + props: { imageSource: { table: "People", column: "photo" } }, + }, + ], + }); + const image = dsl.children![3]; + + expect(image.image).toBe('{{ People.selectedRow["photo"] }}'); + expect(image.dynamicBindingPathList).toEqual([{ key: "image" }]); + }); + + it("binds a text's content and an image's src to a query response field via patch", () => { + const withBoth = page(); + + withBoth.children!.push( + node({ widgetId: "txt1", widgetName: "Temp", type: "TEXT_WIDGET" }), + node({ widgetId: "img1", widgetName: "Photo", type: "IMAGE_WIDGET" }), + ); + const { dsl } = applyWidgetPatch(withBoth, { + operations: [ + { + kind: "update", + name: "Temp", + props: { source: { query: "getWeather", field: "current.temp" } }, + }, + { + kind: "update", + name: "Photo", + props: { imageSource: { query: "getProfile", field: "avatarUrl" } }, + }, + ], + }); + const text = dsl.children!.find((w) => w.widgetName === "Temp")!; + const image = dsl.children!.find((w) => w.widgetName === "Photo")!; + + expect(text.text).toBe('{{ getWeather.data?.current.temp ?? "" }}'); + expect(text.dynamicBindingPathList).toEqual([{ key: "text" }]); + expect(image.image).toBe('{{ getProfile.data?.avatarUrl ?? "" }}'); + expect(image.dynamicBindingPathList).toEqual([{ key: "image" }]); + }); + + it("patches a computed value onto a text widget and rejects it elsewhere", () => { + const withText = page(); + + withText.children!.push( + node({ widgetId: "txt1", widgetName: "Today", type: "TEXT_WIDGET" }), + node({ widgetId: "t1", widgetName: "Results", type: "TABLE_WIDGET_V2" }), + ); + const { dsl } = applyWidgetPatch(withText, { + operations: [ + { + kind: "update", + name: "Today", + props: { value: { now: { format: "dayOfWeek" } } }, + }, + ], + }); + const today = dsl.children!.find((w) => w.widgetName === "Today")!; + + expect(today.text).toBe("{{ moment().format('dddd') }}"); + expect(today.dynamicBindingPathList).toEqual([{ key: "text" }]); + + expect(() => + applyWidgetPatch(withText, { + operations: [ + { + kind: "update", + name: "Results", + props: { value: { now: { format: "dayOfWeek" } } }, + }, + ], + }), + ).toThrow(/can only be set on a TEXT_WIDGET/); + + // Ambiguous combinations in one update are rejected — both the literal and the source variant. + expect(() => + applyWidgetPatch(withText, { + operations: [ + { + kind: "update", + name: "Today", + props: { text: "static", value: { now: { format: "date" } } }, + }, + ], + }), + ).toThrow(/cannot set both/); + + expect(() => + applyWidgetPatch(withText, { + operations: [ + { + kind: "update", + name: "Today", + props: { + source: { query: "getDay" }, + value: { now: { format: "date" } }, + }, + }, + ], + }), + ).toThrow(/cannot set both/); + }); + + it("patches a formula value and guards its selected-row refs like concat", () => { + const withText = page(); + + withText.children!.push( + node({ widgetId: "txt1", widgetName: "Calc", type: "TEXT_WIDGET" }), + node({ widgetId: "t1", widgetName: "Users", type: "TABLE_WIDGET_V2" }), + ); + const { dsl } = applyWidgetPatch(withText, { + operations: [ + { + kind: "update", + name: "Calc", + props: { + value: { + formula: { + op: "div", + args: [{ table: "Users", column: "amount" }, 2], + }, + }, + }, + }, + ], + }); + const calc = dsl.children!.find((w) => w.widgetName === "Calc")!; + + expect(calc.text).toBe( + '{{ ((v) => Number.isFinite(v) ? v : "")((Number(Users.selectedRow["amount"]) / 2)) }}', + ); + expect(calc.dynamicBindingPathList).toEqual([{ key: "text" }]); + + // Dangling-table guard walks the formula AST, same posture as concat parts. + expect(() => + applyWidgetPatch(withText, { + operations: [ + { + kind: "update", + name: "Calc", + props: { + value: { + formula: { + op: "abs", + args: [{ table: "Missing", column: "x" }], + }, + }, + }, + }, + ], + }), + ).toThrow(/was not found/); + }); + + it("rejects concat selected-row parts referencing a missing or non-table widget", () => { + const withText = page(); + + withText.children!.push( + node({ widgetId: "txt1", widgetName: "Combo", type: "TEXT_WIDGET" }), + node({ + widgetId: "i1", + widgetName: "NotATable", + type: "INPUT_WIDGET_V2", + }), + ); + + // Same dangling-table guard as `source` refs: a missing table is caught at patch time, not + // silently compiled into a blank widget. + expect(() => + applyWidgetPatch(withText, { + operations: [ + { + kind: "update", + name: "Combo", + props: { + value: { + concat: [ + { table: "Missing", column: "first" }, + { literal: " " }, + ], + }, + }, + }, + ], + }), + ).toThrow(/was not found/); + + expect(() => + applyWidgetPatch(withText, { + operations: [ + { + kind: "update", + name: "Combo", + props: { + value: { + concat: [ + { table: "NotATable", column: "first" }, + { literal: " " }, + ], + }, + }, + }, + ], + }), + ).toThrow(/is not a table widget/); + }); + + it("patching value over a prior source binding keeps a single dynamic path entry", () => { + const withText = page(); + + withText.children!.push( + node({ widgetId: "txt1", widgetName: "Temp", type: "TEXT_WIDGET" }), + ); + const { dsl: bound } = applyWidgetPatch(withText, { + operations: [ + { + kind: "update", + name: "Temp", + props: { source: { query: "getWeather", field: "temp" } }, + }, + ], + }); + const { dsl } = applyWidgetPatch(bound, { + operations: [ + { + kind: "update", + name: "Temp", + props: { value: { now: { format: "time" } } }, + }, + ], + }); + const text = dsl.children!.find((w) => w.widgetName === "Temp")!; + + expect(text.text).toBe("{{ moment().format('LT') }}"); + expect(text.dynamicBindingPathList).toEqual([{ key: "text" }]); + }); + + it("rejects a query-ref source on a non-text widget", () => { + const withTable = page(); + + withTable.children!.push( + node({ widgetId: "t1", widgetName: "Results", type: "TABLE_WIDGET_V2" }), + ); + + expect(() => + applyWidgetPatch(withTable, { + operations: [ + { + kind: "update", + name: "Results", + props: { source: { query: "getUsers" } }, + }, + ], + }), + ).toThrow(/can only be set on a TEXT_WIDGET/); + }); + + it("binds whole-response query refs (no field) via patch on text and image", () => { + const withBoth = page(); + + withBoth.children!.push( + node({ widgetId: "txt1", widgetName: "Day", type: "TEXT_WIDGET" }), + node({ widgetId: "img1", widgetName: "Pic", type: "IMAGE_WIDGET" }), + ); + const { dsl } = applyWidgetPatch(withBoth, { + operations: [ + { + kind: "update", + name: "Day", + props: { source: { query: "getDay" } }, + }, + { + kind: "update", + name: "Pic", + props: { imageSource: { query: "getPic" } }, + }, + ], + }); + + expect(dsl.children!.find((w) => w.widgetName === "Day")!.text).toBe( + '{{ getDay.data ?? "" }}', + ); + expect(dsl.children!.find((w) => w.widgetName === "Pic")!.image).toBe( + '{{ getPic.data ?? "" }}', + ); + }); + + it("clears the stale dynamic path when a literal text replaces a query-field binding", () => { + const withText = page(); + + withText.children!.push( + node({ widgetId: "txt1", widgetName: "Temp", type: "TEXT_WIDGET" }), + ); + const { dsl: bound } = applyWidgetPatch(withText, { + operations: [ + { + kind: "update", + name: "Temp", + props: { source: { query: "getWeather", field: "temp" } }, + }, + ], + }); + const { dsl } = applyWidgetPatch(bound, { + operations: [ + { kind: "update", name: "Temp", props: { text: "static again" } }, + ], + }); + const text = dsl.children!.find((w) => w.widgetName === "Temp")!; + + expect(text.text).toBe("static again"); + expect(text.dynamicBindingPathList).toEqual([]); + }); + + it("rejects a mixed selected-row/query ref object", () => { + expect( + widgetPatchSchema.safeParse({ + operations: [ + { + kind: "update", + name: "Temp", + props: { + source: { table: "Users", column: "email", query: "getUsers" }, + }, + }, + ], + }).success, + ).toBe(false); + }); + + it("clears the stale dynamic path when a literal image replaces an imageSource binding", () => { + const withImage = page(); + + withImage.children!.push( + node({ widgetId: "t1", widgetName: "People", type: "TABLE_WIDGET_V2" }), + node({ widgetId: "img1", widgetName: "Photo", type: "IMAGE_WIDGET" }), + ); + + const bound = applyWidgetPatch(withImage, { + operations: [ + { + kind: "update", + name: "Photo", + props: { imageSource: { table: "People", column: "photo" } }, + }, + ], + }); + const { dsl } = applyWidgetPatch(bound.dsl, { + operations: [ + { + kind: "update", + name: "Photo", + props: { image: "https://x/y.png" }, + }, + ], + }); + const image = dsl.children![3]; + + expect(image.image).toBe("https://x/y.png"); + expect(image.dynamicBindingPathList).toEqual([]); + }); + + it("rejects imageSource on a non-image, and a literal image alongside it", () => { + const withImage = page(); + + withImage.children!.push( + node({ widgetId: "t1", widgetName: "People", type: "TABLE_WIDGET_V2" }), + node({ widgetId: "img1", widgetName: "Photo", type: "IMAGE_WIDGET" }), + ); + + // imageSource only applies to image widgets. + expect(() => + applyWidgetPatch(withImage, { + operations: [ + { + kind: "update", + name: "Greeting", + props: { imageSource: { table: "People", column: "photo" } }, + }, + ], + }), + ).toThrow(/can only be set on a IMAGE_WIDGET/); + + // A literal image and an imageSource binding in one update is ambiguous. + expect(() => + applyWidgetPatch(withImage, { + operations: [ + { + kind: "update", + name: "Photo", + props: { + image: "https://x/y.png", + imageSource: { table: "People", column: "photo" }, + }, + }, + ], + }), + ).toThrow(/cannot set both 'image' and 'imageSource'/); + }); + + it("gates visibility on a table's row selection (visibleWhen rowSelected)", () => { + const withTable = page(); + + withTable.children!.push( + node({ widgetId: "t1", widgetName: "Users", type: "TABLE_WIDGET_V2" }), + node({ widgetId: "b1", widgetName: "EditBtn", type: "BUTTON_WIDGET" }), + ); + const { dsl } = applyWidgetPatch(withTable, { + operations: [ + { + kind: "update", + name: "EditBtn", + props: { visibleWhen: { rowSelected: "Users" } }, + }, + ], + }); + const button = dsl.children!.find((w) => w.widgetName === "EditBtn")!; + + expect(button.isVisible).toBe("{{ Users.selectedRowIndex !== -1 }}"); + expect(button.dynamicBindingPathList).toEqual([{ key: "isVisible" }]); + + // Dangling or wrong-type targets reject, same posture as the control form. + expect(() => + applyWidgetPatch(withTable, { + operations: [ + { + kind: "update", + name: "EditBtn", + props: { visibleWhen: { rowSelected: "Missing" } }, + }, + ], + }), + ).toThrow(/was not found/); + + expect(() => + applyWidgetPatch(withTable, { + operations: [ + { + kind: "update", + name: "EditBtn", + props: { visibleWhen: { rowSelected: "EditBtn" } }, + }, + ], + }), + ).toThrow(/is not a table widget/); + }); + + it("gates visibility on an input holding text (visibleWhen notEmpty)", () => { + const withInput = page(); + + withInput.children!.push( + node({ widgetId: "i1", widgetName: "Search", type: "INPUT_WIDGET_V2" }), + node({ widgetId: "b1", widgetName: "GoBtn", type: "BUTTON_WIDGET" }), + ); + const { dsl } = applyWidgetPatch(withInput, { + operations: [ + { + kind: "update", + name: "GoBtn", + props: { visibleWhen: { notEmpty: "Search" } }, + }, + ], + }); + const button = dsl.children!.find((w) => w.widgetName === "GoBtn")!; + + expect(button.isVisible).toBe("{{ !!Search.text }}"); + expect(button.dynamicBindingPathList).toEqual([{ key: "isVisible" }]); + + expect(() => + applyWidgetPatch(withInput, { + operations: [ + { + kind: "update", + name: "GoBtn", + props: { visibleWhen: { notEmpty: "GoBtn" } }, + }, + ], + }), + ).toThrow(/must be an input widget/); + }); + + it("gates a widget's visibility on a select control's value (visibleWhen)", () => { + const withToggle = page(); + + withToggle.children!.push( + node({ widgetId: "t1", widgetName: "Results", type: "TABLE_WIDGET_V2" }), + node({ widgetId: "s1", widgetName: "ViewToggle", type: "SELECT_WIDGET" }), + ); + const { dsl } = applyWidgetPatch(withToggle, { + operations: [ + { + kind: "update", + name: "Results", + props: { visibleWhen: { control: "ViewToggle", equals: "Table" } }, + }, + ], + }); + const table = dsl.children![2]; + + expect(table.isVisible).toBe( + "{{ ViewToggle.selectedOptionValue === 'Table' }}", + ); + expect(table.dynamicBindingPathList).toEqual([{ key: "isVisible" }]); + }); + + it("uses selectedTab for a tabs control, and rejects a non-control or literal-isVisible clash", () => { + const withTabs = page(); + + withTabs.children!.push( + node({ widgetId: "c1", widgetName: "Cards", type: "LIST_WIDGET_V2" }), + node({ widgetId: "tb", widgetName: "ViewTabs", type: "TABS_WIDGET" }), + ); + + const { dsl } = applyWidgetPatch(withTabs, { + operations: [ + { + kind: "update", + name: "Cards", + props: { visibleWhen: { control: "ViewTabs", equals: "Cards" } }, + }, + ], + }); + + expect(dsl.children![2].isVisible).toBe( + "{{ ViewTabs.selectedTab === 'Cards' }}", + ); + + // A non-control target is rejected. + expect(() => + applyWidgetPatch(withTabs, { + operations: [ + { + kind: "update", + name: "Cards", + props: { visibleWhen: { control: "Greeting", equals: "x" } }, + }, + ], + }), + ).toThrow(/must be a select or tabs control/); + + // A literal isVisible alongside visibleWhen is ambiguous. + expect(() => + applyWidgetPatch(withTabs, { + operations: [ + { + kind: "update", + name: "Cards", + props: { + isVisible: true, + visibleWhen: { control: "ViewTabs", equals: "Cards" }, + }, + }, + ], + }), + ).toThrow(/cannot set both 'isVisible' and 'visibleWhen'/); + }); + + it("supports dotted/hyphenated control values and clears the binding when a literal isVisible replaces it", () => { + const withToggle = page(); + + withToggle.children!.push( + node({ widgetId: "t1", widgetName: "Panel", type: "CONTAINER_WIDGET" }), + node({ widgetId: "s1", widgetName: "ViewToggle", type: "SELECT_WIDGET" }), + ); + + const bound = applyWidgetPatch(withToggle, { + operations: [ + { + kind: "update", + name: "Panel", + props: { + visibleWhen: { control: "ViewToggle", equals: "Grid-View.2" }, + }, + }, + ], + }); + + expect(bound.dsl.children![2].isVisible).toBe( + "{{ ViewToggle.selectedOptionValue === 'Grid-View.2' }}", + ); + expect(bound.dsl.children![2].dynamicBindingPathList).toEqual([ + { key: "isVisible" }, + ]); + + // A later literal isVisible clears the dynamic-path registration. + const { dsl } = applyWidgetPatch(bound.dsl, { + operations: [ + { kind: "update", name: "Panel", props: { isVisible: true } }, + ], + }); + + expect(dsl.children![2].isVisible).toBe(true); + expect(dsl.children![2].dynamicBindingPathList).toEqual([]); + }); + + it("rejects a visibleWhen value carrying quote/binding characters at the schema", () => { + for (const equals of ["Table' || evil('", "{{ evil() }}", 'a"b']) { + expect( + widgetPatchSchema.safeParse({ + operations: [ + { + kind: "update", + name: "Results", + props: { visibleWhen: { control: "ViewToggle", equals } }, + }, + ], + }).success, + ).toBe(false); + } + }); + + it("re-binds a table's data with a clear-when-empty guard", () => { + const withWidgets = page(); + + withWidgets.children!.push( + node({ widgetId: "t1", widgetName: "Results", type: "TABLE_WIDGET_V2" }), + node({ + widgetId: "in1", + widgetName: "ZipInput", + type: "INPUT_WIDGET_V2", + }), + ); + const { changes, dsl } = applyWidgetPatch(withWidgets, { + operations: [ + { + kind: "update", + name: "Results", + props: { + tableData: { + query: "lookupZip", + field: "places", + clearWhenEmpty: "ZipInput", + }, + }, + }, + ], + }); + const table = dsl.children![2]; + + expect(table.tableData).toBe( + "{{ ZipInput.text ? (lookupZip.data?.places ?? []) : [] }}", + ); + expect(table.dynamicBindingPathList).toEqual([{ key: "tableData" }]); + expect(changes[0].changedProps).toEqual(["tableData"]); + }); + + it("re-binds a table's data without a guard (plain binding)", () => { + const withTable = page(); + + withTable.children!.push( + node({ widgetId: "t1", widgetName: "Results", type: "TABLE_WIDGET_V2" }), + ); + const { dsl } = applyWidgetPatch(withTable, { + operations: [ + { + kind: "update", + name: "Results", + props: { tableData: { query: "getRows" } }, + }, + ], + }); + + expect(dsl.children![2].tableData).toBe("{{ getRows.data ?? [] }}"); + }); + + it("rejects a tableData binding on a non-table, or a missing guard input", () => { + const withTable = page(); + + withTable.children!.push( + node({ widgetId: "t1", widgetName: "Results", type: "TABLE_WIDGET_V2" }), + ); + + // tableData only applies to tables. + expect(() => + applyWidgetPatch(withTable, { + operations: [ + { + kind: "update", + name: "Greeting", + props: { tableData: { query: "getRows" } }, + }, + ], + }), + ).toThrow(/can only be set on a TABLE_WIDGET_V2/); + + // The guard input must exist. + expect(() => + applyWidgetPatch(withTable, { + operations: [ + { + kind: "update", + name: "Results", + props: { tableData: { query: "getRows", clearWhenEmpty: "Nope" } }, + }, + ], + }), + ).toThrow(/clearWhenEmpty input "Nope" was not found/); + }); + + it("re-binds a table's data with a guard but no field", () => { + const withWidgets = page(); + + withWidgets.children!.push( + node({ widgetId: "t1", widgetName: "Results", type: "TABLE_WIDGET_V2" }), + node({ + widgetId: "in1", + widgetName: "Search", + type: "INPUT_WIDGET_V2", + }), + ); + const { dsl } = applyWidgetPatch(withWidgets, { + operations: [ + { + kind: "update", + name: "Results", + props: { tableData: { query: "getRows", clearWhenEmpty: "Search" } }, + }, + ], + }); + + expect(dsl.children![2].tableData).toBe( + "{{ Search.text ? (getRows.data ?? []) : [] }}", + ); + }); + + it("re-binds a table's data to a store key (M5 store accumulation)", () => { + const withTable = page(); + + withTable.children!.push( + node({ widgetId: "t1", widgetName: "Results", type: "TABLE_WIDGET_V2" }), + ); + const { changes, dsl } = applyWidgetPatch(withTable, { + operations: [ + { + kind: "update", + name: "Results", + props: { tableData: { store: "zipResults" } }, + }, + ], + }); + const table = dsl.children![2]; + + expect(table.tableData).toBe("{{ appsmith.store.zipResults ?? [] }}"); + expect(table.dynamicBindingPathList).toEqual([{ key: "tableData" }]); + expect(changes[0].changedProps).toEqual(["tableData"]); + }); + + it("rejects a store tableData binding on a non-table, a bad store key, or mixed forms", () => { + const withTable = page(); + + withTable.children!.push( + node({ widgetId: "t1", widgetName: "Results", type: "TABLE_WIDGET_V2" }), + ); + + // Store form is table-only, like the query form. + expect(() => + applyWidgetPatch(withTable, { + operations: [ + { + kind: "update", + name: "Greeting", + props: { tableData: { store: "zipResults" } }, + }, + ], + }), + ).toThrow(/can only be set on a TABLE_WIDGET_V2/); + + // Prototype-polluting and digit-leading keys are rejected by the schema. + for (const badKey of ["__proto__", "constructor", "prototype", "1abc"]) { + expect(() => + applyWidgetPatch(withTable, { + operations: [ + { + kind: "update", + name: "Results", + props: { tableData: { store: badKey } }, + }, + ], + }), + ).toThrow(); + } + + // The union arms are strict: store cannot be mixed with query-form props. + expect(() => + applyWidgetPatch(withTable, { + operations: [ + { + kind: "update", + name: "Results", + props: { + tableData: { store: "zipResults", clearWhenEmpty: "ZipInput" }, + }, + }, + ], + }), + ).toThrow(); + }); + + it("rejects a clearWhenEmpty guard that is not an input widget", () => { + const withWidgets = page(); + + withWidgets.children!.push( + node({ widgetId: "t1", widgetName: "Results", type: "TABLE_WIDGET_V2" }), + ); + + // "Greeting" is a TEXT_WIDGET — it has no `.text` value to gate on. + expect(() => + applyWidgetPatch(withWidgets, { + operations: [ + { + kind: "update", + name: "Results", + props: { + tableData: { query: "getRows", clearWhenEmpty: "Greeting" }, + }, + }, + ], + }), + ).toThrow(/must be an input widget/); + }); + + it("adds named-format validation to an input (regex + errorMessage + required)", () => { + const withInput = page(); + + withInput.children!.push( + node({ + widgetId: "in1", + widgetName: "ZipInput", + type: "INPUT_WIDGET_V2", + }), + ); + const { changes, dsl } = applyWidgetPatch(withInput, { + operations: [ + { + kind: "update", + name: "ZipInput", + props: { validation: { format: "zipcode" } }, + }, + ], + }); + const input = dsl.children![2]; + + expect(input.regex).toBe("^\\d{5}$"); + expect(input.errorMessage).toBe("Please enter a 5-digit zip code"); + expect(input.isRequired).toBe(true); + expect(changes[0].changedProps).toEqual(["validation"]); + }); + + it("lets the caller override the validation error message", () => { + const withInput = page(); + + withInput.children!.push( + node({ + widgetId: "in1", + widgetName: "ZipInput", + type: "INPUT_WIDGET_V2", + }), + ); + const { dsl } = applyWidgetPatch(withInput, { + operations: [ + { + kind: "update", + name: "ZipInput", + props: { + validation: { format: "zipcode", message: "5 digits only" }, + }, + }, + ], + }); + + expect(dsl.children![2].errorMessage).toBe("5 digits only"); + }); + + it("compiles a vetted, binding-free regex + message for every named format", () => { + const RAW_EXPRESSION = /\{\{|\}\}|\$\{|`/; + + for (const format of Object.keys( + INPUT_VALIDATION_FORMATS, + ) as InputValidationFormat[]) { + const { errorMessage, regex } = compileInputValidation({ format }); + + // The compiled regex is exactly the vetted preset and carries no Appsmith binding syntax (regex is a + // bind-evaluated prop, so a `{{ }}` in it would be an injection). + expect(regex).toBe(INPUT_VALIDATION_FORMATS[format].regex); + expect(RAW_EXPRESSION.test(regex)).toBe(false); + expect(errorMessage).toBe(INPUT_VALIDATION_FORMATS[format].message); + // Each regex compiles to a real RegExp. + expect(() => new RegExp(regex)).not.toThrow(); + } + + // Spot-check the exact patterns so a typo in any preset is caught. + expect(compileInputValidation({ format: "email" }).regex).toBe( + "^[^\\s@]{1,64}@[^\\s@]{1,255}\\.[^\\s@]{1,63}$", + ); + expect(compileInputValidation({ format: "usPhone" }).regex).toBe( + "^\\d{10}$", + ); + expect(compileInputValidation({ format: "integer" }).regex).toBe( + "^-?\\d+$", + ); + }); + + it("rejects validation on a non-input widget", () => { + expect(() => + applyWidgetPatch(page(), { + operations: [ + { + kind: "update", + name: "Greeting", + props: { validation: { format: "zipcode" } }, + }, + ], + }), + ).toThrow(/can only be set on an INPUT_WIDGET_V2/); + }); + + it("disables a button while a named input is invalid", () => { + const withWidgets = page(); + + withWidgets.children!.push( + node({ + widgetId: "in1", + widgetName: "ZipInput", + type: "INPUT_WIDGET_V2", + }), + node({ + widgetId: "b1", + widgetName: "LookupButton", + type: "BUTTON_WIDGET", + }), + ); + const { dsl } = applyWidgetPatch(withWidgets, { + operations: [ + { + kind: "update", + name: "LookupButton", + props: { disableWhenInvalid: "ZipInput" }, + }, + ], + }); + const button = dsl.children![3]; + + expect(button.isDisabled).toBe("{{ !ZipInput.isValid }}"); + expect(button.dynamicBindingPathList).toEqual([{ key: "isDisabled" }]); + }); + + it("rejects disableWhenInvalid on a missing or non-input widget, or with a literal isDisabled", () => { + const withButton = page(); + + withButton.children!.push( + node({ + widgetId: "b1", + widgetName: "LookupButton", + type: "BUTTON_WIDGET", + }), + ); + + // Missing input. + expect(() => + applyWidgetPatch(withButton, { + operations: [ + { + kind: "update", + name: "LookupButton", + props: { disableWhenInvalid: "Nope" }, + }, + ], + }), + ).toThrow(/disableWhenInvalid input "Nope" was not found/); + + // Non-input (Greeting is a TEXT_WIDGET). + expect(() => + applyWidgetPatch(withButton, { + operations: [ + { + kind: "update", + name: "LookupButton", + props: { disableWhenInvalid: "Greeting" }, + }, + ], + }), + ).toThrow(/must be an input widget/); + + // Ambiguous: literal isDisabled AND disableWhenInvalid. + expect(() => + applyWidgetPatch(withButton, { + operations: [ + { + kind: "update", + name: "LookupButton", + props: { isDisabled: false, disableWhenInvalid: "LookupButton" }, + }, + ], + }), + ).toThrow(/cannot set both 'isDisabled' and 'disableWhenInvalid'/); + }); + + it("rejects an unknown validation format at the schema", () => { + expect( + widgetPatchSchema.safeParse({ + operations: [ + { + kind: "update", + name: "ZipInput", + props: { validation: { format: "ssn" } }, + }, + ], + }).success, + ).toBe(false); + }); + + it("rejects a validation message that carries binding/template syntax", () => { + for (const message of ["{{ evil() }}", "bad ${x}", "back`tick"]) { + expect( + widgetPatchSchema.safeParse({ + operations: [ + { + kind: "update", + name: "ZipInput", + props: { validation: { format: "zipcode", message } }, + }, + ], + }).success, + ).toBe(false); + } + }); + + it("rejects setting a literal isRequired alongside validation (would defeat the guard)", () => { + const withInput = page(); + + withInput.children!.push( + node({ + widgetId: "in1", + widgetName: "ZipInput", + type: "INPUT_WIDGET_V2", + }), + ); + + expect(() => + applyWidgetPatch(withInput, { + operations: [ + { + kind: "update", + name: "ZipInput", + props: { validation: { format: "zipcode" }, isRequired: false }, + }, + ], + }), + ).toThrow(/cannot set both 'isRequired' and 'validation'/); + }); + + it("rejects a binding/template/egress smuggled through a row color", () => { + // Bindings/templates, AND a CSS url() egress primitive (tracking beacon / internal probe), rejected on both + // odd and even row colors. + const bad = [ + "{{ evil() }}", + "${x}", + "red`", + "url(//attacker.example/beacon)", + "url(/x)", + ]; + + for (const color of bad) { + expect( + widgetPatchSchema.safeParse({ + operations: [ + { kind: "update", name: "Results", props: { evenRowColor: color } }, + ], + }).success, + ).toBe(false); + expect( + widgetPatchSchema.safeParse({ + operations: [ + { kind: "update", name: "Results", props: { oddRowColor: color } }, + ], + }).success, + ).toBe(false); + } + }); + + it("accepts legitimate literal color forms for row striping", () => { + for (const color of [ + "#fff", + "#e6f2ff", + "rgb(230, 242, 255)", + "rgba(0,0,0,0.5)", + "hsl(210, 100%, 96%)", + "lightblue", + ]) { + expect( + widgetPatchSchema.safeParse({ + operations: [ + { kind: "update", name: "Results", props: { evenRowColor: color } }, + ], + }).success, + ).toBe(true); + } + }); + + it("compiles a selected-row binding onto a text widget (source)", () => { + const withTable = page(); + + withTable.children!.push( + node({ widgetId: "t1", widgetName: "Users", type: "TABLE_WIDGET_V2" }), + ); + const { changes, dsl } = applyWidgetPatch(withTable, { + operations: [ + { + kind: "update", + name: "Greeting", + props: { source: { table: "Users", column: "email" } }, + }, + ], + }); + const greeting = dsl.children![0]; + + expect(greeting.text).toBe('{{ Users.selectedRow["email"] }}'); + expect(greeting.dynamicBindingPathList).toEqual([{ key: "text" }]); + expect(changes[0].changedProps).toEqual(["source"]); + }); + + it("compiles a selected-row prefill onto an input widget (defaultValue)", () => { + const withInput = page(); + + withInput.children!.push( + node({ widgetId: "t1", widgetName: "Users", type: "TABLE_WIDGET_V2" }), + node({ + widgetId: "in1", + widgetName: "EmailInput", + type: "INPUT_WIDGET_V2", + dynamicBindingPathList: [{ key: "defaultText" }], + }), + ); + const { dsl } = applyWidgetPatch(withInput, { + operations: [ + { + kind: "update", + name: "EmailInput", + props: { defaultValue: { table: "Users", column: "email" } }, + }, + ], + }); + const input = dsl.children![3]; + + expect(input.defaultText).toBe('{{ Users.selectedRow["email"] }}'); + // Re-binding does not duplicate the registered dynamic path. + expect(input.dynamicBindingPathList).toEqual([{ key: "defaultText" }]); + }); + + it("rejects binding patches on the wrong widget type, unknown tables, and non-tables", () => { + const withTable = page(); + + withTable.children!.push( + node({ widgetId: "t1", widgetName: "Users", type: "TABLE_WIDGET_V2" }), + ); + + // source only applies to text widgets. + expect(() => + applyWidgetPatch(withTable, { + operations: [ + { + kind: "update", + name: "Details", + props: { source: { table: "Users", column: "email" } }, + }, + ], + }), + ).toThrow(/can only be set on a TEXT_WIDGET/); + + // The referenced table must exist... + expect(() => + applyWidgetPatch(withTable, { + operations: [ + { + kind: "update", + name: "Greeting", + props: { source: { table: "Nope", column: "email" } }, + }, + ], + }), + ).toThrow(/table "Nope" was not found/); + + // ...and actually be a table. + expect(() => + applyWidgetPatch(withTable, { + operations: [ + { + kind: "update", + name: "Greeting", + props: { source: { table: "Details", column: "email" } }, + }, + ], + }), + ).toThrow(/not a table widget/); + }); + + it("rejects a literal and a binding for the same property in one update", () => { + const withTable = page(); + + withTable.children!.push( + node({ widgetId: "t1", widgetName: "Users", type: "TABLE_WIDGET_V2" }), + ); + + expect(() => + applyWidgetPatch(withTable, { + operations: [ + { + kind: "update", + name: "Greeting", + props: { + text: "static", + source: { table: "Users", column: "email" }, + }, + }, + ], + }), + ).toThrow(/cannot set both 'text' and 'source'/); + }); + + it("rejects a literal defaultText and a defaultValue binding in one update", () => { + const withInput = page(); + + withInput.children!.push( + node({ widgetId: "t1", widgetName: "Users", type: "TABLE_WIDGET_V2" }), + node({ + widgetId: "in1", + widgetName: "EmailInput", + type: "INPUT_WIDGET_V2", + }), + ); + + expect(() => + applyWidgetPatch(withInput, { + operations: [ + { + kind: "update", + name: "EmailInput", + props: { + defaultText: "static", + defaultValue: { table: "Users", column: "email" }, + }, + }, + ], + }), + ).toThrow(/cannot set both 'defaultText' and 'defaultValue'/); + }); + + it("clears the stale dynamic path when a literal later replaces a binding", () => { + const withTable = page(); + + withTable.children!.push( + node({ widgetId: "t1", widgetName: "Users", type: "TABLE_WIDGET_V2" }), + ); + + // Bind first, then overwrite with a literal in a separate update. + const bound = applyWidgetPatch(withTable, { + operations: [ + { + kind: "update", + name: "Greeting", + props: { source: { table: "Users", column: "email" } }, + }, + ], + }); + const { dsl } = applyWidgetPatch(bound.dsl, { + operations: [ + { kind: "update", name: "Greeting", props: { text: "Plain again" } }, + ], + }); + const greeting = dsl.children![0]; + + expect(greeting.text).toBe("Plain again"); + expect(greeting.dynamicBindingPathList).toEqual([]); + }); + + it("schema rejects injection through binding refs in patches", () => { + const bad = [ + { source: { table: "Users", column: 'x"]; evil()//' } }, + { source: { table: "{{Users}}", column: "email" } }, + { defaultValue: { table: "Users", column: "a`b" } }, + ]; + + for (const props of bad) { + expect( + widgetPatchSchema.safeParse({ + operations: [{ kind: "update", name: "Greeting", props }], + }).success, + ).toBe(false); + } + }); + + it("moves a widget into a container's inner canvas and preserves its size", () => { + const { changes, dsl } = applyWidgetPatch(page(), { + operations: [ + { + kind: "move", + name: "Greeting", + parent: "Details", + position: { topRow: 8, leftColumn: 4 }, + }, + ], + }); + const container = dsl.children![0]; + const innerCanvas = container.children![0]; + const greeting = innerCanvas.children![0]; + + expect(dsl.children?.map((child) => child.widgetName)).toEqual(["Details"]); + expect(greeting).toMatchObject({ + widgetName: "Greeting", + parentId: "detailsCanvas", + topRow: 8, + bottomRow: 12, + leftColumn: 4, + rightColumn: 28, + }); + // The move itself, plus the container-fit cascade: Details grows so the widget landing at rows 8..12 is not + // clipped (M6 section D). + expect(changes).toEqual([ + { + kind: "move", + widgetName: "Greeting", + previousParentWidgetName: "MainContainer", + parentWidgetName: "Details", + previousPosition: { topRow: 0, leftColumn: 0 }, + position: { topRow: 8, leftColumn: 4 }, + }, + { + kind: "resize", + widgetName: "Details", + previousSize: { rows: 4 }, + size: { rows: 12 }, + }, + ]); + }); + + it("removes a leaf widget by name", () => { + const { changes, dsl } = applyWidgetPatch(page(), { + operations: [{ kind: "remove", name: "Greeting" }], + }); + + expect(dsl.children?.map((child) => child.widgetName)).toEqual(["Details"]); + expect(changes).toEqual([{ kind: "remove", widgetName: "Greeting" }]); + }); + + it("rejects deletion of the root or a widget with children", () => { + expect(() => + applyWidgetPatch(page(), { + operations: [{ kind: "remove", name: "MainContainer" }], + }), + ).toThrow("root canvas"); + expect(() => + applyWidgetPatch(page(), { + operations: [{ kind: "remove", name: "Details" }], + }), + ).toThrow("has children"); + }); + + it("rejects raw bindings, templates, and non-allowlisted properties", () => { + for (const text of ["{{ Query.data }}", "${dangerous}", "`dangerous`"]) { + expect( + widgetPatchSchema.safeParse({ + operations: [{ kind: "update", name: "Greeting", props: { text } }], + }).success, + ).toBe(false); + } + + expect( + widgetPatchSchema.safeParse({ + operations: [ + { + kind: "update", + name: "Greeting", + props: { dynamicBindingPathList: [] }, + }, + ], + }).success, + ).toBe(false); + }); + + it("rejects U+2028/U+2029 line/paragraph separators in safe text", () => { + // JSON.stringify leaves these unescaped and they terminate a JS string literal on pre-ES2019 engines, so + // safeText must reject them (defense-in-depth for the JS-evaluated select sourceData and every other sink). + for (const sep of [ + String.fromCharCode(0x2028), + String.fromCharCode(0x2029), + ]) { + expect( + widgetPatchSchema.safeParse({ + operations: [ + { kind: "update", name: "Greeting", props: { text: `a${sep}b` } }, + ], + }).success, + ).toBe(false); + } + }); + + it("rejects reparenting a widget under itself or its descendants", () => { + expect(() => + applyWidgetPatch(page(), { + operations: [{ kind: "move", name: "Details", parent: "Details" }], + }), + ).toThrow("cannot be parented to itself"); + }); + + it("is strictly typed at runtime", () => { + expect(() => + widgetPatchSchema.parse({ + operations: [{ kind: "move", name: "Greeting", unknown: true }], + }), + ).toThrow(z.ZodError); + }); +}); + +// M6 — collision-aware move (design section B). +describe("applyWidgetPatch — occupancy-aware move", () => { + function twoStacked(): WidgetNode { + const a = node({ + widgetId: "a", + widgetName: "Upper", + type: "TEXT_WIDGET", + topRow: 0, + bottomRow: 10, + }); + const b = node({ + widgetId: "b", + widgetName: "Lower", + type: "TEXT_WIDGET", + topRow: 12, + bottomRow: 20, + }); + + return node({ + widgetId: "0", + widgetName: "MainContainer", + type: "CANVAS_WIDGET", + bottomRow: 380, + rightColumn: 640, + children: [a, b], + }); + } + + it("repairs a colliding move to the nearest free spot, recording requestedPosition and a note", () => { + const { changes, dsl, notes } = applyWidgetPatch(twoStacked(), { + operations: [ + { kind: "move", name: "Lower", position: { topRow: 2, leftColumn: 0 } }, + ], + }); + const lower = dsl.children![1]; + + // 2 collides with Upper (0..10) -> pushed to 11. + expect(lower).toMatchObject({ topRow: 11, bottomRow: 19 }); + expect(changes[0]).toEqual({ + kind: "move", + widgetName: "Lower", + previousPosition: { topRow: 12, leftColumn: 0 }, + position: { topRow: 11, leftColumn: 0 }, + requestedPosition: { topRow: 2, leftColumn: 0 }, + }); + // The adjustment is ALSO surfaced as a top-level note (agents skim changes; they read notes). + expect(notes.join(" ")).toContain('"Lower"'); + expect(notes.join(" ")).toContain( + "placed at { topRow: 11, leftColumn: 0 }", + ); + }); + + it("keeps the mobile row mirrors in sync with the repaired desktop rows", () => { + const { dsl } = applyWidgetPatch(twoStacked(), { + operations: [ + { kind: "move", name: "Lower", position: { topRow: 2, leftColumn: 0 } }, + ], + }); + const lower = dsl.children![1]; + + expect(lower.mobileTopRow).toBe(11); + expect(lower.mobileBottomRow).toBe(19); + expect(lower.mobileLeftColumn).toBe(0); + expect(lower.mobileRightColumn).toBe(24); + }); + + it("honors a free explicit position exactly (no repair, no requestedPosition)", () => { + const { changes, dsl, notes } = applyWidgetPatch(twoStacked(), { + operations: [ + { + kind: "move", + name: "Lower", + position: { topRow: 30, leftColumn: 8 }, + }, + ], + }); + + expect(dsl.children![1]).toMatchObject({ + topRow: 30, + bottomRow: 38, + leftColumn: 8, + rightColumn: 32, + }); + expect(changes[0].requestedPosition).toBeUndefined(); + expect(notes).toEqual([]); + }); + + it("strict: true rejects a colliding move with the collider names AND the nearest free position", () => { + expect(() => + applyWidgetPatch(twoStacked(), { + operations: [ + { + kind: "move", + name: "Lower", + position: { topRow: 2, leftColumn: 0 }, + strict: true, + }, + ], + }), + ).toThrow( + 'moving "Lower" to { topRow: 2, leftColumn: 0 } would overlap "Upper"; nearest free position is { topRow: 11, leftColumn: 0 }', + ); + }); + + it("reparenting is occupancy-aware: the landing position avoids occupants and is always recorded", () => { + const dsl = twoStacked(); + const inner = node({ + widgetId: "cardCanvas", + widgetName: "CardCanvas", + type: "CANVAS_WIDGET", + topRow: 0, + bottomRow: 30, + rightColumn: 24, + children: [ + node({ + widgetId: "occ", + widgetName: "Occupant", + type: "TEXT_WIDGET", + topRow: 0, + bottomRow: 6, + }), + ], + }); + + dsl.children!.push( + node({ + widgetId: "card", + widgetName: "Card", + type: "CONTAINER_WIDGET", + topRow: 22, + bottomRow: 52, + rightColumn: 24, + children: [inner], + }), + ); + + const { changes, dsl: edited } = applyWidgetPatch(dsl, { + operations: [{ kind: "move", name: "Upper", parent: "Card" }], + }); + const moved = edited + .children!.find((c) => c.widgetName === "Card")! + .children![0].children!.find((c) => c.widgetName === "Upper")!; + + // Old coordinates (0..10) collide with Occupant (0..6): server lands it at 7 and records the position. + expect(moved).toMatchObject({ topRow: 7, bottomRow: 17, leftColumn: 0 }); + expect(changes[0]).toMatchObject({ + kind: "move", + widgetName: "Upper", + previousParentWidgetName: "MainContainer", + parentWidgetName: "Card", + previousPosition: { topRow: 0, leftColumn: 0 }, + position: { topRow: 7, leftColumn: 0 }, + }); + }); + + it("moves a detached modal without occupancy checks or cascades", () => { + const dsl = twoStacked(); + + dsl.children!.push( + node({ + widgetId: "m1", + widgetName: "AddModal", + type: "MODAL_WIDGET", + topRow: 0, + bottomRow: 24, + rightColumn: 32, + detachFromLayout: true, + }), + ); + + const { + changes, + dsl: edited, + notes, + } = applyWidgetPatch(dsl, { + operations: [ + { + kind: "move", + name: "AddModal", + position: { topRow: 5, leftColumn: 0 }, + }, + ], + }); + + // The nominal rect moved; no repair against the in-flow widgets it "covers". + expect(edited.children![2]).toMatchObject({ topRow: 5, bottomRow: 29 }); + expect(changes).toHaveLength(1); + expect(notes).toEqual([]); + }); + + it("rejects moving a widget whose rect is non-numeric", () => { + const dsl = twoStacked(); + + dsl.children![0].topRow = Number.NaN; + + expect(() => + applyWidgetPatch(dsl, { + operations: [ + { + kind: "move", + name: "Upper", + position: { topRow: 30, leftColumn: 0 }, + }, + ], + }), + ).toThrow(/non-numeric position/); + }); +}); + +// M6 — the resize operation (design section B2). +describe("applyWidgetPatch — resize", () => { + function pageWith(children: WidgetNode[]): WidgetNode { + return node({ + widgetId: "0", + widgetName: "MainContainer", + type: "CANVAS_WIDGET", + bottomRow: 380, + rightColumn: 640, + children, + }); + } + + function card(bodyChildren: WidgetNode[], rows: number): WidgetNode { + const inner = node({ + widgetId: "cardCanvas", + widgetName: "CardCanvas", + type: "CANVAS_WIDGET", + topRow: 0, + bottomRow: rows, + rightColumn: 40, + children: bodyChildren, + }); + + return node({ + widgetId: "card", + widgetName: "Card", + type: "CONTAINER_WIDGET", + topRow: 0, + bottomRow: rows, + rightColumn: 40, + children: [inner], + }); + } + + it("grows a widget and cascade-pushes the colliding below-sibling, recording both", () => { + const dsl = pageWith([ + node({ + widgetId: "a", + widgetName: "Upper", + type: "TEXT_WIDGET", + topRow: 0, + bottomRow: 10, + }), + node({ + widgetId: "b", + widgetName: "Lower", + type: "TEXT_WIDGET", + topRow: 11, + bottomRow: 18, + }), + ]); + const { + changes, + dsl: edited, + notes, + } = applyWidgetPatch(dsl, { + operations: [{ kind: "resize", name: "Upper", rows: 15 }], + }); + const [upper, lower] = edited.children!; + + expect(upper).toMatchObject({ topRow: 0, bottomRow: 15 }); + // Lower was pushed just past the grown widget. + expect(lower).toMatchObject({ topRow: 16, bottomRow: 23 }); + expect(lower.mobileTopRow).toBe(16); + expect(changes[0]).toEqual({ + kind: "resize", + widgetName: "Upper", + previousSize: { rows: 10, columns: 24 }, + size: { rows: 15, columns: 24 }, + }); + expect(changes[1]).toMatchObject({ kind: "move", widgetName: "Lower" }); + expect(notes.join(" ")).toContain('"Lower"'); + }); + + it("strict: true rejects growth that would land on a sibling", () => { + const dsl = pageWith([ + node({ + widgetId: "a", + widgetName: "Upper", + type: "TEXT_WIDGET", + topRow: 0, + bottomRow: 10, + }), + node({ + widgetId: "b", + widgetName: "Lower", + type: "TEXT_WIDGET", + topRow: 11, + bottomRow: 18, + }), + ]); + + expect(() => + applyWidgetPatch(dsl, { + operations: [{ kind: "resize", name: "Upper", rows: 15, strict: true }], + }), + ).toThrow(/would overlap "Lower"/); + }); + + it("rejects width growth past the canvas with the available columns", () => { + const dsl = pageWith([ + node({ + widgetId: "a", + widgetName: "Wide", + type: "TEXT_WIDGET", + leftColumn: 50, + rightColumn: 64, + }), + ]); + + expect(() => + applyWidgetPatch(dsl, { + operations: [{ kind: "resize", name: "Wide", columns: 20 }], + }), + ).toThrow(/14 columns are available from leftColumn 50/); + }); + + it("rejects shrinking a container below its children with the executable minimum", () => { + const dsl = pageWith([ + card( + [ + node({ + widgetId: "f", + widgetName: "Field", + type: "INPUT_WIDGET_V2", + topRow: 0, + bottomRow: 30, + }), + ], + 40, + ), + ]); + + expect(() => + applyWidgetPatch(dsl, { + operations: [{ kind: "resize", name: "Card", rows: 10 }], + }), + ).toThrow(/smallest rows that fit the children: 30/); + + expect(() => + applyWidgetPatch(dsl, { + operations: [{ kind: "resize", name: "Card", columns: 10 }], + }), + ).toThrow(/smallest columns that fit the children: 24/); + }); + + it("shrinks a container down to (but not past) its children, keeping the inner canvas in step", () => { + const dsl = pageWith([ + card( + [ + node({ + widgetId: "f", + widgetName: "Field", + type: "INPUT_WIDGET_V2", + topRow: 0, + bottomRow: 30, + }), + ], + 40, + ), + ]); + const { dsl: edited } = applyWidgetPatch(dsl, { + operations: [{ kind: "resize", name: "Card", rows: 30 }], + }); + const container = edited.children![0]; + + expect(container).toMatchObject({ topRow: 0, bottomRow: 30 }); + expect(container.children![0]).toMatchObject({ bottomRow: 30 }); + expect(container.mobileBottomRow).toBe(30); + }); + + it("translates modal rows into the pixel height prop (rows × rowHeightPx)", () => { + const dsl = pageWith([ + node({ + widgetId: "m1", + widgetName: "AddModal", + type: "MODAL_WIDGET", + topRow: 0, + bottomRow: 24, + rightColumn: 32, + height: 252, + detachFromLayout: true, + children: [ + node({ + widgetId: "mc", + widgetName: "ModalCanvas", + type: "CANVAS_WIDGET", + topRow: 0, + bottomRow: 24, + rightColumn: 32, + children: [], + }), + ], + }), + ]); + const { + changes, + dsl: edited, + notes, + } = applyWidgetPatch(dsl, { + operations: [{ kind: "resize", name: "AddModal", rows: 40 }], + }); + + expect(edited.children![0].height).toBe(400); + expect(changes[0]).toEqual({ + kind: "resize", + widgetName: "AddModal", + previousSize: { rows: 25 }, + size: { rows: 40 }, + }); + expect(notes.join(" ")).toContain("400px"); + }); + + it("rejects modal column resizing (height-only vocabulary in v1)", () => { + const dsl = pageWith([ + node({ + widgetId: "m1", + widgetName: "AddModal", + type: "MODAL_WIDGET", + height: 252, + detachFromLayout: true, + }), + ]); + + expect(() => + applyWidgetPatch(dsl, { + operations: [{ kind: "resize", name: "AddModal", columns: 40 }], + }), + ).toThrow(/rows only/); + }); + + it("schema: resize requires rows or columns, integers >= 1, and is strictly typed", () => { + expect( + widgetPatchSchema.safeParse({ + operations: [{ kind: "resize", name: "Card" }], + }).success, + ).toBe(false); + expect( + widgetPatchSchema.safeParse({ + operations: [{ kind: "resize", name: "Card", rows: 0 }], + }).success, + ).toBe(false); + expect( + widgetPatchSchema.safeParse({ + operations: [{ kind: "resize", name: "Card", rows: 2.5 }], + }).success, + ).toBe(false); + expect( + widgetPatchSchema.safeParse({ + operations: [{ kind: "resize", name: "Card", rows: 12, unknown: 1 }], + }).success, + ).toBe(false); + expect( + widgetPatchSchema.safeParse({ + operations: [ + { kind: "resize", name: "Card", rows: 12, columns: 20, strict: true }, + ], + }).success, + ).toBe(true); + }); +}); diff --git a/app/client/packages/mcp/src/builder/editPatch.ts b/app/client/packages/mcp/src/builder/editPatch.ts new file mode 100644 index 000000000000..0ab96cd3afbd --- /dev/null +++ b/app/client/packages/mcp/src/builder/editPatch.ts @@ -0,0 +1,1147 @@ +import { z } from "zod"; +import { ROOT_WIDGET_ID, ROW_HEIGHT, type WidgetNode } from "./layout.js"; +import { + applyPosition, + canvasColumns, + cascadeFit, + clampRow, + collisions, + contentExtent, + isDetached, + isNumber, + MAX_ROW, + nearestFreePosition, + rectOf, + syncMobileRows, + type CascadeAdjustment, + type Position, + type Rect, +} from "./occupancy.js"; +import { containsModal, hostModalOf, PAGE_HOST } from "./modalGraph.js"; +import { + compileDisableWhenInvalid, + compileInputValidation, + compileComputedValue, + compileQueryFieldBinding, + compileSelectedRowBinding, + computedValueSchema, + computedValueTableRefs, + compileTableDataBinding, + compileNotEmptyBinding, + compileRowSelectedBinding, + compileVisibleWhenBinding, + inputValidationSchema, + queryFieldRefSchema, + selectedRowRefSchema, + tableDataBindingSchema, + visibleWhenRefSchema, + type InputValidationRef, + type QueryFieldRef, + type SelectedRowRef, + type TableDataBinding, + type VisibleWhenRef, +} from "./schema.js"; + +// Mirrors schema.ts RAW_EXPRESSION, including U+2028/U+2029 line/paragraph separators that JSON.stringify leaves +// unescaped and that terminate a JS string literal on pre-ES2019 engines. +const RAW_EXPRESSION = /\u007b\u007b|\u007d\u007d|\$\u007b|`|\u2028|\u2029/; +const MAX_WIDGET_NAME_LENGTH = 64; + +const widgetNameSchema = z + .string() + .trim() + .min(1) + .max(MAX_WIDGET_NAME_LENGTH) + .regex(/^[A-Za-z0-9_]+$/, "must be alphanumeric/underscore"); + +const safeText = (max: number) => + z + .string() + .max(max) + .refine( + (value) => !RAW_EXPRESSION.test(value), + "must not contain bindings", + ); + +// A literal CSS color for style props (e.g. table row colors), emitted raw into a styled-component +// `background: ${color}` declaration. This is a COLOR-GRAMMAR allowlist, not a loose charset: hex, an +// rgb/rgba/hsl/hsla function with a numeric-only argument list, or a bare named-color token. Crucially it cannot +// spell `url(...)` (letters are forbidden inside the function parens), so it admits no CSS egress primitive — a +// value can neither fetch a URL (tracking beacon / internal-network probe) nor form a binding/expression. +const cssColor = z + .string() + .min(1) + .max(64) + .regex( + /^(?:#[0-9A-Fa-f]{3,8}|(?:rgb|rgba|hsl|hsla)\(\s*[0-9.,%/ ]+\s*\)|[A-Za-z]+)$/, + "color must be a literal color: hex (#rgb/#rrggbb), rgb()/rgba()/hsl()/hsla(), or a named color", + ); + +const literalScalarSchema = z.union([ + safeText(10_000), + z.number().finite(), + z.boolean(), + z.null(), +]); + +const optionSchema = z + .object({ + label: safeText(200).pipe(z.string().min(1)), + value: literalScalarSchema, + }) + .strict(); + +// This is deliberately a closed, mostly literal-only property vocabulary. It excludes events, raw bindings, +// dynamic path lists, and arbitrary style/configuration fields that could become executable at render time. The two +// exceptions — `source` (text) and `defaultValue` (input) — are STRUCTURED selected-row references compiled by the +// patch applier into `{{ Table.selectedRow[...] }}` bindings; the agent still never authors expression text. +export const widgetPropsPatchSchema = z + .object({ + text: safeText(10_000).optional(), + // Text content: a selected-row ref (detail views) OR a query-field ref (scalar readouts). + source: z.union([selectedRowRefSchema, queryFieldRefSchema]).optional(), + // Computed text value (dates, counts, concat) — compiled onto the text prop. Text-only. + value: computedValueSchema.optional(), + defaultValue: selectedRowRefSchema.optional(), + // Bind an image's src to a column of a table's selected row (e.g. an employee photo in a detail panel) OR + // to a field of a query's response. + imageSource: z + .union([selectedRowRefSchema, queryFieldRefSchema]) + .optional(), + // Gate a widget's visibility: on a control's value ({ control, equals } — a view toggle switching panels), + // on a table having a selected row ({ rowSelected } — detail panels/action buttons), or on an input holding + // text ({ notEmpty }). Compiled to isVisible. + visibleWhen: visibleWhenRefSchema.optional(), + // Re-bind a table's data: a query ref (optionally clear-when-empty) OR (M5) a store key accumulated by + // wire_event's appendToStore ({ store: '' }). Compiled, never literal-assigned. + tableData: tableDataBindingSchema.optional(), + // Add named-format validation to an input (compiled to a vetted regex + error message). + validation: inputValidationSchema.optional(), + // Disable this widget while the named input is invalid — compiled to `{{ !.isValid }}`. + disableWhenInvalid: widgetNameSchema.optional(), + label: safeText(200).optional(), + inputType: z.enum(["TEXT", "NUMBER", "EMAIL", "PASSWORD"]).optional(), + options: z.array(optionSchema).max(200).optional(), + title: safeText(200).optional(), + image: safeText(2_000).optional(), + chartType: z + .enum([ + "LINE_CHART", + "BAR_CHART", + "PIE_CHART", + "COLUMN_CHART", + "AREA_CHART", + ]) + .optional(), + chartName: safeText(200).optional(), + defaultText: safeText(10_000).optional(), + placeholderText: safeText(200).optional(), + dateFormat: safeText(100).optional(), + isRequired: z.boolean().optional(), + isDisabled: z.boolean().optional(), + isVisible: z.boolean().optional(), + // Table row-striping (TableWidgetV2 style props). Literal colors only — never bindings — so the zebra effect + // ships as static style, not an evaluated expression. + oddRowColor: cssColor.optional(), + evenRowColor: cssColor.optional(), + // Table interactivity toggles (TableWidgetV2). Literal booleans — turn on client-side search across all columns, + // the column filter UI, sorting, download, and pagination for a directory-style browse experience. + isVisibleSearch: z.boolean().optional(), + enableClientSideSearch: z.boolean().optional(), + isVisibleFilters: z.boolean().optional(), + isSortable: z.boolean().optional(), + isVisibleDownload: z.boolean().optional(), + isVisiblePagination: z.boolean().optional(), + }) + .strict() + .refine((props) => Object.keys(props).length > 0, "must update a property"); + +const positionSchema = z + .object({ + topRow: z.number().int().min(0), + leftColumn: z.number().int().min(0), + }) + .strict(); + +const updateOperationSchema = z + .object({ + kind: z.literal("update"), + name: widgetNameSchema, + props: widgetPropsPatchSchema, + }) + .strict(); + +const moveOperationSchema = z + .object({ + kind: z.literal("move"), + name: widgetNameSchema, + parent: widgetNameSchema.optional(), + position: positionSchema.optional(), + // Default (false): a position that lands on another widget is REPAIRED to the nearest free spot below, and the + // adjustment is reported. strict: true rejects the collision instead, naming the colliders and the nearest + // free position so one retry can succeed. + strict: z.boolean().optional(), + }) + .strict() + .refine( + (operation) => + operation.parent !== undefined || operation.position !== undefined, + "must set parent or position", + ); + +// M6 (B2): resize a widget in grid units. rows/columns are the widget's SPAN (bottomRow - topRow / +// rightColumn - leftColumn); at least one is required. Modals translate rows into their pixel height prop. +const resizeOperationSchema = z + .object({ + kind: z.literal("resize"), + name: widgetNameSchema, + rows: z.number().int().min(1).max(MAX_ROW).optional(), + columns: z.number().int().min(1).max(MAX_ROW).optional(), + // Default (false): growth that collides pushes the overlapping siblings down (cascade). strict: true rejects + // the collision instead, naming the colliders. + strict: z.boolean().optional(), + }) + .strict() + .refine( + (operation) => + operation.rows !== undefined || operation.columns !== undefined, + "must set rows or columns", + ); + +const removeOperationSchema = z + .object({ + kind: z.literal("remove"), + name: widgetNameSchema, + }) + .strict(); + +export const widgetPatchSchema = z + .object({ + operations: z + .array( + z.union([ + updateOperationSchema, + moveOperationSchema, + resizeOperationSchema, + removeOperationSchema, + ]), + ) + .min(1) + .max(50), + }) + .strict(); + +export type WidgetPatch = z.infer; +export type WidgetPatchOperation = WidgetPatch["operations"][number]; +export type WidgetPropsPatch = z.infer; + +export interface WidgetPatchChange { + kind: WidgetPatchOperation["kind"]; + widgetName: string; + changedProps?: (keyof WidgetPropsPatch)[]; + previousParentWidgetName?: string; + parentWidgetName?: string; + previousPosition?: { topRow: number; leftColumn: number }; + position?: { topRow: number; leftColumn: number }; + // Present when a colliding move was repaired: what the caller asked for vs the `position` actually applied. + requestedPosition?: { topRow: number; leftColumn: number }; + // resize semantics: the widget's span in grid units before/after (modals report rows derived from their px height). + previousSize?: { rows?: number; columns?: number }; + size?: { rows?: number; columns?: number }; +} + +export interface WidgetPatchResult { + dsl: WidgetNode; + changes: WidgetPatchChange[]; + // Human-readable adjustment reports (collision repairs, cascade pushes, modal-scroll warnings). Agents skim + // `changes`; they read notes — every automatic adjustment is surfaced here too. + notes: string[]; +} + +interface LocatedWidget { + node: WidgetNode; + parent?: WidgetNode; +} + +function cloneDsl(dsl: WidgetNode): WidgetNode { + return JSON.parse(JSON.stringify(dsl)) as WidgetNode; +} + +function indexWidgets(root: WidgetNode): Map { + const widgets = new Map(); + + function visit(node: WidgetNode, parent?: WidgetNode): void { + if (widgets.has(node.widgetName)) { + throw new Error(`widget names must be unique: "${node.widgetName}"`); + } + + widgets.set(node.widgetName, { node, parent }); + + for (const child of node.children ?? []) visit(child, node); + } + + visit(root); + + return widgets; +} + +function requireWidget( + widgets: Map, + name: string, +): LocatedWidget { + const widget = widgets.get(name); + + if (!widget) throw new Error(`widget "${name}" was not found`); + + if (widget.node.widgetId === ROOT_WIDGET_ID) { + throw new Error("the root canvas cannot be modified"); + } + + return widget; +} + +function directCanvas(node: WidgetNode): WidgetNode | undefined { + if (node.type === "CANVAS_WIDGET") return node; + + return node.children?.find((child) => child.type === "CANVAS_WIDGET"); +} + +function isDescendant(ancestor: WidgetNode, candidate: WidgetNode): boolean { + for (const child of ancestor.children ?? []) { + if ( + child.widgetId === candidate.widgetId || + isDescendant(child, candidate) + ) { + return true; + } + } + + return false; +} + +function removeFromParent(widget: LocatedWidget): void { + if (!widget.parent?.children) { + throw new Error( + `widget "${widget.node.widgetName}" has no removable parent`, + ); + } + + const index = widget.parent.children.findIndex( + (child) => child.widgetId === widget.node.widgetId, + ); + + if (index === -1) { + throw new Error(`widget "${widget.node.widgetName}" is not in its parent`); + } + + widget.parent.children.splice(index, 1); +} + +function positionOf(node: WidgetNode): { topRow: number; leftColumn: number } { + return { topRow: node.topRow, leftColumn: node.leftColumn }; +} + +function registerDynamicBinding(node: WidgetNode, key: string): void { + const paths = Array.isArray(node.dynamicBindingPathList) + ? (node.dynamicBindingPathList as { key: string }[]) + : []; + + if (!paths.some((entry) => entry.key === key)) paths.push({ key }); + + node.dynamicBindingPathList = paths; +} + +// When a literal replaces a previously bound property, the stale dynamic-path entry must go too — otherwise the +// widget keeps advertising a binding it no longer has. +function unregisterDynamicBinding(node: WidgetNode, key: string): void { + if (!Array.isArray(node.dynamicBindingPathList)) return; + + node.dynamicBindingPathList = ( + node.dynamicBindingPathList as { key: string }[] + ).filter((entry) => entry.key !== key); +} + +// Compile a structured selected-row reference onto a widget: type-checked target property, dangling-table guard, +// compiler-emitted binding, and dynamic-path registration. The agent never supplies the expression. +function applySelectedRowBinding( + widgets: Map, + node: WidgetNode, + ref: SelectedRowRef, + expected: { widgetType: string; property: string; field: string }, +): void { + if (node.type !== expected.widgetType) { + throw new Error( + `'${expected.field}' can only be set on a ${expected.widgetType} ("${node.widgetName}" is ${node.type})`, + ); + } + + const table = widgets.get(ref.table); + + if (!table) throw new Error(`table "${ref.table}" was not found`); + + if (table.node.type !== "TABLE_WIDGET_V2") { + throw new Error(`"${ref.table}" is not a table widget`); + } + + node[expected.property] = compileSelectedRowBinding(ref); + registerDynamicBinding(node, expected.property); +} + +// A scalar display slot (text's content, image's src) accepts EITHER a selected-row ref or a query-field ref; +// the strict object shapes discriminate by key. Selected-row refs get the dangling-table guard; query refs +// compile directly (queries live outside the widget map, matching applyTableDataBinding's posture). +function applyScalarDisplayBinding( + widgets: Map, + node: WidgetNode, + ref: SelectedRowRef | QueryFieldRef, + expected: { widgetType: string; property: string; field: string }, +): void { + if ("table" in ref) { + applySelectedRowBinding(widgets, node, ref, expected); + + return; + } + + if (node.type !== expected.widgetType) { + throw new Error( + `'${expected.field}' can only be set on a ${expected.widgetType} ("${node.widgetName}" is ${node.type})`, + ); + } + + node[expected.property] = compileQueryFieldBinding(ref); + registerDynamicBinding(node, expected.property); +} + +// Re-bind a table's data to a query (optionally clear-when-empty) or to a store key (M5). Type-checks the target, +// verifies the guard input exists, emits the binding from the closed vocabulary, and registers the dynamic path. +// The agent never supplies expression text. +function applyTableDataBinding( + widgets: Map, + node: WidgetNode, + ref: TableDataBinding, +): void { + if (node.type !== "TABLE_WIDGET_V2") { + throw new Error( + `'tableData' can only be set on a TABLE_WIDGET_V2 ("${node.widgetName}" is ${node.type})`, + ); + } + + // The store form has no guard/field to verify — its key is schema-validated and the binding head (appsmith) is + // always valid, so it compiles directly. + if ("store" in ref) { + node.tableData = compileTableDataBinding(ref); + registerDynamicBinding(node, "tableData"); + + return; + } + + // The guard is emitted as `${guard}.text` — so it must be an input widget. A non-input has no `.text` (undefined + // is falsy), which would silently keep the table permanently empty; reject that up front instead. + if (ref.clearWhenEmpty !== undefined) { + const guard = widgets.get(ref.clearWhenEmpty); + + if (!guard) { + throw new Error( + `clearWhenEmpty input "${ref.clearWhenEmpty}" was not found`, + ); + } + + if (guard.node.type !== "INPUT_WIDGET_V2") { + throw new Error( + `clearWhenEmpty "${ref.clearWhenEmpty}" must be an input widget (it is ${guard.node.type})`, + ); + } + } + + node.tableData = compileTableDataBinding(ref); + registerDynamicBinding(node, "tableData"); +} + +// Add named-format validation to an input: emits a vetted literal regex + error message and marks the input +// required (so an empty value is invalid too). Input-only; the agent never supplies a regex. +function applyInputValidation(node: WidgetNode, ref: InputValidationRef): void { + if (node.type !== "INPUT_WIDGET_V2") { + throw new Error( + `'validation' can only be set on an INPUT_WIDGET_V2 ("${node.widgetName}" is ${node.type})`, + ); + } + + const { errorMessage, regex } = compileInputValidation(ref); + + node.regex = regex; + node.errorMessage = errorMessage; + node.isRequired = true; +} + +// Disable a widget while the named input is invalid — emits `{{ !.isValid }}` onto isDisabled and registers +// the dynamic path. The referenced widget must be an input (only inputs expose `.isValid`). +function applyDisableWhenInvalid( + widgets: Map, + node: WidgetNode, + inputName: string, +): void { + const input = widgets.get(inputName); + + if (!input) { + throw new Error(`disableWhenInvalid input "${inputName}" was not found`); + } + + if (input.node.type !== "INPUT_WIDGET_V2") { + throw new Error( + `disableWhenInvalid "${inputName}" must be an input widget (it is ${input.node.type})`, + ); + } + + node.isDisabled = compileDisableWhenInvalid(inputName); + registerDynamicBinding(node, "isDisabled"); +} + +// The meta value property a control widget exposes, used by visibleWhen. Only controls that hold a single selectable +// value are supported; the agent never supplies the property name. +const CONTROL_VALUE_PROPS: Record = { + SELECT_WIDGET: "selectedOptionValue", + TABS_WIDGET: "selectedTab", +}; + +// Gate a widget's visibility on a control's value — emits `{{ Control. === '' }}` onto isVisible. +// The control must exist and be a supported single-value control. +function applyVisibleWhenBinding( + widgets: Map, + node: WidgetNode, + ref: VisibleWhenRef, +): void { + // Row-selection predicate: visible only while the referenced table has a selected row. + if ("rowSelected" in ref) { + const table = widgets.get(ref.rowSelected); + + if (!table) { + throw new Error(`visibleWhen table "${ref.rowSelected}" was not found`); + } + + if (table.node.type !== "TABLE_WIDGET_V2") { + throw new Error(`"${ref.rowSelected}" is not a table widget`); + } + + node.isVisible = compileRowSelectedBinding(ref.rowSelected); + registerDynamicBinding(node, "isVisible"); + + return; + } + + // Non-empty-input predicate: visible only while the referenced input holds text. + if ("notEmpty" in ref) { + const input = widgets.get(ref.notEmpty); + + if (!input) { + throw new Error(`visibleWhen input "${ref.notEmpty}" was not found`); + } + + if (input.node.type !== "INPUT_WIDGET_V2") { + throw new Error( + `visibleWhen "${ref.notEmpty}" must be an input widget (it is ${input.node.type})`, + ); + } + + node.isVisible = compileNotEmptyBinding(ref.notEmpty); + registerDynamicBinding(node, "isVisible"); + + return; + } + + const control = widgets.get(ref.control); + + if (!control) { + throw new Error(`visibleWhen control "${ref.control}" was not found`); + } + + const valueProp = CONTROL_VALUE_PROPS[control.node.type]; + + if (!valueProp) { + throw new Error( + `visibleWhen "${ref.control}" must be a select or tabs control (it is ${control.node.type})`, + ); + } + + node.isVisible = compileVisibleWhenBinding( + ref.control, + valueProp, + ref.equals, + ); + registerDynamicBinding(node, "isVisible"); +} + +function formatPosition(position: Position): string { + return `{ topRow: ${position.topRow}, leftColumn: ${position.leftColumn} }`; +} + +function formatNames(names: string[]): string { + return names.map((name) => `"${name}"`).join(", "); +} + +// Fold the container-fit cascade's adjustments into the patch result so every server-initiated push/growth is +// visible in `changes` alongside the operation that caused it. +function mergeCascade( + changes: WidgetPatchChange[], + notes: string[], + cascade: { adjustments: CascadeAdjustment[]; notes: string[] }, +): void { + for (const adjustment of cascade.adjustments) { + changes.push({ ...adjustment }); + } + + notes.push(...cascade.notes); +} + +// The inner CANVAS_WIDGET children of a container-like widget (one for containers/forms/modals, one per tab for +// tabs). Empty for plain widgets. +function innerCanvasesOf(node: WidgetNode): WidgetNode[] { + return (node.children ?? []).filter( + (child) => child.type === "CANVAS_WIDGET", + ); +} + +// Applies local, typed widget mutations without interpreting templates or bindings. The input DSL is never mutated. +export function applyWidgetPatch( + currentDsl: WidgetNode, + patchInput: unknown, +): WidgetPatchResult { + const patch = widgetPatchSchema.parse(patchInput); + const dsl = cloneDsl(currentDsl); + const changes: WidgetPatchChange[] = []; + const notes: string[] = []; + + for (const operation of patch.operations) { + const widgets = indexWidgets(dsl); + const located = requireWidget(widgets, operation.name); + + if (operation.kind === "update") { + // Structured binding refs are compiled (never literal-assigned); everything else is a plain literal. + const { + defaultValue, + disableWhenInvalid, + imageSource, + source, + tableData, + validation, + value, + visibleWhen, + ...literals + } = operation.props; + + // A binding and a literal for the same property in one patch would silently race; reject the ambiguity. + if (source !== undefined && literals.text !== undefined) { + throw new Error("cannot set both 'text' and 'source' in one update"); + } + + // `value` (computed) also compiles onto the text prop — any combination with text/source is ambiguous. + if ( + value !== undefined && + (literals.text !== undefined || source !== undefined) + ) { + throw new Error( + "cannot set both 'value' and 'text'/'source' in one update", + ); + } + + if (defaultValue !== undefined && literals.defaultText !== undefined) { + throw new Error( + "cannot set both 'defaultText' and 'defaultValue' in one update", + ); + } + + if (imageSource !== undefined && literals.image !== undefined) { + throw new Error( + "cannot set both 'image' and 'imageSource' in one update", + ); + } + + if (visibleWhen !== undefined && literals.isVisible !== undefined) { + throw new Error( + "cannot set both 'isVisible' and 'visibleWhen' in one update", + ); + } + + // disableWhenInvalid emits an isDisabled binding; a literal isDisabled in the same update would race it. + if ( + disableWhenInvalid !== undefined && + literals.isDisabled !== undefined + ) { + throw new Error( + "cannot set both 'isDisabled' and 'disableWhenInvalid' in one update", + ); + } + + // validation marks the input required (so empty is invalid too); a literal isRequired in the same update runs + // last via Object.assign and would silently clobber it — reject the ambiguity rather than defeat the guard. + if (validation !== undefined && literals.isRequired !== undefined) { + throw new Error( + "cannot set both 'isRequired' and 'validation' in one update", + ); + } + + if (source !== undefined) { + applyScalarDisplayBinding(widgets, located.node, source, { + widgetType: "TEXT_WIDGET", + property: "text", + field: "source", + }); + } + + if (value !== undefined) { + if (located.node.type !== "TEXT_WIDGET") { + throw new Error( + `'value' can only be set on a TEXT_WIDGET ("${located.node.widgetName}" is ${located.node.type})`, + ); + } + + // Guard parity with `source` [COUNCIL: B2 architect]: every { table, column } ref inside the + // computed value (concat parts, formula leaves) gets the same dangling-table checks; query refs + // stay unguarded per the documented posture (queries live outside the widget map, and a missing + // query degrades to a blank part, a safe fail). + for (const tableName of computedValueTableRefs(value)) { + const table = widgets.get(tableName); + + if (!table) throw new Error(`table "${tableName}" was not found`); + + if (table.node.type !== "TABLE_WIDGET_V2") { + throw new Error(`"${tableName}" is not a table widget`); + } + } + + located.node.text = compileComputedValue(value); + registerDynamicBinding(located.node, "text"); + } + + if (defaultValue !== undefined) { + applySelectedRowBinding(widgets, located.node, defaultValue, { + widgetType: "INPUT_WIDGET_V2", + property: "defaultText", + field: "defaultValue", + }); + } + + if (imageSource !== undefined) { + applyScalarDisplayBinding(widgets, located.node, imageSource, { + widgetType: "IMAGE_WIDGET", + property: "image", + field: "imageSource", + }); + } + + if (tableData !== undefined) { + applyTableDataBinding(widgets, located.node, tableData); + } + + if (validation !== undefined) { + applyInputValidation(located.node, validation); + } + + if (disableWhenInvalid !== undefined) { + applyDisableWhenInvalid(widgets, located.node, disableWhenInvalid); + } + + if (visibleWhen !== undefined) { + applyVisibleWhenBinding(widgets, located.node, visibleWhen); + } + + Object.assign(located.node, literals); + + // A literal overwriting a previously bound property also clears its dynamic-path registration. + if (literals.text !== undefined) { + unregisterDynamicBinding(located.node, "text"); + } + + if (literals.defaultText !== undefined) { + unregisterDynamicBinding(located.node, "defaultText"); + } + + if (literals.image !== undefined) { + unregisterDynamicBinding(located.node, "image"); + } + + // A literal isDisabled replacing a prior disableWhenInvalid binding clears its dynamic-path registration. + if (literals.isDisabled !== undefined) { + unregisterDynamicBinding(located.node, "isDisabled"); + } + + // A literal isVisible replacing a prior visibleWhen binding clears its dynamic-path registration. + if (literals.isVisible !== undefined) { + unregisterDynamicBinding(located.node, "isVisible"); + } + + changes.push({ + kind: "update", + widgetName: operation.name, + changedProps: Object.keys( + operation.props, + ) as (keyof WidgetPropsPatch)[], + }); + continue; + } + + if (operation.kind === "remove") { + if ((located.node.children?.length ?? 0) > 0) { + throw new Error( + `widget "${operation.name}" has children; remove them first`, + ); + } + + removeFromParent(located); + changes.push({ kind: "remove", widgetName: operation.name }); + continue; + } + + if (operation.kind === "resize") { + applyResize(dsl, located, operation, changes, notes); + continue; + } + + applyMove(dsl, widgets, located, operation, changes, notes); + } + + return { dsl, changes, notes }; +} + +type MoveOperation = Extract; +type ResizeOperation = Extract; + +// Collision-aware move (design section B). Explicit positions are honored when free; a collision is repaired to +// the nearest free spot below (recorded as requestedPosition vs position, plus a note) or rejected under +// strict: true. Reparenting is occupancy-aware: the landing position in the new canvas is always server-computed +// and recorded — a deliberate semantic change from the old "keep the coordinates blindly" behavior. +function applyMove( + dsl: WidgetNode, + widgets: Map, + located: LocatedWidget, + operation: MoveOperation, + changes: WidgetPatchChange[], + notes: string[], +): void { + const strict = operation.strict === true; + const previousParentWidgetName = located.parent?.widgetName; + const previousPosition = positionOf(located.node); + let parentWidgetName = previousParentWidgetName; + let destinationCanvas = located.parent; + const reparented = operation.parent !== undefined; + + if (operation.parent !== undefined) { + const target = widgets.get(operation.parent); + const destination = target && directCanvas(target.node); + + if (!target || !destination) { + throw new Error( + `parent "${operation.parent}" must name a canvas or widget with an inner canvas`, + ); + } + + if ( + destination.widgetId === located.node.widgetId || + isDescendant(located.node, destination) + ) { + throw new Error( + `widget "${operation.name}" cannot be parented to itself`, + ); + } + + // M6 modal discipline (structural): a modal — or a subtree smuggling one — may not move under another + // modal's canvas. Modals are page-level overlays; stacking is an event-graph concern, not a nesting one. + if ( + containsModal(located.node) && + (target.node.type === "MODAL_WIDGET" || + hostModalOf(dsl, String(target.node.widgetName)) !== PAGE_HOST) + ) { + throw new Error( + `cannot move "${operation.name}" into "${operation.parent}": it contains a modal, and a modal cannot ` + + "live inside another modal. Keep modals at the page level and open them with a wire_event showModal action", + ); + } + + removeFromParent(located); + destination.children = destination.children ?? []; + destination.children.push(located.node); + located.node.parentId = destination.widgetId; + parentWidgetName = target.node.widgetName; + destinationCanvas = destination; + } + + // Detached overlays (modals) are not in-flow: no occupancy, no cascade — apply the position directly. + if (isDetached(located.node)) { + if (operation.position !== undefined) { + applyPosition(located.node, operation.position); + } + + changes.push({ + kind: "move", + widgetName: operation.name, + ...(reparented ? { previousParentWidgetName, parentWidgetName } : {}), + ...(operation.position !== undefined + ? { previousPosition, position: positionOf(located.node) } + : {}), + }); + + return; + } + + const rect = rectOf(located.node); + + if (!rect) { + throw new Error( + `widget "${operation.name}" has a non-numeric position and cannot be moved safely`, + ); + } + + if (!destinationCanvas || destinationCanvas.type !== "CANVAS_WIDGET") { + throw new Error(`widget "${operation.name}" is not on a canvas`); + } + + const columns = canvasColumns(destinationCanvas); + const rows = rect.bottomRow - rect.topRow; + let width = rect.rightColumn - rect.leftColumn; + + if (rows <= 0 || width <= 0) { + throw new Error( + `widget "${operation.name}" has a non-positive size and cannot be moved safely`, + ); + } + + if (width > columns) { + notes.push( + `"${operation.name}" (${width} columns) is wider than "${parentWidgetName}" (${columns} columns); its width was reduced to fit`, + ); + width = columns; + } + + // Self-exclusion by node identity (not widgetId) so a corrupt duplicated-id tree cannot misroute the repair. + const siblings = (destinationCanvas.children ?? []).filter( + (child) => child !== located.node, + ); + const requested: Position = operation.position ?? { + topRow: rect.topRow, + leftColumn: rect.leftColumn, + }; + let landing: Position; + let requestedPosition: Position | undefined; + + if (operation.position !== undefined) { + const targetRect: Rect = { + topRow: requested.topRow, + bottomRow: requested.topRow + rows, + leftColumn: requested.leftColumn, + rightColumn: requested.leftColumn + width, + }; + const colliders = collisions(siblings, targetRect); + + if (colliders.length === 0) { + landing = requested; + } else if (strict) { + const free = nearestFreePosition( + siblings, + { rows, columns: width }, + requested, + columns, + ); + + throw new Error( + `moving "${operation.name}" to ${formatPosition(requested)} would overlap ${formatNames(colliders)}; nearest free position is ${formatPosition(free)}`, + ); + } else { + landing = nearestFreePosition( + siblings, + { rows, columns: width }, + requested, + columns, + ); + requestedPosition = requested; + notes.push( + `"${operation.name}": requested position ${formatPosition(requested)} overlaps ${formatNames(colliders)}; placed at ${formatPosition(landing)} instead`, + ); + } + } else { + // Reparent without an explicit position: land at the nearest free spot to the old coordinates in the NEW canvas. + landing = nearestFreePosition( + siblings, + { rows, columns: width }, + requested, + columns, + ); + } + + applyPosition(located.node, landing, width); + + changes.push({ + kind: "move", + widgetName: operation.name, + ...(reparented ? { previousParentWidgetName, parentWidgetName } : {}), + previousPosition, + position: positionOf(located.node), + ...(requestedPosition !== undefined ? { requestedPosition } : {}), + }); + + // Container-fit cascade (D): grow the enclosing container chain to the widget's new extent, pushing anything + // displaced further down. No-op when the landing fits. + mergeCascade(changes, notes, cascadeFit(dsl, operation.name)); +} + +// The resize operation (design section B2), in grid units. Growth cascade-pushes colliding below-siblings +// (strict: true rejects); width cannot exceed the canvas (no horizontal reflow in v1); container-likes may not +// shrink below their children's occupied extent (the rejection names the executable minimum). Modal heights are a +// pixel prop: rows are translated via the grid's row height, and an over-tall body warns (the modal scrolls). +function applyResize( + dsl: WidgetNode, + located: LocatedWidget, + operation: ResizeOperation, + changes: WidgetPatchChange[], + notes: string[], +): void { + const strict = operation.strict === true; + const node = located.node; + + if (node.type === "MODAL_WIDGET") { + if (operation.columns !== undefined) { + throw new Error( + `modal "${operation.name}" width cannot be resized in grid columns; set rows only (height = rows × ${ROW_HEIGHT}px)`, + ); + } + + const rows = operation.rows!; + const previousHeight = isNumber(node.height) ? node.height : undefined; + + node.height = rows * ROW_HEIGHT; + + let extent = 0; + + for (const inner of innerCanvasesOf(node)) { + extent = Math.max(extent, contentExtent(inner)); + } + + notes.push( + `modal "${operation.name}" height set to ${rows * ROW_HEIGHT}px (${rows} rows × ${ROW_HEIGHT}px)`, + ); + + if (extent > rows) { + notes.push( + `modal "${operation.name}" body content is ${extent} rows; at ${rows} rows it will scroll`, + ); + } + + changes.push({ + kind: "resize", + widgetName: operation.name, + ...(previousHeight !== undefined + ? { previousSize: { rows: Math.round(previousHeight / ROW_HEIGHT) } } + : {}), + size: { rows }, + }); + + return; + } + + const rect = rectOf(node); + + if (!rect) { + throw new Error( + `widget "${operation.name}" has a non-numeric position and cannot be resized safely`, + ); + } + + const canvas = located.parent; + + if (!canvas || canvas.type !== "CANVAS_WIDGET") { + throw new Error(`widget "${operation.name}" is not on a canvas`); + } + + const columns = canvasColumns(canvas); + const currentRows = rect.bottomRow - rect.topRow; + const currentColumns = rect.rightColumn - rect.leftColumn; + const targetRows = operation.rows ?? Math.max(1, currentRows); + const targetColumns = operation.columns ?? Math.max(1, currentColumns); + + // No horizontal reflow in v1: width growth past the canvas is rejected with the available room. + if (rect.leftColumn + targetColumns > columns) { + throw new Error( + `resizing "${operation.name}" to ${targetColumns} columns exceeds its canvas: ${Math.max(0, columns - rect.leftColumn)} columns are available from leftColumn ${rect.leftColumn}`, + ); + } + + // A container/form/tabs may not shrink below its children's occupied extent — reject with the executable minimum. + const innerCanvases = innerCanvasesOf(node); + + if (innerCanvases.length > 0) { + let minRows = 0; + let minColumns = 0; + + for (const inner of innerCanvases) { + minRows = Math.max(minRows, contentExtent(inner)); + + for (const child of inner.children ?? []) { + const childRect = rectOf(child); + + if (childRect && !isDetached(child)) { + minColumns = Math.max(minColumns, childRect.rightColumn); + } + } + } + + if (operation.rows !== undefined && targetRows < minRows) { + throw new Error( + `cannot shrink "${operation.name}" to ${targetRows} rows; smallest rows that fit the children: ${minRows}`, + ); + } + + if (operation.columns !== undefined && targetColumns < minColumns) { + throw new Error( + `cannot shrink "${operation.name}" to ${targetColumns} columns; smallest columns that fit the children: ${minColumns}`, + ); + } + } + + // strict: reject growth that would land on siblings the widget does not already touch. + const targetRect: Rect = { + topRow: rect.topRow, + bottomRow: rect.topRow + targetRows, + leftColumn: rect.leftColumn, + rightColumn: rect.leftColumn + targetColumns, + }; + const siblings = (canvas.children ?? []).filter((child) => child !== node); + const alreadyColliding = new Set(collisions(siblings, rect)); + const newColliders = collisions(siblings, targetRect).filter( + (name) => !alreadyColliding.has(name), + ); + + if (newColliders.length > 0 && strict) { + throw new Error( + `resizing "${operation.name}" to ${targetRows} rows × ${targetColumns} columns would overlap ${formatNames(newColliders)}; retry without strict to push them down, or pick a smaller size`, + ); + } + + // Write hygiene: rows derive from the clamped topRow (same contract as applyPosition — the span is never + // distorted); the column write is bounded by the canvas check above. + node.bottomRow = clampRow(rect.topRow) + targetRows; + node.rightColumn = Math.max(0, Math.round(rect.leftColumn)) + targetColumns; + syncMobileRows(node); + + // Keep the inner canvas rect in step with the container's body (build invariant: the canvas spans the container). + for (const inner of innerCanvasesOf(node)) { + if (isNumber(inner.bottomRow)) { + inner.bottomRow = Math.max(targetRows, contentExtent(inner)); + } + + if (isNumber(inner.rightColumn)) inner.rightColumn = targetColumns; + } + + changes.push({ + kind: "resize", + widgetName: operation.name, + previousSize: { rows: currentRows, columns: currentColumns }, + size: { rows: targetRows, columns: targetColumns }, + }); + + if (newColliders.length > 0) { + notes.push( + `resizing "${operation.name}" grew it into ${formatNames(newColliders)}; they were pushed down`, + ); + } + + // Cascade (D): push displaced siblings down and grow the ancestor chain; the delta gate verifies the final result. + mergeCascade(changes, notes, cascadeFit(dsl, operation.name)); +} diff --git a/app/client/packages/mcp/src/builder/events.test.ts b/app/client/packages/mcp/src/builder/events.test.ts new file mode 100644 index 000000000000..6f69a8f5f1bd --- /dev/null +++ b/app/client/packages/mcp/src/builder/events.test.ts @@ -0,0 +1,751 @@ +import { + applyEvent, + compileEventBinding, + eventActionKinds, + eventReferences, + widgetExists, + wireEventSpecSchema, +} from "./events.js"; +import type { WidgetNode } from "./layout.js"; + +function page(children: WidgetNode[]): WidgetNode { + return { + widgetId: "0", + widgetName: "MainContainer", + type: "CANVAS_WIDGET", + topRow: 0, + bottomRow: 380, + leftColumn: 0, + rightColumn: 640, + children, + }; +} + +function widget( + overrides: Partial & { widgetId: string }, +): WidgetNode { + return { + widgetName: overrides.widgetId, + type: "BUTTON_WIDGET", + topRow: 0, + bottomRow: 4, + leftColumn: 0, + rightColumn: 16, + ...overrides, + }; +} + +describe("compileEventBinding — closed vocabulary", () => { + it("emits the correct binding for each action kind", () => { + expect(compileEventBinding({ run: "insertRow" })).toBe( + "{{ insertRow.run() }}", + ); + expect(compileEventBinding({ navigate: "Details Page" })).toBe( + "{{ navigateTo('Details Page', {}, 'SAME_WINDOW') }}", + ); + expect(compileEventBinding({ showModal: "EditModal" })).toBe( + "{{ showModal('EditModal') }}", + ); + expect(compileEventBinding({ closeModal: "EditModal" })).toBe( + "{{ closeModal('EditModal') }}", + ); + expect(compileEventBinding({ showAlert: "Saved!", style: "success" })).toBe( + "{{ showAlert('Saved!', 'success') }}", + ); + // Alert style defaults to info. + expect(compileEventBinding({ showAlert: "Heads up" })).toBe( + "{{ showAlert('Heads up', 'info') }}", + ); + // Reset a single widget, and several at once (a Clear button). + expect(compileEventBinding({ reset: "ZipInput" })).toBe( + "{{ resetWidget('ZipInput', true) }}", + ); + expect(compileEventBinding({ reset: ["ZipInput", "Results"] })).toBe( + "{{ resetWidget('ZipInput', true); resetWidget('Results', true) }}", + ); + }); + + it("validates and references every widget in a reset action", () => { + expect( + wireEventSpecSchema.safeParse({ + widget: "ClearButton", + event: "onClick", + action: { reset: ["ZipInput", "Results"] }, + }).success, + ).toBe(true); + // Injection in a reset target is rejected — both the array form and the single-string form. + expect( + wireEventSpecSchema.safeParse({ + widget: "ClearButton", + event: "onClick", + action: { reset: ["ZipInput'); evil("] }, + }).success, + ).toBe(false); + expect( + wireEventSpecSchema.safeParse({ + widget: "ClearButton", + event: "onClick", + action: { reset: "ZipInput'); evil(" }, + }).success, + ).toBe(false); + expect(eventReferences({ reset: ["ZipInput", "Results"] })).toEqual([ + { kind: "widget", name: "ZipInput" }, + { kind: "widget", name: "Results" }, + ]); + }); + + it("compiles the canonical submit -> refresh -> close -> alert chain", () => { + expect( + compileEventBinding({ + run: "insertUser", + onSuccess: [ + { run: "getUsers" }, + { closeModal: "AddUserModal" }, + { showAlert: "Saved", style: "success" }, + ], + onError: [{ showAlert: "Save failed", style: "error" }], + }), + ).toBe( + "{{ insertUser.run().then(() => { getUsers.run(); closeModal('AddUserModal'); showAlert('Saved', 'success'); }).catch(() => { showAlert('Save failed', 'error'); }) }}", + ); + }); + + it("compiles reset as an onSuccess follow-up of a run chain", () => { + expect( + compileEventBinding({ + run: "deleteRow", + onSuccess: [{ run: "getRows" }, { reset: ["ZipInput", "Results"] }], + }), + ).toBe( + "{{ deleteRow.run().then(() => { getRows.run(); resetWidget('ZipInput', true); resetWidget('Results', true); }) }}", + ); + // And it round-trips through eventReferences (primary query + reset widgets). + expect( + eventReferences({ + run: "deleteRow", + onSuccess: [{ reset: "ZipInput" }], + }), + ).toEqual([ + { kind: "query", name: "deleteRow" }, + { kind: "widget", name: "ZipInput" }, + ]); + }); + + it("compiles onSuccess without onError (and vice versa)", () => { + expect( + compileEventBinding({ run: "save", onSuccess: [{ run: "reload" }] }), + ).toBe("{{ save.run().then(() => { reload.run(); }) }}"); + expect( + compileEventBinding({ + run: "save", + onError: [{ showAlert: "Failed", style: "error" }], + }), + ).toBe("{{ save.run().catch(() => { showAlert('Failed', 'error'); }) }}"); + }); +}); + +describe("wireEventSpecSchema — rejects anything unsafe", () => { + const bad: unknown[] = [ + { widget: "Btn", event: "onClick", action: { run: "a; DROP" } }, + { widget: "Btn", event: "onClick", action: { run: "{{evil}}" } }, + { + widget: "Btn", + event: "onClick", + action: { navigate: "Page');evil()//" }, + }, + { widget: "Btn", event: "onClick", action: { showModal: "a'b" } }, + { widget: "Btn", event: "onBogus", action: { run: "q" } }, + { widget: "Btn", event: "onClick", action: { run: "q", navigate: "P" } }, + { widget: "Btn", event: "onClick", action: {} }, + // Alert messages exclude quotes/backticks/braces — nothing can escape the single-quoted argument. + { widget: "Btn", event: "onClick", action: { showAlert: "it's done" } }, + { widget: "Btn", event: "onClick", action: { showAlert: "x'); evil(" } }, + { widget: "Btn", event: "onClick", action: { showAlert: "{{evil}}" } }, + // Follow-ups are validated with the same closed vocabulary and cannot nest further chains. + { + widget: "Btn", + event: "onClick", + action: { run: "q", onSuccess: [{ run: "a; DROP" }] }, + }, + { + widget: "Btn", + event: "onClick", + action: { run: "q", onSuccess: [{ run: "a", onSuccess: [] }] }, + }, + { + widget: "Btn", + event: "onClick", + action: { run: "q", onError: [{ showAlert: "bad`tick" }] }, + }, + // Chains only hang off run — a navigate cannot have callbacks. + { + widget: "Btn", + event: "onClick", + action: { navigate: "P", onSuccess: [{ run: "q" }] }, + }, + ]; + + it.each(bad.map((b, i) => [i, b] as const))("rejects case %#", (_i, spec) => { + expect(wireEventSpecSchema.safeParse(spec).success).toBe(false); + }); + + it("accepts a valid spec", () => { + expect( + wireEventSpecSchema.safeParse({ + widget: "SaveButton", + event: "onClick", + action: { run: "insertRow" }, + }).success, + ).toBe(true); + }); + + it("accepts a chained run with onSuccess/onError follow-ups", () => { + expect( + wireEventSpecSchema.safeParse({ + widget: "SaveButton", + event: "onClick", + action: { + run: "insertUser", + onSuccess: [ + { run: "getUsers" }, + { closeModal: "AddUserModal" }, + { showAlert: "Saved", style: "success" }, + ], + onError: [{ showAlert: "Save failed", style: "error" }], + }, + }).success, + ).toBe(true); + }); +}); + +describe("applyEvent", () => { + it("sets the trigger binding and registers the trigger path without mutating input", () => { + const dsl = page([widget({ widgetId: "Save", widgetName: "Save" })]); + const { binding, dsl: next } = applyEvent(dsl, { + widget: "Save", + event: "onClick", + action: { run: "insertRow" }, + }); + const button = (next.children as WidgetNode[])[0]; + + expect(button.onClick).toBe("{{ insertRow.run() }}"); + expect(button.dynamicTriggerPathList).toEqual([{ key: "onClick" }]); + expect(binding).toBe("{{ insertRow.run() }}"); + // Input DSL is untouched. + expect((dsl.children as WidgetNode[])[0].onClick).toBeUndefined(); + }); + + it("does not duplicate an already-registered trigger path", () => { + const dsl = page([ + widget({ + widgetId: "Save", + widgetName: "Save", + dynamicTriggerPathList: [{ key: "onClick" }], + }), + ]); + const { dsl: next } = applyEvent(dsl, { + widget: "Save", + event: "onClick", + action: { run: "q" }, + }); + + expect((next.children as WidgetNode[])[0].dynamicTriggerPathList).toEqual([ + { key: "onClick" }, + ]); + }); + + it("rejects an unknown widget", () => { + expect(() => + applyEvent(page([]), { + widget: "Missing", + event: "onClick", + action: { run: "q" }, + }), + ).toThrow(/was not found/); + }); + + it("rejects an event that is invalid for the widget type", () => { + const dsl = page([ + widget({ widgetId: "T", widgetName: "T", type: "TABLE_WIDGET_V2" }), + ]); + + expect(() => + applyEvent(dsl, { widget: "T", event: "onClick", action: { run: "q" } }), + ).toThrow(/not supported/); + }); + + it("schema rejects event names outside the closed vocabulary", () => { + // Pins the closed enum: a regression to z.string() would silently accept arbitrary property names. + expect( + wireEventSpecSchema.safeParse({ + widget: "Save", + event: "onBlur", + action: { run: "q" }, + }).success, + ).toBe(false); + }); + + it("supports change/submit events on select, input, checkbox, switch, and datepicker", () => { + const dsl = page([ + widget({ widgetId: "S", widgetName: "S", type: "SELECT_WIDGET" }), + widget({ widgetId: "I", widgetName: "I", type: "INPUT_WIDGET_V2" }), + widget({ widgetId: "C", widgetName: "C", type: "CHECKBOX_WIDGET" }), + widget({ widgetId: "W", widgetName: "W", type: "SWITCH_WIDGET" }), + widget({ widgetId: "D", widgetName: "D", type: "DATE_PICKER_WIDGET2" }), + ]); + const cases = [ + { widget: "S", event: "onOptionChange" }, + { widget: "I", event: "onSubmit" }, + { widget: "C", event: "onCheckChange" }, + { widget: "W", event: "onChange" }, + { widget: "D", event: "onDateSelected" }, + ] as const; + + for (const testCase of cases) { + const { dsl: next } = applyEvent(dsl, { + widget: testCase.widget, + event: testCase.event, + action: { run: "refresh" }, + }); + const node = (next.children as WidgetNode[]).find( + (child) => child.widgetName === testCase.widget, + )!; + + expect(node[testCase.event]).toBe("{{ refresh.run() }}"); + expect(node.dynamicTriggerPathList).toEqual([{ key: testCase.event }]); + } + + // Cross-type misuse still rejects: a select does not take onSubmit. + expect(() => + applyEvent(dsl, { widget: "S", event: "onSubmit", action: { run: "q" } }), + ).toThrow(/not supported/); + }); + + it("supports table selection, modal close, and tab change events", () => { + const dsl = page([ + widget({ widgetId: "T", widgetName: "T", type: "TABLE_WIDGET_V2" }), + widget({ widgetId: "M", widgetName: "M", type: "MODAL_WIDGET" }), + widget({ widgetId: "Tabs", widgetName: "Tabs", type: "TABS_WIDGET" }), + ]); + + expect( + ( + applyEvent(dsl, { + widget: "T", + event: "onRowSelected", + action: { run: "loadRow" }, + }).dsl.children as WidgetNode[] + )[0].onRowSelected, + ).toBe("{{ loadRow.run() }}"); + expect( + ( + applyEvent(dsl, { + widget: "M", + event: "onClose", + action: { run: "cleanup" }, + }).dsl.children as WidgetNode[] + )[1].onClose, + ).toBe("{{ cleanup.run() }}"); + }); +}); + +describe("M5 store accumulation — appendToStore / clearStoreKey / statement lists", () => { + function accepts(action: unknown): boolean { + return wireEventSpecSchema.safeParse({ + widget: "Btn", + event: "onClick", + action, + }).success; + } + + it("compiles a plain appendToStore ([].concat flattening, persist=false)", () => { + expect( + compileEventBinding({ + appendToStore: { key: "zipResults", query: "LookupZip" }, + }), + ).toBe( + "{{ storeValue('zipResults', [].concat(appsmith.store.zipResults ?? [], LookupZip.data ?? []), false) }}", + ); + }); + + it("compiles appendToStore with a dotted response field", () => { + expect( + compileEventBinding({ + appendToStore: { key: "rows", query: "LookupZip", field: "places" }, + }), + ).toBe( + "{{ storeValue('rows', [].concat(appsmith.store.rows ?? [], LookupZip.data?.places ?? []), false) }}", + ); + }); + + it("compiles a fields projection (space-keyed + numeric-index paths) into ONE bracket-access row", () => { + expect( + compileEventBinding({ + appendToStore: { + key: "zipResults", + query: "LookupZip", + fields: [ + { as: "zip", path: ["post code"] }, + { as: "city", path: ["places", 0, "place name"] }, + { as: "state", path: ["places", 0, "state"] }, + ], + }, + }), + ).toBe( + "{{ storeValue('zipResults', [].concat(appsmith.store.zipResults ?? [], " + + '{ zip: LookupZip.data?.["post code"], city: LookupZip.data?.["places"]?.[0]?.["place name"], state: LookupZip.data?.["places"]?.[0]?.["state"] }' + + " ?? []), false) }}", + ); + }); + + it("JSON.stringify-escapes hostile path elements (quotes/backslashes cannot escape the literal)", () => { + expect( + compileEventBinding({ + appendToStore: { + key: "k", + query: "q", + fields: [{ as: "a", path: ['x"] , evil ["y'] }], + }, + }), + ).toBe( + "{{ storeValue('k', [].concat(appsmith.store.k ?? [], " + + '{ a: q.data?.["x\\"] , evil [\\"y"] }' + + " ?? []), false) }}", + ); + }); + + it("compiles clearStoreKey (one key emptied, never the whole store)", () => { + expect(compileEventBinding({ clearStoreKey: { key: "zipResults" } })).toBe( + "{{ storeValue('zipResults', [], false) }}", + ); + }); + + it("compiles a statement-list primary joined with ';' (the Clear button)", () => { + expect( + compileEventBinding([ + { clearStoreKey: { key: "zipResults" } }, + { reset: "ZipInput" }, + ]), + ).toBe( + "{{ storeValue('zipResults', [], false); resetWidget('ZipInput', true) }}", + ); + }); + + it("compiles a statement list whose run keeps its onSuccess chain", () => { + expect( + compileEventBinding([ + { showAlert: "Looking up" }, + { + run: "LookupZip", + onSuccess: [ + { appendToStore: { key: "zipResults", query: "LookupZip" } }, + ], + }, + ]), + ).toBe( + "{{ showAlert('Looking up', 'info'); LookupZip.run().then(() => { storeValue('zipResults', [].concat(appsmith.store.zipResults ?? [], LookupZip.data ?? []), false); }) }}", + ); + }); + + it("accepts the new verbs as primary and as onSuccess/onError follow-ups", () => { + expect( + accepts({ appendToStore: { key: "zipResults", query: "LookupZip" } }), + ).toBe(true); + expect(accepts({ clearStoreKey: { key: "zipResults" } })).toBe(true); + expect( + accepts({ + run: "LookupZip", + onSuccess: [ + { + appendToStore: { + key: "zipResults", + query: "LookupZip", + fields: [{ as: "zip", path: ["post code"] }], + }, + }, + ], + onError: [{ clearStoreKey: { key: "zipResults" } }], + }), + ).toBe(true); + }); + + it("rejects prototype-polluting, digit-leading, and malformed store keys", () => { + for (const badKey of [ + "__proto__", + "constructor", + "prototype", + "hasOwnProperty", + "1zipResults", + "zip-results", + "zip results", + "a".repeat(65), + "", + ]) { + expect( + accepts({ appendToStore: { key: badKey, query: "LookupZip" } }), + ).toBe(false); + expect(accepts({ clearStoreKey: { key: badKey } })).toBe(false); + // The same denylist guards `as` — an unquoted object-literal key where __proto__ would pollute the row. + expect( + accepts({ + appendToStore: { + key: "ok", + query: "q", + fields: [{ as: badKey, path: ["x"] }], + }, + }), + ).toBe(false); + } + }); + + it("rejects field+fields together, oversized projections, and binding syntax in a path element", () => { + expect( + accepts({ + appendToStore: { + key: "k", + query: "q", + field: "places", + fields: [{ as: "a", path: ["x"] }], + }, + }), + ).toBe(false); + // > 20 fields. + expect( + accepts({ + appendToStore: { + key: "k", + query: "q", + fields: Array.from({ length: 21 }, (_, i) => ({ + as: `f${i}`, + path: ["x"], + })), + }, + }), + ).toBe(false); + // > 5 path elements. + expect( + accepts({ + appendToStore: { + key: "k", + query: "q", + fields: [{ as: "a", path: ["a", "b", "c", "d", "e", "f"] }], + }, + }), + ).toBe(false); + + // M6: single quotes are rejected outright — a path element like "closeModal('EditModal')" would survive + // JSON.stringify intact and poison later text scans of the persisted binding (a stacking edge misread as a + // wizard transition, skipping the modal depth gate). + expect( + accepts({ + appendToStore: { + key: "k", + query: "q", + fields: [{ as: "a", path: ["closeModal('EditModal')"] }], + }, + }), + ).toBe(false); + expect( + accepts({ + appendToStore: { + key: "k", + query: "q", + fields: [{ as: "a", path: ["post code"] }], + }, + }), + ).toBe(true); + + // A path element that could terminate the outer {{ }} or open an expression is rejected outright, even though + // JSON.stringify would escape quotes — the mustache splitter is not JS-aware. + for (const badElement of ["a}}b", "a{{b", "a${b}", "a`b"]) { + expect( + accepts({ + appendToStore: { + key: "k", + query: "q", + fields: [{ as: "a", path: [badElement] }], + }, + }), + ).toBe(false); + } + + // Negative / fractional array indexes are rejected. + expect( + accepts({ + appendToStore: { + key: "k", + query: "q", + fields: [{ as: "a", path: [-1] }], + }, + }), + ).toBe(false); + expect( + accepts({ + appendToStore: { + key: "k", + query: "q", + fields: [{ as: "a", path: [1.5] }], + }, + }), + ).toBe(false); + }); + + it("bounds statement lists to 2–5 entries with at most one run", () => { + // A single-entry list is not a list; use the plain form. + expect(accepts([{ clearStoreKey: { key: "k" } }])).toBe(false); + expect( + accepts([{ clearStoreKey: { key: "k" } }, { reset: "ZipInput" }]), + ).toBe(true); + // Two runs would emit two independent promise chains — rejected. + expect(accepts([{ run: "a" }, { run: "b" }])).toBe(false); + // > 5 statements. + expect( + accepts( + Array.from({ length: 6 }, () => ({ clearStoreKey: { key: "k" } })), + ), + ).toBe(false); + }); + + it("rejects a sibling appendToStore of a query the same list runs (stale-read footgun)", () => { + // run() is async: the sibling append would execute before it resolves and read the PREVIOUS response. + expect( + accepts([ + { run: "LookupZip" }, + { appendToStore: { key: "zipResults", query: "LookupZip" } }, + ]), + ).toBe(false); + // The correct shape — append inside the run's onSuccess — stays valid. + expect( + accepts([ + { + run: "LookupZip", + onSuccess: [ + { appendToStore: { key: "zipResults", query: "LookupZip" } }, + ], + }, + { reset: "ZipInput" }, + ]), + ).toBe(true); + // Appending a DIFFERENT query's (existing) response alongside a run is allowed. + expect( + accepts([ + { run: "LookupZip" }, + { appendToStore: { key: "zipResults", query: "Other" } }, + ]), + ).toBe(true); + }); + + it("routes appendToStore's query through eventReferences (dangling guard) across lists and chains", () => { + expect( + eventReferences({ + appendToStore: { key: "zipResults", query: "LookupZip" }, + }), + ).toEqual([{ kind: "query", name: "LookupZip" }]); + // clearStoreKey references nothing. + expect(eventReferences({ clearStoreKey: { key: "zipResults" } })).toEqual( + [], + ); + expect( + eventReferences([ + { clearStoreKey: { key: "zipResults" } }, + { reset: "ZipInput" }, + { + run: "LookupZip", + onSuccess: [{ appendToStore: { key: "zipResults", query: "Other" } }], + }, + ]), + ).toEqual([ + { kind: "widget", name: "ZipInput" }, + { kind: "query", name: "LookupZip" }, + { kind: "query", name: "Other" }, + ]); + }); + + it("reports deduped statement verb kinds for telemetry", () => { + expect( + eventActionKinds({ + run: "LookupZip", + onSuccess: [ + { appendToStore: { key: "zipResults", query: "LookupZip" } }, + ], + }), + ).toEqual(["run", "appendToStore"]); + expect( + eventActionKinds([ + { clearStoreKey: { key: "zipResults" } }, + { reset: "ZipInput" }, + { reset: "Other" }, + ]), + ).toEqual(["clearStoreKey", "reset"]); + expect(eventActionKinds({ showAlert: "Done" })).toEqual(["showAlert"]); + }); + + it("applyEvent writes a statement-list binding and registers the trigger path", () => { + const dsl = page([widget({ widgetId: "Clear", widgetName: "Clear" })]); + const { binding, dsl: next } = applyEvent(dsl, { + widget: "Clear", + event: "onClick", + action: [{ clearStoreKey: { key: "zipResults" } }, { reset: "ZipInput" }], + }); + + expect(binding).toBe( + "{{ storeValue('zipResults', [], false); resetWidget('ZipInput', true) }}", + ); + expect((next.children as WidgetNode[])[0].dynamicTriggerPathList).toEqual([ + { key: "onClick" }, + ]); + }); +}); + +describe("eventReferences / widgetExists", () => { + it("returns the referenced entity for validation", () => { + expect(eventReferences({ run: "q" })).toEqual([ + { kind: "query", name: "q" }, + ]); + expect(eventReferences({ navigate: "Home" })).toEqual([ + { kind: "page", name: "Home" }, + ]); + expect(eventReferences({ showModal: "M" })).toEqual([ + { kind: "widget", name: "M" }, + ]); + // showAlert references nothing. + expect(eventReferences({ showAlert: "Done" })).toEqual([]); + }); + + it("collects every reference across a chain (primary + follow-ups)", () => { + expect( + eventReferences({ + run: "insertUser", + onSuccess: [ + { run: "getUsers" }, + { closeModal: "AddUserModal" }, + { showAlert: "Saved" }, + ], + onError: [{ navigate: "Errors" }], + }), + ).toEqual([ + { kind: "query", name: "insertUser" }, + { kind: "query", name: "getUsers" }, + { kind: "widget", name: "AddUserModal" }, + { kind: "page", name: "Errors" }, + ]); + }); + + it("finds nested widgets by name", () => { + const dsl = page([ + { + ...widget({ widgetId: "C", widgetName: "C", type: "CANVAS_WIDGET" }), + children: [ + widget({ + widgetId: "M", + widgetName: "EditModal", + type: "MODAL_WIDGET", + }), + ], + }, + ]); + + expect(widgetExists(dsl, "EditModal")).toBe(true); + expect(widgetExists(dsl, "Nope")).toBe(false); + }); +}); diff --git a/app/client/packages/mcp/src/builder/events.ts b/app/client/packages/mcp/src/builder/events.ts new file mode 100644 index 000000000000..9d640a20f427 --- /dev/null +++ b/app/client/packages/mcp/src/builder/events.ts @@ -0,0 +1,452 @@ +import { z } from "zod"; +import type { WidgetNode } from "./layout.js"; +import { responseFieldPath, storeKeySchema } from "./schema.js"; + +// M-F event wiring. A CLOSED event vocabulary: the agent supplies a structured action reference (run a query, +// navigate to a page, show/close a modal), and the compiler emits the trigger binding. The agent never authors raw +// `{{ }}` or JS. Names are strict identifiers / safe page names, so the single-quoted arguments the compiler emits +// (e.g. navigateTo('Page')) cannot be broken out of. + +const queryName = z + .string() + .min(1) + .max(64) + .regex(/^[A-Za-z0-9_]+$/, "must be a query name (alphanumeric/underscore)"); +const widgetName = z + .string() + .min(1) + .max(64) + .regex(/^[A-Za-z0-9_]+$/, "must be a widget name (alphanumeric/underscore)"); +const pageName = z + .string() + .min(1) + .max(64) + .regex(/^[A-Za-z0-9_ ]+$/, "must be a safe page name"); + +// Alert text is emitted inside a single-quoted argument, so the charset excludes quotes, backslashes, braces, and +// backticks — nothing in it can terminate the string or open an expression. +const alertMessage = z + .string() + .min(1) + .max(200) + .regex( + /^[A-Za-z0-9_ .,:!?()-]+$/, + "alert messages allow letters, numbers, spaces, and . , : ! ? ( ) -", + ); + +const alertStyle = z.enum(["info", "success", "warning", "error"]); + +// Reset (clear) one or more widgets to their default state — e.g. a Clear button that empties an input and resets a +// table. Each name is a strict widget identifier, so the emitted resetWidget('Name', true) cannot be broken out of. +const resetAction = z + .object({ + reset: z.union([widgetName, z.array(widgetName).min(1).max(10)]), + }) + .strict(); + +// M5 fields projection — one bracket-access path element into the query's response: a string key or a non-negative +// integer array index. Each element is emitted through JSON.stringify into `?.[...]` bracket access, so quotes and +// backslashes in a key cannot terminate the emitted string literal. Defense-in-depth ON TOP of that escaping: the +// binding-syntax charsets are still rejected, because Appsmith's `{{ }}` extraction is text-level (not JS-aware) — +// a `}}` inside a JSON-escaped string would still terminate the outer binding and let a following `{{ ... }}` in the +// same key become a second, evaluated expression. Same rejection set as schema.ts safeText (incl. U+2028/U+2029). +const RAW_EXPRESSION = /\u007b\u007b|\u007d\u007d|\$\u007b|`|\u2028|\u2029/; +const storePathElement = z.union([ + z + .string() + .min(1) + .max(100) + .refine((value) => !RAW_EXPRESSION.test(value), { + message: + "path elements must not contain binding/template syntax ({{ }}, ${ }, or backticks)", + }) + // M6 hardening: a single quote in a path element would let an agent embed the literal text + // closeModal('Host') / showModal('X') inside the compiled binding's JSON string. The wiring being made is + // judged structurally, but LATER text scans of the persisted binding would misread those as edges — + // closeModal poisoning could reclassify a real stacking edge as a wizard transition and skip the depth + // gate. JSON keys with apostrophes are vanishingly rare; reject the quote outright. + .refine((value) => !value.includes("'"), { + message: "path elements must not contain single quotes", + }), + z.number().int().min(0).max(100_000), +]); + +// One projected row field: `as` names the property on the appended row (a store key — identifier-shaped and +// prototype-denylisted, because it is emitted as an UNQUOTED object-literal key where `__proto__:` would rewrite +// the row's prototype); `path` is the bracket-access path into the query's response. The projection exists because +// real APIs return space-keyed and array-indexed values ("post code", places[0].state) that the dotted `field` +// identifier path cannot reach. +const storeFieldProjection = z + .object({ + as: storeKeySchema, + path: z.array(storePathElement).min(1).max(5), + }) + .strict(); + +// M5 appendToStore — accumulate a query's response under a store key (the ZIP-lookup "results table that grows with +// each lookup" pattern). `field` keeps the existing responseFieldPath rules; `fields` projects named paths into ONE +// appended row; the two are mutually exclusive. `query` resolves through the same dangling-reference guard as `run`. +const appendToStoreSpec = z + .object({ + key: storeKeySchema, + query: queryName, + field: responseFieldPath.optional(), + fields: z.array(storeFieldProjection).min(1).max(20).optional(), + }) + .strict() + .refine((spec) => !(spec.field !== undefined && spec.fields !== undefined), { + message: "'field' and 'fields' are mutually exclusive", + }); + +const appendToStoreAction = z + .object({ appendToStore: appendToStoreSpec }) + .strict(); + +// M5 clearStoreKey — empty ONE store key (storeValue('', [])), keeping the `?? []` table binding stable. +// Deliberately NOT named clearStore: the platform global clearStore() wipes the entire store. +const clearStoreKeyAction = z + .object({ clearStoreKey: z.object({ key: storeKeySchema }).strict() }) + .strict(); + +// A follow-up step inside a run's onSuccess/onError callback: the same closed kinds plus showAlert and reset. +// Follow-ups can never nest further (no callbacks on callbacks), so the compiled binding stays one bounded chain. +const followUpActionSchema = z.union([ + z.object({ run: queryName }).strict(), + z.object({ navigate: pageName }).strict(), + z.object({ showModal: widgetName }).strict(), + z.object({ closeModal: widgetName }).strict(), + z.object({ showAlert: alertMessage, style: alertStyle.optional() }).strict(), + resetAction, + appendToStoreAction, + clearStoreKeyAction, +]); + +export type FollowUpAction = z.infer; + +// One primary action per event. `run` may chain follow-ups: onSuccess runs after the query resolves (refresh a +// table by re-running its query, close the modal, confirm with an alert) and onError after it rejects. +export const eventActionSchema = z.union([ + z + .object({ + run: queryName, + onSuccess: z.array(followUpActionSchema).min(1).max(5).optional(), + onError: z.array(followUpActionSchema).min(1).max(5).optional(), + }) + .strict(), + z.object({ navigate: pageName }).strict(), + z.object({ showModal: widgetName }).strict(), + z.object({ closeModal: widgetName }).strict(), + z.object({ showAlert: alertMessage, style: alertStyle.optional() }).strict(), + resetAction, + appendToStoreAction, + clearStoreKeyAction, +]); + +export type EventAction = z.infer; + +// M5: the primary action may also be a LIST of 2–5 statements executed in order — required so a Clear button can +// clearStoreKey AND reset the input in one click. Only `run` carries onSuccess/onError, and at most one run per +// list keeps the emission a single bounded chain; the statements join the same fixed templates with ';', so the +// security profile is unchanged. +export const eventActionListSchema = z + .array(eventActionSchema) + .min(2) + .max(5) + .refine((actions) => actions.filter((step) => "run" in step).length <= 1, { + message: "a statement list may contain at most one 'run'", + }) + // The list is STARTED in order but `run` is asynchronous: a sibling appendToStore of the same query would + // execute before the run resolves and read the PREVIOUS response — the exact stale-table confusion this + // vocabulary exists to eliminate. Data-dependent verbs belong in that run's onSuccess. + .refine( + (actions) => { + const runs = new Set( + actions.flatMap((step) => ("run" in step ? [step.run] : [])), + ); + + return !actions.some( + (step) => "appendToStore" in step && runs.has(step.appendToStore.query), + ); + }, + { + message: + "appendToStore of a query that a sibling 'run' starts would read the previous response ('run' is asynchronous); put the appendToStore in that run's onSuccess instead", + }, + ); + +export type EventActionInput = EventAction | EventAction[]; + +// The event property allowed on each widget type (closed). form submit is wired via the form's button onClick. +// Change/submit events let controls drive queries directly ("re-run when the dropdown changes") — the page-load +// case needs no event at all: the server auto-derives on-page-load execution from widget bindings when the +// layout is saved, so a query bound to any widget already runs on load. +const EVENTS_BY_TYPE: Record = { + BUTTON_WIDGET: ["onClick"], + TABLE_WIDGET_V2: ["onRowSelected"], + MODAL_WIDGET: ["onClose"], + TABS_WIDGET: ["onTabSelected"], + SELECT_WIDGET: ["onOptionChange"], + INPUT_WIDGET_V2: ["onSubmit"], + CHECKBOX_WIDGET: ["onCheckChange"], + SWITCH_WIDGET: ["onChange"], + DATE_PICKER_WIDGET2: ["onDateSelected"], +}; + +export const wireEventSpecSchema = z + .object({ + widget: widgetName, + event: z.enum([ + "onClick", + "onRowSelected", + "onClose", + "onTabSelected", + "onOptionChange", + "onSubmit", + "onCheckChange", + "onChange", + "onDateSelected", + ]), + // A single action, or (M5) an ordered list of 2–5 statements. + action: z.union([eventActionSchema, eventActionListSchema]), + }) + .strict(); + +export type WireEventSpec = z.infer; + +// The bracket-access path into a query's response for one projected field: `Q.data?.["post code"]`, +// `Q.data?.["places"]?.[0]?.["state"]`. Every element passes through JSON.stringify, so a key's quotes/backslashes +// cannot terminate the emitted string literal (binding syntax is additionally rejected by storePathElement). +function compileStorePath(query: string, path: (string | number)[]): string { + return path.reduce( + (expression, element) => `${expression}?.[${JSON.stringify(element)}]`, + `${query}.data`, + ); +} + +// One statement of the closed vocabulary, without the outer `{{ }}`. Every interpolated value is a validated +// identifier / safe charset, so no argument can escape its single-quoted position. +function compileStatement(action: FollowUpAction): string { + if ("run" in action) return `${action.run}.run()`; + + if ("navigate" in action) { + return `navigateTo('${action.navigate}', {}, 'SAME_WINDOW')`; + } + + if ("showModal" in action) return `showModal('${action.showModal}')`; + + if ("closeModal" in action) return `closeModal('${action.closeModal}')`; + + if ("reset" in action) { + const names = Array.isArray(action.reset) ? action.reset : [action.reset]; + + return names.map((name) => `resetWidget('${name}', true)`).join("; "); + } + + // M5 appendToStore: accumulate under a store key. `[].concat` (not spread) flattens an array-returning query + // (one row per record) and appends an object response — or the fields-projected row — as a single row. + // `persist=false` (third arg) is deliberate: the Appsmith store persists to localStorage keyed per APP, not per + // user, so persisted accumulation would leak one user's rows to the next user of a shared browser and outlive + // logout. Session-only means results reset on reload — by design. + if ("appendToStore" in action) { + const { field, fields, key, query } = action.appendToStore; + const expression = fields + ? `{ ${fields + .map(({ as, path }) => `${as}: ${compileStorePath(query, path)}`) + .join(", ")} }` + : field + ? `${query}.data?.${field}` + : `${query}.data`; + + return `storeValue('${key}', [].concat(appsmith.store.${key} ?? [], ${expression} ?? []), false)`; + } + + // M5 clearStoreKey: empty ONE key (never the platform clearStore(), which wipes the whole store). Writing [] + // rather than removing the value keeps the table's `?? []` binding stable. + if ("clearStoreKey" in action) { + return `storeValue('${action.clearStoreKey.key}', [], false)`; + } + + return `showAlert('${action.showAlert}', '${action.style ?? "info"}')`; +} + +// One primary statement's full expression: a chained run compiles to one bounded promise expression +// (`Q.run().then(() => { ...; }).catch(() => { ...; })`), every other verb to its fixed template. +function compilePrimaryExpression(action: EventAction): string { + if (!("run" in action)) return compileStatement(action); + + let expression = `${action.run}.run()`; + + if (action.onSuccess !== undefined) { + const steps = action.onSuccess.map(compileStatement).join("; "); + + expression += `.then(() => { ${steps}; })`; + } + + if (action.onError !== undefined) { + const steps = action.onError.map(compileStatement).join("; "); + + expression += `.catch(() => { ${steps}; })`; + } + + return expression; +} + +// The single binding emitter. A statement list (M5) joins the same fixed per-statement templates with ';' — the +// schema caps it at 5 statements with at most one run, so the emission stays one bounded expression. +export function compileEventBinding(action: EventActionInput): string { + const actions = Array.isArray(action) ? action : [action]; + + return `{{ ${actions.map(compilePrimaryExpression).join("; ")} }}`; +} + +// Every entity an event references — each primary statement plus all follow-ups — so the caller can verify each +// exists before writing (dangling-reference guard). showAlert and clearStoreKey reference nothing; +// appendToStore references its source query. +export function eventReferences(action: EventActionInput): { + kind: "query" | "page" | "widget"; + name: string; +}[] { + const single = ( + step: FollowUpAction, + ): { kind: "query" | "page" | "widget"; name: string }[] => { + if ("run" in step) return [{ kind: "query" as const, name: step.run }]; + + if ("navigate" in step) { + return [{ kind: "page" as const, name: step.navigate }]; + } + + if ("showModal" in step) { + return [{ kind: "widget" as const, name: step.showModal }]; + } + + if ("closeModal" in step) { + return [{ kind: "widget" as const, name: step.closeModal }]; + } + + if ("reset" in step) { + const names = Array.isArray(step.reset) ? step.reset : [step.reset]; + + return names.map((name) => ({ kind: "widget" as const, name })); + } + + if ("appendToStore" in step) { + return [{ kind: "query" as const, name: step.appendToStore.query }]; + } + + return []; + }; + const primary = ( + step: EventAction, + ): { kind: "query" | "page" | "widget"; name: string }[] => { + if (!("run" in step)) return single(step); + + return [ + { kind: "query" as const, name: step.run }, + ...(step.onSuccess ?? []).flatMap(single), + ...(step.onError ?? []).flatMap(single), + ]; + }; + const actions = Array.isArray(action) ? action : [action]; + + return actions.flatMap(primary); +} + +// The statement verb kinds an action uses (each primary statement plus its follow-ups), deduped in encounter +// order — recorded in wire_event's audit/telemetry payload so verb adoption (e.g. appendToStore) is measurable. +export function eventActionKinds(action: EventActionInput): string[] { + const kindOf = (step: FollowUpAction | EventAction): string => { + if ("run" in step) return "run"; + + if ("navigate" in step) return "navigate"; + + if ("showModal" in step) return "showModal"; + + if ("closeModal" in step) return "closeModal"; + + if ("reset" in step) return "reset"; + + if ("appendToStore" in step) return "appendToStore"; + + if ("clearStoreKey" in step) return "clearStoreKey"; + + return "showAlert"; + }; + const kinds: string[] = []; + const add = (kind: string): void => { + if (!kinds.includes(kind)) kinds.push(kind); + }; + + for (const step of Array.isArray(action) ? action : [action]) { + add(kindOf(step)); + + if ("run" in step) { + for (const followUp of step.onSuccess ?? []) add(kindOf(followUp)); + + for (const followUp of step.onError ?? []) add(kindOf(followUp)); + } + } + + return kinds; +} + +function cloneJson(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function locateWidgetNamed( + node: WidgetNode, + name: string, +): WidgetNode | undefined { + if (node.widgetName === name) return node; + + for (const child of node.children ?? []) { + const found = locateWidgetNamed(child, name); + + if (found) return found; + } + + return undefined; +} + +// Set the event's trigger binding on the named widget and register the trigger path. Returns a new DSL (the caller's +// DSL is never mutated) plus a small change summary. Throws on unknown widget or an event invalid for the type. +export function applyEvent( + dsl: WidgetNode, + spec: WireEventSpec, +): { dsl: WidgetNode; widgetType: string; binding: string } { + const next = cloneJson(dsl); + const widget = locateWidgetNamed(next, spec.widget); + + if (!widget) { + throw new Error(`widget "${spec.widget}" was not found on this page`); + } + + const allowed = EVENTS_BY_TYPE[widget.type]; + + if (!allowed || !allowed.includes(spec.event)) { + throw new Error( + `event "${spec.event}" is not supported on ${widget.type} (${spec.widget})`, + ); + } + + const binding = compileEventBinding(spec.action); + + widget[spec.event] = binding; + + const triggerPaths = Array.isArray(widget.dynamicTriggerPathList) + ? (widget.dynamicTriggerPathList as { key: string }[]) + : []; + + if (!triggerPaths.some((entry) => entry.key === spec.event)) { + triggerPaths.push({ key: spec.event }); + } + + widget.dynamicTriggerPathList = triggerPaths; + + return { dsl: next, widgetType: widget.type, binding }; +} + +// Whether a modal/widget referenced by show/closeModal exists on the page (widget-scoped reference check). +export function widgetExists(dsl: WidgetNode, name: string): boolean { + return locateWidgetNamed(dsl, name) !== undefined; +} diff --git a/app/client/packages/mcp/src/builder/graphqlQuery.test.ts b/app/client/packages/mcp/src/builder/graphqlQuery.test.ts new file mode 100644 index 000000000000..a330df7bdf8c --- /dev/null +++ b/app/client/packages/mcp/src/builder/graphqlQuery.test.ts @@ -0,0 +1,146 @@ +import { + buildGraphqlActionDto, + compileGraphqlQuery, + graphqlQuerySpecSchema, +} from "./graphqlQuery.js"; + +function parse(query: unknown) { + return graphqlQuerySpecSchema.safeParse(query); +} + +const base = { + name: "GqlQ", + applicationId: "app1", + pageId: "p1", + datasourceId: "ds1", +}; + +describe("create_graphql_query builder", () => { + it("compiles a query with no variables (minified braces allowed)", () => { + const parsed = parse({ + ...base, + query: "query { viewer { id name } }", + }); + + expect(parsed.success).toBe(true); + const compiled = compileGraphqlQuery(parsed.data!); + + expect(compiled.body).toBe("query { viewer { id name } }"); + expect(compiled.variables).toBe(""); + expect(compiled.hasBinding).toBe(false); + + const dto = buildGraphqlActionDto(parsed.data!, compiled) as { + actionConfiguration: { + httpMethod: string; + pluginSpecifiedTemplates: { value: unknown }[]; + dynamicBindingPathList?: unknown; + }; + }; + + expect(dto.actionConfiguration.httpMethod).toBe("POST"); + // [0] JSON smart substitution ON, [1] empty variables, [2] pagination. + expect(dto.actionConfiguration.pluginSpecifiedTemplates[0].value).toBe( + true, + ); + expect(dto.actionConfiguration.pluginSpecifiedTemplates[1].value).toBe(""); + expect("dynamicBindingPathList" in dto.actionConfiguration).toBe(false); + }); + + it("allows closing '}}' (GraphQL) while blocking a '{{' binding open", () => { + // Adjacent closing braces are valid GraphQL and must be accepted. + expect(parse({ ...base, query: "{ user { id }}" }).success).toBe(true); + // A binding-open sequence must be rejected. + expect( + parse({ ...base, query: "{ user(id: {{Evil.x}}) { id } }" }).success, + ).toBe(false); + }); + + it("compiles variables — literals JSON-encoded, widget refs as smart-sub mustaches", () => { + const parsed = parse({ + ...base, + query: "query($id: ID!, $active: Boolean) { user(id: $id) { id } }", + variables: [ + { name: "id", value: { widget: "Row", property: "selectedRow.id" } }, + { name: "active", value: { literal: true } }, + ], + }); + + expect(parsed.success).toBe(true); + const compiled = compileGraphqlQuery(parsed.data!); + + expect(compiled.variables).toBe( + '{ "id": {{ Row.selectedRow.id }}, "active": true }', + ); + expect(compiled.hasBinding).toBe(true); + + const dto = buildGraphqlActionDto(parsed.data!, compiled) as { + actionConfiguration: { + pluginSpecifiedTemplates: { value: unknown }[]; + dynamicBindingPathList?: { key: string }[]; + }; + }; + + expect(dto.actionConfiguration.pluginSpecifiedTemplates[1].value).toBe( + '{ "id": {{ Row.selectedRow.id }}, "active": true }', + ); + // The variables field carries the binding. + expect(dto.actionConfiguration.dynamicBindingPathList).toEqual([ + { key: "pluginSpecifiedTemplates[1].value" }, + ]); + }); + + it("all-literal variables produce a non-empty map and no binding path", () => { + const parsed = parse({ + ...base, + query: "query($n: Int) { items(first: $n) { id } }", + variables: [{ name: "n", value: { literal: 10 } }], + }); + + expect(parsed.success).toBe(true); + const compiled = compileGraphqlQuery(parsed.data!); + + expect(compiled.variables).toBe('{ "n": 10 }'); + expect(compiled.hasBinding).toBe(false); + + const dto = buildGraphqlActionDto(parsed.data!, compiled) as { + actionConfiguration: { dynamicBindingPathList?: unknown }; + }; + + expect("dynamicBindingPathList" in dto.actionConfiguration).toBe(false); + }); + + it("rejects unsafe specs", () => { + const bad: unknown[] = [ + // template syntax in the query. + { ...base, query: "query { a(x: ${evil}) }" }, + // backtick in the query. + { ...base, query: "query { a(x: `evil`) }" }, + // not a GraphQL operation (doesn't start with { / query / mutation / subscription). + { ...base, query: "SELECT * FROM users" }, + // variable name charset. + { + ...base, + query: "query { a }", + variables: [{ name: "bad name", value: { literal: 1 } }], + }, + // binding syntax in a literal variable value. + { + ...base, + query: "query { a }", + variables: [{ name: "x", value: { literal: "{{evil}}" } }], + }, + // widget property path charset. + { + ...base, + query: "query { a }", + variables: [ + { name: "x", value: { widget: "In", property: "a; drop" } }, + ], + }, + ]; + + for (const spec of bad) { + expect(parse(spec).success).toBe(false); + } + }); +}); diff --git a/app/client/packages/mcp/src/builder/graphqlQuery.ts b/app/client/packages/mcp/src/builder/graphqlQuery.ts new file mode 100644 index 000000000000..022a1579a3a5 --- /dev/null +++ b/app/client/packages/mcp/src/builder/graphqlQuery.ts @@ -0,0 +1,178 @@ +import { z } from "zod"; + +// D4 create_graphql_query — a STRUCTURED GraphQL builder. A GraphQL operation is itself a query language, so unlike +// SQL/Mongo the compiler cannot synthesize the whole operation; instead it accepts the operation STRING plus a +// separate, structured VARIABLES map. Runtime data enters ONLY through variables, which GraphQL parameterizes (the +// query references `$name`, the variables JSON carries the value) — the query string never string-interpolates +// runtime data. This mirrors the existing create_rest_api posture (the agent supplies a request body) and the +// GraphQL plugin stores it as a REST POST: actionConfiguration.body = the query, pluginSpecifiedTemplates[0] = JSON +// smart substitution (forced ON → bound variable values are JSON-encoded, not concatenated), [1] = the variables. +// +// Binding-injection gate: the query is sent as the POST body, which Appsmith eval scans for `{{ }}` bindings. So the +// query is gated against a binding-OPEN sequence (`{{`), template syntax (`${`), and backtick — but NOT `}}`, which +// is a legitimate GraphQL closing-brace pair (a binding requires the `{{` open, which is blocked). Variable values +// are literals (JSON-encoded) or widget references (`{{ Widget.prop }}`, parameterized by smart substitution). + +// A GraphQL query/mutation string. Blocks the binding-open `{{`, template `${`, and backtick so it can never become +// an Appsmith binding; `}}`, `{`, and `$name` (GraphQL variable syntax) are allowed. +const GRAPHQL_BINDING_OPEN = /\{\{|\$\{|`/; +const graphqlOperation = z + .string() + .trim() + .min(1) + .max(8000) + .refine((v) => !GRAPHQL_BINDING_OPEN.test(v), { + message: + "query must not contain binding/template syntax ({{ , ${ , or backtick)", + }) + // A GraphQL operation starts with a selection set or the query/mutation/subscription keyword. + .refine((v) => /^(\{|query\b|mutation\b|subscription\b)/.test(v.trim()), { + message: + "query must be a GraphQL operation (start with '{', 'query', 'mutation', or 'subscription')", + }); + +// A GraphQL variable name: a plain identifier (matches the `$name` referenced in the operation). Safe to embed as a +// JSON object key. +const variableName = z + .string() + .min(1) + .max(64) + .regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "variable name must be an identifier"); + +const bindingIdentifier = z + .string() + .min(1) + .max(64) + .regex(/^[A-Za-z0-9_]+$/, "must be alphanumeric/underscore"); +const propertyPath = z + .string() + .min(1) + .max(128) + .regex(/^[A-Za-z_][A-Za-z0-9_.]*$/, "must be a dotted identifier path"); + +const RAW_EXPRESSION = /\{\{|\}\}|\$\{|`/; +// Belt-and-suspenders [council R2]: every compiler-emitted widget binding must match this exact shape (identity dot +// property-path), mirroring restApi.ts's SAFE_BINDING. The bindingIdentifier/propertyPath charsets already guarantee +// it; this is a final assertion so a future charset change can't silently loosen the emitted binding. +const SAFE_BINDING = + /^\{\{ [A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_.]* \}\}$/; +// A variable value — a literal scalar (JSON-encoded, no binding syntax) or a widget reference (parameterized). +const valueRef = z.union([ + z + .object({ + literal: z.union([ + z + .string() + .max(4000) + .refine((v) => !RAW_EXPRESSION.test(v), { + message: "must not contain binding/template syntax", + }), + z.number(), + z.boolean(), + z.null(), + ]), + }) + .strict(), + z.object({ widget: bindingIdentifier, property: propertyPath }).strict(), +]); + +export const graphqlQuerySpecSchema = z + .object({ + name: bindingIdentifier, + applicationId: z.string().min(1).max(128), + pageId: z.string().min(1).max(128), + datasourceId: z.string().min(1).max(128), + query: graphqlOperation, + // The operation's variables ($name in the query). Each value is a literal or a widget reference. + variables: z + .array(z.object({ name: variableName, value: valueRef }).strict()) + .max(50) + .optional(), + }) + .strict(); + +export type GraphqlQuerySpec = z.infer; + +export interface CompiledGraphqlQuery { + body: string; + variables: string; + hasBinding: boolean; +} + +// Build the JSON-string variables map. A literal is embedded via JSON.stringify (quotes/backslashes escaped, so it +// cannot break out of its JSON position); a widget reference is a bare `{{ Widget.prop }}` value that smart +// substitution JSON-encodes at runtime. +function compileVariables(spec: GraphqlQuerySpec): { + variables: string; + hasBinding: boolean; +} { + if (spec.variables === undefined || spec.variables.length === 0) { + return { variables: "", hasBinding: false }; + } + + let hasBinding = false; + const parts = spec.variables.map(({ name, value }) => { + const key = JSON.stringify(name); + + if ("literal" in value) { + return `${key}: ${JSON.stringify(value.literal)}`; + } + + hasBinding = true; + + // A bare mustache value (unquoted) so smart substitution supplies correct JSON typing at runtime. + const binding = `{{ ${value.widget}.${value.property} }}`; + + if (!SAFE_BINDING.test(binding)) { + throw new Error("internal: emitted an unsafe variable binding"); + } + + return `${key}: ${binding}`; + }); + + return { variables: `{ ${parts.join(", ")} }`, hasBinding }; +} + +export function compileGraphqlQuery( + spec: GraphqlQuerySpec, +): CompiledGraphqlQuery { + const { hasBinding, variables } = compileVariables(spec); + + return { body: spec.query, variables, hasBinding }; +} + +// The GraphQL plugin stores a REST POST: body = the operation; pluginSpecifiedTemplates[0] = JSON smart +// substitution (ON so bound variables are parameterized), [1] = the variables JSON, [2] = pagination (unused). +export function buildGraphqlActionDto( + spec: GraphqlQuerySpec, + compiled: CompiledGraphqlQuery, +): Record { + const dynamicBindingPathList = compiled.hasBinding + ? [{ key: "pluginSpecifiedTemplates[1].value" }] + : []; + + // A GraphQL query (read) is safe to run on page load so a bound widget populates without a manual trigger; a + // mutation/subscription stays manual. The operation string's leading keyword is the signal ('{' is shorthand for + // a query). + const trimmed = compiled.body.trimStart(); + const isQuery = trimmed.startsWith("query") || trimmed.startsWith("{"); + + return { + name: spec.name, + pageId: spec.pageId, + datasource: { id: spec.datasourceId }, + executeOnLoad: isQuery, + actionConfiguration: { + httpMethod: "POST", + headers: [{ key: "Content-Type", value: "application/json" }], + body: compiled.body, + formData: { apiContentType: "json" }, + pluginSpecifiedTemplates: [ + { value: true }, + { value: compiled.variables }, + { value: {} }, + ], + ...(dynamicBindingPathList.length > 0 ? { dynamicBindingPathList } : {}), + }, + }; +} diff --git a/app/client/packages/mcp/src/builder/instructions.test.ts b/app/client/packages/mcp/src/builder/instructions.test.ts new file mode 100644 index 000000000000..592780488283 --- /dev/null +++ b/app/client/packages/mcp/src/builder/instructions.test.ts @@ -0,0 +1,493 @@ +import { + getCapabilities, + TOOL_CATALOG, + WIDGET_CATALOG, +} from "./capabilities.js"; +import { + getInstructionDoc, + GUIDES, + INSTRUCTION_DOCS, + parseFields, + RECIPES, + recreateFromScreenshotPlan, + scaffoldCrudPlan, + scaffoldFormPlan, + SERVER_INSTRUCTIONS, + WIDGET_REFERENCE, +} from "./instructions.js"; +import { PRESETS } from "./presets.js"; +import { WIDGET_TYPES } from "./schema.js"; + +// Tools an agent could be told to call. Recipes/prompts must reference ONLY these — never a tool that doesn't exist +// yet (e.g. the data-layer tools), so we never walk the agent into a dead end. +const EXISTING_TOOLS = [ + "get_capabilities", + "list_presets", + "get_preset", + "validate_app_spec", + "build_application", + "edit_page", + "inspect_page", + "list_datasources", + "get_datasource_structure", + "create_query", +]; +// Tools that still do not exist — recipes/prompts must never reference these. +const NONEXISTENT_TOOLS = [ + "bind_widget", + "wire_form_to_query", + "update_layout", +]; + +describe("widget reference", () => { + it("is generated from WIDGET_CATALOG and lists every schema widget type", () => { + const text = WIDGET_REFERENCE.render(); + + for (const widget of WIDGET_CATALOG) { + expect(text).toContain(`## ${widget.type}`); + } + + // Guards against catalog/schema drift as M3 adds widgets. + expect(WIDGET_CATALOG.map((w) => w.type).sort()).toEqual( + [...WIDGET_TYPES].sort(), + ); + }); + + it("never advertises binding/template syntax as an accepted field shape", () => { + const text = WIDGET_REFERENCE.render(); + + expect(text).not.toMatch(/\{\{|\}\}|\$\{/); + }); +}); + +describe("guides and recipes", () => { + it("have unique slugs and non-empty bodies", () => { + const docs = [...GUIDES, ...RECIPES, WIDGET_REFERENCE]; + const slugs = docs.map((d) => d.slug); + + expect(new Set(slugs).size).toBe(slugs.length); + + for (const doc of docs) expect(doc.render().length).toBeGreaterThan(0); + }); + + it("reference only tools that exist", () => { + for (const recipe of RECIPES) { + const body = recipe.render(); + + for (const tool of NONEXISTENT_TOOLS) { + expect(body).not.toContain(tool); + } + } + }); +}); + +describe("screenshot decode guide + prompt", () => { + it("ships the screenshot guide with the three passes and the approximation table", () => { + const doc = getInstructionDoc("screenshot")!; + const body = doc.render(); + + expect(body).toContain("Pass 1"); + expect(body).toContain("Pass 2"); + expect(body).toContain("Pass 3"); + + // The approximation table must point at presets that actually exist. + for (const preset of ["view-switcher", "status-board", "timeline"]) { + expect(body).toContain(preset); + expect(PRESETS[preset]).toBeDefined(); + } + + // Theme extraction teaches only real spec fields. + expect(body).toContain("theme.primaryColor"); + expect(body).not.toMatch(/\{\{|\}\}|\$\{/); + }); + + it("the recreate_from_screenshot plan references only real tools and sanitizes the name", () => { + const plan = recreateFromScreenshotPlan('Tasks "Q3"