From f07f6bf587b22f2c50a023f716837e70d80bcf29 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Fri, 10 Jul 2026 17:13:19 -0400 Subject: [PATCH 01/81] feat: add embedded MCP server with user-scoped token authentication (#15383) Add an opt-in, same-instance Streamable HTTP MCP endpoint that acts as the calling Appsmith user. MCP clients authenticate with a user-scoped bearer token; the Node service forwards that token to existing /api/v1 endpoints so Spring Security reconstructs the real user and existing workspace/app/page ACLs authorize every operation. No privileged/internal credential is used. Server (CE, EE-overridable via *CE base + thin concrete subclass split): - UserMcpToken domain/repository/service + McpTokenController for create/list/ revoke of user-scoped tokens (SHA-256 pre-hash then bcrypt at rest, plaintext shown once, max 10 active tokens/user). - Bearer AuthenticationWebFilter (mcp_ prefix) reconstructs the token owner; invalid/revoked/disabled tokens return 401. - Migration076 creates the userMcpToken indexes (auto-index-creation is off). Node service (app/client/packages/mcp): - Streamable HTTP transport, loopback bind, /health endpoint, request body cap, per-request token revalidation, per-session token binding, and per-user + global session caps. - Tools: list_workspaces, list_applications, get_application_context, and import_application_artifact / import_partial_application_artifact (validated artifact upload through the existing import APIs). Client: - MCP token management UI in the user profile (create / copy-once / revoke). Deploy/CI: - Opt-in APPSMITH_MCP_ENABLED gate (default off) for supervisord autostart and the Caddy /mcp route; Dockerfile copy, mcp-build workflow, route health test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ad-hoc-docker-image.yml | 21 +- .../workflows/build-client-server-count.yml | 11 +- .github/workflows/build-client-server.yml | 11 +- .github/workflows/build-docker-image.yml | 13 + .../workflows/docs/test-build-docker-image.md | 6 +- .github/workflows/github-release.yml | 22 +- .github/workflows/mcp-build.yml | 108 ++++ ...mand-build-docker-image-deploy-preview.yml | 24 +- .github/workflows/playwright-e2e.yml | 13 +- .github/workflows/pr-cypress.yml | 10 +- .github/workflows/test-build-docker-image.yml | 35 +- Dockerfile | 3 + app/client/packages/mcp/.env.example | 4 + app/client/packages/mcp/.eslintignore | 2 + app/client/packages/mcp/build.js | 12 + app/client/packages/mcp/build.sh | 11 + app/client/packages/mcp/jest.config.cjs | 12 + app/client/packages/mcp/package.json | 28 + app/client/packages/mcp/src/app.test.ts | 412 ++++++++++++++ app/client/packages/mcp/src/app.ts | 480 +++++++++++++++++ app/client/packages/mcp/src/server.ts | 18 + app/client/packages/mcp/start-server.sh | 14 + app/client/packages/mcp/tsconfig.json | 12 + app/client/src/api/McpTokenApi.ts | 37 ++ app/client/src/ce/constants/messages.ts | 22 + .../src/pages/UserProfile/McpTokens.test.tsx | 149 +++++ .../src/pages/UserProfile/McpTokens.tsx | 269 +++++++++ app/client/src/pages/UserProfile/index.tsx | 8 + app/client/yarn.lock | 509 ++++++++++++++++-- .../McpTokenAuthenticationConverter.java | 27 + .../McpTokenAuthenticationManager.java | 34 ++ .../tokens/McpTokenAuthentication.java | 24 + .../server/configurations/SecurityConfig.java | 13 +- .../controllers/McpTokenController.java | 16 + .../controllers/ce/McpTokenControllerCE.java | 38 ++ .../appsmith/server/domains/UserMcpToken.java | 11 + .../server/domains/ce/UserMcpTokenCE.java | 23 + .../server/dtos/McpTokenResponseDTO.java | 8 + .../Migration076AddUserMcpTokenIndexes.java | 40 ++ .../repositories/UserMcpTokenRepository.java | 7 + .../ce/UserMcpTokenRepositoryCE.java | 17 + .../server/services/UserMcpTokenService.java | 18 + .../services/UserMcpTokenServiceImpl.java | 18 + .../services/ce/UserMcpTokenServiceCE.java | 17 + .../ce/UserMcpTokenServiceCEImpl.java | 129 +++++ .../McpTokenAuthenticationWebFilterTest.java | 54 ++ .../McpTokenAuthenticationConverterTest.java | 38 ++ .../McpTokenAuthenticationManagerTest.java | 57 ++ .../services/UserMcpTokenServiceImplTest.java | 115 ++++ contributions/ServerSetup.md | 24 +- .../fs/opt/appsmith/caddy-reconfigure.mjs | 12 + deploy/docker/fs/opt/appsmith/entrypoint.sh | 7 +- deploy/docker/fs/opt/appsmith/healthcheck.sh | 7 +- deploy/docker/fs/opt/appsmith/run-mcp.sh | 3 + .../supervisord/application_process/mcp.conf | 9 + .../docker/route-tests/common/mcp-health.hurl | 9 + scripts/local_testing.sh | 10 +- 57 files changed, 3001 insertions(+), 60 deletions(-) create mode 100644 .github/workflows/mcp-build.yml create mode 100644 app/client/packages/mcp/.env.example create mode 100644 app/client/packages/mcp/.eslintignore create mode 100644 app/client/packages/mcp/build.js create mode 100755 app/client/packages/mcp/build.sh create mode 100644 app/client/packages/mcp/jest.config.cjs create mode 100644 app/client/packages/mcp/package.json create mode 100644 app/client/packages/mcp/src/app.test.ts create mode 100644 app/client/packages/mcp/src/app.ts create mode 100644 app/client/packages/mcp/src/server.ts create mode 100755 app/client/packages/mcp/start-server.sh create mode 100644 app/client/packages/mcp/tsconfig.json create mode 100644 app/client/src/api/McpTokenApi.ts create mode 100644 app/client/src/pages/UserProfile/McpTokens.test.tsx create mode 100644 app/client/src/pages/UserProfile/McpTokens.tsx create mode 100644 app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/converters/McpTokenAuthenticationConverter.java create mode 100644 app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManager.java create mode 100644 app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/tokens/McpTokenAuthentication.java create mode 100644 app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/McpTokenController.java create mode 100644 app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/McpTokenControllerCE.java create mode 100644 app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserMcpToken.java create mode 100644 app/server/appsmith-server/src/main/java/com/appsmith/server/domains/ce/UserMcpTokenCE.java create mode 100644 app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/McpTokenResponseDTO.java create mode 100644 app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/db/ce/Migration076AddUserMcpTokenIndexes.java create mode 100644 app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/UserMcpTokenRepository.java create mode 100644 app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/UserMcpTokenRepositoryCE.java create mode 100644 app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserMcpTokenService.java create mode 100644 app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserMcpTokenServiceImpl.java create mode 100644 app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserMcpTokenServiceCE.java create mode 100644 app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserMcpTokenServiceCEImpl.java create mode 100644 app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/McpTokenAuthenticationWebFilterTest.java create mode 100644 app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/converters/McpTokenAuthenticationConverterTest.java create mode 100644 app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManagerTest.java create mode 100644 app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserMcpTokenServiceImplTest.java create mode 100644 deploy/docker/fs/opt/appsmith/run-mcp.sh create mode 100644 deploy/docker/fs/opt/appsmith/templates/supervisord/application_process/mcp.conf create mode 100644 deploy/docker/route-tests/common/mcp-health.hurl 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..5c5cc2671062 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..a712009bd5d5 --- /dev/null +++ b/.github/workflows/mcp-build.yml @@ -0,0 +1,108 @@ +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' + steps: + - name: Checkout the merged pull-request commit + if: inputs.pr != 0 + uses: actions/checkout@v4 + with: + fetch-tags: true + 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 + ref: ${{ inputs.branch }} + + - name: Checkout the head commit + if: inputs.pr == 0 && inputs.branch == '' + uses: actions/checkout@v4 + with: + fetch-tags: true + + - 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: Lint + run: yarn lint + + - name: Run unit tests + if: inputs.skip-tests != 'true' + run: yarn test:unit + + - 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/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/packages/mcp/.env.example b/app/client/packages/mcp/.env.example new file mode 100644 index 000000000000..2832711b5679 --- /dev/null +++ b/app/client/packages/mcp/.env.example @@ -0,0 +1,4 @@ +# Set APPSMITH_MCP_ENABLED=1 to run the MCP server (disabled by default). +APPSMITH_MCP_ENABLED=1 +APPSMITH_MCP_PORT=8092 +APPSMITH_API_BASE_URL=http://127.0.0.1:8080 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/build.js b/app/client/packages/mcp/build.js new file mode 100644 index 000000000000..b44fd0b674ba --- /dev/null +++ b/app/client/packages/mcp/build.js @@ -0,0 +1,12 @@ +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}`, + 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/package.json b/app/client/packages/mcp/package.json new file mode 100644 index 000000000000..e23c9f4521bf --- /dev/null +++ b/app/client/packages/mcp/package.json @@ -0,0 +1,28 @@ +{ + "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", + "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..8390d93971eb --- /dev/null +++ b/app/client/packages/mcp/src/app.test.ts @@ -0,0 +1,412 @@ +import supertest from "supertest"; +import { + MAX_ARTIFACT_BYTES, + createAppsmithApi, + createMcpHttpServer, + type AppsmithApi, +} from "./app.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 the user bearer token for read and artifact import requests", async () => { + const fetchFn = successfulFetch(); + const api = createAppsmithApi( + "user-token", + API_BASE_URL, + fetchFn as unknown as typeof fetch, + ); + + 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`, + `${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); + 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("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( + validateToken = jest.fn(async () => ({ + username: "user@appsmith.com", + isAnonymous: false, + })), +) { + return (): AppsmithApi => ({ + getApplicationContext: jest.fn(), + importApplicationArtifact: jest.fn(), + importPartialApplicationArtifact: jest.fn(), + listApplications: jest.fn(), + listWorkspaces: 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 }[] }; +} { + const body = response.body as { result?: unknown } | undefined; + + if (body && body.result !== undefined) { + return body as { result: { tools: { name: 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("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" }, + }); + }); + + 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", + "import_application_artifact", + "import_partial_application_artifact", + ]), + ); + expect(names).not.toEqual( + expect.arrayContaining(["create_application", "update_layout"]), + ); + }); + + 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" }); + + expect(expired).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 tokens that resolve to an anonymous session", async () => { + const validateToken = jest.fn(async () => ({ + isAnonymous: true, + username: "anonymousUser", + })); + const server = createMcpHttpServer(API_BASE_URL, createApi(validateToken)); + + const response = await supertest(server) + .post("/mcp") + .set("Accept", "application/json, text/event-stream") + .set("Authorization", "Bearer mcp_anon-token") + .send(initializeRequest); + + expect(response).toMatchObject({ + status: 401, + body: { error: "invalid bearer token" }, + }); + }); + + it("rejects new sessions when the per-user limit is reached", async () => { + const server = createMcpHttpServer(API_BASE_URL, createApi(), { + maxSessionsPerUser: 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: 429, + body: { error: "MCP session limit reached for this user" }, + }); + }); +}); diff --git a/app/client/packages/mcp/src/app.ts b/app/client/packages/mcp/src/app.ts new file mode 100644 index 000000000000..e36648706974 --- /dev/null +++ b/app/client/packages/mcp/src/app.ts @@ -0,0 +1,480 @@ +import { randomUUID, timingSafeEqual } from "node:crypto"; +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; + +const MAX_ID_LENGTH = 128; +export const MAX_ARTIFACT_BYTES = 1024 * 1024; +export const MAX_REQUEST_BODY_BYTES = 2 * 1024 * 1024; +export const MAX_MCP_SESSIONS = 100; +export const MAX_MCP_SESSIONS_PER_USER = 10; +export const MCP_SESSION_TTL_MS = 15 * 60 * 1000; +const MCP_TOKEN_PREFIX = "mcp_"; +const idSchema = z + .string() + .trim() + .min(1) + .max(MAX_ID_LENGTH) + .regex(/^[A-Za-z0-9_-]+$/); +const artifactSchema = z.record(z.unknown()).superRefine((artifact, ctx) => { + try { + serializeArtifact(artifact); + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + error instanceof Error + ? error.message + : "Artifact must be valid JSON", + }); + } +}); + +interface ApiResponse { + data: T; + responseMeta?: { success?: boolean }; +} + +export interface AppsmithApi { + getApplicationContext: ( + applicationId: string, + pageId: string, + layoutId: string, + ) => Promise<{ pages: unknown; page: unknown; layout: unknown }>; + listApplications: (workspaceId: string) => Promise; + listWorkspaces: () => Promise; + importApplicationArtifact: ( + workspaceId: string, + artifact: Record, + ) => Promise; + importPartialApplicationArtifact: ( + workspaceId: string, + applicationId: string, + pageId: string, + artifact: Record, + ) => Promise; + validateToken: () => Promise; +} + +function serializeArtifact(artifact: Record): string { + if (Object.keys(artifact).length === 0) { + throw new Error("Artifact must not be empty"); + } + + const seen = new WeakSet(); + const serialized = JSON.stringify( + artifact, + function (this: Record, _key, value) { + // JSON.stringify applies toJSON() before the replacer runs, so inspect the + // original value via the holder to reject Date and other non-plain objects + // that would otherwise be silently coerced to a string (e.g. an ISO date). + const original = this[_key]; + + if ( + original !== null && + typeof original === "object" && + typeof (original as { toJSON?: unknown }).toJSON === "function" + ) { + throw new Error("Artifact must contain only plain JSON objects"); + } + + if ( + value === undefined || + typeof value === "bigint" || + typeof value === "function" || + typeof value === "symbol" || + (typeof value === "number" && !Number.isFinite(value)) + ) { + throw new Error("Artifact must contain only JSON values"); + } + + if (value && typeof value === "object") { + const prototype = Object.getPrototypeOf(value); + + if ( + !Array.isArray(value) && + prototype !== Object.prototype && + prototype !== null + ) { + throw new Error("Artifact must contain only plain JSON objects"); + } + + if (seen.has(value)) { + throw new Error("Artifact must not contain circular references"); + } + + seen.add(value); + } + + return value; + }, + ); + + if (!serialized || Buffer.byteLength(serialized, "utf8") > MAX_ARTIFACT_BYTES) { + throw new Error(`Artifact must not exceed ${MAX_ARTIFACT_BYTES} bytes`); + } + + return serialized; +} + +function artifactUpload(artifact: Record): FormData { + const parsedArtifact = artifactSchema.parse(artifact); + const body = serializeArtifact(parsedArtifact); + const file = new Blob([body], { type: "application/json" }); + const formData = new FormData(); + + formData.append("file", file, "appsmith-artifact.json"); + + return formData; +} + +export function createAppsmithApi( + token: string, + apiBaseUrl: string, + fetchFn: typeof fetch = fetch, +): AppsmithApi { + async function request(path: string, init?: RequestInit): Promise { + const isMultipart = init?.body instanceof FormData; + const response = await fetchFn(`${apiBaseUrl}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${token}`, + ...(isMultipart ? {} : { "Content-Type": "application/json" }), + ...init?.headers, + }, + }); + + if (!response.ok) { + throw new Error(`Appsmith API request failed (${response.status})`); + } + + return ((await response.json()) as ApiResponse).data; + } + + return { + listWorkspaces: async () => request("/api/v1/workspaces"), + listApplications: async (workspaceId) => + request( + `/api/v1/applications/home?workspaceId=${encodeURIComponent(workspaceId)}`, + ), + getApplicationContext: async (applicationId, pageId, layoutId) => { + const [pages, page, layout] = await Promise.all([ + request( + `/api/v1/pages?applicationId=${encodeURIComponent(applicationId)}`, + ), + request(`/api/v1/pages/${encodeURIComponent(pageId)}`), + request( + `/api/v1/layouts/${encodeURIComponent(layoutId)}/pages/${encodeURIComponent(pageId)}`, + ), + ]); + + return { pages, page, layout }; + }, + importApplicationArtifact: async (workspaceId, artifact) => + request( + `/api/v1/applications/import/${encodeURIComponent(workspaceId)}`, + { + method: "POST", + body: artifactUpload(artifact), + }, + ), + importPartialApplicationArtifact: async ( + workspaceId, + applicationId, + pageId, + artifact, + ) => + request( + `/api/v1/applications/import/partial/${encodeURIComponent(workspaceId)}/${encodeURIComponent(applicationId)}?pageId=${encodeURIComponent(pageId)}`, + { + method: "POST", + body: artifactUpload(artifact), + }, + ), + validateToken: async () => request("/api/v1/users/me"), + }; +} + +function result(data: unknown) { + return { + content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], + }; +} + +export function buildMcpServer(api: AppsmithApi) { + const server = new McpServer({ name: "appsmith-mcp", version: "0.0.1" }); + + server.tool( + "list_workspaces", + "List workspaces accessible to the authenticated Appsmith user.", + {}, + async () => result(await api.listWorkspaces()), + ); + + server.tool( + "list_applications", + "List applications in a workspace accessible to the authenticated Appsmith user.", + { workspaceId: idSchema }, + async ({ workspaceId }) => result(await api.listApplications(workspaceId)), + ); + + server.tool( + "get_application_context", + "Read the requested application page and layout. Appsmith authorizes every API request using the caller's bearer token.", + { applicationId: idSchema, pageId: idSchema, layoutId: idSchema }, + async ({ applicationId, layoutId, pageId }) => + result(await api.getApplicationContext(applicationId, pageId, layoutId)), + ); + + server.tool( + "import_application_artifact", + "Create an application by importing a validated Appsmith application artifact. Appsmith authorizes the import using the caller's bearer token.", + { + workspaceId: idSchema, + artifact: artifactSchema, + }, + async ({ artifact, workspaceId }) => + result(await api.importApplicationArtifact(workspaceId, artifact)), + ); + + server.tool( + "import_partial_application_artifact", + "Update an application page by importing a validated partial Appsmith artifact. Appsmith authorizes the import using the caller's bearer token.", + { + workspaceId: idSchema, + applicationId: idSchema, + pageId: idSchema, + artifact: artifactSchema, + }, + async ({ applicationId, artifact, pageId, workspaceId }) => + result( + await api.importPartialApplicationArtifact( + workspaceId, + applicationId, + pageId, + artifact, + ), + ), + ); + + return server; +} + +export function bearerToken(req: IncomingMessage): string | undefined { + const value = req.headers.authorization; + const match = value?.match(/^Bearer\s+(.+)$/i); + + return match?.[1]?.trim(); +} + +function writeJson(res: ServerResponse, status: number, body: unknown) { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); +} + +class HttpError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + } +} + +async function readBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let byteLength = 0; + + for await (const chunk of req) { + const buffer = Buffer.from(chunk); + byteLength += buffer.byteLength; + + if (byteLength > MAX_REQUEST_BODY_BYTES) { + throw new HttpError(413, "request body too large"); + } + + chunks.push(buffer); + } + + try { + return JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch { + throw new HttpError(400, "invalid JSON request body"); + } +} + +interface McpSession { + expiresAt: number; + token: string; + username: string; + transport: StreamableHTTPServerTransport; +} + +export interface McpHttpServerOptions { + maxSessions?: number; + maxSessionsPerUser?: number; + now?: () => number; + sessionTtlMs?: number; +} + +function tokensMatch(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + + return ( + leftBuffer.byteLength === rightBuffer.byteLength && + timingSafeEqual(leftBuffer, rightBuffer) + ); +} + +export function createMcpHttpServer( + apiBaseUrl: string, + createApi: (token: string) => AppsmithApi = (token) => + createAppsmithApi(token, apiBaseUrl), + options: McpHttpServerOptions = {}, +): Server { + const sessions = new Map(); + const maxSessions = options.maxSessions ?? MAX_MCP_SESSIONS; + const maxSessionsPerUser = + options.maxSessionsPerUser ?? MAX_MCP_SESSIONS_PER_USER; + const now = options.now ?? Date.now; + const sessionTtlMs = options.sessionTtlMs ?? MCP_SESSION_TTL_MS; + + function removeExpiredSessions() { + const currentTime = now(); + + for (const [id, session] of sessions) { + if (session.expiresAt <= currentTime) { + sessions.delete(id); + void session.transport.close(); + } + } + } + + async function authenticatedUsername(api: AppsmithApi): Promise { + let profile: { username?: string; isAnonymous?: boolean }; + + try { + profile = (await api.validateToken()) as { + username?: string; + isAnonymous?: boolean; + }; + } catch { + throw new HttpError(401, "invalid bearer token"); + } + + if (!profile || profile.isAnonymous === true || !profile.username) { + throw new HttpError(401, "invalid bearer token"); + } + + return profile.username; + } + + return createServer(async (req, res) => { + try { + const path = (req.url ?? "").split("?")[0]; + + if (path === "/health") { + writeJson(res, 200, { status: "ok" }); + + return; + } + + if (path !== "/mcp") { + writeJson(res, 404, { error: "not found" }); + + return; + } + + const token = bearerToken(req); + + if (!token || !token.startsWith(MCP_TOKEN_PREFIX)) { + res.writeHead(401, { "WWW-Authenticate": "Bearer" }); + res.end(); + + return; + } + + removeExpiredSessions(); + + const sessionId = req.headers["mcp-session-id"] as string | undefined; + const session = sessionId ? sessions.get(sessionId) : undefined; + const body = req.method === "POST" ? await readBody(req) : undefined; + let transport = session?.transport; + + if (session) { + if (!tokensMatch(session.token, token)) { + sessions.delete(sessionId!); + void session.transport.close(); + writeJson(res, 401, { error: "invalid MCP session" }); + + return; + } + + await authenticatedUsername(createApi(token)); + session.expiresAt = now() + sessionTtlMs; + } + + if (!transport && isInitializeRequest(body)) { + if (sessions.size >= maxSessions) { + writeJson(res, 503, { error: "MCP session limit reached" }); + + return; + } + + const api = createApi(token); + const username = await authenticatedUsername(api); + let userSessionCount = 0; + + for (const existing of sessions.values()) { + if (existing.username === username) userSessionCount += 1; + } + + if (userSessionCount >= maxSessionsPerUser) { + writeJson(res, 429, { + error: "MCP session limit reached for this user", + }); + + return; + } + + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: randomUUID, + onsessioninitialized: (id) => { + sessions.set(id, { + expiresAt: now() + sessionTtlMs, + token, + username, + transport: transport!, + }); + }, + }); + transport.onclose = () => { + if (transport?.sessionId) sessions.delete(transport.sessionId); + }; + await buildMcpServer(api).connect(transport); + } + + if (!transport) { + writeJson(res, 400, { error: "initialize the MCP session first" }); + + return; + } + + await transport.handleRequest(req, res, body); + } catch (error) { + const status = error instanceof HttpError ? error.status : 500; + const message = + error instanceof HttpError ? error.message : "MCP request failed"; + + writeJson(res, status, { error: message }); + } + }); +} diff --git a/app/client/packages/mcp/src/server.ts b/app/client/packages/mcp/src/server.ts new file mode 100644 index 000000000000..92b5ef2ce858 --- /dev/null +++ b/app/client/packages/mcp/src/server.ts @@ -0,0 +1,18 @@ +import { createMcpHttpServer } from "./app.js"; + +const port = Number(process.env.APPSMITH_MCP_PORT ?? 8092); +const apiBaseUrl = process.env.APPSMITH_API_BASE_URL ?? "http://127.0.0.1:8080"; + +const httpServer = createMcpHttpServer(apiBaseUrl); + +function reportProcessFailure(kind: string) { + process.stderr.write(`Appsmith MCP ${kind}\n`); + process.exitCode = 1; +} + +process.once("uncaughtException", () => reportProcessFailure("process failure")); +process.once("unhandledRejection", () => reportProcessFailure("process rejection")); + +httpServer.listen(port, "127.0.0.1", () => { + process.stderr.write(`Appsmith MCP listening on 127.0.0.1:${port}\n`); +}); diff --git a/app/client/packages/mcp/start-server.sh b/app/client/packages/mcp/start-server.sh new file mode 100755 index 000000000000..9bc1a960a977 --- /dev/null +++ b/app/client/packages/mcp/start-server.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +set -o errexit + +cd "$(dirname "$0")" +./build.sh + +if [[ -f .env ]]; then + set -o allexport + source .env + set +o allexport +fi + +exec node --enable-source-maps dist/bundle/server.js diff --git a/app/client/packages/mcp/tsconfig.json b/app/client/packages/mcp/tsconfig.json new file mode 100644 index 000000000000..a5492c6a23fb --- /dev/null +++ b/app/client/packages/mcp/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/app/client/src/api/McpTokenApi.ts b/app/client/src/api/McpTokenApi.ts new file mode 100644 index 000000000000..7308e1ede0bd --- /dev/null +++ b/app/client/src/api/McpTokenApi.ts @@ -0,0 +1,37 @@ +import Api from "api/Api"; +import type { ApiResponse } from "api/ApiResponses"; + +export interface McpTokenMetadata { + createdAt: string; + id: string; +} + +export interface CreatedMcpToken extends McpTokenMetadata { + token: string; +} + +class McpTokenApi extends Api { + static url = "v1/users/mcp-tokens"; + + static async create(): Promise> { + const response = await Api.post(McpTokenApi.url); + + return response as ApiResponse; + } + + static async list(): Promise[]> { + const response = await Api.get(McpTokenApi.url); + + return ( + Array.isArray(response) ? response : [response] + ) as ApiResponse[]; + } + + static async revoke(tokenId: string): Promise> { + const response = await Api.delete(`${McpTokenApi.url}/${tokenId}`); + + return response as ApiResponse; + } +} + +export default McpTokenApi; diff --git a/app/client/src/ce/constants/messages.ts b/app/client/src/ce/constants/messages.ts index 93533ed3e330..7814052d0f45 100644 --- a/app/client/src/ce/constants/messages.ts +++ b/app/client/src/ce/constants/messages.ts @@ -238,6 +238,28 @@ export const USER_DISPLAY_NAME_PLACEHOLDER = () => "Display name"; export const USER_DISPLAY_PICTURE_PLACEHOLDER = () => "Display picture"; export const USER_EMAIL_PLACEHOLDER = () => "Email"; export const USER_RESET_PASSWORD = () => "Reset password"; +export const MCP_TOKENS = () => "MCP tokens"; +export const MCP_TOKENS_DESCRIPTION = () => + "Create a token to connect an MCP client to Appsmith. Tokens are shown only once."; +export const CREATE_MCP_TOKEN = () => "Create token"; +export const MCP_TOKEN_CREATED = () => "MCP token created"; +export const MCP_TOKEN_CREATED_DESCRIPTION = () => + "Copy this token now. You will not be able to view it again."; +export const MCP_TOKEN_VALUE_LABEL = () => "MCP token"; +export const COPY_MCP_TOKEN = () => "Copy token"; +export const MCP_TOKEN_COPIED = () => "MCP token copied"; +export const MCP_TOKEN_COPY_FAILED = () => "Unable to copy MCP token."; +export const MCP_TOKENS_LOADING = () => "Loading MCP tokens…"; +export const MCP_TOKENS_EMPTY = () => "No MCP tokens have been created."; +export const MCP_TOKEN_CREATED_AT = () => "Created"; +export const REVOKE_MCP_TOKEN = () => "Revoke"; +export const REVOKE_MCP_TOKEN_CONFIRM = () => "Revoke token"; +export const REVOKE_MCP_TOKEN_CONFIRMATION = () => + "Revoke this MCP token? Connected MCP clients will no longer be able to use it."; +export const MCP_TOKEN_REVOKED = () => "MCP token revoked"; +export const MCP_TOKENS_LOAD_FAILED = () => "Unable to load MCP tokens."; +export const MCP_TOKEN_CREATE_FAILED = () => "Unable to create MCP token."; +export const MCP_TOKEN_REVOKE_FAILED = () => "Unable to revoke MCP token."; export const CREATE_PASSWORD_RESET_SUCCESS = () => `Your password has been set`; export const CREATE_PASSWORD_RESET_SUCCESS_LOGIN_LINK = () => `Login`; diff --git a/app/client/src/pages/UserProfile/McpTokens.test.tsx b/app/client/src/pages/UserProfile/McpTokens.test.tsx new file mode 100644 index 000000000000..391e718313d0 --- /dev/null +++ b/app/client/src/pages/UserProfile/McpTokens.test.tsx @@ -0,0 +1,149 @@ +import "@testing-library/jest-dom/extend-expect"; +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { ThemeProvider } from "styled-components"; +import { lightTheme } from "selectors/themeSelectors"; +import McpTokenApi from "api/McpTokenApi"; +import McpTokens from "./McpTokens"; + +jest.mock("api/McpTokenApi", () => ({ + __esModule: true, + default: { + create: jest.fn(), + list: jest.fn(), + revoke: jest.fn(), + }, +})); + +const successResponse = (data: T) => ({ + responseMeta: { success: true, status: 200 }, + data, +}); + +const renderComponent = () => + render( + + + , + ); + +describe("McpTokens", () => { + beforeEach(() => { + (McpTokenApi.list as jest.Mock).mockResolvedValue([ + successResponse({ + id: "token-1", + createdAt: "2026-07-10T12:00:00.000Z", + }), + ]); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("lists metadata without displaying token plaintext", async () => { + renderComponent(); + + expect(await screen.findByText("token-1")).toBeInTheDocument(); + expect(screen.queryByText("secret-token")).not.toBeInTheDocument(); + }); + + it("shows a labeled, read-only monospace token field after creation", async () => { + Object.assign(navigator, { + clipboard: { writeText: jest.fn().mockResolvedValue(undefined) }, + }); + (McpTokenApi.create as jest.Mock).mockResolvedValue( + successResponse({ + id: "token-2", + token: "secret-token", + createdAt: "2026-07-10T12:00:00.000Z", + }), + ); + renderComponent(); + + await screen.findByText("token-1"); + fireEvent.click(screen.getByRole("button", { name: "Create token" })); + + const tokenField = await screen.findByLabelText("MCP token"); + + expect(tokenField).toHaveValue("secret-token"); + expect(tokenField).toHaveAttribute("readonly"); + expect(tokenField).toHaveStyle("font-family: ui-monospace"); + fireEvent.click(screen.getByRole("button", { name: "Copy token" })); + + await waitFor(() => + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + "secret-token", + ), + ); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + + await waitFor(() => + expect(screen.queryByLabelText("MCP token")).not.toBeInTheDocument(), + ); + }); + + it("confirms a revoke request before calling the API", async () => { + (McpTokenApi.revoke as jest.Mock).mockResolvedValue(successResponse(true)); + renderComponent(); + + await screen.findByText("token-1"); + fireEvent.click(screen.getByRole("button", { name: "Revoke token-1" })); + expect(McpTokenApi.revoke).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Revoke token" })); + + await waitFor(() => + expect(McpTokenApi.revoke).toHaveBeenCalledWith("token-1"), + ); + expect(screen.queryByText("token-1")).not.toBeInTheDocument(); + }); + + it("renders an accessible error when the token list fails", async () => { + (McpTokenApi.list as jest.Mock).mockRejectedValue( + new Error("Request failed"), + ); + renderComponent(); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Request failed", + ); + }); + + it("announces token loading status", () => { + (McpTokenApi.list as jest.Mock).mockImplementation( + async () => new Promise(() => undefined), + ); + renderComponent(); + + expect(screen.getByRole("status")).toHaveTextContent("Loading MCP tokens"); + }); + + it("closes the revoke confirmation and shows a page error on failure", async () => { + (McpTokenApi.revoke as jest.Mock).mockRejectedValue( + new Error("Revocation failed"), + ); + renderComponent(); + + await screen.findByText("token-1"); + fireEvent.click(screen.getByRole("button", { name: "Revoke token-1" })); + fireEvent.click(screen.getByRole("button", { name: "Revoke token" })); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Revocation failed", + ); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("provides a clearly named cancel action for revoke confirmation", async () => { + renderComponent(); + + await screen.findByText("token-1"); + fireEvent.click(screen.getByRole("button", { name: "Revoke token-1" })); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(McpTokenApi.revoke).not.toHaveBeenCalled(); + }); +}); diff --git a/app/client/src/pages/UserProfile/McpTokens.tsx b/app/client/src/pages/UserProfile/McpTokens.tsx new file mode 100644 index 000000000000..664a458d895c --- /dev/null +++ b/app/client/src/pages/UserProfile/McpTokens.tsx @@ -0,0 +1,269 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { + Button, + Input, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + Text, + toast, +} from "@appsmith/ads"; +import { + COPY_MCP_TOKEN, + CREATE_MCP_TOKEN, + MCP_TOKEN_COPIED, + MCP_TOKEN_COPY_FAILED, + MCP_TOKEN_CREATE_FAILED, + MCP_TOKEN_CREATED, + MCP_TOKEN_CREATED_AT, + MCP_TOKEN_CREATED_DESCRIPTION, + MCP_TOKEN_VALUE_LABEL, + MCP_TOKEN_REVOKE_FAILED, + MCP_TOKEN_REVOKED, + MCP_TOKENS, + MCP_TOKENS_DESCRIPTION, + MCP_TOKENS_EMPTY, + MCP_TOKENS_LOAD_FAILED, + MCP_TOKENS_LOADING, + CANCEL, + REVOKE_MCP_TOKEN, + REVOKE_MCP_TOKEN_CONFIRM, + REVOKE_MCP_TOKEN_CONFIRMATION, + createMessage, +} from "ee/constants/messages"; +import McpTokenApi, { + type CreatedMcpToken, + type McpTokenMetadata, +} from "api/McpTokenApi"; +import type { ApiResponse } from "api/ApiResponses"; +import styled from "styled-components"; +import { Wrapper } from "./StyledComponents"; + +const TokensWrapper = styled(Wrapper)` + width: 640px; + max-width: 100%; +`; + +const getErrorMessage = (error: unknown, fallback: string) => { + const response = error as Partial & { message?: string }; + + return response.responseMeta?.error?.message || response.message || fallback; +}; + +const ensureSuccess = (response: ApiResponse) => { + if (!response.responseMeta?.success) { + throw response; + } + + return response.data; +}; + +const formatCreatedAt = (createdAt: string) => { + const date = new Date(createdAt); + + return Number.isNaN(date.getTime()) ? createdAt : date.toLocaleString(); +}; + +function McpTokens() { + const [tokens, setTokens] = useState([]); + const [createdToken, setCreatedToken] = useState( + null, + ); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isCreating, setIsCreating] = useState(false); + const [revokeTokenId, setRevokeTokenId] = useState(null); + const [isRevoking, setIsRevoking] = useState(false); + + const loadTokens = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + const response = await McpTokenApi.list(); + + setTokens(response.map(ensureSuccess)); + } catch (error) { + setError(getErrorMessage(error, createMessage(MCP_TOKENS_LOAD_FAILED))); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + loadTokens(); + }, [loadTokens]); + + const createToken = async () => { + setIsCreating(true); + setError(null); + + try { + const response = await McpTokenApi.create(); + const token = ensureSuccess(response); + + setCreatedToken(token); + setTokens((tokens) => [ + { id: token.id, createdAt: token.createdAt }, + ...tokens, + ]); + } catch (error) { + setError(getErrorMessage(error, createMessage(MCP_TOKEN_CREATE_FAILED))); + } finally { + setIsCreating(false); + } + }; + + const copyCreatedToken = async () => { + if (!createdToken) { + return; + } + + try { + await navigator.clipboard.writeText(createdToken.token); + toast.show(createMessage(MCP_TOKEN_COPIED), { kind: "success" }); + } catch { + toast.show(createMessage(MCP_TOKEN_COPY_FAILED), { kind: "error" }); + } + }; + + const revokeToken = async () => { + if (!revokeTokenId) { + return; + } + + setIsRevoking(true); + setError(null); + + try { + ensureSuccess(await McpTokenApi.revoke(revokeTokenId)); + setTokens((tokens) => + tokens.filter((token) => token.id !== revokeTokenId), + ); + setRevokeTokenId(null); + toast.show(createMessage(MCP_TOKEN_REVOKED), { kind: "success" }); + } catch (error) { + setRevokeTokenId(null); + setError(getErrorMessage(error, createMessage(MCP_TOKEN_REVOKE_FAILED))); + } finally { + setIsRevoking(false); + } + }; + + return ( + <> + + {createMessage(MCP_TOKENS_DESCRIPTION)} + {error && ( + + {error} + + )} +
+ +
+ {isLoading ? ( + + {createMessage(MCP_TOKENS_LOADING)} + + ) : tokens.length === 0 ? ( + {createMessage(MCP_TOKENS_EMPTY)} + ) : ( +
+ {tokens.map((token) => ( +
+ {token.id} + + {createMessage(MCP_TOKEN_CREATED_AT)}:{" "} + {formatCreatedAt(token.createdAt)} + + +
+ ))} +
+ )} +
+ + { + if (!open) { + setCreatedToken(null); + } + }} + open={Boolean(createdToken)} + > + + {createMessage(MCP_TOKEN_CREATED)} + + + {createMessage(MCP_TOKEN_CREATED_DESCRIPTION)} + + + + + + + + { + if (!open) { + setRevokeTokenId(null); + } + }} + open={Boolean(revokeTokenId)} + > + + {createMessage(REVOKE_MCP_TOKEN)} + + + {createMessage(REVOKE_MCP_TOKEN_CONFIRMATION)} + + + + + + + + + + ); +} + +export default McpTokens; diff --git a/app/client/src/pages/UserProfile/index.tsx b/app/client/src/pages/UserProfile/index.tsx index d7ec1f27aad7..c0f33603352d 100644 --- a/app/client/src/pages/UserProfile/index.tsx +++ b/app/client/src/pages/UserProfile/index.tsx @@ -12,6 +12,8 @@ import { fetchGlobalGitConfigInit } from "actions/gitSyncActions"; import { useGitModEnabled } from "pages/Editor/gitSync/hooks/modHooks"; import { GitGlobalProfile as GitGlobalProfileNew } from "git"; import { gitFetchGlobalProfile } from "git/store"; +import { MCP_TOKENS, createMessage } from "ee/constants/messages"; +import McpTokens from "./McpTokens"; function GitGlobalProfile() { const isGitModEnabled = useGitModEnabled(); @@ -52,6 +54,12 @@ function UserProfile() { panelComponent: , icon: "git-branch", }); + tabs.push({ + key: "mcpTokens", + title: createMessage(MCP_TOKENS), + panelComponent: , + icon: "key", + }); if (location.pathname === GIT_PROFILE_ROUTE) { initialTab = "gitConfig"; diff --git a/app/client/yarn.lock b/app/client/yarn.lock index 1a42c2bb1794..2f4aab8e8f55 100644 --- a/app/client/yarn.lock +++ b/app/client/yarn.lock @@ -3892,6 +3892,15 @@ __metadata: languageName: node linkType: hard +"@hono/node-server@npm:^1.19.9": + version: 1.19.14 + resolution: "@hono/node-server@npm:1.19.14" + peerDependencies: + hono: ^4 + checksum: c2343ee3d46352f492ee475e12b3c6adfeb3cb4514d7eb06511c38987ec2e6578e26f8ee8d5aa9843c1d34959a76d09cb02f9dd28015f3d172dd9495d66fa527 + languageName: node + linkType: hard + "@humanwhocodes/config-array@npm:^0.11.11": version: 0.11.11 resolution: "@humanwhocodes/config-array@npm:0.11.11" @@ -4625,6 +4634,39 @@ __metadata: languageName: node linkType: hard +"@modelcontextprotocol/sdk@npm:^1.12.0": + version: 1.29.0 + resolution: "@modelcontextprotocol/sdk@npm:1.29.0" + dependencies: + "@hono/node-server": ^1.19.9 + ajv: ^8.17.1 + ajv-formats: ^3.0.1 + content-type: ^1.0.5 + cors: ^2.8.5 + cross-spawn: ^7.0.5 + eventsource: ^3.0.2 + eventsource-parser: ^3.0.0 + express: ^5.2.1 + express-rate-limit: ^8.2.1 + hono: ^4.11.4 + jose: ^6.1.3 + json-schema-typed: ^8.0.2 + pkce-challenge: ^5.0.0 + raw-body: ^3.0.0 + zod: ^3.25 || ^4.0 + zod-to-json-schema: ^3.25.1 + peerDependencies: + "@cfworker/json-schema": ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + "@cfworker/json-schema": + optional: true + zod: + optional: false + checksum: 68d538bc9a2782f4b674d6dfd72fe3daffc795ee82fe8dca0f6297b7ce9ece05c31c7cbe04bdeca695e678ab51742c025c2ddf125091b48c06daa7c8732d3d7e + languageName: node + linkType: hard + "@mongodb-js/saslprep@npm:^1.1.0, @mongodb-js/saslprep@npm:^1.1.9": version: 1.2.0 resolution: "@mongodb-js/saslprep@npm:1.2.0" @@ -11253,6 +11295,13 @@ __metadata: languageName: node linkType: hard +"@types/cookiejar@npm:^2.1.5": + version: 2.1.5 + resolution: "@types/cookiejar@npm:2.1.5" + checksum: 04d5990e87b6387532d15a87d9ec9b2eb783039291193863751dcfd7fc723a3b3aa30ce4c06b03975cba58632e933772f1ff031af23eaa3ac7f94e71afa6e073 + languageName: node + linkType: hard + "@types/cross-spawn@npm:^6.0.2": version: 6.0.6 resolution: "@types/cross-spawn@npm:6.0.6" @@ -11715,6 +11764,13 @@ __metadata: languageName: node linkType: hard +"@types/methods@npm:^1.1.4": + version: 1.1.4 + resolution: "@types/methods@npm:1.1.4" + checksum: ad2a7178486f2fd167750f3eb920ab032a947ff2e26f55c86670a6038632d790b46f52e5b6ead5823f1e53fc68028f1e9ddd15cfead7903e04517c88debd72b1 + languageName: node + linkType: hard + "@types/mime@npm:^1": version: 1.3.5 resolution: "@types/mime@npm:1.3.5" @@ -12213,6 +12269,28 @@ __metadata: languageName: node linkType: hard +"@types/superagent@npm:^8.1.0": + version: 8.1.10 + resolution: "@types/superagent@npm:8.1.10" + dependencies: + "@types/cookiejar": ^2.1.5 + "@types/methods": ^1.1.4 + "@types/node": "*" + form-data: ^4.0.0 + checksum: a712c3e8c9fa0d0a3ab9252fb4a1f3dcbfe143be56f5934e8ac7748fe282f517d15132ca79644dd07561c0679f97b1616e607f64bb628e6b28d1222ce781ee7a + languageName: node + linkType: hard + +"@types/supertest@npm:^6.0.2": + version: 6.0.3 + resolution: "@types/supertest@npm:6.0.3" + dependencies: + "@types/methods": ^1.1.4 + "@types/superagent": ^8.1.0 + checksum: 5f75e4190620bb06168cca6998c1af29168e7ca64de34abe522002573d96b37ce2611ee53a9a360b3f42d44f0bfea547d0bb258f038ab69ac50d90bd5b96fc13 + languageName: node + linkType: hard + "@types/tern@npm:*": version: 0.23.3 resolution: "@types/tern@npm:0.23.3" @@ -13419,6 +13497,16 @@ __metadata: languageName: node linkType: hard +"accepts@npm:^2.0.0": + version: 2.0.0 + resolution: "accepts@npm:2.0.0" + dependencies: + mime-types: ^3.0.0 + negotiator: ^1.0.0 + checksum: 49fe6c050cb6f6ff4e771b4d88324fca4d3127865f2473872e818dca127d809ba3aa8fdfc7acb51dd3c5bade7311ca6b8cfff7015ea6db2f7eb9c8444d223a4f + languageName: node + linkType: hard + "accepts@npm:~1.3.4, accepts@npm:~1.3.8": version: 1.3.8 resolution: "accepts@npm:1.3.8" @@ -13620,6 +13708,20 @@ __metadata: languageName: node linkType: hard +"ajv-formats@npm:^3.0.1": + version: 3.0.1 + resolution: "ajv-formats@npm:3.0.1" + dependencies: + ajv: ^8.0.0 + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + checksum: f4e1fe232d67fcafc02eafe373a7a9962351e0439dd0736647ca75c93c3da23b430b6502c255ab4315410ae330d4f3013ac9fe226c40b2524ca93a58e786d086 + languageName: node + linkType: hard + "ajv-keywords@npm:^3.4.1, ajv-keywords@npm:^3.5.2": version: 3.5.2 resolution: "ajv-keywords@npm:3.5.2" @@ -13816,6 +13918,23 @@ __metadata: languageName: unknown linkType: soft +"appsmith-mcp@workspace:packages/mcp": + version: 0.0.0-use.local + resolution: "appsmith-mcp@workspace:packages/mcp" + dependencies: + "@modelcontextprotocol/sdk": ^1.12.0 + "@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 + zod: ^3.25.0 + languageName: unknown + linkType: soft + "appsmith-rts@workspace:packages/rts": version: 0.0.0-use.local resolution: "appsmith-rts@workspace:packages/rts" @@ -15140,6 +15259,23 @@ __metadata: languageName: node linkType: hard +"body-parser@npm:^2.2.1": + version: 2.3.0 + resolution: "body-parser@npm:2.3.0" + dependencies: + bytes: ^3.1.2 + content-type: ^2.0.0 + debug: ^4.4.3 + http-errors: ^2.0.1 + iconv-lite: ^0.7.2 + on-finished: ^2.4.1 + qs: ^6.15.2 + raw-body: ^3.0.2 + type-is: ^2.1.0 + checksum: 44d681aaa2d6ffe3efb4f6c098e7c4f7beae0d158580f7abea1cd487fc05b485488815f5c6fc71aa05975541c00042b4becdb485c441ddd63ce9c71b63b7ea4d + languageName: node + linkType: hard + "body-parser@npm:~1.20.5": version: 1.20.5 resolution: "body-parser@npm:1.20.5" @@ -15587,7 +15723,7 @@ __metadata: languageName: node linkType: hard -"bytes@npm:3.1.2, bytes@npm:~3.1.2": +"bytes@npm:3.1.2, bytes@npm:^3.1.2, bytes@npm:~3.1.2": version: 3.1.2 resolution: "bytes@npm:3.1.2" checksum: e4bcd3948d289c5127591fbedf10c0b639ccbf00243504e4e127374a15c3bc8eed0d28d4aaab08ff6f1cf2abc0cce6ba3085ed32f4f90e82a5683ce0014e1b6e @@ -16637,6 +16773,13 @@ __metadata: languageName: node linkType: hard +"content-disposition@npm:^1.0.0": + version: 1.1.0 + resolution: "content-disposition@npm:1.1.0" + checksum: 894a516ef9fd1375c5434cec9d4134c78b7bcdf9aa9b4d1980d4f57faae234d52e2efc363ccd2aee2a0a84b4b5f642582ed5bd1d39f3575e539d51677c3d23a2 + languageName: node + linkType: hard + "content-disposition@npm:~0.5.4": version: 0.5.4 resolution: "content-disposition@npm:0.5.4" @@ -16646,13 +16789,20 @@ __metadata: languageName: node linkType: hard -"content-type@npm:~1.0.4, content-type@npm:~1.0.5": +"content-type@npm:^1.0.5, content-type@npm:~1.0.4, content-type@npm:~1.0.5": version: 1.0.5 resolution: "content-type@npm:1.0.5" checksum: 566271e0a251642254cde0f845f9dd4f9856e52d988f4eb0d0dcffbb7a1f8ec98de7a5215fc628f3bce30fe2fb6fd2bc064b562d721658c59b544e2d34ea2766 languageName: node linkType: hard +"content-type@npm:^2.0.0": + version: 2.0.0 + resolution: "content-type@npm:2.0.0" + checksum: b2ea6c6e8bc11e51f7e5928c75752b57e5186f40e83b129a1beadf343343aa3f01e920d3b7663b7cb50faae2adba20d8d456e2d919a30a35b6b7e6937b830b54 + languageName: node + linkType: hard + "convert-source-map@npm:^1.3.0, convert-source-map@npm:^1.4.0, convert-source-map@npm:^1.5.0, convert-source-map@npm:^1.6.0, convert-source-map@npm:^1.7.0": version: 1.9.0 resolution: "convert-source-map@npm:1.9.0" @@ -16674,6 +16824,13 @@ __metadata: languageName: node linkType: hard +"cookie-signature@npm:^1.2.1": + version: 1.2.2 + resolution: "cookie-signature@npm:1.2.2" + checksum: 1ad4f9b3907c9f3673a0f0a07c0a23da7909ac6c9204c5d80a0ec102fe50ccc45f27fdf496361840d6c132c5bb0037122c0a381f856d070183d1ebe3e5e041ff + languageName: node + linkType: hard + "cookie-signature@npm:~1.0.6": version: 1.0.7 resolution: "cookie-signature@npm:1.0.7" @@ -16741,6 +16898,16 @@ __metadata: languageName: node linkType: hard +"cors@npm:^2.8.5": + version: 2.8.6 + resolution: "cors@npm:2.8.6" + dependencies: + object-assign: ^4 + vary: ^1 + checksum: a967922b00fd17d836d21308c66ab9081d6c0f7dc019486ba1643a58281b12fc27d8c260471ddca72874b5bfe17a2d471ff8762d34f6009022ff749ec1136220 + languageName: node + linkType: hard + "cosmiconfig@npm:^6.0.0": version: 6.0.0 resolution: "cosmiconfig@npm:6.0.0" @@ -16877,7 +17044,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6": +"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.5, cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -17581,15 +17748,15 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.0, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.4.0": - version: 4.4.0 - resolution: "debug@npm:4.4.0" +"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.0, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.4.0, debug@npm:^4.4.3": + version: 4.4.3 + resolution: "debug@npm:4.4.3" dependencies: ms: ^2.1.3 peerDependenciesMeta: supports-color: optional: true - checksum: fb42df878dd0e22816fc56e1fdca9da73caa85212fbe40c868b1295a6878f9101ae684f4eeef516c13acfc700f5ea07f1136954f43d4cd2d477a811144136479 + checksum: 4805abd570e601acdca85b6aa3757186084a45cff9b2fa6eee1f3b173caa776b45f478b2a71a572d616d2010cea9211d0ac4a02a610e4c18ac4324bde3760834 languageName: node linkType: hard @@ -17877,7 +18044,7 @@ __metadata: languageName: node linkType: hard -"depd@npm:2.0.0, depd@npm:~2.0.0": +"depd@npm:2.0.0, depd@npm:^2.0.0, depd@npm:~2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" checksum: abbe19c768c97ee2eed6282d8ce3031126662252c58d711f646921c9623f9052e3e1906443066beec1095832f534e57c523b7333f8e7e0d93051ab6baef5ab3a @@ -18556,7 +18723,7 @@ __metadata: languageName: node linkType: hard -"encodeurl@npm:~2.0.0": +"encodeurl@npm:^2.0.0, encodeurl@npm:~2.0.0": version: 2.0.0 resolution: "encodeurl@npm:2.0.0" checksum: abf5cd51b78082cf8af7be6785813c33b6df2068ce5191a40ca8b1afe6a86f9230af9a9ce694a5ce4665955e5c1120871826df9c128a642e09c58d592e2807fe @@ -19559,7 +19726,7 @@ __metadata: languageName: node linkType: hard -"etag@npm:~1.8.1": +"etag@npm:^1.8.1, etag@npm:~1.8.1": version: 1.8.1 resolution: "etag@npm:1.8.1" checksum: 571aeb3dbe0f2bbd4e4fadbdb44f325fc75335cd5f6f6b6a091e6a06a9f25ed5392f0863c5442acb0646787446e816f13cbfc6edce5b07658541dff573cab1ff @@ -19608,6 +19775,22 @@ __metadata: languageName: node linkType: hard +"eventsource-parser@npm:^3.0.0, eventsource-parser@npm:^3.0.1": + version: 3.1.0 + resolution: "eventsource-parser@npm:3.1.0" + checksum: 38f6b2d3cd9916fb7902c6489fe981866f5be265c6ce4b890d86c5b549c4fc45fbb89a80a5e83016940fe68c797cd4994d286719bf4d81360cb628b6455618f6 + languageName: node + linkType: hard + +"eventsource@npm:^3.0.2": + version: 3.0.7 + resolution: "eventsource@npm:3.0.7" + dependencies: + eventsource-parser: ^3.0.1 + checksum: cd8cbc3418238b9d751b6652edf442d4b869829fbc3b73444abca1816fe3d23dc707130dd9a990360bc27c281d986f2f62059d870921173425c3ac28d20a8414 + languageName: node + linkType: hard + "evp_bytestokey@npm:^1.0.0, evp_bytestokey@npm:^1.0.3": version: 1.0.3 resolution: "evp_bytestokey@npm:1.0.3" @@ -19745,6 +19928,17 @@ __metadata: languageName: node linkType: hard +"express-rate-limit@npm:^8.2.1": + version: 8.5.2 + resolution: "express-rate-limit@npm:8.5.2" + dependencies: + ip-address: ^10.2.0 + peerDependencies: + express: ">= 4.11" + checksum: 2a8d508aca4489597b41fb63accd6aac8f10506edb2567180c8375c4170d48651f7bf417079c45421bdefba3c0422a7d06615ba99cd88ca67cbbdf977e71f405 + languageName: node + linkType: hard + "express-validator@npm:^6.14.2": version: 6.15.0 resolution: "express-validator@npm:6.15.0" @@ -19794,6 +19988,42 @@ __metadata: languageName: node linkType: hard +"express@npm:^5.2.1": + version: 5.2.1 + resolution: "express@npm:5.2.1" + dependencies: + accepts: ^2.0.0 + body-parser: ^2.2.1 + content-disposition: ^1.0.0 + content-type: ^1.0.5 + cookie: ^0.7.1 + cookie-signature: ^1.2.1 + debug: ^4.4.0 + depd: ^2.0.0 + encodeurl: ^2.0.0 + escape-html: ^1.0.3 + etag: ^1.8.1 + finalhandler: ^2.1.0 + fresh: ^2.0.0 + http-errors: ^2.0.0 + merge-descriptors: ^2.0.0 + mime-types: ^3.0.0 + on-finished: ^2.4.1 + once: ^1.4.0 + parseurl: ^1.3.3 + proxy-addr: ^2.0.7 + qs: ^6.14.0 + range-parser: ^1.2.1 + router: ^2.2.0 + send: ^1.1.0 + serve-static: ^2.2.0 + statuses: ^2.0.1 + type-is: ^2.0.1 + vary: ^1.1.2 + checksum: e0bc9c11fcf4e6ed29c9b0551229e8cf35d959970eb5e10ef3e48763eb3a63487251950d9bf4ef38b93085f0f33bb1fc37ab07349b8fa98a0fa5f67236d4c054 + languageName: node + linkType: hard + "extend@npm:^3.0.0, extend@npm:^3.0.2, extend@npm:~3.0.2": version: 3.0.2 resolution: "extend@npm:3.0.2" @@ -20124,6 +20354,20 @@ __metadata: languageName: node linkType: hard +"finalhandler@npm:^2.1.0": + version: 2.1.1 + resolution: "finalhandler@npm:2.1.1" + dependencies: + debug: ^4.4.0 + encodeurl: ^2.0.0 + escape-html: ^1.0.3 + on-finished: ^2.4.1 + parseurl: ^1.3.3 + statuses: ^2.0.1 + checksum: e5303c4cccce46019cf0f59b07a36cc6d37549f1efe2111c16cd78e6e500d3bfd68d3b45044c9a67a0c75ad3128ee1106fae9a0152ca3c0a8ee3bf3a4a1464bb + languageName: node + linkType: hard + "finalhandler@npm:~1.3.1": version: 1.3.2 resolution: "finalhandler@npm:1.3.2" @@ -20510,6 +20754,13 @@ __metadata: languageName: node linkType: hard +"fresh@npm:^2.0.0": + version: 2.0.0 + resolution: "fresh@npm:2.0.0" + checksum: 38b9828352c6271e2a0dd8bdd985d0100dbbc4eb8b6a03286071dd6f7d96cfaacd06d7735701ad9a95870eb3f4555e67c08db1dcfe24c2e7bb87383c72fae1d2 + languageName: node + linkType: hard + "fresh@npm:~0.5.2": version: 0.5.2 resolution: "fresh@npm:0.5.2" @@ -21578,6 +21829,13 @@ __metadata: languageName: node linkType: hard +"hono@npm:^4.11.4": + version: 4.12.29 + resolution: "hono@npm:4.12.29" + checksum: 9699928d3d94f1bcf0d8e99a1464fa5f827a4a1f1f914305e194d041feca8c7fc8c497dd44489af9b6a5cfacd2ebe435e46afaf310b80a2075c6bb2a31954b30 + languageName: node + linkType: hard + "hoopy@npm:^0.1.4": version: 0.1.4 resolution: "hoopy@npm:0.1.4" @@ -21765,19 +22023,7 @@ __metadata: languageName: node linkType: hard -"http-errors@npm:~1.6.2": - version: 1.6.3 - resolution: "http-errors@npm:1.6.3" - dependencies: - depd: ~1.1.2 - inherits: 2.0.3 - setprototypeof: 1.1.0 - statuses: ">= 1.4.0 < 2" - checksum: a9654ee027e3d5de305a56db1d1461f25709ac23267c6dc28cdab8323e3f96caa58a9a6a5e93ac15d7285cee0c2f019378c3ada9026e7fe19c872d695f27de7c - languageName: node - linkType: hard - -"http-errors@npm:~2.0.0, http-errors@npm:~2.0.1": +"http-errors@npm:^2.0.0, http-errors@npm:^2.0.1, http-errors@npm:~2.0.0, http-errors@npm:~2.0.1": version: 2.0.1 resolution: "http-errors@npm:2.0.1" dependencies: @@ -21790,6 +22036,18 @@ __metadata: languageName: node linkType: hard +"http-errors@npm:~1.6.2": + version: 1.6.3 + resolution: "http-errors@npm:1.6.3" + dependencies: + depd: ~1.1.2 + inherits: 2.0.3 + setprototypeof: 1.1.0 + statuses: ">= 1.4.0 < 2" + checksum: a9654ee027e3d5de305a56db1d1461f25709ac23267c6dc28cdab8323e3f96caa58a9a6a5e93ac15d7285cee0c2f019378c3ada9026e7fe19c872d695f27de7c + languageName: node + linkType: hard + "http-parser-js@npm:>=0.5.1": version: 0.5.2 resolution: "http-parser-js@npm:0.5.2" @@ -21963,6 +22221,15 @@ __metadata: languageName: node linkType: hard +"iconv-lite@npm:^0.7.2, iconv-lite@npm:~0.7.0": + version: 0.7.3 + resolution: "iconv-lite@npm:0.7.3" + dependencies: + safer-buffer: ">= 2.1.2 < 3.0.0" + checksum: 36b3dc2f5a25c2cac15f7df42a1f23e796f1616b2b4103b205a5c237d9b40483184a8c07bbc2d72b01ca28f4770506fad5a43203adae9c97a5d4733af684b6a8 + languageName: node + linkType: hard + "icss-replace-symbols@npm:^1.1.0": version: 1.1.0 resolution: "icss-replace-symbols@npm:1.1.0" @@ -22309,6 +22576,13 @@ __metadata: languageName: node linkType: hard +"ip-address@npm:^10.2.0": + version: 10.2.0 + resolution: "ip-address@npm:10.2.0" + checksum: 3ffba04dc4cdaf81ed2ed6edc47eee1494bb97550ef73f1918ca28405d175c03efa416b8337e868123b08c2cc677e3a07c5ce03eda3b1aeb2741c149bd37ddf9 + languageName: node + linkType: hard + "ip-address@npm:^9.0.5": version: 9.0.5 resolution: "ip-address@npm:9.0.5" @@ -22765,6 +23039,13 @@ __metadata: languageName: node linkType: hard +"is-promise@npm:^4.0.0": + version: 4.0.0 + resolution: "is-promise@npm:4.0.0" + checksum: 0b46517ad47b00b6358fd6553c83ec1f6ba9acd7ffb3d30a0bf519c5c69e7147c132430452351b8a9fc198f8dd6c4f76f8e6f5a7f100f8c77d57d9e0f4261a8a + languageName: node + linkType: hard + "is-reference@npm:1.2.1, is-reference@npm:^1.2.1": version: 1.2.1 resolution: "is-reference@npm:1.2.1" @@ -23960,6 +24241,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^6.1.3": + version: 6.2.3 + resolution: "jose@npm:6.2.3" + checksum: 37d4bbeab2b4e25029c7d97fcc3ff4b57c07a8b2e60fff5ee0d92b07be3157f894d9cefc4412d2bdd6542d4a318f55a615952f2673846ab662d713d00f2ae2fb + languageName: node + linkType: hard + "js-cookie@npm:3.0.7": version: 3.0.7 resolution: "js-cookie@npm:3.0.7" @@ -24158,6 +24446,13 @@ __metadata: languageName: node linkType: hard +"json-schema-typed@npm:^8.0.2": + version: 8.0.2 + resolution: "json-schema-typed@npm:8.0.2" + checksum: 8ddb3c2b1bad406507ea077d4d8cfe6dae5b920f642efab145e4bd6e0d3984f01034ee4467bbeabd51e129b17d7446ceb357a6b1648857fdfaf61ba8ef621ff6 + languageName: node + linkType: hard + "json-schema@npm:0.4.0": version: 0.4.0 resolution: "json-schema@npm:0.4.0" @@ -25388,6 +25683,13 @@ __metadata: languageName: node linkType: hard +"media-typer@npm:^1.1.0": + version: 1.1.0 + resolution: "media-typer@npm:1.1.0" + checksum: a58dd60804df73c672942a7253ccc06815612326dc1c0827984b1a21704466d7cde351394f47649e56cf7415e6ee2e26e000e81b51b3eebb5a93540e8bf93cbd + languageName: node + linkType: hard + "memfs@npm:^3.1.2": version: 3.5.3 resolution: "memfs@npm:3.5.3" @@ -25456,6 +25758,13 @@ __metadata: languageName: node linkType: hard +"merge-descriptors@npm:^2.0.0": + version: 2.0.0 + resolution: "merge-descriptors@npm:2.0.0" + checksum: e383332e700a94682d0125a36c8be761142a1320fc9feeb18e6e36647c9edf064271645f5669b2c21cf352116e561914fd8aa831b651f34db15ef4038c86696a + languageName: node + linkType: hard + "merge-stream@npm:^2.0.0": version: 2.0.0 resolution: "merge-stream@npm:2.0.0" @@ -25852,13 +26161,20 @@ __metadata: languageName: node linkType: hard -"mime-db@npm:1.52.0, mime-db@npm:>= 1.43.0 < 2": +"mime-db@npm:1.52.0": version: 1.52.0 resolution: "mime-db@npm:1.52.0" checksum: 0d99a03585f8b39d68182803b12ac601d9c01abfa28ec56204fa330bc9f3d1c5e14beb049bafadb3dbdf646dfb94b87e24d4ec7b31b7279ef906a8ea9b6a513f languageName: node linkType: hard +"mime-db@npm:>= 1.43.0 < 2, mime-db@npm:^1.54.0": + version: 1.54.0 + resolution: "mime-db@npm:1.54.0" + checksum: e99aaf2f23f5bd607deb08c83faba5dd25cf2fec90a7cc5b92d8260867ee08dab65312e1a589e60093dc7796d41e5fae013268418482f1db4c7d52d0a0960ac9 + languageName: node + linkType: hard + "mime-match@npm:^1.0.2": version: 1.0.2 resolution: "mime-match@npm:1.0.2" @@ -25877,6 +26193,15 @@ __metadata: languageName: node linkType: hard +"mime-types@npm:^3.0.0, mime-types@npm:^3.0.2": + version: 3.0.2 + resolution: "mime-types@npm:3.0.2" + dependencies: + mime-db: ^1.54.0 + checksum: 70b74794f408419e4b6a8e3c93ccbed79b6a6053973a3957c5cc04ff4ad8d259f0267da179e3ecae34c3edfb4bfd7528db23a101e32d21ad8e196178c8b7b75a + languageName: node + linkType: hard + "mime@npm:1.6.0": version: 1.6.0 resolution: "mime@npm:1.6.0" @@ -26547,6 +26872,13 @@ __metadata: languageName: node linkType: hard +"negotiator@npm:^1.0.0": + version: 1.0.0 + resolution: "negotiator@npm:1.0.0" + checksum: 20ebfe79b2d2e7cf9cbc8239a72662b584f71164096e6e8896c8325055497c96f6b80cd22c258e8a2f2aa382a787795ec3ee8b37b422a302c7d4381b0d5ecfbb + languageName: node + linkType: hard + "neo-async@npm:^2.5.0, neo-async@npm:^2.6.2": version: 2.6.2 resolution: "neo-async@npm:2.6.2" @@ -26894,7 +27226,7 @@ __metadata: languageName: node linkType: hard -"object-assign@npm:^4.0.1, object-assign@npm:^4.1.0, object-assign@npm:^4.1.1": +"object-assign@npm:^4, object-assign@npm:^4.0.1, object-assign@npm:^4.1.0, object-assign@npm:^4.1.1": version: 4.1.1 resolution: "object-assign@npm:4.1.1" checksum: fcc6e4ea8c7fe48abfbb552578b1c53e0d194086e2e6bbbf59e0a536381a292f39943c6e9628af05b5528aa5e3318bb30d6b2e53cadaf5b8fe9e12c4b69af23f @@ -27552,7 +27884,7 @@ __metadata: languageName: node linkType: hard -"parseurl@npm:~1.3.2, parseurl@npm:~1.3.3": +"parseurl@npm:^1.3.3, parseurl@npm:~1.3.2, parseurl@npm:~1.3.3": version: 1.3.3 resolution: "parseurl@npm:1.3.3" checksum: 407cee8e0a3a4c5cd472559bca8b6a45b82c124e9a4703302326e9ab60fc1081442ada4e02628efef1eb16197ddc7f8822f5a91fd7d7c86b51f530aedb17dfa2 @@ -27694,6 +28026,13 @@ __metadata: languageName: node linkType: hard +"path-to-regexp@npm:^8.0.0": + version: 8.4.2 + resolution: "path-to-regexp@npm:8.4.2" + checksum: c30fba443e413cc736b7b28056a4b60b537ae1caa80152da21e8093bd41deba7c408a4ac6f11a1bf594e089d8fd8d87ed31476c55c50983719fb355826370ade + languageName: node + linkType: hard + "path-to-regexp@npm:~0.1.12": version: 0.1.13 resolution: "path-to-regexp@npm:0.1.13" @@ -27902,6 +28241,13 @@ __metadata: languageName: node linkType: hard +"pkce-challenge@npm:^5.0.0": + version: 5.0.1 + resolution: "pkce-challenge@npm:5.0.1" + checksum: 6079ee7520592f827cb78c397a1d2ff0a90a1952756b2efb893bf884524b7d2930dea82d92fec21bb3af99aba558d13553d9da87d5bd9f844b6c4cb3bc8f1ce0 + languageName: node + linkType: hard + "pkg-dir@npm:^3.0.0": version: 3.0.0 resolution: "pkg-dir@npm:3.0.0" @@ -29310,7 +29656,7 @@ __metadata: languageName: node linkType: hard -"proxy-addr@npm:~2.0.7": +"proxy-addr@npm:^2.0.7, proxy-addr@npm:~2.0.7": version: 2.0.7 resolution: "proxy-addr@npm:2.0.7" dependencies: @@ -29446,12 +29792,13 @@ __metadata: languageName: node linkType: hard -"qs@npm:^6.11.0, qs@npm:~6.15.1": - version: 6.15.2 - resolution: "qs@npm:6.15.2" +"qs@npm:^6.11.0, qs@npm:^6.14.0, qs@npm:^6.15.2, qs@npm:~6.15.1": + version: 6.15.3 + resolution: "qs@npm:6.15.3" dependencies: - side-channel: ^1.1.0 - checksum: 135ae673e6367d258208baf9f49c169eff045f0671f5aae02a289eee54909c1c054029d1e3d4dd600c6a33847e40736b6293db3794ecef74896e583457198eae + es-define-property: ^1.0.1 + side-channel: ^1.1.1 + checksum: b59a0f20498f323d1f990f1fad3b808ee328f96c3c45575beeb0d51ec27dc0ed3422d9e9308a7c09f6000cb914b95dfb6eb7cca8636bc4d1befdc2a10cf59d2b languageName: node linkType: hard @@ -29544,6 +29891,18 @@ __metadata: languageName: node linkType: hard +"raw-body@npm:^3.0.0, raw-body@npm:^3.0.2": + version: 3.0.2 + resolution: "raw-body@npm:3.0.2" + dependencies: + bytes: ~3.1.2 + http-errors: ~2.0.1 + iconv-lite: ~0.7.0 + unpipe: ~1.0.0 + checksum: bf8ce8e9734f273f24d81f9fed35609dbd25c2869faa5fb5075f7ee225c0913e2240adda03759d7e72f2a757f8012d58bb7a871a80261d5140ad65844caeb5bd + languageName: node + linkType: hard + "raw-body@npm:~2.5.3": version: 2.5.3 resolution: "raw-body@npm:2.5.3" @@ -31802,6 +32161,19 @@ __metadata: languageName: node linkType: hard +"router@npm:^2.2.0": + version: 2.2.0 + resolution: "router@npm:2.2.0" + dependencies: + debug: ^4.4.0 + depd: ^2.0.0 + is-promise: ^4.0.0 + parseurl: ^1.3.3 + path-to-regexp: ^8.0.0 + checksum: 4c3bec8011ed10bb07d1ee860bc715f245fff0fdff991d8319741d2932d89c3fe0a56766b4fa78e95444bc323fd2538e09c8e43bfbd442c2a7fab67456df7fa5 + languageName: node + linkType: hard + "rrdom@npm:^2.0.0-alpha.13": version: 2.0.0-alpha.17 resolution: "rrdom@npm:2.0.0-alpha.17" @@ -32148,6 +32520,25 @@ __metadata: languageName: node linkType: hard +"send@npm:^1.1.0, send@npm:^1.2.0": + version: 1.2.1 + resolution: "send@npm:1.2.1" + dependencies: + debug: ^4.4.3 + encodeurl: ^2.0.0 + escape-html: ^1.0.3 + etag: ^1.8.1 + fresh: ^2.0.0 + http-errors: ^2.0.1 + mime-types: ^3.0.2 + ms: ^2.1.3 + on-finished: ^2.4.1 + range-parser: ^1.2.1 + statuses: ^2.0.2 + checksum: 5361e3556fbc874c080a4cfbb4541e02c16221ca3c68c4f692320d38ef7e147381f805ce3ac50dfaa2129f07daa81098e2bc567e9a4d13993a92893d59a64d68 + languageName: node + linkType: hard + "send@npm:~0.19.0, send@npm:~0.19.1": version: 0.19.2 resolution: "send@npm:0.19.2" @@ -32204,6 +32595,18 @@ __metadata: languageName: node linkType: hard +"serve-static@npm:^2.2.0": + version: 2.2.1 + resolution: "serve-static@npm:2.2.1" + dependencies: + encodeurl: ^2.0.0 + escape-html: ^1.0.3 + parseurl: ^1.3.3 + send: ^1.2.0 + checksum: dd71e9a316a7d7f726503973c531168cfa6a6a56a98d5c6b279c4d0d41a83a1bc6900495dc0633712b95d88ccbf9ed4f4a780a4c4c00bf84b496e9e710d68825 + languageName: node + linkType: hard + "serve-static@npm:~1.16.2": version: 1.16.3 resolution: "serve-static@npm:1.16.3" @@ -32414,7 +32817,7 @@ __metadata: languageName: node linkType: hard -"side-channel@npm:^1.0.4, side-channel@npm:^1.1.0": +"side-channel@npm:^1.0.4, side-channel@npm:^1.1.1": version: 1.1.1 resolution: "side-channel@npm:1.1.1" dependencies: @@ -32949,7 +33352,7 @@ __metadata: languageName: node linkType: hard -"statuses@npm:^2.0.0, statuses@npm:~2.0.1, statuses@npm:~2.0.2": +"statuses@npm:^2.0.0, statuses@npm:^2.0.1, statuses@npm:^2.0.2, statuses@npm:~2.0.1, statuses@npm:~2.0.2": version: 2.0.2 resolution: "statuses@npm:2.0.2" checksum: 6927feb50c2a75b2a4caab2c565491f7a93ad3d8dbad7b1398d52359e9243a20e2ebe35e33726dee945125ef7a515e9097d8a1b910ba2bbd818265a2f6c39879 @@ -34549,6 +34952,17 @@ __metadata: languageName: node linkType: hard +"type-is@npm:^2.0.1, type-is@npm:^2.1.0": + version: 2.1.0 + resolution: "type-is@npm:2.1.0" + dependencies: + content-type: ^2.0.0 + media-typer: ^1.1.0 + mime-types: ^3.0.0 + checksum: 214a44fc635fd1396bb656af1f0392d151e44e31f622ca2d030f4b4bb4f20069dab250c6798a6c6f4b215f87cd6f68099cbfdb0975696c3383f7a4cad26f6174 + languageName: node + linkType: hard + "type-is@npm:~1.6.18": version: 1.6.18 resolution: "type-is@npm:1.6.18" @@ -35307,7 +35721,7 @@ __metadata: languageName: node linkType: hard -"vary@npm:~1.1.2": +"vary@npm:^1, vary@npm:^1.1.2, vary@npm:~1.1.2": version: 1.1.2 resolution: "vary@npm:1.1.2" checksum: ae0123222c6df65b437669d63dfa8c36cee20a504101b2fcd97b8bf76f91259c17f9f2b4d70a1e3c6bbcee7f51b28392833adb6b2770b23b01abec84e369660b @@ -36530,12 +36944,12 @@ __metadata: languageName: node linkType: hard -"zod-to-json-schema@npm:^3.23.3": - version: 3.24.2 - resolution: "zod-to-json-schema@npm:3.24.2" +"zod-to-json-schema@npm:^3.23.3, zod-to-json-schema@npm:^3.25.1": + version: 3.25.2 + resolution: "zod-to-json-schema@npm:3.25.2" peerDependencies: - zod: ^3.24.1 - checksum: 1edcf680c4938f99a7b9df5b0d5eb3a3eb2f2cc57d4d0dbe5cc34539c104443a8498bb225a43fd3a848f117205027cd20f4ea8324fdb1e4aede8433fc1595ee9 + zod: ^3.25.28 || ^4 + checksum: 8b728476374a38711e0ca5c066be16ed8facf5a5eb5a76f371b0062e5b6b11f906f446c616d6879b9a4d373738c74cd26e4dfe1d460e7fb9a155a3fe9d04b690 languageName: node linkType: hard @@ -36548,10 +36962,17 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.22.4, zod@npm:^3.23.8": - version: 3.24.2 - resolution: "zod@npm:3.24.2" - checksum: c02455c09678c5055c636d64f9fcda2424fea0aa46ac7d9681e7f41990bc55f488bcd84b9d7cfef0f6e906f51f55b245239d92a9f726248aa74c5b84edf00c2d +"zod@npm:^3.22.4, zod@npm:^3.23.8, zod@npm:^3.25.0": + version: 3.25.76 + resolution: "zod@npm:3.25.76" + checksum: c9a403a62b329188a5f6bd24d5d935d2bba345f7ab8151d1baa1505b5da9f227fb139354b043711490c798e91f3df75991395e40142e6510a4b16409f302b849 + languageName: node + linkType: hard + +"zod@npm:^3.25 || ^4.0": + version: 4.4.3 + resolution: "zod@npm:4.4.3" + checksum: bf236fdee7a5a5ec645eef5bfea3aad34e7df912931c2a23bc17e5b59882482751da42392916529da52ff9bc70f584797a5d496f1fb81f2d1a4c90fdd3922d2a languageName: node linkType: hard diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/converters/McpTokenAuthenticationConverter.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/converters/McpTokenAuthenticationConverter.java new file mode 100644 index 000000000000..bc9b1402b929 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/converters/McpTokenAuthenticationConverter.java @@ -0,0 +1,27 @@ +package com.appsmith.server.authentication.converters; + +import com.appsmith.server.authentication.tokens.McpTokenAuthentication; +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.server.authentication.ServerAuthenticationConverter; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +@Component +public class McpTokenAuthenticationConverter implements ServerAuthenticationConverter { + + private static final String BEARER_PREFIX = "Bearer "; + private static final String MCP_TOKEN_PREFIX = "mcp_"; + + @Override + public Mono convert(ServerWebExchange exchange) { + String authorization = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION); + if (authorization == null || !authorization.regionMatches(true, 0, BEARER_PREFIX, 0, BEARER_PREFIX.length())) { + return Mono.empty(); + } + + String token = authorization.substring(BEARER_PREFIX.length()).trim(); + return token.startsWith(MCP_TOKEN_PREFIX) ? Mono.just(new McpTokenAuthentication(token)) : Mono.empty(); + } +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManager.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManager.java new file mode 100644 index 000000000000..9c515154048b --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManager.java @@ -0,0 +1,34 @@ +package com.appsmith.server.authentication.managers; + +import com.appsmith.server.authentication.tokens.McpTokenAuthentication; +import com.appsmith.server.services.UserMcpTokenService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class McpTokenAuthenticationManager implements ReactiveAuthenticationManager { + + private final UserMcpTokenService userMcpTokenService; + + @Override + public Mono authenticate(Authentication authentication) { + if (!(authentication instanceof McpTokenAuthentication)) { + return Mono.empty(); + } + + return userMcpTokenService + .authenticate((String) authentication.getCredentials()) + .map(user -> (Authentication) UsernamePasswordAuthenticationToken.authenticated( + user, null, Optional.ofNullable(user.getAuthorities()).orElseGet(List::of))) + .switchIfEmpty(Mono.error(new BadCredentialsException("Invalid MCP token"))); + } +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/tokens/McpTokenAuthentication.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/tokens/McpTokenAuthentication.java new file mode 100644 index 000000000000..fb03895c01aa --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/tokens/McpTokenAuthentication.java @@ -0,0 +1,24 @@ +package com.appsmith.server.authentication.tokens; + +import org.springframework.security.authentication.AbstractAuthenticationToken; + +public class McpTokenAuthentication extends AbstractAuthenticationToken { + + private final String token; + + public McpTokenAuthentication(String token) { + super(null); + this.token = token; + setAuthenticated(false); + } + + @Override + public Object getCredentials() { + return token; + } + + @Override + public Object getPrincipal() { + return null; + } +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java index 7dcc85cbcdfb..effbeab6e03a 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java @@ -1,10 +1,12 @@ package com.appsmith.server.configurations; import com.appsmith.external.exceptions.ErrorDTO; +import com.appsmith.server.authentication.converters.McpTokenAuthenticationConverter; import com.appsmith.server.authentication.handlers.AccessDeniedHandler; import com.appsmith.server.authentication.handlers.AuthenticationFailureHandler; import com.appsmith.server.authentication.handlers.CustomServerOAuth2AuthorizationRequestResolver; import com.appsmith.server.authentication.handlers.LogoutSuccessHandler; +import com.appsmith.server.authentication.managers.McpTokenAuthenticationManager; import com.appsmith.server.authentication.oauth2clientrepositories.CustomOauth2ClientRepositoryManager; import com.appsmith.server.constants.FieldName; import com.appsmith.server.constants.Url; @@ -44,6 +46,7 @@ import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.ServerAuthenticationEntryPoint; +import org.springframework.security.web.server.authentication.AuthenticationWebFilter; import org.springframework.security.web.server.authentication.ServerAuthenticationEntryPointFailureHandler; import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler; import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher; @@ -184,13 +187,21 @@ public SecurityWebFilterChain internalWebFilterChain(ServerHttpSecurity http) { @Bean @SuppressWarnings("Convert2MethodRef") // Helps readability. - public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { + public SecurityWebFilterChain securityWebFilterChain( + ServerHttpSecurity http, + McpTokenAuthenticationManager mcpTokenAuthenticationManager, + McpTokenAuthenticationConverter mcpTokenAuthenticationConverter) { ServerAuthenticationEntryPointFailureHandler failureHandler = new ServerAuthenticationEntryPointFailureHandler(authenticationEntryPoint); + AuthenticationWebFilter mcpTokenAuthenticationWebFilter = + new AuthenticationWebFilter(mcpTokenAuthenticationManager); + mcpTokenAuthenticationWebFilter.setServerAuthenticationConverter(mcpTokenAuthenticationConverter); + mcpTokenAuthenticationWebFilter.setAuthenticationFailureHandler(failureHandler); csrfConfig.applyTo(http); return http.addFilterAt(this::sanityCheckFilter, SecurityWebFiltersOrder.FIRST) + .addFilterAt(mcpTokenAuthenticationWebFilter, SecurityWebFiltersOrder.AUTHENTICATION) // Default security headers configuration from // https://docs.spring.io/spring-security/site/docs/5.0.x/reference/html/headers.html .headers(headerSpec -> headerSpec diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/McpTokenController.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/McpTokenController.java new file mode 100644 index 000000000000..de4cea9c2701 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/McpTokenController.java @@ -0,0 +1,16 @@ +package com.appsmith.server.controllers; + +import com.appsmith.server.constants.Url; +import com.appsmith.server.controllers.ce.McpTokenControllerCE; +import com.appsmith.server.services.UserMcpTokenService; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(Url.USER_URL + "/mcp-tokens") +public class McpTokenController extends McpTokenControllerCE { + + public McpTokenController(UserMcpTokenService userMcpTokenService) { + super(userMcpTokenService); + } +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/McpTokenControllerCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/McpTokenControllerCE.java new file mode 100644 index 000000000000..4ab1f6fd2ed6 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/McpTokenControllerCE.java @@ -0,0 +1,38 @@ +package com.appsmith.server.controllers.ce; + +import com.appsmith.server.domains.User; +import com.appsmith.server.dtos.McpTokenResponseDTO; +import com.appsmith.server.dtos.ResponseDTO; +import com.appsmith.server.services.UserMcpTokenService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@RequiredArgsConstructor +public class McpTokenControllerCE { + + private final UserMcpTokenService userMcpTokenService; + + @PostMapping + public Mono> create(@AuthenticationPrincipal User user) { + return userMcpTokenService.create(user).map(token -> new ResponseDTO<>(HttpStatus.CREATED, token)); + } + + @GetMapping + public Flux> list(@AuthenticationPrincipal User user) { + return userMcpTokenService.list(user).map(token -> new ResponseDTO<>(HttpStatus.OK, token)); + } + + @DeleteMapping("/{tokenId}") + public Mono> revoke(@AuthenticationPrincipal User user, @PathVariable String tokenId) { + return userMcpTokenService + .revoke(user, tokenId) + .map(revoked -> new ResponseDTO<>(revoked ? HttpStatus.OK : HttpStatus.NOT_FOUND, revoked)); + } +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserMcpToken.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserMcpToken.java new file mode 100644 index 000000000000..e41b0547a205 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserMcpToken.java @@ -0,0 +1,11 @@ +package com.appsmith.server.domains; + +import com.appsmith.server.domains.ce.UserMcpTokenCE; +import org.springframework.data.mongodb.core.mapping.Document; + +/** + * Community token persistence model. Enterprise overlays must extend the matching CE MCP token seams rather than + * replacing this collection or authentication contract. + */ +@Document +public class UserMcpToken extends UserMcpTokenCE {} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/ce/UserMcpTokenCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/ce/UserMcpTokenCE.java new file mode 100644 index 000000000000..ec73ec291a96 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/ce/UserMcpTokenCE.java @@ -0,0 +1,23 @@ +package com.appsmith.server.domains.ce; + +import com.appsmith.external.models.BaseDomain; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.FieldNameConstants; +import org.springframework.data.mongodb.core.index.Indexed; + +@Getter +@Setter +@ToString +@FieldNameConstants +public class UserMcpTokenCE extends BaseDomain { + + @Indexed(unique = true) + private String tokenId; + + private String userId; + + @ToString.Exclude + private String tokenHash; +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/McpTokenResponseDTO.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/McpTokenResponseDTO.java new file mode 100644 index 000000000000..43cd1d025c32 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/McpTokenResponseDTO.java @@ -0,0 +1,8 @@ +package com.appsmith.server.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.time.Instant; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record McpTokenResponseDTO(String id, String token, Instant createdAt) {} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/db/ce/Migration076AddUserMcpTokenIndexes.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/db/ce/Migration076AddUserMcpTokenIndexes.java new file mode 100644 index 000000000000..7d34e41b9f3a --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/db/ce/Migration076AddUserMcpTokenIndexes.java @@ -0,0 +1,40 @@ +package com.appsmith.server.migrations.db.ce; + +import com.appsmith.server.domains.UserMcpToken; +import com.appsmith.server.domains.ce.UserMcpTokenCE; +import io.mongock.api.annotations.ChangeUnit; +import io.mongock.api.annotations.Execution; +import io.mongock.api.annotations.RollbackExecution; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.index.Index; + +import static com.appsmith.server.migrations.DatabaseChangelog1.ensureIndexes; +import static com.appsmith.server.migrations.DatabaseChangelog1.makeIndex; + +@ChangeUnit(order = "076", id = "add-user-mcp-token-indexes", author = "") +public class Migration076AddUserMcpTokenIndexes { + + private final MongoTemplate mongoTemplate; + + public Migration076AddUserMcpTokenIndexes(MongoTemplate mongoTemplate) { + this.mongoTemplate = mongoTemplate; + } + + @RollbackExecution + public void rollbackExecution() { + // Indexes are retained to avoid degrading token authentication or token-management queries. + } + + @Execution + public void addUserMcpTokenIndexes() { + Index tokenIdIndex = makeIndex(UserMcpTokenCE.Fields.tokenId) + .named("user_mcp_token_token_id") + .unique() + .background(); + Index activeTokensByUserIndex = makeIndex(UserMcpTokenCE.Fields.userId, "deletedAt") + .named("user_mcp_token_user_deleted_at") + .background(); + + ensureIndexes(mongoTemplate, UserMcpToken.class, tokenIdIndex, activeTokensByUserIndex); + } +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/UserMcpTokenRepository.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/UserMcpTokenRepository.java new file mode 100644 index 000000000000..4b2634bf1c1f --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/UserMcpTokenRepository.java @@ -0,0 +1,7 @@ +package com.appsmith.server.repositories; + +import com.appsmith.server.repositories.ce.UserMcpTokenRepositoryCE; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserMcpTokenRepository extends UserMcpTokenRepositoryCE {} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/UserMcpTokenRepositoryCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/UserMcpTokenRepositoryCE.java new file mode 100644 index 000000000000..cfae1cd21133 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/UserMcpTokenRepositoryCE.java @@ -0,0 +1,17 @@ +package com.appsmith.server.repositories.ce; + +import com.appsmith.server.domains.UserMcpToken; +import com.appsmith.server.repositories.BaseRepository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface UserMcpTokenRepositoryCE extends BaseRepository { + + Mono findByTokenIdAndDeletedAtIsNull(String tokenId); + + Mono findByTokenIdAndUserIdAndDeletedAtIsNull(String tokenId, String userId); + + Flux findAllByUserIdAndDeletedAtIsNull(String userId); + + Mono countByUserIdAndDeletedAtIsNull(String userId); +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserMcpTokenService.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserMcpTokenService.java new file mode 100644 index 000000000000..348274c7452a --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserMcpTokenService.java @@ -0,0 +1,18 @@ +package com.appsmith.server.services; + +import com.appsmith.server.domains.User; +import com.appsmith.server.dtos.McpTokenResponseDTO; +import com.appsmith.server.services.ce.UserMcpTokenServiceCE; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface UserMcpTokenService extends UserMcpTokenServiceCE { + + Mono create(User user); + + Flux list(User user); + + Mono revoke(User user, String tokenId); + + Mono authenticate(String token); +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserMcpTokenServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserMcpTokenServiceImpl.java new file mode 100644 index 000000000000..1503f3e25c2f --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserMcpTokenServiceImpl.java @@ -0,0 +1,18 @@ +package com.appsmith.server.services; + +import com.appsmith.server.repositories.UserMcpTokenRepository; +import com.appsmith.server.repositories.UserRepository; +import com.appsmith.server.services.ce.UserMcpTokenServiceCEImpl; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +public class UserMcpTokenServiceImpl extends UserMcpTokenServiceCEImpl implements UserMcpTokenService { + + public UserMcpTokenServiceImpl( + UserMcpTokenRepository userMcpTokenRepository, + UserRepository userRepository, + PasswordEncoder passwordEncoder) { + super(userMcpTokenRepository, userRepository, passwordEncoder); + } +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserMcpTokenServiceCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserMcpTokenServiceCE.java new file mode 100644 index 000000000000..d078a9b194ce --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserMcpTokenServiceCE.java @@ -0,0 +1,17 @@ +package com.appsmith.server.services.ce; + +import com.appsmith.server.domains.User; +import com.appsmith.server.dtos.McpTokenResponseDTO; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface UserMcpTokenServiceCE { + + Mono create(User user); + + Flux list(User user); + + Mono revoke(User user, String tokenId); + + Mono authenticate(String token); +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserMcpTokenServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserMcpTokenServiceCEImpl.java new file mode 100644 index 000000000000..cde79e2fefdc --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserMcpTokenServiceCEImpl.java @@ -0,0 +1,129 @@ +package com.appsmith.server.services.ce; + +import com.appsmith.server.domains.User; +import com.appsmith.server.domains.UserMcpToken; +import com.appsmith.server.dtos.McpTokenResponseDTO; +import com.appsmith.server.exceptions.AppsmithError; +import com.appsmith.server.exceptions.AppsmithException; +import com.appsmith.server.repositories.UserMcpTokenRepository; +import com.appsmith.server.repositories.UserRepository; +import org.springframework.security.crypto.password.PasswordEncoder; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.UUID; + +public class UserMcpTokenServiceCEImpl implements UserMcpTokenServiceCE { + + private static final String TOKEN_HASH_ALGORITHM = "SHA-256"; + private static final int MAX_ACTIVE_TOKENS_PER_USER = 10; + private static final String TOKEN_PREFIX = "mcp_"; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private final UserMcpTokenRepository userMcpTokenRepository; + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public UserMcpTokenServiceCEImpl( + UserMcpTokenRepository userMcpTokenRepository, + UserRepository userRepository, + PasswordEncoder passwordEncoder) { + this.userMcpTokenRepository = userMcpTokenRepository; + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + } + + @Override + public Mono create(User user) { + return userMcpTokenRepository + .countByUserIdAndDeletedAtIsNull(user.getId()) + .flatMap(activeTokenCount -> { + if (activeTokenCount >= MAX_ACTIVE_TOKENS_PER_USER) { + return Mono.error(new AppsmithException( + AppsmithError.INVALID_PARAMETER, + "maximum of " + MAX_ACTIVE_TOKENS_PER_USER + " MCP tokens")); + } + + String tokenId = UUID.randomUUID().toString(); + String token = TOKEN_PREFIX + tokenId + "." + generateSecret(); + UserMcpToken userMcpToken = new UserMcpToken(); + userMcpToken.setTokenId(tokenId); + userMcpToken.setUserId(user.getId()); + userMcpToken.setTokenHash(passwordEncoder.encode(hashToken(token))); + + return userMcpTokenRepository + .save(userMcpToken) + .map(savedToken -> + new McpTokenResponseDTO(savedToken.getTokenId(), token, savedToken.getCreatedAt())); + }); + } + + @Override + public Flux list(User user) { + return userMcpTokenRepository + .findAllByUserIdAndDeletedAtIsNull(user.getId()) + .map(token -> new McpTokenResponseDTO(token.getTokenId(), null, token.getCreatedAt())); + } + + @Override + public Mono revoke(User user, String tokenId) { + return userMcpTokenRepository + .findByTokenIdAndUserIdAndDeletedAtIsNull(tokenId, user.getId()) + .flatMap(token -> userMcpTokenRepository.archiveById(token.getId())) + .defaultIfEmpty(false); + } + + @Override + public Mono authenticate(String token) { + String tokenId = extractTokenId(token); + if (tokenId == null) { + return Mono.empty(); + } + + return userMcpTokenRepository + .findByTokenIdAndDeletedAtIsNull(tokenId) + .filter(storedToken -> passwordEncoder.matches(hashToken(token), storedToken.getTokenHash())) + .flatMap(storedToken -> userRepository.findById(storedToken.getUserId())) + .filter(user -> Boolean.TRUE.equals(user.getIsEnabled())); + } + + private String generateSecret() { + byte[] bytes = new byte[32]; + SECURE_RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String hashToken(String token) { + try { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(MessageDigest.getInstance(TOKEN_HASH_ALGORITHM) + .digest(token.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 must be available", exception); + } + } + + private String extractTokenId(String token) { + if (!token.startsWith(TOKEN_PREFIX)) { + return null; + } + + int separatorIndex = token.indexOf('.', TOKEN_PREFIX.length()); + if (separatorIndex == -1) { + return null; + } + + try { + return UUID.fromString(token.substring(TOKEN_PREFIX.length(), separatorIndex)) + .toString(); + } catch (IllegalArgumentException exception) { + return null; + } + } +} diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/McpTokenAuthenticationWebFilterTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/McpTokenAuthenticationWebFilterTest.java new file mode 100644 index 000000000000..d1f0e5d6d221 --- /dev/null +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/McpTokenAuthenticationWebFilterTest.java @@ -0,0 +1,54 @@ +package com.appsmith.server.authentication; + +import com.appsmith.server.authentication.converters.McpTokenAuthenticationConverter; +import com.appsmith.server.authentication.managers.McpTokenAuthenticationManager; +import com.appsmith.server.services.UserMcpTokenService; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.security.web.server.authentication.AuthenticationWebFilter; +import org.springframework.security.web.server.authentication.HttpStatusServerEntryPoint; +import org.springframework.security.web.server.authentication.ServerAuthenticationEntryPointFailureHandler; +import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Mono; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class McpTokenAuthenticationWebFilterTest { + + @Test + void invalidMcpBearer_returnsUnauthorizedFromAuthenticationFilter() { + UserMcpTokenService service = mock(UserMcpTokenService.class); + when(service.authenticate("mcp_invalid")).thenReturn(Mono.empty()); + + authenticationClient(service) + .get() + .uri("/protected") + .headers(headers -> headers.setBearerAuth("mcp_invalid")) + .exchange() + .expectStatus() + .isUnauthorized(); + } + + @Test + void nonMcpBearer_passesThroughToOtherAuthenticationMechanisms() { + authenticationClient(mock(UserMcpTokenService.class)) + .get() + .uri("/protected") + .headers(headers -> headers.setBearerAuth("ordinary_bearer_token")) + .exchange() + .expectStatus() + .isOk(); + } + + private WebTestClient authenticationClient(UserMcpTokenService service) { + AuthenticationWebFilter filter = new AuthenticationWebFilter(new McpTokenAuthenticationManager(service)); + filter.setServerAuthenticationConverter(new McpTokenAuthenticationConverter()); + filter.setAuthenticationFailureHandler(new ServerAuthenticationEntryPointFailureHandler( + new HttpStatusServerEntryPoint(HttpStatus.UNAUTHORIZED))); + + return WebTestClient.bindToWebHandler(exchange -> filter.filter( + exchange, ignored -> exchange.getResponse().setComplete())) + .build(); + } +} diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/converters/McpTokenAuthenticationConverterTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/converters/McpTokenAuthenticationConverterTest.java new file mode 100644 index 000000000000..26c7beb0cc70 --- /dev/null +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/converters/McpTokenAuthenticationConverterTest.java @@ -0,0 +1,38 @@ +package com.appsmith.server.authentication.converters; + +import com.appsmith.server.authentication.tokens.McpTokenAuthentication; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import reactor.test.StepVerifier; + +import static org.assertj.core.api.Assertions.assertThat; + +class McpTokenAuthenticationConverterTest { + + private final McpTokenAuthenticationConverter converter = new McpTokenAuthenticationConverter(); + + @Test + void convert_identifiesMcpBearerToken() { + MockServerWebExchange exchange = exchangeWithBearer("mcp_token"); + + StepVerifier.create(converter.convert(exchange)) + .assertNext(authentication -> { + assertThat(authentication).isInstanceOf(McpTokenAuthentication.class); + assertThat(authentication.getCredentials()).isEqualTo("mcp_token"); + }) + .verifyComplete(); + } + + @Test + void convert_preservesNonMcpBearerForOtherAuthenticationMechanisms() { + StepVerifier.create(converter.convert(exchangeWithBearer("ordinary_bearer_token"))) + .verifyComplete(); + } + + private MockServerWebExchange exchangeWithBearer(String token) { + return MockServerWebExchange.from( + MockServerHttpRequest.get("/api/v1/users/me").header(HttpHeaders.AUTHORIZATION, "Bearer " + token)); + } +} diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManagerTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManagerTest.java new file mode 100644 index 000000000000..97af60a8dac7 --- /dev/null +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManagerTest.java @@ -0,0 +1,57 @@ +package com.appsmith.server.authentication.managers; + +import com.appsmith.server.authentication.tokens.McpTokenAuthentication; +import com.appsmith.server.domains.User; +import com.appsmith.server.services.UserMcpTokenService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class McpTokenAuthenticationManagerTest { + + @Mock + private UserMcpTokenService userMcpTokenService; + + @Test + void authenticate_usesValidatedTokenOwnerAsPrincipal() { + User user = mock(User.class); + when(user.getAuthorities()).thenReturn(List.of(new SimpleGrantedAuthority("test-authority"))); + McpTokenAuthenticationManager manager = new McpTokenAuthenticationManager(userMcpTokenService); + when(userMcpTokenService.authenticate("mcp_token")).thenReturn(Mono.just(user)); + + StepVerifier.create(manager.authenticate(new McpTokenAuthentication("mcp_token"))) + .assertNext(authentication -> { + assertThat(authentication.isAuthenticated()).isTrue(); + assertThat(authentication).isInstanceOf(UsernamePasswordAuthenticationToken.class); + assertThat(authentication.getPrincipal()).isSameAs(user); + assertThat(authentication.getAuthorities()) + .extracting(GrantedAuthority::getAuthority) + .containsExactly("test-authority"); + }) + .verifyComplete(); + } + + @Test + void authenticate_rejectsInvalidMcpToken() { + McpTokenAuthenticationManager manager = new McpTokenAuthenticationManager(userMcpTokenService); + when(userMcpTokenService.authenticate("mcp_token")).thenReturn(Mono.empty()); + + StepVerifier.create(manager.authenticate(new McpTokenAuthentication("mcp_token"))) + .expectError(BadCredentialsException.class) + .verify(); + } +} diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserMcpTokenServiceImplTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserMcpTokenServiceImplTest.java new file mode 100644 index 000000000000..47de30c1bffe --- /dev/null +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserMcpTokenServiceImplTest.java @@ -0,0 +1,115 @@ +package com.appsmith.server.services; + +import com.appsmith.server.domains.User; +import com.appsmith.server.domains.UserMcpToken; +import com.appsmith.server.dtos.McpTokenResponseDTO; +import com.appsmith.server.repositories.UserMcpTokenRepository; +import com.appsmith.server.repositories.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserMcpTokenServiceImplTest { + + @Mock + private UserMcpTokenRepository userMcpTokenRepository; + + @Mock + private UserRepository userRepository; + + @Captor + private ArgumentCaptor userMcpTokenCaptor; + + private UserMcpTokenServiceImpl service; + private User user; + private UserMcpToken persistedToken; + + @BeforeEach + void setUp() { + service = new UserMcpTokenServiceImpl(userMcpTokenRepository, userRepository, new BCryptPasswordEncoder()); + user = new User(); + user.setId("user-id"); + user.setIsEnabled(true); + } + + @Test + void create_generatesOneTimeTokenAndStoresOnlyHash() { + when(userMcpTokenRepository.countByUserIdAndDeletedAtIsNull(user.getId())) + .thenReturn(Mono.just(0L)); + when(userMcpTokenRepository.save(any(UserMcpToken.class))) + .thenAnswer(invocation -> Mono.just(invocation.getArgument(0))); + + StepVerifier.create(service.create(user)) + .assertNext(response -> { + assertThat(response.id()).isNotBlank(); + assertThat(response.token()).startsWith("mcp_" + response.id() + "."); + }) + .verifyComplete(); + + verify(userMcpTokenRepository).save(userMcpTokenCaptor.capture()); + UserMcpToken storedToken = userMcpTokenCaptor.getValue(); + assertThat(storedToken.getUserId()).isEqualTo(user.getId()); + assertThat(storedToken.getTokenHash()).doesNotStartWith("mcp_"); + } + + @Test + void create_rejectsWhenUserHasMaximumActiveTokens() { + when(userMcpTokenRepository.countByUserIdAndDeletedAtIsNull(user.getId())) + .thenReturn(Mono.just(10L)); + + StepVerifier.create(service.create(user)).expectError().verify(); + } + + @Test + void authenticate_matchesStoredHashAndReturnsOwner() { + McpTokenResponseDTO generated = createToken(); + when(userMcpTokenRepository.findByTokenIdAndDeletedAtIsNull(persistedToken.getTokenId())) + .thenReturn(Mono.just(persistedToken)); + when(userRepository.findById(user.getId())).thenReturn(Mono.just(user)); + + StepVerifier.create(service.authenticate(generated.token())) + .expectNext(user) + .verifyComplete(); + } + + @Test + void revoke_onlyArchivesTokenOwnedByCurrentUser() { + UserMcpToken storedToken = new UserMcpToken(); + storedToken.setId("mongo-id"); + storedToken.setTokenId("token-id"); + storedToken.setUserId(user.getId()); + when(userMcpTokenRepository.findByTokenIdAndUserIdAndDeletedAtIsNull("token-id", user.getId())) + .thenReturn(Mono.just(storedToken)); + when(userMcpTokenRepository.archiveById("mongo-id")).thenReturn(Mono.just(true)); + + StepVerifier.create(service.revoke(user, "token-id")).expectNext(true).verifyComplete(); + + verify(userMcpTokenRepository).findByTokenIdAndUserIdAndDeletedAtIsNull(eq("token-id"), eq(user.getId())); + verify(userMcpTokenRepository).archiveById("mongo-id"); + } + + private McpTokenResponseDTO createToken() { + when(userMcpTokenRepository.countByUserIdAndDeletedAtIsNull(user.getId())) + .thenReturn(Mono.just(0L)); + when(userMcpTokenRepository.save(any(UserMcpToken.class))).thenAnswer(invocation -> { + persistedToken = invocation.getArgument(0); + return Mono.just(persistedToken); + }); + + return service.create(user).block(); + } +} diff --git a/contributions/ServerSetup.md b/contributions/ServerSetup.md index 54dcdcd7c9ae..26e2cc4c07aa 100644 --- a/contributions/ServerSetup.md +++ b/contributions/ServerSetup.md @@ -232,6 +232,16 @@ APPSMITH_GIT_ROOT=/absolute/path/to/git-storage - You can check the status of the server by hitting the endpoint: [http://localhost:8080/api/v1/users/me](http://localhost:8080/api/v1/users/me) on your browser. +11. Start the MCP server in a second terminal: + + ```console + cd app/client/packages/mcp + cp .env.example .env + ./start-server.sh + ``` + + The service listens on `http://127.0.0.1:8092`; use `http://127.0.0.1:8092/health` to verify it. Its `/mcp` endpoint requires a bearer token. + ## Local setup on Windows using WSL2 ## Pre-requisites @@ -393,9 +403,19 @@ There are two ways to resolve this issue: (1) free up more space (2) change dock By default, the server will start on port 8080. -9. When the server starts, it automatically runs migrations on MongoDB and will populate it with some initial required data. +9. Start the MCP server in a second terminal: + +```console +cd app/client/packages/mcp +cp .env.example .env +./start-server.sh +``` + +The MCP service listens on `http://127.0.0.1:8092`; verify it with `http://127.0.0.1:8092/health`. Its `/mcp` endpoint requires a bearer token. + +10. When the server starts, it automatically runs migrations on MongoDB and will populate it with some initial required data. -10. You can check the status of the server by hitting the endpoint: [http://localhost:8080](http://localhost:8080) on your browser. By default you should see an HTTP 401 error. +11. You can check the status of the server by hitting the endpoint: [http://localhost:8080](http://localhost:8080) on your browser. By default you should see an HTTP 401 error. Now the last bit, let's get your Intellij IDEA up and running. diff --git a/deploy/docker/fs/opt/appsmith/caddy-reconfigure.mjs b/deploy/docker/fs/opt/appsmith/caddy-reconfigure.mjs index 334b486a364b..435a8d94f573 100644 --- a/deploy/docker/fs/opt/appsmith/caddy-reconfigure.mjs +++ b/deploy/docker/fs/opt/appsmith/caddy-reconfigure.mjs @@ -14,6 +14,9 @@ const AppsmithCaddy = process.env._APPSMITH_CADDY const isRateLimitingEnabled = process.env.APPSMITH_RATE_LIMIT !== "disabled" const RATE_LIMIT = parseInt(process.env.APPSMITH_RATE_LIMIT || 100, 10) +// The MCP server is opt-in. Only route to it when explicitly enabled. +const isMcpEnabled = process.env.APPSMITH_MCP_ENABLED === "1" + let certLocation = null if (CUSTOM_DOMAIN !== "") { try { @@ -151,6 +154,15 @@ parts.push(` import reverse_proxy 8091 } + ${isMcpEnabled ? `handle /mcp { + import reverse_proxy ${process.env.APPSMITH_MCP_PORT || 8092} + } + + handle /mcp/health { + rewrite * /health + import reverse_proxy ${process.env.APPSMITH_MCP_PORT || 8092} + }` : ""} + ${isRateLimitingEnabled ? `rate_limit { zone dynamic_zone { # This key is designed to work irrespective of any load balancers running on the Appsmith container. diff --git a/deploy/docker/fs/opt/appsmith/entrypoint.sh b/deploy/docker/fs/opt/appsmith/entrypoint.sh index e8b4c1398c55..a7cded6904af 100644 --- a/deploy/docker/fs/opt/appsmith/entrypoint.sh +++ b/deploy/docker/fs/opt/appsmith/entrypoint.sh @@ -501,6 +501,11 @@ configure_supervisord() { cp -f "$supervisord_conf_source"/application_process/*.conf "$SUPERVISORD_CONF_TARGET" + # The MCP server is opt-in. Remove its supervisord program unless explicitly enabled. + if [[ ${APPSMITH_MCP_ENABLED-} != 1 ]]; then + rm -f "$SUPERVISORD_CONF_TARGET/mcp.conf" + fi + # Disable services based on configuration if [[ -z "${DYNO}" ]]; then if [[ $isUriLocal -eq 0 && $isMongoUrl -eq 1 ]]; then @@ -718,7 +723,7 @@ mkdir -p /appsmith-stacks/data/{backup,restore} /appsmith-stacks/ssl # Create sub-directory to store services log in the container mounting folder export APPSMITH_LOG_DIR="${APPSMITH_LOG_DIR:-/appsmith-stacks/logs}" -mkdir -p "$APPSMITH_LOG_DIR"/{supervisor,backend,cron,editor,rts,mongodb,redis,postgres,appsmithctl} +mkdir -p "$APPSMITH_LOG_DIR"/{supervisor,backend,cron,editor,rts,mcp,mongodb,redis,postgres,appsmithctl} setup_auto_heal capture_infra_details diff --git a/deploy/docker/fs/opt/appsmith/healthcheck.sh b/deploy/docker/fs/opt/appsmith/healthcheck.sh index e87a7a6cc6ed..ae259f80dfc2 100644 --- a/deploy/docker/fs/opt/appsmith/healthcheck.sh +++ b/deploy/docker/fs/opt/appsmith/healthcheck.sh @@ -20,6 +20,11 @@ while read -r line echo 'ERROR: Server is down'; healthy=false fi + elif [[ "$process" == "mcp" ]]; then + if [[ $(curl -s -w "%{http_code}\n" http://localhost:${APPSMITH_MCP_PORT:-8092}/health -o /dev/null) -ne 200 ]]; then + echo 'ERROR: MCP is down'; + healthy=false + fi elif [[ "$process" == "mongo" ]]; then if [[ $(mongo --eval 'db.runCommand("ping").ok') -ne 1 ]]; then echo 'ERROR: Mongo is down'; @@ -32,7 +37,7 @@ while read -r line fi fi fi - done <<< $(supervisorctl status editor rts backend) + done <<< $(supervisorctl status editor rts mcp backend) if [ $healthy == true ]; then exit 0 else diff --git a/deploy/docker/fs/opt/appsmith/run-mcp.sh b/deploy/docker/fs/opt/appsmith/run-mcp.sh new file mode 100644 index 000000000000..ecd7ee0c8bec --- /dev/null +++ b/deploy/docker/fs/opt/appsmith/run-mcp.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +exec node --enable-source-maps /opt/appsmith/mcp/bundle/server.js diff --git a/deploy/docker/fs/opt/appsmith/templates/supervisord/application_process/mcp.conf b/deploy/docker/fs/opt/appsmith/templates/supervisord/application_process/mcp.conf new file mode 100644 index 000000000000..a31d620ad9c9 --- /dev/null +++ b/deploy/docker/fs/opt/appsmith/templates/supervisord/application_process/mcp.conf @@ -0,0 +1,9 @@ +[program:mcp] +command=/opt/appsmith/run-with-env.sh /opt/appsmith/run-mcp.sh +autorestart=true +autostart=true +priority=16 +startretries=3 +startsecs=0 +stderr_logfile=%(ENV_APPSMITH_LOG_DIR)s/mcp/mcp.err.log +stdout_logfile=%(ENV_APPSMITH_LOG_DIR)s/mcp/mcp.out.log diff --git a/deploy/docker/route-tests/common/mcp-health.hurl b/deploy/docker/route-tests/common/mcp-health.hurl new file mode 100644 index 000000000000..2b9222697671 --- /dev/null +++ b/deploy/docker/route-tests/common/mcp-health.hurl @@ -0,0 +1,9 @@ +GET http://local.com/mcp/health +HTTP 200 +``` +Scheme = 'http' +X-Forwarded-Proto = 'http' +Host = 'local.com' +X-Forwarded-Host = 'local.com' +Forwarded = '' +``` diff --git a/scripts/local_testing.sh b/scripts/local_testing.sh index fad1f8740e4a..cbc9a1cc1105 100755 --- a/scripts/local_testing.sh +++ b/scripts/local_testing.sh @@ -109,7 +109,15 @@ if ! ./build.sh > /dev/null; then echo RTS build failed >&2 exit 1 fi -pretty_print "RTS build successful. Starting Docker build ..." +pretty_print "RTS build successful. Starting MCP build ..." + +popd +pushd app/client/packages/mcp/ > /dev/null +if ! ./build.sh > /dev/null; then + echo MCP build failed >&2 + exit 1 +fi +pretty_print "MCP build successful. Starting Docker build ..." popd bash "$(dirname "$0")/generate_info_json.sh" From b55ba0aecbf5bae02c3f98acdaff88861624b3a8 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Sat, 11 Jul 2026 12:52:09 -0400 Subject: [PATCH 02/81] feat(mcp): app-builder tools + adversarial security hardening Add the high-level page-spec app-builder that compiles to a validated Appsmith import artifact, and harden the MCP surface following an adversarial red-team pass. App-builder (app/client/packages/mcp/src/builder): - Zod page/app/edit spec + best-effort placement, curated widget templates, 64-column auto-layout, spec -> import-artifact compiler with depth and total-widget caps, presets, and a capability catalog. - New tools: get_capabilities, list_presets, get_preset, validate_app_spec (dry-run), build_application, edit_page (append-only, best-effort placement). - Removed the raw artifact-import tools (an arbitrary-content injection surface) in favour of the validated compiler. Security hardening (from the red-team review): - Atomic session admission so concurrent initializes cannot bypass the per-user / global session caps (whole-instance DoS). - Stop crash-looping: guard fire-and-forget transport.close(), log instead of process.exit on unhandledRejection, supervisord startsecs=5. - Do not evict a session on token mismatch (targeted DoS via a leaked session id). - Bound inbound sockets (requestTimeout / headersTimeout / maxConnections). - Token expiry (90d) enforced at authentication; block minting new MCP tokens from an MCP-authenticated principal (no post-revocation persistence). Tests: 39 Node tests (compiler, placement, edit, tool wiring, concurrent session caps, mismatch-no-evict) and Java unit tests for token expiry and the auth marker. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build-client-server.yml | 2 +- .github/workflows/mcp-build.yml | 6 +- app/client/packages/mcp/build.js | 2 +- app/client/packages/mcp/src/app.test.ts | 337 +++++++++++++++- app/client/packages/mcp/src/app.ts | 299 +++++++++++--- .../packages/mcp/src/builder/builder.test.ts | 380 ++++++++++++++++++ .../packages/mcp/src/builder/capabilities.ts | 74 ++++ .../packages/mcp/src/builder/compile.ts | 304 ++++++++++++++ app/client/packages/mcp/src/builder/layout.ts | 261 ++++++++++++ .../packages/mcp/src/builder/presets.ts | 101 +++++ app/client/packages/mcp/src/builder/schema.ts | 167 ++++++++ .../packages/mcp/src/builder/templates.ts | 231 +++++++++++ app/client/packages/mcp/src/server.ts | 16 +- .../McpTokenAuthenticationConverter.java | 15 +- .../McpTokenAuthenticationManager.java | 50 ++- .../tokens/McpTokenAuthentication.java | 14 + .../server/constants/RateLimitConstants.java | 1 + .../controllers/ce/McpTokenControllerCE.java | 24 +- .../server/domains/ce/UserMcpTokenCE.java | 5 + .../server/dtos/McpTokenResponseDTO.java | 2 +- .../server/ratelimiting/RateLimitConfig.java | 3 + .../ratelimiting/ce/RateLimitServiceCE.java | 2 + .../ce/RateLimitServiceCEImpl.java | 12 + .../services/UserMcpTokenServiceImpl.java | 8 +- .../ce/UserMcpTokenServiceCEImpl.java | 46 ++- .../McpTokenAuthenticationWebFilterTest.java | 8 +- .../McpTokenAuthenticationConverterTest.java | 9 +- .../McpTokenAuthenticationManagerTest.java | 53 ++- .../services/UserMcpTokenServiceImplTest.java | 61 ++- .../supervisord/application_process/mcp.conf | 4 +- .../2026-07-11-mcp-app-builder-design.md | 144 +++++++ 31 files changed, 2538 insertions(+), 103 deletions(-) create mode 100644 app/client/packages/mcp/src/builder/builder.test.ts create mode 100644 app/client/packages/mcp/src/builder/capabilities.ts create mode 100644 app/client/packages/mcp/src/builder/compile.ts create mode 100644 app/client/packages/mcp/src/builder/layout.ts create mode 100644 app/client/packages/mcp/src/builder/presets.ts create mode 100644 app/client/packages/mcp/src/builder/schema.ts create mode 100644 app/client/packages/mcp/src/builder/templates.ts create mode 100644 docs/plans/2026-07-11-mcp-app-builder-design.md diff --git a/.github/workflows/build-client-server.yml b/.github/workflows/build-client-server.yml index 5c5cc2671062..68b9546cb049 100644 --- a/.github/workflows/build-client-server.yml +++ b/.github/workflows/build-client-server.yml @@ -118,7 +118,7 @@ jobs: mcp-build: name: mcp-build needs: [file-check] - if: success() && needs.file-check.outputs.runId == 0 + if: success() && needs.file-check.outputs.runId == '0' uses: ./.github/workflows/mcp-build.yml secrets: inherit with: diff --git a/.github/workflows/mcp-build.yml b/.github/workflows/mcp-build.yml index a712009bd5d5..b00435c04acf 100644 --- a/.github/workflows/mcp-build.yml +++ b/.github/workflows/mcp-build.yml @@ -44,6 +44,7 @@ jobs: uses: actions/checkout@v4 with: fetch-tags: true + persist-credentials: false ref: refs/pull/${{ inputs.pr }}/merge - name: Checkout the specified branch @@ -51,6 +52,7 @@ jobs: uses: actions/checkout@v4 with: fetch-tags: true + persist-credentials: false ref: ${{ inputs.branch }} - name: Checkout the head commit @@ -58,6 +60,7 @@ jobs: uses: actions/checkout@v4 with: fetch-tags: true + persist-credentials: false - name: Use Node.js uses: actions/setup-node@v4 @@ -87,9 +90,6 @@ jobs: - name: Check formatting run: yarn prettier - - name: Lint - run: yarn lint - - name: Run unit tests if: inputs.skip-tests != 'true' run: yarn test:unit diff --git a/app/client/packages/mcp/build.js b/app/client/packages/mcp/build.js index b44fd0b674ba..f4e2ce790cf0 100644 --- a/app/client/packages/mcp/build.js +++ b/app/client/packages/mcp/build.js @@ -7,6 +7,6 @@ await esbuild.build({ minify: true, platform: "node", sourcemap: true, - target: `node${process.versions.node}`, + target: `node${process.versions.node.split(".")[0]}`, outdir: "dist/bundle", }); diff --git a/app/client/packages/mcp/src/app.test.ts b/app/client/packages/mcp/src/app.test.ts index 8390d93971eb..10daf5666058 100644 --- a/app/client/packages/mcp/src/app.test.ts +++ b/app/client/packages/mcp/src/app.test.ts @@ -56,6 +56,7 @@ describe("Appsmith API client", () => { "Bearer user-token", ), ).toBe(true); + for (const callIndex of [5, 6]) { const init = fetchFn.mock.calls[callIndex][1]; const formData = init?.body as FormData; @@ -66,6 +67,7 @@ describe("Appsmith API client", () => { 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; @@ -73,9 +75,7 @@ describe("Appsmith API client", () => { "file", ) as File; - await expect( - fullArtifact.text(), - ).resolves.toBe( + await expect(fullArtifact.text()).resolves.toBe( JSON.stringify({ application: { name: "New application" } }), ); await expect(partialArtifact.text()).resolves.toBe( @@ -144,6 +144,7 @@ function createApi( importPartialApplicationArtifact: jest.fn(), listApplications: jest.fn(), listWorkspaces: jest.fn(), + updateLayout: jest.fn(), validateToken, }); } @@ -152,12 +153,14 @@ function createApi( // 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 }[] }; + 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 }[] } }; + return body as { + result: { tools: { name: string }[]; content: { text: string }[] }; + }; } const dataLine = (response.text ?? "") @@ -282,19 +285,321 @@ describe("MCP HTTP server", () => { "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. + expect(names).not.toEqual( + expect.arrayContaining([ + "create_application", + "update_layout", "import_application_artifact", "import_partial_application_artifact", ]), ); - expect(names).not.toEqual( - expect.arrayContaining(["create_application", "update_layout"]), + }); + + 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" }); + 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); + }); + + 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"); + }); + + 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(400); + }); + + 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 accepted = responses.filter( + (r) => r.headers["mcp-session-id"], + ).length; + const rejected = responses.filter((r) => r.status === 429).length; + + // The atomic reservation must admit exactly one; the rest hit the per-user cap. + expect(accepted).toBe(1); + expect(rejected).toBe(4); + } 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 }) + .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) @@ -374,6 +679,22 @@ describe("MCP HTTP server", () => { 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 tokens that resolve to an anonymous session", async () => { const validateToken = jest.fn(async () => ({ isAnonymous: true, diff --git a/app/client/packages/mcp/src/app.ts b/app/client/packages/mcp/src/app.ts index e36648706974..874076381bc7 100644 --- a/app/client/packages/mcp/src/app.ts +++ b/app/client/packages/mcp/src/app.ts @@ -9,13 +9,22 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; +import { getCapabilities } from "./builder/capabilities.js"; +import { applyEdit, compileApp } from "./builder/compile.js"; +import type { WidgetNode } from "./builder/layout.js"; +import { listPresets, PRESETS } from "./builder/presets.js"; +import { appSpecSchema, editSpecSchema } from "./builder/schema.js"; const MAX_ID_LENGTH = 128; + export const MAX_ARTIFACT_BYTES = 1024 * 1024; export const MAX_REQUEST_BODY_BYTES = 2 * 1024 * 1024; export const MAX_MCP_SESSIONS = 100; export const MAX_MCP_SESSIONS_PER_USER = 10; export const MCP_SESSION_TTL_MS = 15 * 60 * 1000; +export const REQUEST_TIMEOUT_MS = 30 * 1000; +export const HEADERS_TIMEOUT_MS = 10 * 1000; +export const MAX_INBOUND_CONNECTIONS = 512; const MCP_TOKEN_PREFIX = "mcp_"; const idSchema = z .string() @@ -30,9 +39,7 @@ const artifactSchema = z.record(z.unknown()).superRefine((artifact, ctx) => { ctx.addIssue({ code: z.ZodIssueCode.custom, message: - error instanceof Error - ? error.message - : "Artifact must be valid JSON", + error instanceof Error ? error.message : "Artifact must be valid JSON", }); } }); @@ -60,6 +67,12 @@ export interface AppsmithApi { pageId: string, artifact: Record, ) => Promise; + updateLayout: ( + applicationId: string, + pageId: string, + layoutId: string, + dsl: Record, + ) => Promise; validateToken: () => Promise; } @@ -117,7 +130,10 @@ function serializeArtifact(artifact: Record): string { }, ); - if (!serialized || Buffer.byteLength(serialized, "utf8") > MAX_ARTIFACT_BYTES) { + if ( + !serialized || + Buffer.byteLength(serialized, "utf8") > MAX_ARTIFACT_BYTES + ) { throw new Error(`Artifact must not exceed ${MAX_ARTIFACT_BYTES} bytes`); } @@ -142,20 +158,30 @@ export function createAppsmithApi( ): AppsmithApi { async function request(path: string, init?: RequestInit): Promise { const isMultipart = init?.body instanceof FormData; - const response = await fetchFn(`${apiBaseUrl}${path}`, { - ...init, - headers: { - Authorization: `Bearer ${token}`, - ...(isMultipart ? {} : { "Content-Type": "application/json" }), - ...init?.headers, - }, - }); + // Bound every upstream call so a hung Appsmith response cannot pin an MCP worker indefinitely. Preserve a + // caller-supplied signal when present; otherwise abort on our own timeout. + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - if (!response.ok) { - throw new Error(`Appsmith API request failed (${response.status})`); - } + try { + const response = await fetchFn(`${apiBaseUrl}${path}`, { + ...init, + signal: init?.signal ?? controller.signal, + headers: { + Authorization: `Bearer ${token}`, + ...(isMultipart ? {} : { "Content-Type": "application/json" }), + ...init?.headers, + }, + }); - return ((await response.json()) as ApiResponse).data; + if (!response.ok) { + throw new Error(`Appsmith API request failed (${response.status})`); + } + + return ((await response.json()) as ApiResponse).data; + } finally { + clearTimeout(timeout); + } } return { @@ -198,10 +224,42 @@ export function createAppsmithApi( body: artifactUpload(artifact), }, ), + updateLayout: async (applicationId, pageId, layoutId, dsl) => + request( + `/api/v1/layouts/${encodeURIComponent(layoutId)}/pages/${encodeURIComponent(pageId)}?applicationId=${encodeURIComponent(applicationId)}`, + { + method: "PUT", + body: JSON.stringify({ dsl }), + }, + ), validateToken: async () => request("/api/v1/users/me"), }; } +function validationError(issues: z.ZodIssue[]) { + return result({ + valid: false, + errors: issues.map((issue) => ({ + path: issue.path.join("."), + message: issue.message, + })), + }); +} + +// The compiler enforces aggregate caps (depth, total widgets) and serializeArtifact enforces size/plain-JSON; surface +// those as clean validation errors instead of letting them become a 500. +function compileError(error: unknown) { + return result({ + valid: false, + errors: [ + { + path: "", + message: error instanceof Error ? error.message : "compilation failed", + }, + ], + }); +} + function result(data: unknown) { return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], @@ -233,35 +291,141 @@ export function buildMcpServer(api: AppsmithApi) { result(await api.getApplicationContext(applicationId, pageId, layoutId)), ); + // The raw artifact-import tools were removed: they let an agent upload an arbitrary artifact (custom JS libs, JS + // objects, datasources) — a large content-injection surface. Authoring goes through the validated + // build_application / edit_page spec compiler instead. + server.tool( - "import_application_artifact", - "Create an application by importing a validated Appsmith application artifact. Appsmith authorizes the import using the caller's bearer token.", - { - workspaceId: idSchema, - artifact: artifactSchema, + "get_capabilities", + "Discover what the app builder supports: widget types, the page/app/edit spec shapes, presets, and the layout grid. Call this first when building an app.", + {}, + async () => result(getCapabilities()), + ); + + server.tool( + "list_presets", + "List ready-made page-spec presets (form, table-detail, card-grid, crud) that can be adapted into an app spec.", + {}, + async () => result(listPresets()), + ); + + server.tool( + "get_preset", + "Get a preset page spec by name to use or adapt.", + { name: z.string().trim().min(1) }, + async ({ name }) => { + const preset = PRESETS[name]; + + if (!preset) { + return result({ + error: `unknown preset "${name}"`, + available: Object.keys(PRESETS), + }); + } + + return result(preset); + }, + ); + + server.tool( + "validate_app_spec", + "Dry-run: validate and compile an app spec WITHOUT creating anything. Returns structured errors, or a summary of what would be built. Use this to iterate before build_application.", + { app: z.record(z.unknown()) }, + async ({ app }) => { + const parsed = appSpecSchema.safeParse(app); + + if (!parsed.success) { + return validationError(parsed.error.issues); + } + + try { + compileApp(parsed.data); + } catch (error) { + return compileError(error); + } + + return result({ + valid: true, + application: parsed.data.name, + pages: parsed.data.pages.map((page) => ({ + name: page.name, + widgets: page.widgets.length, + })), + }); }, - async ({ artifact, workspaceId }) => - result(await api.importApplicationArtifact(workspaceId, artifact)), ); server.tool( - "import_partial_application_artifact", - "Update an application page by importing a validated partial Appsmith artifact. Appsmith authorizes the import using the caller's bearer token.", + "build_application", + "Create an Appsmith application from a high-level app spec. Widgets are auto-placed on the grid, compiled to an artifact, and imported via the caller's ACL-enforced permissions.", + { workspaceId: idSchema, app: z.record(z.unknown()) }, + async ({ app, workspaceId }) => { + const parsed = appSpecSchema.safeParse(app); + + if (!parsed.success) { + return validationError(parsed.error.issues); + } + + let artifact: Record; + + try { + artifact = compileApp(parsed.data); + } catch (error) { + return compileError(error); + } + + return result(await api.importApplicationArtifact(workspaceId, artifact)); + }, + ); + + server.tool( + "edit_page", + "Append widgets to an existing page from a high-level edit spec, with best-effort placement (after a widget / inside a container). Existing widgets are never modified. Returns notes about how placement was resolved.", { - workspaceId: idSchema, applicationId: idSchema, pageId: idSchema, - artifact: artifactSchema, + layoutId: idSchema, + edit: z.record(z.unknown()), + }, + async ({ applicationId, edit, layoutId, pageId }) => { + const parsed = editSpecSchema.safeParse(edit); + + if (!parsed.success) { + return validationError(parsed.error.issues); + } + + const context = await api.getApplicationContext( + applicationId, + pageId, + layoutId, + ); + const currentDsl = (context.layout as { dsl?: WidgetNode } | undefined) + ?.dsl; + + if (!currentDsl) { + return result({ error: "could not read the current page layout" }); + } + + let dsl: WidgetNode; + let notes: string[]; + + try { + ({ dsl, notes } = applyEdit(currentDsl, parsed.data)); + // Guard the write path with the same size + plain-JSON check the import path uses. + serializeArtifact(dsl as Record); + } catch (error) { + return compileError(error); + } + + const updated = await api.updateLayout( + applicationId, + pageId, + layoutId, + dsl, + ); + + return result({ notes, layout: updated }); }, - async ({ applicationId, artifact, pageId, workspaceId }) => - result( - await api.importPartialApplicationArtifact( - workspaceId, - applicationId, - pageId, - artifact, - ), - ), ); return server; @@ -294,6 +458,7 @@ async function readBody(req: IncomingMessage): Promise { for await (const chunk of req) { const buffer = Buffer.from(chunk); + byteLength += buffer.byteLength; if (byteLength > MAX_REQUEST_BODY_BYTES) { @@ -341,6 +506,10 @@ export function createMcpHttpServer( options: McpHttpServerOptions = {}, ): Server { const sessions = new Map(); + // Reservations bridge the async gap between admitting an initialize and the session registering in + // onsessioninitialized, so concurrent initializes cannot all pass the caps before any of them registers. + let pendingTotal = 0; + const pendingByUser = new Map(); const maxSessions = options.maxSessions ?? MAX_MCP_SESSIONS; const maxSessionsPerUser = options.maxSessionsPerUser ?? MAX_MCP_SESSIONS_PER_USER; @@ -353,7 +522,7 @@ export function createMcpHttpServer( for (const [id, session] of sessions) { if (session.expiresAt <= currentTime) { sessions.delete(id); - void session.transport.close(); + void session.transport.close().catch(() => {}); } } } @@ -377,7 +546,10 @@ export function createMcpHttpServer( return profile.username; } - return createServer(async (req, res) => { + const server = createServer(async (req, res) => { + // Released in `finally`; holds a session reservation across the admission → registration gap. + let releasePending = () => {}; + try { const path = (req.url ?? "").split("?")[0]; @@ -393,6 +565,14 @@ export function createMcpHttpServer( return; } + // This endpoint serves programmatic MCP clients, not browsers. A cross-site page could otherwise use DNS + // rebinding to reach the loopback service, so reject any request that carries a browser Origin header. + if (req.headers.origin !== undefined) { + writeJson(res, 403, { error: "cross-origin requests are not allowed" }); + + return; + } + const token = bearerToken(req); if (!token || !token.startsWith(MCP_TOKEN_PREFIX)) { @@ -411,8 +591,8 @@ export function createMcpHttpServer( if (session) { if (!tokensMatch(session.token, token)) { - sessions.delete(sessionId!); - void session.transport.close(); + // Do NOT evict the session here: a mismatched token must not let someone who guessed/leaked a session id + // tear down the real owner's session (targeted DoS). The owner's own token still binds it. writeJson(res, 401, { error: "invalid MCP session" }); return; @@ -423,20 +603,24 @@ export function createMcpHttpServer( } if (!transport && isInitializeRequest(body)) { - if (sessions.size >= maxSessions) { - writeJson(res, 503, { error: "MCP session limit reached" }); - - return; - } - const api = createApi(token); const username = await authenticatedUsername(api); - let userSessionCount = 0; + + // Check-and-reserve synchronously (no await between reading the counts and incrementing the reservation) + // so a burst of concurrent initializes can't all slip past the caps. `sessions.size + pendingTotal` counts + // both registered and in-flight sessions. + let userSessionCount = pendingByUser.get(username) ?? 0; for (const existing of sessions.values()) { if (existing.username === username) userSessionCount += 1; } + if (sessions.size + pendingTotal >= maxSessions) { + writeJson(res, 503, { error: "MCP session limit reached" }); + + return; + } + if (userSessionCount >= maxSessionsPerUser) { writeJson(res, 429, { error: "MCP session limit reached for this user", @@ -445,6 +629,17 @@ export function createMcpHttpServer( return; } + pendingTotal += 1; + pendingByUser.set(username, (pendingByUser.get(username) ?? 0) + 1); + releasePending = () => { + releasePending = () => {}; + pendingTotal -= 1; + const remaining = (pendingByUser.get(username) ?? 1) - 1; + + if (remaining <= 0) pendingByUser.delete(username); + else pendingByUser.set(username, remaining); + }; + transport = new StreamableHTTPServerTransport({ sessionIdGenerator: randomUUID, onsessioninitialized: (id) => { @@ -475,6 +670,16 @@ export function createMcpHttpServer( error instanceof HttpError ? error.message : "MCP request failed"; writeJson(res, status, { error: message }); + } finally { + // By now the session has either registered (counted in `sessions`) or failed; release the reservation. + releasePending(); } }); + + // Bound inbound sockets so a slow-loris or a flood of half-open connections can't pin the single process. + server.maxConnections = MAX_INBOUND_CONNECTIONS; + server.requestTimeout = REQUEST_TIMEOUT_MS; + server.headersTimeout = HEADERS_TIMEOUT_MS; + + return server; } diff --git a/app/client/packages/mcp/src/builder/builder.test.ts b/app/client/packages/mcp/src/builder/builder.test.ts new file mode 100644 index 000000000000..f1c81565ec43 --- /dev/null +++ b/app/client/packages/mcp/src/builder/builder.test.ts @@ -0,0 +1,380 @@ +import { applyEdit, compileApp } from "./compile.js"; +import { sequentialIdGenerator, type WidgetNode } from "./layout.js"; +import { appSpecSchema, editSpecSchema, type WidgetSpec } from "./schema.js"; +import { PRESETS } from "./presets.js"; + +function ids() { + return sequentialIdGenerator(); +} + +function rootOf(artifact: Record, pageIndex = 0): WidgetNode { + const pageList = artifact.pageList as { + unpublishedPage: { layouts: { dsl: WidgetNode }[] }; + }[]; + + return pageList[pageIndex].unpublishedPage.layouts[0].dsl; +} + +describe("compileApp — import artifact contract", () => { + it("emits the required top-level fields the import API validates", () => { + const artifact = compileApp( + { + name: "My App", + pages: [{ name: "Home", widgets: [{ type: "text", text: "Hi" }] }], + }, + ids(), + ); + + expect(artifact.clientSchemaVersion).toBe(2); + expect(artifact.serverSchemaVersion).toBe(12); + expect(artifact.artifactJsonType).toBe("APPLICATION"); + expect(artifact.actionList).toEqual([]); + expect(artifact.datasourceList).toEqual([]); + expect((artifact.exportedApplication as { name: string }).name).toBe( + "My App", + ); + expect(Array.isArray(artifact.pageList)).toBe(true); + expect((artifact.pageList as unknown[]).length).toBe(1); + }); + + it("builds a root canvas with widgetId '0' and a 64-column grid", () => { + const artifact = compileApp( + { + name: "App", + pages: [{ name: "Home", widgets: [{ type: "text", text: "Hi" }] }], + }, + ids(), + ); + const root = rootOf(artifact); + + expect(root.widgetId).toBe("0"); + expect(root.type).toBe("CANVAS_WIDGET"); + expect(root.snapColumns).toBe(64); + expect(root.children).toHaveLength(1); + }); + + it("maps spec widget types to the correct Appsmith widget types and versions", () => { + const artifact = compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { type: "table", data: "{{q.data}}" }, + { type: "input", label: "Email", inputType: "EMAIL" }, + { type: "button", text: "Go" }, + ], + }, + ], + }, + ids(), + ); + const [table, input, button] = rootOf(artifact).children as WidgetNode[]; + + expect(table.type).toBe("TABLE_WIDGET_V2"); + expect(table.version).toBe(2); + expect(table.tableData).toBe("{{q.data}}"); + expect(input.type).toBe("INPUT_WIDGET_V2"); + expect(input.inputType).toBe("EMAIL"); + expect(button.type).toBe("BUTTON_WIDGET"); + }); + + it("stamps every widget with the mandatory DSL fields", () => { + const artifact = compileApp( + { + name: "App", + pages: [{ name: "Home", widgets: [{ type: "input", label: "X" }] }], + }, + ids(), + ); + const widget = (rootOf(artifact).children as WidgetNode[])[0]; + + for (const field of [ + "widgetId", + "widgetName", + "type", + "version", + "parentId", + "renderMode", + "topRow", + "bottomRow", + "leftColumn", + "rightColumn", + ]) { + expect(widget[field]).toBeDefined(); + } + + expect(widget.parentId).toBe("0"); + expect(widget.isVisible).toBe(true); + // rows/columns must not leak into the persisted DSL. + expect(widget.rows).toBeUndefined(); + expect(widget.columns).toBeUndefined(); + }); + + it("auto-stacks widgets vertically without overlap", () => { + const artifact = compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { type: "text", text: "A" }, + { type: "input", label: "B" }, + { type: "button", text: "C" }, + ], + }, + ], + }, + ids(), + ); + const children = rootOf(artifact).children as WidgetNode[]; + + for (let i = 1; i < children.length; i += 1) { + expect(children[i].topRow).toBeGreaterThanOrEqual( + children[i - 1].bottomRow, + ); + } + }); + + it("gives colliding widgets unique names", () => { + const artifact = compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { type: "table", data: "" }, + { type: "table", data: "" }, + ], + }, + ], + }, + ids(), + ); + const [a, b] = rootOf(artifact).children as WidgetNode[]; + + expect(a.widgetName).not.toBe(b.widgetName); + }); + + it("nests container children inside an inner canvas", () => { + const artifact = compileApp( + { + name: "App", + pages: [ + { + name: "Home", + widgets: [ + { + type: "container", + name: "Card", + children: [{ type: "text", text: "In card" }], + }, + ], + }, + ], + }, + ids(), + ); + const container = (rootOf(artifact).children as WidgetNode[])[0]; + + expect(container.type).toBe("CONTAINER_WIDGET"); + const inner = (container.children as WidgetNode[])[0]; + + expect(inner.type).toBe("CANVAS_WIDGET"); + expect(inner.parentId).toBe(container.widgetId); + expect((inner.children as WidgetNode[])[0].type).toBe("TEXT_WIDGET"); + }); + + it("compiles a select with default options", () => { + const artifact = compileApp( + { + name: "App", + pages: [{ name: "Home", widgets: [{ type: "select", label: "Pick" }] }], + }, + ids(), + ); + const widget = (rootOf(artifact).children as WidgetNode[])[0]; + + expect(widget.type).toBe("SELECT_WIDGET"); + expect(Array.isArray(widget.options)).toBe(true); + }); + + it("rejects container nesting beyond the maximum depth", () => { + let nested: WidgetSpec = { type: "text", text: "leaf" }; + + for (let i = 0; i < 7; i += 1) { + nested = { type: "container", children: [nested] }; + } + + expect(() => + compileApp( + { name: "App", pages: [{ name: "Home", widgets: [nested] }] }, + ids(), + ), + ).toThrow(/nesting/); + }); + + 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); + } + }); +}); + +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(); + const { dsl: edited } = applyEdit( + dsl, + { + add: [ + { type: "input", label: "Note", placement: { inside: "Details" } }, + ], + }, + ids(), + ); + 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"); + }); + + 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"); + }); +}); + +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); + }); +}); 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..0785d6f0ac61 --- /dev/null +++ b/app/client/packages/mcp/src/builder/capabilities.ts @@ -0,0 +1,74 @@ +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. + +const WIDGET_CATALOG = [ + { + type: "text", + fields: { text: "string" }, + purpose: "Static or bound text / headings.", + }, + { + type: "input", + fields: { + label: "string", + inputType: "TEXT | NUMBER | EMAIL | PASSWORD", + }, + purpose: "Single-line text entry.", + }, + { + type: "select", + fields: { + label: "string", + options: "{ label: string, value: string|number }[]", + }, + purpose: "Dropdown selection.", + }, + { type: "button", fields: { text: "string" }, purpose: "Trigger an action." }, + { + type: "image", + fields: { image: "string (url or {{binding}})" }, + purpose: "Display an image.", + }, + { + type: "table", + fields: { data: "string (JSON array or {{query.data}} binding)" }, + purpose: "Tabular data with search/sort/pagination.", + }, + { + type: "container", + fields: { children: "widget spec[]" }, + purpose: "Group widgets into a card; nest other widgets inside.", + }, +] as const; + +export function getCapabilities() { + return { + description: + "Build Appsmith apps from a high-level page spec. The MCP layer auto-places widgets on a 64-column grid and " + + "compiles to a real Appsmith artifact imported via the ACL-enforced API. You never author raw widget DSL.", + 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" }, + presets: listPresets(), + grid: { columns: GRID_COLUMNS, rowHeightPx: ROW_HEIGHT }, + tools: [ + "get_capabilities — this catalog", + "list_presets / get_preset — ready page specs to adapt", + "validate_app_spec — dry-run compile; returns errors, imports nothing", + "build_application — compile an app spec and create the app", + "edit_page — append widgets to an existing page (best-effort placement)", + ], + }; +} 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..5c6d7c6cfc62 --- /dev/null +++ b/app/client/packages/mcp/src/builder/compile.ts @@ -0,0 +1,304 @@ +import type { + AppSpec, + EditSpec, + PageSpec, + WidgetSpec, + WidgetType, +} from "./schema.js"; +import { WIDGET_TEMPLATES } from "./templates.js"; +import { + createInnerCanvas, + createRootCanvas, + type IdGenerator, + GRID_COLUMNS, + NameAllocator, + nextFreeRow, + randomIdGenerator, + resolvePlacement, + ROOT_WIDGET_ID, + ROW_HEIGHT, + stampWidget, + type WidgetNode, +} from "./layout.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", +}; + +interface CompileContext { + idGen: IdGenerator; + names: NameAllocator; + remaining: { widgets: number }; +} + +function compileWidgetAt( + spec: WidgetSpec, + ctx: CompileContext, + parentId: string, + topRow: number, + availableColumns: number, + depth: number, +): 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); + const columns = Math.min(built.footprint.columns, availableColumns); + const widgetId = ctx.idGen(); + const widgetName = ctx.names.allocate( + spec.name ?? DEFAULT_BASE_NAME[spec.type], + ); + + if (spec.type === "container") { + 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: 0, + columns, + rows, + props: built.props, + children: [innerCanvas], + }); + } + + return stampWidget({ + widgetId, + widgetName, + appsmithType: template.appsmithType, + version: template.version, + parentId, + topRow, + leftColumn: 0, + columns, + rows: built.footprint.rows, + props: built.props, + }); +} + +function stackWidgets( + specs: WidgetSpec[], + ctx: CompileContext, + parentId: string, + availableColumns: number, + startRow: number, + depth: number, +): { nodes: WidgetNode[]; endRow: number } { + const nodes: WidgetNode[] = []; + let cursor = startRow; + + for (const spec of specs) { + const node = compileWidgetAt( + spec, + ctx, + parentId, + cursor, + availableColumns, + depth, + ); + + nodes.push(node); + cursor = node.bottomRow + ROW_GAP; + } + + return { nodes, endRow: cursor }; +} + +function compilePageDsl(pageSpec: PageSpec, ctx: CompileContext): WidgetNode { + const root = createRootCanvas(); + const { endRow, nodes } = stackWidgets( + pageSpec.widgets, + ctx, + ROOT_WIDGET_ID, + GRID_COLUMNS, + 0, + 0, + ); + + root.children = nodes; + root.bottomRow = Math.max(endRow * ROW_HEIGHT, 380); + root.minHeight = root.bottomRow; + + return root; +} + +function compilePage( + pageSpec: PageSpec, + idGen: IdGenerator, + remaining: { widgets: number }, +) { + const ctx: CompileContext = { idGen, names: new NameAllocator(), remaining }; + 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: [] }], + }, + }; +} + +// 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), + ); + const pageNames = appSpec.pages.map((page) => page.name); + + return { + clientSchemaVersion: CLIENT_SCHEMA_VERSION, + serverSchemaVersion: SERVER_SCHEMA_VERSION, + artifactJsonType: "APPLICATION", + exportedApplication: { + name: appSpec.name, + slug: appSpec.name.toLowerCase().replace(/\s+/g, "-"), + color: "#4F70FE", + icon: "app", + isPublic: false, + unpublishedCustomJSLibs: [], + publishedCustomJSLibs: [], + }, + pageList, + actionList: [], + datasourceList: [], + actionCollectionList: [], + customJSLibList: [], + editModeTheme: { name: "Default", isSystemTheme: true }, + publishedTheme: { name: "Default", isSystemTheme: true }, + 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; + const node = compileWidgetAt( + spec, + ctx, + placement.canvas.widgetId, + placement.topRow, + availableColumns, + 0, + ); + + placement.canvas.children = placement.canvas.children ?? []; + placement.canvas.children.push(node); + + // Grow the canvas so the new widget is within bounds. + const bottom = nextFreeRow(placement.canvas.children); + + if (placement.canvas.widgetId === ROOT_WIDGET_ID) { + placement.canvas.bottomRow = Math.max( + typeof placement.canvas.bottomRow === "number" + ? placement.canvas.bottomRow + : 0, + bottom * ROW_HEIGHT, + ); + } else if (bottom > (placement.canvas.bottomRow as number)) { + placement.canvas.bottomRow = bottom; + } + } + + return { dsl, notes }; +} diff --git a/app/client/packages/mcp/src/builder/layout.ts b/app/client/packages/mcp/src/builder/layout.ts new file mode 100644 index 000000000000..35e134ef0e08 --- /dev/null +++ b/app/client/packages/mcp/src/builder/layout.ts @@ -0,0 +1,261 @@ +import { randomUUID } from "node:crypto"; + +// Appsmith canvas grid geometry (from the client's GridDefaults). +export const GRID_COLUMNS = 64; +export const ROW_HEIGHT = 10; +export const ROOT_WIDGET_ID = "0"; +export const ROOT_WIDGET_NAME = "MainContainer"; +const ROW_GAP = 1; +// Recomputed by the layout engine on render; sensible seed values keep the imported DSL well-formed. +const DEFAULT_PARENT_COLUMN_SPACE = 10; + +export type WidgetNode = Record & { + widgetId: string; + widgetName: string; + type: string; + topRow: number; + bottomRow: number; + leftColumn: number; + rightColumn: number; + children?: WidgetNode[]; +}; + +export type IdGenerator = () => string; + +export function randomIdGenerator(): IdGenerator { + return () => randomUUID().replace(/-/g, "").slice(0, 10); +} + +// Deterministic ids for tests/snapshots. +export function sequentialIdGenerator(prefix = "w"): IdGenerator { + let n = 0; + + return () => `${prefix}${(n += 1)}`; +} + +// Allocates unique widget names within a page (Table, Table1, Table2, ...), seeded by names already present in the +// page so edits never collide with existing widgets. +export class NameAllocator { + private readonly used = new Set(); + + constructor(existing: Iterable = []) { + for (const name of existing) this.used.add(name); + } + + allocate(base: string): string { + if (!this.used.has(base)) { + this.used.add(base); + + return base; + } + + let index = 1; + + while (this.used.has(`${base}${index}`)) index += 1; + + const name = `${base}${index}`; + + this.used.add(name); + + return name; + } +} + +export interface StampParams { + widgetId: string; + widgetName: string; + appsmithType: string; + version: number; + parentId: string; + topRow: number; + leftColumn: number; + columns: number; + rows: number; + props: Record; + children?: WidgetNode[]; +} + +export function stampWidget(params: StampParams): WidgetNode { + const bottomRow = params.topRow + params.rows; + const rightColumn = params.leftColumn + params.columns; + + return { + ...params.props, + widgetId: params.widgetId, + widgetName: params.widgetName, + type: params.appsmithType, + version: params.version, + parentId: params.parentId, + renderMode: "CANVAS", + isVisible: true, + isLoading: false, + topRow: params.topRow, + bottomRow, + leftColumn: params.leftColumn, + rightColumn, + mobileTopRow: params.topRow, + mobileBottomRow: bottomRow, + mobileLeftColumn: params.leftColumn, + mobileRightColumn: rightColumn, + parentColumnSpace: DEFAULT_PARENT_COLUMN_SPACE, + parentRowSpace: ROW_HEIGHT, + ...(params.children !== undefined ? { children: params.children } : {}), + }; +} + +export function createRootCanvas(): WidgetNode { + return { + widgetName: ROOT_WIDGET_NAME, + type: "CANVAS_WIDGET", + version: 4, + widgetId: ROOT_WIDGET_ID, + backgroundColor: "none", + snapColumns: GRID_COLUMNS, + parentColumnSpace: 1, + parentRowSpace: 1, + leftColumn: 0, + rightColumn: GRID_COLUMNS * DEFAULT_PARENT_COLUMN_SPACE, + topRow: 0, + bottomRow: 380, + containerStyle: "none", + detachFromLayout: true, + canExtend: true, + minHeight: 380, + dynamicBindingPathList: [], + dynamicTriggerPathList: [], + children: [], + }; +} + +export function createInnerCanvas( + widgetId: string, + widgetName: string, + parentId: string, + columns: number, + rows: number, + children: WidgetNode[], +): WidgetNode { + return { + widgetName, + type: "CANVAS_WIDGET", + version: 1, + widgetId, + parentId, + renderMode: "CANVAS", + isVisible: true, + detachFromLayout: true, + canExtend: false, + topRow: 0, + bottomRow: rows, + leftColumn: 0, + rightColumn: columns, + parentColumnSpace: 1, + parentRowSpace: 1, + dynamicBindingPathList: [], + dynamicTriggerPathList: [], + children, + }; +} + +// The next free row on a canvas = the largest bottomRow among its children (0 when empty). +export function nextFreeRow(children: WidgetNode[]): number { + return children.reduce( + (max, child) => + Math.max(max, typeof child.bottomRow === "number" ? child.bottomRow : 0), + 0, + ); +} + +export interface PlacementResult { + // The canvas (root or a container's inner canvas) to append into. + canvas: WidgetNode; + // The starting topRow for the appended widget(s). + topRow: number; + // Human-readable note about how placement was resolved (reported back to the caller). + note?: string; +} + +function findWidgetNamed( + node: WidgetNode, + name: string, +): WidgetNode | undefined { + if (node.widgetName === name) return node; + + for (const child of node.children ?? []) { + const found = findWidgetNamed(child, name); + + if (found) return found; + } + + return undefined; +} + +function findParentCanvas( + root: WidgetNode, + target: WidgetNode, +): WidgetNode | undefined { + const stack: WidgetNode[] = [root]; + + while (stack.length) { + const node = stack.pop()!; + + for (const child of node.children ?? []) { + if (child.widgetId === target.widgetId) return node; + + stack.push(child); + } + } + + return undefined; +} + +// Resolve where to append, honoring best-effort placement. Falls back to "append to the page canvas" and records a +// note when a target can't be honored. +export function resolvePlacement( + root: WidgetNode, + placement: { after?: string; inside?: string } | undefined, +): PlacementResult { + if (placement?.inside) { + const container = findWidgetNamed(root, placement.inside); + const innerCanvas = container?.children?.find( + (child) => child.type === "CANVAS_WIDGET", + ); + + if (innerCanvas) { + return { + canvas: innerCanvas, + topRow: nextFreeRow(innerCanvas.children ?? []), + }; + } + + return { + canvas: root, + topRow: nextFreeRow(root.children ?? []), + note: `container "${placement.inside}" not found; appended to the page instead`, + }; + } + + if (placement?.after) { + const target = findWidgetNamed(root, placement.after); + + if (target) { + const parent = findParentCanvas(root, target) ?? root; + + return { + canvas: parent, + topRow: + typeof target.bottomRow === "number" + ? target.bottomRow + ROW_GAP + : nextFreeRow(parent.children ?? []), + }; + } + + return { + canvas: root, + topRow: nextFreeRow(root.children ?? []), + note: `widget "${placement.after}" not found; appended to the page instead`, + }; + } + + return { canvas: root, topRow: nextFreeRow(root.children ?? []) }; +} diff --git a/app/client/packages/mcp/src/builder/presets.ts b/app/client/packages/mcp/src/builder/presets.ts new file mode 100644 index 000000000000..b498eeb074bb --- /dev/null +++ b/app/client/packages/mcp/src/builder/presets.ts @@ -0,0 +1,101 @@ +import type { PageSpec } from "./schema.js"; + +// Ready-made page specs the caller's agent can use as-is or adapt. Presets encode "how Appsmith places things" for +// common shapes so the agent doesn't have to compose them from scratch. + +export interface Preset { + name: string; + description: string; + spec: PageSpec; +} + +export const PRESETS: Record = { + form: { + name: "form", + description: + "A titled form: heading, a few labelled inputs, and a submit button. Rename/extend the inputs to fit your data.", + spec: { + name: "Form", + widgets: [ + { type: "text", name: "Title", text: "New record" }, + { type: "input", name: "Name", label: "Name" }, + { type: "input", name: "Email", label: "Email", inputType: "EMAIL" }, + { type: "button", name: "Submit", text: "Submit" }, + ], + }, + }, + + "table-detail": { + name: "table-detail", + description: + "A table above a details card. Bind the table's `data` to a query, and the inputs in the card to the selected row.", + spec: { + name: "Records", + widgets: [ + { type: "table", name: "RecordsTable", data: "" }, + { + type: "container", + name: "DetailsCard", + children: [ + { type: "text", name: "DetailsTitle", text: "Details" }, + { type: "input", name: "DetailName", label: "Name" }, + { + type: "input", + name: "DetailEmail", + label: "Email", + inputType: "EMAIL", + }, + ], + }, + ], + }, + }, + + "card-grid": { + name: "card-grid", + description: + "A single card (image + title + action) meant to be duplicated across a grid/list. Bind the image and text to your data, then repeat per row.", + spec: { + name: "Gallery", + widgets: [ + { + type: "container", + name: "Card", + children: [ + { type: "image", name: "CardImage", image: "" }, + { type: "text", name: "CardTitle", text: "Title" }, + { type: "button", name: "CardAction", text: "Open" }, + ], + }, + ], + }, + }, + + crud: { + name: "crud", + description: + "A basic create/read/update layout: a table of records plus an edit form with save/delete actions.", + spec: { + name: "Manage", + widgets: [ + { type: "table", name: "Records", data: "" }, + { type: "input", name: "EditName", label: "Name" }, + { + type: "input", + name: "EditEmail", + label: "Email", + inputType: "EMAIL", + }, + { type: "button", name: "Save", text: "Save" }, + { type: "button", name: "Delete", text: "Delete" }, + ], + }, + }, +}; + +export function listPresets() { + return Object.values(PRESETS).map(({ description, name }) => ({ + name, + description, + })); +} diff --git a/app/client/packages/mcp/src/builder/schema.ts b/app/client/packages/mcp/src/builder/schema.ts new file mode 100644 index 000000000000..ebe9843ab140 --- /dev/null +++ b/app/client/packages/mcp/src/builder/schema.ts @@ -0,0 +1,167 @@ +import { z } from "zod"; + +// The agent-facing "page spec" — a high-level description of what to build. The compiler turns this into a valid +// Appsmith import artifact; the agent never authors raw widget DSL. + +export const WIDGET_TYPES = [ + "text", + "input", + "select", + "button", + "image", + "table", + "container", +] as const; + +export type WidgetType = (typeof WIDGET_TYPES)[number]; + +// Best-effort placement. Omitted → append below existing content. `after` → below a named widget. `inside` → into a +// named container's inner canvas. A miss falls back to append and is reported. +export const placementSchema = z + .object({ + after: z.string().min(1).optional(), + inside: z.string().min(1).optional(), + }) + .strict(); + +export type Placement = z.infer; + +const nameField = z + .string() + .trim() + .min(1) + .max(64) + .regex(/^[A-Za-z0-9_]+$/, "name must be alphanumeric/underscore") + .optional(); + +const selectOption = z.object({ + label: z.string().min(1), + value: z.union([z.string(), z.number()]), +}); + +// Recursive: a container can hold child widgets. z.lazy handles the self-reference. +export const widgetSpecSchema: z.ZodType = z.lazy(() => + z.discriminatedUnion("type", [ + z + .object({ + type: z.literal("text"), + name: nameField, + text: z.string().max(10000).optional(), + placement: placementSchema.optional(), + }) + .strict(), + z + .object({ + type: z.literal("input"), + name: nameField, + label: z.string().max(200).optional(), + inputType: z.enum(["TEXT", "NUMBER", "EMAIL", "PASSWORD"]).optional(), + placement: placementSchema.optional(), + }) + .strict(), + z + .object({ + type: z.literal("select"), + name: nameField, + label: z.string().max(200).optional(), + options: z.array(selectOption).max(200).optional(), + placement: placementSchema.optional(), + }) + .strict(), + z + .object({ + type: z.literal("button"), + name: nameField, + text: z.string().max(200).optional(), + placement: placementSchema.optional(), + }) + .strict(), + z + .object({ + type: z.literal("image"), + name: nameField, + image: z.string().max(2000).optional(), + placement: placementSchema.optional(), + }) + .strict(), + z + .object({ + type: z.literal("table"), + name: nameField, + data: z.string().max(20000).optional(), + placement: placementSchema.optional(), + }) + .strict(), + z + .object({ + type: z.literal("container"), + name: nameField, + children: z.array(widgetSpecSchema).max(50).optional(), + placement: placementSchema.optional(), + }) + .strict(), + ]), +); + +export interface PlacementSpec { + after?: string; + inside?: string; +} + +export type WidgetSpec = + | { type: "text"; name?: string; text?: string; placement?: PlacementSpec } + | { + type: "input"; + name?: string; + label?: string; + inputType?: "TEXT" | "NUMBER" | "EMAIL" | "PASSWORD"; + placement?: PlacementSpec; + } + | { + type: "select"; + name?: string; + label?: string; + options?: { label: string; value: string | number }[]; + placement?: PlacementSpec; + } + | { type: "button"; name?: string; text?: string; placement?: PlacementSpec } + | { type: "image"; name?: string; image?: string; placement?: PlacementSpec } + | { type: "table"; name?: string; data?: string; placement?: PlacementSpec } + | { + type: "container"; + name?: string; + children?: WidgetSpec[]; + placement?: PlacementSpec; + }; + +export const pageSpecSchema = z + .object({ + name: z + .string() + .trim() + .min(1) + .max(64) + .regex(/^[A-Za-z0-9_ ]+$/, "page name must be alphanumeric"), + widgets: z.array(widgetSpecSchema).min(1).max(100), + }) + .strict(); + +export type PageSpec = z.infer; + +export const appSpecSchema = z + .object({ + name: z.string().trim().min(1).max(99), + pages: z.array(pageSpecSchema).min(1).max(20), + }) + .strict(); + +export type AppSpec = z.infer; + +// The edit contract: add widgets to an existing page. +export const editSpecSchema = z + .object({ + add: z.array(widgetSpecSchema).min(1).max(50), + }) + .strict(); + +export type EditSpec = z.infer; diff --git a/app/client/packages/mcp/src/builder/templates.ts b/app/client/packages/mcp/src/builder/templates.ts new file mode 100644 index 000000000000..b25f44900904 --- /dev/null +++ b/app/client/packages/mcp/src/builder/templates.ts @@ -0,0 +1,231 @@ +import type { WidgetSpec, WidgetType } from "./schema.js"; + +// Curated widget templates. Each maps a high-level spec widget to a valid Appsmith DSL node: the Appsmith widget +// `type`, its DSL `version`, a default grid footprint (in 64-column grid units), the base default props, and a +// per-widget builder that folds in the spec-provided values (text, label, data, ...). +// +// Defaults are a trimmed subset of the client's getDefaults() blobs — enough structural props to render. They are +// intentionally small; the fixture drift test guards against the import schema moving underneath us. + +export interface WidgetFootprint { + columns: number; // width in 64-col grid units + rows: number; // height in grid rows (each row = 10px) +} + +export interface BuiltWidget { + props: Record; + footprint: WidgetFootprint; + // Present only for container: the child specs to compile into the inner canvas. + children?: WidgetSpec[]; +} + +export interface WidgetTemplate { + appsmithType: string; + version: number; + footprint: WidgetFootprint; + build: (spec: WidgetSpec) => BuiltWidget; +} + +function bindingPaths(...keys: string[]) { + return keys.map((key) => ({ key })); +} + +export const WIDGET_TEMPLATES: Record = { + text: { + appsmithType: "TEXT_WIDGET", + version: 1, + footprint: { columns: 24, rows: 4 }, + build: (spec) => { + const text = spec.type === "text" ? spec.text ?? "Text" : "Text"; + + return { + footprint: { columns: 24, rows: 4 }, + props: { + text, + fontSize: "1rem", + fontStyle: "BOLD", + textAlign: "LEFT", + textColor: "#231F20", + shouldTruncate: false, + overflow: "NONE", + animateLoading: true, + responsiveBehavior: "fill", + }, + }; + }, + }, + + input: { + appsmithType: "INPUT_WIDGET_V2", + version: 2, + footprint: { columns: 24, rows: 7 }, + build: (spec) => { + const label = spec.type === "input" ? spec.label ?? "Label" : "Label"; + const inputType = + spec.type === "input" ? spec.inputType ?? "TEXT" : "TEXT"; + + return { + footprint: { columns: 24, rows: 7 }, + props: { + label, + inputType, + labelPosition: "Top", + labelAlignment: "left", + labelTextSize: "0.875rem", + labelWidth: 5, + defaultText: "", + isRequired: false, + isDisabled: false, + resetOnSubmit: true, + showStepArrows: false, + animateLoading: true, + responsiveBehavior: "fill", + }, + }; + }, + }, + + select: { + appsmithType: "SELECT_WIDGET", + version: 1, + footprint: { columns: 24, rows: 7 }, + build: (spec) => { + const label = spec.type === "select" ? spec.label ?? "Label" : "Label"; + const options = + spec.type === "select" && spec.options + ? spec.options + : [ + { label: "Option 1", value: "1" }, + { label: "Option 2", value: "2" }, + ]; + + return { + footprint: { columns: 24, rows: 7 }, + props: { + label, + options, + labelPosition: "Top", + labelAlignment: "left", + labelTextSize: "0.875rem", + isRequired: false, + isDisabled: false, + isFilterable: true, + serverSideFiltering: false, + animateLoading: true, + responsiveBehavior: "fill", + }, + }; + }, + }, + + button: { + appsmithType: "BUTTON_WIDGET", + version: 1, + footprint: { columns: 16, rows: 4 }, + build: (spec) => { + const text = spec.type === "button" ? spec.text ?? "Submit" : "Submit"; + + return { + footprint: { columns: 16, rows: 4 }, + props: { + text, + buttonVariant: "PRIMARY", + placement: "CENTER", + isDisabled: false, + isDefaultClickDisabled: true, + recaptchaType: "V3", + animateLoading: true, + responsiveBehavior: "hug", + }, + }; + }, + }, + + image: { + appsmithType: "IMAGE_WIDGET", + version: 1, + footprint: { columns: 16, rows: 24 }, + build: (spec) => { + const image = spec.type === "image" ? spec.image ?? "" : ""; + + return { + footprint: { columns: 16, rows: 24 }, + props: { + image, + defaultImage: "https://assets.appsmith.com/widgets/default.png", + imageShape: "RECTANGLE", + maxZoomLevel: 1, + objectFit: "cover", + enableRotation: false, + enableDownload: false, + animateLoading: true, + }, + }; + }, + }, + + table: { + appsmithType: "TABLE_WIDGET_V2", + version: 2, + footprint: { columns: 40, rows: 28 }, + build: (spec) => { + const data = spec.type === "table" ? spec.data ?? "" : ""; + + return { + footprint: { columns: 40, rows: 28 }, + props: { + tableData: data, + primaryColumns: {}, + columnOrder: [], + columnWidthMap: {}, + label: "Data", + searchKey: "", + defaultSelectedRowIndex: 0, + defaultSelectedRowIndices: [0], + textSize: "0.875rem", + horizontalAlignment: "LEFT", + verticalAlignment: "CENTER", + totalRecordsCount: 0, + defaultPageSize: 0, + borderColor: "#E0DEDE", + borderWidth: "1", + enableClientSideSearch: true, + isVisibleSearch: true, + isVisibleFilters: false, + isVisibleDownload: true, + isVisiblePagination: true, + isSortable: true, + delimiter: ",", + inlineEditingSaveOption: "ROW_LEVEL", + animateLoading: true, + responsiveBehavior: "fill", + dynamicBindingPathList: data ? bindingPaths("tableData") : [], + dynamicPropertyPathList: [], + }, + }; + }, + }, + + container: { + appsmithType: "CONTAINER_WIDGET", + version: 1, + footprint: { columns: 40, rows: 30 }, + build: (spec) => { + const children = spec.type === "container" ? spec.children ?? [] : []; + + return { + footprint: { columns: 40, rows: 30 }, + children, + props: { + backgroundColor: "#FFFFFF", + containerStyle: "card", + borderColor: "#E0DEDE", + borderWidth: "1", + boxShadow: "NONE", + animateLoading: true, + responsiveBehavior: "fill", + }, + }; + }, + }, +}; diff --git a/app/client/packages/mcp/src/server.ts b/app/client/packages/mcp/src/server.ts index 92b5ef2ce858..ec8de8de5c00 100644 --- a/app/client/packages/mcp/src/server.ts +++ b/app/client/packages/mcp/src/server.ts @@ -5,13 +5,17 @@ const apiBaseUrl = process.env.APPSMITH_API_BASE_URL ?? "http://127.0.0.1:8080"; const httpServer = createMcpHttpServer(apiBaseUrl); -function reportProcessFailure(kind: string) { - process.stderr.write(`Appsmith MCP ${kind}\n`); - process.exitCode = 1; -} +// A truly uncaught exception leaves the process in an undefined state — exit so supervisord restarts it cleanly. +process.once("uncaughtException", () => { + process.stderr.write("Appsmith MCP uncaught exception\n"); + process.exit(1); +}); -process.once("uncaughtException", () => reportProcessFailure("process failure")); -process.once("unhandledRejection", () => reportProcessFailure("process rejection")); +// An unhandled rejection (e.g. a stray transport cleanup) must NOT hard-exit: that would let a single bad request +// crash-loop the whole service. Log and keep serving. +process.on("unhandledRejection", () => { + process.stderr.write("Appsmith MCP unhandled rejection\n"); +}); httpServer.listen(port, "127.0.0.1", () => { process.stderr.write(`Appsmith MCP listening on 127.0.0.1:${port}\n`); diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/converters/McpTokenAuthenticationConverter.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/converters/McpTokenAuthenticationConverter.java index bc9b1402b929..d92bf650c207 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/converters/McpTokenAuthenticationConverter.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/converters/McpTokenAuthenticationConverter.java @@ -8,6 +8,8 @@ import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; +import java.net.InetSocketAddress; + @Component public class McpTokenAuthenticationConverter implements ServerAuthenticationConverter { @@ -22,6 +24,17 @@ public Mono convert(ServerWebExchange exchange) { } String token = authorization.substring(BEARER_PREFIX.length()).trim(); - return token.startsWith(MCP_TOKEN_PREFIX) ? Mono.just(new McpTokenAuthentication(token)) : Mono.empty(); + return token.startsWith(MCP_TOKEN_PREFIX) + ? Mono.just(new McpTokenAuthentication(token, clientAddress(exchange))) + : Mono.empty(); + } + + private String clientAddress(ServerWebExchange exchange) { + InetSocketAddress remoteAddress = exchange.getRequest().getRemoteAddress(); + if (remoteAddress == null || remoteAddress.getAddress() == null) { + return "unknown"; + } + + return remoteAddress.getAddress().getHostAddress(); } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManager.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManager.java index 9c515154048b..a9add310ea85 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManager.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManager.java @@ -1,23 +1,30 @@ package com.appsmith.server.authentication.managers; import com.appsmith.server.authentication.tokens.McpTokenAuthentication; +import com.appsmith.server.constants.RateLimitConstants; +import com.appsmith.server.domains.User; +import com.appsmith.server.ratelimiting.RateLimitService; import com.appsmith.server.services.UserMcpTokenService; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; +import java.util.ArrayList; +import java.util.Collection; import java.util.List; -import java.util.Optional; @Component @RequiredArgsConstructor public class McpTokenAuthenticationManager implements ReactiveAuthenticationManager { private final UserMcpTokenService userMcpTokenService; + private final RateLimitService rateLimitService; @Override public Mono authenticate(Authentication authentication) { @@ -25,10 +32,41 @@ public Mono authenticate(Authentication authentication) { return Mono.empty(); } - return userMcpTokenService - .authenticate((String) authentication.getCredentials()) - .map(user -> (Authentication) UsernamePasswordAuthenticationToken.authenticated( - user, null, Optional.ofNullable(user.getAuthorities()).orElseGet(List::of))) - .switchIfEmpty(Mono.error(new BadCredentialsException("Invalid MCP token"))); + McpTokenAuthentication mcpTokenAuthentication = (McpTokenAuthentication) authentication; + + return rateLimitService + .isRateLimitExceeded( + RateLimitConstants.BUCKET_KEY_FOR_MCP_AUTHENTICATION, mcpTokenAuthentication.getClientAddress()) + .onErrorReturn(false) + .flatMap(rateLimitExceeded -> { + if (rateLimitExceeded) { + return invalidMcpToken(); + } + + return userMcpTokenService + .authenticate((String) authentication.getCredentials()) + .map(user -> (Authentication) + UsernamePasswordAuthenticationToken.authenticated(user, null, mcpAuthorities(user))) + .switchIfEmpty(rateLimitService + .tryIncreaseCounter( + RateLimitConstants.BUCKET_KEY_FOR_MCP_AUTHENTICATION, + mcpTokenAuthentication.getClientAddress()) + .onErrorResume(error -> Mono.empty()) + .then(invalidMcpToken())); + }); + } + + private Mono invalidMcpToken() { + return Mono.error(new BadCredentialsException("Invalid MCP token")); + } + + private static List mcpAuthorities(User user) { + List authorities = new ArrayList<>(); + Collection existing = user.getAuthorities(); + if (existing != null) { + authorities.addAll(existing); + } + authorities.add(new SimpleGrantedAuthority(McpTokenAuthentication.MCP_AUTHORITY)); + return authorities; } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/tokens/McpTokenAuthentication.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/tokens/McpTokenAuthentication.java index fb03895c01aa..5bc09dc95dfa 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/tokens/McpTokenAuthentication.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/tokens/McpTokenAuthentication.java @@ -4,14 +4,28 @@ public class McpTokenAuthentication extends AbstractAuthenticationToken { + // Marker authority stamped on the authenticated principal so downstream code can tell an MCP-token-authenticated + // request from a normal session (used to forbid an MCP token from minting more MCP tokens). + public static final String MCP_AUTHORITY = "MCP_TOKEN"; + private final String token; + private final String clientAddress; public McpTokenAuthentication(String token) { + this(token, "unknown"); + } + + public McpTokenAuthentication(String token, String clientAddress) { super(null); this.token = token; + this.clientAddress = clientAddress; setAuthenticated(false); } + public String getClientAddress() { + return clientAddress; + } + @Override public Object getCredentials() { return token; diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/RateLimitConstants.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/RateLimitConstants.java index b054058ad390..875967f3b5c4 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/RateLimitConstants.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/RateLimitConstants.java @@ -5,4 +5,5 @@ public class RateLimitConstants { "Your account is suspended for 24 hours. Please reset your password to continue"; public static final String BUCKET_KEY_FOR_LOGIN_API = "login"; public static final String BUCKET_KEY_FOR_TEST_DATASOURCE_API = "test_datasource_or_execute_query"; + public static final String BUCKET_KEY_FOR_MCP_AUTHENTICATION = "mcp_authentication"; } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/McpTokenControllerCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/McpTokenControllerCE.java index 4ab1f6fd2ed6..050ea141e47c 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/McpTokenControllerCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/McpTokenControllerCE.java @@ -1,12 +1,17 @@ package com.appsmith.server.controllers.ce; +import com.appsmith.server.authentication.tokens.McpTokenAuthentication; import com.appsmith.server.domains.User; import com.appsmith.server.dtos.McpTokenResponseDTO; import com.appsmith.server.dtos.ResponseDTO; +import com.appsmith.server.exceptions.AppsmithError; +import com.appsmith.server.exceptions.AppsmithException; import com.appsmith.server.services.UserMcpTokenService; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -21,7 +26,24 @@ public class McpTokenControllerCE { @PostMapping public Mono> create(@AuthenticationPrincipal User user) { - return userMcpTokenService.create(user).map(token -> new ResponseDTO<>(HttpStatus.CREATED, token)); + // An MCP token must not be usable to mint more MCP tokens — that would let a leaked token persist past + // revocation. Token creation is allowed only from a normal (session) login. + return ReactiveSecurityContextHolder.getContext() + .map(context -> isMcpAuthenticated(context.getAuthentication())) + .defaultIfEmpty(false) + .flatMap(mcpAuthenticated -> { + if (mcpAuthenticated) { + return Mono.error(new AppsmithException(AppsmithError.UNAUTHORIZED_ACCESS)); + } + + return userMcpTokenService.create(user).map(token -> new ResponseDTO<>(HttpStatus.CREATED, token)); + }); + } + + private static boolean isMcpAuthenticated(Authentication authentication) { + return authentication != null + && authentication.getAuthorities().stream() + .anyMatch(authority -> McpTokenAuthentication.MCP_AUTHORITY.equals(authority.getAuthority())); } @GetMapping diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/ce/UserMcpTokenCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/ce/UserMcpTokenCE.java index ec73ec291a96..84d50473944f 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/ce/UserMcpTokenCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/ce/UserMcpTokenCE.java @@ -7,6 +7,8 @@ import lombok.experimental.FieldNameConstants; import org.springframework.data.mongodb.core.index.Indexed; +import java.time.Instant; + @Getter @Setter @ToString @@ -20,4 +22,7 @@ public class UserMcpTokenCE extends BaseDomain { @ToString.Exclude private String tokenHash; + + // Absolute expiry; a token stops authenticating after this instant. Bounds the blast radius of a leaked token. + private Instant expiresAt; } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/McpTokenResponseDTO.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/McpTokenResponseDTO.java index 43cd1d025c32..9325532a588a 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/McpTokenResponseDTO.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/McpTokenResponseDTO.java @@ -5,4 +5,4 @@ import java.time.Instant; @JsonInclude(JsonInclude.Include.NON_NULL) -public record McpTokenResponseDTO(String id, String token, Instant createdAt) {} +public record McpTokenResponseDTO(String id, String token, Instant createdAt, Instant expiresAt) {} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/ratelimiting/RateLimitConfig.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/ratelimiting/RateLimitConfig.java index 67df3de744a9..3c515dae72b9 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/ratelimiting/RateLimitConfig.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/ratelimiting/RateLimitConfig.java @@ -38,6 +38,9 @@ public RateLimitConfig(AbstractRedisClient redisClient) { apiConfigurationMap.put( RateLimitConstants.BUCKET_KEY_FOR_TEST_DATASOURCE_API, createBucketConfiguration(Duration.ofSeconds(5), 3)); + apiConfigurationMap.put( + RateLimitConstants.BUCKET_KEY_FOR_MCP_AUTHENTICATION, + createBucketConfiguration(Duration.ofMinutes(1), 5)); // Add more API configurations as needed } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/ratelimiting/ce/RateLimitServiceCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/ratelimiting/ce/RateLimitServiceCE.java index b0844f643f91..6f9817c189d0 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/ratelimiting/ce/RateLimitServiceCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/ratelimiting/ce/RateLimitServiceCE.java @@ -8,6 +8,8 @@ public interface RateLimitServiceCE { Mono tryIncreaseCounter(String apiIdentifier, String userIdentifier); + Mono isRateLimitExceeded(String apiIdentifier, String userIdentifier); + Mono resetCounter(String apiIdentifier, String userIdentifier); Mono blockEndpointForConnectionRequest( diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/ratelimiting/ce/RateLimitServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/ratelimiting/ce/RateLimitServiceCEImpl.java index 174a58ff7911..1e58b96a2734 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/ratelimiting/ce/RateLimitServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/ratelimiting/ce/RateLimitServiceCEImpl.java @@ -59,6 +59,18 @@ public Mono tryIncreaseCounter(String apiIdentifier, String userIdentif .subscribeOn(LoadShifter.elasticScheduler); } + @Override + public Mono isRateLimitExceeded(String apiIdentifier, String userIdentifier) { + return sanitizeInput(apiIdentifier, userIdentifier) + .map(isInputValid -> rateLimitConfig + .getOrCreateAPIUserSpecificBucket(apiIdentifier, userIdentifier) + .getAvailableTokens() + == 0) + // Since we are interacting with redis, we want to make sure that the operation is done on a separate + // thread pool + .subscribeOn(LoadShifter.elasticScheduler); + } + @Override public Mono resetCounter(String apiIdentifier, String userIdentifier) { diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserMcpTokenServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserMcpTokenServiceImpl.java index 1503f3e25c2f..53298d6a966f 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserMcpTokenServiceImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserMcpTokenServiceImpl.java @@ -3,16 +3,12 @@ import com.appsmith.server.repositories.UserMcpTokenRepository; import com.appsmith.server.repositories.UserRepository; import com.appsmith.server.services.ce.UserMcpTokenServiceCEImpl; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @Service public class UserMcpTokenServiceImpl extends UserMcpTokenServiceCEImpl implements UserMcpTokenService { - public UserMcpTokenServiceImpl( - UserMcpTokenRepository userMcpTokenRepository, - UserRepository userRepository, - PasswordEncoder passwordEncoder) { - super(userMcpTokenRepository, userRepository, passwordEncoder); + public UserMcpTokenServiceImpl(UserMcpTokenRepository userMcpTokenRepository, UserRepository userRepository) { + super(userMcpTokenRepository, userRepository); } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserMcpTokenServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserMcpTokenServiceCEImpl.java index cde79e2fefdc..32e979fbf20c 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserMcpTokenServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserMcpTokenServiceCEImpl.java @@ -7,7 +7,6 @@ import com.appsmith.server.exceptions.AppsmithException; import com.appsmith.server.repositories.UserMcpTokenRepository; import com.appsmith.server.repositories.UserRepository; -import org.springframework.security.crypto.password.PasswordEncoder; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -15,6 +14,8 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; import java.util.Base64; import java.util.UUID; @@ -23,19 +24,15 @@ public class UserMcpTokenServiceCEImpl implements UserMcpTokenServiceCE { private static final String TOKEN_HASH_ALGORITHM = "SHA-256"; private static final int MAX_ACTIVE_TOKENS_PER_USER = 10; private static final String TOKEN_PREFIX = "mcp_"; + private static final Duration TOKEN_TTL = Duration.ofDays(90); private static final SecureRandom SECURE_RANDOM = new SecureRandom(); private final UserMcpTokenRepository userMcpTokenRepository; private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; - public UserMcpTokenServiceCEImpl( - UserMcpTokenRepository userMcpTokenRepository, - UserRepository userRepository, - PasswordEncoder passwordEncoder) { + public UserMcpTokenServiceCEImpl(UserMcpTokenRepository userMcpTokenRepository, UserRepository userRepository) { this.userMcpTokenRepository = userMcpTokenRepository; this.userRepository = userRepository; - this.passwordEncoder = passwordEncoder; } @Override @@ -54,12 +51,16 @@ public Mono create(User user) { UserMcpToken userMcpToken = new UserMcpToken(); userMcpToken.setTokenId(tokenId); userMcpToken.setUserId(user.getId()); - userMcpToken.setTokenHash(passwordEncoder.encode(hashToken(token))); + userMcpToken.setTokenHash(hashToken(token)); + userMcpToken.setExpiresAt(Instant.now().plus(TOKEN_TTL)); return userMcpTokenRepository .save(userMcpToken) - .map(savedToken -> - new McpTokenResponseDTO(savedToken.getTokenId(), token, savedToken.getCreatedAt())); + .map(savedToken -> new McpTokenResponseDTO( + savedToken.getTokenId(), + token, + savedToken.getCreatedAt(), + savedToken.getExpiresAt())); }); } @@ -67,7 +68,8 @@ public Mono create(User user) { public Flux list(User user) { return userMcpTokenRepository .findAllByUserIdAndDeletedAtIsNull(user.getId()) - .map(token -> new McpTokenResponseDTO(token.getTokenId(), null, token.getCreatedAt())); + .map(token -> + new McpTokenResponseDTO(token.getTokenId(), null, token.getCreatedAt(), token.getExpiresAt())); } @Override @@ -87,7 +89,8 @@ public Mono authenticate(String token) { return userMcpTokenRepository .findByTokenIdAndDeletedAtIsNull(tokenId) - .filter(storedToken -> passwordEncoder.matches(hashToken(token), storedToken.getTokenHash())) + .filter(storedToken -> matchesStoredHash(token, storedToken.getTokenHash())) + .filter(UserMcpTokenServiceCEImpl::isNotExpired) .flatMap(storedToken -> userRepository.findById(storedToken.getUserId())) .filter(user -> Boolean.TRUE.equals(user.getIsEnabled())); } @@ -109,8 +112,25 @@ private String hashToken(String token) { } } + /** + * MCP tokens carry a 256-bit random secret, so brute force is infeasible and a key-stretching hash (BCrypt) is + * both redundant and a CPU-exhaustion DoS vector on every authenticated request. Compare the stored SHA-256 hash + * with a constant-time equality check instead. + */ + private boolean matchesStoredHash(String token, String storedHash) { + if (storedHash == null) { + return false; + } + return MessageDigest.isEqual( + hashToken(token).getBytes(StandardCharsets.UTF_8), storedHash.getBytes(StandardCharsets.UTF_8)); + } + + private static boolean isNotExpired(UserMcpToken storedToken) { + return storedToken.getExpiresAt() == null || storedToken.getExpiresAt().isAfter(Instant.now()); + } + private String extractTokenId(String token) { - if (!token.startsWith(TOKEN_PREFIX)) { + if (token == null || !token.startsWith(TOKEN_PREFIX)) { return null; } diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/McpTokenAuthenticationWebFilterTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/McpTokenAuthenticationWebFilterTest.java index d1f0e5d6d221..ec7d19b0aa82 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/McpTokenAuthenticationWebFilterTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/McpTokenAuthenticationWebFilterTest.java @@ -2,6 +2,7 @@ import com.appsmith.server.authentication.converters.McpTokenAuthenticationConverter; import com.appsmith.server.authentication.managers.McpTokenAuthenticationManager; +import com.appsmith.server.ratelimiting.RateLimitService; import com.appsmith.server.services.UserMcpTokenService; import org.junit.jupiter.api.Test; import org.springframework.http.HttpStatus; @@ -11,6 +12,7 @@ import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Mono; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -42,7 +44,11 @@ void nonMcpBearer_passesThroughToOtherAuthenticationMechanisms() { } private WebTestClient authenticationClient(UserMcpTokenService service) { - AuthenticationWebFilter filter = new AuthenticationWebFilter(new McpTokenAuthenticationManager(service)); + RateLimitService rateLimitService = mock(RateLimitService.class); + when(rateLimitService.isRateLimitExceeded(anyString(), anyString())).thenReturn(Mono.just(false)); + when(rateLimitService.tryIncreaseCounter(anyString(), anyString())).thenReturn(Mono.just(true)); + AuthenticationWebFilter filter = + new AuthenticationWebFilter(new McpTokenAuthenticationManager(service, rateLimitService)); filter.setServerAuthenticationConverter(new McpTokenAuthenticationConverter()); filter.setAuthenticationFailureHandler(new ServerAuthenticationEntryPointFailureHandler( new HttpStatusServerEntryPoint(HttpStatus.UNAUTHORIZED))); diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/converters/McpTokenAuthenticationConverterTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/converters/McpTokenAuthenticationConverterTest.java index 26c7beb0cc70..ed1c66d64304 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/converters/McpTokenAuthenticationConverterTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/converters/McpTokenAuthenticationConverterTest.java @@ -7,6 +7,8 @@ import org.springframework.mock.web.server.MockServerWebExchange; import reactor.test.StepVerifier; +import java.net.InetSocketAddress; + import static org.assertj.core.api.Assertions.assertThat; class McpTokenAuthenticationConverterTest { @@ -21,6 +23,8 @@ void convert_identifiesMcpBearerToken() { .assertNext(authentication -> { assertThat(authentication).isInstanceOf(McpTokenAuthentication.class); assertThat(authentication.getCredentials()).isEqualTo("mcp_token"); + assertThat(((McpTokenAuthentication) authentication).getClientAddress()) + .isEqualTo("192.0.2.1"); }) .verifyComplete(); } @@ -32,7 +36,8 @@ void convert_preservesNonMcpBearerForOtherAuthenticationMechanisms() { } private MockServerWebExchange exchangeWithBearer(String token) { - return MockServerWebExchange.from( - MockServerHttpRequest.get("/api/v1/users/me").header(HttpHeaders.AUTHORIZATION, "Bearer " + token)); + return MockServerWebExchange.from(MockServerHttpRequest.get("/api/v1/users/me") + .remoteAddress(new InetSocketAddress("192.0.2.1", 8080)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)); } } diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManagerTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManagerTest.java index 97af60a8dac7..27b27365ce59 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManagerTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManagerTest.java @@ -1,7 +1,9 @@ package com.appsmith.server.authentication.managers; import com.appsmith.server.authentication.tokens.McpTokenAuthentication; +import com.appsmith.server.constants.RateLimitConstants; import com.appsmith.server.domains.User; +import com.appsmith.server.ratelimiting.RateLimitService; import com.appsmith.server.services.UserMcpTokenService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -9,6 +11,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import reactor.core.publisher.Mono; @@ -17,7 +20,10 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -26,11 +32,14 @@ class McpTokenAuthenticationManagerTest { @Mock private UserMcpTokenService userMcpTokenService; + @Mock + private RateLimitService rateLimitService; + @Test void authenticate_usesValidatedTokenOwnerAsPrincipal() { User user = mock(User.class); when(user.getAuthorities()).thenReturn(List.of(new SimpleGrantedAuthority("test-authority"))); - McpTokenAuthenticationManager manager = new McpTokenAuthenticationManager(userMcpTokenService); + McpTokenAuthenticationManager manager = manager(); when(userMcpTokenService.authenticate("mcp_token")).thenReturn(Mono.just(user)); StepVerifier.create(manager.authenticate(new McpTokenAuthentication("mcp_token"))) @@ -40,18 +49,56 @@ void authenticate_usesValidatedTokenOwnerAsPrincipal() { assertThat(authentication.getPrincipal()).isSameAs(user); assertThat(authentication.getAuthorities()) .extracting(GrantedAuthority::getAuthority) - .containsExactly("test-authority"); + .containsExactly("test-authority", McpTokenAuthentication.MCP_AUTHORITY); }) .verifyComplete(); + + verify(rateLimitService, never()) + .tryIncreaseCounter(eq(RateLimitConstants.BUCKET_KEY_FOR_MCP_AUTHENTICATION), eq("unknown")); } @Test void authenticate_rejectsInvalidMcpToken() { - McpTokenAuthenticationManager manager = new McpTokenAuthenticationManager(userMcpTokenService); + McpTokenAuthenticationManager manager = manager(); when(userMcpTokenService.authenticate("mcp_token")).thenReturn(Mono.empty()); + when(rateLimitService.tryIncreaseCounter(RateLimitConstants.BUCKET_KEY_FOR_MCP_AUTHENTICATION, "unknown")) + .thenReturn(Mono.just(true)); StepVerifier.create(manager.authenticate(new McpTokenAuthentication("mcp_token"))) .expectError(BadCredentialsException.class) .verify(); + + verify(rateLimitService) + .tryIncreaseCounter(eq(RateLimitConstants.BUCKET_KEY_FOR_MCP_AUTHENTICATION), eq("unknown")); + } + + @Test + void authenticate_rejectsRateLimitedSourceWithoutTokenLookup() { + McpTokenAuthenticationManager manager = manager(); + when(rateLimitService.isRateLimitExceeded(RateLimitConstants.BUCKET_KEY_FOR_MCP_AUTHENTICATION, "192.0.2.1")) + .thenReturn(Mono.just(true)); + + StepVerifier.create(manager.authenticate(new McpTokenAuthentication("mcp_token", "192.0.2.1"))) + .expectError(BadCredentialsException.class) + .verify(); + + verify(userMcpTokenService, never()).authenticate("mcp_token"); + } + + @Test + void authenticate_ignoresNonMcpAuthentication() { + McpTokenAuthenticationManager manager = manager(); + Authentication authentication = mock(Authentication.class); + + StepVerifier.create(manager.authenticate(authentication)).verifyComplete(); + + verify(rateLimitService, never()) + .isRateLimitExceeded(RateLimitConstants.BUCKET_KEY_FOR_MCP_AUTHENTICATION, "unknown"); + } + + private McpTokenAuthenticationManager manager() { + when(rateLimitService.isRateLimitExceeded(RateLimitConstants.BUCKET_KEY_FOR_MCP_AUTHENTICATION, "unknown")) + .thenReturn(Mono.just(false)); + return new McpTokenAuthenticationManager(userMcpTokenService, rateLimitService); } } diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserMcpTokenServiceImplTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserMcpTokenServiceImplTest.java index 47de30c1bffe..5ab2ab01da69 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserMcpTokenServiceImplTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserMcpTokenServiceImplTest.java @@ -12,10 +12,15 @@ import org.mockito.Captor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.Base64; + import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -40,7 +45,7 @@ class UserMcpTokenServiceImplTest { @BeforeEach void setUp() { - service = new UserMcpTokenServiceImpl(userMcpTokenRepository, userRepository, new BCryptPasswordEncoder()); + service = new UserMcpTokenServiceImpl(userMcpTokenRepository, userRepository); user = new User(); user.setId("user-id"); user.setIsEnabled(true); @@ -86,6 +91,47 @@ void authenticate_matchesStoredHashAndReturnsOwner() { .verifyComplete(); } + // Regression for the BCrypt CPU-exhaustion DoS: the token secret is high-entropy, so it must be stored as a + // plain fast SHA-256 hash (deterministic, no key stretching), not a BCrypt hash. Fails on the unpatched code, + // which stored passwordEncoder.encode(...) (a non-deterministic "$2a$"-prefixed BCrypt hash). + @Test + void create_storesFastSha256HashNotBcrypt() { + McpTokenResponseDTO generated = createToken(); + + assertThat(persistedToken.getTokenHash()).doesNotStartWith("$2"); + assertThat(persistedToken.getTokenHash()).isEqualTo(sha256Base64Url(generated.token())); + } + + // The exact DoS proof-of-concept payload — a valid tokenId with a wrong secret — must be rejected via the fast + // constant-time comparison, without invoking any key-stretching hash. + @Test + void authenticate_rejectsValidTokenIdWithWrongSecret() { + McpTokenResponseDTO generated = createToken(); + when(userMcpTokenRepository.findByTokenIdAndDeletedAtIsNull(persistedToken.getTokenId())) + .thenReturn(Mono.just(persistedToken)); + + String tamperedToken = "mcp_" + generated.id() + ".wrong-secret"; + StepVerifier.create(service.authenticate(tamperedToken)).verifyComplete(); + } + + @Test + void create_setsFutureExpiry() { + createToken(); + + assertThat(persistedToken.getExpiresAt()).isNotNull(); + assertThat(persistedToken.getExpiresAt()).isAfter(Instant.now()); + } + + @Test + void authenticate_rejectsExpiredToken() { + McpTokenResponseDTO generated = createToken(); + persistedToken.setExpiresAt(Instant.now().minusSeconds(60)); + when(userMcpTokenRepository.findByTokenIdAndDeletedAtIsNull(persistedToken.getTokenId())) + .thenReturn(Mono.just(persistedToken)); + + StepVerifier.create(service.authenticate(generated.token())).verifyComplete(); + } + @Test void revoke_onlyArchivesTokenOwnedByCurrentUser() { UserMcpToken storedToken = new UserMcpToken(); @@ -112,4 +158,15 @@ private McpTokenResponseDTO createToken() { return service.create(user).block(); } + + private static String sha256Base64Url(String value) { + try { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString( + MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } } diff --git a/deploy/docker/fs/opt/appsmith/templates/supervisord/application_process/mcp.conf b/deploy/docker/fs/opt/appsmith/templates/supervisord/application_process/mcp.conf index a31d620ad9c9..7acf95a9c154 100644 --- a/deploy/docker/fs/opt/appsmith/templates/supervisord/application_process/mcp.conf +++ b/deploy/docker/fs/opt/appsmith/templates/supervisord/application_process/mcp.conf @@ -4,6 +4,8 @@ autorestart=true autostart=true priority=16 startretries=3 -startsecs=0 +# Require the process to stay up 5s to count as a successful start, so a crash-loop reaches FATAL +# (and stops) instead of restarting instantly and forever. +startsecs=5 stderr_logfile=%(ENV_APPSMITH_LOG_DIR)s/mcp/mcp.err.log stdout_logfile=%(ENV_APPSMITH_LOG_DIR)s/mcp/mcp.out.log diff --git a/docs/plans/2026-07-11-mcp-app-builder-design.md b/docs/plans/2026-07-11-mcp-app-builder-design.md new file mode 100644 index 000000000000..d4ad0eeae073 --- /dev/null +++ b/docs/plans/2026-07-11-mcp-app-builder-design.md @@ -0,0 +1,144 @@ +# MCP App-Builder — Design (v1) + +Date: 2026-07-11 +Status: Approved (80% solution — pragmatic, best-effort; not exhaustive) +Feature branch: `feat/15383/add_mcp_server` + +## Goal + +Let an MCP caller (Claude / ChatGPT — already an AI agent) build and edit Appsmith +applications by describing intent at a high level. Appsmith "knows how to place +things": the MCP layer auto-lays-out widgets and compiles to Appsmith's real import +artifact, which the existing ACL-enforced import APIs persist. + +## Decisions (locked) + +1. **High-level page spec**, not raw DSL. The agent describes widgets; the MCP layer + auto-places and compiles. +2. **Compiler lives in the Node MCP package** (`app/client/packages/mcp`), using a + curated per-widget template library. No server changes. Drift guarded by a fixture + test. +3. **The caller's agent drives a toolkit.** The MCP exposes presets, capability/schema + discovery, a validating compiler, and build/edit tools. The MCP does not re-run the + caller's reasoning. Appsmith AI-assist is an optional, flag-gated booster (deferred). + +## Target format (verified against the codebase) + +Import endpoint `POST /api/v1/applications/import/{workspaceId}` (multipart, Gson-parsed +into `ApplicationJson`). Minimal valid artifact: + +- `exportedApplication` — at least `{ name }` +- `pageList` — ≥1 `NewPage`, each with `unpublishedPage.layouts[0].dsl` = a root + `CANVAS_WIDGET` (`widgetId:"0"`, `widgetName:"MainContainer"`, `snapColumns:64`, + `parentColumnSpace:1`, `parentRowSpace:1`, `children:[]`) +- `actionList: []` and `datasourceList: []` (must be present, may be empty) +- `clientSchemaVersion: 2`, `serverSchemaVersion: 12` + +Widget DSL: start from the client's `getDefaults()` blob per type, then stamp +`widgetId`, unique `widgetName`, `type`, `version`, `parentId`, `renderMode:"CANVAS"`, +`isVisible:true`, `isLoading:false`, grid bounds `topRow/bottomRow/leftColumn/rightColumn` +(+ `mobile*` mirrors), `parentColumnSpace/parentRowSpace`. Drop `rows`/`columns`. +Grid: 64 columns, row height 10px. Containers nest an inner `CANVAS_WIDGET`. + +## Agent-facing page spec (Zod-validated) + +Build: +```json +{ "name": "Customers", + "widgets": [ { "type": "table", "data": "{{getCustomers.data}}" }, + { "type": "input", "label": "Email" } ] } +``` +Edit (append to an existing page): +```json +{ "add": [ { "type": "input", "label": "Phone", "placement": { "after": "Email" } }, + { "type": "text", "text": "Details", "placement": { "inside": "DetailsCard" } } ] } +``` + +Placement (best-effort, always reports what it did): omitted → append below the page +canvas's max `bottomRow`; `after:""` → below that widget; `inside:""` → +into that container's inner canvas. Miss → append + note. + +## Tool surface (added to `buildMcpServer`) + +| Tool | Purpose | +|---|---| +| `get_capabilities` | Catalog of supported widget types, spec fields, and presets. | +| `list_presets` / `get_preset` | Ready page-specs the agent adapts (form, table-detail, card-grid, crud). | +| `validate_app_spec` | Dry-run: compile + return structured errors, import nothing. | +| `build_application` | Compile pages → artifact → existing import API. | +| `edit_page` | Read current DSL → compile add-spec → append (best-effort placement) → write back. | + +Existing `list_workspaces`/`list_applications`/`get_application_context` remain. All +tools stay token-scoped; the import/layout APIs do all authorization. + +`edit_page` write path: prefer the layout-update API (read → merge-append → `PUT +/layouts`) for placement control — the DSL is compiler-produced (not agent-raw) and we +only append, which is materially safer than the removed `update_layout`. Verify +partial-import merge/placement first; use it instead if it places acceptably. Flag for +the review council. + +## Package layout + +``` +src/builder/ + schema.ts Zod schema: page spec + app spec + edit spec + placement + templates.ts curated getDefaults per widget (text,input,select,button,image,table,container) + layout.ts vertical auto-placement on the 64-col grid; placement resolution + compile.ts pageSpec[] -> import artifact; edit merge into existing DSL; depth/total-widget caps + presets.ts form, table-detail, card-grid, crud + capabilities.ts machine-readable widget/preset catalog +``` + +(As-built naming: `schema.ts` — `spec.ts` collides with jest's test glob; `validate_app_spec` — it validates a whole app spec.) + +## v1 widget set + +`text, input, select, button, image, table, container`. Covers the +"directory + grid + card + upload" example minus datasource wiring. + +## Testing + +- Unit: each widget template emits required structural fields; layout stacking; + placement resolution (append / after / inside / miss-fallback). +- Fixture drift test: compile a sample app; assert the artifact satisfies the import + contract (top-level fields, schema versions, root canvas shape). Catches DSL/schema + drift against the real import format. +- Honest limit: true render verification needs a running Appsmith instance and is out + of scope for unit tests; the fixture test approximates it against the documented + format. + +## Deferred (designed, not v1) + +- `suggest_spec` AI-assist booster: Appsmith ask-AI + our keys, gated by the ask-AI + feature flag. For heavier server-side generation when enabled. +- Datasource schema introspection: Supabase/Postgres table → infer columns → generate + query + bind a table (the "connects to my Supabase table" step). +- Modify/remove existing widgets by name (property patching); precise/pixel layout. + +## Risks + +- Widget-default drift vs client configs → keep the v1 set small; schema-version pins + tested against literals (2/12) so a compiler drift trips CI. (The real cross-check + against `JsonSchemaVersionsFallback.java` is a deferred hardening.) +- `edit_page` layout-write reintroduces a layout-mutation path → mitigated by + compiler-produced DSL + append-only + ACL'd endpoint + the same size/plain-JSON guard + the import path uses; reviewed by the council (APPROVE WITH RISKS). +- `edit_page` read-merge-write has **no optimistic concurrency** — a lost-update window + against a concurrent editor. Accepted for v1; append-only bounds the damage. +- DoS/amplification: per-array Zod caps are per-level only → compiler enforces an + aggregate `MAX_TOTAL_WIDGETS` (300) and `MAX_CONTAINER_DEPTH` (5), surfaced as clean + validation errors. +- The deferred **modify/remove existing widgets** feature would reintroduce full-DSL + mutation risk (property-patching foreign widgets) — it is NOT a small increment and + must be re-reviewed on its own. +- No true render verification locally (needs a running instance) → accepted for the 80% + bar; the contract/compile tests approximate it. + +## Council review (2026-07-11) + +security-reviewer, senior-architect, qa-engineer → **APPROVE WITH RISKS**. The +re-introduced layout-write path was cleared as safe (compiler-produced, append-only, +ACL-enforced). Hardening applied from the review: aggregate depth/widget caps, the +`edit_page` write-path size/plain-JSON guard, literal schema-version pins, and an +`edit_page` end-to-end test. Tracked (non-blocking): lost-update window, and the +modify/remove-widgets re-review requirement above. From de790f922d02e64af623fa9d365ae8d8d61ce76f Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Sat, 11 Jul 2026 15:18:41 -0400 Subject: [PATCH 03/81] =?UTF-8?q?fix(mcp):=20review=20round=20=E2=80=94=20?= =?UTF-8?q?flag-off=20healthcheck,=20token=20expiry=20UX,=20session-reserv?= =?UTF-8?q?ation=20leak?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From adversarial + UX + architect reviews: - healthcheck.sh: only health-check the mcp supervisord program when it exists, so a container with MCP disabled (the default) is not reported unhealthy fleet-wide. - Surface token expiry in the profile UI (list rows + created-token modal) — the server already returned expiresAt; the client was discarding it, so agents would die silently at 90 days. Fail closed on a null expiry server-side. - Release the MCP session reservation at onsessioninitialized (registration) instead of only in finally, so a stalled initialize SSE stream cannot pin the reservation and saturate the session caps. - Exclude packages/mcp from the client tsconfig (it has its own tsc, like rts) and use as-unknown-as casts in McpTokenApi so the client check-types passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/client/packages/mcp/src/app.ts | 4 ++++ app/client/src/api/McpTokenApi.ts | 5 +++-- app/client/src/ce/constants/messages.ts | 1 + .../src/pages/UserProfile/McpTokens.test.tsx | 2 ++ app/client/src/pages/UserProfile/McpTokens.tsx | 15 ++++++++++++++- app/client/tsconfig.json | 2 +- .../services/ce/UserMcpTokenServiceCEImpl.java | 4 +++- deploy/docker/fs/opt/appsmith/healthcheck.sh | 8 +++++++- 8 files changed, 35 insertions(+), 6 deletions(-) diff --git a/app/client/packages/mcp/src/app.ts b/app/client/packages/mcp/src/app.ts index 874076381bc7..ec3bb75d300c 100644 --- a/app/client/packages/mcp/src/app.ts +++ b/app/client/packages/mcp/src/app.ts @@ -649,6 +649,10 @@ export function createMcpHttpServer( username, transport: transport!, }); + // Release the reservation the moment the session is registered — the initialize response is a + // long-lived SSE stream, so waiting for `finally` (end of handleRequest) would let a client that + // never drains the stream pin the reservation until the session TTL and saturate the caps. + releasePending(); }, }); transport.onclose = () => { diff --git a/app/client/src/api/McpTokenApi.ts b/app/client/src/api/McpTokenApi.ts index 7308e1ede0bd..3708c9f1b8f2 100644 --- a/app/client/src/api/McpTokenApi.ts +++ b/app/client/src/api/McpTokenApi.ts @@ -3,6 +3,7 @@ import type { ApiResponse } from "api/ApiResponses"; export interface McpTokenMetadata { createdAt: string; + expiresAt: string; id: string; } @@ -16,7 +17,7 @@ class McpTokenApi extends Api { static async create(): Promise> { const response = await Api.post(McpTokenApi.url); - return response as ApiResponse; + return response as unknown as ApiResponse; } static async list(): Promise[]> { @@ -30,7 +31,7 @@ class McpTokenApi extends Api { static async revoke(tokenId: string): Promise> { const response = await Api.delete(`${McpTokenApi.url}/${tokenId}`); - return response as ApiResponse; + return response as unknown as ApiResponse; } } diff --git a/app/client/src/ce/constants/messages.ts b/app/client/src/ce/constants/messages.ts index 7814052d0f45..f4eaf03e8d6c 100644 --- a/app/client/src/ce/constants/messages.ts +++ b/app/client/src/ce/constants/messages.ts @@ -252,6 +252,7 @@ export const MCP_TOKEN_COPY_FAILED = () => "Unable to copy MCP token."; export const MCP_TOKENS_LOADING = () => "Loading MCP tokens…"; export const MCP_TOKENS_EMPTY = () => "No MCP tokens have been created."; export const MCP_TOKEN_CREATED_AT = () => "Created"; +export const MCP_TOKEN_EXPIRES_AT = () => "Expires"; export const REVOKE_MCP_TOKEN = () => "Revoke"; export const REVOKE_MCP_TOKEN_CONFIRM = () => "Revoke token"; export const REVOKE_MCP_TOKEN_CONFIRMATION = () => diff --git a/app/client/src/pages/UserProfile/McpTokens.test.tsx b/app/client/src/pages/UserProfile/McpTokens.test.tsx index 391e718313d0..ae32a097008a 100644 --- a/app/client/src/pages/UserProfile/McpTokens.test.tsx +++ b/app/client/src/pages/UserProfile/McpTokens.test.tsx @@ -33,6 +33,7 @@ describe("McpTokens", () => { successResponse({ id: "token-1", createdAt: "2026-07-10T12:00:00.000Z", + expiresAt: "2026-10-08T12:00:00.000Z", }), ]); }); @@ -57,6 +58,7 @@ describe("McpTokens", () => { id: "token-2", token: "secret-token", createdAt: "2026-07-10T12:00:00.000Z", + expiresAt: "2026-10-08T12:00:00.000Z", }), ); renderComponent(); diff --git a/app/client/src/pages/UserProfile/McpTokens.tsx b/app/client/src/pages/UserProfile/McpTokens.tsx index 664a458d895c..8a0d19bb0796 100644 --- a/app/client/src/pages/UserProfile/McpTokens.tsx +++ b/app/client/src/pages/UserProfile/McpTokens.tsx @@ -18,6 +18,7 @@ import { MCP_TOKEN_CREATE_FAILED, MCP_TOKEN_CREATED, MCP_TOKEN_CREATED_AT, + MCP_TOKEN_EXPIRES_AT, MCP_TOKEN_CREATED_DESCRIPTION, MCP_TOKEN_VALUE_LABEL, MCP_TOKEN_REVOKE_FAILED, @@ -106,7 +107,11 @@ function McpTokens() { setCreatedToken(token); setTokens((tokens) => [ - { id: token.id, createdAt: token.createdAt }, + { + id: token.id, + createdAt: token.createdAt, + expiresAt: token.expiresAt, + }, ...tokens, ]); } catch (error) { @@ -181,6 +186,10 @@ function McpTokens() { {createMessage(MCP_TOKEN_CREATED_AT)}:{" "} {formatCreatedAt(token.createdAt)} + + {createMessage(MCP_TOKEN_EXPIRES_AT)}:{" "} + {formatCreatedAt(token.expiresAt)} + diff --git a/app/client/tsconfig.json b/app/client/tsconfig.json index 3796279b383a..6b33c28e5907 100644 --- a/app/client/tsconfig.json +++ b/app/client/tsconfig.json @@ -35,5 +35,5 @@ "verbatimModuleSyntax": true }, "include": ["./src/**/*", "./packages"], - "exclude": ["./packages/rts", "**/node_modules", "**/.*/"] + "exclude": ["./packages/rts", "./packages/mcp", "**/node_modules", "**/.*/"] } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserMcpTokenServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserMcpTokenServiceCEImpl.java index 32e979fbf20c..df6402caaf4b 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserMcpTokenServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserMcpTokenServiceCEImpl.java @@ -126,7 +126,9 @@ private boolean matchesStoredHash(String token, String storedHash) { } private static boolean isNotExpired(UserMcpToken storedToken) { - return storedToken.getExpiresAt() == null || storedToken.getExpiresAt().isAfter(Instant.now()); + // Fail closed: a token with no expiry is rejected. Every token minted here carries one, so a missing + // expiry means a malformed/foreign record that must not authenticate. + return storedToken.getExpiresAt() != null && storedToken.getExpiresAt().isAfter(Instant.now()); } private String extractTokenId(String token) { diff --git a/deploy/docker/fs/opt/appsmith/healthcheck.sh b/deploy/docker/fs/opt/appsmith/healthcheck.sh index ae259f80dfc2..bfa606ce35f7 100644 --- a/deploy/docker/fs/opt/appsmith/healthcheck.sh +++ b/deploy/docker/fs/opt/appsmith/healthcheck.sh @@ -1,5 +1,11 @@ #!/usr/bin/env bash healthy=true +# MCP is opt-in (APPSMITH_MCP_ENABLED); its supervisord program is absent when disabled. Only health-check it when +# it is actually a configured program, so a container with MCP off (the default) is not reported unhealthy. +processes="editor rts backend" +if supervisorctl status | grep -q '^mcp'; then + processes="$processes mcp" +fi while read -r line do line_arr=($line) @@ -37,7 +43,7 @@ while read -r line fi fi fi - done <<< $(supervisorctl status editor rts mcp backend) + done <<< $(supervisorctl status $processes) if [ $healthy == true ]; then exit 0 else From 673f6d49fca5729ca3952eaacc1c436d595685b3 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Sat, 11 Jul 2026 15:30:29 -0400 Subject: [PATCH 04/81] fix(mcp): fix failing server unit tests (CsrfTest, manager test) CI server-unit-tests failures: - CsrfTest: the MCP AuthenticationWebFilter, added at AUTHENTICATION order with a match-any matcher, collided with the form-login filter and broke POST /api/v1/login (No provider found for UsernamePasswordAuthenticationToken -> 500). Restrict the filter to only engage for 'Bearer mcp_' requests so it never touches other auth flows. - McpTokenAuthenticationManagerTest: make the shared rate-limit stub lenient (tests that reject non-MCP / rate-limited requests never reach it) and wrap the failure-path tryIncreaseCounter in Mono.defer so it is not eagerly evaluated on the success path (which NPE'd on the unstubbed mock). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../managers/McpTokenAuthenticationManager.java | 4 ++-- .../server/configurations/SecurityConfig.java | 11 +++++++++++ .../managers/McpTokenAuthenticationManagerTest.java | 6 +++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManager.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManager.java index a9add310ea85..4eb013c1c9a8 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManager.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManager.java @@ -47,12 +47,12 @@ public Mono authenticate(Authentication authentication) { .authenticate((String) authentication.getCredentials()) .map(user -> (Authentication) UsernamePasswordAuthenticationToken.authenticated(user, null, mcpAuthorities(user))) - .switchIfEmpty(rateLimitService + .switchIfEmpty(Mono.defer(() -> rateLimitService .tryIncreaseCounter( RateLimitConstants.BUCKET_KEY_FOR_MCP_AUTHENTICATION, mcpTokenAuthentication.getClientAddress()) .onErrorResume(error -> Mono.empty()) - .then(invalidMcpToken())); + .then(invalidMcpToken()))); }); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java index effbeab6e03a..4e802dc30ebc 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java @@ -197,6 +197,17 @@ public SecurityWebFilterChain securityWebFilterChain( new AuthenticationWebFilter(mcpTokenAuthenticationManager); mcpTokenAuthenticationWebFilter.setServerAuthenticationConverter(mcpTokenAuthenticationConverter); mcpTokenAuthenticationWebFilter.setAuthenticationFailureHandler(failureHandler); + // Only engage for requests that actually carry an MCP token. Without this, the filter's default "any + // exchange" matcher sits at AUTHENTICATION order alongside the form-login filter and breaks the login flow + // (No provider found for UsernamePasswordAuthenticationToken -> 500). + mcpTokenAuthenticationWebFilter.setRequiresAuthenticationMatcher(exchange -> { + final String mcpBearerPrefix = "Bearer mcp_"; + final String authorization = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION); + return authorization != null + && authorization.regionMatches(true, 0, mcpBearerPrefix, 0, mcpBearerPrefix.length()) + ? ServerWebExchangeMatcher.MatchResult.match() + : ServerWebExchangeMatcher.MatchResult.notMatch(); + }); csrfConfig.applyTo(http); diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManagerTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManagerTest.java index 27b27365ce59..da49ea7e28d8 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManagerTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManagerTest.java @@ -21,6 +21,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -97,7 +98,10 @@ void authenticate_ignoresNonMcpAuthentication() { } private McpTokenAuthenticationManager manager() { - when(rateLimitService.isRateLimitExceeded(RateLimitConstants.BUCKET_KEY_FOR_MCP_AUTHENTICATION, "unknown")) + // Lenient: tests that reject non-MCP auth or a rate-limited source never reach this stub. + lenient() + .when(rateLimitService.isRateLimitExceeded( + RateLimitConstants.BUCKET_KEY_FOR_MCP_AUTHENTICATION, "unknown")) .thenReturn(Mono.just(false)); return new McpTokenAuthenticationManager(userMcpTokenService, rateLimitService); } From 1aed8c5712f766d3701ff9afa2f3162f1b180433 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Sat, 11 Jul 2026 15:59:50 -0400 Subject: [PATCH 05/81] fix(mcp): CsrfTest filter-order + McpTokens font-family test assertion - SecurityConfig: add the MCP AuthenticationWebFilter BEFORE the AUTHENTICATION order instead of AT it. Two AuthenticationWebFilters at the same order break form-login's manager wiring, causing POST /api/v1/login to 500 (CsrfTest failure). - McpTokens.test.tsx: assert the full monospace font-family stack that actually renders; toHaveStyle requires the exact value, not a prefix. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/client/src/pages/UserProfile/McpTokens.test.tsx | 4 +++- .../com/appsmith/server/configurations/SecurityConfig.java | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/client/src/pages/UserProfile/McpTokens.test.tsx b/app/client/src/pages/UserProfile/McpTokens.test.tsx index ae32a097008a..94598fbeb610 100644 --- a/app/client/src/pages/UserProfile/McpTokens.test.tsx +++ b/app/client/src/pages/UserProfile/McpTokens.test.tsx @@ -70,7 +70,9 @@ describe("McpTokens", () => { expect(tokenField).toHaveValue("secret-token"); expect(tokenField).toHaveAttribute("readonly"); - expect(tokenField).toHaveStyle("font-family: ui-monospace"); + expect(tokenField).toHaveStyle( + "font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + ); fireEvent.click(screen.getByRole("button", { name: "Copy token" })); await waitFor(() => diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java index 4e802dc30ebc..12ded07df414 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java @@ -212,7 +212,10 @@ public SecurityWebFilterChain securityWebFilterChain( csrfConfig.applyTo(http); return http.addFilterAt(this::sanityCheckFilter, SecurityWebFiltersOrder.FIRST) - .addFilterAt(mcpTokenAuthenticationWebFilter, SecurityWebFiltersOrder.AUTHENTICATION) + // Add BEFORE the AUTHENTICATION slot (not AT it): placing a second AuthenticationWebFilter at the + // same order as form-login breaks its manager wiring (login -> 500). This runs the MCP bearer check + // just ahead of form-login and is inert for non-MCP requests (see the matcher above). + .addFilterBefore(mcpTokenAuthenticationWebFilter, SecurityWebFiltersOrder.AUTHENTICATION) // Default security headers configuration from // https://docs.spring.io/spring-security/site/docs/5.0.x/reference/html/headers.html .headers(headerSpec -> headerSpec From e5ed59bf6c00e6341b7aa80af76ef66f738171bb Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Sat, 11 Jul 2026 16:29:35 -0400 Subject: [PATCH 06/81] fix(mcp): stop MCP auth manager from hijacking form-login (CsrfTest 500) Root cause of the CsrfTest failure: McpTokenAuthenticationManager was the only ReactiveAuthenticationManager @Component in the server, so Spring Boot auto-wired it as the global default authentication manager. Form-login (which sets no explicit manager) then used it and rejected UsernamePasswordAuthenticationToken with 'No provider found' -> POST /api/v1/login returned 500. Fix: drop @Component from the manager and construct it directly in SecurityConfig for the MCP filter only, so the context has no global ReactiveAuthenticationManager bean and form-login resolves its default (UserDetailsService-based) manager as before. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../managers/McpTokenAuthenticationManager.java | 5 +++-- .../com/appsmith/server/configurations/SecurityConfig.java | 7 ++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManager.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManager.java index 4eb013c1c9a8..cf1bda6ae68b 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManager.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/managers/McpTokenAuthenticationManager.java @@ -12,14 +12,15 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import java.util.ArrayList; import java.util.Collection; import java.util.List; -@Component +// Intentionally NOT a @Component/@Bean: as the only ReactiveAuthenticationManager bean it would be auto-wired as the +// global default and hijack form-login (which then rejects UsernamePasswordAuthenticationToken -> 500). SecurityConfig +// constructs it directly for the MCP filter instead. @RequiredArgsConstructor public class McpTokenAuthenticationManager implements ReactiveAuthenticationManager { diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java index 12ded07df414..7eeeb257b224 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java @@ -19,6 +19,7 @@ import com.appsmith.server.helpers.RedirectHelper; import com.appsmith.server.ratelimiting.RateLimitService; import com.appsmith.server.services.AnalyticsService; +import com.appsmith.server.services.UserMcpTokenService; import com.appsmith.server.services.UserService; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -189,10 +190,14 @@ public SecurityWebFilterChain internalWebFilterChain(ServerHttpSecurity http) { @SuppressWarnings("Convert2MethodRef") // Helps readability. public SecurityWebFilterChain securityWebFilterChain( ServerHttpSecurity http, - McpTokenAuthenticationManager mcpTokenAuthenticationManager, + UserMcpTokenService userMcpTokenService, McpTokenAuthenticationConverter mcpTokenAuthenticationConverter) { ServerAuthenticationEntryPointFailureHandler failureHandler = new ServerAuthenticationEntryPointFailureHandler(authenticationEntryPoint); + // Construct the MCP auth manager here rather than injecting a bean: as the only ReactiveAuthenticationManager + // bean it would be auto-wired as the global default and break form-login. + McpTokenAuthenticationManager mcpTokenAuthenticationManager = + new McpTokenAuthenticationManager(userMcpTokenService, rateLimitService); AuthenticationWebFilter mcpTokenAuthenticationWebFilter = new AuthenticationWebFilter(mcpTokenAuthenticationManager); mcpTokenAuthenticationWebFilter.setServerAuthenticationConverter(mcpTokenAuthenticationConverter); From 0bb4bbd24f14fd8996a9eb6a471cd5410e12de44 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Sat, 11 Jul 2026 23:05:10 -0400 Subject: [PATCH 07/81] =?UTF-8?q?feat(mcp):=20app-builder=20capability=20r?= =?UTF-8?q?elease=20=E2=80=94=20lint,=20instructions,=20catalog,=20data=20?= =?UTF-8?q?layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the MCP capability layer on top of the base server so an external AI (Claude/GPT) can build functional Appsmith apps end-to-end: - Feedback loop: static DSL linter + inline diagnostics on build/edit + inspect_page tool - Instruction sets via MCP resources (guides/recipes/widget reference) and prompts - Catalog: form, modal, datepicker, chart, tabs, list widgets + app-level theming - Data layer (behind APPSMITH_MCP_DATA_ENABLED): safe binding vocabulary (table.source / button.onClick), list_datasources / get_datasource_structure, and create_query — a structured SQL builder (no raw SQL/bindings; every value a prepared-statement bind parameter; datasource cross-tenant guard; idempotent) Security: agents can never author raw {{ }} / expression syntax anywhere. Closed two table-data injection paths and a datasource-metadata egress; create_query resolves the workspace server-authoritatively from applicationId to prevent cross-tenant datasource binding, and validates every emitted binding at construction. 120 unit tests; tsc + eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/client/packages/mcp/src/app.test.ts | 464 +++++++++++++++++- app/client/packages/mcp/src/app.ts | 306 +++++++++++- .../packages/mcp/src/builder/builder.test.ts | 152 +++++- .../packages/mcp/src/builder/capabilities.ts | 85 +++- .../packages/mcp/src/builder/catalog.test.ts | 312 ++++++++++++ .../packages/mcp/src/builder/compile.ts | 120 ++++- .../mcp/src/builder/instructions.test.ts | 125 +++++ .../packages/mcp/src/builder/instructions.ts | 246 ++++++++++ app/client/packages/mcp/src/builder/layout.ts | 4 + .../packages/mcp/src/builder/lint.test.ts | 202 ++++++++ app/client/packages/mcp/src/builder/lint.ts | 342 +++++++++++++ .../packages/mcp/src/builder/presets.ts | 4 +- .../packages/mcp/src/builder/query.test.ts | 169 +++++++ app/client/packages/mcp/src/builder/query.ts | 230 +++++++++ app/client/packages/mcp/src/builder/schema.ts | 268 +++++++++- .../packages/mcp/src/builder/templates.ts | 201 +++++++- app/client/packages/mcp/src/server.ts | 4 +- 17 files changed, 3180 insertions(+), 54 deletions(-) create mode 100644 app/client/packages/mcp/src/builder/catalog.test.ts create mode 100644 app/client/packages/mcp/src/builder/instructions.test.ts create mode 100644 app/client/packages/mcp/src/builder/instructions.ts create mode 100644 app/client/packages/mcp/src/builder/lint.test.ts create mode 100644 app/client/packages/mcp/src/builder/lint.ts create mode 100644 app/client/packages/mcp/src/builder/query.test.ts create mode 100644 app/client/packages/mcp/src/builder/query.ts diff --git a/app/client/packages/mcp/src/app.test.ts b/app/client/packages/mcp/src/app.test.ts index 10daf5666058..5616bd5e96ee 100644 --- a/app/client/packages/mcp/src/app.test.ts +++ b/app/client/packages/mcp/src/app.test.ts @@ -145,6 +145,11 @@ function createApi( listApplications: jest.fn(), listWorkspaces: jest.fn(), updateLayout: jest.fn(), + listDatasources: jest.fn(), + getDatasourceStructure: jest.fn(), + getApplication: jest.fn(async () => ({ workspaceId: "ws1" })), + listActions: jest.fn(), + createAction: jest.fn(), validateToken, }); } @@ -293,15 +298,18 @@ describe("MCP HTTP server", () => { "edit_page", ]), ); + // The raw artifact-import tools and the removed raw-DSL tools must not be exposed. - expect(names).not.toEqual( - expect.arrayContaining([ - "create_application", - "update_layout", - "import_application_artifact", - "import_partial_application_artifact", - ]), - ); + // 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 () => { @@ -327,7 +335,7 @@ describe("MCP HTTP server", () => { .set("Authorization", "Bearer mcp_user-token") .set("mcp-session-id", sessionId) .send({ jsonrpc: "2.0", method: "notifications/initialized" }); - await supertest(server) + const call = await supertest(server) .post("/mcp") .set("Accept", "application/json, text/event-stream") .set("Authorization", "Bearer mcp_user-token") @@ -357,6 +365,97 @@ describe("MCP HTTP server", () => { 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 () => { @@ -491,6 +590,15 @@ describe("MCP HTTP server", () => { 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 () => { @@ -731,3 +839,341 @@ describe("MCP HTTP server", () => { }); }); }); + +describe("MCP instruction surface (M2)", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async function session(): Promise<{ server: any; sessionId: string }> { + 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" }); + + return { server, sessionId }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async function rpc(method: string, params?: unknown): Promise { + const { server, sessionId } = await session(); + const response = 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: 9, method, params }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (parseJsonRpc(response) as any).result; + } + + it("exposes exactly the intended instruction resources", async () => { + const result = await rpc("resources/list"); + const uris = (result.resources as { uri: string }[]) + .map((r) => r.uri) + .sort(); + + expect(uris).toEqual( + [ + "appsmith://guide/bindings", + "appsmith://guide/naming", + "appsmith://guide/placement", + "appsmith://recipe/crud", + "appsmith://recipe/form", + "appsmith://recipe/table-detail", + "appsmith://reference/widgets", + ].sort(), + ); + }); + + it("reads a resource and returns markdown content", async () => { + const result = await rpc("resources/read", { + uri: "appsmith://reference/widgets", + }); + const [content] = result.contents as { text: string; mimeType: string }[]; + + expect(content.mimeType).toBe("text/markdown"); + expect(content.text).toContain("# Widget reference"); + }); + + it("exposes the guided-workflow prompts", async () => { + const result = await rpc("prompts/list"); + const names = (result.prompts as { name: string }[]).map((p) => p.name); + + expect(names).toContain("scaffold_crud"); + expect(names).toContain("scaffold_form"); + }); + + it("renders a scaffold prompt as a user message referencing real tools", async () => { + const result = await rpc("prompts/get", { + name: "scaffold_crud", + arguments: { entity: "Customer", fields: "email:EMAIL" }, + }); + const [message] = result.messages as { + role: string; + content: { type: string; text: string }; + }[]; + + expect(message.role).toBe("user"); + expect(message.content.text).toContain("CustomerTable"); + expect(message.content.text).toContain("build_application"); + expect(message.content.text).not.toContain("create_query"); + }); +}); + +describe("M4 data layer — sub-flag gates the data tools", () => { + async function toolNames(dataEnabled: boolean): Promise { + const server = createMcpHttpServer(API_BASE_URL, createApi(), { + dataEnabled, + }); + 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 listed = 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: 7, method: "tools/list" }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = parseJsonRpc(listed).result as any; + + return (result.tools as { name: string }[]).map((t) => t.name); + } + + it("hides the data tools when the sub-flag is off (default)", async () => { + const names = await toolNames(false); + + expect(names).not.toContain("list_datasources"); + expect(names).not.toContain("get_datasource_structure"); + // The build/authoring tools are still present. + expect(names).toContain("build_application"); + // Raw artifact-import tools remain absent regardless. + expect(names).not.toContain("import_application_artifact"); + }); + + it("exposes the data tools when the sub-flag is on", async () => { + const names = await toolNames(true); + + expect(names).toContain("list_datasources"); + expect(names).toContain("get_datasource_structure"); + expect(names).not.toContain("import_application_artifact"); + }); + + // Spin a data-enabled server with a custom api, handshake, call a tool, return the parsed result body. + async function callTool( + api: AppsmithApi, + name: string, + args: unknown, + ): Promise> { + const server = createMcpHttpServer(API_BASE_URL, () => api, { + dataEnabled: true, + }); + 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: 11, + method: "tools/call", + params: { name, arguments: args }, + }); + + return JSON.parse(parseJsonRpc(call).result.content[0].text); + } + + const validQuery = { + name: "getUsers", + applicationId: "app1", + pageId: "p1", + datasourceId: "ds1", + operation: "SELECT", + table: "users", + }; + + it("create_query appears only when the sub-flag is on", async () => { + expect(await toolNames(false)).not.toContain("create_query"); + expect(await toolNames(true)).toContain("create_query"); + }); + + it("create_query creates a parameterized query when the datasource is accessible", async () => { + const createAction = jest.fn< + Promise<{ id: string }>, + [Record] + >(async () => ({ id: "act1" })); + const api: AppsmithApi = { + ...createApi()(), + listDatasources: jest.fn(async () => [{ id: "ds1", name: "DB" }]), + listActions: jest.fn(async () => []), + createAction, + }; + const body = await callTool(api, "create_query", { + query: { + ...validQuery, + filters: [{ column: "id", op: "eq", value: { literal: 1 } }], + }, + }); + + expect(body.created).toBe(true); + expect(body.body).toBe("SELECT * FROM users WHERE id = {{ 1 }};"); + expect(createAction).toHaveBeenCalledTimes(1); + const dto = createAction.mock.calls[0][0] as { datasource: { id: string } }; + + expect(dto.datasource).toEqual({ id: "ds1" }); + }); + + it("create_query refuses a datasource the caller cannot access (IDOR guard)", async () => { + const createAction = jest.fn(); + const api: AppsmithApi = { + ...createApi()(), + // The caller's accessible datasources do NOT include ds1. + listDatasources: jest.fn(async () => [{ id: "other", name: "X" }]), + listActions: jest.fn(async () => []), + createAction, + }; + const body = await callTool(api, "create_query", { query: validQuery }); + + expect(body.error).toMatch(/not accessible/); + expect(createAction).not.toHaveBeenCalled(); + }); + + it("create_query resolves the workspace from the application, not the agent (cross-tenant guard)", async () => { + const createAction = jest.fn(); + // ds1 exists only in workspace A; the app resolves to workspace B, so the lookup there returns nothing. + const listDatasources = jest.fn(async (workspaceId: string) => + workspaceId === "wsA" ? [{ id: "ds1" }] : [], + ); + const api: AppsmithApi = { + ...createApi()(), + getApplication: jest.fn(async () => ({ workspaceId: "wsB" })), + listDatasources, + listActions: jest.fn(async () => []), + createAction, + }; + const body = await callTool(api, "create_query", { query: validQuery }); + + // Datasource is looked up in the app's OWN workspace (wsB), where ds1 is absent → refused, no POST. + expect(listDatasources).toHaveBeenCalledWith("wsB"); + expect(body.error).toMatch(/not accessible/); + expect(createAction).not.toHaveBeenCalled(); + }); + + it("create_query is idempotent: returns the existing query instead of duplicating", async () => { + const createAction = jest.fn(); + const api: AppsmithApi = { + ...createApi()(), + listDatasources: jest.fn(async () => [{ id: "ds1" }]), + listActions: jest.fn(async () => [{ name: "getUsers", pageId: "p1" }]), + createAction, + }; + const body = await callTool(api, "create_query", { query: validQuery }); + + expect(body.created).toBe(false); + expect(createAction).not.toHaveBeenCalled(); + }); + + it("create_query rejects a spec that could inject before any API call", async () => { + const listDatasources = jest.fn(async () => [{ id: "ds1" }]); + const createAction = jest.fn(); + const api: AppsmithApi = { + ...createApi()(), + listDatasources, + listActions: jest.fn(async () => []), + createAction, + }; + const body = await callTool(api, "create_query", { + query: { ...validQuery, table: "users; DROP TABLE users" }, + }); + + expect(body.valid).toBe(false); + expect(createAction).not.toHaveBeenCalled(); + }); + + it("list_datasources projects to non-secret fields only, even if upstream leaks", async () => { + // Simulate a (hypothetical) upstream response that carries credential material — the tool must strip it. + const listDatasources = jest.fn(async () => [ + { + id: "ds1", + name: "Prod DB", + pluginId: "postgres", + workspaceId: "ws1", + datasourceStorages: { + default: { + datasourceConfiguration: { + authentication: { password: "hunter2", token: "secret-oauth" }, + }, + }, + }, + }, + ]); + const api: AppsmithApi = { ...createApi()(), listDatasources }; + const server = createMcpHttpServer(API_BASE_URL, () => api, { + dataEnabled: true, + }); + 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: 8, + method: "tools/call", + params: { name: "list_datasources", arguments: { workspaceId: "ws1" } }, + }); + const text = parseJsonRpc(call).result.content[0].text; + + expect(text).toContain("Prod DB"); + expect(text).not.toContain("hunter2"); + expect(text).not.toContain("secret-oauth"); + expect(text).not.toContain("password"); + expect(text).not.toContain("datasourceStorages"); + }); +}); diff --git a/app/client/packages/mcp/src/app.ts b/app/client/packages/mcp/src/app.ts index ec3bb75d300c..ed19ea30e17e 100644 --- a/app/client/packages/mcp/src/app.ts +++ b/app/client/packages/mcp/src/app.ts @@ -11,8 +11,23 @@ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import { getCapabilities } from "./builder/capabilities.js"; import { applyEdit, compileApp } from "./builder/compile.js"; +import { + FIELDS_FORMAT, + GUIDES, + type InstructionDoc, + RECIPES, + scaffoldCrudPlan, + scaffoldFormPlan, + WIDGET_REFERENCE, +} from "./builder/instructions.js"; import type { WidgetNode } from "./builder/layout.js"; +import { lintArtifact, lintDsl } from "./builder/lint.js"; import { listPresets, PRESETS } from "./builder/presets.js"; +import { + buildActionDto, + compileQuery, + querySpecSchema, +} from "./builder/query.js"; import { appSpecSchema, editSpecSchema } from "./builder/schema.js"; const MAX_ID_LENGTH = 128; @@ -73,6 +88,11 @@ export interface AppsmithApi { layoutId: string, dsl: Record, ) => Promise; + listDatasources: (workspaceId: string) => Promise; + getDatasourceStructure: (datasourceId: string) => Promise; + getApplication: (applicationId: string) => Promise; + listActions: (applicationId: string) => Promise; + createAction: (action: Record) => Promise; validateToken: () => Promise; } @@ -232,10 +252,100 @@ export function createAppsmithApi( body: JSON.stringify({ dsl }), }, ), + listDatasources: async (workspaceId) => + request( + `/api/v1/datasources?workspaceId=${encodeURIComponent(workspaceId)}`, + ), + getDatasourceStructure: async (datasourceId) => + request( + `/api/v1/datasources/${encodeURIComponent(datasourceId)}/structure`, + ), + getApplication: async (applicationId) => + request(`/api/v1/applications/${encodeURIComponent(applicationId)}`), + listActions: async (applicationId) => + request( + `/api/v1/actions?applicationId=${encodeURIComponent(applicationId)}`, + ), + createAction: async (action) => + request("/api/v1/actions", { + method: "POST", + body: JSON.stringify(action), + }), validateToken: async () => request("/api/v1/users/me"), }; } +// Least-privilege projection for list_datasources: the agent only needs enough to identify a datasource and author a +// binding, so we forward a fixed whitelist of non-secret discovery fields and drop everything else. This is a +// belt-and-braces egress guard on top of the server's JsonView secret-masking — a future server change can't leak +// credentials/host details into the LLM's context through this tool, because only these keys are ever passed on. +const DATASOURCE_PUBLIC_FIELDS = [ + "id", + "name", + "pluginId", + "pluginName", + "workspaceId", + "type", +] as const; + +function projectDatasources(response: unknown): unknown { + const project = (entry: unknown) => { + if (!entry || typeof entry !== "object") return entry; + + const source = entry as Record; + const projected: Record = {}; + + for (const field of DATASOURCE_PUBLIC_FIELDS) { + if (source[field] !== undefined) projected[field] = source[field]; + } + + return projected; + }; + + return Array.isArray(response) ? response.map(project) : project(response); +} + +// IDOR guard for create_query: the referenced datasource must be one the caller can actually access in the given +// workspace (the server's create path fetches the datasource without a permission check — TODO in NewActionService — +// so we cross-check here before POST). +function datasourceAccessible(list: unknown, datasourceId: string): boolean { + return ( + Array.isArray(list) && + list.some( + (entry) => + entry !== null && + typeof entry === "object" && + (entry as { id?: unknown }).id === datasourceId, + ) + ); +} + +// Idempotency lookup: an existing action on the same page with the same name (the server rejects duplicates, so we +// return the existing one instead of erroring on a retry). +function findExistingAction( + list: unknown, + name: string, + pageId: string, +): unknown { + if (!Array.isArray(list)) return undefined; + + return list.find((entry) => { + if (entry === null || typeof entry !== "object") return false; + + const action = entry as { + name?: unknown; + pageId?: unknown; + unpublishedAction?: { pageId?: unknown }; + }; + const actionPageId = action.pageId ?? action.unpublishedAction?.pageId; + + return ( + action.name === name && + (actionPageId === undefined || actionPageId === pageId) + ); + }); +} + function validationError(issues: z.ZodIssue[]) { return result({ valid: false, @@ -266,7 +376,7 @@ function result(data: unknown) { }; } -export function buildMcpServer(api: AppsmithApi) { +export function buildMcpServer(api: AppsmithApi, dataEnabled = false) { const server = new McpServer({ name: "appsmith-mcp", version: "0.0.1" }); server.tool( @@ -374,7 +484,15 @@ export function buildMcpServer(api: AppsmithApi) { return compileError(error); } - return result(await api.importApplicationArtifact(workspaceId, artifact)); + // Structural diagnostics on the exact artifact being imported, returned inline so the agent gets feedback + // without a second call (build -> inspect -> fix loop). + const diagnostics = lintArtifact(artifact); + const imported = await api.importApplicationArtifact( + workspaceId, + artifact, + ); + + return result({ application: imported, diagnostics }); }, ); @@ -424,13 +542,190 @@ export function buildMcpServer(api: AppsmithApi) { dsl, ); - return result({ notes, layout: updated }); + const diagnostics = lintDsl(dsl); + + return result({ notes, diagnostics, layout: updated }); + }, + ); + + server.tool( + "inspect_page", + "Lint a live page and return structural diagnostics (overlaps, off-grid widgets, clipped containers, duplicate names, dangling bindings). Use this to verify a build/edit and drive fixes. Read-only.", + { applicationId: idSchema, pageId: idSchema, layoutId: idSchema }, + async ({ applicationId, layoutId, pageId }) => { + const context = await api.getApplicationContext( + applicationId, + pageId, + layoutId, + ); + const dsl = (context.layout as { dsl?: WidgetNode } | undefined)?.dsl; + + if (!dsl) { + return result({ error: "could not read the current page layout" }); + } + + return result({ diagnostics: lintDsl(dsl) }); }, ); + // M4 data layer — gated behind APPSMITH_MCP_DATA_ENABLED. These read tools let an agent discover the datasources + // and structure it can bind widgets to (via the closed binding vocabulary: table.source / button.onClick). They + // wrap the existing ACL-enforced Appsmith REST endpoints under the caller's bearer token; no new server surface. + if (dataEnabled) { + server.tool( + "list_datasources", + "List datasources in a workspace that the authenticated user can access. Bind a table to one of these via a query name (table.source = { query }).", + { workspaceId: idSchema }, + async ({ workspaceId }) => + result(projectDatasources(await api.listDatasources(workspaceId))), + ); + + server.tool( + "get_datasource_structure", + "Read a datasource's structure (tables/columns) so you can shape queries and bindings. Read-only.", + { datasourceId: idSchema }, + async ({ datasourceId }) => + result(await api.getDatasourceStructure(datasourceId)), + ); + + server.tool( + "create_query", + "Create a SQL query (SELECT/INSERT/UPDATE/DELETE) on a datasource from a STRUCTURED spec — no raw SQL, no raw bindings. Values become prepared-statement parameters. Widgets then reference it by name (table.source={query} / button.onClick={run}). Idempotent by page + name.", + { query: z.record(z.unknown()) }, + async ({ query }) => { + const parsed = querySpecSchema.safeParse(query); + + if (!parsed.success) return validationError(parsed.error.issues); + + const spec = parsed.data; + + // Resolve the workspace SERVER-AUTHORITATIVELY from the application — never trust an agent-supplied + // workspaceId — so a datasource can only be bound within the target page's own workspace (cross-tenant guard). + const application = (await api.getApplication(spec.applicationId)) as { + workspaceId?: string; + }; + const workspaceId = application?.workspaceId; + + if (typeof workspaceId !== "string") { + return result({ + error: `could not resolve the workspace for application ${spec.applicationId}`, + }); + } + + // IDOR guard: only bind a datasource the caller can access in the page's own workspace. + const datasources = await api.listDatasources(workspaceId); + + if (!datasourceAccessible(datasources, spec.datasourceId)) { + return result({ + error: `datasource ${spec.datasourceId} is not accessible in this application's workspace`, + }); + } + + // Idempotency: return the existing query rather than creating a duplicate on retry. + const existing = findExistingAction( + await api.listActions(spec.applicationId), + spec.name, + spec.pageId, + ); + + if (existing !== undefined) { + return result({ + created: false, + reason: "a query with this name already exists on this page", + action: existing, + }); + } + + let body: string; + + try { + body = compileQuery(spec); + } catch (error) { + return compileError(error); + } + + const created = await api.createAction(buildActionDto(spec, body)); + + return result({ created: true, body, action: created }); + }, + ); + } + + registerInstructions(server); + return server; } +// M2 — register instruction sets as MCP resources (reference/guides/recipes) and prompts (guided workflows), so an +// agent can pull guidance through the protocol instead of only through tool output. All content is static text. +function registerInstructions(server: McpServer): void { + const markdown = "text/markdown"; + + const registerDoc = (prefix: string, doc: InstructionDoc) => { + const uri = `appsmith://${prefix}/${doc.slug}`; + + server.registerResource( + `${prefix}-${doc.slug}`, + uri, + { title: doc.title, description: doc.description, mimeType: markdown }, + async () => ({ + contents: [{ uri, mimeType: markdown, text: doc.render() }], + }), + ); + }; + + registerDoc("reference", WIDGET_REFERENCE); + + for (const guide of GUIDES) registerDoc("guide", guide); + + for (const recipe of RECIPES) registerDoc("recipe", recipe); + + const promptArgs = { + entity: z.string().trim().min(1).max(64), + fields: z.string().max(2000).optional(), + }; + + server.registerPrompt( + "scaffold_crud", + { + title: "Scaffold a CRUD page", + description: `Guided workflow to build a table + details form for an entity. fields: ${FIELDS_FORMAT}`, + argsSchema: promptArgs, + }, + ({ entity, fields }) => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: scaffoldCrudPlan(entity, fields ?? ""), + }, + }, + ], + }), + ); + + server.registerPrompt( + "scaffold_form", + { + title: "Scaffold a form page", + description: `Guided workflow to build a labelled input form. fields: ${FIELDS_FORMAT}`, + argsSchema: promptArgs, + }, + ({ entity, fields }) => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: scaffoldFormPlan(entity, fields ?? ""), + }, + }, + ], + }), + ); +} + export function bearerToken(req: IncomingMessage): string | undefined { const value = req.headers.authorization; const match = value?.match(/^Bearer\s+(.+)$/i); @@ -487,6 +782,8 @@ export interface McpHttpServerOptions { maxSessionsPerUser?: number; now?: () => number; sessionTtlMs?: number; + // Gate for the M4 data layer (APPSMITH_MCP_DATA_ENABLED). Off by default: data tools are not registered. + dataEnabled?: boolean; } function tokensMatch(left: string, right: string): boolean { @@ -515,6 +812,7 @@ export function createMcpHttpServer( options.maxSessionsPerUser ?? MAX_MCP_SESSIONS_PER_USER; const now = options.now ?? Date.now; const sessionTtlMs = options.sessionTtlMs ?? MCP_SESSION_TTL_MS; + const dataEnabled = options.dataEnabled ?? false; function removeExpiredSessions() { const currentTime = now(); @@ -658,7 +956,7 @@ export function createMcpHttpServer( transport.onclose = () => { if (transport?.sessionId) sessions.delete(transport.sessionId); }; - await buildMcpServer(api).connect(transport); + await buildMcpServer(api, dataEnabled).connect(transport); } if (!transport) { diff --git a/app/client/packages/mcp/src/builder/builder.test.ts b/app/client/packages/mcp/src/builder/builder.test.ts index f1c81565ec43..c0ebe1d9a5d2 100644 --- a/app/client/packages/mcp/src/builder/builder.test.ts +++ b/app/client/packages/mcp/src/builder/builder.test.ts @@ -61,7 +61,7 @@ describe("compileApp — import artifact contract", () => { { name: "Home", widgets: [ - { type: "table", data: "{{q.data}}" }, + { type: "table", data: [{ id: 1, name: "Ada" }] }, { type: "input", label: "Email", inputType: "EMAIL" }, { type: "button", text: "Go" }, ], @@ -74,7 +74,9 @@ describe("compileApp — import artifact contract", () => { expect(table.type).toBe("TABLE_WIDGET_V2"); expect(table.version).toBe(2); - expect(table.tableData).toBe("{{q.data}}"); + // Literal rows are serialized as static JSON and NOT registered as a dynamic binding. + expect(table.tableData).toBe(JSON.stringify([{ id: 1, name: "Ada" }])); + expect(table.dynamicBindingPathList).toEqual([]); expect(input.type).toBe("INPUT_WIDGET_V2"); expect(input.inputType).toBe("EMAIL"); expect(button.type).toBe("BUTTON_WIDGET"); @@ -145,10 +147,7 @@ describe("compileApp — import artifact contract", () => { pages: [ { name: "Home", - widgets: [ - { type: "table", data: "" }, - { type: "table", data: "" }, - ], + widgets: [{ type: "table" }, { type: "table" }], }, ], }, @@ -294,15 +293,21 @@ describe("applyEdit — append to an existing page", () => { 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: [ - { type: "input", label: "Note", placement: { inside: "Details" } }, - ], + add: Array.from({ length: 6 }, (_, 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", )!; @@ -310,6 +315,10 @@ describe("applyEdit — append to an existing page", () => { 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", () => { @@ -377,4 +386,129 @@ describe("spec validation", () => { 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 index 0785d6f0ac61..bcfa0c4a49d1 100644 --- a/app/client/packages/mcp/src/builder/capabilities.ts +++ b/app/client/packages/mcp/src/builder/capabilities.ts @@ -4,11 +4,14 @@ 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. -const WIDGET_CATALOG = [ +// 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: "string" }, - purpose: "Static or bound text / headings.", + fields: { text: "plain string (no binding/template syntax)" }, + purpose: "Static text / headings.", }, { type: "input", @@ -26,22 +29,76 @@ const WIDGET_CATALOG = [ }, purpose: "Dropdown selection.", }, - { type: "button", fields: { text: "string" }, purpose: "Trigger an action." }, + { + type: "button", + fields: { + text: "string", + onClick: "{ run: '' } — runs a named query (data layer)", + }, + purpose: "Trigger an action.", + }, { type: "image", - fields: { image: "string (url or {{binding}})" }, + fields: { image: "string url" }, purpose: "Display an image.", }, { type: "table", - fields: { data: "string (JSON array or {{query.data}} binding)" }, - purpose: "Tabular data with search/sort/pagination.", + fields: { + data: "{ [column]: string|number|boolean|null }[] — static literal rows", + source: "{ query: '' } — bind to a query's data (data layer)", + }, + purpose: + "Tabular data with search/sort/pagination. Use `data` for static rows OR `source` to bind a query (not both).", }, { 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: "chart", + fields: { + title: "string", + chartType: + "LINE_CHART | BAR_CHART | PIE_CHART | COLUMN_CHART | AREA_CHART", + series: "{ name?: string, points?: { x, y }[] }[] — static series", + }, + purpose: "Visualize static series data.", + }, + { + type: "tabs", + fields: { + tabs: "{ label: string, children?: widget spec[] }[]", + }, + purpose: "Organize content into tabs, each with its own canvas.", + }, + { + type: "list", + fields: { children: "widget spec[] (the repeating item template)" }, + purpose: "Repeat a template of widgets over a list of items.", + }, ] as const; export function getCapabilities() { @@ -69,6 +126,20 @@ export function getCapabilities() { "validate_app_spec — dry-run compile; returns errors, imports nothing", "build_application — compile an app spec and create the app", "edit_page — append widgets to an existing page (best-effort placement)", + "inspect_page — lint a live page and return structural diagnostics", + ], + dataTools: [ + "list_datasources / get_datasource_structure — discover what to query (data layer)", + "create_query — build a SELECT/INSERT/UPDATE/DELETE query from a structured spec (data layer)", + ], + resources: [ + "appsmith://reference/widgets — the widget catalog as a resource", + "appsmith://guide/{placement,naming,bindings} — technique guides", + "appsmith://recipe/{crud,form,table-detail} — end-to-end build walkthroughs", + ], + prompts: [ + "scaffold_crud — guided workflow to build a CRUD page from an entity + fields", + "scaffold_form — guided workflow to build a form page", ], }; } 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..9aaca7629c89 --- /dev/null +++ b/app/client/packages/mcp/src/builder/catalog.test.ts @@ -0,0 +1,312 @@ +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 } 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 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 list with a repeating item template canvas", () => { + const [list] = children( + build([{ type: "list", children: [{ type: "text", text: "row" }] }]), + ); + + expect(list.type).toBe("LIST_WIDGET_V2"); + const inner = children(list)[0]; + + expect(inner.type).toBe("CANVAS_WIDGET"); + expect(children(inner).map((c) => c.type)).toContain("TEXT_WIDGET"); + }); + + 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", children: [{ type: "text", text: "y" }] }, + ]); + + expect(lintDsl(dsl).errors).toBe(0); + }); +}); + +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"); + }); +}); diff --git a/app/client/packages/mcp/src/builder/compile.ts b/app/client/packages/mcp/src/builder/compile.ts index 5c6d7c6cfc62..a36cb2a584af 100644 --- a/app/client/packages/mcp/src/builder/compile.ts +++ b/app/client/packages/mcp/src/builder/compile.ts @@ -2,6 +2,7 @@ import type { AppSpec, EditSpec, PageSpec, + Theme, WidgetSpec, WidgetType, } from "./schema.js"; @@ -44,12 +45,40 @@ const DEFAULT_BASE_NAME: Record = { image: "Image", table: "Table", container: "Container", + form: "Form", + modal: "Modal", + datepicker: "DatePicker", + chart: "Chart", + tabs: "Tabs", + list: "List", }; 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( @@ -74,13 +103,79 @@ function compileWidgetAt( const template = WIDGET_TEMPLATES[spec.type]; const built = template.build(spec); + + applyTheme(built.props, template.appsmithType, ctx.theme); + const columns = Math.min(built.footprint.columns, availableColumns); const widgetId = ctx.idGen(); const widgetName = ctx.names.allocate( spec.name ?? DEFAULT_BASE_NAME[spec.type], ); - if (spec.type === "container") { + // 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: 0, + columns, + rows: maxRows, + props: { ...built.props, tabsObj, defaultTab: tabSpecs[0].label }, + children: canvases, + }); + } + + // Any widget whose template returns `children` is container-like (container, form, modal, list): 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( @@ -180,8 +275,14 @@ function compilePage( pageSpec: PageSpec, idGen: IdGenerator, remaining: { widgets: number }, + theme?: Theme, ) { - const ctx: CompileContext = { idGen, names: new NameAllocator(), remaining }; + const ctx: CompileContext = { + idGen, + names: new NameAllocator(), + remaining, + theme, + }; const dsl = compilePageDsl(pageSpec, ctx); const layout = { dsl, layoutOnLoadActions: [] as unknown[] }; @@ -208,7 +309,7 @@ export function compileApp( // 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), + compilePage(page, idGen, remaining, appSpec.theme), ); const pageNames = appSpec.pages.map((page) => page.name); @@ -298,6 +399,19 @@ export function applyEdit( } else if (bottom > (placement.canvas.bottomRow as number)) { placement.canvas.bottomRow = bottom; } + + // The inner canvas grew, but its enclosing container keeps its own height. Match the build-time invariant + // (container.bottomRow = container.topRow + innerCanvas.bottomRow) so the container encompasses the new extent + // instead of clipping it. + if (placement.container) { + const containerTop = + typeof placement.container.topRow === "number" + ? placement.container.topRow + : 0; + + placement.container.bottomRow = + containerTop + (placement.canvas.bottomRow as number); + } } return { dsl, notes }; 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..02c1aca1563d --- /dev/null +++ b/app/client/packages/mcp/src/builder/instructions.test.ts @@ -0,0 +1,125 @@ +import { WIDGET_CATALOG } from "./capabilities.js"; +import { + GUIDES, + parseFields, + RECIPES, + scaffoldCrudPlan, + scaffoldFormPlan, + WIDGET_REFERENCE, +} from "./instructions.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("prompt field parsing", () => { + it("parses name:type pairs and normalizes types", () => { + expect(parseFields("name:TEXT, email:EMAIL, age:NUMBER")).toEqual([ + { name: "name", inputType: "TEXT" }, + { name: "email", inputType: "EMAIL" }, + { name: "age", inputType: "NUMBER" }, + ]); + }); + + it("defaults unknown types to TEXT and strips unsafe characters from names", () => { + expect(parseFields("full name:weird, ok:PASSWORD")).toEqual([ + { name: "fullname", inputType: "TEXT" }, + { name: "ok", inputType: "PASSWORD" }, + ]); + }); + + it("tolerates empty input", () => { + expect(parseFields("")).toEqual([]); + }); +}); + +describe("scaffold prompt plans", () => { + it("substitutes the entity and references real tools by name", () => { + const plan = scaffoldCrudPlan("Customer", "name:TEXT, email:EMAIL"); + + expect(plan).toContain("CustomerTable"); + expect(plan).toContain("name (TEXT)"); + expect(plan).toContain("email (EMAIL)"); + + for (const tool of EXISTING_TOOLS.filter((t) => t !== "list_presets")) { + // crud plan uses get_preset, validate, build, inspect, edit, capabilities + if (plan.includes(tool)) expect(plan).toContain(tool); + } + + expect(plan).toContain("get_preset"); + expect(plan).toContain("build_application"); + + for (const tool of NONEXISTENT_TOOLS) expect(plan).not.toContain(tool); + }); + + it("sanitizes a hostile entity name", () => { + const plan = scaffoldFormPlan("Drop; {{evil}}", "x:TEXT"); + + expect(plan).not.toMatch(/\{\{|\}\}/); + expect(plan).toContain("Dropevil"); + }); + + it("handles missing fields gracefully", () => { + expect(scaffoldCrudPlan("Order", "")).toContain("no fields parsed"); + }); +}); diff --git a/app/client/packages/mcp/src/builder/instructions.ts b/app/client/packages/mcp/src/builder/instructions.ts new file mode 100644 index 000000000000..5c879912414d --- /dev/null +++ b/app/client/packages/mcp/src/builder/instructions.ts @@ -0,0 +1,246 @@ +import { WIDGET_CATALOG } from "./capabilities.js"; +import { listPresets } from "./presets.js"; + +// M2 — instruction sets delivered through the MCP protocol as `resources` (reference/guides/recipes) and `prompts` +// (guided workflows). Everything here is inline TS (no external assets) so the build/tooling is unchanged, and +// every recipe/guide references only tools that actually exist. Data-layer wiring is intentionally NOT taught here; +// it arrives with the data layer so we never advertise a capability that isn't enabled. + +export interface InstructionDoc { + slug: string; + title: string; + description: string; + render: () => string; +} + +// --- reference/widgets — generated from the single WIDGET_CATALOG source (no forked list) ----------------------- + +function renderWidgetReference(): string { + const lines = ["# Widget reference", ""]; + + for (const widget of WIDGET_CATALOG) { + lines.push(`## ${widget.type}`); + lines.push(widget.purpose); + lines.push(""); + lines.push("Fields:"); + + for (const [field, shape] of Object.entries(widget.fields)) { + lines.push(`- \`${field}\`: ${shape}`); + } + + lines.push(""); + } + + lines.push( + "Every widget also accepts an optional `name` (alphanumeric/underscore) and `placement`", + "(`{ after: '' }` or `{ inside: '' }`).", + ); + + return lines.join("\n"); +} + +// --- guides ---------------------------------------------------------------------------------------------------- + +const GUIDE_PLACEMENT = `# Placement guide + +Widgets are auto-placed on a 64-column grid, stacked top-to-bottom in spec order. + +- Omit \`placement\` to append below existing content. +- \`{ after: "Email" }\` places the widget just below the widget named \`Email\` (on the same canvas). +- \`{ inside: "DetailsCard" }\` places the widget inside a container's inner canvas. +- \`after\` and \`inside\` are mutually exclusive — set at most one. +- A placement that can't be resolved falls back to "append" and is reported in the response \`notes\`. + +After a build or edit, call \`inspect_page\` (or read the inline \`diagnostics\`) to catch overlaps, +off-grid widgets, or a container that clips its contents, then fix and re-check.`; + +const GUIDE_NAMING = `# Naming guide + +- \`name\` must match \`[A-Za-z0-9_]+\` (letters, digits, underscore). No spaces, dashes, or punctuation. +- Names must be unique within a page — the compiler auto-suffixes collisions (Table, Table1, Table2…), + but explicit, meaningful names make later \`edit_page\` placement (\`after\`/\`inside\`) reliable. +- If you omit \`name\`, a sensible default is assigned from the widget type.`; + +const GUIDE_BINDINGS = `# Data & bindings guide + +Agents never author raw expressions. Any field containing binding/template syntax +(\`{{ }}\`, \`\${ }\`, or backticks) is rejected — this keeps agent-authored content from being +evaluated as code in a viewer's browser. + +- Static data: a table's \`data\` takes literal rows only — an array of flat objects of + string/number/boolean/null cells. It is rendered as-is, never evaluated. +- Dynamic (query-backed) data: supplied through structured references, not hand-written + \`{{ }}\`: + - Bind a table to a query: \`{ "type": "table", "source": { "query": "getUsers" } }\`. + - Run a query on a button click: \`{ "type": "button", "onClick": { "run": "insertRow" } }\`. + The query name must be a plain identifier; the compiler emits the safe binding for you. These + work when the data layer is enabled on the instance (list_datasources / get_datasource_structure + help you discover what to query). A table sets \`data\` OR \`source\`, never both.`; + +export const GUIDES: InstructionDoc[] = [ + { + slug: "placement", + title: "Placement guide", + description: + "How auto-placement works and how to target it with after/inside.", + render: () => GUIDE_PLACEMENT, + }, + { + slug: "naming", + title: "Naming guide", + description: "Rules and conventions for widget names.", + render: () => GUIDE_NAMING, + }, + { + slug: "bindings", + title: "Data & bindings guide", + description: "Why raw expressions are rejected and how data binding works.", + render: () => GUIDE_BINDINGS, + }, +]; + +// --- recipes (end-to-end walkthroughs; reference only tools that exist) ----------------------------------------- + +const RECIPE_CRUD = `# Recipe: CRUD page + +Goal: a table of records with a details form to view/edit a selected row. + +1. \`get_capabilities\` — confirm the widget set and spec shape. +2. \`get_preset\` with name \`crud\` — start from the ready CRUD layout. +3. Adapt the preset: rename the table and inputs to your entity, add/remove inputs to match + your fields. Keep names alphanumeric and unique. +4. \`validate_app_spec\` — dry-run compile; fix any reported errors before building. +5. \`build_application\` — create the app. Read the inline \`diagnostics\`. +6. \`inspect_page\` — verify no overlaps/off-grid/clipped-container issues; \`edit_page\` to fix. + +To make it live (when the data layer is enabled): +7. \`list_datasources\` then \`get_datasource_structure\` — find your DB and its tables/columns. +8. \`create_query\` — e.g. a SELECT over your table (structured spec, no raw SQL). +9. \`edit_page\` — set the table's \`source\` to { query: '' } and a button's \`onClick\` + to { run: '' }. The compiler emits the safe bindings for you.`; + +const RECIPE_FORM = `# Recipe: Form page + +Goal: a labelled input form with a submit button, grouped in a card. + +1. \`get_capabilities\`, then \`get_preset\` with name \`form\`. +2. Replace the inputs with your fields (set \`label\` and \`inputType\`), keep the submit button. +3. Group related fields with a \`container\` and place inputs \`{ inside: "" }\`. +4. \`validate_app_spec\` → \`build_application\` → \`inspect_page\`, fixing any diagnostics.`; + +const RECIPE_TABLE_DETAIL = `# Recipe: Master–detail page + +Goal: a table above a details card (the common admin/read pattern). + +1. \`get_preset\` with name \`table-detail\`. +2. Rename the table and the card's inputs to your entity/fields. +3. Add fields as inputs \`{ inside: "DetailsCard" }\` so they land in the card. +4. \`validate_app_spec\` → \`build_application\` → \`inspect_page\`. + +Static layout today; wire the table to a query and the inputs to the selected row when the data +layer is enabled.`; + +export const RECIPES: InstructionDoc[] = [ + { + slug: "crud", + title: "CRUD page recipe", + description: "Build a table + details form from the crud preset.", + render: () => RECIPE_CRUD, + }, + { + slug: "form", + title: "Form page recipe", + description: "Build a labelled input form from the form preset.", + render: () => RECIPE_FORM, + }, + { + slug: "table-detail", + title: "Master–detail recipe", + description: "Build a table-above-details-card layout.", + render: () => RECIPE_TABLE_DETAIL, + }, +]; + +export const WIDGET_REFERENCE: InstructionDoc = { + slug: "widgets", + title: "Widget reference", + description: "Every widget type, its purpose, and its fields.", + render: renderWidgetReference, +}; + +// --- prompts (guided workflows) -------------------------------------------------------------------------------- + +// Prompt args are string-only (MCP protocol). `fields` is a documented delimited string, parsed here. +export const FIELDS_FORMAT = + 'comma-separated "name:type" pairs, e.g. "name:TEXT, email:EMAIL, active:TEXT"'; + +interface ParsedField { + name: string; + inputType: "TEXT" | "NUMBER" | "EMAIL" | "PASSWORD"; +} + +const INPUT_TYPES = new Set(["TEXT", "NUMBER", "EMAIL", "PASSWORD"]); + +export function parseFields(fields: string): ParsedField[] { + return fields + .split(",") + .map((pair) => pair.trim()) + .filter((pair) => pair.length > 0) + .map((pair) => { + const [rawName, rawType] = pair.split(":").map((part) => part.trim()); + const upper = (rawType ?? "TEXT").toUpperCase(); + const inputType = ( + INPUT_TYPES.has(upper) ? upper : "TEXT" + ) as ParsedField["inputType"]; + + return { name: (rawName ?? "").replace(/[^A-Za-z0-9_]/g, ""), inputType }; + }) + .filter((field) => field.name.length > 0); +} + +// Returns the concrete, ordered plan text for a scaffolding prompt — a single user-turn message that walks the +// agent through real tools with the caller's arguments already substituted. +export function scaffoldCrudPlan(entity: string, fields: string): string { + const cleanEntity = entity.replace(/[^A-Za-z0-9_]/g, "") || "Record"; + const parsed = parseFields(fields); + const fieldLines = parsed.length + ? parsed.map((f) => `- ${f.name} (${f.inputType})`).join("\n") + : "- (no fields parsed; add inputs for your entity's columns)"; + + return `Build a CRUD page for "${cleanEntity}". Follow these steps, using the tools by name: + +1. Call get_capabilities to confirm the widget set. +2. Call get_preset with { "name": "crud" } to get the starting layout. +3. Adapt the preset spec: + - Rename the table to "${cleanEntity}Table". + - Give the details card one input per field: +${fieldLines} + - Keep names alphanumeric/underscore and unique. +4. Call validate_app_spec with the adapted app spec; fix any reported errors. +5. Call build_application with { "workspaceId": "", "app": }. +6. Read the returned diagnostics, then call inspect_page and resolve any issues with edit_page. + +Table data is static for now. See appsmith://guide/bindings for how query-backed data works.`; +} + +export function scaffoldFormPlan(entity: string, fields: string): string { + const cleanEntity = entity.replace(/[^A-Za-z0-9_]/g, "") || "Record"; + const parsed = parseFields(fields); + const fieldLines = parsed.length + ? parsed.map((f) => `- ${f.name} (${f.inputType})`).join("\n") + : "- (no fields parsed; add inputs for your form)"; + + return `Build a form page for "${cleanEntity}". Use the tools by name: + +1. Call get_capabilities, then get_preset with { "name": "form" }. +2. Adapt the spec: put these inputs inside a container named "${cleanEntity}Form": +${fieldLines} + Keep the submit button. Names must be alphanumeric/underscore and unique. +3. Call validate_app_spec; fix errors. +4. Call build_application, then inspect_page and fix any diagnostics with edit_page.`; +} + +// Presets referenced by the recipes above, surfaced for tests/consistency. +export function recipePresetNames(): string[] { + return listPresets().map((preset) => preset.name); +} diff --git a/app/client/packages/mcp/src/builder/layout.ts b/app/client/packages/mcp/src/builder/layout.ts index 35e134ef0e08..3679cc4d7c0b 100644 --- a/app/client/packages/mcp/src/builder/layout.ts +++ b/app/client/packages/mcp/src/builder/layout.ts @@ -169,6 +169,9 @@ export function nextFreeRow(children: WidgetNode[]): number { export interface PlacementResult { // The canvas (root or a container's inner canvas) to append into. canvas: WidgetNode; + // The enclosing container widget when `canvas` is a container's inner canvas. The caller grows its height to + // encompass the canvas's new extent. Absent when appending to the root page canvas. + container?: WidgetNode; // The starting topRow for the appended widget(s). topRow: number; // Human-readable note about how placement was resolved (reported back to the caller). @@ -224,6 +227,7 @@ export function resolvePlacement( if (innerCanvas) { return { canvas: innerCanvas, + container, topRow: nextFreeRow(innerCanvas.children ?? []), }; } diff --git a/app/client/packages/mcp/src/builder/lint.test.ts b/app/client/packages/mcp/src/builder/lint.test.ts new file mode 100644 index 000000000000..556e57df6d81 --- /dev/null +++ b/app/client/packages/mcp/src/builder/lint.test.ts @@ -0,0 +1,202 @@ +import { compileApp } from "./compile.js"; +import { sequentialIdGenerator, type WidgetNode } from "./layout.js"; +import { lintArtifact, lintDsl } from "./lint.js"; + +// A minimal root page canvas with the given children. +function canvas(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: "INPUT_WIDGET_V2", + topRow: 0, + bottomRow: 7, + leftColumn: 0, + rightColumn: 24, + ...overrides, + }; +} + +function rules(dsl: WidgetNode): string[] { + return lintDsl(dsl).issues.map((issue) => issue.rule); +} + +describe("lintDsl — tree integrity", () => { + it("flags a duplicate widget id", () => { + const dsl = canvas([ + widget({ widgetId: "a", widgetName: "A", topRow: 0, bottomRow: 7 }), + widget({ widgetId: "a", widgetName: "B", topRow: 8, bottomRow: 15 }), + ]); + + expect(rules(dsl)).toContain("duplicate-id"); + }); + + it("flags a missing root", () => { + const diagnostics = lintDsl(undefined as unknown as WidgetNode); + + expect(diagnostics.errors).toBeGreaterThan(0); + expect(diagnostics.issues[0].rule).toBe("tree"); + }); +}); + +describe("lintDsl — geometry", () => { + it("flags a widget that extends past the grid", () => { + const dsl = canvas([widget({ widgetId: "a", rightColumn: 68 })]); + + expect(rules(dsl)).toContain("off-grid"); + }); + + it("flags two overlapping siblings", () => { + const dsl = canvas([ + widget({ + widgetId: "a", + topRow: 0, + bottomRow: 10, + leftColumn: 0, + rightColumn: 30, + }), + widget({ + widgetId: "b", + topRow: 5, + bottomRow: 15, + leftColumn: 10, + rightColumn: 40, + }), + ]); + + expect(rules(dsl)).toContain("overlap"); + }); + + it("flags a non-positive height", () => { + const dsl = canvas([widget({ widgetId: "a", topRow: 10, bottomRow: 10 })]); + + expect(rules(dsl)).toContain("zero-height"); + }); + + it("flags a container shorter than its inner content", () => { + const inner: WidgetNode = { + widgetId: "canvas1", + widgetName: "Canvas1", + type: "CANVAS_WIDGET", + topRow: 0, + bottomRow: 40, // inner extent 40 rows + leftColumn: 0, + rightColumn: 40, + children: [], + }; + const container: WidgetNode = { + widgetId: "c1", + widgetName: "Container1", + type: "CONTAINER_WIDGET", + topRow: 0, + bottomRow: 20, // only 20 rows tall — clips the 40-row inner canvas + leftColumn: 0, + rightColumn: 40, + children: [inner], + }; + + expect(rules(canvas([container]))).toContain("container-clips"); + }); +}); + +describe("lintDsl — naming", () => { + it("flags duplicate widget names", () => { + const dsl = canvas([ + widget({ widgetId: "a", widgetName: "Dup", topRow: 0, bottomRow: 7 }), + widget({ widgetId: "b", widgetName: "Dup", topRow: 8, bottomRow: 15 }), + ]); + + expect(rules(dsl)).toContain("duplicate-name"); + }); + + it("flags a name with characters Appsmith may reject", () => { + const dsl = canvas([widget({ widgetId: "a", widgetName: "bad-name!" })]); + + expect(rules(dsl)).toContain("invalid-name"); + }); +}); + +describe("lintDsl — counting contract", () => { + it("reports errors and warnings equal to the matching issue counts", () => { + const dsl = canvas([ + widget({ widgetId: "a", widgetName: "Dup", topRow: 10, bottomRow: 10 }), // dup-name + zero-height (errors) + widget({ widgetId: "b", widgetName: "Dup", rightColumn: 68 }), // dup-name (error) + off-grid (warn) + ]); + const diagnostics = lintDsl(dsl); + const errors = diagnostics.issues.filter((i) => i.sev === "error").length; + const warnings = diagnostics.issues.filter((i) => i.sev === "warn").length; + + expect(diagnostics.errors).toBe(errors); + expect(diagnostics.warnings).toBe(warnings); + expect(diagnostics.errors + diagnostics.warnings).toBe( + diagnostics.issues.length, + ); + }); +}); + +describe("lintDsl — dangling-binding check is dormant until M4", () => { + const dslWithBinding = canvas([ + widget({ widgetId: "a", widgetName: "A", label: "{{ getUsers.data }}" }), + ]); + + it("stays silent with no known data names (M1)", () => { + expect(lintDsl(dslWithBinding).issues).toHaveLength(0); + }); + + it("fires when known data names are supplied and the reference is unknown (M4 activation)", () => { + const diagnostics = lintDsl(dslWithBinding, { knownDataNames: ["other"] }); + + expect(diagnostics.issues.map((i) => i.rule)).toContain("dangling-binding"); + }); + + it("stays clean when the reference is a declared data name", () => { + const diagnostics = lintDsl(dslWithBinding, { + knownDataNames: ["getUsers"], + }); + + expect(diagnostics.errors).toBe(0); + }); +}); + +describe("lintArtifact — compiler output is lint-clean", () => { + it("produces zero errors and warnings for a compiled app", () => { + 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" }], + }, + { type: "table", name: "Records", data: [{ id: 1 }] }, + ], + }, + ], + }, + sequentialIdGenerator(), + ); + const diagnostics = lintArtifact(artifact); + + expect(diagnostics.errors).toBe(0); + expect(diagnostics.warnings).toBe(0); + expect(Object.keys(diagnostics.pages)).toContain("Home"); + }); +}); diff --git a/app/client/packages/mcp/src/builder/lint.ts b/app/client/packages/mcp/src/builder/lint.ts new file mode 100644 index 000000000000..9fc41835f277 --- /dev/null +++ b/app/client/packages/mcp/src/builder/lint.ts @@ -0,0 +1,342 @@ +import { GRID_COLUMNS, ROOT_WIDGET_ID, type WidgetNode } from "./layout.js"; + +// Static, deterministic structural linter for a compiled page DSL. It reuses the compiler's grid geometry to catch +// the classes of defect the builder can produce (bad placement, tree corruption, duplicate names) WITHOUT a browser +// or runtime evaluation. This is the feedback signal an AI agent iterates against: build -> inspect -> fix. + +export type Severity = "error" | "warn"; + +export interface Issue { + sev: Severity; + rule: string; + msg: string; + // The offending widget's name, when the issue is attributable to one. + widget?: string; +} + +export interface Diagnostics { + errors: number; + warnings: number; + issues: Issue[]; +} + +export interface LintOptions { + // Names the agent has declared (queries/JS objects). When provided, `{{ name.* }}` bindings that reference an + // unknown name are flagged. Dormant until the data layer (M4) supplies query names. + knownDataNames?: Iterable; +} + +const CANVAS_TYPE = "CANVAS_WIDGET"; +const CONTAINER_TYPES = new Set(["CONTAINER_WIDGET", "FORM_WIDGET"]); +const BINDING_PATTERN = /\{\{([\s\S]*?)\}\}/g; +// Leading identifier of a binding expression, e.g. `getUsers` in `{{ getUsers.data }}`. +const BINDING_HEAD = /^\s*([A-Za-z_$][A-Za-z0-9_$]*)/; + +function isNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function rangesOverlap( + aStart: number, + aEnd: number, + bStart: number, + bEnd: number, +): boolean { + return aStart < bEnd && bStart < aEnd; +} + +// The grid width available to a canvas's direct children: the root canvas is measured in the 64-column page grid, +// while an inner canvas is measured in its own column count (mirrors compile.ts's availableColumns). +function canvasColumns(canvas: WidgetNode): number { + return canvas.widgetId === ROOT_WIDGET_ID + ? GRID_COLUMNS + : isNumber(canvas.rightColumn) + ? canvas.rightColumn + : GRID_COLUMNS; +} + +function childCanvasOf(node: WidgetNode): WidgetNode | undefined { + return node.children?.find((child) => child.type === CANVAS_TYPE); +} + +function nameOf(node: WidgetNode): string { + return typeof node.widgetName === "string" ? node.widgetName : node.widgetId; +} + +// Collect every string leaf under a node's OWN props (not descendants), so binding scans don't double-count nested +// widgets. Children are walked separately by the tree traversal. +function ownStringProps(node: WidgetNode): string[] { + const strings: string[] = []; + + for (const [key, value] of Object.entries(node)) { + if (key === "children") continue; + + if (typeof value === "string") strings.push(value); + } + + return strings; +} + +class Linter { + private readonly issues: Issue[] = []; + private readonly seenNames = new Map(); + private readonly seenIds = new Set(); + private readonly knownDataNames: Set; + + constructor(options: LintOptions) { + this.knownDataNames = new Set(options.knownDataNames ?? []); + } + + lint(root: WidgetNode): Diagnostics { + if (!root || typeof root !== "object") { + this.error("tree", "page has no root widget"); + + return this.result(); + } + + this.walk(root); + this.checkDuplicateNames(); + + return this.result(); + } + + private walk(node: WidgetNode): void { + this.checkIdentity(node); + this.checkBindings(node); + + // Geometry checks apply to the direct children laid out on a canvas. + if (node.type === CANVAS_TYPE) this.checkCanvasChildren(node); + + // A container's own box must be tall enough to hold its inner canvas's extent. + if (CONTAINER_TYPES.has(node.type)) this.checkContainerHeight(node); + + for (const child of node.children ?? []) this.walk(child); + } + + private checkIdentity(node: WidgetNode): void { + const name = nameOf(node); + + if (typeof node.widgetId === "string") { + if (this.seenIds.has(node.widgetId)) { + this.error( + "duplicate-id", + `widget id "${node.widgetId}" appears more than once (tree corruption)`, + name, + ); + } + + this.seenIds.add(node.widgetId); + } + + if (typeof node.widgetName === "string") { + this.seenNames.set( + node.widgetName, + (this.seenNames.get(node.widgetName) ?? 0) + 1, + ); + + if (!/^[A-Za-z0-9_ ]+$/.test(node.widgetName)) { + this.warn( + "invalid-name", + `widget name "${node.widgetName}" has characters Appsmith may reject`, + name, + ); + } + } + } + + private checkCanvasChildren(canvas: WidgetNode): void { + const children = canvas.children ?? []; + const columns = canvasColumns(canvas); + + for (const child of children) { + const name = nameOf(child); + + if (isNumber(child.bottomRow) && isNumber(child.topRow)) { + if (child.bottomRow <= child.topRow) { + this.error( + "zero-height", + `"${name}" has non-positive height (topRow ${child.topRow} >= bottomRow ${child.bottomRow})`, + name, + ); + } + } + + if (isNumber(child.rightColumn) && child.rightColumn > columns) { + this.warn( + "off-grid", + `"${name}" extends past the grid (rightColumn ${child.rightColumn} > ${columns})`, + name, + ); + } + } + + // Sibling overlap: any two children whose column AND row ranges intersect. + for (let i = 0; i < children.length; i += 1) { + for (let j = i + 1; j < children.length; j += 1) { + const a = children[i]; + const b = children[j]; + + if ( + isNumber(a.leftColumn) && + isNumber(a.rightColumn) && + isNumber(a.topRow) && + isNumber(a.bottomRow) && + isNumber(b.leftColumn) && + isNumber(b.rightColumn) && + isNumber(b.topRow) && + isNumber(b.bottomRow) && + rangesOverlap( + a.leftColumn, + a.rightColumn, + b.leftColumn, + b.rightColumn, + ) && + rangesOverlap(a.topRow, a.bottomRow, b.topRow, b.bottomRow) + ) { + this.warn( + "overlap", + `"${nameOf(a)}" and "${nameOf(b)}" overlap on the same canvas`, + nameOf(a), + ); + } + } + } + } + + private checkContainerHeight(container: WidgetNode): void { + const inner = childCanvasOf(container); + + if ( + !inner || + !isNumber(container.topRow) || + !isNumber(container.bottomRow) || + !isNumber(inner.bottomRow) + ) { + return; + } + + const containerHeight = container.bottomRow - container.topRow; + + if (containerHeight < inner.bottomRow) { + this.warn( + "container-clips", + `"${nameOf(container)}" is shorter than its content (height ${containerHeight} < inner extent ${inner.bottomRow}); inner widgets will be clipped`, + nameOf(container), + ); + } + } + + private checkBindings(node: WidgetNode): void { + for (const value of ownStringProps(node)) { + BINDING_PATTERN.lastIndex = 0; + + let match: RegExpExecArray | null; + + while ((match = BINDING_PATTERN.exec(value)) !== null) { + const head = BINDING_HEAD.exec(match[1]); + + if (!head) continue; + + const reference = head[1]; + + // Only flag when we have a known-name set to check against (M4). `appsmith`/`moment`/JS globals are always + // considered valid heads and skipped. + if ( + this.knownDataNames.size > 0 && + !this.knownDataNames.has(reference) && + !GLOBAL_BINDING_HEADS.has(reference) + ) { + this.error( + "dangling-binding", + `binding {{ ${match[1].trim()} }} references unknown "${reference}"`, + nameOf(node), + ); + } + } + } + } + + private checkDuplicateNames(): void { + for (const [name, count] of this.seenNames) { + if (count > 1) { + this.error( + "duplicate-name", + `widget name "${name}" is used ${count} times (Appsmith requires unique names)`, + name, + ); + } + } + } + + private error(rule: string, msg: string, widget?: string): void { + this.issues.push({ sev: "error", rule, msg, widget }); + } + + private warn(rule: string, msg: string, widget?: string): void { + this.issues.push({ sev: "warn", rule, msg, widget }); + } + + private result(): Diagnostics { + const errors = this.issues.filter((issue) => issue.sev === "error").length; + + return { + errors, + warnings: this.issues.length - errors, + issues: this.issues, + }; + } +} + +// Binding heads that are always valid: JS/Appsmith globals the agent may legitimately reference. +const GLOBAL_BINDING_HEADS = new Set([ + "appsmith", + "moment", + "Math", + "Object", + "Array", + "JSON", + "Number", + "String", + "Boolean", + "Date", + "window", +]); + +export function lintDsl( + root: WidgetNode, + options: LintOptions = {}, +): Diagnostics { + return new Linter(options).lint(root); +} + +// Lint every page DSL inside a compiled import artifact (pageList[].unpublishedPage.layouts[0].dsl). Returns a +// per-page diagnostics map plus a rolled-up total, so build_application can report structural health inline. +export function lintArtifact( + artifact: Record, + options: LintOptions = {}, +): { errors: number; warnings: number; pages: Record } { + const pages: Record = {}; + let errors = 0; + let warnings = 0; + + const pageList = Array.isArray(artifact.pageList) ? artifact.pageList : []; + + for (const page of pageList) { + const unpublished = (page as { unpublishedPage?: unknown }) + .unpublishedPage as + | { name?: string; layouts?: { dsl?: WidgetNode }[] } + | undefined; + const dsl = unpublished?.layouts?.[0]?.dsl; + const name = unpublished?.name ?? "page"; + + if (!dsl) continue; + + const diagnostics = lintDsl(dsl, options); + + pages[name] = diagnostics; + errors += diagnostics.errors; + warnings += diagnostics.warnings; + } + + return { errors, warnings, pages }; +} diff --git a/app/client/packages/mcp/src/builder/presets.ts b/app/client/packages/mcp/src/builder/presets.ts index b498eeb074bb..2572c28b2131 100644 --- a/app/client/packages/mcp/src/builder/presets.ts +++ b/app/client/packages/mcp/src/builder/presets.ts @@ -32,7 +32,7 @@ export const PRESETS: Record = { spec: { name: "Records", widgets: [ - { type: "table", name: "RecordsTable", data: "" }, + { type: "table", name: "RecordsTable" }, { type: "container", name: "DetailsCard", @@ -78,7 +78,7 @@ export const PRESETS: Record = { spec: { name: "Manage", widgets: [ - { type: "table", name: "Records", data: "" }, + { type: "table", name: "Records" }, { type: "input", name: "EditName", label: "Name" }, { type: "input", diff --git a/app/client/packages/mcp/src/builder/query.test.ts b/app/client/packages/mcp/src/builder/query.test.ts new file mode 100644 index 000000000000..59be80fda826 --- /dev/null +++ b/app/client/packages/mcp/src/builder/query.test.ts @@ -0,0 +1,169 @@ +import { buildActionDto, compileQuery, querySpecSchema } from "./query.js"; + +function parse(spec: unknown) { + const result = querySpecSchema.safeParse(spec); + + if (!result.success) throw new Error("spec did not parse"); + + return result.data; +} + +const base = { + name: "getUsers", + applicationId: "app1", + pageId: "p1", + datasourceId: "ds1", +}; + +describe("compileQuery — parameterized SQL, no raw injection", () => { + it("compiles a SELECT with columns, a filter, and a limit", () => { + const body = compileQuery( + parse({ + ...base, + operation: "SELECT", + table: "users", + columns: ["id", "name"], + filters: [{ column: "status", op: "eq", value: { literal: "active" } }], + limit: 50, + }), + ); + + expect(body).toBe( + 'SELECT id, name FROM users WHERE status = {{ "active" }} LIMIT 50;', + ); + }); + + it("emits a widget reference as an identifier binding", () => { + const body = compileQuery( + parse({ + ...base, + operation: "SELECT", + table: "orders", + filters: [ + { + column: "id", + op: "eq", + value: { widget: "Table1", property: "selectedRow.id" }, + }, + ], + }), + ); + + expect(body).toBe( + "SELECT * FROM orders WHERE id = {{ Table1.selectedRow.id }};", + ); + }); + + it("compiles INSERT / UPDATE / DELETE with bind parameters", () => { + const insert = compileQuery( + parse({ + ...base, + operation: "INSERT", + table: "users", + values: [ + { column: "name", value: { literal: "Ada" } }, + { column: "age", value: { literal: 30 } }, + ], + }), + ); + const update = compileQuery( + parse({ + ...base, + operation: "UPDATE", + table: "users", + values: [{ column: "name", value: { literal: "Ada" } }], + filters: [{ column: "id", op: "eq", value: { literal: 1 } }], + }), + ); + const del = compileQuery( + parse({ + ...base, + operation: "DELETE", + table: "users", + filters: [{ column: "id", op: "eq", value: { literal: 1 } }], + }), + ); + + expect(insert).toBe( + 'INSERT INTO users (name, age) VALUES ({{ "Ada" }}, {{ 30 }});', + ); + expect(update).toBe( + 'UPDATE users SET name = {{ "Ada" }} WHERE id = {{ 1 }};', + ); + expect(del).toBe("DELETE FROM users WHERE id = {{ 1 }};"); + }); + + it("accepts a schema-qualified table name", () => { + const body = compileQuery( + parse({ ...base, operation: "SELECT", table: "public.users" }), + ); + + expect(body).toBe("SELECT * FROM public.users;"); + }); +}); + +describe("querySpecSchema — rejects anything that could inject", () => { + const bad: [string, unknown][] = [ + ["semicolon in table", { table: "users; DROP TABLE users" }], + ["quote in table", { table: 'users"' }], + ["comment token in column", { columns: ["id--"] }], + ["DDL operation", { operation: "DROP" }], + [ + "raw expression literal", + { + filters: [{ column: "a", op: "eq", value: { literal: "{{evil()}}" } }], + }, + ], + [ + "backtick literal", + { filters: [{ column: "a", op: "eq", value: { literal: "`x`" } }] }, + ], + ["non-identifier query name", { name: "get users" }], + [ + "bad widget property", + { + filters: [ + { column: "a", op: "eq", value: { widget: "T", property: "a;b" } }, + ], + }, + ], + [ + "unknown operator", + { filters: [{ column: "a", op: "regex", value: { literal: 1 } }] }, + ], + ]; + + it.each(bad)("rejects %s", (_label, override) => { + const result = querySpecSchema.safeParse({ + ...base, + operation: "SELECT", + table: "users", + ...(override as Record), + }); + + expect(result.success).toBe(false); + }); +}); + +describe("buildActionDto", () => { + it("embeds the datasource as { id } and forces prepared statements on", () => { + const spec = parse({ ...base, operation: "SELECT", table: "users" }); + const dto = buildActionDto(spec, "SELECT * FROM users;") as { + name: string; + pageId: string; + datasource: { id: string }; + actionConfiguration: { + body: string; + pluginSpecifiedTemplates: { value: boolean }[]; + }; + }; + + expect(dto.name).toBe("getUsers"); + expect(dto.pageId).toBe("p1"); + expect(dto.datasource).toEqual({ id: "ds1" }); + expect(dto.actionConfiguration.body).toBe("SELECT * FROM users;"); + expect(dto.actionConfiguration.pluginSpecifiedTemplates[0].value).toBe( + true, + ); + }); +}); diff --git a/app/client/packages/mcp/src/builder/query.ts b/app/client/packages/mcp/src/builder/query.ts new file mode 100644 index 000000000000..b791f62c6144 --- /dev/null +++ b/app/client/packages/mcp/src/builder/query.ts @@ -0,0 +1,230 @@ +import { z } from "zod"; + +// M4 create_query — a STRUCTURED query builder (Security ruling "Option B"). The agent never authors raw SQL or raw +// `{{ }}`. The compiler emits the SQL text AND every binding from validated identifiers, and every value is emitted +// as a `{{ }}` bind parameter (prepared statement), never string-concatenated. This bounds SQL injection (values are +// parameterized, identifiers are allow-listed) and preserves the "agents never author raw expressions" invariant. + +// SQL identifier: a table/column/schema name. No quotes, semicolons, whitespace, comment tokens, or brace/`$`. +const SQL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; +const sqlIdentifier = z + .string() + .min(1) + .max(128) + .regex(SQL_IDENTIFIER, "must be a plain SQL identifier"); + +// A qualified name is `table` or `schema.table` — each part a plain identifier. +const qualifiedName = z + .string() + .min(1) + .max(257) + .refine( + (value) => value.split(".").every((part) => SQL_IDENTIFIER.test(part)), + "must be table or schema.table (plain identifiers)", + ); + +// Binding identifier (query/widget name), same rule the widget-binding vocabulary uses. +const bindingIdentifier = z + .string() + .min(1) + .max(64) + .regex(/^[A-Za-z0-9_]+$/, "must be alphanumeric/underscore"); + +// A property path on a widget, e.g. `selectedRow.id`. Dotted plain identifiers only. +const propertyPath = z + .string() + .min(1) + .max(128) + .regex(/^[A-Za-z_][A-Za-z0-9_.]*$/, "must be a dotted identifier path"); + +// A scalar literal that cannot contain expression/template syntax (mirrors the schema's scalarCell). +const RAW_EXPRESSION = /\{\{|\}\}|\$\{|`/; +const literalScalar = z.union([ + z + .string() + .max(1000) + .refine((value) => !RAW_EXPRESSION.test(value), "no template syntax"), + z.number(), + z.boolean(), + z.null(), +]); + +// The ONLY place dynamic data enters a query: a literal (emitted as a bind param) or a widget reference. +const valueRef = z.union([ + z.object({ literal: literalScalar }).strict(), + z.object({ widget: bindingIdentifier, property: propertyPath }).strict(), +]); + +const OPERATORS = { + eq: "=", + ne: "!=", + gt: ">", + gte: ">=", + lt: "<", + lte: "<=", + like: "LIKE", + in: "IN", +} as const; + +export const querySpecSchema = z + .object({ + name: bindingIdentifier, + // NOTE: no workspaceId — the tool resolves the workspace server-authoritatively from applicationId so a + // prompt-injected agent cannot bind a datasource from another workspace onto this page (cross-tenant guard). + applicationId: z.string().min(1).max(128), + pageId: z.string().min(1).max(128), + datasourceId: z.string().min(1).max(128), + operation: z.enum(["SELECT", "INSERT", "UPDATE", "DELETE"]), + table: qualifiedName, + columns: z.array(sqlIdentifier).max(100).optional(), + filters: z + .array( + z + .object({ + column: sqlIdentifier, + op: z.enum(["eq", "ne", "gt", "gte", "lt", "lte", "like", "in"]), + value: valueRef, + }) + .strict(), + ) + .max(20) + .optional(), + values: z + .array(z.object({ column: sqlIdentifier, value: valueRef }).strict()) + .max(100) + .optional(), + limit: z.number().int().min(1).max(1000).optional(), + }) + .strict(); + +export type QuerySpec = z.infer; +type ValueRef = z.infer; + +const MAX_BODY_BYTES = 8 * 1024; + +// Emit a value as a `{{ }}` bind parameter. A literal is JSON-encoded (safe: it passed the no-raw-expression gate, so +// it cannot contain braces); a widget reference is a validated identifier path. Either way the braces are +// compiler-authored and the inner content cannot break out. +function emitBinding(value: ValueRef): string { + const binding = + "literal" in value + ? `{{ ${JSON.stringify(value.literal)} }}` + : `{{ ${value.widget}.${value.property} }}`; + + // Fail-closed at the point of construction, where this is unambiguously a single binding (a body-level scan can be + // fooled by a `}` inside a string literal). Every emitted binding must be an identifier path or a JSON scalar. + if (!SAFE_BINDING.test(binding)) { + throw new Error(`unsafe binding emitted: ${binding}`); + } + + return binding; +} + +function emitWhere(filters: QuerySpec["filters"]): string { + if (!filters || filters.length === 0) return ""; + + const clauses = filters.map((filter) => { + const operator = OPERATORS[filter.op]; + const binding = emitBinding(filter.value); + + if (filter.op === "in") return `${filter.column} IN (${binding})`; + + return `${filter.column} ${operator} ${binding}`; + }); + + return ` WHERE ${clauses.join(" AND ")}`; +} + +// Compile a structured spec into a parameterized SQL body. Throws on an empty/oversized body or if the emitted body +// somehow contains a brace pair that isn't a compiler-emitted binding (fail-closed defense-in-depth). +export function compileQuery(spec: QuerySpec): string { + let body: string; + + switch (spec.operation) { + case "SELECT": { + const columns = + spec.columns && spec.columns.length > 0 ? spec.columns.join(", ") : "*"; + const limit = spec.limit !== undefined ? ` LIMIT ${spec.limit}` : ""; + + body = `SELECT ${columns} FROM ${spec.table}${emitWhere(spec.filters)}${limit};`; + break; + } + case "INSERT": { + if (!spec.values || spec.values.length === 0) { + throw new Error("INSERT requires values"); + } + + const columns = spec.values.map((entry) => entry.column).join(", "); + const bindings = spec.values + .map((entry) => emitBinding(entry.value)) + .join(", "); + + body = `INSERT INTO ${spec.table} (${columns}) VALUES (${bindings});`; + break; + } + case "UPDATE": { + if (!spec.values || spec.values.length === 0) { + throw new Error("UPDATE requires values"); + } + + const assignments = spec.values + .map((entry) => `${entry.column} = ${emitBinding(entry.value)}`) + .join(", "); + + body = `UPDATE ${spec.table} SET ${assignments}${emitWhere(spec.filters)};`; + break; + } + case "DELETE": { + body = `DELETE FROM ${spec.table}${emitWhere(spec.filters)};`; + break; + } + } + + assertBodySafe(body); + + return body; +} + +// Every `{{ ... }}` in the compiled body must be a compiler-emitted binding: either a dotted identifier path or a +// JSON scalar literal. Anything else (or a stray `${`/backtick) means a bug or an escape — reject. +const SAFE_BINDING = + /^\{\{ (?:[A-Za-z_][A-Za-z0-9_.]*|"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?|true|false|null) \}\}$/; + +function assertBodySafe(body: string): void { + if (body.length === 0) throw new Error("compiled query is empty"); + + if (Buffer.byteLength(body, "utf8") > MAX_BODY_BYTES) { + throw new Error("compiled query exceeds the size limit"); + } + + if (/\$\{|`/.test(body)) { + throw new Error("compiled query contains forbidden template syntax"); + } + + // Each binding is validated at emission (emitBinding), so here we only guard the aggregate: `{{` and `}}` must be + // balanced. Unbalanced braces would mean a bug produced a malformed body — reject rather than persist it. + const opens = (body.match(/\{\{/g) ?? []).length; + const closes = (body.match(/\}\}/g) ?? []).length; + + if (opens !== closes) { + throw new Error("compiled query has unbalanced bindings"); + } +} + +// Build the ActionDTO for POST /api/v1/actions. Datasource is an EMBEDDED { id } object (server derives +// workspace/plugin from it); prepared statements are forced ON so values bind as parameters. +export function buildActionDto( + spec: QuerySpec, + body: string, +): Record { + return { + name: spec.name, + pageId: spec.pageId, + datasource: { id: spec.datasourceId }, + actionConfiguration: { + body, + // SQL plugins read the prepared-statement flag from pluginSpecifiedTemplates[0].value. + pluginSpecifiedTemplates: [{ value: true }], + }, + }; +} diff --git a/app/client/packages/mcp/src/builder/schema.ts b/app/client/packages/mcp/src/builder/schema.ts index ebe9843ab140..73d43d483db5 100644 --- a/app/client/packages/mcp/src/builder/schema.ts +++ b/app/client/packages/mcp/src/builder/schema.ts @@ -11,6 +11,12 @@ export const WIDGET_TYPES = [ "image", "table", "container", + "form", + "modal", + "datepicker", + "chart", + "tabs", + "list", ] as const; export type WidgetType = (typeof WIDGET_TYPES)[number]; @@ -22,7 +28,16 @@ export const placementSchema = z after: z.string().min(1).optional(), inside: z.string().min(1).optional(), }) - .strict(); + .strict() + // `after` and `inside` are mutually exclusive — they resolve to different canvases, so accepting both would let + // one silently win. Either alone, or neither, is still valid. + .refine( + (placement) => + !(placement.after !== undefined && placement.inside !== undefined), + { + message: "placement cannot set both 'after' and 'inside'", + }, + ); export type Placement = z.infer; @@ -34,9 +49,66 @@ const nameField = z .regex(/^[A-Za-z0-9_]+$/, "name must be alphanumeric/underscore") .optional(); +// Security invariant (Security Reviewer ruling): agents NEVER author raw expressions. Every free-text field an agent +// supplies is rejected if it contains binding/interpolation/template syntax, so nothing agent-authored can become an +// evaluated `{{ }}` expression in a viewer's eval worker. Dynamic data arrives only via the compiler's closed +// binding vocabulary (structured refs -> `{{ query.data }}`), never as passthrough text. +const RAW_EXPRESSION = /\{\{|\}\}|\$\{|`/; + +function safeText(max: number) { + return z + .string() + .max(max) + .refine((value) => !RAW_EXPRESSION.test(value), { + message: + "must not contain binding/template syntax ({{ }}, ${ }, or backticks)", + }); +} + +const scalarCell = z.union([safeText(1000), z.number(), z.boolean(), z.null()]); + +// Column keys are agent-supplied strings that the TableWidgetV2 client embeds into a generated `{{ }}` column +// binding, where the downstream escape does NOT neutralize `}}`/`${`/backtick/backslash/quote. So keys need a +// STRICTER gate than values: a conservative identifier charset that admits none of those characters. +const columnKey = z + .string() + .min(1) + .max(100) + .regex( + /^[A-Za-z0-9_ ]+$/, + "table column names must be alphanumeric, underscore, or space", + ); + +// A literal table row: a flat object of scalar cells keyed by safe column names. Static data only — never +// registered as a dynamic binding. +const tableRow = z.record(columnKey, scalarCell); + +// M4 closed binding vocabulary. The agent supplies a structured reference to a named query — never raw expression +// text. The name is a strict identifier so the compiler-emitted `{{ .data }}` / `{{ .run() }}` cannot be +// broken out of. This is the ONLY way agent input becomes a dynamic binding. +const bindingIdentifier = z + .string() + .min(1) + .max(64) + .regex(/^[A-Za-z0-9_]+$/, "must be a query name (alphanumeric/underscore)"); + +const queryRef = z.object({ query: bindingIdentifier }).strict(); +const runRef = z.object({ run: bindingIdentifier }).strict(); + +// Chart series: a named series of {x,y} points. Static data (x label is safe text, y is a number). +const chartPoint = z + .object({ x: z.union([safeText(200), z.number()]), y: z.number() }) + .strict(); +const chartSeries = z + .object({ + name: safeText(200).optional(), + points: z.array(chartPoint).max(1000).optional(), + }) + .strict(); + const selectOption = z.object({ - label: z.string().min(1), - value: z.union([z.string(), z.number()]), + label: safeText(200).pipe(z.string().min(1)), + value: z.union([safeText(200), z.number()]), }); // Recursive: a container can hold child widgets. z.lazy handles the self-reference. @@ -46,7 +118,7 @@ export const widgetSpecSchema: z.ZodType = z.lazy(() => .object({ type: z.literal("text"), name: nameField, - text: z.string().max(10000).optional(), + text: safeText(10000).optional(), placement: placementSchema.optional(), }) .strict(), @@ -54,7 +126,7 @@ export const widgetSpecSchema: z.ZodType = z.lazy(() => .object({ type: z.literal("input"), name: nameField, - label: z.string().max(200).optional(), + label: safeText(200).optional(), inputType: z.enum(["TEXT", "NUMBER", "EMAIL", "PASSWORD"]).optional(), placement: placementSchema.optional(), }) @@ -63,7 +135,7 @@ export const widgetSpecSchema: z.ZodType = z.lazy(() => .object({ type: z.literal("select"), name: nameField, - label: z.string().max(200).optional(), + label: safeText(200).optional(), options: z.array(selectOption).max(200).optional(), placement: placementSchema.optional(), }) @@ -72,7 +144,9 @@ export const widgetSpecSchema: z.ZodType = z.lazy(() => .object({ type: z.literal("button"), name: nameField, - text: z.string().max(200).optional(), + text: safeText(200).optional(), + // M4: run a named query on click. Compiler emits `{{ .run() }}`; nothing raw is accepted. + onClick: runRef.optional(), placement: placementSchema.optional(), }) .strict(), @@ -80,7 +154,7 @@ export const widgetSpecSchema: z.ZodType = z.lazy(() => .object({ type: z.literal("image"), name: nameField, - image: z.string().max(2000).optional(), + image: safeText(2000).optional(), placement: placementSchema.optional(), }) .strict(), @@ -88,9 +162,15 @@ export const widgetSpecSchema: z.ZodType = z.lazy(() => .object({ type: z.literal("table"), name: nameField, - data: z.string().max(20000).optional(), + // Static, literal rows only — never a free string registered as a dynamic binding (that was a live + // injection hole). + data: z.array(tableRow).max(1000).optional(), + // M4: bind the table to a named query instead of static rows. Compiler emits `{{ .data }}`. + source: queryRef.optional(), placement: placementSchema.optional(), }) + // NOTE: `data` and `source` are mutually exclusive; enforced in the compiler (a `.refine` here would turn the + // arm into a ZodEffects, which z.discriminatedUnion rejects). .strict(), z .object({ @@ -100,6 +180,80 @@ export const widgetSpecSchema: z.ZodType = z.lazy(() => placement: placementSchema.optional(), }) .strict(), + z + .object({ + type: z.literal("form"), + name: nameField, + // A form groups inputs with a submit button; children are the fields inside it. + submitLabel: safeText(200).optional(), + children: z.array(widgetSpecSchema).max(50).optional(), + placement: placementSchema.optional(), + }) + .strict(), + z + .object({ + type: z.literal("modal"), + name: nameField, + title: safeText(200).optional(), + children: z.array(widgetSpecSchema).max(50).optional(), + placement: placementSchema.optional(), + }) + .strict(), + z + .object({ + type: z.literal("datepicker"), + name: nameField, + label: safeText(200).optional(), + placement: placementSchema.optional(), + }) + .strict(), + z + .object({ + type: z.literal("chart"), + name: nameField, + title: safeText(200).optional(), + chartType: z + .enum([ + "LINE_CHART", + "BAR_CHART", + "PIE_CHART", + "COLUMN_CHART", + "AREA_CHART", + ]) + .optional(), + series: z.array(chartSeries).max(10).optional(), + placement: placementSchema.optional(), + }) + .strict(), + z + .object({ + type: z.literal("tabs"), + name: nameField, + // Each tab has a label and its own inner canvas of children. + tabs: z + .array( + z + .object({ + label: safeText(120), + children: z.array(widgetSpecSchema).max(50).optional(), + }) + .strict(), + ) + .min(1) + .max(10) + .optional(), + placement: placementSchema.optional(), + }) + .strict(), + z + .object({ + type: z.literal("list"), + name: nameField, + // The children form one list item template, repeated for each data row. + children: z.array(widgetSpecSchema).max(30).optional(), + placement: placementSchema.optional(), + }) + .strict(), ]), ); @@ -108,6 +262,9 @@ export interface PlacementSpec { inside?: string; } +export type TableCell = string | number | boolean | null; +export type TableRow = Record; + export type WidgetSpec = | { type: "text"; name?: string; text?: string; placement?: PlacementSpec } | { @@ -124,14 +281,74 @@ export type WidgetSpec = options?: { label: string; value: string | number }[]; placement?: PlacementSpec; } - | { type: "button"; name?: string; text?: string; placement?: PlacementSpec } + | { + type: "button"; + name?: string; + text?: string; + onClick?: { run: string }; + placement?: PlacementSpec; + } | { type: "image"; name?: string; image?: string; placement?: PlacementSpec } - | { type: "table"; name?: string; data?: string; placement?: PlacementSpec } + | { + type: "table"; + name?: string; + data?: TableRow[]; + source?: { query: string }; + placement?: PlacementSpec; + } | { type: "container"; name?: string; children?: WidgetSpec[]; placement?: PlacementSpec; + } + | { + type: "form"; + name?: string; + submitLabel?: string; + children?: WidgetSpec[]; + placement?: PlacementSpec; + } + | { + type: "modal"; + name?: string; + title?: string; + children?: WidgetSpec[]; + placement?: PlacementSpec; + } + | { + type: "datepicker"; + name?: string; + label?: string; + placement?: PlacementSpec; + } + | { + type: "chart"; + name?: string; + title?: string; + chartType?: + | "LINE_CHART" + | "BAR_CHART" + | "PIE_CHART" + | "COLUMN_CHART" + | "AREA_CHART"; + series?: { + name?: string; + points?: { x: string | number; y: number }[]; + }[]; + placement?: PlacementSpec; + } + | { + type: "tabs"; + name?: string; + tabs?: { label: string; children?: WidgetSpec[] }[]; + placement?: PlacementSpec; + } + | { + type: "list"; + name?: string; + children?: WidgetSpec[]; + placement?: PlacementSpec; }; export const pageSpecSchema = z @@ -148,9 +365,36 @@ export const pageSpecSchema = z export type PageSpec = z.infer; +// App-level theme tokens. Strict formats (hex color, numeric+unit radius, a safe font-name charset) so no token can +// carry expression/injection syntax into widget style props. +export const themeSchema = z + .object({ + primaryColor: z + .string() + .regex(/^#[0-9A-Fa-f]{3,8}$/, "primaryColor must be a hex color") + .optional(), + borderRadius: z + .string() + .regex( + /^\d+(\.\d+)?(px|rem|em)$/, + "borderRadius must be like 8px or 0.5rem", + ) + .optional(), + fontFamily: z + .string() + .max(60) + .regex(/^[A-Za-z0-9 ,'-]+$/, "fontFamily has unexpected characters") + .optional(), + }) + .strict(); + +export type Theme = z.infer; + export const appSpecSchema = z .object({ - name: z.string().trim().min(1).max(99), + // Defense-in-depth: the app name isn't eval-reachable, but no agent string escapes the no-raw-expression rule. + name: safeText(99).pipe(z.string().trim().min(1)), + theme: themeSchema.optional(), pages: z.array(pageSpecSchema).min(1).max(20), }) .strict(); diff --git a/app/client/packages/mcp/src/builder/templates.ts b/app/client/packages/mcp/src/builder/templates.ts index b25f44900904..c9c69d638019 100644 --- a/app/client/packages/mcp/src/builder/templates.ts +++ b/app/client/packages/mcp/src/builder/templates.ts @@ -26,10 +26,6 @@ export interface WidgetTemplate { build: (spec: WidgetSpec) => BuiltWidget; } -function bindingPaths(...keys: string[]) { - return keys.map((key) => ({ key })); -} - export const WIDGET_TEMPLATES: Record = { text: { appsmithType: "TEXT_WIDGET", @@ -124,6 +120,11 @@ export const WIDGET_TEMPLATES: Record = { footprint: { columns: 16, rows: 4 }, build: (spec) => { const text = spec.type === "button" ? spec.text ?? "Submit" : "Submit"; + const run = spec.type === "button" ? spec.onClick?.run : undefined; + + // M4: a bound onClick runs a named query via the closed vocabulary (`{{ .run() }}`), registered as a + // dynamic trigger. Unbound, onClick is an inert stub (empty, no trigger path) — nothing is evaluated. + const onClick = run !== undefined ? `{{ ${run}.run() }}` : ""; return { footprint: { columns: 16, rows: 4 }, @@ -134,6 +135,8 @@ export const WIDGET_TEMPLATES: Record = { isDisabled: false, isDefaultClickDisabled: true, recaptchaType: "V3", + onClick, + dynamicTriggerPathList: run !== undefined ? [{ key: "onClick" }] : [], animateLoading: true, responsiveBehavior: "hug", }, @@ -169,12 +172,27 @@ export const WIDGET_TEMPLATES: Record = { version: 2, footprint: { columns: 40, rows: 28 }, build: (spec) => { - const data = spec.type === "table" ? spec.data ?? "" : ""; + const rows = spec.type === "table" ? spec.data ?? [] : []; + const source = spec.type === "table" ? spec.source : undefined; + + if (source !== undefined && rows.length > 0) { + throw new Error("table cannot set both 'data' and 'source'"); + } + + // Two safe data origins: (1) static literal rows serialized as JSON with NO binding, or (2) a query source + // compiled to `{{ .data }}` from the closed vocabulary (the query name is a strict identifier, so the + // expression cannot be broken out of). Agents never author raw expression text either way. + const bound = source !== undefined; + const tableData = bound + ? `{{ ${source.query}.data }}` + : rows.length > 0 + ? JSON.stringify(rows) + : ""; return { footprint: { columns: 40, rows: 28 }, props: { - tableData: data, + tableData, primaryColumns: {}, columnOrder: [], columnWidthMap: {}, @@ -199,7 +217,8 @@ export const WIDGET_TEMPLATES: Record = { inlineEditingSaveOption: "ROW_LEVEL", animateLoading: true, responsiveBehavior: "fill", - dynamicBindingPathList: data ? bindingPaths("tableData") : [], + // A query source is a real dynamic binding; static rows are not. + dynamicBindingPathList: bound ? [{ key: "tableData" }] : [], dynamicPropertyPathList: [], }, }; @@ -228,4 +247,172 @@ export const WIDGET_TEMPLATES: Record = { }; }, }, + + form: { + appsmithType: "FORM_WIDGET", + version: 1, + footprint: { columns: 40, rows: 34 }, + build: (spec) => { + const fields = spec.type === "form" ? spec.children ?? [] : []; + const submitLabel = + spec.type === "form" ? spec.submitLabel ?? "Submit" : "Submit"; + + // A form is a container that ends with a submit button. The synthetic button is a real child spec, compiled + // like any other widget; its onClick stays an inert stub until the data layer wires submission. + const children: WidgetSpec[] = [ + ...fields, + { type: "button", text: submitLabel }, + ]; + + return { + footprint: { columns: 40, rows: 34 }, + children, + props: { + backgroundColor: "#FFFFFF", + borderColor: "#E0DEDE", + borderWidth: "1", + boxShadow: "NONE", + animateLoading: true, + responsiveBehavior: "fill", + }, + }; + }, + }, + + modal: { + appsmithType: "MODAL_WIDGET", + version: 2, + footprint: { columns: 32, rows: 24 }, + build: (spec) => { + const children = spec.type === "modal" ? spec.children ?? [] : []; + const title = spec.type === "modal" ? spec.title ?? "Modal" : "Modal"; + + return { + footprint: { columns: 32, rows: 24 }, + children, + props: { + canOutsideClickClose: true, + canEscapeKeyClose: true, + shouldScrollContents: true, + size: "MODAL_SMALL", + width: 456, + height: 252, + title, + animateLoading: true, + detachFromLayout: true, + }, + }; + }, + }, + + datepicker: { + appsmithType: "DATE_PICKER_WIDGET2", + version: 2, + footprint: { columns: 20, rows: 7 }, + build: (spec) => { + const label = spec.type === "datepicker" ? spec.label ?? "Date" : "Date"; + + return { + footprint: { columns: 20, rows: 7 }, + props: { + label, + labelPosition: "Top", + labelAlignment: "left", + labelTextSize: "0.875rem", + dateFormat: "YYYY-MM-DD HH:mm", + isRequired: false, + isDisabled: false, + minDate: "1920-12-31T18:30:00.000Z", + maxDate: "2121-12-31T18:29:00.000Z", + firstDayOfWeek: 0, + timePrecision: "minute", + animateLoading: true, + responsiveBehavior: "fill", + }, + }; + }, + }, + + chart: { + appsmithType: "CHART_WIDGET", + version: 1, + footprint: { columns: 24, rows: 32 }, + build: (spec) => { + const title = spec.type === "chart" ? spec.title ?? "Chart" : "Chart"; + const chartType = + spec.type === "chart" ? spec.chartType ?? "LINE_CHART" : "LINE_CHART"; + const series = spec.type === "chart" ? spec.series ?? [] : []; + + // Static chartData: { : { seriesName, data: [{x,y}] } }. No binding — the points are literals. + const chartData: Record = {}; + + series.forEach((oneSeries, index) => { + chartData[`series${index + 1}`] = { + seriesName: oneSeries.name ?? `Series ${index + 1}`, + data: (oneSeries.points ?? []).map((point) => ({ + x: point.x, + y: point.y, + })), + }; + }); + + return { + footprint: { columns: 24, rows: 32 }, + props: { + chartType, + chartName: title, + allowScroll: false, + chartData, + xAxisName: "", + yAxisName: "", + labelOrientation: "auto", + setAdaptiveYMin: false, + animateLoading: true, + responsiveBehavior: "fill", + dynamicBindingPathList: [], + }, + }; + }, + }, + + list: { + appsmithType: "LIST_WIDGET_V2", + version: 3, + footprint: { columns: 40, rows: 30 }, + build: (spec) => { + // The children are the repeating item template; they compile into the list's inner canvas. + const children = spec.type === "list" ? spec.children ?? [] : []; + + return { + footprint: { columns: 40, rows: 30 }, + children, + props: { + listData: [], + currentItemsView: "[]", + pageSize: 3, + serverSidePagination: false, + animateLoading: true, + responsiveBehavior: "fill", + dynamicBindingPathList: [], + dynamicTriggerPathList: [], + }, + }; + }, + }, + + tabs: { + appsmithType: "TABS_WIDGET", + version: 3, + footprint: { columns: 40, rows: 32 }, + // Tabs is multi-canvas (one inner canvas per tab); the compiler builds its structure directly rather than via + // the single-inner-canvas container path. This template supplies only type/version/footprint + base props. + build: () => ({ + footprint: { columns: 40, rows: 32 }, + props: { + shouldShowTabs: true, + animateLoading: true, + responsiveBehavior: "fill", + }, + }), + }, }; diff --git a/app/client/packages/mcp/src/server.ts b/app/client/packages/mcp/src/server.ts index ec8de8de5c00..2826aac0ca7f 100644 --- a/app/client/packages/mcp/src/server.ts +++ b/app/client/packages/mcp/src/server.ts @@ -2,8 +2,10 @@ import { createMcpHttpServer } from "./app.js"; const port = Number(process.env.APPSMITH_MCP_PORT ?? 8092); const apiBaseUrl = process.env.APPSMITH_API_BASE_URL ?? "http://127.0.0.1:8080"; +// M4 data layer is a second, independent opt-in on top of APPSMITH_MCP_ENABLED. Default off. +const dataEnabled = process.env.APPSMITH_MCP_DATA_ENABLED === "1"; -const httpServer = createMcpHttpServer(apiBaseUrl); +const httpServer = createMcpHttpServer(apiBaseUrl, undefined, { dataEnabled }); // A truly uncaught exception leaves the process in an undefined state — exit so supervisord restarts it cleanly. process.once("uncaughtException", () => { From d362734bfeee919224cb5a61b468b2a243225a77 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Sun, 12 Jul 2026 23:40:15 -0400 Subject: [PATCH 08/81] =?UTF-8?q?feat(mcp):=20safe=20direct-authoring=20MC?= =?UTF-8?q?P=20v1=20=E2=80=94=20governance,=20CRUD,=20data=20layer,=20even?= =?UTF-8?q?ts,=20JS=20objects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the MCP server as a safe direct-authoring interface: AI clients build and modify Appsmith apps through validated, compiler-owned specs (never raw DSL/SQL/bindings), mutating via the existing ACL-enforced REST APIs under the caller's token. Governance (MCP-owned, out of product documents; gated on Mongo + Redis): - Distributed locks + mandatory revision checks + audit records; destructive/high-impact ops require a one-time confirmation token bound to actor/entity/operation/revision/digest. - Consistent errors (revision_conflict / entity_locked / confirmation_invalid); every governed mutation returns a changeId. Explicit fallback: governed/destructive tools are not registered when governance is unconfigured (no silent in-memory store). Tools (gate-aware; get_capabilities is generated from a single source and drift-tested): - Authoring: build_application, edit_page, patch_widgets, wire_event (closed event vocab), read_semantic_page, inspect_page, read_pages/read_theme/read_publish_status. - Governed: update_theme, page CRUD, audit history + bounded layout rollback, publish. - Data layer (APPSMITH_MCP_DATA_ENABLED): datasource discovery, structured SQL/REST action creation, action lifecycle (get/update/duplicate/delete) and confirmation-gated run. - Restricted JS objects (APPSMITH_MCP_JS_ENABLED): declarative-only, compiler-emitted. Security: agents never author raw {{ }}/SQL/JS; datasource binding requires EXECUTE_DATASOURCES (fails closed); create_query resolves workspace server-authoritatively; list/read outputs never leak bodies/headers/credentials. Out of scope (reported unavailable): Git sync, CE workflows. Tests: 252 unit/tool tests incl. a real @modelcontextprotocol/sdk transport acceptance and deploy-gating guards; tsc + eslint clean; spotless applied. A Java MCP-token controller test is added (CI-verified). Live acceptance (Docker/Playwright/real instance) runs in CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/client/packages/mcp/.env.example | 22 + app/client/packages/mcp/package.json | 2 + app/client/packages/mcp/src/app.test.ts | 996 +++++++- app/client/packages/mcp/src/app.ts | 2201 ++++++++++++++++- .../mcp/src/builder/actionPatch.test.ts | 178 ++ .../packages/mcp/src/builder/actionPatch.ts | 172 ++ .../packages/mcp/src/builder/capabilities.ts | 272 +- .../mcp/src/builder/editPatch.test.ts | 168 ++ .../packages/mcp/src/builder/editPatch.ts | 303 +++ .../packages/mcp/src/builder/events.test.ts | 198 ++ app/client/packages/mcp/src/builder/events.ts | 140 ++ .../packages/mcp/src/builder/jsObject.test.ts | 158 ++ .../packages/mcp/src/builder/jsObject.ts | 274 ++ .../packages/mcp/src/builder/pages.test.ts | 153 ++ app/client/packages/mcp/src/builder/pages.ts | 144 ++ .../packages/mcp/src/builder/restApi.test.ts | 214 ++ .../packages/mcp/src/builder/restApi.ts | 290 +++ .../packages/mcp/src/builder/semantic.test.ts | 144 ++ .../packages/mcp/src/builder/semantic.ts | 208 ++ .../packages/mcp/src/builder/theme.test.ts | 109 + app/client/packages/mcp/src/builder/theme.ts | 141 ++ .../packages/mcp/src/deploy-gating.test.ts | 47 + .../mcp/src/governance/coordinator.test.ts | 238 ++ .../mcp/src/governance/coordinator.ts | 183 ++ .../packages/mcp/src/governance/store.ts | 161 ++ .../packages/mcp/src/sdk-transport.test.ts | 94 + app/client/packages/mcp/src/server.ts | 91 +- app/client/src/api/McpTokenApi.ts | 6 + app/client/src/ce/constants/messages.ts | 6 + .../src/pages/UserProfile/McpTokens.test.tsx | 26 + .../src/pages/UserProfile/McpTokens.tsx | 86 +- app/client/yarn.lock | 161 +- .../controllers/ce/McpTokenControllerCE.java | 32 +- .../base/NewActionServiceCEImpl.java | 17 +- .../server/services/UserMcpTokenService.java | 2 + .../services/ce/UserMcpTokenServiceCE.java | 2 + .../ce/UserMcpTokenServiceCEImpl.java | 21 + .../ce/McpTokenControllerCETest.java | 136 + .../services/UserMcpTokenServiceImplTest.java | 25 + .../services/ce/ActionServiceCE_Test.java | 22 +- .../services/ce/NewActionServiceUnitTest.java | 45 + contributions/ServerSetup.md | 52 + ...026-07-11-mcp-capability-release-design.md | 109 + 43 files changed, 7913 insertions(+), 136 deletions(-) create mode 100644 app/client/packages/mcp/src/builder/actionPatch.test.ts create mode 100644 app/client/packages/mcp/src/builder/actionPatch.ts create mode 100644 app/client/packages/mcp/src/builder/editPatch.test.ts create mode 100644 app/client/packages/mcp/src/builder/editPatch.ts create mode 100644 app/client/packages/mcp/src/builder/events.test.ts create mode 100644 app/client/packages/mcp/src/builder/events.ts create mode 100644 app/client/packages/mcp/src/builder/jsObject.test.ts create mode 100644 app/client/packages/mcp/src/builder/jsObject.ts create mode 100644 app/client/packages/mcp/src/builder/pages.test.ts create mode 100644 app/client/packages/mcp/src/builder/pages.ts create mode 100644 app/client/packages/mcp/src/builder/restApi.test.ts create mode 100644 app/client/packages/mcp/src/builder/restApi.ts create mode 100644 app/client/packages/mcp/src/builder/semantic.test.ts create mode 100644 app/client/packages/mcp/src/builder/semantic.ts create mode 100644 app/client/packages/mcp/src/builder/theme.test.ts create mode 100644 app/client/packages/mcp/src/builder/theme.ts create mode 100644 app/client/packages/mcp/src/deploy-gating.test.ts create mode 100644 app/client/packages/mcp/src/governance/coordinator.test.ts create mode 100644 app/client/packages/mcp/src/governance/coordinator.ts create mode 100644 app/client/packages/mcp/src/governance/store.ts create mode 100644 app/client/packages/mcp/src/sdk-transport.test.ts create mode 100644 app/server/appsmith-server/src/test/java/com/appsmith/server/controllers/ce/McpTokenControllerCETest.java create mode 100644 docs/plans/2026-07-11-mcp-capability-release-design.md diff --git a/app/client/packages/mcp/.env.example b/app/client/packages/mcp/.env.example index 2832711b5679..9d01726e6cf1 100644 --- a/app/client/packages/mcp/.env.example +++ b/app/client/packages/mcp/.env.example @@ -2,3 +2,25 @@ 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. Keep disabled until the deployment has completed +# the MCP data-layer security review. +APPSMITH_MCP_DATA_ENABLED=0 + +# Restricted JS objects (gate). Enables the declarative, restricted JS-object +# tools. Requires the data layer and governance. Off by default. +APPSMITH_MCP_JS_ENABLED=0 + +# 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/package.json b/app/client/packages/mcp/package.json index e23c9f4521bf..92863c25331c 100644 --- a/app/client/packages/mcp/package.json +++ b/app/client/packages/mcp/package.json @@ -13,6 +13,8 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", + "mongodb": "^7.5.0", + "redis": "^6.1.0", "zod": "^3.25.0" }, "devDependencies": { diff --git a/app/client/packages/mcp/src/app.test.ts b/app/client/packages/mcp/src/app.test.ts index 5616bd5e96ee..596ad4e55bbc 100644 --- a/app/client/packages/mcp/src/app.test.ts +++ b/app/client/packages/mcp/src/app.test.ts @@ -5,6 +5,13 @@ import { createMcpHttpServer, type AppsmithApi, } from "./app.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"; @@ -19,12 +26,16 @@ function successfulFetch() { } describe("Appsmith API client", () => { - it("forwards the user bearer token for read and artifact import requests", async () => { + 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(); @@ -56,6 +67,15 @@ describe("Appsmith API client", () => { "Bearer user-token", ), ).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]; @@ -147,9 +167,24 @@ function createApi( updateLayout: jest.fn(), listDatasources: jest.fn(), getDatasourceStructure: jest.fn(), - getApplication: jest.fn(async () => ({ workspaceId: "ws1" })), + getApplicationPages: jest.fn(async () => ({ workspaceId: "ws1" })), 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, }); } @@ -1030,6 +1065,52 @@ describe("M4 data layer — sub-flag gates the data tools", () => { expect(await toolNames(true)).toContain("create_query"); }); + it("inspect_page reports bindings to queries that do not exist", async () => { + const api: AppsmithApi = { + ...createApi()(), + getApplicationContext: jest.fn(async () => ({ + pages: [], + page: {}, + layout: { + dsl: { + widgetId: "0", + widgetName: "MainContainer", + type: "CANVAS_WIDGET", + topRow: 0, + bottomRow: 380, + leftColumn: 0, + rightColumn: 640, + children: [ + { + widgetId: "table1", + widgetName: "OrdersTable", + type: "TABLE_WIDGET_V2", + topRow: 0, + bottomRow: 20, + leftColumn: 0, + rightColumn: 64, + tableData: "{{ missingQuery.data }}", + }, + ], + }, + }, + })), + listActions: jest.fn(async () => [{ name: "getOrders", pageId: "p1" }]), + }; + + const body = await callTool(api, "inspect_page", { + applicationId: "app1", + pageId: "p1", + layoutId: "l1", + }); + + expect( + (body.diagnostics as { issues: { rule: string }[] }).issues.map( + (issue) => issue.rule, + ), + ).toContain("dangling-binding"); + }); + it("create_query creates a parameterized query when the datasource is accessible", async () => { const createAction = jest.fn< Promise<{ id: string }>, @@ -1037,7 +1118,9 @@ describe("M4 data layer — sub-flag gates the data tools", () => { >(async () => ({ id: "act1" })); const api: AppsmithApi = { ...createApi()(), - listDatasources: jest.fn(async () => [{ id: "ds1", name: "DB" }]), + listDatasources: jest.fn(async () => [ + { id: "ds1", name: "DB", pluginId: "postgres-plugin" }, + ]), listActions: jest.fn(async () => []), createAction, }; @@ -1071,6 +1154,22 @@ describe("M4 data layer — sub-flag gates the data tools", () => { expect(createAction).not.toHaveBeenCalled(); }); + it("create_query refuses non-SQL datasources", async () => { + const createAction = jest.fn(); + const api: AppsmithApi = { + ...createApi()(), + listDatasources: jest.fn(async () => [ + { id: "ds1", name: "Users API", pluginId: "rest-api-plugin" }, + ]), + listActions: jest.fn(async () => []), + createAction, + }; + const body = await callTool(api, "create_query", { query: validQuery }); + + expect(body.error).toMatch(/supports only/i); + expect(createAction).not.toHaveBeenCalled(); + }); + it("create_query resolves the workspace from the application, not the agent (cross-tenant guard)", async () => { const createAction = jest.fn(); // ds1 exists only in workspace A; the app resolves to workspace B, so the lookup there returns nothing. @@ -1079,7 +1178,7 @@ describe("M4 data layer — sub-flag gates the data tools", () => { ); const api: AppsmithApi = { ...createApi()(), - getApplication: jest.fn(async () => ({ workspaceId: "wsB" })), + getApplicationPages: jest.fn(async () => ({ workspaceId: "wsB" })), listDatasources, listActions: jest.fn(async () => []), createAction, @@ -1096,7 +1195,9 @@ describe("M4 data layer — sub-flag gates the data tools", () => { const createAction = jest.fn(); const api: AppsmithApi = { ...createApi()(), - listDatasources: jest.fn(async () => [{ id: "ds1" }]), + listDatasources: jest.fn(async () => [ + { id: "ds1", pluginId: "postgres-plugin" }, + ]), listActions: jest.fn(async () => [{ name: "getUsers", pageId: "p1" }]), createAction, }; @@ -1177,3 +1278,888 @@ describe("M4 data layer — sub-flag gates the data tools", () => { expect(text).not.toContain("datasourceStorages"); }); }); + +describe("governance-wrapped layout mutations", () => { + class MemoryGovernanceStore implements McpGovernanceStore { + readonly changes: McpChangeRecord[] = []; + readonly confirmations = new Map(); + locked = new Set(); + + async acquireLock(entityKey: string): Promise { + if (this.locked.has(entityKey)) return undefined; + + this.locked.add(entityKey); + + return `lock:${entityKey}`; + } + async releaseLock(entityKey: string): Promise { + this.locked.delete(entityKey); + } + async createConfirmation(c: PreparedConfirmation): Promise { + this.confirmations.set(c.id, c); + } + async consumeConfirmation( + id: string, + ): Promise { + const c = this.confirmations.get(id); + + this.confirmations.delete(id); + + return c; + } + async saveChange(change: McpChangeRecord): Promise { + this.changes.push(change); + } + async getChange( + id: string, + actorId: string, + ): Promise { + return this.changes.find((c) => c.id === id && c.actorId === actorId); + } + async listChanges( + actorId: string, + limit: number, + ): Promise { + return this.changes + .filter((c) => c.actorId === actorId) + .slice(-limit) + .reverse(); + } + } + + const ROOT_DSL = { + widgetId: "0", + widgetName: "MainContainer", + type: "CANVAS_WIDGET", + topRow: 0, + bottomRow: 380, + leftColumn: 0, + rightColumn: 640, + children: [], + }; + + 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; + } + + function makeServer(store: McpGovernanceStore) { + const api: AppsmithApi = { + ...createApi()(), + getApplicationContext: jest.fn(async () => ({ + pages: [], + page: {}, + layout: { dsl: ROOT_DSL }, + })), + updateLayout: jest.fn(async () => ({ ok: true })), + }; + + return createMcpHttpServer(API_BASE_URL, () => api, { + governance: new McpGovernanceCoordinator(store, { + now: () => new Date("2026-07-12T00:00:00.000Z"), + }), + }); + } + + async function editPage( + server: ReturnType, + args: Record, + ) { + 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: 12, + method: "tools/call", + params: { name: "edit_page", arguments: args }, + }); + + return JSON.parse(parseJsonRpc(call).result.content[0].text); + } + + const baseArgs = { + applicationId: "app1", + pageId: "p1", + layoutId: "l1", + edit: { add: [{ type: "button", text: "Go" }] }, + }; + + it("requires a revision when governance is enabled", async () => { + const store = new MemoryGovernanceStore(); + const body = await editPage(makeServer(store), baseArgs); + + expect(body.code).toBe("revision_required"); + expect(store.changes).toHaveLength(0); + }); + + it("commits a governed edit, returns a changeId, and records an audit change", async () => { + const store = new MemoryGovernanceStore(); + const revision = fingerprintDsl(ROOT_DSL as never); + const body = await editPage(makeServer(store), { ...baseArgs, revision }); + + expect(typeof body.changeId).toBe("string"); + expect(body.revision).toMatch(/^[a-f0-9]{64}$/); + expect(store.changes).toHaveLength(1); + expect(store.changes[0].operation).toBe("edit_page"); + expect(store.changes[0].actorId).toBe("user@appsmith.com"); + // Rollback snapshot captures the prior DSL. + expect(store.changes[0].rollback).toHaveProperty("dsl"); + }); + + it("rejects a stale revision with a revision_conflict code", async () => { + const store = new MemoryGovernanceStore(); + const body = await editPage(makeServer(store), { + ...baseArgs, + revision: "0".repeat(64), + }); + + expect(body.code).toBe("revision_conflict"); + expect(store.changes).toHaveLength(0); + }); + + const THEME = { + config: { colorMode: "LIGHT" }, + stylesheet: { BUTTON_WIDGET: {} }, + properties: { + colors: { primaryColor: "#553DE9" }, + borderRadius: { appBorderRadius: "0.375rem" }, + fontFamily: { appFont: "Nunito Sans" }, + }, + }; + + async function callTool( + server: ReturnType, + name: string, + args: Record, + ) { + 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: 13, + method: "tools/call", + params: { name, arguments: args }, + }); + const text = parseJsonRpc(call).result.content[0].text; + + return { body: JSON.parse(text), text }; + } + + it("read_theme returns only safe tokens and a revision (no stylesheet/config)", async () => { + const api: AppsmithApi = { + ...createApi()(), + getCurrentTheme: jest.fn(async () => THEME), + }; + const server = createMcpHttpServer(API_BASE_URL, () => api); + const { body, text } = await callTool(server, "read_theme", { + applicationId: "app1", + }); + + expect(body.theme.primaryColor).toBe("#553DE9"); + expect(body.theme.fontFamily).toBe("Nunito Sans"); + expect(body.theme.revision).toMatch(/^[a-f0-9]{64}$/); + expect(text).not.toContain("stylesheet"); + expect(text).not.toContain("colorMode"); + }); + + it("update_theme commits a governed token change and records an audit", async () => { + const store = new MemoryGovernanceStore(); + const updateTheme = jest.fn< + Promise, + [string, Record] + >(async () => ({ + ...THEME, + properties: { + ...THEME.properties, + colors: { primaryColor: "#0A66C2" }, + }, + })); + const api: AppsmithApi = { + ...createApi()(), + getCurrentTheme: jest.fn(async () => THEME), + updateTheme, + }; + const server = createMcpHttpServer(API_BASE_URL, () => api, { + governance: new McpGovernanceCoordinator(store), + }); + // Discover the current revision first (as an agent would via read_theme). + const read = await callTool(server, "read_theme", { + applicationId: "app1", + }); + const update = await callTool(server, "update_theme", { + applicationId: "app1", + revision: read.body.theme.revision, + patch: { primaryColor: "#0A66C2" }, + }); + + expect(updateTheme).toHaveBeenCalledTimes(1); + expect(update.body.theme.primaryColor).toBe("#0A66C2"); + expect(typeof update.body.changeId).toBe("string"); + expect(store.changes).toHaveLength(1); + expect(store.changes[0].operation).toBe("update_theme"); + // The safe update payload must never smuggle stylesheet edits from the agent. + const sentBody = updateTheme.mock.calls[0][1]; + + expect(sentBody).toHaveProperty("properties"); + }); + + it("update_theme is unavailable when governance is not configured", async () => { + const api: AppsmithApi = { + ...createApi()(), + getCurrentTheme: jest.fn(async () => THEME), + }; + const server = createMcpHttpServer(API_BASE_URL, () => api); + const sessionId = await initSession(server); + const listed = 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: 14, method: "tools/list" }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const names = (parseJsonRpc(listed).result as any).tools.map( + (t: { name: string }) => t.name, + ); + + expect(names).toContain("read_theme"); + expect(names).not.toContain("update_theme"); + }); + + const APP_ID = "a".repeat(24); + const PAGE_ID = "b".repeat(24); + const PAGES = { pages: [{ id: PAGE_ID, name: "Extra", slug: "extra" }] }; + + function pageServer(store: McpGovernanceStore, deletePage = jest.fn()) { + const api: AppsmithApi = { + ...createApi()(), + getApplicationPages: jest.fn(async () => PAGES), + deletePage, + }; + + return createMcpHttpServer(API_BASE_URL, () => api, { + governance: new McpGovernanceCoordinator(store), + }); + } + + it("prepare_delete_page then confirm_delete_page deletes with a bound one-time token", async () => { + const store = new MemoryGovernanceStore(); + const deletePage = jest.fn(async () => ({ ok: true })); + const server = pageServer(store, deletePage); + + const read = await callTool(server, "read_pages", { + applicationId: APP_ID, + }); + const revision = read.body.revision; + + const prepared = await callTool(server, "prepare_delete_page", { + spec: { applicationId: APP_ID, pageId: PAGE_ID, revision }, + }); + + expect(typeof prepared.body.confirmationId).toBe("string"); + + const confirmed = await callTool(server, "confirm_delete_page", { + spec: { applicationId: APP_ID, pageId: PAGE_ID, revision }, + confirmationId: prepared.body.confirmationId, + }); + + expect(confirmed.body.deleted).toBe(true); + expect(deletePage).toHaveBeenCalledWith(PAGE_ID); + expect(typeof confirmed.body.changeId).toBe("string"); + expect(store.changes.some((c) => c.operation === "delete_page")).toBe(true); + + // Replay: the one-time token cannot be used again. + const replay = await callTool(server, "confirm_delete_page", { + spec: { applicationId: APP_ID, pageId: PAGE_ID, revision }, + confirmationId: prepared.body.confirmationId, + }); + + expect(replay.body.code).toBe("confirmation_invalid"); + expect(deletePage).toHaveBeenCalledTimes(1); + }); + + it("confirm_delete_page rejects a token that was never prepared (and replay)", async () => { + const store = new MemoryGovernanceStore(); + const deletePage = jest.fn(); + const server = pageServer(store, deletePage); + const read = await callTool(server, "read_pages", { + applicationId: APP_ID, + }); + + const confirmed = await callTool(server, "confirm_delete_page", { + spec: { + applicationId: APP_ID, + pageId: PAGE_ID, + revision: read.body.revision, + }, + confirmationId: "unprepared-token", + }); + + expect(confirmed.body.code).toBe("confirmation_invalid"); + expect(deletePage).not.toHaveBeenCalled(); + }); + + it("page CRUD tools are unavailable without governance", async () => { + const api: AppsmithApi = { + ...createApi()(), + getApplicationPages: jest.fn(async () => PAGES), + }; + const server = createMcpHttpServer(API_BASE_URL, () => api); + const sessionId = await initSession(server); + const listed = 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: 15, method: "tools/list" }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const names = (parseJsonRpc(listed).result as any).tools.map( + (t: { name: string }) => t.name, + ); + + expect(names).toContain("read_pages"); + + for (const tool of [ + "create_page", + "rename_page", + "prepare_delete_page", + "confirm_delete_page", + ]) { + expect(names).not.toContain(tool); + } + }); + + const STORED_ACTION = { + id: "act1", + name: "OldName", + pageId: "p1", + pluginType: "DB", + updatedAt: "2026-07-12T00:00:00.000Z", + datasource: { id: "ds1", pluginId: "postgres" }, + }; + + it("get_action returns safe metadata + revision, and update_action commits a governed rename", async () => { + const store = new MemoryGovernanceStore(); + const updateAction = jest.fn< + Promise, + [string, Record] + >(async () => ({ ...STORED_ACTION, name: "NewName" })); + const api: AppsmithApi = { + ...createApi()(), + getAction: jest.fn(async () => STORED_ACTION), + updateAction, + }; + const server = createMcpHttpServer(API_BASE_URL, () => api, { + dataEnabled: true, + governance: new McpGovernanceCoordinator(store), + }); + + const read = await callTool(server, "get_action", { actionId: "act1" }); + + expect(read.body.action.name).toBe("OldName"); + expect(read.text).not.toContain("actionConfiguration"); + expect(read.body.revision).toMatch(/^[a-f0-9]{64}$/); + + const updated = await callTool(server, "update_action", { + spec: { + kind: "SQL", + actionId: "act1", + applicationId: "app1", + revision: read.body.revision, + name: "NewName", + }, + }); + + expect(updated.body.updated).toBe(true); + expect(updateAction).toHaveBeenCalledTimes(1); + // The DTO sent to the server carries only id + safe fields, never a raw body from the caller. + expect(updateAction.mock.calls[0][1]).toEqual({ + id: "act1", + name: "NewName", + }); + expect(typeof updated.body.changeId).toBe("string"); + expect(store.changes.some((c) => c.operation === "update_action")).toBe( + true, + ); + }); + + it("records an audit change and rolls a layout edit back to its prior snapshot", async () => { + const store = new MemoryGovernanceStore(); + let current: Record = ROOT_DSL; + const updateLayout = jest.fn< + Promise<{ ok: boolean }>, + [string, string, string, Record] + >(async (_app, _page, _layout, dsl) => { + current = dsl; + + return { ok: true }; + }); + const api: AppsmithApi = { + ...createApi()(), + getApplicationContext: jest.fn(async () => ({ + pages: [], + page: {}, + layout: { dsl: current }, + })), + updateLayout: updateLayout as never, + }; + const server = createMcpHttpServer(API_BASE_URL, () => api, { + governance: new McpGovernanceCoordinator(store), + }); + + const before = fingerprintDsl(ROOT_DSL as never); + const edited = await callTool(server, "edit_page", { + applicationId: "app1", + pageId: "p1", + layoutId: "l1", + revision: before, + edit: { add: [{ type: "button", text: "Go" }] }, + }); + + expect(edited.body.changeId).toBeDefined(); + expect(current).not.toBe(ROOT_DSL); // an edit was applied + + const list = await callTool(server, "list_changes", {}); + const change = list.body.changes.find( + (c: { operation: string }) => c.operation === "edit_page", + ); + + expect(change.rollbackAvailable).toBe(true); + // History never leaks the snapshot DSL. + expect(list.text).not.toContain("MainContainer"); + + const prepared = await callTool(server, "prepare_rollback", { + changeId: change.id, + }); + const rolled = await callTool(server, "confirm_rollback", { + changeId: change.id, + confirmationId: prepared.body.confirmationId, + }); + + expect(rolled.body.rolledBack).toBe(true); + expect(rolled.body.revision).toBe(before); // restored to the original snapshot + }); + + it("get_capabilities advertises EXACTLY the registered tools (no drift), under all gates", async () => { + for (const gate of [ + { dataEnabled: false, governed: false, js: false }, + { dataEnabled: true, governed: false, js: false }, + { dataEnabled: true, governed: true, js: false }, + { dataEnabled: true, governed: true, js: true }, + { dataEnabled: false, governed: true, js: true }, + ]) { + const server = createMcpHttpServer(API_BASE_URL, () => createApi()(), { + dataEnabled: gate.dataEnabled, + jsEnabled: gate.js, + governance: gate.governed + ? new McpGovernanceCoordinator(new MemoryGovernanceStore()) + : undefined, + }); + const sessionId = await initSession(server); + const listed = 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: 20, method: "tools/list" }); + const registered = new Set( + parseJsonRpc(listed).result.tools.map((t) => t.name), + ); + + const caps = await callTool(server, "get_capabilities", {}); + const advertised = new Set( + (caps.body.tools as string[]).map((line) => line.split(" — ")[0]), + ); + + expect([...advertised].sort()).toEqual([...registered].sort()); + } + }); + + it("wire_event binds a widget event via the closed vocabulary and is governed", async () => { + const store = new MemoryGovernanceStore(); + const DSL = { + ...ROOT_DSL, + children: [ + { + widgetId: "b", + widgetName: "SaveBtn", + type: "BUTTON_WIDGET", + topRow: 0, + bottomRow: 4, + leftColumn: 0, + rightColumn: 16, + }, + { + widgetId: "m", + widgetName: "EditModal", + type: "MODAL_WIDGET", + topRow: 5, + bottomRow: 20, + leftColumn: 0, + rightColumn: 32, + }, + ], + }; + let current: Record = DSL; + const updateLayout = jest.fn< + Promise<{ ok: boolean }>, + [string, string, string, Record] + >(async (_app, _page, _layout, dsl) => { + current = dsl; + + return { ok: true }; + }); + const api: AppsmithApi = { + ...createApi()(), + getApplicationContext: jest.fn(async () => ({ + pages: [], + page: {}, + layout: { dsl: current }, + })), + updateLayout: updateLayout as never, + }; + const server = createMcpHttpServer(API_BASE_URL, () => api, { + governance: new McpGovernanceCoordinator(store), + }); + + const wired = await callTool(server, "wire_event", { + applicationId: "app1", + pageId: "p1", + layoutId: "l1", + revision: fingerprintDsl(DSL as never), + spec: { + widget: "SaveBtn", + event: "onClick", + action: { showModal: "EditModal" }, + }, + }); + + expect(wired.body.changeId).toBeDefined(); + const written = updateLayout.mock.calls[0][3] as { + children: { widgetName: string; onClick?: string }[]; + }; + const button = written.children.find((w) => w.widgetName === "SaveBtn"); + + expect(button?.onClick).toBe("{{ showModal('EditModal') }}"); + }); + + it("wire_event rejects an action targeting a missing modal", async () => { + const store = new MemoryGovernanceStore(); + const DSL = { + ...ROOT_DSL, + children: [ + { + widgetId: "b", + widgetName: "SaveBtn", + type: "BUTTON_WIDGET", + topRow: 0, + bottomRow: 4, + leftColumn: 0, + rightColumn: 16, + }, + ], + }; + const updateLayout = jest.fn(); + const api: AppsmithApi = { + ...createApi()(), + getApplicationContext: jest.fn(async () => ({ + pages: [], + page: {}, + layout: { dsl: DSL }, + })), + updateLayout: updateLayout as never, + }; + const server = createMcpHttpServer(API_BASE_URL, () => api, { + governance: new McpGovernanceCoordinator(store), + }); + + const wired = await callTool(server, "wire_event", { + applicationId: "app1", + pageId: "p1", + layoutId: "l1", + revision: fingerprintDsl(DSL as never), + spec: { + widget: "SaveBtn", + event: "onClick", + action: { showModal: "Ghost" }, + }, + }); + + expect(wired.body.error).toMatch(/was not found/); + expect(updateLayout).not.toHaveBeenCalled(); + }); + + it("create_js_object resolves plugin/workspace and commits a governed, restricted JS object", async () => { + const store = new MemoryGovernanceStore(); + const APP = "a".repeat(24); + const createActionCollection = jest.fn< + Promise, + [Record] + >(async () => ({ id: "coll1", name: "Helpers" })); + const api: AppsmithApi = { + ...createApi()(), + getApplicationPages: jest.fn(async () => ({ + workspaceId: "w".repeat(24), + })), + listPlugins: jest.fn(async () => [ + { id: "p".repeat(24), type: "JS", packageName: "js-plugin" }, + ]), + listActionCollections: jest.fn(async () => []), + createActionCollection, + }; + const server = createMcpHttpServer(API_BASE_URL, () => api, { + jsEnabled: true, + governance: new McpGovernanceCoordinator(store), + }); + + const read = await callTool(server, "read_js_object", { + applicationId: APP, + }); + const created = await callTool(server, "create_js_object", { + spec: { + applicationId: APP, + pageId: "b".repeat(24), + name: "Helpers", + revision: read.body.revision, + functions: [{ name: "loadUsers", run: [{ query: "getUsers" }] }], + }, + }); + + expect(created.body.created).toBe(true); + expect(createActionCollection).toHaveBeenCalledTimes(1); + // The compiled JS body is emitted by the compiler (async fn running a named query), not raw agent input. + const body = createActionCollection.mock.calls[0][0] as { body?: string }; + + expect(body.body).toContain("await getUsers.run();"); + expect(body.body).not.toMatch(/\$\{|`/); + expect(typeof created.body.changeId).toBe("string"); + }); + + it("JS-object tools are unavailable without the JS gate", async () => { + const server = createMcpHttpServer(API_BASE_URL, () => createApi()(), { + governance: new McpGovernanceCoordinator(new MemoryGovernanceStore()), + }); + const sessionId = await initSession(server); + const listed = 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: 22, method: "tools/list" }); + const names = new Set(parseJsonRpc(listed).result.tools.map((t) => t.name)); + + for (const tool of [ + "read_js_object", + "create_js_object", + "update_js_object", + "prepare_delete_js_object", + "confirm_delete_js_object", + ]) { + expect(names.has(tool)).toBe(false); + } + }); + + // Item K.2 — tool-level coverage for the always-on read/patch and data-layer reads. + const TEXT_DSL = { + ...ROOT_DSL, + children: [ + { + widgetId: "g", + widgetName: "Greeting", + type: "TEXT_WIDGET", + text: "Hi", + topRow: 0, + bottomRow: 4, + leftColumn: 0, + rightColumn: 24, + }, + ], + }; + + function textServer( + updateLayout: AppsmithApi["updateLayout"] = jest.fn(async () => ({ + ok: true, + })), + ) { + const api: AppsmithApi = { + ...createApi()(), + getApplicationContext: jest.fn(async () => ({ + pages: [], + page: {}, + layout: { dsl: TEXT_DSL }, + })), + updateLayout, + }; + + return createMcpHttpServer(API_BASE_URL, () => api); + } + + it("read_semantic_page returns a safe projection and a revision", async () => { + const read = await callTool(textServer(), "read_semantic_page", { + applicationId: "app1", + pageId: "p1", + layoutId: "l1", + }); + + expect(read.body.revision).toBe(fingerprintDsl(TEXT_DSL as never)); + expect(JSON.stringify(read.body.page)).toContain("Greeting"); + }); + + it("patch_widgets updates an allowlisted property and writes it back", async () => { + const updateLayout = jest.fn< + Promise<{ ok: boolean }>, + [string, string, string, Record] + >(async () => ({ ok: true })); + const patched = await callTool(textServer(updateLayout), "patch_widgets", { + applicationId: "app1", + pageId: "p1", + layoutId: "l1", + revision: fingerprintDsl(TEXT_DSL as never), + patch: { + operations: [ + { kind: "update", name: "Greeting", props: { text: "Welcome" } }, + ], + }, + }); + + expect(patched.body.changes).toBeDefined(); + const written = updateLayout.mock.calls[0][3] as { + children: { widgetName: string; text?: string }[]; + }; + + expect( + written.children.find((w) => w.widgetName === "Greeting")?.text, + ).toBe("Welcome"); + }); + + it("list_actions returns safe metadata only (no bodies, headers, or credentials)", async () => { + const api: AppsmithApi = { + ...createApi()(), + listActions: jest.fn(async () => [ + { + id: "act1", + name: "getUsers", + pageId: "p1", + pluginType: "DB", + actionConfiguration: { + body: "SELECT secret_col FROM t", + headers: [{ key: "Authorization", value: "Bearer xyz" }], + }, + datasource: { id: "ds1", pluginId: "postgres" }, + }, + ]), + }; + const server = createMcpHttpServer(API_BASE_URL, () => api, { + dataEnabled: true, + }); + const { body, text } = await callTool(server, "list_actions", { + applicationId: "app1", + }); + + expect(JSON.stringify(body)).toContain("getUsers"); + expect(text).not.toContain("SELECT secret_col"); + expect(text).not.toContain("Bearer xyz"); + expect(text).not.toContain("actionConfiguration"); + }); + + // Item E — run_action / prepare_run_action / confirm_run_action. + const READ_ONLY_ACTION = { + id: "act1", + name: "getUsers", + pageId: "p1", + pluginType: "DB", + updatedAt: "2026-07-12T00:00:00.000Z", + actionConfiguration: { body: "SELECT * FROM users;" }, + datasource: { id: "ds1", pluginId: "postgres" }, + }; + const WRITE_ACTION = { + ...READ_ONLY_ACTION, + name: "insertUser", + actionConfiguration: { body: "INSERT INTO users (name) VALUES ({{x}});" }, + }; + + it("run_action executes a read-only action and refuses a non-read-only one", async () => { + const executeAction = jest.fn(async () => ({ isExecutionSuccess: true })); + const roServer = createMcpHttpServer( + API_BASE_URL, + () => ({ + ...createApi()(), + getAction: jest.fn(async () => READ_ONLY_ACTION), + executeAction, + }), + { dataEnabled: true }, + ); + const ro = await callTool(roServer, "run_action", { actionId: "act1" }); + + expect(ro.body.executed).toBe(true); + expect(executeAction).toHaveBeenCalledWith("act1"); + + const rwExecute = jest.fn(); + const rwServer = createMcpHttpServer( + API_BASE_URL, + () => ({ + ...createApi()(), + getAction: jest.fn(async () => WRITE_ACTION), + executeAction: rwExecute, + }), + { dataEnabled: true }, + ); + const rw = await callTool(rwServer, "run_action", { actionId: "act1" }); + + expect(rw.body.code).toBe("confirmation_required"); + expect(rwExecute).not.toHaveBeenCalled(); + }); + + it("prepare_run_action + confirm_run_action runs a non-read-only action with a token", async () => { + const store = new MemoryGovernanceStore(); + const executeAction = jest.fn(async () => ({ isExecutionSuccess: true })); + const server = createMcpHttpServer( + API_BASE_URL, + () => ({ + ...createApi()(), + getAction: jest.fn(async () => WRITE_ACTION), + executeAction, + }), + { dataEnabled: true, governance: new McpGovernanceCoordinator(store) }, + ); + + const read = await callTool(server, "get_action", { actionId: "act1" }); + const prepared = await callTool(server, "prepare_run_action", { + actionId: "act1", + revision: read.body.revision, + }); + + expect(prepared.body.readOnly).toBe(false); + + const confirmed = await callTool(server, "confirm_run_action", { + actionId: "act1", + revision: read.body.revision, + confirmationId: prepared.body.confirmationId, + }); + + expect(confirmed.body.executed).toBe(true); + expect(executeAction).toHaveBeenCalledWith("act1"); + expect(typeof confirmed.body.changeId).toBe("string"); + expect(store.changes.some((c) => c.operation === "run_action")).toBe(true); + }); +}); diff --git a/app/client/packages/mcp/src/app.ts b/app/client/packages/mcp/src/app.ts index ed19ea30e17e..11e7eb68a7bf 100644 --- a/app/client/packages/mcp/src/app.ts +++ b/app/client/packages/mcp/src/app.ts @@ -1,4 +1,4 @@ -import { randomUUID, timingSafeEqual } from "node:crypto"; +import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; import { createServer, type IncomingMessage, @@ -9,8 +9,22 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; +import { + buildDuplicateActionDto, + buildUpdateActionDto, + deleteActionSpecSchema, + duplicateActionSpecSchema, + updateActionSpecSchema, +} from "./builder/actionPatch.js"; import { getCapabilities } from "./builder/capabilities.js"; import { applyEdit, compileApp } from "./builder/compile.js"; +import { + applyEvent, + eventReference, + widgetExists, + wireEventSpecSchema, +} from "./builder/events.js"; +import { applyWidgetPatch, widgetPatchSchema } from "./builder/editPatch.js"; import { FIELDS_FORMAT, GUIDES, @@ -22,13 +36,129 @@ import { } from "./builder/instructions.js"; import type { WidgetNode } from "./builder/layout.js"; import { lintArtifact, lintDsl } from "./builder/lint.js"; +import { + buildCreateJsObjectRequest, + buildUpdateJsObjectRequest, + createJsObjectSpecSchema, + deleteJsObjectSpecSchema, + updateJsObjectSpecSchema, +} from "./builder/jsObject.js"; +import { + buildCreatePageRequest, + buildRenamePageRequest, + createPageSpecSchema, + deletePageSpecSchema, + renamePageSpecSchema, +} from "./builder/pages.js"; import { listPresets, PRESETS } from "./builder/presets.js"; import { buildActionDto, compileQuery, querySpecSchema, } from "./builder/query.js"; +import { + buildRestActionDto, + compileRestApi, + restApiSpecSchema, +} from "./builder/restApi.js"; +import { + canonicalStableSerialize, + fingerprintDsl, + projectSemanticPage, +} from "./builder/semantic.js"; +import { + buildThemeUpdatePayload, + projectTheme, + themePatchSchema, +} from "./builder/theme.js"; import { appSpecSchema, editSpecSchema } from "./builder/schema.js"; +import { + DestructiveConfirmationError, + GovernanceLockError, + GovernanceRevisionConflictError, + type McpChangeRecord, + type McpGovernanceCoordinator, +} from "./governance/coordinator.js"; + +// Safe audit-record projection for the history tools: never returns the rollback snapshot (it can hold full DSL) — +// only metadata and the semantic summary, plus whether a safe rollback exists. +function isLayoutRollbackable(change: McpChangeRecord): boolean { + const rollback = change.rollback as { kind?: unknown; dsl?: unknown }; + + return rollback?.kind === "layout" && rollback.dsl !== undefined; +} + +function projectChange(change: McpChangeRecord): Record { + return { + id: change.id, + operation: change.operation, + entityKey: change.entityKey, + revisionBefore: change.revisionBefore, + revisionAfter: change.revisionAfter, + createdAt: change.createdAt, + summary: change.summary, + rollbackAvailable: isLayoutRollbackable(change), + }; +} + +// Context passed to every tool handler: which gated tool families are enabled, the governance coordinator (present +// only when Mongo+Redis are configured), and the authenticated MCP username used as the governance actor identity. +export interface ServerContext { + dataEnabled: boolean; + jsEnabled: boolean; + governance?: McpGovernanceCoordinator; + actorId: string; +} + +// A page is the lock/audit entity for layout, page-metadata, and event mutations. +function pageEntityKey(applicationId: string, pageId: string): string { + return `page:${applicationId}:${pageId}`; +} + +// The application's page list is the lock/revision entity for page-list mutations (create/rename/delete page). +function pagesEntityKey(applicationId: string): string { + return `application:${applicationId}:pages`; +} + +// Binds a destructive confirmation to the exact operation payload so a token can only confirm the specific change it +// was prepared for (not a different page/action). +function operationDigest(payload: unknown): string { + return createHash("sha256") + .update(canonicalStableSerialize(payload), "utf8") + .digest("hex"); +} + +interface ToolResult { + content: { type: "text"; text: string }[]; + // Matches the SDK's CallToolResult, which carries an index signature. Without this an `interface` (unlike a type + // alias) is not assignable to it, so governed tool handlers returning ToolResult fail to typecheck. + [key: string]: unknown; +} + +// Map governance failures to a stable, safe MCP error payload so agents get a consistent stale/busy/confirmation +// signal instead of an opaque 500. Returns undefined for non-governance errors (let the caller handle those). +function governanceError(error: unknown): ToolResult | undefined { + if (error instanceof GovernanceRevisionConflictError) { + return result({ + error: + "page revision is stale; re-read the entity before retrying this mutation", + code: "revision_conflict", + }); + } + + if (error instanceof GovernanceLockError) { + return result({ + error: "this entity is currently being changed; retry shortly", + code: "entity_locked", + }); + } + + if (error instanceof DestructiveConfirmationError) { + return result({ error: error.message, code: "confirmation_invalid" }); + } + + return undefined; +} const MAX_ID_LENGTH = 128; @@ -90,9 +220,36 @@ export interface AppsmithApi { ) => Promise; listDatasources: (workspaceId: string) => Promise; getDatasourceStructure: (datasourceId: string) => Promise; - getApplication: (applicationId: string) => Promise; + getApplicationPages: (applicationId: string) => Promise; listActions: (applicationId: string) => Promise; createAction: (action: Record) => Promise; + getAction: (actionId: string) => Promise; + updateAction: ( + actionId: string, + action: Record, + ) => Promise; + deleteAction: (actionId: string) => Promise; + executeAction: (actionId: string) => Promise; + getCurrentTheme: (applicationId: string) => Promise; + updateTheme: ( + applicationId: string, + theme: Record, + ) => Promise; + createPage: (body: Record) => Promise; + updatePage: ( + pageId: string, + body: Record, + ) => Promise; + deletePage: (pageId: string) => Promise; + publishApplication: (applicationId: string) => Promise; + listPlugins: () => Promise; + listActionCollections: (applicationId: string) => Promise; + createActionCollection: (body: Record) => Promise; + updateActionCollection: ( + collectionId: string, + body: Record, + ) => Promise; + deleteActionCollection: (collectionId: string) => Promise; validateToken: () => Promise; } @@ -175,6 +332,7 @@ export function createAppsmithApi( token: string, apiBaseUrl: string, fetchFn: typeof fetch = fetch, + correlationHeaders: Record = {}, ): AppsmithApi { async function request(path: string, init?: RequestInit): Promise { const isMultipart = init?.body instanceof FormData; @@ -190,6 +348,7 @@ export function createAppsmithApi( headers: { Authorization: `Bearer ${token}`, ...(isMultipart ? {} : { "Content-Type": "application/json" }), + ...correlationHeaders, ...init?.headers, }, }); @@ -260,8 +419,10 @@ export function createAppsmithApi( request( `/api/v1/datasources/${encodeURIComponent(datasourceId)}/structure`, ), - getApplication: async (applicationId) => - request(`/api/v1/applications/${encodeURIComponent(applicationId)}`), + getApplicationPages: async (applicationId) => + request( + `/api/v1/pages?applicationId=${encodeURIComponent(applicationId)}&mode=EDIT`, + ), listActions: async (applicationId) => request( `/api/v1/actions?applicationId=${encodeURIComponent(applicationId)}`, @@ -271,6 +432,80 @@ export function createAppsmithApi( method: "POST", body: JSON.stringify(action), }), + getAction: async (actionId) => + request(`/api/v1/actions/${encodeURIComponent(actionId)}`), + updateAction: async (actionId, action) => + request(`/api/v1/actions/${encodeURIComponent(actionId)}`, { + method: "PUT", + body: JSON.stringify(action), + }), + deleteAction: async (actionId) => + request(`/api/v1/actions/${encodeURIComponent(actionId)}`, { + method: "DELETE", + }), + executeAction: async (actionId) => { + // Execute a STORED action by id only. The agent never supplies an execute payload, so this cannot proxy + // arbitrary parameters/body — the server runs the persisted action under the caller's ACL. + const formData = new FormData(); + + formData.append( + "executeActionDTO", + JSON.stringify({ actionId, viewMode: false }), + ); + + return request("/api/v1/actions/execute", { + method: "POST", + body: formData, + }); + }, + getCurrentTheme: async (applicationId) => + request( + `/api/v1/themes/applications/${encodeURIComponent(applicationId)}/current?mode=EDIT`, + ), + updateTheme: async (applicationId, theme) => + request( + `/api/v1/themes/applications/${encodeURIComponent(applicationId)}`, + { method: "PUT", body: JSON.stringify(theme) }, + ), + createPage: async (body) => + request("/api/v1/pages", { method: "POST", body: JSON.stringify(body) }), + updatePage: async (pageId, body) => + request(`/api/v1/pages/${encodeURIComponent(pageId)}`, { + method: "PUT", + body: JSON.stringify(body), + }), + deletePage: async (pageId) => + request(`/api/v1/pages/${encodeURIComponent(pageId)}`, { + method: "DELETE", + }), + publishApplication: async (applicationId) => + request( + `/api/v1/applications/publish/${encodeURIComponent(applicationId)}`, + { method: "POST" }, + ), + listPlugins: async () => request("/api/v1/plugins"), + listActionCollections: async (applicationId) => + request( + `/api/v1/collections/actions?applicationId=${encodeURIComponent(applicationId)}`, + ), + createActionCollection: async (body) => + request("/api/v1/collections/actions", { + method: "POST", + body: JSON.stringify(body), + }), + updateActionCollection: async (collectionId, body) => + request( + `/api/v1/collections/actions/${encodeURIComponent(collectionId)}`, + { + method: "PUT", + body: JSON.stringify(body), + }, + ), + deleteActionCollection: async (collectionId) => + request( + `/api/v1/collections/actions/${encodeURIComponent(collectionId)}`, + { method: "DELETE" }, + ), validateToken: async () => request("/api/v1/users/me"), }; } @@ -288,6 +523,19 @@ const DATASOURCE_PUBLIC_FIELDS = [ "type", ] as const; +// `create_query` emits SQL and relies on the SQL plugin prepared-statement +// contract. Do not accept another plugin merely because it has a datasource +// identifier: REST, GraphQL, Mongo, and Sheets need their own typed builders. +const SQL_PLUGIN_IDS = new Set([ + "mariadb-plugin", + "mssql-plugin", + "mysql-plugin", + "oracle-plugin", + "postgres-plugin", + "snowflake-plugin", +]); +const REST_PLUGIN_IDS = new Set(["restapi-plugin"]); + function projectDatasources(response: unknown): unknown { const project = (entry: unknown) => { if (!entry || typeof entry !== "object") return entry; @@ -305,9 +553,8 @@ function projectDatasources(response: unknown): unknown { return Array.isArray(response) ? response.map(project) : project(response); } -// IDOR guard for create_query: the referenced datasource must be one the caller can actually access in the given -// workspace (the server's create path fetches the datasource without a permission check — TODO in NewActionService — -// so we cross-check here before POST). +// IDOR guard for action creation: the referenced datasource must be one the caller can actually access in the given +// workspace. The server also verifies execute permission; this check keeps the MCP response deterministic and scoped. function datasourceAccessible(list: unknown, datasourceId: string): boolean { return ( Array.isArray(list) && @@ -320,6 +567,238 @@ function datasourceAccessible(list: unknown, datasourceId: string): boolean { ); } +function isSqlDatasource(list: unknown, datasourceId: string): boolean { + if (!Array.isArray(list)) return false; + + return list.some((entry) => { + if (entry === null || typeof entry !== "object") return false; + + const datasource = entry as { id?: unknown; pluginId?: unknown }; + + return ( + datasource.id === datasourceId && + typeof datasource.pluginId === "string" && + SQL_PLUGIN_IDS.has(datasource.pluginId) + ); + }); +} + +function isRestDatasource(list: unknown, datasourceId: string): boolean { + if (!Array.isArray(list)) return false; + + return list.some((entry) => { + if (entry === null || typeof entry !== "object") return false; + + const datasource = entry as { id?: unknown; pluginId?: unknown }; + + return ( + datasource.id === datasourceId && + typeof datasource.pluginId === "string" && + REST_PLUGIN_IDS.has(datasource.pluginId) + ); + }); +} + +function workspaceIdFromApplicationPages( + response: unknown, +): string | undefined { + if (response === null || typeof response !== "object") return undefined; + + const pages = response as { + workspaceId?: unknown; + application?: { workspaceId?: unknown }; + }; + const workspaceId = pages.workspaceId ?? pages.application?.workspaceId; + + return typeof workspaceId === "string" ? workspaceId : undefined; +} + +// Safe page-list metadata: never returns DSL, actions, or bindings — only identity/name/slug/visibility. This is +// both the read projection and the basis for the page-list revision used for optimistic concurrency on page CRUD. +function projectPages( + response: unknown, +): { id: string; name: string; slug?: string; isHidden?: boolean }[] { + const pages = (response as { pages?: unknown } | null)?.pages; + + if (!Array.isArray(pages)) return []; + + return pages + .filter((page) => page !== null && typeof page === "object") + .map((page) => { + const entry = page as { + id?: unknown; + name?: unknown; + slug?: unknown; + isHidden?: unknown; + }; + + return { + id: String(entry.id ?? ""), + name: String(entry.name ?? ""), + ...(typeof entry.slug === "string" ? { slug: entry.slug } : {}), + ...(typeof entry.isHidden === "boolean" + ? { isHidden: entry.isHidden } + : {}), + }; + }); +} + +function fingerprintPages(response: unknown): string { + return createHash("sha256") + .update(canonicalStableSerialize(projectPages(response)), "utf8") + .digest("hex"); +} + +// Safe projection + revision for a single stored action. The revision folds in the server-side `updatedAt` so any +// change to the (never-exposed) action body still bumps it, giving real optimistic concurrency without leaking the +// body/headers/credentials. +function projectAction(action: unknown): Record { + return (projectActions([action])[0] as Record) ?? {}; +} + +function actionUpdatedAt(action: unknown): unknown { + const entry = action as { + updatedAt?: unknown; + unpublishedAction?: { updatedAt?: unknown }; + } | null; + + return entry?.updatedAt ?? entry?.unpublishedAction?.updatedAt; +} + +function fingerprintAction(action: unknown): string { + return createHash("sha256") + .update( + canonicalStableSerialize({ + ...projectAction(action), + updatedAt: actionUpdatedAt(action), + }), + "utf8", + ) + .digest("hex"); +} + +// Classify a stored action as read-only. Read-only actions (REST GET/HEAD, or SQL/DB queries that only SELECT) may be +// run without a confirmation; anything else is treated as non-read-only and requires prepare/confirm. Defaults to +// non-read-only (safe) when it cannot be determined. +function isReadOnlyAction(action: unknown): boolean { + const source = + (action as { unpublishedAction?: unknown } | null)?.unpublishedAction ?? + action; + const config = ( + source as { + actionConfiguration?: { httpMethod?: unknown; body?: unknown }; + } | null + )?.actionConfiguration; + const method = config?.httpMethod; + + if (typeof method === "string") { + return ["GET", "HEAD"].includes(method.toUpperCase()); + } + + const body = config?.body; + + if (typeof body === "string") { + const head = body.trim().toUpperCase(); + + return ( + head.startsWith("SELECT") || + head.startsWith("SHOW") || + head.startsWith("EXPLAIN") || + head.startsWith("WITH") + ); + } + + return false; +} + +// Clone a STORED action (server data, not agent input) under a new name, keeping only the permitted fields and +// preserving its datasource by id. The agent never supplies action configuration here. +function cloneActionForDuplicate( + stored: unknown, + newName: string, +): Record { + const source = + (stored as { unpublishedAction?: unknown } | null)?.unpublishedAction ?? + stored; + const entry = source as { + pageId?: unknown; + pluginId?: unknown; + actionConfiguration?: unknown; + datasource?: { id?: unknown }; + }; + + return { + name: newName, + ...(entry.pageId !== undefined ? { pageId: entry.pageId } : {}), + ...(entry.pluginId !== undefined ? { pluginId: entry.pluginId } : {}), + ...(entry.actionConfiguration !== undefined + ? { actionConfiguration: entry.actionConfiguration } + : {}), + ...(typeof entry.datasource?.id === "string" + ? { datasource: { id: entry.datasource.id } } + : {}), + }; +} + +// The fixed JS plugin id (agents never supply it). Resolved from the plugins list by type/packageName. +function jsPluginId(plugins: unknown): string | undefined { + if (!Array.isArray(plugins)) return undefined; + + const js = plugins.find( + (plugin) => + plugin !== null && + typeof plugin === "object" && + ((plugin as { type?: unknown }).type === "JS" || + (plugin as { packageName?: unknown }).packageName === "js-plugin"), + ); + const id = (js as { id?: unknown } | undefined)?.id; + + return typeof id === "string" ? id : undefined; +} + +// Safe JS-object projection: id, name, page, and the declared function names. Never returns the compiled JS body. +function projectJsObject(collection: unknown): Record { + const entry = collection as { + id?: unknown; + name?: unknown; + pageId?: unknown; + actions?: { name?: unknown }[]; + } | null; + + return { + id: entry?.id, + name: entry?.name, + pageId: entry?.pageId, + functions: Array.isArray(entry?.actions) + ? entry.actions + .map((action) => (action as { name?: unknown }).name) + .filter((name): name is string => typeof name === "string") + : [], + }; +} + +function fingerprintJsObject(collection: unknown): string { + return createHash("sha256") + .update( + canonicalStableSerialize({ + ...projectJsObject(collection), + updatedAt: (collection as { updatedAt?: unknown } | null)?.updatedAt, + }), + "utf8", + ) + .digest("hex"); +} + +function projectJsList(collections: unknown): Record[] { + return Array.isArray(collections) ? collections.map(projectJsObject) : []; +} + +function fingerprintJsList(collections: unknown): string { + return createHash("sha256") + .update(canonicalStableSerialize(projectJsList(collections)), "utf8") + .digest("hex"); +} + // Idempotency lookup: an existing action on the same page with the same name (the server rejects duplicates, so we // return the existing one instead of erroring on a retry). function findExistingAction( @@ -346,6 +825,84 @@ function findExistingAction( }); } +function actionNames(list: unknown): string[] { + if (!Array.isArray(list)) return []; + + return list.flatMap((entry) => { + if (entry === null || typeof entry !== "object") return []; + + const action = entry as { + name?: unknown; + unpublishedAction?: { name?: unknown }; + }; + const name = action.name ?? action.unpublishedAction?.name; + + return typeof name === "string" ? [name] : []; + }); +} + +function projectActions(list: unknown): unknown[] { + if (!Array.isArray(list)) return []; + + return list.flatMap((entry) => { + if (entry === null || typeof entry !== "object") return []; + + const action = entry as { + id?: unknown; + name?: unknown; + pageId?: unknown; + pluginType?: unknown; + unpublishedAction?: { + id?: unknown; + name?: unknown; + pageId?: unknown; + pluginType?: unknown; + datasource?: { id?: unknown; pluginId?: unknown }; + }; + datasource?: { id?: unknown; pluginId?: unknown }; + }; + const source = action.unpublishedAction ?? action; + const datasource = source.datasource; + + return [ + { + id: action.id ?? source.id, + name: source.name, + pageId: source.pageId, + pluginType: source.pluginType, + ...(datasource + ? { + datasource: { + id: datasource.id, + pluginId: datasource.pluginId, + }, + } + : {}), + }, + ]; + }); +} + +async function lintLiveDsl( + api: AppsmithApi, + applicationId: string, + dsl: WidgetNode, + dataEnabled: boolean, +) { + if (!dataEnabled) return lintDsl(dsl); + + // Query names are authoritative on the server. If this optional diagnostic read + // fails after a successful write, preserve the write result and return the + // structural diagnostics that are still available locally. + try { + return lintDsl(dsl, { + knownDataNames: actionNames(await api.listActions(applicationId)), + }); + } catch { + return lintDsl(dsl); + } +} + function validationError(issues: z.ZodIssue[]) { return result({ valid: false, @@ -376,9 +933,113 @@ function result(data: unknown) { }; } -export function buildMcpServer(api: AppsmithApi, dataEnabled = false) { +export function buildMcpServer( + api: AppsmithApi, + ctx: ServerContext = { dataEnabled: false, jsEnabled: false, actorId: "" }, +) { + const { actorId, dataEnabled, governance, jsEnabled } = ctx; const server = new McpServer({ name: "appsmith-mcp", version: "0.0.1" }); + // Shared commit path for layout mutations (edit_page / patch_widgets). When governance is active it wraps the + // write in a distributed lock + mandatory revision check + audit record (returning a changeId) and maps + // governance failures to stable error codes; otherwise it performs the plain, already-revision-checked write. + async function commitLayout(params: { + applicationId: string; + pageId: string; + layoutId: string; + currentDsl: WidgetNode; + currentRevision: string; + revision: string | undefined; + operation: string; + newDsl: WidgetNode; + extra: Record; + }): Promise { + const write = async () => { + const layout = await api.updateLayout( + params.applicationId, + params.pageId, + params.layoutId, + params.newDsl, + ); + const diagnostics = await lintLiveDsl( + api, + params.applicationId, + params.newDsl, + dataEnabled, + ); + + return { layout, diagnostics }; + }; + const revisionAfter = fingerprintDsl(params.newDsl); + + // A stale revision is rejected consistently in both paths. The revision is a content hash of the public DSL (not + // a secret), so a plain comparison is fine and avoids length-mismatch throws. + if ( + params.revision !== undefined && + params.revision !== params.currentRevision + ) { + return result({ + error: + "page revision is stale; re-read the page before retrying this mutation", + code: "revision_conflict", + revision: params.currentRevision, + }); + } + + if (!governance) { + const { diagnostics, layout } = await write(); + + return result({ + ...params.extra, + diagnostics, + layout, + revision: revisionAfter, + }); + } + + // Governance active -> a revision is mandatory (B): a blind write cannot be reconciled or rolled back. + if (params.revision === undefined) { + return result({ + error: + "a page revision is required when governance is enabled; read the page first", + code: "revision_required", + }); + } + + try { + const { changeId, value } = await governance.execute({ + actorId, + entityKey: pageEntityKey(params.applicationId, params.pageId), + operation: params.operation, + expectedRevision: params.revision, + currentRevision: params.currentRevision, + mutate: async () => ({ + value: await write(), + revisionAfter, + // A layout snapshot the rollback tools can re-apply. Never returned to the agent. + rollback: { + kind: "layout", + applicationId: params.applicationId, + pageId: params.pageId, + layoutId: params.layoutId, + dsl: params.currentDsl, + }, + summary: params.extra, + }), + }); + + return result({ + ...params.extra, + diagnostics: value.diagnostics, + layout: value.layout, + revision: revisionAfter, + changeId, + }); + } catch (error) { + return governanceError(error) ?? compileError(error); + } + } + server.tool( "list_workspaces", "List workspaces accessible to the authenticated Appsmith user.", @@ -407,9 +1068,16 @@ export function buildMcpServer(api: AppsmithApi, dataEnabled = false) { server.tool( "get_capabilities", - "Discover what the app builder supports: widget types, the page/app/edit spec shapes, presets, and the layout grid. Call this first when building an app.", + "Discover what this MCP server supports: widget types, spec shapes, presets, the layout grid, and the exact set of tools available under the current gates (data layer, restricted JS, governance). Call this first.", {}, - async () => result(getCapabilities()), + async () => + result( + getCapabilities({ + data: dataEnabled, + js: jsEnabled, + governance: governance !== undefined, + }), + ), ); server.tool( @@ -498,14 +1166,18 @@ export function buildMcpServer(api: AppsmithApi, dataEnabled = false) { server.tool( "edit_page", - "Append widgets to an existing page from a high-level edit spec, with best-effort placement (after a widget / inside a container). Existing widgets are never modified. Returns notes about how placement was resolved.", + "Append widgets to an existing page from a high-level edit spec. Read the page with read_semantic_page first and pass its revision token to detect stale writes. Existing widgets are never modified.", { applicationId: idSchema, pageId: idSchema, layoutId: idSchema, + revision: z + .string() + .regex(/^[a-f0-9]{64}$/) + .optional(), edit: z.record(z.unknown()), }, - async ({ applicationId, edit, layoutId, pageId }) => { + async ({ applicationId, edit, layoutId, pageId, revision }) => { const parsed = editSpecSchema.safeParse(edit); if (!parsed.success) { @@ -524,6 +1196,8 @@ export function buildMcpServer(api: AppsmithApi, dataEnabled = false) { return result({ error: "could not read the current page layout" }); } + const currentRevision = fingerprintDsl(currentDsl); + let dsl: WidgetNode; let notes: string[]; @@ -535,16 +1209,17 @@ export function buildMcpServer(api: AppsmithApi, dataEnabled = false) { return compileError(error); } - const updated = await api.updateLayout( + return commitLayout({ applicationId, pageId, layoutId, - dsl, - ); - - const diagnostics = lintDsl(dsl); - - return result({ notes, diagnostics, layout: updated }); + currentDsl, + currentRevision, + revision, + operation: "edit_page", + newDsl: dsl, + extra: { notes }, + }); }, ); @@ -564,53 +1239,749 @@ export function buildMcpServer(api: AppsmithApi, dataEnabled = false) { return result({ error: "could not read the current page layout" }); } - return result({ diagnostics: lintDsl(dsl) }); + return result({ + diagnostics: await lintLiveDsl(api, applicationId, dsl, dataEnabled), + }); }, ); - // M4 data layer — gated behind APPSMITH_MCP_DATA_ENABLED. These read tools let an agent discover the datasources - // and structure it can bind widgets to (via the closed binding vocabulary: table.source / button.onClick). They - // wrap the existing ACL-enforced Appsmith REST endpoints under the caller's bearer token; no new server surface. - if (dataEnabled) { - server.tool( - "list_datasources", - "List datasources in a workspace that the authenticated user can access. Bind a table to one of these via a query name (table.source = { query }).", - { workspaceId: idSchema }, - async ({ workspaceId }) => - result(projectDatasources(await api.listDatasources(workspaceId))), - ); + server.tool( + "patch_widgets", + "Directly update, move, reparent, or remove widgets using a strict typed patch. Read the page with read_semantic_page first and pass its revision. Only allowlisted literal properties can change; removing a widget with children is rejected.", + { + applicationId: idSchema, + pageId: idSchema, + layoutId: idSchema, + revision: z.string().regex(/^[a-f0-9]{64}$/), + patch: z.record(z.unknown()), + }, + async ({ applicationId, layoutId, pageId, patch, revision }) => { + const parsed = widgetPatchSchema.safeParse(patch); - server.tool( - "get_datasource_structure", - "Read a datasource's structure (tables/columns) so you can shape queries and bindings. Read-only.", - { datasourceId: idSchema }, - async ({ datasourceId }) => - result(await api.getDatasourceStructure(datasourceId)), - ); + if (!parsed.success) return validationError(parsed.error.issues); - server.tool( - "create_query", - "Create a SQL query (SELECT/INSERT/UPDATE/DELETE) on a datasource from a STRUCTURED spec — no raw SQL, no raw bindings. Values become prepared-statement parameters. Widgets then reference it by name (table.source={query} / button.onClick={run}). Idempotent by page + name.", - { query: z.record(z.unknown()) }, - async ({ query }) => { - const parsed = querySpecSchema.safeParse(query); + const context = await api.getApplicationContext( + applicationId, + pageId, + layoutId, + ); + const currentDsl = (context.layout as { dsl?: WidgetNode } | undefined) + ?.dsl; - if (!parsed.success) return validationError(parsed.error.issues); + if (!currentDsl) { + return result({ error: "could not read the current page layout" }); + } - const spec = parsed.data; + const currentRevision = fingerprintDsl(currentDsl); - // Resolve the workspace SERVER-AUTHORITATIVELY from the application — never trust an agent-supplied - // workspaceId — so a datasource can only be bound within the target page's own workspace (cross-tenant guard). - const application = (await api.getApplication(spec.applicationId)) as { - workspaceId?: string; - }; - const workspaceId = application?.workspaceId; + let patched: ReturnType; - if (typeof workspaceId !== "string") { - return result({ - error: `could not resolve the workspace for application ${spec.applicationId}`, - }); - } + try { + patched = applyWidgetPatch(currentDsl, parsed.data); + serializeArtifact(patched.dsl as Record); + } catch (error) { + return compileError(error); + } + + return commitLayout({ + applicationId, + pageId, + layoutId, + currentDsl, + currentRevision, + revision, + operation: "patch_widgets", + newDsl: patched.dsl, + extra: { changes: patched.changes }, + }); + }, + ); + + server.tool( + "wire_event", + "Wire a widget event to a safe action from a CLOSED vocabulary: run a query, navigate to a page, or show/close a modal. Supported events: button onClick, table onRowSelected, modal onClose, tabs onTabSelected. Read the page first and pass its revision. The compiler emits the binding; no raw JS or bindings are accepted.", + { + applicationId: idSchema, + pageId: idSchema, + layoutId: idSchema, + revision: z + .string() + .regex(/^[a-f0-9]{64}$/) + .optional(), + spec: z.record(z.unknown()), + }, + async ({ applicationId, layoutId, pageId, revision, spec }) => { + const parsed = wireEventSpecSchema.safeParse(spec); + + if (!parsed.success) return validationError(parsed.error.issues); + + const context = await api.getApplicationContext( + applicationId, + pageId, + layoutId, + ); + const currentDsl = (context.layout as { dsl?: WidgetNode } | undefined) + ?.dsl; + + if (!currentDsl) { + return result({ error: "could not read the current page layout" }); + } + + // Dangling-reference guard: the referenced query/page/widget must exist before we write the trigger. + const reference = eventReference(parsed.data.action); + + if ( + reference.kind === "widget" && + !widgetExists(currentDsl, reference.name) + ) { + return result({ + error: `modal/widget "${reference.name}" was not found on this page`, + }); + } + + if (reference.kind === "page") { + const pages = projectPages( + await api.getApplicationPages(applicationId), + ); + + if (!pages.some((page) => page.name === reference.name)) { + return result({ error: `page "${reference.name}" was not found` }); + } + } + + if (reference.kind === "query" && dataEnabled) { + const known = actionNames(await api.listActions(applicationId)); + + if (!known.includes(reference.name)) { + return result({ error: `query "${reference.name}" was not found` }); + } + } + + const currentRevision = fingerprintDsl(currentDsl); + let newDsl: WidgetNode; + + try { + ({ dsl: newDsl } = applyEvent(currentDsl, parsed.data)); + serializeArtifact(newDsl as Record); + } catch (error) { + return compileError(error); + } + + return commitLayout({ + applicationId, + pageId, + layoutId, + currentDsl, + currentRevision, + revision, + operation: "wire_event", + newDsl, + extra: { widget: parsed.data.widget, event: parsed.data.event }, + }); + }, + ); + + server.tool( + "read_semantic_page", + "Read a compact, safe semantic view of a page for targeted authoring. Returns widget hierarchy, geometry, and allowlisted static properties, plus a revision token for a later write. It never returns arbitrary DSL properties, events, or raw bindings.", + { applicationId: idSchema, pageId: idSchema, layoutId: idSchema }, + async ({ applicationId, layoutId, pageId }) => { + const context = await api.getApplicationContext( + applicationId, + pageId, + layoutId, + ); + const dsl = (context.layout as { dsl?: WidgetNode } | undefined)?.dsl; + + if (!dsl) { + return result({ error: "could not read the current page layout" }); + } + + return result({ + revision: fingerprintDsl(dsl), + page: projectSemanticPage(dsl), + diagnostics: await lintLiveDsl(api, applicationId, dsl, dataEnabled), + }); + }, + ); + + server.tool( + "read_pages", + "List an application's pages (safe metadata: id, name, slug, visibility) plus a revision token for create_page / rename_page / delete_page. Never returns DSL, actions, or bindings.", + { applicationId: idSchema }, + async ({ applicationId }) => { + const pages = await api.getApplicationPages(applicationId); + + return result({ + pages: projectPages(pages), + revision: fingerprintPages(pages), + }); + }, + ); + + server.tool( + "read_publish_status", + "Read the application's publish state (last deployed time, public flag, name) plus the current page-list revision to pass to prepare_publish.", + { applicationId: idSchema }, + async ({ applicationId }) => { + const pages = await api.getApplicationPages(applicationId); + const application = + (pages as { application?: Record } | null) + ?.application ?? {}; + + return result({ + publish: { + name: application.name, + lastDeployedAt: application.lastDeployedAt, + isPublic: application.isPublic, + }, + revision: fingerprintPages(pages), + }); + }, + ); + + server.tool( + "read_theme", + "Read the application's safe theme tokens (primaryColor, borderRadius, fontFamily) plus a revision token for a later update_theme. Stylesheet and config internals are never returned.", + { applicationId: idSchema }, + async ({ applicationId }) => { + try { + return result({ + theme: projectTheme(await api.getCurrentTheme(applicationId)), + }); + } catch (error) { + return compileError(error); + } + }, + ); + + // Governed mutation tools require the Mongo+Redis governance coordinator (lock + revision + audit). When it is not + // configured they are not registered at all, so a caller cannot perform a governed change without governance. + if (governance) { + const gov = governance; + + server.tool( + "update_theme", + "Update the application's theme tokens (primaryColor, borderRadius, fontFamily only) using a revision from read_theme. Stylesheets, CSS, URLs, and bindings are never accepted. Returns the new tokens, revision, and change id.", + { + applicationId: idSchema, + revision: z.string().regex(/^[a-f0-9]{64}$/), + patch: z.record(z.unknown()), + }, + async ({ applicationId, patch, revision }) => { + const parsed = themePatchSchema.safeParse(patch); + + if (!parsed.success) return validationError(parsed.error.issues); + + let currentTheme: unknown; + let current: ReturnType; + + try { + currentTheme = await api.getCurrentTheme(applicationId); + current = projectTheme(currentTheme); + } catch (error) { + return compileError(error); + } + + try { + const { changeId, value } = await gov.execute({ + actorId, + entityKey: `theme:${applicationId}`, + operation: "update_theme", + expectedRevision: revision, + currentRevision: current.revision, + mutate: async () => { + const body = buildThemeUpdatePayload(currentTheme, parsed.data); + const projection = projectTheme( + await api.updateTheme(applicationId, body), + ); + + return { + value: projection, + revisionAfter: projection.revision, + rollback: { tokens: current }, + summary: { tokens: parsed.data }, + }; + }, + }); + + return result({ theme: value, revision: value.revision, changeId }); + } catch (error) { + return governanceError(error) ?? compileError(error); + } + }, + ); + + // Item I — audit history + bounded rollback over the MCP-owned change records. + server.tool( + "list_changes", + "List recent MCP governance change records for the authenticated user (audit trail). Returns safe change headers and semantic summaries — never rollback snapshots.", + { limit: z.number().int().min(1).max(100).optional() }, + async ({ limit }) => + result({ + changes: (await gov.listChanges(actorId, limit ?? 20)).map( + projectChange, + ), + }), + ); + + server.tool( + "get_change", + "Get a single MCP change record by id (audit metadata + semantic summary; the rollback snapshot is never exposed).", + { changeId: idSchema }, + async ({ changeId }) => { + const change = await gov.getChange(changeId, actorId); + + return change + ? result({ change: projectChange(change) }) + : result({ error: "change not found" }); + }, + ); + + server.tool( + "get_change_diff", + "Get the semantic before/after summary for a change (operation, revisions, summary).", + { changeId: idSchema }, + async ({ changeId }) => { + const change = await gov.getChange(changeId, actorId); + + return change + ? result({ + operation: change.operation, + revisionBefore: change.revisionBefore, + revisionAfter: change.revisionAfter, + summary: change.summary, + }) + : result({ error: "change not found" }); + }, + ); + + server.tool( + "prepare_rollback", + "Prepare to roll back a layout change (edit_page/patch_widgets). Returns a one-time confirmation token. Only offered when a safe layout snapshot exists.", + { changeId: idSchema }, + async ({ changeId }) => { + const change = await gov.getChange(changeId, actorId); + + if (!change) return result({ error: "change not found" }); + + if (!isLayoutRollbackable(change)) { + return result({ + error: "this change cannot be safely rolled back", + code: "rollback_unavailable", + }); + } + + const confirmation = await gov.prepareDestructiveConfirmation({ + actorId, + entityKey: change.entityKey, + operation: "rollback", + revision: change.revisionAfter, + digest: operationDigest({ changeId }), + }); + + return result({ + confirmationId: confirmation.id, + expiresAt: confirmation.expiresAt, + changeId, + }); + }, + ); + + server.tool( + "confirm_rollback", + "Roll back a layout change using a confirmation token from prepare_rollback. Re-applies the prior layout snapshot only if the page is unchanged since the change.", + { changeId: idSchema, confirmationId: idSchema }, + async ({ changeId, confirmationId }) => { + const change = await gov.getChange(changeId, actorId); + + if (!change) return result({ error: "change not found" }); + + const rollback = change.rollback as { + kind?: string; + applicationId?: string; + pageId?: string; + layoutId?: string; + dsl?: WidgetNode; + }; + + if ( + rollback?.kind !== "layout" || + !rollback.applicationId || + !rollback.pageId || + !rollback.layoutId || + !rollback.dsl + ) { + return result({ + error: "this change cannot be safely rolled back", + code: "rollback_unavailable", + }); + } + + try { + await gov.consumeDestructiveConfirmation({ + confirmationId, + actorId, + entityKey: change.entityKey, + operation: "rollback", + revision: change.revisionAfter, + digest: operationDigest({ changeId }), + }); + + const context = await api.getApplicationContext( + rollback.applicationId, + rollback.pageId, + rollback.layoutId, + ); + const currentDsl = ( + context.layout as { dsl?: WidgetNode } | undefined + )?.dsl; + + if (!currentDsl) { + return result({ error: "could not read the current page layout" }); + } + + const snapshot = rollback.dsl; + const { changeId: newChangeId } = await gov.execute({ + actorId, + entityKey: change.entityKey, + operation: "rollback", + // The page must be unchanged since the change being rolled back. + expectedRevision: change.revisionAfter, + currentRevision: fingerprintDsl(currentDsl), + mutate: async () => { + const layout = await api.updateLayout( + rollback.applicationId!, + rollback.pageId!, + rollback.layoutId!, + snapshot, + ); + + return { + value: { layout }, + revisionAfter: fingerprintDsl(snapshot), + rollback: { + kind: "layout", + applicationId: rollback.applicationId, + pageId: rollback.pageId, + layoutId: rollback.layoutId, + dsl: currentDsl, + }, + summary: { rolledBackChange: changeId }, + }; + }, + }); + + return result({ + rolledBack: true, + revision: fingerprintDsl(snapshot), + changeId: newChangeId, + }); + } catch (error) { + return governanceError(error) ?? compileError(error); + } + }, + ); + + // Item H — publish is high-impact, so it is confirmation-gated. + server.tool( + "prepare_publish", + "Publishing (deploying) the application is high-impact. Returns a one-time confirmation token bound to the application and its current page-list revision. Call confirm_publish with the token to deploy.", + { + applicationId: idSchema, + revision: z.string().regex(/^[a-f0-9]{64}$/), + }, + async ({ applicationId, revision }) => { + const confirmation = await gov.prepareDestructiveConfirmation({ + actorId, + entityKey: `application:${applicationId}`, + operation: "publish", + revision, + digest: operationDigest({ applicationId }), + }); + + return result({ + confirmationId: confirmation.id, + expiresAt: confirmation.expiresAt, + operation: "publish", + }); + }, + ); + + server.tool( + "confirm_publish", + "Publish (deploy) the application using a confirmation token from prepare_publish. Token, actor, application, and revision must match, and the page-list revision must be unchanged since preparation.", + { + applicationId: idSchema, + revision: z.string().regex(/^[a-f0-9]{64}$/), + confirmationId: idSchema, + }, + async ({ applicationId, confirmationId, revision }) => { + try { + await gov.consumeDestructiveConfirmation({ + confirmationId, + actorId, + entityKey: `application:${applicationId}`, + operation: "publish", + revision, + digest: operationDigest({ applicationId }), + }); + + const currentRevision = fingerprintPages( + await api.getApplicationPages(applicationId), + ); + const { changeId } = await gov.execute({ + actorId, + entityKey: `application:${applicationId}`, + operation: "publish", + expectedRevision: revision, + currentRevision, + mutate: async () => { + await api.publishApplication(applicationId); + + return { + value: {}, + revisionAfter: currentRevision, + rollback: {}, + summary: { applicationId }, + }; + }, + }); + + return result({ published: true, changeId }); + } catch (error) { + return governanceError(error) ?? compileError(error); + } + }, + ); + + server.tool( + "create_page", + "Create a new blank page in an application. The caller cannot supply DSL, actions, or bindings — only a safe page name. Pass a page-list revision from the application's pages for optimistic concurrency. Returns the safe page list, new revision, and change id.", + { spec: z.record(z.unknown()) }, + async ({ spec }) => { + const parsed = createPageSpecSchema.safeParse(spec); + + if (!parsed.success) return validationError(parsed.error.issues); + + const request = buildCreatePageRequest(parsed.data); + const currentRevision = fingerprintPages( + await api.getApplicationPages(parsed.data.applicationId), + ); + + try { + const { changeId, value } = await gov.execute({ + actorId, + entityKey: pagesEntityKey(parsed.data.applicationId), + operation: "create_page", + expectedRevision: parsed.data.revision, + currentRevision, + mutate: async () => { + const created = await api.createPage(request.body); + const after = await api.getApplicationPages( + parsed.data.applicationId, + ); + + return { + value: { + pageId: (created as { id?: string } | null)?.id, + pages: projectPages(after), + }, + revisionAfter: fingerprintPages(after), + rollback: { + createdPageId: (created as { id?: string } | null)?.id, + }, + summary: { name: parsed.data.name }, + }; + }, + }); + + return result({ created: true, ...value, changeId }); + } catch (error) { + return governanceError(error) ?? compileError(error); + } + }, + ); + + server.tool( + "rename_page", + "Rename a page. Pass the application's page-list revision for optimistic concurrency. Returns the safe page list, new revision, and change id.", + { spec: z.record(z.unknown()) }, + async ({ spec }) => { + const parsed = renamePageSpecSchema.safeParse(spec); + + if (!parsed.success) return validationError(parsed.error.issues); + + const request = buildRenamePageRequest(parsed.data); + const currentRevision = fingerprintPages( + await api.getApplicationPages(parsed.data.applicationId), + ); + + try { + const { changeId, value } = await gov.execute({ + actorId, + entityKey: pagesEntityKey(parsed.data.applicationId), + operation: "rename_page", + expectedRevision: parsed.data.revision, + currentRevision, + mutate: async () => { + await api.updatePage(parsed.data.pageId, request.body); + const after = await api.getApplicationPages( + parsed.data.applicationId, + ); + + return { + value: { pages: projectPages(after) }, + revisionAfter: fingerprintPages(after), + rollback: { pageId: parsed.data.pageId }, + summary: { name: parsed.data.name }, + }; + }, + }); + + return result({ renamed: true, ...value, changeId }); + } catch (error) { + return governanceError(error) ?? compileError(error); + } + }, + ); + + server.tool( + "prepare_delete_page", + "Prepare to delete a page. Deleting a page is destructive, so this returns a one-time confirmation token bound to this exact page and revision. Call confirm_delete_page with the token to perform the deletion.", + { spec: z.record(z.unknown()) }, + async ({ spec }) => { + const parsed = deletePageSpecSchema.safeParse(spec); + + if (!parsed.success) return validationError(parsed.error.issues); + + const confirmation = await gov.prepareDestructiveConfirmation({ + actorId, + entityKey: pagesEntityKey(parsed.data.applicationId), + operation: "delete_page", + revision: parsed.data.revision, + digest: operationDigest({ + applicationId: parsed.data.applicationId, + pageId: parsed.data.pageId, + }), + }); + + return result({ + confirmationId: confirmation.id, + expiresAt: confirmation.expiresAt, + operation: "delete_page", + pageId: parsed.data.pageId, + }); + }, + ); + + server.tool( + "confirm_delete_page", + "Delete a page using a confirmation token from prepare_delete_page. The token, actor, page, and revision must all match, and the page-list revision must be unchanged since preparation.", + { + spec: z.record(z.unknown()), + confirmationId: idSchema, + }, + async ({ confirmationId, spec }) => { + const parsed = deletePageSpecSchema.safeParse(spec); + + if (!parsed.success) return validationError(parsed.error.issues); + + try { + await gov.consumeDestructiveConfirmation({ + confirmationId, + actorId, + entityKey: pagesEntityKey(parsed.data.applicationId), + operation: "delete_page", + revision: parsed.data.revision, + digest: operationDigest({ + applicationId: parsed.data.applicationId, + pageId: parsed.data.pageId, + }), + }); + + const currentRevision = fingerprintPages( + await api.getApplicationPages(parsed.data.applicationId), + ); + const { changeId, value } = await gov.execute({ + actorId, + entityKey: pagesEntityKey(parsed.data.applicationId), + operation: "delete_page", + expectedRevision: parsed.data.revision, + currentRevision, + mutate: async () => { + await api.deletePage(parsed.data.pageId); + const after = await api.getApplicationPages( + parsed.data.applicationId, + ); + + return { + value: { pages: projectPages(after) }, + revisionAfter: fingerprintPages(after), + rollback: { deletedPageId: parsed.data.pageId }, + summary: { pageId: parsed.data.pageId }, + }; + }, + }); + + return result({ deleted: true, ...value, changeId }); + } catch (error) { + return governanceError(error) ?? compileError(error); + } + }, + ); + } + + // M4 data layer — gated behind APPSMITH_MCP_DATA_ENABLED. These read tools let an agent discover the datasources + // and structure it can bind widgets to (via the closed binding vocabulary: table.source / button.onClick). They + // wrap the existing ACL-enforced Appsmith REST endpoints under the caller's bearer token; no new server surface. + if (dataEnabled) { + server.tool( + "list_datasources", + "List datasources in a workspace that the authenticated user can access. Bind a table to one of these via a query name (table.source = { query }).", + { workspaceId: idSchema }, + async ({ workspaceId }) => + result(projectDatasources(await api.listDatasources(workspaceId))), + ); + + server.tool( + "get_datasource_structure", + "Read a datasource's structure (tables/columns) so you can shape queries and bindings. Read-only.", + { datasourceId: idSchema }, + async ({ datasourceId }) => + result(await api.getDatasourceStructure(datasourceId)), + ); + + server.tool( + "list_actions", + "List safe metadata for the authenticated user's actions in an application. Query bodies, headers, credentials, and raw action configuration are intentionally excluded.", + { applicationId: idSchema }, + async ({ applicationId }) => + result(projectActions(await api.listActions(applicationId))), + ); + + server.tool( + "create_query", + "Create a SQL query (SELECT/INSERT/UPDATE/DELETE) on a datasource from a STRUCTURED spec — no raw SQL, no raw bindings. Values become prepared-statement parameters. Widgets then reference it by name (table.source={query} / button.onClick={run}). Idempotent by page + name.", + { query: z.record(z.unknown()) }, + async ({ query }) => { + const parsed = querySpecSchema.safeParse(query); + + if (!parsed.success) return validationError(parsed.error.issues); + + const spec = parsed.data; + + // Resolve the workspace SERVER-AUTHORITATIVELY from the application — never trust an agent-supplied + // workspaceId — so a datasource can only be bound within the target page's own workspace (cross-tenant guard). + const workspaceId = workspaceIdFromApplicationPages( + await api.getApplicationPages(spec.applicationId), + ); + + if (typeof workspaceId !== "string") { + return result({ + error: `could not resolve the workspace for application ${spec.applicationId}`, + }); + } // IDOR guard: only bind a datasource the caller can access in the page's own workspace. const datasources = await api.listDatasources(workspaceId); @@ -621,6 +1992,13 @@ export function buildMcpServer(api: AppsmithApi, dataEnabled = false) { }); } + if (!isSqlDatasource(datasources, spec.datasourceId)) { + return result({ + error: + "create_query currently supports only MariaDB, Microsoft SQL Server, MySQL, Oracle, PostgreSQL, and Snowflake datasources", + }); + } + // Idempotency: return the existing query rather than creating a duplicate on retry. const existing = findExistingAction( await api.listActions(spec.applicationId), @@ -649,6 +2027,606 @@ export function buildMcpServer(api: AppsmithApi, dataEnabled = false) { return result({ created: true, body, action: created }); }, ); + + server.tool( + "create_rest_api", + "Create a REST API action from a structured specification using an existing REST datasource. The datasource retains its server-side base URL and credentials. Supports only safe path segments, fixed headers, literals, and validated widget-property bindings. Idempotent by page + name.", + { api: z.record(z.unknown()) }, + async ({ api: restApi }) => { + const parsed = restApiSpecSchema.safeParse(restApi); + + if (!parsed.success) return validationError(parsed.error.issues); + + const spec = parsed.data; + const workspaceId = workspaceIdFromApplicationPages( + await api.getApplicationPages(spec.applicationId), + ); + + if (typeof workspaceId !== "string") { + return result({ + error: `could not resolve the workspace for application ${spec.applicationId}`, + }); + } + + const datasources = await api.listDatasources(workspaceId); + + if (!datasourceAccessible(datasources, spec.datasourceId)) { + return result({ + error: `datasource ${spec.datasourceId} is not accessible in this application's workspace`, + }); + } + + if (!isRestDatasource(datasources, spec.datasourceId)) { + return result({ + error: "create_rest_api requires an existing REST API datasource", + }); + } + + const existing = findExistingAction( + await api.listActions(spec.applicationId), + spec.name, + spec.pageId, + ); + + if (existing !== undefined) { + return result({ + created: false, + reason: "an action with this name already exists on this page", + action: existing, + }); + } + + try { + const compiled = compileRestApi(spec); + const created = await api.createAction( + buildRestActionDto(spec, compiled), + ); + + return result({ + created: true, + action: created, + request: { + method: spec.method, + path: compiled.path, + queryParameterCount: compiled.queryParameters.length, + }, + }); + } catch (error) { + return compileError(error); + } + }, + ); + + server.tool( + "get_action", + "Read safe metadata for a single action (id, name, page, plugin, datasource) plus a revision token for update/duplicate/delete. Never returns the query body, headers, or credentials.", + { actionId: idSchema }, + async ({ actionId }) => { + const action = await api.getAction(actionId); + + return result({ + action: projectAction(action), + revision: fingerprintAction(action), + }); + }, + ); + + server.tool( + "run_action", + "Run a READ-ONLY stored action (REST GET/HEAD or a SELECT-only query) by id and return its result. Non-read-only actions are refused here — use prepare_run_action / confirm_run_action for those. No execute payload is accepted.", + { actionId: idSchema }, + async ({ actionId }) => { + const action = await api.getAction(actionId); + + if (!isReadOnlyAction(action)) { + return result({ + error: + "this action is not read-only; use prepare_run_action then confirm_run_action", + code: "confirmation_required", + readOnly: false, + }); + } + + return result({ + executed: true, + readOnly: true, + result: await api.executeAction(actionId), + }); + }, + ); + + // Action mutations additionally require governance (lock + revision + audit / confirmation). + if (governance) { + const govData = governance; + + server.tool( + "update_action", + "Update a stored SQL or REST action from a STRUCTURED spec (no raw SQL, bindings, credentials, base URLs, or headers). Pass a revision from get_action. Returns safe metadata, new revision, and change id.", + { spec: z.record(z.unknown()) }, + async ({ spec }) => { + const parsed = updateActionSpecSchema.safeParse(spec); + + if (!parsed.success) return validationError(parsed.error.issues); + + const request = buildUpdateActionDto(parsed.data); + let current: unknown; + + try { + current = await api.getAction(request.actionId); + } catch (error) { + return compileError(error); + } + + try { + const { changeId, value } = await govData.execute({ + actorId, + entityKey: `action:${request.actionId}`, + operation: "update_action", + expectedRevision: parsed.data.revision, + currentRevision: fingerprintAction(current), + mutate: async () => { + const updated = await api.updateAction( + request.actionId, + request.action, + ); + + return { + value: { + action: projectAction(updated), + revision: fingerprintAction(updated), + }, + revisionAfter: fingerprintAction(updated), + rollback: { actionId: request.actionId }, + summary: { actionId: request.actionId }, + }; + }, + }); + + return result({ updated: true, ...value, changeId }); + } catch (error) { + return governanceError(error) ?? compileError(error); + } + }, + ); + + server.tool( + "duplicate_action", + "Duplicate a stored action under a new name, preserving its datasource server-side. No action configuration is accepted from the caller. Pass a revision from get_action. Governed.", + { spec: z.record(z.unknown()) }, + async ({ spec }) => { + const parsed = duplicateActionSpecSchema.safeParse(spec); + + if (!parsed.success) return validationError(parsed.error.issues); + + const request = buildDuplicateActionDto(parsed.data); + let current: unknown; + + try { + current = await api.getAction(request.actionId); + } catch (error) { + return compileError(error); + } + + try { + const { changeId, value } = await govData.execute({ + actorId, + entityKey: `action:${request.actionId}`, + operation: "duplicate_action", + expectedRevision: parsed.data.revision, + currentRevision: fingerprintAction(current), + mutate: async () => { + const created = await api.createAction( + cloneActionForDuplicate(current, request.name), + ); + + return { + value: { + action: projectAction(created), + revision: fingerprintAction(created), + }, + revisionAfter: fingerprintAction(created), + rollback: { + createdActionId: (created as { id?: string } | null)?.id, + }, + summary: { name: request.name }, + }; + }, + }); + + return result({ duplicated: true, ...value, changeId }); + } catch (error) { + return governanceError(error) ?? compileError(error); + } + }, + ); + + server.tool( + "prepare_delete_action", + "Prepare to delete an action. Returns a one-time confirmation token bound to this exact action and revision. Call confirm_delete_action with the token to delete it.", + { spec: z.record(z.unknown()) }, + async ({ spec }) => { + const parsed = deleteActionSpecSchema.safeParse(spec); + + if (!parsed.success) return validationError(parsed.error.issues); + + const confirmation = await govData.prepareDestructiveConfirmation({ + actorId, + entityKey: `action:${parsed.data.actionId}`, + operation: "delete_action", + revision: parsed.data.revision, + digest: operationDigest({ + applicationId: parsed.data.applicationId, + actionId: parsed.data.actionId, + }), + }); + + return result({ + confirmationId: confirmation.id, + expiresAt: confirmation.expiresAt, + operation: "delete_action", + actionId: parsed.data.actionId, + }); + }, + ); + + server.tool( + "confirm_delete_action", + "Delete an action using a confirmation token from prepare_delete_action. Token, actor, action, and revision must all match, and the action's revision must be unchanged since preparation.", + { spec: z.record(z.unknown()), confirmationId: idSchema }, + async ({ confirmationId, spec }) => { + const parsed = deleteActionSpecSchema.safeParse(spec); + + if (!parsed.success) return validationError(parsed.error.issues); + + try { + await govData.consumeDestructiveConfirmation({ + confirmationId, + actorId, + entityKey: `action:${parsed.data.actionId}`, + operation: "delete_action", + revision: parsed.data.revision, + digest: operationDigest({ + applicationId: parsed.data.applicationId, + actionId: parsed.data.actionId, + }), + }); + + const current = await api.getAction(parsed.data.actionId); + const { changeId } = await govData.execute({ + actorId, + entityKey: `action:${parsed.data.actionId}`, + operation: "delete_action", + expectedRevision: parsed.data.revision, + currentRevision: fingerprintAction(current), + mutate: async () => { + await api.deleteAction(parsed.data.actionId); + + return { + value: {}, + revisionAfter: "deleted", + rollback: { deletedActionId: parsed.data.actionId }, + summary: { actionId: parsed.data.actionId }, + }; + }, + }); + + return result({ + deleted: true, + actionId: parsed.data.actionId, + changeId, + }); + } catch (error) { + return governanceError(error) ?? compileError(error); + } + }, + ); + + server.tool( + "prepare_run_action", + "Prepare to run an action. Non-read-only executions require this confirmation step. Returns a one-time token bound to this action and revision, plus whether the action is read-only. Pass a revision from get_action.", + { + actionId: idSchema, + revision: z.string().regex(/^[a-f0-9]{64}$/), + }, + async ({ actionId, revision }) => { + const action = await api.getAction(actionId); + const confirmation = await govData.prepareDestructiveConfirmation({ + actorId, + entityKey: `action:${actionId}`, + operation: "run_action", + revision, + digest: operationDigest({ actionId }), + }); + + return result({ + confirmationId: confirmation.id, + expiresAt: confirmation.expiresAt, + operation: "run_action", + readOnly: isReadOnlyAction(action), + }); + }, + ); + + server.tool( + "confirm_run_action", + "Run an action using a confirmation token from prepare_run_action. Token, actor, action, and revision must match, and the action must be unchanged since preparation. Returns the execution result and an audit change id.", + { + actionId: idSchema, + revision: z.string().regex(/^[a-f0-9]{64}$/), + confirmationId: idSchema, + }, + async ({ actionId, confirmationId, revision }) => { + try { + await govData.consumeDestructiveConfirmation({ + confirmationId, + actorId, + entityKey: `action:${actionId}`, + operation: "run_action", + revision, + digest: operationDigest({ actionId }), + }); + + const current = await api.getAction(actionId); + const { changeId, value } = await govData.execute({ + actorId, + entityKey: `action:${actionId}`, + operation: "run_action", + expectedRevision: revision, + currentRevision: fingerprintAction(current), + mutate: async () => ({ + value: { result: await api.executeAction(actionId) }, + revisionAfter: fingerprintAction(current), + rollback: {}, + summary: { actionId }, + }), + }); + + return result({ executed: true, ...value, changeId }); + } catch (error) { + return governanceError(error) ?? compileError(error); + } + }, + ); + } + } + + // Item G — restricted JS objects, gated behind APPSMITH_MCP_JS_ENABLED. The agent supplies only a declarative, + // restricted definition (constants + async functions that run named queries and return literal objects); the + // jsObject compiler emits the JS. Raw JS source is never accepted. + if (jsEnabled) { + server.tool( + "read_js_object", + "List the application's JS objects with safe metadata (id, name, page, function names) and revision tokens for update/delete, plus a list revision for create. Never returns JS source.", + { applicationId: idSchema }, + async ({ applicationId }) => { + const collections = await api.listActionCollections(applicationId); + + return result({ + jsObjects: (Array.isArray(collections) ? collections : []).map( + (collection) => ({ + ...projectJsObject(collection), + revision: fingerprintJsObject(collection), + }), + ), + revision: fingerprintJsList(collections), + }); + }, + ); + + if (governance) { + const govJs = governance; + + server.tool( + "create_js_object", + "Create a restricted JS object from a declarative spec (constants + async functions that run named queries and return literal objects). No raw JS, imports, globals, network calls, or loops. Pass a JS-list revision from read_js_object. Governed.", + { spec: z.record(z.unknown()) }, + async ({ spec }) => { + const raw = spec as Record; + const applicationId = raw.applicationId; + + if (typeof applicationId !== "string") { + return result({ error: "spec.applicationId is required" }); + } + + // Resolve workspace + JS plugin server-side; the agent never supplies these ids. + const workspaceId = workspaceIdFromApplicationPages( + await api.getApplicationPages(applicationId), + ); + const pluginId = jsPluginId(await api.listPlugins()); + + if (typeof workspaceId !== "string" || pluginId === undefined) { + return result({ + error: "could not resolve the workspace or JS plugin", + }); + } + + const parsed = createJsObjectSpecSchema.safeParse({ + ...raw, + workspaceId, + pluginId, + }); + + if (!parsed.success) return validationError(parsed.error.issues); + + const request = buildCreateJsObjectRequest(parsed.data); + const currentRevision = fingerprintJsList( + await api.listActionCollections(applicationId), + ); + + try { + const { changeId, value } = await govJs.execute({ + actorId, + entityKey: `application:${applicationId}:jsobjects`, + operation: "create_js_object", + expectedRevision: parsed.data.revision, + currentRevision, + mutate: async () => { + const created = await api.createActionCollection(request.body); + + return { + value: { jsObject: projectJsObject(created) }, + revisionAfter: fingerprintJsList( + await api.listActionCollections(applicationId), + ), + rollback: { + createdCollectionId: (created as { id?: string } | null) + ?.id, + }, + summary: { name: parsed.data.name }, + }; + }, + }); + + return result({ created: true, ...value, changeId }); + } catch (error) { + return governanceError(error) ?? compileError(error); + } + }, + ); + + server.tool( + "update_js_object", + "Update a restricted JS object from a declarative spec. Pass a revision from read_js_object. No raw JS. Governed.", + { spec: z.record(z.unknown()) }, + async ({ spec }) => { + const parsed = updateJsObjectSpecSchema.safeParse(spec); + + if (!parsed.success) return validationError(parsed.error.issues); + + const collections = await api.listActionCollections( + parsed.data.applicationId, + ); + const current = (Array.isArray(collections) ? collections : []).find( + (collection) => + (collection as { id?: unknown } | null)?.id === + parsed.data.collectionId, + ); + + if (current === undefined) { + return result({ error: "js object not found" }); + } + + const request = buildUpdateJsObjectRequest(parsed.data); + + try { + const { changeId, value } = await govJs.execute({ + actorId, + entityKey: `jsobject:${parsed.data.collectionId}`, + operation: "update_js_object", + expectedRevision: parsed.data.revision, + currentRevision: fingerprintJsObject(current), + mutate: async () => { + const updated = await api.updateActionCollection( + parsed.data.collectionId, + request.body, + ); + + return { + value: { jsObject: projectJsObject(updated) }, + revisionAfter: fingerprintJsObject(updated), + rollback: { collectionId: parsed.data.collectionId }, + summary: { collectionId: parsed.data.collectionId }, + }; + }, + }); + + return result({ updated: true, ...value, changeId }); + } catch (error) { + return governanceError(error) ?? compileError(error); + } + }, + ); + + server.tool( + "prepare_delete_js_object", + "Prepare to delete a JS object. Returns a one-time confirmation token bound to this object and revision.", + { spec: z.record(z.unknown()) }, + async ({ spec }) => { + const parsed = deleteJsObjectSpecSchema.safeParse(spec); + + if (!parsed.success) return validationError(parsed.error.issues); + + const confirmation = await govJs.prepareDestructiveConfirmation({ + actorId, + entityKey: `jsobject:${parsed.data.collectionId}`, + operation: "delete_js_object", + revision: parsed.data.revision, + digest: operationDigest({ + applicationId: parsed.data.applicationId, + collectionId: parsed.data.collectionId, + }), + }); + + return result({ + confirmationId: confirmation.id, + expiresAt: confirmation.expiresAt, + operation: "delete_js_object", + collectionId: parsed.data.collectionId, + }); + }, + ); + + server.tool( + "confirm_delete_js_object", + "Delete a JS object using a confirmation token from prepare_delete_js_object. Token, actor, object, and revision must all match.", + { spec: z.record(z.unknown()), confirmationId: idSchema }, + async ({ confirmationId, spec }) => { + const parsed = deleteJsObjectSpecSchema.safeParse(spec); + + if (!parsed.success) return validationError(parsed.error.issues); + + try { + await govJs.consumeDestructiveConfirmation({ + confirmationId, + actorId, + entityKey: `jsobject:${parsed.data.collectionId}`, + operation: "delete_js_object", + revision: parsed.data.revision, + digest: operationDigest({ + applicationId: parsed.data.applicationId, + collectionId: parsed.data.collectionId, + }), + }); + + const collections = await api.listActionCollections( + parsed.data.applicationId, + ); + const current = ( + Array.isArray(collections) ? collections : [] + ).find( + (collection) => + (collection as { id?: unknown } | null)?.id === + parsed.data.collectionId, + ); + const { changeId } = await govJs.execute({ + actorId, + entityKey: `jsobject:${parsed.data.collectionId}`, + operation: "delete_js_object", + expectedRevision: parsed.data.revision, + currentRevision: fingerprintJsObject(current), + mutate: async () => { + await api.deleteActionCollection(parsed.data.collectionId); + + return { + value: {}, + revisionAfter: "deleted", + rollback: { deletedCollectionId: parsed.data.collectionId }, + summary: { collectionId: parsed.data.collectionId }, + }; + }, + }); + + return result({ + deleted: true, + collectionId: parsed.data.collectionId, + changeId, + }); + } catch (error) { + return governanceError(error) ?? compileError(error); + } + }, + ); + } } registerInstructions(server); @@ -738,6 +2716,47 @@ function writeJson(res: ServerResponse, status: number, body: unknown) { res.end(JSON.stringify(body)); } +function logMcpEvent(event: string, fields: Record) { + // Keep audit records machine-readable and intentionally omit bearer tokens, + // request bodies, and tool arguments because they can contain credentials or + // customer data. + process.stderr.write( + `${JSON.stringify({ event, timestamp: new Date().toISOString(), ...fields })}\n`, + ); +} + +function requestOperation(body: unknown): { method?: string; tool?: string } { + if (!body || typeof body !== "object") return {}; + + const request = body as { + method?: unknown; + params?: { name?: unknown }; + }; + const method = + typeof request.method === "string" ? request.method : undefined; + const tool = + method === "tools/call" && typeof request.params?.name === "string" + ? request.params.name + : undefined; + + return { method, tool }; +} + +function correlationHeaders(req: IncomingMessage): Record { + const internalRequestId = req.headers["x-appsmith-request-id"]; + const requestId = req.headers["x-request-id"]; + const headers: Record = { + "X-Appsmith-Request-Id": + typeof internalRequestId === "string" ? internalRequestId : randomUUID(), + }; + + if (typeof requestId === "string") { + headers["X-Request-Id"] = requestId; + } + + return headers; +} + class HttpError extends Error { constructor( readonly status: number, @@ -784,6 +2803,11 @@ export interface McpHttpServerOptions { sessionTtlMs?: number; // Gate for the M4 data layer (APPSMITH_MCP_DATA_ENABLED). Off by default: data tools are not registered. dataEnabled?: boolean; + // Gate for restricted JS objects (APPSMITH_MCP_JS_ENABLED). Off by default. + jsEnabled?: boolean; + // Governance coordinator (present only when Mongo+Redis are configured). When absent, governed/destructive tools + // are not registered and normal mutations run unlocked (still revision-checked). + governance?: McpGovernanceCoordinator; } function tokensMatch(left: string, right: string): boolean { @@ -798,8 +2822,11 @@ function tokensMatch(left: string, right: string): boolean { export function createMcpHttpServer( apiBaseUrl: string, - createApi: (token: string) => AppsmithApi = (token) => - createAppsmithApi(token, apiBaseUrl), + createApi: ( + token: string, + requestHeaders?: Record, + ) => AppsmithApi = (token, requestHeaders) => + createAppsmithApi(token, apiBaseUrl, fetch, requestHeaders), options: McpHttpServerOptions = {}, ): Server { const sessions = new Map(); @@ -813,6 +2840,8 @@ export function createMcpHttpServer( const now = options.now ?? Date.now; const sessionTtlMs = options.sessionTtlMs ?? MCP_SESSION_TTL_MS; const dataEnabled = options.dataEnabled ?? false; + const jsEnabled = options.jsEnabled ?? false; + const governance = options.governance; function removeExpiredSessions() { const currentTime = now(); @@ -847,6 +2876,24 @@ export function createMcpHttpServer( const server = createServer(async (req, res) => { // Released in `finally`; holds a session reservation across the admission → registration gap. let releasePending = () => {}; + const startedAt = Date.now(); + const upstreamHeaders = correlationHeaders(req); + const requestId = upstreamHeaders["X-Appsmith-Request-Id"]; + let username: string | undefined; + let operation: { method?: string; tool?: string } = {}; + + res.once("finish", () => { + logMcpEvent("appsmith_mcp_request", { + requestId, + path: (req.url ?? "").split("?")[0], + httpMethod: req.method, + mcpMethod: operation.method, + tool: operation.tool, + username, + status: res.statusCode, + durationMs: Date.now() - startedAt, + }); + }); try { const path = (req.url ?? "").split("?")[0]; @@ -885,6 +2932,8 @@ export function createMcpHttpServer( const sessionId = req.headers["mcp-session-id"] as string | undefined; const session = sessionId ? sessions.get(sessionId) : undefined; const body = req.method === "POST" ? await readBody(req) : undefined; + + operation = requestOperation(body); let transport = session?.transport; if (session) { @@ -896,21 +2945,25 @@ export function createMcpHttpServer( return; } - await authenticatedUsername(createApi(token)); + username = await authenticatedUsername( + createApi(token, upstreamHeaders), + ); session.expiresAt = now() + sessionTtlMs; } if (!transport && isInitializeRequest(body)) { - const api = createApi(token); - const username = await authenticatedUsername(api); + const api = createApi(token, upstreamHeaders); + const authenticatedUser = await authenticatedUsername(api); + + username = authenticatedUser; // Check-and-reserve synchronously (no await between reading the counts and incrementing the reservation) // so a burst of concurrent initializes can't all slip past the caps. `sessions.size + pendingTotal` counts // both registered and in-flight sessions. - let userSessionCount = pendingByUser.get(username) ?? 0; + let userSessionCount = pendingByUser.get(authenticatedUser) ?? 0; for (const existing of sessions.values()) { - if (existing.username === username) userSessionCount += 1; + if (existing.username === authenticatedUser) userSessionCount += 1; } if (sessions.size + pendingTotal >= maxSessions) { @@ -928,14 +2981,17 @@ export function createMcpHttpServer( } pendingTotal += 1; - pendingByUser.set(username, (pendingByUser.get(username) ?? 0) + 1); + pendingByUser.set( + authenticatedUser, + (pendingByUser.get(authenticatedUser) ?? 0) + 1, + ); releasePending = () => { releasePending = () => {}; pendingTotal -= 1; - const remaining = (pendingByUser.get(username) ?? 1) - 1; + const remaining = (pendingByUser.get(authenticatedUser) ?? 1) - 1; - if (remaining <= 0) pendingByUser.delete(username); - else pendingByUser.set(username, remaining); + if (remaining <= 0) pendingByUser.delete(authenticatedUser); + else pendingByUser.set(authenticatedUser, remaining); }; transport = new StreamableHTTPServerTransport({ @@ -944,7 +3000,7 @@ export function createMcpHttpServer( sessions.set(id, { expiresAt: now() + sessionTtlMs, token, - username, + username: authenticatedUser, transport: transport!, }); // Release the reservation the moment the session is registered — the initialize response is a @@ -956,7 +3012,12 @@ export function createMcpHttpServer( transport.onclose = () => { if (transport?.sessionId) sessions.delete(transport.sessionId); }; - await buildMcpServer(api, dataEnabled).connect(transport); + await buildMcpServer(api, { + dataEnabled, + jsEnabled, + governance, + actorId: authenticatedUser, + }).connect(transport); } if (!transport) { diff --git a/app/client/packages/mcp/src/builder/actionPatch.test.ts b/app/client/packages/mcp/src/builder/actionPatch.test.ts new file mode 100644 index 000000000000..1f7a7ec71b83 --- /dev/null +++ b/app/client/packages/mcp/src/builder/actionPatch.test.ts @@ -0,0 +1,178 @@ +import { + buildDeleteActionDto, + buildDuplicateActionDto, + buildUpdateActionDto, + deleteActionSpecSchema, + duplicateActionSpecSchema, + updateActionSpecSchema, +} from "./actionPatch.js"; + +const revision = "a".repeat(64); +const reference = { + actionId: "storedAction1", + applicationId: "app1", + revision, +}; + +const query = { + name: "ListUsers", + applicationId: "app1", + pageId: "page1", + datasourceId: "storedSqlDatasource", + operation: "SELECT", + table: "users", +}; + +const rest = { + name: "GetUsers", + applicationId: "app1", + pageId: "page1", + datasourceId: "storedRestDatasource", + method: "GET", + path: "/v1/users", +}; + +function parseUpdate(spec: unknown) { + return updateActionSpecSchema.parse(spec); +} + +describe("action lifecycle builders", () => { + it("builds a SQL update DTO solely from structured SQL and stored IDs", () => { + const dto = buildUpdateActionDto( + parseUpdate({ ...reference, kind: "SQL", name: "ListUsers", query }), + ); + + expect(dto).toEqual({ + ...reference, + action: { + id: "storedAction1", + name: "ListUsers", + pageId: "page1", + datasource: { id: "storedSqlDatasource" }, + actionConfiguration: { + body: "SELECT * FROM users;", + pluginSpecifiedTemplates: [{ value: true }], + }, + }, + }); + }); + + it("builds a REST update DTO solely from structured REST fields", () => { + const dto = buildUpdateActionDto( + parseUpdate({ ...reference, kind: "REST", name: "GetUsers", rest }), + ); + + expect(dto.action).toMatchObject({ + id: "storedAction1", + name: "GetUsers", + pageId: "page1", + datasource: { id: "storedRestDatasource" }, + actionConfiguration: { + path: "/v1/users", + httpMethod: "GET", + httpVersion: "HTTP11", + }, + }); + expect(dto.action).not.toHaveProperty("applicationId"); + }); + + it("allows a name-only update without an action configuration", () => { + const dto = buildUpdateActionDto( + parseUpdate({ ...reference, kind: "SQL", name: "RenamedQuery" }), + ); + + expect(dto.action).toEqual({ id: "storedAction1", name: "RenamedQuery" }); + }); + + it("builds duplicate and delete requests from stored references", () => { + expect( + buildDuplicateActionDto( + duplicateActionSpecSchema.parse({ + ...reference, + kind: "REST", + name: "GetUsersCopy", + }), + ), + ).toEqual({ ...reference, kind: "REST", name: "GetUsersCopy" }); + expect(buildDeleteActionDto(deleteActionSpecSchema.parse(reference))).toEqual( + reference, + ); + }); +}); + +describe("action lifecycle schemas reject unsafe action mutation", () => { + const badUpdate: [string, Record][] = [ + ["unsupported action kind", { kind: "GRAPHQL", name: "Bad" }], + ["raw SQL body", { kind: "SQL", query: { ...query, body: "DROP TABLE users" } }], + [ + "raw SQL binding", + { kind: "SQL", query: { ...query, table: "{{ evil }}" } }, + ], + [ + "raw REST binding", + { kind: "REST", rest: { ...rest, path: "/v1/{{ evil }}" } }, + ], + [ + "URL change", + { kind: "REST", rest: { ...rest, path: "https://attacker.example/users" } }, + ], + [ + "arbitrary header", + { + kind: "REST", + rest: { + ...rest, + headers: [{ key: "Authorization", value: "Bearer secret" }], + }, + }, + ], + [ + "credentials", + { kind: "REST", rest: { ...rest, credentials: { username: "a" } } }, + ], + [ + "inline datasource URL", + { + kind: "REST", + rest: { ...rest, datasourceId: { url: "https://attacker.example" } }, + }, + ], + [ + "mismatched application", + { kind: "SQL", name: "ListUsers", query: { ...query, applicationId: "app2" } }, + ], + [ + "mismatched action name", + { kind: "REST", name: "Other", rest }, + ], + ["empty update", { kind: "SQL" }], + ]; + + it.each(badUpdate)("rejects %s", (_label, override) => { + expect( + updateActionSpecSchema.safeParse({ ...reference, ...override }).success, + ).toBe(false); + }); + + it("rejects unknown fields and invalid lifecycle references", () => { + expect( + duplicateActionSpecSchema.safeParse({ + ...reference, + kind: "SQL", + name: "Copy", + actionConfiguration: { body: "SELECT 1" }, + }).success, + ).toBe(false); + expect( + deleteActionSpecSchema.safeParse({ actionId: "action1", applicationId: "app1" }) + .success, + ).toBe(false); + expect( + deleteActionSpecSchema.safeParse({ ...reference, revision: "stale" }).success, + ).toBe(false); + expect( + deleteActionSpecSchema.safeParse({ ...reference, url: "https://attacker.example" }) + .success, + ).toBe(false); + }); +}); diff --git a/app/client/packages/mcp/src/builder/actionPatch.ts b/app/client/packages/mcp/src/builder/actionPatch.ts new file mode 100644 index 000000000000..d2fc29925703 --- /dev/null +++ b/app/client/packages/mcp/src/builder/actionPatch.ts @@ -0,0 +1,172 @@ +import { z } from "zod"; +import { + buildActionDto, + compileQuery, + querySpecSchema, + type QuerySpec, +} from "./query.js"; +import { + buildRestActionDto, + compileRestApi, + restApiSpecSchema, + type RestApiSpec, +} from "./restApi.js"; + +// Lifecycle operations identify an already stored action. They deliberately have no escape hatch for inline +// datasource settings, raw SQL, or arbitrary action configuration. +const RAW_EXPRESSION = /\{\{|\}\}|\$\{|`/; +const identifier = z + .string() + .min(1) + .max(128) + .refine((value) => !RAW_EXPRESSION.test(value), "must not contain template syntax"); +const actionName = z + .string() + .min(1) + .max(64) + .regex(/^[A-Za-z0-9_]+$/, "must be alphanumeric/underscore"); +const revision = z + .string() + .regex(/^[a-f0-9]{64}$/, "must be a SHA-256 revision token"); + +const actionReferenceSchema = z + .object({ + actionId: identifier, + applicationId: identifier, + revision, + }) + .strict(); + +const sqlUpdateSchema = actionReferenceSchema + .extend({ + kind: z.literal("SQL"), + // A SQL update is always compiled from the existing structured-query vocabulary; raw body text is not accepted. + query: querySpecSchema.optional(), + name: actionName.optional(), + }) + .strict(); + +const restUpdateSchema = actionReferenceSchema + .extend({ + kind: z.literal("REST"), + // REST configuration is likewise rebuilt from the restricted REST vocabulary. It cannot carry credentials, + // base URLs, arbitrary headers, or a raw binding. + rest: restApiSpecSchema.optional(), + name: actionName.optional(), + }) + .strict(); + +export const updateActionSpecSchema = z + .union([sqlUpdateSchema, restUpdateSchema]) + .superRefine((spec, context) => { + const source = spec.kind === "SQL" ? spec.query : spec.rest; + + if (spec.name === undefined && source === undefined) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "must update a name or structured action configuration", + }); + return; + } + + if (!source) return; + + if (source.applicationId !== spec.applicationId) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: [spec.kind === "SQL" ? "query" : "rest", "applicationId"], + message: "must match the action applicationId", + }); + } + + if (spec.name !== undefined && source.name !== spec.name) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: [spec.kind === "SQL" ? "query" : "rest", "name"], + message: "must match the update name", + }); + } + }); + +export const duplicateActionSpecSchema = actionReferenceSchema + .extend({ + kind: z.enum(["SQL", "REST"]), + name: actionName, + }) + .strict(); + +// Deletion intentionally has no optional fields: the application/revision pair is required for optimistic +// concurrency, while the stored action ID is the only action identifier the caller may supply. +export const deleteActionSpecSchema = actionReferenceSchema; + +export type UpdateActionSpec = z.infer; +export type DuplicateActionSpec = z.infer; +export type DeleteActionSpec = z.infer; + +export interface ActionLifecycleRequest { + actionId: string; + applicationId: string; + revision: string; +} + +export interface UpdateActionRequest extends ActionLifecycleRequest { + action: Record; +} + +function buildUpdateAction( + spec: UpdateActionSpec, +): Record { + const source = spec.kind === "SQL" ? spec.query : spec.rest; + const action: Record = { id: spec.actionId }; + const name = spec.name ?? source?.name; + + if (name !== undefined) action.name = name; + if (!source) return action; + + if (spec.kind === "SQL") { + const query = source as QuerySpec; + const body = compileQuery(query); + const compiled = buildActionDto(query, body); + + return { id: spec.actionId, ...compiled }; + } + + const rest = source as RestApiSpec; + const compiled = buildRestActionDto(rest, compileRestApi(rest)); + + return { id: spec.actionId, ...compiled }; +} + +// Builds the minimal, safe patch request. The output excludes every caller-controlled field except the closed +// ActionDTO vocabulary produced by the structured SQL/REST compilers. +export function buildUpdateActionDto(spec: UpdateActionSpec): UpdateActionRequest { + return { + actionId: spec.actionId, + applicationId: spec.applicationId, + revision: spec.revision, + action: buildUpdateAction(spec), + }; +} + +// Duplicate and delete preserve the stored action/datasource server-side. No action configuration is accepted here. +export function buildDuplicateActionDto( + spec: DuplicateActionSpec, +): ActionLifecycleRequest & { kind: "SQL" | "REST"; name: string } { + return { + actionId: spec.actionId, + applicationId: spec.applicationId, + revision: spec.revision, + kind: spec.kind, + name: spec.name, + }; +} + +export function buildDeleteActionDto( + spec: DeleteActionSpec, +): ActionLifecycleRequest { + return { + actionId: spec.actionId, + applicationId: spec.applicationId, + revision: spec.revision, + }; +} diff --git a/app/client/packages/mcp/src/builder/capabilities.ts b/app/client/packages/mcp/src/builder/capabilities.ts index bcfa0c4a49d1..b9e08410bc60 100644 --- a/app/client/packages/mcp/src/builder/capabilities.ts +++ b/app/client/packages/mcp/src/builder/capabilities.ts @@ -101,11 +101,245 @@ export const WIDGET_CATALOG = [ }, ] as const; -export function getCapabilities() { +// 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", + }, + { + 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: "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", + }, + { 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", + }, + { 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", + }, + // 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", + }, + { name: "list_changes", gate: "governance", summary: "audit history" }, + { 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", + }, + { + name: "prepare_publish", + gate: "governance", + summary: "prepare publish (confirmation)", + }, + { + name: "confirm_publish", + gate: "governance", + summary: "deploy with a token", + }, + // Data layer (APPSMITH_MCP_DATA_ENABLED). + { name: "list_datasources", gate: "data", summary: "discover datasources" }, + { + 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_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", + }, + { + 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", + }, + // 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", + }, + ]; + +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; + } +} + +export function getCapabilities( + gates: CapabilityGates = { data: false, js: false, governance: false }, +) { return { description: - "Build Appsmith apps from a high-level page spec. The MCP layer auto-places widgets on a 64-column grid and " + - "compiles to a real Appsmith artifact imported via the ACL-enforced API. You never author raw widget DSL.", + "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", @@ -120,18 +354,26 @@ export function getCapabilities() { editSpec: { add: "widget spec[] — appended to an existing page" }, presets: listPresets(), grid: { columns: GRID_COLUMNS, rowHeightPx: ROW_HEIGHT }, - tools: [ - "get_capabilities — this catalog", - "list_presets / get_preset — ready page specs to adapt", - "validate_app_spec — dry-run compile; returns errors, imports nothing", - "build_application — compile an app spec and create the app", - "edit_page — append widgets to an existing page (best-effort placement)", - "inspect_page — lint a live page and return structural diagnostics", - ], - dataTools: [ - "list_datasources / get_datasource_structure — discover what to query (data layer)", - "create_query — build a SELECT/INSERT/UPDATE/DELETE query from a structured spec (data layer)", - ], + // 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, + }, + governanceNote: gates.governance + ? "Mutations are locked, revision-checked, and audited; destructive/high-impact operations require a one-time confirmation token." + : "Governance (Mongo+Redis) is not configured, so governed and destructive tools are not registered.", + workflows: { + available: false, + note: "CE workflow tools are not available via MCP.", + }, + gitSync: { + available: false, + note: "Git sync is Appsmith platform functionality and is out of MCP scope.", + }, resources: [ "appsmith://reference/widgets — the widget catalog as a resource", "appsmith://guide/{placement,naming,bindings} — technique guides", 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..093ae319cafa --- /dev/null +++ b/app/client/packages/mcp/src/builder/editPatch.test.ts @@ -0,0 +1,168 @@ +import { z } from "zod"; +import type { WidgetNode } from "./layout.js"; +import { applyWidgetPatch, widgetPatchSchema } from "./editPatch.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 { dsl, changes } = 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("moves a widget into a container's inner canvas and preserves its size", () => { + const { dsl, changes } = 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, + }); + expect(changes).toEqual([ + { + kind: "move", + widgetName: "Greeting", + previousParentWidgetName: "MainContainer", + parentWidgetName: "Details", + previousPosition: { topRow: 0, leftColumn: 0 }, + position: { topRow: 8, leftColumn: 4 }, + }, + ]); + }); + + it("removes a leaf widget by name", () => { + const { dsl, changes } = 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 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); + }); +}); 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..8ce54a18a0e2 --- /dev/null +++ b/app/client/packages/mcp/src/builder/editPatch.ts @@ -0,0 +1,303 @@ +import { z } from "zod"; +import { ROOT_WIDGET_ID, type WidgetNode } from "./layout.js"; + +const RAW_EXPRESSION = /\{\{|\}\}|\$\{|`/; +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"); + +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, literal-only property vocabulary. In particular it excludes events, bindings, +// dynamic path lists, and arbitrary style/configuration fields that could become executable at render time. +export const widgetPropsPatchSchema = z + .object({ + text: safeText(10_000).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(), + }) + .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(), + }) + .strict() + .refine( + (operation) => + operation.parent !== undefined || operation.position !== undefined, + "must set parent or position", + ); + +const removeOperationSchema = z + .object({ + kind: z.literal("remove"), + name: widgetNameSchema, + }) + .strict(); + +export const widgetPatchSchema = z + .object({ + operations: z + .array( + z.union([ + updateOperationSchema, + moveOperationSchema, + 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 }; +} + +export interface WidgetPatchResult { + dsl: WidgetNode; + changes: WidgetPatchChange[]; +} + +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 moveToPosition( + node: WidgetNode, + position: { topRow: number; leftColumn: number }, +): void { + const rowSpan = node.bottomRow - node.topRow; + const columnSpan = node.rightColumn - node.leftColumn; + + node.topRow = position.topRow; + node.bottomRow = position.topRow + rowSpan; + node.leftColumn = position.leftColumn; + node.rightColumn = position.leftColumn + columnSpan; +} + +// 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[] = []; + + for (const operation of patch.operations) { + const widgets = indexWidgets(dsl); + const located = requireWidget(widgets, operation.name); + + if (operation.kind === "update") { + Object.assign(located.node, operation.props); + 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; + } + + const previousParentWidgetName = located.parent?.widgetName; + const previousPosition = positionOf(located.node); + let parentWidgetName = previousParentWidgetName; + + 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`); + } + + removeFromParent(located); + destination.children = destination.children ?? []; + destination.children.push(located.node); + located.node.parentId = destination.widgetId; + parentWidgetName = target.node.widgetName; + } + + if (operation.position !== undefined) { + moveToPosition(located.node, operation.position); + } + + changes.push({ + kind: "move", + widgetName: operation.name, + ...(operation.parent !== undefined + ? { previousParentWidgetName, parentWidgetName } + : {}), + ...(operation.position !== undefined + ? { previousPosition, position: positionOf(located.node) } + : {}), + }); + } + + return { dsl, changes }; +} 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..11dfed2ce0a1 --- /dev/null +++ b/app/client/packages/mcp/src/builder/events.test.ts @@ -0,0 +1,198 @@ +import { + applyEvent, + compileEventBinding, + eventReference, + 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') }}", + ); + }); +}); + +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: {} }, + ]; + + 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); + }); +}); + +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("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("eventReference / widgetExists", () => { + it("returns the referenced entity for validation", () => { + expect(eventReference({ run: "q" })).toEqual({ kind: "query", name: "q" }); + expect(eventReference({ navigate: "Home" })).toEqual({ + kind: "page", + name: "Home", + }); + expect(eventReference({ showModal: "M" })).toEqual({ + kind: "widget", + name: "M", + }); + }); + + 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..d232da1c1e49 --- /dev/null +++ b/app/client/packages/mcp/src/builder/events.ts @@ -0,0 +1,140 @@ +import { z } from "zod"; +import type { WidgetNode } from "./layout.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"); + +// Exactly one action per event. +export const eventActionSchema = z.union([ + z.object({ run: queryName }).strict(), + z.object({ navigate: pageName }).strict(), + z.object({ showModal: widgetName }).strict(), + z.object({ closeModal: widgetName }).strict(), +]); + +export type EventAction = z.infer; + +// The event property allowed on each widget type (closed). form submit is wired via the form's button onClick. +const EVENTS_BY_TYPE: Record = { + BUTTON_WIDGET: ["onClick"], + TABLE_WIDGET_V2: ["onRowSelected"], + MODAL_WIDGET: ["onClose"], + TABS_WIDGET: ["onTabSelected"], +}; + +export const wireEventSpecSchema = z + .object({ + widget: widgetName, + event: z.enum(["onClick", "onRowSelected", "onClose", "onTabSelected"]), + action: eventActionSchema, + }) + .strict(); + +export type WireEventSpec = z.infer; + +// The single binding emitter. Every argument is a validated identifier / safe page name. +export function compileEventBinding(action: EventAction): 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}') }}`; + + return `{{ closeModal('${action.closeModal}') }}`; +} + +// The entity an event references, so the caller can verify it exists before writing (dangling-reference guard). +export function eventReference(action: EventAction): { + kind: "query" | "page" | "widget"; + name: string; +} { + if ("run" in action) return { kind: "query", name: action.run }; + + if ("navigate" in action) return { kind: "page", name: action.navigate }; + + if ("showModal" in action) return { kind: "widget", name: action.showModal }; + + return { kind: "widget", name: action.closeModal }; +} + +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/jsObject.test.ts b/app/client/packages/mcp/src/builder/jsObject.test.ts new file mode 100644 index 000000000000..3f546a542ce1 --- /dev/null +++ b/app/client/packages/mcp/src/builder/jsObject.test.ts @@ -0,0 +1,158 @@ +import { + buildCreateJsObjectRequest, + buildDeleteJsObjectRequest, + buildUpdateJsObjectRequest, + compileJsObject, + createJsObjectSpecSchema, + deleteJsObjectSpecSchema, + updateJsObjectSpecSchema, +} from "./jsObject.js"; + +const revision = "d".repeat(64); +const createSpec = { + applicationId: "app1", + pageId: "page1", + workspaceId: "workspace1", + pluginId: "jsPlugin1", + revision, + name: "UserHelpers", + constants: { limit: 25, enabled: true }, + functions: [ + { + name: "loadUsers", + run: [{ query: "GetUsers" }], + returns: { loaded: true, count: 25 }, + }, + ], +}; + +describe("JS object builder", () => { + it("compiles only the declarative JS-object grammar", () => { + const spec = createJsObjectSpecSchema.parse(createSpec); + + expect(compileJsObject(spec)).toBe( + "export default { limit: 25, enabled: true, loadUsers: async () => { await GetUsers.run(); return { loaded: true, count: 25 }; } };", + ); + expect(buildCreateJsObjectRequest(spec)).toEqual({ + applicationId: "app1", + revision, + method: "POST", + path: "v1/collections/actions", + body: { + name: "UserHelpers", + pageId: "page1", + workspaceId: "workspace1", + pluginId: "jsPlugin1", + pluginType: "JS", + body: "export default { limit: 25, enabled: true, loadUsers: async () => { await GetUsers.run(); return { loaded: true, count: 25 }; } };", + variables: [], + actions: [], + }, + destructive: false, + }); + }); + + it("builds revision-bound updates and destructive deletes", () => { + const update = updateJsObjectSpecSchema.parse({ + applicationId: "app1", + collectionId: "collection1", + revision, + name: "RenamedHelpers", + functions: [{ name: "refresh", run: [{ query: "GetUsers" }] }], + }); + + expect(buildUpdateJsObjectRequest(update)).toEqual({ + applicationId: "app1", + revision, + method: "PATCH", + path: "v1/collections/actions/collection1", + body: { + actionCollection: { + id: "collection1", + name: "RenamedHelpers", + pluginType: "JS", + body: "export default { refresh: async () => { await GetUsers.run(); } };", + variables: [], + actions: [], + }, + actions: { added: [], updated: [], deleted: [] }, + }, + destructive: false, + }); + + expect( + buildDeleteJsObjectRequest( + deleteJsObjectSpecSchema.parse({ + applicationId: "app1", + collectionId: "collection1", + revision, + }), + ), + ).toEqual({ + applicationId: "app1", + collectionId: "collection1", + revision, + method: "DELETE", + path: "v1/collections/actions/collection1", + destructive: true, + confirmation: { + operation: "delete_js_object", + entityKey: "js_object:collection1", + }, + }); + }); +}); + +describe("JS object grammar rejects source and dynamic syntax", () => { + const invalid: [string, Record][] = [ + ["raw body", { body: "export default { unsafe() { return eval('1') } }" }], + ["raw source", { source: "import x from 'x'" }], + ["imports", { imports: ["node:fs"] }], + ["network API", { functions: [{ name: "bad", fetch: "https://evil.example" }] }], + ["eval", { functions: [{ name: "bad", eval: "1 + 1" }] }], + ["global access", { functions: [{ name: "bad", global: "process" }] }], + ["computed properties", { functions: [{ name: "bad", returns: { "[key]": 1 } }] }], + ["loops", { functions: [{ name: "bad", loop: "for (;;) {}" }] }], + ["arbitrary expression", { functions: [{ name: "bad", expression: "a + b" }] }], + ["template binding", { constants: { value: "{{ GetUsers.data }}" } }], + ["template literal", { constants: { value: "${GetUsers.data}" } }], + ]; + + it.each(invalid)("rejects %s", (_label, override) => { + expect( + createJsObjectSpecSchema.safeParse({ + ...createSpec, + ...override, + }).success, + ).toBe(false); + }); + + it("rejects unsafe identifiers, duplicate functions, incomplete updates, and missing revisions", () => { + expect( + createJsObjectSpecSchema.safeParse({ + ...createSpec, + functions: [{ name: "run-query" }], + }).success, + ).toBe(false); + expect( + createJsObjectSpecSchema.safeParse({ + ...createSpec, + functions: [{ name: "same" }, { name: "same" }], + }).success, + ).toBe(false); + expect( + updateJsObjectSpecSchema.safeParse({ + applicationId: "app1", + collectionId: "collection1", + revision, + constants: { answer: 42 }, + }).success, + ).toBe(false); + expect( + deleteJsObjectSpecSchema.safeParse({ + applicationId: "app1", + collectionId: "collection1", + }).success, + ).toBe(false); + }); +}); diff --git a/app/client/packages/mcp/src/builder/jsObject.ts b/app/client/packages/mcp/src/builder/jsObject.ts new file mode 100644 index 000000000000..c680c422eeae --- /dev/null +++ b/app/client/packages/mcp/src/builder/jsObject.ts @@ -0,0 +1,274 @@ +import { z } from "zod"; + +const RAW_EXPRESSION = /\{\{|\}\}|\$\{|`/; +const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; +const REVISION = /^[a-f0-9]{64}$/; + +const identifier = z + .string() + .min(1) + .max(128) + .regex(IDENTIFIER, "must be a plain identifier"); +const entityId = z + .string() + .min(1) + .max(128) + .refine((value) => !RAW_EXPRESSION.test(value), "must not contain template syntax"); +const revision = z + .string() + .regex(REVISION, "must be a SHA-256 revision token"); +const collectionName = z + .string() + .min(1) + .max(64) + .regex(IDENTIFIER, "must be a plain identifier"); + +// All emitted values are JSON literals. This deliberately excludes expressions, references, template bindings, and +// executable values; the only dynamic operation in the grammar is a compiler-emitted query `.run()` call. +const literal = z.union([ + z + .string() + .max(1_000) + .refine((value) => !RAW_EXPRESSION.test(value), "must not contain template syntax"), + z.number().finite(), + z.boolean(), + z.null(), +]); + +const constants = z.record(identifier, literal).refine( + (value) => Object.keys(value).length <= 50, + "must contain at most 50 constants", +); + +const functionSpecSchema = z + .object({ + name: identifier, + // A run is a named, stored Appsmith query. The compiler owns both `await` and `.run()`; callers never supply + // a callee, arguments, property access, or source code. + run: z.array(z.object({ query: identifier }).strict()).max(20).optional(), + // Return values are a literal object only. A caller cannot smuggle expressions, computed fields, or global + // references through the generated function. + returns: z.record(identifier, literal).optional(), + }) + .strict(); + +const functions = z.array(functionSpecSchema).min(1).max(50); +const definitionFields = { + constants: constants.optional(), + functions, +}; + +const createFields = { + applicationId: entityId, + pageId: entityId, + workspaceId: entityId, + pluginId: entityId, + revision, + name: collectionName, +}; + +export const createJsObjectSpecSchema = z + .object({ + ...createFields, + ...definitionFields, + }) + .strict() + .refine( + ({ functions }) => + new Set(functions.map(({ name }) => name)).size === functions.length, + "function names must be unique", + ); + +export const updateJsObjectSpecSchema = z + .object({ + applicationId: entityId, + collectionId: entityId, + revision, + name: collectionName.optional(), + constants: constants.optional(), + functions: z.array(functionSpecSchema).min(1).max(50).optional(), + }) + .strict() + .superRefine((spec, context) => { + if ( + spec.name === undefined && + spec.constants === undefined && + spec.functions === undefined + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "must update a name or declarative JS-object definition", + }); + } + + if ( + spec.functions && + new Set(spec.functions.map(({ name }) => name)).size !== + spec.functions.length + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["functions"], + message: "function names must be unique", + }); + } + + if (spec.constants !== undefined && spec.functions === undefined) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["functions"], + message: "must provide a complete function definition with constants", + }); + } + }); + +export const deleteJsObjectSpecSchema = z + .object({ + applicationId: entityId, + collectionId: entityId, + revision, + }) + .strict(); + +export type CreateJsObjectSpec = z.infer; +export type UpdateJsObjectSpec = z.infer; +export type DeleteJsObjectSpec = z.infer; + +export interface ActionCollectionDto extends Record { + name?: string; + pageId?: string; + workspaceId?: string; + pluginId?: string; + pluginType: "JS"; + body?: string; + variables?: []; + actions?: []; +} + +export interface ActionCollectionMutationRequest { + applicationId: string; + revision: string; + method: "POST" | "PATCH"; + path: string; + body: Record; + destructive: false; +} + +export interface DeleteJsObjectRequest { + applicationId: string; + collectionId: string; + revision: string; + method: "DELETE"; + path: string; + destructive: true; + confirmation: { + operation: "delete_js_object"; + entityKey: string; + }; +} + +function renderObject(entries: Record>): string { + return Object.entries(entries) + .map(([key, value]) => `${key}: ${JSON.stringify(value)}`) + .join(", "); +} + +function renderFunction(spec: z.infer): string { + const statements = (spec.run ?? []).map( + ({ query }) => `await ${query}.run();`, + ); + + if (spec.returns !== undefined) { + statements.push(`return { ${renderObject(spec.returns)} };`); + } + + return `${spec.name}: async () => { ${statements.join(" ")} }`; +} + +// This is the sole JS emitter. Names have identifier-only validation and values are JSON serialized, so the output +// cannot contain caller-authored syntax. It creates only constants, async query runs, and literal return objects. +export function compileJsObject( + spec: Pick, +): string { + const constantsSource = spec.constants ? renderObject(spec.constants) : ""; + const functionsSource = spec.functions.map(renderFunction).join(", "); + const source = `export default { ${[constantsSource, functionsSource].filter(Boolean).join(", ")} };`; + + if (RAW_EXPRESSION.test(source)) { + throw new Error("compiled JS object contains forbidden template syntax"); + } + + return source; +} + +export function buildCreateJsObjectRequest( + spec: CreateJsObjectSpec, +): ActionCollectionMutationRequest { + const dto: ActionCollectionDto = { + name: spec.name, + pageId: spec.pageId, + workspaceId: spec.workspaceId, + pluginId: spec.pluginId, + pluginType: "JS", + body: compileJsObject(spec), + variables: [], + actions: [], + }; + + return { + applicationId: spec.applicationId, + revision: spec.revision, + method: "POST", + path: "v1/collections/actions", + body: dto, + destructive: false, + }; +} + +export function buildUpdateJsObjectRequest( + spec: UpdateJsObjectSpec, +): ActionCollectionMutationRequest { + const actionCollection: ActionCollectionDto & { id: string } = { + id: spec.collectionId, + pluginType: "JS", + }; + + if (spec.name !== undefined) actionCollection.name = spec.name; + if (spec.functions !== undefined) { + actionCollection.body = compileJsObject({ + constants: spec.constants, + functions: spec.functions, + }); + actionCollection.variables = []; + actionCollection.actions = []; + } + + return { + applicationId: spec.applicationId, + revision: spec.revision, + method: "PATCH", + path: `v1/collections/actions/${spec.collectionId}`, + body: { + actionCollection, + actions: { added: [], updated: [], deleted: [] }, + }, + destructive: false, + }; +} + +export function buildDeleteJsObjectRequest( + spec: DeleteJsObjectSpec, +): DeleteJsObjectRequest { + return { + applicationId: spec.applicationId, + collectionId: spec.collectionId, + revision: spec.revision, + method: "DELETE", + path: `v1/collections/actions/${spec.collectionId}`, + destructive: true, + confirmation: { + operation: "delete_js_object", + entityKey: `js_object:${spec.collectionId}`, + }, + }; +} diff --git a/app/client/packages/mcp/src/builder/pages.test.ts b/app/client/packages/mcp/src/builder/pages.test.ts new file mode 100644 index 000000000000..931ae42bf714 --- /dev/null +++ b/app/client/packages/mcp/src/builder/pages.test.ts @@ -0,0 +1,153 @@ +import { + buildCreatePageRequest, + buildDeletePageRequest, + buildRenamePageRequest, + createPageSpecSchema, + deletePageSpecSchema, + renamePageSpecSchema, +} from "./pages.js"; + +const applicationId = "a".repeat(24); +const pageId = "b".repeat(24); +const revision = "c".repeat(64); + +describe("page CRUD request builders", () => { + it("builds a create request with only a compiler-owned blank layout", () => { + const request = buildCreatePageRequest( + createPageSpecSchema.parse({ + applicationId, + revision, + name: "Orders", + }), + ); + + expect(request).toEqual({ + applicationId, + revision, + method: "POST", + path: "v1/pages", + body: { + applicationId, + name: "Orders", + layouts: [ + { + dsl: { + widgetName: "MainContainer", + backgroundColor: "none", + rightColumn: 1242, + snapColumns: 64, + widgetId: "0", + topRow: 0, + bottomRow: 1292, + parentRowSpace: 1, + type: "CANVAS_WIDGET", + detachFromLayout: true, + minHeight: 1292, + dynamicBindingPathList: {}, + parentColumnSpace: 1, + leftColumn: 0, + children: [], + }, + layoutOnLoadActions: [], + }, + ], + }, + destructive: false, + }); + }); + + it("builds a minimal rename request", () => { + expect( + buildRenamePageRequest( + renamePageSpecSchema.parse({ + applicationId, + pageId, + revision, + name: "Orders Archive", + }), + ), + ).toEqual({ + applicationId, + revision, + method: "PUT", + path: `v1/pages/${pageId}`, + body: { name: "Orders Archive" }, + destructive: false, + }); + }); + + it("marks deletion as destructive and binds its confirmation to the page", () => { + expect( + buildDeletePageRequest( + deletePageSpecSchema.parse({ applicationId, pageId, revision }), + ), + ).toEqual({ + applicationId, + pageId, + revision, + method: "DELETE", + path: `v1/pages/${pageId}`, + destructive: true, + confirmation: { + operation: "delete_page", + entityKey: `page:${pageId}`, + }, + }); + }); +}); + +describe("page CRUD schemas reject unsafe mutation input", () => { + const invalidCreate: [string, Record][] = [ + ["missing revision", { revision: undefined }], + ["raw DSL", { dsl: { widgetName: "Injected" } }], + ["arbitrary page config", { isHidden: true }], + ["JavaScript binding", { name: "{{ evil.run() }}" }], + ["template interpolation", { name: "${evil}" }], + ["unsafe filename", { name: "../admin" }], + ["empty name", { name: " " }], + ["invalid application ID", { applicationId: "app1" }], + ]; + + it.each(invalidCreate)("rejects create input with %s", (_label, override) => { + expect( + createPageSpecSchema.safeParse({ + applicationId, + revision, + name: "Orders", + ...override, + }).success, + ).toBe(false); + }); + + it.each([ + ["raw DSL", { layouts: [] }], + ["arbitrary page config", { customSlug: "orders" }], + ["binding name", { name: "{{ evil }}" }], + ["unsafe name", { name: "Orders/Archive" }], + ["missing revision", { revision: undefined }], + ])("rejects rename input with %s", (_label, override) => { + expect( + renamePageSpecSchema.safeParse({ + applicationId, + pageId, + revision, + name: "Orders", + ...override, + }).success, + ).toBe(false); + }); + + it("rejects delete input without a revision or with caller-controlled fields", () => { + expect( + deletePageSpecSchema.safeParse({ applicationId, pageId }).success, + ).toBe(false); + expect( + deletePageSpecSchema.safeParse({ + applicationId, + pageId, + revision, + force: true, + }).success, + ).toBe(false); + }); +}); diff --git a/app/client/packages/mcp/src/builder/pages.ts b/app/client/packages/mcp/src/builder/pages.ts new file mode 100644 index 000000000000..d220a0b88108 --- /dev/null +++ b/app/client/packages/mcp/src/builder/pages.ts @@ -0,0 +1,144 @@ +import { z } from "zod"; + +const RAW_EXPRESSION = /\{\{|\}\}|\$\{|`/; +const APP_OR_PAGE_ID = /^[A-Za-z0-9]{24,50}$/; +const PAGE_NAME = /^[A-Za-z0-9][A-Za-z0-9 _-]*$/; +const REVISION = /^[a-f0-9]{64}$/; + +const identifier = z + .string() + .regex(APP_OR_PAGE_ID, "must be a 24-50 character Appsmith identifier"); +const revision = z + .string() + .regex(REVISION, "must be a SHA-256 revision token"); +const pageName = z + .string() + .trim() + .min(1) + .max(30) + .regex(PAGE_NAME, "must use safe letters, numbers, spaces, underscores, or hyphens") + .refine( + (value) => !RAW_EXPRESSION.test(value), + "must not contain binding or template syntax", + ); + +const pageReferenceSchema = z + .object({ + applicationId: identifier, + pageId: identifier, + revision, + }) + .strict(); + +// PageCreationDTO requires a non-empty layouts list. This closed, compiler-owned canvas is the equivalent of the +// platform's blank-page template; callers cannot provide DSL, bindings, actions, or arbitrary page configuration. +function buildBlankPageDsl(): Record { + return { + widgetName: "MainContainer", + backgroundColor: "none", + rightColumn: 1242, + snapColumns: 64, + widgetId: "0", + topRow: 0, + bottomRow: 1292, + parentRowSpace: 1, + type: "CANVAS_WIDGET", + detachFromLayout: true, + minHeight: 1292, + dynamicBindingPathList: {}, + parentColumnSpace: 1, + leftColumn: 0, + children: [], + }; +} + +export const createPageSpecSchema = z + .object({ + applicationId: identifier, + revision, + name: pageName, + }) + .strict(); + +export const renamePageSpecSchema = pageReferenceSchema + .extend({ name: pageName }) + .strict(); + +// Delete has no optional caller-controlled fields and is deliberately separate from the non-destructive request +// union, allowing the caller to require a governance confirmation before invoking the Page API. +export const deletePageSpecSchema = pageReferenceSchema; + +export type CreatePageSpec = z.infer; +export type RenamePageSpec = z.infer; +export type DeletePageSpec = z.infer; + +export interface PageMutationRequest { + applicationId: string; + revision: string; + method: "POST" | "PUT"; + path: string; + body: Record; + destructive: false; +} + +export interface DeletePageRequest { + applicationId: string; + pageId: string; + revision: string; + method: "DELETE"; + path: string; + destructive: true; + confirmation: { + operation: "delete_page"; + entityKey: string; + }; +} + +// The revision is MCP orchestration metadata, not an undocumented Page API field. Callers must compare it with +// their current application/page revision before dispatching this request. +export function buildCreatePageRequest( + spec: CreatePageSpec, +): PageMutationRequest { + return { + applicationId: spec.applicationId, + revision: spec.revision, + method: "POST", + path: "v1/pages", + body: { + applicationId: spec.applicationId, + name: spec.name, + layouts: [{ dsl: buildBlankPageDsl(), layoutOnLoadActions: [] }], + }, + destructive: false, + }; +} + +export function buildRenamePageRequest( + spec: RenamePageSpec, +): PageMutationRequest { + return { + applicationId: spec.applicationId, + revision: spec.revision, + method: "PUT", + path: `v1/pages/${spec.pageId}`, + body: { name: spec.name }, + destructive: false, + }; +} + +export function buildDeletePageRequest( + spec: DeletePageSpec, +): DeletePageRequest { + return { + applicationId: spec.applicationId, + pageId: spec.pageId, + revision: spec.revision, + method: "DELETE", + path: `v1/pages/${spec.pageId}`, + destructive: true, + confirmation: { + operation: "delete_page", + entityKey: `page:${spec.pageId}`, + }, + }; +} diff --git a/app/client/packages/mcp/src/builder/restApi.test.ts b/app/client/packages/mcp/src/builder/restApi.test.ts new file mode 100644 index 000000000000..161eec7467af --- /dev/null +++ b/app/client/packages/mcp/src/builder/restApi.test.ts @@ -0,0 +1,214 @@ +import { + buildRestActionDto, + compileRestApi, + restApiSpecSchema, +} from "./restApi.js"; + +function parse(spec: unknown) { + const result = restApiSpecSchema.safeParse(spec); + + if (!result.success) throw new Error("spec did not parse"); + + return result.data; +} + +const base = { + name: "getUsers", + applicationId: "app1", + pageId: "page1", + datasourceId: "storedRestDatasource", + method: "GET", + path: "/v1/users", +}; + +describe("compileRestApi — structured REST actions", () => { + it("compiles safe query parameters and allowlisted headers", () => { + const compiled = compileRestApi( + parse({ + ...base, + queryParameters: [ + { key: "limit", value: { literal: 25 } }, + { + key: "userId", + value: { widget: "UsersTable", property: "selectedRow.id" }, + }, + ], + headers: [ + { key: "Accept", value: "application/json" }, + { key: "Content-Type", value: "application/json" }, + ], + }), + ); + + expect(compiled).toMatchObject({ + path: "/v1/users", + queryParameters: [ + { key: "limit", value: "25" }, + { key: "userId", value: "{{ UsersTable.selectedRow.id }}" }, + ], + headers: [ + { key: "Accept", value: "application/json" }, + { key: "Content-Type", value: "application/json" }, + ], + body: "", + bodyFormData: [], + apiContentType: "none", + }); + }); + + it("compiles JSON and form bodies with compiler-authored widget bindings", () => { + const json = compileRestApi( + parse({ + ...base, + method: "PATCH", + body: { + type: "json", + values: { + name: { literal: "Ada" }, + active: { literal: true }, + userId: { widget: "UsersTable", property: "selectedRow.id" }, + }, + }, + }), + ); + const form = compileRestApi( + parse({ + ...base, + method: "POST", + body: { + type: "form", + fields: [ + { key: "name", value: { literal: "Ada" } }, + { + key: "userId", + value: { widget: "UsersTable", property: "selectedRow.id" }, + }, + ], + }, + }), + ); + + expect(json).toMatchObject({ + body: '{"name":"Ada","active":true,"userId":{{ UsersTable.selectedRow.id }}}', + bodyFormData: [], + apiContentType: "application/json", + }); + expect(form).toMatchObject({ + body: "", + bodyFormData: [ + { key: "name", value: "Ada" }, + { key: "userId", value: "{{ UsersTable.selectedRow.id }}" }, + ], + apiContentType: "application/x-www-form-urlencoded", + }); + }); + + it("builds a REST ActionDTO with the stored datasource", () => { + const spec = parse({ + ...base, + method: "POST", + body: { type: "json", values: { name: { literal: "Ada" } } }, + }); + const dto = buildRestActionDto(spec, compileRestApi(spec)) as { + name: string; + pageId: string; + datasource: { id: string }; + actionConfiguration: { + path: string; + httpMethod: string; + httpVersion: string; + formData: { apiContentType: string }; + body: string; + }; + }; + + expect(dto.name).toBe("getUsers"); + expect(dto.pageId).toBe("page1"); + expect(dto.datasource).toEqual({ id: "storedRestDatasource" }); + expect(dto.actionConfiguration).toMatchObject({ + path: "/v1/users", + httpMethod: "POST", + httpVersion: "HTTP11", + formData: { apiContentType: "application/json" }, + body: '{"name":"Ada"}', + }); + }); +}); + +describe("restApiSpecSchema — rejects injection and unbounded input", () => { + const bad: [string, Record][] = [ + ["absolute URL", { path: "https://attacker.example/users" }], + ["protocol-relative path", { path: "//attacker.example/users" }], + ["path traversal", { path: "/v1/../admin" }], + ["template path", { path: "/v1/{{evil}}" }], + ["path query string", { path: "/v1/users?admin=true" }], + ["unsupported method", { method: "OPTIONS" }], + [ + "raw query binding", + { queryParameters: [{ key: "id", value: { literal: "{{evil()}}" } }] }, + ], + [ + "unsafe widget property", + { + queryParameters: [ + { + key: "id", + value: { widget: "Table1", property: "selectedRow;run()" }, + }, + ], + }, + ], + [ + "arbitrary authorization header", + { headers: [{ key: "Authorization", value: "Bearer secret" }] }, + ], + [ + "templated header value", + { headers: [{ key: "Accept", value: "{{evil()}}" }] }, + ], + [ + "duplicate query key", + { + queryParameters: [ + { key: "id", value: { literal: 1 } }, + { key: "id", value: { literal: 2 } }, + ], + }, + ], + [ + "inline datasource config", + { datasourceId: { url: "https://attacker.example" } }, + ], + ]; + + it.each(bad)("rejects %s", (_label, override) => { + expect(restApiSpecSchema.safeParse({ ...base, ...override }).success).toBe( + false, + ); + }); + + it("rejects a GET body and oversized compiled body", () => { + const getWithBody = parse({ + ...base, + body: { type: "json", values: { id: { literal: 1 } } }, + }); + const oversized = parse({ + ...base, + method: "POST", + body: { + type: "json", + values: Object.fromEntries( + Array.from({ length: 10 }, (_, index) => [ + `field${index}`, + { literal: "x".repeat(1000) }, + ]), + ), + }, + }); + + expect(() => compileRestApi(getWithBody)).toThrow( + "GET requests cannot include a body", + ); + expect(() => compileRestApi(oversized)).toThrow("exceeds the size limit"); + }); +}); diff --git a/app/client/packages/mcp/src/builder/restApi.ts b/app/client/packages/mcp/src/builder/restApi.ts new file mode 100644 index 000000000000..d3058a365eed --- /dev/null +++ b/app/client/packages/mcp/src/builder/restApi.ts @@ -0,0 +1,290 @@ +import { z } from "zod"; + +const MAX_BODY_BYTES = 8 * 1024; +const RAW_EXPRESSION = /\{\{|\}\}|\$\{|`/; +const BINDING_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; +const PROPERTY_PATH = /^[A-Za-z_][A-Za-z0-9_.]*$/; +const FIELD_KEY = /^[A-Za-z][A-Za-z0-9_.-]*$/; +const PATH_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._~-]*$/; +const SAFE_BINDING = + /^\{\{ [A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_.]* \}\}$/; + +const actionName = z + .string() + .min(1) + .max(64) + .regex(BINDING_IDENTIFIER, "must be alphanumeric/underscore"); + +const identifier = z.string().min(1).max(128); + +const literalScalar = z.union([ + z + .string() + .max(1000) + .refine((value) => !RAW_EXPRESSION.test(value), "no template syntax"), + z.number().finite(), + z.boolean(), + z.null(), +]); + +const valueRef = z.union([ + z.object({ literal: literalScalar }).strict(), + z + .object({ + widget: actionName, + property: z + .string() + .min(1) + .max(128) + .regex(PROPERTY_PATH, "must be a dotted identifier path"), + }) + .strict(), +]); + +const keyValueRef = z + .object({ + key: z.string().min(1).max(64).regex(FIELD_KEY, "must be a safe key"), + value: valueRef, + }) + .strict(); + +const safePath = z + .string() + .min(1) + .max(512) + .refine((value) => { + if ( + !value.startsWith("/") || + value.includes("://") || + value.includes("//") || + value.includes("?") || + value.includes("#") || + RAW_EXPRESSION.test(value) + ) { + return false; + } + + const segments = value.slice(1).split("/"); + + return ( + segments.length > 0 && + segments.every( + (segment) => + segment !== "." && segment !== ".." && PATH_SEGMENT.test(segment), + ) + ); + }, "must be an absolute path made of safe segments"); + +const header = z + .union([ + z + .object({ + key: z.literal("Accept"), + value: z.enum(["application/json", "text/plain", "*/*"]), + }) + .strict(), + z + .object({ + key: z.literal("Content-Type"), + value: z.enum([ + "application/json", + "application/x-www-form-urlencoded", + ]), + }) + .strict(), + ]) + .refine((value) => !RAW_EXPRESSION.test(value.value), "no template syntax"); + +const body = z + .discriminatedUnion("type", [ + z + .object({ + type: z.literal("json"), + values: z.record(z.string().min(1).max(64).regex(FIELD_KEY), valueRef), + }) + .strict(), + z + .object({ + type: z.literal("form"), + fields: z.array(keyValueRef).min(1).max(50), + }) + .strict(), + ]) + .refine( + (value) => + value.type !== "json" || Object.entries(value.values).length <= 50, + "must contain at most 50 fields", + ); + +export const restApiSpecSchema = z + .object({ + name: actionName, + applicationId: identifier, + pageId: identifier, + // Stored datasource only: callers cannot submit an inline base URL or config. + datasourceId: identifier, + method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]), + path: safePath, + queryParameters: z + .array(keyValueRef) + .max(20) + .refine( + (values) => + new Set(values.map(({ key }) => key)).size === values.length, + "must not repeat query parameter keys", + ) + .optional(), + headers: z + .array(header) + .max(2) + .refine( + (values) => + new Set(values.map(({ key }) => key)).size === values.length, + "must not repeat header keys", + ) + .optional(), + body: body.optional(), + }) + .strict(); + +export type RestApiSpec = z.infer; +type ValueRef = z.infer; +type RestBody = z.infer; + +function emitValue(value: ValueRef): string { + if ("literal" in value) { + return JSON.stringify(value.literal); + } + + const binding = `{{ ${value.widget}.${value.property} }}`; + + if (!SAFE_BINDING.test(binding)) { + throw new Error(`unsafe binding emitted: ${binding}`); + } + + return binding; +} + +function emitParameterValue(value: ValueRef): string { + return "literal" in value + ? value.literal === null + ? "null" + : String(value.literal) + : emitValue(value); +} + +function compileBody(body: RestBody | undefined): { + body: string; + bodyFormData: { key: string; value: string }[]; + apiContentType: + | "none" + | "application/json" + | "application/x-www-form-urlencoded"; +} { + if (!body) { + return { body: "", bodyFormData: [], apiContentType: "none" }; + } + + if (body.type === "form") { + return { + body: "", + bodyFormData: body.fields.map(({ key, value }) => ({ + key, + value: emitParameterValue(value), + })), + apiContentType: "application/x-www-form-urlencoded", + }; + } + + const entries = Object.entries(body.values).map( + ([key, value]) => `${JSON.stringify(key)}:${emitValue(value)}`, + ); + + return { + body: `{${entries.join(",")}}`, + bodyFormData: [], + apiContentType: "application/json", + }; +} + +function assertBodySafe(body: string): void { + if (Buffer.byteLength(body, "utf8") > MAX_BODY_BYTES) { + throw new Error("compiled REST body exceeds the size limit"); + } + + if (/\$\{|`/.test(body)) { + throw new Error("compiled REST body contains forbidden template syntax"); + } + + const openings = (body.match(/\{\{/g) ?? []).length; + const closings = (body.match(/\}\}/g) ?? []).length; + const bindings = body.match(/\{\{ [^}]* \}\}/g) ?? []; + + if ( + openings !== closings || + openings !== bindings.length || + !bindings.every((binding) => SAFE_BINDING.test(binding)) + ) { + throw new Error("compiled REST body contains an unsafe binding"); + } +} + +export interface CompiledRestApi { + path: string; + queryParameters: { key: string; value: string }[]; + headers: { key: "Accept" | "Content-Type"; value: string }[]; + body: string; + bodyFormData: { key: string; value: string }[]; + apiContentType: + | "none" + | "application/json" + | "application/x-www-form-urlencoded"; +} + +// Emits the only dynamic syntax accepted by REST actions. Values supplied by callers are either serialized literals +// or a validated widget reference; raw Appsmith bindings are never passed through. +export function compileRestApi(spec: RestApiSpec): CompiledRestApi { + if (spec.body && spec.method === "GET") { + throw new Error("GET requests cannot include a body"); + } + + const compiledBody = compileBody(spec.body); + + assertBodySafe(compiledBody.body); + compiledBody.bodyFormData.forEach((field) => assertBodySafe(field.value)); + + return { + path: spec.path, + queryParameters: (spec.queryParameters ?? []).map(({ key, value }) => ({ + key, + value: emitParameterValue(value), + })), + headers: spec.headers ?? [], + ...compiledBody, + }; +} + +// Builds the REST ActionDTO accepted by POST /api/v1/actions. The datasource remains an embedded identifier; its +// configuration, including the base URL and authentication, stays server-side in the stored datasource. +export function buildRestActionDto( + spec: RestApiSpec, + compiled: CompiledRestApi, +): Record { + return { + name: spec.name, + pageId: spec.pageId, + datasource: { id: spec.datasourceId }, + actionConfiguration: { + path: compiled.path, + httpMethod: spec.method, + httpVersion: "HTTP11", + encodeParamsToggle: true, + headers: compiled.headers, + queryParameters: compiled.queryParameters, + body: compiled.body, + bodyFormData: compiled.bodyFormData, + formData: { apiContentType: compiled.apiContentType }, + pluginSpecifiedTemplates: [{ value: true }], + }, + }; +} diff --git a/app/client/packages/mcp/src/builder/semantic.test.ts b/app/client/packages/mcp/src/builder/semantic.test.ts new file mode 100644 index 000000000000..6ecd13ac6c79 --- /dev/null +++ b/app/client/packages/mcp/src/builder/semantic.test.ts @@ -0,0 +1,144 @@ +import type { WidgetNode } from "./layout.js"; +import { + canonicalStableSerialize, + fingerprintDsl, + projectSemanticPage, +} from "./semantic.js"; + +function node( + overrides: Partial & + Pick, +): WidgetNode { + return { + topRow: 0, + bottomRow: 4, + leftColumn: 0, + rightColumn: 24, + ...overrides, + }; +} + +describe("projectSemanticPage", () => { + it("flattens containers and retains the immediate parent name", () => { + const dsl = node({ + widgetId: "root", + widgetName: "MainContainer", + type: "CANVAS_WIDGET", + children: [ + node({ + widgetId: "container", + widgetName: "ProfileCard", + type: "CONTAINER_WIDGET", + topRow: 2, + bottomRow: 20, + rightColumn: 40, + children: [ + node({ + widgetId: "canvas", + widgetName: "ProfileCanvas", + type: "CANVAS_WIDGET", + children: [ + node({ + widgetId: "text", + widgetName: "WelcomeText", + type: "TEXT_WIDGET", + text: "Welcome", + }), + ], + }), + ], + }), + ], + }); + + expect(projectSemanticPage(dsl).widgets).toEqual([ + expect.objectContaining({ + id: "root", + name: "MainContainer", + appsmithType: "CANVAS_WIDGET", + geometry: { topRow: 0, bottomRow: 4, leftColumn: 0, rightColumn: 24 }, + }), + expect.objectContaining({ + id: "container", + catalogType: "container", + parentWidgetName: "MainContainer", + geometry: { topRow: 2, bottomRow: 20, leftColumn: 0, rightColumn: 40 }, + }), + expect.objectContaining({ + id: "canvas", + parentWidgetName: "ProfileCard", + }), + expect.objectContaining({ + id: "text", + catalogType: "text", + parentWidgetName: "ProfileCanvas", + props: { text: "Welcome" }, + }), + ]); + }); + + it("represents unknown widgets without exposing arbitrary props or bindings", () => { + const dsl = node({ + widgetId: "custom", + widgetName: "CustomWidget", + type: "VENDOR_WIDGET", + title: "Safe title", + text: "{{ query.data }}", + options: [ + { label: "Safe", value: "one" }, + { label: "{{ unsafe }}", value: "two" }, + ], + dynamicBindingPathList: [{ key: "text" }], + dynamicTriggerPathList: [{ key: "onClick" }], + secretConfiguration: { token: "do not disclose" }, + children: [], + }); + + const [widget] = projectSemanticPage(dsl).widgets; + + expect(widget).toMatchObject({ + id: "custom", + name: "CustomWidget", + appsmithType: "VENDOR_WIDGET", + geometry: { topRow: 0, bottomRow: 4, leftColumn: 0, rightColumn: 24 }, + props: { title: "Safe title" }, + }); + expect(widget.catalogType).toBeUndefined(); + expect(widget.props).not.toHaveProperty("text"); + expect(widget.props).not.toHaveProperty("options"); + expect(JSON.stringify(widget)).not.toContain("dynamicBindingPathList"); + expect(JSON.stringify(widget)).not.toContain("secretConfiguration"); + }); +}); + +describe("DSL fingerprints", () => { + it("uses stable key ordering and changes when the DSL changes", () => { + const first = node({ + widgetId: "one", + widgetName: "Text1", + type: "TEXT_WIDGET", + text: "Hello", + label: "Greeting", + }); + const reordered = node({ + type: "TEXT_WIDGET", + widgetName: "Text1", + widgetId: "one", + label: "Greeting", + text: "Hello", + }); + const changed = node({ + widgetId: "one", + widgetName: "Text1", + type: "TEXT_WIDGET", + text: "Goodbye", + label: "Greeting", + }); + + expect(canonicalStableSerialize(first)).toBe( + canonicalStableSerialize(reordered), + ); + expect(fingerprintDsl(first)).toBe(fingerprintDsl(reordered)); + expect(fingerprintDsl(first)).not.toBe(fingerprintDsl(changed)); + }); +}); diff --git a/app/client/packages/mcp/src/builder/semantic.ts b/app/client/packages/mcp/src/builder/semantic.ts new file mode 100644 index 000000000000..d4868fbb55fe --- /dev/null +++ b/app/client/packages/mcp/src/builder/semantic.ts @@ -0,0 +1,208 @@ +import { createHash } from "node:crypto"; +import type { WidgetNode } from "./layout.js"; +import type { WidgetType } from "./schema.js"; + +const CATALOG_TYPE_BY_APPSMITH_TYPE: Record = { + TEXT_WIDGET: "text", + INPUT_WIDGET_V2: "input", + SELECT_WIDGET: "select", + BUTTON_WIDGET: "button", + IMAGE_WIDGET: "image", + TABLE_WIDGET_V2: "table", + CONTAINER_WIDGET: "container", + FORM_WIDGET: "form", + MODAL_WIDGET: "modal", + DATE_PICKER_WIDGET2: "datepicker", + CHART_WIDGET: "chart", + TABS_WIDGET: "tabs", + LIST_WIDGET_V2: "list", +}; + +const SAFE_PROP_KEYS = [ + "text", + "label", + "inputType", + "options", + "title", + "image", + "chartType", + "chartName", + "defaultText", + "placeholderText", + "dateFormat", + "isRequired", + "isDisabled", + "isVisible", +] as const; + +type SafeCommonProp = (typeof SAFE_PROP_KEYS)[number]; +type SafePropValue = string | number | boolean | null | SafeOption[]; + +export interface SafeOption { + label: string; + value: string | number | boolean | null; +} + +export interface SemanticGeometry { + topRow: number; + bottomRow: number; + leftColumn: number; + rightColumn: number; +} + +export interface SemanticWidget { + id: string; + name: string; + appsmithType: string; + catalogType?: WidgetType; + parentWidgetName?: string; + geometry: SemanticGeometry; + props: Partial>; +} + +export interface SemanticPage { + widgets: SemanticWidget[]; +} + +function containsBindingSyntax(value: string): boolean { + return /\{\{|\}\}|\$\{|`/.test(value); +} + +function safeScalar( + value: unknown, +): string | number | boolean | null | undefined { + if ( + value === null || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + + return typeof value === "string" && !containsBindingSyntax(value) + ? value + : undefined; +} + +function safeOptions(value: unknown): SafeOption[] | undefined { + if (!Array.isArray(value)) return undefined; + + const options: SafeOption[] = []; + + for (const option of value) { + if (!isRecord(option) || typeof option.label !== "string") return undefined; + + const safeValue = safeScalar(option.value); + + if (containsBindingSyntax(option.label) || safeValue === undefined) { + return undefined; + } + + options.push({ label: option.label, value: safeValue }); + } + + return options; +} + +function safeProps(node: WidgetNode): SemanticWidget["props"] { + const props: SemanticWidget["props"] = {}; + + for (const key of SAFE_PROP_KEYS) { + const value = node[key]; + const safeValue = + key === "options" ? safeOptions(value) : safeScalar(value); + + if (safeValue !== undefined) props[key] = safeValue; + } + + return props; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function semanticGeometry(node: WidgetNode): SemanticGeometry { + return { + topRow: node.topRow, + bottomRow: node.bottomRow, + leftColumn: node.leftColumn, + rightColumn: node.rightColumn, + }; +} + +// Produces a compact, flattened view of a page DSL. It intentionally excludes bindings, events, and every prop +// outside SAFE_PROP_KEYS, so it can be supplied to an agent without revealing arbitrary page configuration. +export function projectSemanticPage(dsl: WidgetNode): SemanticPage { + const widgets: SemanticWidget[] = []; + + function visit(node: WidgetNode, parentWidgetName?: string): void { + const catalogType = CATALOG_TYPE_BY_APPSMITH_TYPE[node.type]; + + widgets.push({ + id: node.widgetId, + name: node.widgetName, + appsmithType: node.type, + ...(catalogType !== undefined ? { catalogType } : {}), + ...(parentWidgetName !== undefined ? { parentWidgetName } : {}), + geometry: semanticGeometry(node), + props: safeProps(node), + }); + + for (const child of node.children ?? []) visit(child, node.widgetName); + } + + visit(dsl); + + return { widgets }; +} + +// JSON-compatible values only: object keys are sorted recursively, while undefined object members are omitted to +// match JSON.stringify semantics. This makes fingerprints independent of source property insertion order. +export function canonicalStableSerialize(value: unknown): string { + if (value === null) return "null"; + + switch (typeof value) { + case "boolean": + return value ? "true" : "false"; + case "number": + if (!Number.isFinite(value)) { + throw new TypeError("canonical serialization requires finite numbers"); + } + + return JSON.stringify(value); + case "string": + return JSON.stringify(value); + case "undefined": + return "null"; + case "object": + if (Array.isArray(value)) { + return `[${value.map(canonicalStableSerialize).join(",")}]`; + } + + if (!isRecord(value)) { + throw new TypeError( + "canonical serialization requires plain JSON objects", + ); + } + + return `{${Object.keys(value) + .filter((key) => value[key] !== undefined) + .sort() + .map( + (key) => + `${JSON.stringify(key)}:${canonicalStableSerialize(value[key])}`, + ) + .join(",")}}`; + default: + throw new TypeError( + "canonical serialization requires JSON-compatible values", + ); + } +} + +export function fingerprintDsl(dsl: WidgetNode): string { + return createHash("sha256") + .update(canonicalStableSerialize(dsl), "utf8") + .digest("hex"); +} diff --git a/app/client/packages/mcp/src/builder/theme.test.ts b/app/client/packages/mcp/src/builder/theme.test.ts new file mode 100644 index 000000000000..7070af4e194e --- /dev/null +++ b/app/client/packages/mcp/src/builder/theme.test.ts @@ -0,0 +1,109 @@ +import { + buildThemeUpdatePayload, + fingerprintTheme, + projectTheme, + themePatchSchema, +} from "./theme.js"; + +const storedTheme = { + id: "theme1", + name: "default-new", + displayName: "Default", + isSystemTheme: true, + config: { + colors: { primaryColor: "#4F70FE" }, + borderRadius: { appBorderRadius: { M: "4px" } }, + fontFamily: { appFont: ["Inter"] }, + }, + stylesheet: { + ButtonWidget: { background: "var(--ads-v2-color-bg-emphasis)" }, + }, + properties: { + colors: { primaryColor: "#4F70FE", backgroundColor: "#FFFFFF" }, + borderRadius: { appBorderRadius: "4px" }, + fontFamily: { appFont: "Inter" }, + boxShadow: { appBoxShadow: "none" }, + }, +}; + +describe("theme projection", () => { + it("returns only allowlisted tokens and a stable revision", () => { + const first = projectTheme(storedTheme); + const reordered = projectTheme({ + config: storedTheme.config, + stylesheet: storedTheme.stylesheet, + properties: { + fontFamily: { appFont: "Inter" }, + borderRadius: { appBorderRadius: "4px" }, + colors: { backgroundColor: "#FFFFFF", primaryColor: "#4F70FE" }, + }, + }); + + expect(first).toEqual({ + primaryColor: "#4F70FE", + borderRadius: "4px", + fontFamily: "Inter", + revision: expect.any(String), + }); + expect(first).not.toHaveProperty("stylesheet"); + expect(first.revision).toMatch(/^[a-f0-9]{64}$/); + expect(first.revision).toBe(reordered.revision); + expect(fingerprintTheme(storedTheme)).toBe(first.revision); + expect( + projectTheme({ + ...storedTheme, + properties: { + ...storedTheme.properties, + colors: { ...storedTheme.properties.colors, primaryColor: "#112233" }, + }, + }).revision, + ).not.toBe(first.revision); + }); +}); + +describe("theme update payload builder", () => { + it("updates only mapped Appsmith properties and preserves the endpoint payload", () => { + const payload = buildThemeUpdatePayload(storedTheme, { + primaryColor: "#112233", + borderRadius: "0.5rem", + fontFamily: "Source Sans Pro", + }); + + expect(payload).toMatchObject({ + id: "theme1", + config: storedTheme.config, + stylesheet: storedTheme.stylesheet, + properties: { + colors: { primaryColor: "#112233", backgroundColor: "#FFFFFF" }, + borderRadius: { appBorderRadius: "0.5rem" }, + fontFamily: { appFont: "Source Sans Pro" }, + boxShadow: { appBoxShadow: "none" }, + }, + }); + expect(payload).toHaveProperty("new", undefined); + expect(storedTheme.properties.colors.primaryColor).toBe("#4F70FE"); + }); + + it.each([ + ["raw expression", { primaryColor: "{{ evil }}" }], + ["template interpolation", { fontFamily: "${evil}" }], + ["arbitrary CSS", { borderRadius: "4px; color: red" }], + ["non-hex color", { primaryColor: "red" }], + ["URL", { fontFamily: "url(https://attacker.example/font.woff2)" }], + ["unknown property", { stylesheet: { color: "red" } }], + ])("rejects %s", (_label, patch) => { + expect(themePatchSchema.safeParse(patch).success).toBe(false); + }); + + it("rejects an unsafe stored token instead of projecting it", () => { + expect(() => + projectTheme({ + ...storedTheme, + properties: { + ...storedTheme.properties, + colors: { primaryColor: "url(https://attacker.example)" }, + }, + }), + ).toThrow(); + }); +}); diff --git a/app/client/packages/mcp/src/builder/theme.ts b/app/client/packages/mcp/src/builder/theme.ts new file mode 100644 index 000000000000..7c31dfc91c92 --- /dev/null +++ b/app/client/packages/mcp/src/builder/theme.ts @@ -0,0 +1,141 @@ +import { createHash } from "node:crypto"; +import { z } from "zod"; +import { canonicalStableSerialize } from "./semantic.js"; + +const RAW_EXPRESSION = /\{\{|\}\}|\$\{|`/; +const HEX_COLOR = /^#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/; +const BORDER_RADIUS = /^\d+(?:\.\d+)?(?:px|rem|em)$/; +const FONT_FAMILY = /^[A-Za-z0-9 ,'-]+$/; + +const safeLiteral = (pattern: RegExp, message: string) => + z + .string() + .min(1) + .max(60) + .regex(pattern, message) + .refine( + (value) => !RAW_EXPRESSION.test(value), + "must not contain bindings", + ); + +const primaryColor = safeLiteral(HEX_COLOR, "must be a hex color"); +const borderRadius = safeLiteral( + BORDER_RADIUS, + "must be a numeric px, rem, or em value", +); +const fontFamily = safeLiteral( + FONT_FAMILY, + "must be a literal font family name", +); + +// The MCP can change only these literal application-level tokens. It never accepts stylesheets, CSS declarations, +// URLs, or binding syntax from an agent. +export const themePatchSchema = z + .object({ + primaryColor: primaryColor.optional(), + borderRadius: borderRadius.optional(), + fontFamily: fontFamily.optional(), + }) + .strict() + .refine((tokens) => Object.keys(tokens).length > 0, "must update a token"); + +export type ThemePatch = z.infer; + +const themePropertiesSchema = z + .object({ + colors: z.object({ primaryColor: primaryColor.optional() }).passthrough(), + borderRadius: z + .object({ appBorderRadius: borderRadius.optional() }) + .passthrough(), + fontFamily: z.object({ appFont: fontFamily.optional() }).passthrough(), + }) + .passthrough(); + +// This intentionally models only the safe read surface. Config and stylesheet data remain opaque implementation +// details of Appsmith's existing PUT payload and are never returned to an MCP caller. +const storedThemeSchema = z + .object({ + config: z.record(z.unknown()), + stylesheet: z.record(z.unknown()), + properties: themePropertiesSchema, + }) + .passthrough(); + +export interface ThemeProjection { + primaryColor?: string; + borderRadius?: string; + fontFamily?: string; + revision: string; +} + +function fingerprintTokens(tokens: Omit): string { + return createHash("sha256") + .update(canonicalStableSerialize(tokens), "utf8") + .digest("hex"); +} + +function projectTokens(theme: unknown): Omit { + const parsed = storedThemeSchema.parse(theme); + + return { + ...(parsed.properties.colors.primaryColor !== undefined + ? { primaryColor: parsed.properties.colors.primaryColor } + : {}), + ...(parsed.properties.borderRadius.appBorderRadius !== undefined + ? { borderRadius: parsed.properties.borderRadius.appBorderRadius } + : {}), + ...(parsed.properties.fontFamily.appFont !== undefined + ? { fontFamily: parsed.properties.fontFamily.appFont } + : {}), + }; +} + +export function fingerprintTheme(theme: unknown): string { + return fingerprintTokens(projectTokens(theme)); +} + +// Projects a server-fetched theme into the three allowlisted tokens. Because the revision fingerprints this +// projection, opaque stylesheet/config fields can neither leak to callers nor affect optimistic concurrency. +export function projectTheme(theme: unknown): ThemeProjection { + const tokens = projectTokens(theme); + + return { ...tokens, revision: fingerprintTokens(tokens) }; +} + +// Builds the body expected by PUT /v1/themes/applications/{applicationId}. The existing server-fetched theme must be +// retained because Appsmith replaces config, stylesheet, and properties together. Only validated token values are +// changed; agent input cannot replace any other part of that payload. +export function buildThemeUpdatePayload( + currentTheme: unknown, + patchInput: unknown, +): Record { + const patch = themePatchSchema.parse(patchInput); + storedThemeSchema.parse(currentTheme); + const clonedTheme = JSON.parse( + canonicalStableSerialize(currentTheme), + ) as Record; + const properties = clonedTheme.properties as Record< + string, + Record + >; + + properties.colors = { + ...properties.colors, + ...(patch.primaryColor !== undefined + ? { primaryColor: patch.primaryColor } + : {}), + }; + properties.borderRadius = { + ...properties.borderRadius, + ...(patch.borderRadius !== undefined + ? { appBorderRadius: patch.borderRadius } + : {}), + }; + properties.fontFamily = { + ...properties.fontFamily, + ...(patch.fontFamily !== undefined ? { appFont: patch.fontFamily } : {}), + }; + + // Preserve the frontend's existing endpoint contract while preventing a caller from controlling its value. + return { ...clonedTheme, properties, new: undefined }; +} diff --git a/app/client/packages/mcp/src/deploy-gating.test.ts b/app/client/packages/mcp/src/deploy-gating.test.ts new file mode 100644 index 000000000000..a27f9a10d797 --- /dev/null +++ b/app/client/packages/mcp/src/deploy-gating.test.ts @@ -0,0 +1,47 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +// K.5 — deploy-time gating guard. The MCP route and process must remain opt-in behind APPSMITH_MCP_ENABLED. Full +// routing/health behaviour is a Docker integration test; this guards against the gate being silently removed. + +const REPO_ROOT = resolve(__dirname, "../../../../.."); + +function readDeployFile(relativePath: string): string { + return readFileSync(resolve(REPO_ROOT, relativePath), "utf8"); +} + +describe("Caddy MCP routing is gated behind APPSMITH_MCP_ENABLED", () => { + const caddy = readDeployFile( + "deploy/docker/fs/opt/appsmith/caddy-reconfigure.mjs", + ); + + it("derives the MCP flag from APPSMITH_MCP_ENABLED === '1'", () => { + expect(caddy).toMatch( + /isMcpEnabled\s*=\s*process\.env\.APPSMITH_MCP_ENABLED\s*===\s*["']1["']/, + ); + }); + + it("routes /mcp only when MCP is enabled and proxies the MCP port", () => { + expect(caddy).toMatch(/isMcpEnabled\s*\?\s*`?handle \/mcp/); + expect(caddy).toContain("APPSMITH_MCP_PORT"); + }); +}); + +describe("supervisord and healthcheck keep MCP opt-in", () => { + it("entrypoint removes the MCP program when APPSMITH_MCP_ENABLED is not 1", () => { + const entrypoint = readDeployFile( + "deploy/docker/fs/opt/appsmith/entrypoint.sh", + ); + + expect(entrypoint).toContain("APPSMITH_MCP_ENABLED"); + expect(entrypoint).toMatch(/mcp\.conf/); + }); + + it("healthcheck only probes MCP when its program is present", () => { + const healthcheck = readDeployFile( + "deploy/docker/fs/opt/appsmith/healthcheck.sh", + ); + + expect(healthcheck).toMatch(/supervisorctl status .*\|\s*grep .*mcp/); + }); +}); diff --git a/app/client/packages/mcp/src/governance/coordinator.test.ts b/app/client/packages/mcp/src/governance/coordinator.test.ts new file mode 100644 index 000000000000..e0e77a5760b9 --- /dev/null +++ b/app/client/packages/mcp/src/governance/coordinator.test.ts @@ -0,0 +1,238 @@ +import { + DestructiveConfirmationError, + GovernanceLockError, + GovernanceRevisionConflictError, + McpGovernanceCoordinator, +} from "./coordinator.js"; +import type { + McpChangeRecord, + McpGovernanceStore, + PreparedConfirmation, +} from "./store.js"; + +class MemoryGovernanceStore implements McpGovernanceStore { + readonly calls: string[] = []; + readonly changes: McpChangeRecord[] = []; + readonly confirmations = new Map(); + lockId: string | undefined = "lock-1"; + + async acquireLock( + entityKey: string, + ttlMs: number, + ): Promise { + this.calls.push(`acquire:${entityKey}:${ttlMs}`); + + return this.lockId; + } + + async releaseLock(entityKey: string, lockId: string): Promise { + this.calls.push(`release:${entityKey}:${lockId}`); + } + + async createConfirmation( + confirmation: PreparedConfirmation, + ttlMs: number, + ): Promise { + this.calls.push(`create-confirmation:${ttlMs}`); + this.confirmations.set(confirmation.id, confirmation); + } + + async consumeConfirmation( + id: string, + ): Promise { + this.calls.push(`consume-confirmation:${id}`); + const confirmation = this.confirmations.get(id); + + this.confirmations.delete(id); + + return confirmation; + } + + async saveChange(change: McpChangeRecord): Promise { + this.calls.push(`save-change:${change.id}`); + this.changes.push(change); + } + + async getChange(): Promise { + return undefined; + } + + async listChanges(): Promise { + return this.changes; + } +} + +const binding = { + actorId: "user-1", + entityKey: "application:app-1", + operation: "delete", + revision: "revision-1", + digest: "digest-1", +}; + +function coordinator(store: MemoryGovernanceStore): McpGovernanceCoordinator { + return new McpGovernanceCoordinator(store, { + lockTtlMs: 1_000, + confirmationTtlMs: 2_000, + now: () => new Date("2026-07-12T00:00:00.000Z"), + createId: jest + .fn() + .mockReturnValueOnce("change-1") + .mockReturnValueOnce("confirm-1"), + }); +} + +describe("McpGovernanceCoordinator.execute", () => { + it("locks, mutates, records the completed change, then releases the lock", async () => { + const store = new MemoryGovernanceStore(); + const result = await coordinator(store).execute({ + actorId: "user-1", + entityKey: "application:app-1", + operation: "update", + expectedRevision: "revision-1", + currentRevision: "revision-1", + mutate: async () => { + expect(store.calls).toEqual(["acquire:application:app-1:1000"]); + + return { + value: { updated: true }, + revisionAfter: "revision-2", + rollback: { previousName: "Old" }, + summary: { name: "New" }, + }; + }, + }); + + expect(result).toEqual({ value: { updated: true }, changeId: "change-1" }); + expect(store.changes).toEqual([ + { + id: "change-1", + actorId: "user-1", + entityKey: "application:app-1", + operation: "update", + revisionBefore: "revision-1", + revisionAfter: "revision-2", + createdAt: new Date("2026-07-12T00:00:00.000Z"), + rollback: { previousName: "Old" }, + summary: { name: "New" }, + }, + ]); + expect(store.calls).toEqual([ + "acquire:application:app-1:1000", + "save-change:change-1", + "release:application:app-1:lock-1", + ]); + }); + + it("rejects a missing lock without invoking the mutation", async () => { + const store = new MemoryGovernanceStore(); + + store.lockId = undefined; + const mutate = jest.fn(); + + await expect( + coordinator(store).execute({ + actorId: "user-1", + entityKey: "application:app-1", + operation: "update", + expectedRevision: "revision-1", + currentRevision: "revision-1", + mutate, + }), + ).rejects.toBeInstanceOf(GovernanceLockError); + + expect(mutate).not.toHaveBeenCalled(); + expect(store.calls).toEqual(["acquire:application:app-1:1000"]); + }); + + it("rejects a stale revision and releases the acquired lock", async () => { + const store = new MemoryGovernanceStore(); + const mutate = jest.fn(); + + await expect( + coordinator(store).execute({ + actorId: "user-1", + entityKey: "application:app-1", + operation: "update", + expectedRevision: "revision-1", + currentRevision: "revision-2", + mutate, + }), + ).rejects.toBeInstanceOf(GovernanceRevisionConflictError); + + expect(mutate).not.toHaveBeenCalled(); + expect(store.changes).toEqual([]); + expect(store.calls).toEqual([ + "acquire:application:app-1:1000", + "release:application:app-1:lock-1", + ]); + }); + + it("releases the lock when the mutation fails", async () => { + const store = new MemoryGovernanceStore(); + + await expect( + coordinator(store).execute({ + actorId: "user-1", + entityKey: "application:app-1", + operation: "update", + expectedRevision: "revision-1", + currentRevision: "revision-1", + mutate: async () => { + throw new Error("mutation failed"); + }, + }), + ).rejects.toThrow("mutation failed"); + + expect(store.changes).toEqual([]); + expect(store.calls).toEqual([ + "acquire:application:app-1:1000", + "release:application:app-1:lock-1", + ]); + }); +}); + +describe("destructive confirmations", () => { + it("prepares and consumes a confirmation bound to its destructive operation", async () => { + const store = new MemoryGovernanceStore(); + const service = coordinator(store); + + const confirmation = await service.prepareDestructiveConfirmation(binding); + const consumed = await service.consumeDestructiveConfirmation({ + confirmationId: confirmation.id, + ...binding, + }); + + expect(confirmation).toEqual({ + id: "change-1", + ...binding, + expiresAt: new Date("2026-07-12T00:00:02.000Z"), + }); + expect(consumed).toEqual(confirmation); + expect(store.calls).toEqual([ + "create-confirmation:2000", + "consume-confirmation:change-1", + ]); + }); + + it("rejects mismatches and prevents replay after consumption", async () => { + const store = new MemoryGovernanceStore(); + const service = coordinator(store); + const confirmation = await service.prepareDestructiveConfirmation(binding); + + await expect( + service.consumeDestructiveConfirmation({ + confirmationId: confirmation.id, + ...binding, + digest: "different-digest", + }), + ).rejects.toBeInstanceOf(DestructiveConfirmationError); + + await expect( + service.consumeDestructiveConfirmation({ + confirmationId: confirmation.id, + ...binding, + }), + ).rejects.toBeInstanceOf(DestructiveConfirmationError); + }); +}); diff --git a/app/client/packages/mcp/src/governance/coordinator.ts b/app/client/packages/mcp/src/governance/coordinator.ts new file mode 100644 index 000000000000..4f608857288f --- /dev/null +++ b/app/client/packages/mcp/src/governance/coordinator.ts @@ -0,0 +1,183 @@ +import { randomUUID } from "node:crypto"; + +import type { + McpChangeRecord, + McpGovernanceStore, + PreparedConfirmation, +} from "./store.js"; + +export type { McpChangeRecord } from "./store.js"; + +export class GovernanceLockError extends Error { + constructor(entityKey: string) { + super(`entity is already being changed: ${entityKey}`); + this.name = "GovernanceLockError"; + } +} + +export class GovernanceRevisionConflictError extends Error { + constructor(expectedRevision: string, currentRevision: string) { + super( + `revision conflict: expected ${expectedRevision}, current ${currentRevision}`, + ); + this.name = "GovernanceRevisionConflictError"; + } +} + +export class DestructiveConfirmationError extends Error { + constructor(message: string) { + super(message); + this.name = "DestructiveConfirmationError"; + } +} + +export interface MutationResult { + value: TResult; + revisionAfter: string; + rollback?: Record; + summary?: Record; +} + +export interface CoordinatedMutation { + actorId: string; + entityKey: string; + operation: string; + expectedRevision: string; + currentRevision: string; + mutate(): Promise>; +} + +export interface DestructiveConfirmationBinding { + actorId: string; + entityKey: string; + operation: string; + revision: string; + digest: string; +} + +export interface ConsumeDestructiveConfirmation + extends DestructiveConfirmationBinding { + confirmationId: string; +} + +export interface McpGovernanceCoordinatorOptions { + lockTtlMs?: number; + confirmationTtlMs?: number; + now?: () => Date; + createId?: () => string; +} + +export class McpGovernanceCoordinator { + private readonly lockTtlMs: number; + private readonly confirmationTtlMs: number; + private readonly now: () => Date; + private readonly createId: () => string; + + constructor( + private readonly store: McpGovernanceStore, + options: McpGovernanceCoordinatorOptions = {}, + ) { + this.lockTtlMs = options.lockTtlMs ?? 30_000; + this.confirmationTtlMs = options.confirmationTtlMs ?? 5 * 60_000; + this.now = options.now ?? (() => new Date()); + this.createId = options.createId ?? randomUUID; + } + + async execute( + request: CoordinatedMutation, + ): Promise<{ value: TResult; changeId: string }> { + const lockId = await this.store.acquireLock( + request.entityKey, + this.lockTtlMs, + ); + + if (!lockId) { + throw new GovernanceLockError(request.entityKey); + } + + try { + if (request.expectedRevision !== request.currentRevision) { + throw new GovernanceRevisionConflictError( + request.expectedRevision, + request.currentRevision, + ); + } + + const result = await request.mutate(); + const changeId = this.createId(); + + await this.store.saveChange({ + id: changeId, + actorId: request.actorId, + entityKey: request.entityKey, + operation: request.operation, + revisionBefore: request.currentRevision, + revisionAfter: result.revisionAfter, + createdAt: this.now(), + rollback: result.rollback ?? {}, + summary: result.summary ?? {}, + }); + + // Return the audit id so every governed mutation can echo its change/audit id to the caller. + return { value: result.value, changeId }; + } finally { + await this.store.releaseLock(request.entityKey, lockId); + } + } + + async getChange( + id: string, + actorId: string, + ): Promise { + return this.store.getChange(id, actorId); + } + + async listChanges( + actorId: string, + limit: number, + ): Promise { + return this.store.listChanges(actorId, limit); + } + + async prepareDestructiveConfirmation( + binding: DestructiveConfirmationBinding, + ): Promise { + const confirmation: PreparedConfirmation = { + id: this.createId(), + ...binding, + expiresAt: new Date(this.now().getTime() + this.confirmationTtlMs), + }; + + await this.store.createConfirmation(confirmation, this.confirmationTtlMs); + + return confirmation; + } + + async consumeDestructiveConfirmation( + request: ConsumeDestructiveConfirmation, + ): Promise { + const confirmation = await this.store.consumeConfirmation( + request.confirmationId, + ); + + if (!confirmation) { + throw new DestructiveConfirmationError( + "destructive confirmation is missing, expired, or already used", + ); + } + + if ( + confirmation.actorId !== request.actorId || + confirmation.entityKey !== request.entityKey || + confirmation.operation !== request.operation || + confirmation.revision !== request.revision || + confirmation.digest !== request.digest + ) { + throw new DestructiveConfirmationError( + "destructive confirmation does not match this operation", + ); + } + + return confirmation; + } +} diff --git a/app/client/packages/mcp/src/governance/store.ts b/app/client/packages/mcp/src/governance/store.ts new file mode 100644 index 000000000000..6a2d99202200 --- /dev/null +++ b/app/client/packages/mcp/src/governance/store.ts @@ -0,0 +1,161 @@ +import { randomUUID } from "node:crypto"; +import { MongoClient, type Collection } from "mongodb"; +import { createClient, type RedisClientType } from "redis"; + +export interface McpChangeRecord { + id: string; + actorId: string; + entityKey: string; + operation: string; + revisionBefore: string; + revisionAfter: string; + createdAt: Date; + expiresAt?: Date; + rollback: Record; + summary: Record; +} + +export interface PreparedConfirmation { + id: string; + actorId: string; + entityKey: string; + operation: string; + revision: string; + digest: string; + expiresAt: Date; +} + +export interface McpGovernanceStore { + acquireLock(entityKey: string, ttlMs: number): Promise; + releaseLock(entityKey: string, lockId: string): Promise; + createConfirmation( + confirmation: PreparedConfirmation, + ttlMs: number, + ): Promise; + consumeConfirmation(id: string): Promise; + saveChange(change: McpChangeRecord): Promise; + getChange(id: string, actorId: string): Promise; + listChanges(actorId: string, limit: number): Promise; +} + +const LOCK_PREFIX = "appsmith:mcp:lock:"; +const CONFIRM_PREFIX = "appsmith:mcp:confirm:"; +const RELEASE_LOCK_SCRIPT = + "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; +const CONSUME_CONFIRMATION_SCRIPT = + "local value = redis.call('get', KEYS[1]); if value then redis.call('del', KEYS[1]); end; return value"; + +// MCP owns these collections and keys. It never writes Appsmith product documents directly; it only records +// governance metadata around authorized REST mutations made by the MCP service. +export class MongoRedisGovernanceStore implements McpGovernanceStore { + private readonly changes: Collection; + + constructor( + private readonly mongo: MongoClient, + private readonly redis: RedisClientType, + databaseName = "appsmith", + ) { + this.changes = mongo + .db(databaseName) + .collection("mcp_changes"); + } + + async connect(): Promise { + await Promise.all([this.mongo.connect(), this.redis.connect()]); + await this.changes.createIndex({ actorId: 1, createdAt: -1 }); + await this.changes.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + } + + async close(): Promise { + await Promise.allSettled([this.mongo.close(), this.redis.close()]); + } + + async acquireLock( + entityKey: string, + ttlMs: number, + ): Promise { + const lockId = randomUUID(); + const acquired = await this.redis.set( + `${LOCK_PREFIX}${entityKey}`, + lockId, + { + NX: true, + PX: ttlMs, + }, + ); + + return acquired === "OK" ? lockId : undefined; + } + + async releaseLock(entityKey: string, lockId: string): Promise { + await this.redis.eval(RELEASE_LOCK_SCRIPT, { + keys: [`${LOCK_PREFIX}${entityKey}`], + arguments: [lockId], + }); + } + + async createConfirmation( + confirmation: PreparedConfirmation, + ttlMs: number, + ): Promise { + const saved = await this.redis.set( + `${CONFIRM_PREFIX}${confirmation.id}`, + JSON.stringify(confirmation), + { NX: true, PX: ttlMs }, + ); + + if (saved !== "OK") { + throw new Error("confirmation identifier collision"); + } + } + + async consumeConfirmation( + id: string, + ): Promise { + const value = await this.redis.eval(CONSUME_CONFIRMATION_SCRIPT, { + keys: [`${CONFIRM_PREFIX}${id}`], + arguments: [], + }); + + if (typeof value !== "string") return undefined; + + return JSON.parse(value) as PreparedConfirmation; + } + + async saveChange(change: McpChangeRecord): Promise { + await this.changes.insertOne(change); + } + + async getChange( + id: string, + actorId: string, + ): Promise { + return (await this.changes.findOne({ id, actorId })) ?? undefined; + } + + async listChanges( + actorId: string, + limit: number, + ): Promise { + return this.changes + .find({ actorId }) + .sort({ createdAt: -1 }) + .limit(limit) + .toArray(); + } +} + +export function createGovernanceStoreFromEnv(): + | MongoRedisGovernanceStore + | undefined { + const mongoUrl = + process.env.APPSMITH_MONGODB_URI ?? process.env.APPSMITH_DB_URL; + const redisUrl = process.env.APPSMITH_REDIS_URL; + + if (!mongoUrl || !redisUrl) return undefined; + + return new MongoRedisGovernanceStore( + new MongoClient(mongoUrl), + createClient({ url: redisUrl }), + ); +} diff --git a/app/client/packages/mcp/src/sdk-transport.test.ts b/app/client/packages/mcp/src/sdk-transport.test.ts new file mode 100644 index 000000000000..eb5b2638e1c8 --- /dev/null +++ b/app/client/packages/mcp/src/sdk-transport.test.ts @@ -0,0 +1,94 @@ +import type { AddressInfo } from "node:net"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { createMcpHttpServer, type AppsmithApi } from "./app.js"; + +// K.4 — acceptance through a REAL @modelcontextprotocol/sdk client over Streamable HTTP against an in-process server +// (not supertest). Proves the actual protocol handshake, listTools, and callTool work end to end. + +// A stub AppsmithApi: every method is an inert jest.fn except validateToken, which authenticates the bearer. +function stubApi(): AppsmithApi { + const methodNames: (keyof AppsmithApi)[] = [ + "getApplicationContext", + "listApplications", + "listWorkspaces", + "importApplicationArtifact", + "importPartialApplicationArtifact", + "updateLayout", + "listDatasources", + "getDatasourceStructure", + "getApplicationPages", + "listActions", + "createAction", + "getAction", + "updateAction", + "deleteAction", + "executeAction", + "getCurrentTheme", + "updateTheme", + "createPage", + "updatePage", + "deletePage", + "publishApplication", + "listPlugins", + "listActionCollections", + "createActionCollection", + "updateActionCollection", + "deleteActionCollection", + ]; + const api = {} as Record; + + for (const name of methodNames) api[name] = jest.fn(async () => ({})); + + api.validateToken = jest.fn(async () => ({ + username: "user@appsmith.com", + isAnonymous: false, + })); + + return api as unknown as AppsmithApi; +} + +describe("real MCP SDK client over Streamable HTTP", () => { + it("connects, lists tools, and calls a tool via the SDK client", async () => { + const httpServer = createMcpHttpServer("https://appsmith.example", () => + stubApi(), + ); + + await new Promise((resolve) => + httpServer.listen(0, "127.0.0.1", resolve), + ); + const port = (httpServer.address() as AddressInfo).port; + + const client = new Client({ name: "acceptance", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${port}/mcp`), + { + requestInit: { + headers: { Authorization: "Bearer mcp_acceptance-token" }, + }, + }, + ); + + try { + await client.connect(transport); + + const tools = await client.listTools(); + const names = tools.tools.map((tool) => tool.name); + + expect(names).toContain("build_application"); + expect(names).toContain("get_capabilities"); + + const capabilities = await client.callTool({ + name: "get_capabilities", + arguments: {}, + }); + const text = (capabilities.content as { type: string; text: string }[])[0] + .text; + + expect(text).toContain("build_application"); + } finally { + await client.close(); + await new Promise((resolve) => httpServer.close(() => resolve())); + } + }); +}); diff --git a/app/client/packages/mcp/src/server.ts b/app/client/packages/mcp/src/server.ts index 2826aac0ca7f..57bd889177d4 100644 --- a/app/client/packages/mcp/src/server.ts +++ b/app/client/packages/mcp/src/server.ts @@ -1,11 +1,88 @@ +import type { Server } from "node:http"; import { createMcpHttpServer } from "./app.js"; +import { McpGovernanceCoordinator } from "./governance/coordinator.js"; +import { + createGovernanceStoreFromEnv, + type MongoRedisGovernanceStore, +} from "./governance/store.js"; const port = Number(process.env.APPSMITH_MCP_PORT ?? 8092); const apiBaseUrl = process.env.APPSMITH_API_BASE_URL ?? "http://127.0.0.1:8080"; -// M4 data layer is a second, independent opt-in on top of APPSMITH_MCP_ENABLED. Default off. +// The data layer and restricted JS objects are independent opt-ins on top of APPSMITH_MCP_ENABLED. Default off. const dataEnabled = process.env.APPSMITH_MCP_DATA_ENABLED === "1"; +const jsEnabled = process.env.APPSMITH_MCP_JS_ENABLED === "1"; -const httpServer = createMcpHttpServer(apiBaseUrl, undefined, { dataEnabled }); +let httpServer: Server | undefined; +let governanceStore: MongoRedisGovernanceStore | undefined; +let shuttingDown = false; + +function shutdown(signal: string) { + if (shuttingDown) return; + + shuttingDown = true; + process.stderr.write(`Appsmith MCP received ${signal}; draining requests\n`); + + // Stop accepting connections immediately. Existing Streamable HTTP requests + // are given a bounded window to finish before supervisord restarts us. + const forceExit = setTimeout(() => process.exit(1), 10_000); + + forceExit.unref(); + + const finish = (code: number) => { + void Promise.resolve(governanceStore?.close()) + .catch(() => {}) + .finally(() => { + clearTimeout(forceExit); + process.exit(code); + }); + }; + + if (!httpServer) { + finish(0); + + return; + } + + httpServer.close((error) => { + if (error) { + process.stderr.write(`Appsmith MCP shutdown failed: ${error.message}\n`); + finish(1); + + return; + } + + finish(0); + }); +} + +async function main(): Promise { + // Governance is available only when Mongo AND Redis are configured. If configured but unreachable we fail loudly + // (supervisord restarts) rather than silently degrade — governed/destructive safety must not vanish unnoticed. + const store = createGovernanceStoreFromEnv(); + let governance: McpGovernanceCoordinator | undefined; + + if (store) { + await store.connect(); + governanceStore = store; + governance = new McpGovernanceCoordinator(store); + process.stderr.write("Appsmith MCP governance store connected\n"); + } else { + process.stderr.write( + "Appsmith MCP governance disabled (APPSMITH_MONGODB_URI/APPSMITH_DB_URL + APPSMITH_REDIS_URL not set); " + + "governed and destructive tools will not be registered\n", + ); + } + + httpServer = createMcpHttpServer(apiBaseUrl, undefined, { + dataEnabled, + jsEnabled, + governance, + }); + + httpServer.listen(port, "127.0.0.1", () => { + process.stderr.write(`Appsmith MCP listening on 127.0.0.1:${port}\n`); + }); +} // A truly uncaught exception leaves the process in an undefined state — exit so supervisord restarts it cleanly. process.once("uncaughtException", () => { @@ -19,6 +96,12 @@ process.on("unhandledRejection", () => { process.stderr.write("Appsmith MCP unhandled rejection\n"); }); -httpServer.listen(port, "127.0.0.1", () => { - process.stderr.write(`Appsmith MCP listening on 127.0.0.1:${port}\n`); +process.once("SIGINT", () => shutdown("SIGINT")); +process.once("SIGTERM", () => shutdown("SIGTERM")); + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + + process.stderr.write(`Appsmith MCP failed to start: ${message}\n`); + process.exit(1); }); diff --git a/app/client/src/api/McpTokenApi.ts b/app/client/src/api/McpTokenApi.ts index 3708c9f1b8f2..ea1e32711cf2 100644 --- a/app/client/src/api/McpTokenApi.ts +++ b/app/client/src/api/McpTokenApi.ts @@ -28,6 +28,12 @@ class McpTokenApi extends Api { ) as ApiResponse[]; } + static async rotate(tokenId: string): Promise> { + const response = await Api.post(`${McpTokenApi.url}/${tokenId}/rotate`); + + return response as unknown as ApiResponse; + } + static async revoke(tokenId: string): Promise> { const response = await Api.delete(`${McpTokenApi.url}/${tokenId}`); diff --git a/app/client/src/ce/constants/messages.ts b/app/client/src/ce/constants/messages.ts index f4eaf03e8d6c..7dd3a9beb0ee 100644 --- a/app/client/src/ce/constants/messages.ts +++ b/app/client/src/ce/constants/messages.ts @@ -253,6 +253,11 @@ export const MCP_TOKENS_LOADING = () => "Loading MCP tokens…"; export const MCP_TOKENS_EMPTY = () => "No MCP tokens have been created."; export const MCP_TOKEN_CREATED_AT = () => "Created"; export const MCP_TOKEN_EXPIRES_AT = () => "Expires"; +export const ROTATE_MCP_TOKEN = () => "Rotate"; +export const ROTATE_MCP_TOKEN_CONFIRM = () => "Rotate token"; +export const ROTATE_MCP_TOKEN_CONFIRMATION = () => + "Rotate this MCP token? The current secret will stop working immediately."; +export const MCP_TOKEN_ROTATED = () => "MCP token rotated"; export const REVOKE_MCP_TOKEN = () => "Revoke"; export const REVOKE_MCP_TOKEN_CONFIRM = () => "Revoke token"; export const REVOKE_MCP_TOKEN_CONFIRMATION = () => @@ -260,6 +265,7 @@ export const REVOKE_MCP_TOKEN_CONFIRMATION = () => export const MCP_TOKEN_REVOKED = () => "MCP token revoked"; export const MCP_TOKENS_LOAD_FAILED = () => "Unable to load MCP tokens."; export const MCP_TOKEN_CREATE_FAILED = () => "Unable to create MCP token."; +export const MCP_TOKEN_ROTATE_FAILED = () => "Unable to rotate MCP token."; export const MCP_TOKEN_REVOKE_FAILED = () => "Unable to revoke MCP token."; export const CREATE_PASSWORD_RESET_SUCCESS = () => `Your password has been set`; diff --git a/app/client/src/pages/UserProfile/McpTokens.test.tsx b/app/client/src/pages/UserProfile/McpTokens.test.tsx index 94598fbeb610..9f882266e0e6 100644 --- a/app/client/src/pages/UserProfile/McpTokens.test.tsx +++ b/app/client/src/pages/UserProfile/McpTokens.test.tsx @@ -11,6 +11,7 @@ jest.mock("api/McpTokenApi", () => ({ default: { create: jest.fn(), list: jest.fn(), + rotate: jest.fn(), revoke: jest.fn(), }, })); @@ -104,6 +105,31 @@ describe("McpTokens", () => { expect(screen.queryByText("token-1")).not.toBeInTheDocument(); }); + it("rotates a token only after confirmation and shows its replacement once", async () => { + (McpTokenApi.rotate as jest.Mock).mockResolvedValue( + successResponse({ + id: "token-1", + token: "rotated-secret", + createdAt: "2026-07-10T12:00:00.000Z", + expiresAt: "2026-10-08T12:00:00.000Z", + }), + ); + renderComponent(); + + await screen.findByText("token-1"); + fireEvent.click(screen.getByRole("button", { name: "Rotate token-1" })); + expect(McpTokenApi.rotate).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Rotate token" })); + + await waitFor(() => + expect(McpTokenApi.rotate).toHaveBeenCalledWith("token-1"), + ); + expect(await screen.findByLabelText("MCP token")).toHaveValue( + "rotated-secret", + ); + }); + it("renders an accessible error when the token list fails", async () => { (McpTokenApi.list as jest.Mock).mockRejectedValue( new Error("Request failed"), diff --git a/app/client/src/pages/UserProfile/McpTokens.tsx b/app/client/src/pages/UserProfile/McpTokens.tsx index 8a0d19bb0796..6cf0e455eea4 100644 --- a/app/client/src/pages/UserProfile/McpTokens.tsx +++ b/app/client/src/pages/UserProfile/McpTokens.tsx @@ -23,6 +23,8 @@ import { MCP_TOKEN_VALUE_LABEL, MCP_TOKEN_REVOKE_FAILED, MCP_TOKEN_REVOKED, + MCP_TOKEN_ROTATE_FAILED, + MCP_TOKEN_ROTATED, MCP_TOKENS, MCP_TOKENS_DESCRIPTION, MCP_TOKENS_EMPTY, @@ -32,6 +34,9 @@ import { REVOKE_MCP_TOKEN, REVOKE_MCP_TOKEN_CONFIRM, REVOKE_MCP_TOKEN_CONFIRMATION, + ROTATE_MCP_TOKEN, + ROTATE_MCP_TOKEN_CONFIRM, + ROTATE_MCP_TOKEN_CONFIRMATION, createMessage, } from "ee/constants/messages"; import McpTokenApi, { @@ -75,6 +80,8 @@ function McpTokens() { const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isCreating, setIsCreating] = useState(false); + const [rotateTokenId, setRotateTokenId] = useState(null); + const [isRotating, setIsRotating] = useState(false); const [revokeTokenId, setRevokeTokenId] = useState(null); const [isRevoking, setIsRevoking] = useState(false); @@ -157,6 +164,39 @@ function McpTokens() { } }; + const rotateToken = async () => { + if (!rotateTokenId) { + return; + } + + setIsRotating(true); + setError(null); + + try { + const token = ensureSuccess(await McpTokenApi.rotate(rotateTokenId)); + + setCreatedToken(token); + setTokens((tokens) => + tokens.map((existing) => + existing.id === token.id + ? { + id: token.id, + createdAt: token.createdAt, + expiresAt: token.expiresAt, + } + : existing, + ), + ); + setRotateTokenId(null); + toast.show(createMessage(MCP_TOKEN_ROTATED), { kind: "success" }); + } catch (error) { + setRotateTokenId(null); + setError(getErrorMessage(error, createMessage(MCP_TOKEN_ROTATE_FAILED))); + } finally { + setIsRotating(false); + } + }; + return ( <> @@ -190,9 +230,18 @@ function McpTokens() { {createMessage(MCP_TOKEN_EXPIRES_AT)}:{" "} {formatCreatedAt(token.expiresAt)} + + + + + + + { if (!open) { diff --git a/app/client/yarn.lock b/app/client/yarn.lock index 2f4aab8e8f55..bbc103ac0908 100644 --- a/app/client/yarn.lock +++ b/app/client/yarn.lock @@ -4667,12 +4667,12 @@ __metadata: languageName: node linkType: hard -"@mongodb-js/saslprep@npm:^1.1.0, @mongodb-js/saslprep@npm:^1.1.9": - version: 1.2.0 - resolution: "@mongodb-js/saslprep@npm:1.2.0" +"@mongodb-js/saslprep@npm:^1.1.0, @mongodb-js/saslprep@npm:^1.1.9, @mongodb-js/saslprep@npm:^1.4.11": + version: 1.4.12 + resolution: "@mongodb-js/saslprep@npm:1.4.12" dependencies: sparse-bitfield: ^3.0.3 - checksum: a2df7bf5fde7c0a46fb0022208720ec8235dfaad0ed078200af16d123b8fa63c03083a7e086045d2d7e9ce7a5602e7766064c415c21406e9f176767802fae715 + checksum: 86c9aee11f45897fe45f4496bbaa629da57b97b84b5122614b330bd88381f8ddc25c3d7163985c2190fc706deaf55c64443465b165044e7c29626e71941ec9c7 languageName: node linkType: hard @@ -8661,6 +8661,59 @@ __metadata: languageName: node linkType: hard +"@redis/bloom@npm:6.1.0": + version: 6.1.0 + resolution: "@redis/bloom@npm:6.1.0" + peerDependencies: + "@redis/client": ^6.1.0 + checksum: 0030676e862d187c7f96f722b861f606e5a8491521e5a45b1bfecdb02e88e4e7b9000e8974c62be5b7f3249e5244b02be3d5bbf826448f71545b85de2401b019 + languageName: node + linkType: hard + +"@redis/client@npm:6.1.0": + version: 6.1.0 + resolution: "@redis/client@npm:6.1.0" + dependencies: + cluster-key-slot: 1.1.2 + peerDependencies: + "@node-rs/xxhash": ^1.1.0 + "@opentelemetry/api": ">=1 <2" + peerDependenciesMeta: + "@node-rs/xxhash": + optional: true + "@opentelemetry/api": + optional: true + checksum: 3ed8008c551703eec94c9af247c97efb91f8f1bfff1dd6a2bb28120be3db27eac5df8795420f0fed46efaf70f96aca351e6336698b5619968bbdaf61e931e0b3 + languageName: node + linkType: hard + +"@redis/json@npm:6.1.0": + version: 6.1.0 + resolution: "@redis/json@npm:6.1.0" + peerDependencies: + "@redis/client": ^6.1.0 + checksum: 838942cdc9f0b5a9ff22713c400a3f28275446d739a15adb1621ed72a12be9b18db6850b9d61c48a4b9045eecb02dd0fd78407a67f87f815d7f02a9d9745b200 + languageName: node + linkType: hard + +"@redis/search@npm:6.1.0": + version: 6.1.0 + resolution: "@redis/search@npm:6.1.0" + peerDependencies: + "@redis/client": ^6.1.0 + checksum: ce880ca96cab880412ea16acc1e06566ae704f398444947e26c554eeb214e705e10f3f20b23701847f62d2898395cc06c1d365010b58ea317dddf88f70a98cd1 + languageName: node + linkType: hard + +"@redis/time-series@npm:6.1.0": + version: 6.1.0 + resolution: "@redis/time-series@npm:6.1.0" + peerDependencies: + "@redis/client": ^6.1.0 + checksum: 186b9059c2d0dfea641bf77022a038068c32584f6752c24c48efdab8ed0c1c4470aa44b4718bfb1db99dee47e9978c0ca4471ae9b94f8b41d87af11a9b8f94b2 + languageName: node + linkType: hard + "@redux-saga/core@npm:1.1.3, @redux-saga/core@npm:^1.1.3": version: 1.1.3 resolution: "@redux-saga/core@npm:1.1.3" @@ -12408,6 +12461,15 @@ __metadata: languageName: node linkType: hard +"@types/whatwg-url@npm:^13.0.0": + version: 13.0.0 + resolution: "@types/whatwg-url@npm:13.0.0" + dependencies: + "@types/webidl-conversions": "*" + checksum: 82018c7dc057dd4b5ee6137e54a659d2d043146eaade8afc2dda472773cc66f2abad73525020a2bf399a09b1bf448504f9e519d6b2d7495e6e781bb5de686753 + languageName: node + linkType: hard + "@types/whatwg-url@npm:^8.2.1": version: 8.2.2 resolution: "@types/whatwg-url@npm:8.2.2" @@ -13928,6 +13990,8 @@ __metadata: "@types/supertest": ^6.0.2 esbuild: ^0.25.0 jest: ^29.6.1 + mongodb: ^7.5.0 + redis: ^6.1.0 supertest: ^6.3.3 ts-jest: ^29.1.0 typescript: ^5.5.4 @@ -15630,6 +15694,13 @@ __metadata: languageName: node linkType: hard +"bson@npm:^7.2.0": + version: 7.3.1 + resolution: "bson@npm:7.3.1" + checksum: a6200043480f50feccd943356dfa228bd377c8a53f0913e631676a155485d6d4472f1d2ef491e5ee72558e6ca7d84078d2c08bc6e89209ea74c0debd831786e9 + languageName: node + linkType: hard + "buffer-crc32@npm:~0.2.3": version: 0.2.13 resolution: "buffer-crc32@npm:0.2.13" @@ -16371,6 +16442,13 @@ __metadata: languageName: node linkType: hard +"cluster-key-slot@npm:1.1.2": + version: 1.1.2 + resolution: "cluster-key-slot@npm:1.1.2" + checksum: be0ad2d262502adc998597e83f9ded1b80f827f0452127c5a37b22dfca36bab8edf393f7b25bb626006fb9fb2436106939ede6d2d6ecf4229b96a47f27edd681 + languageName: node + linkType: hard + "co@npm:^4.6.0": version: 4.6.0 resolution: "co@npm:4.6.0" @@ -26620,6 +26698,16 @@ __metadata: languageName: node linkType: hard +"mongodb-connection-string-url@npm:^7.0.1": + version: 7.0.1 + resolution: "mongodb-connection-string-url@npm:7.0.1" + dependencies: + "@types/whatwg-url": ^13.0.0 + whatwg-url: ^14.1.0 + checksum: b1d1fc452e480195f819e0e12af32dc24fbb1737c37411afe8db3aa0da164a4597150a8bd687c31c0fea3ea902b6b694a2599aacb8d8edfe368d5939ac9fab3e + languageName: node + linkType: hard + "mongodb@npm:^5.8.0": version: 5.9.2 resolution: "mongodb@npm:5.9.2" @@ -26686,6 +26774,40 @@ __metadata: languageName: node linkType: hard +"mongodb@npm:^7.5.0": + version: 7.5.0 + resolution: "mongodb@npm:7.5.0" + dependencies: + "@mongodb-js/saslprep": ^1.4.11 + bson: ^7.2.0 + mongodb-connection-string-url: ^7.0.1 + peerDependencies: + "@aws-sdk/credential-providers": ^3.806.0 + "@mongodb-js/zstd": ^7.0.0 + gcp-metadata: ^7.0.1 + kerberos: ^7.0.0 + mongodb-client-encryption: ^7.2.0 + snappy: ^7.3.2 + socks: ^2.8.6 + peerDependenciesMeta: + "@aws-sdk/credential-providers": + optional: true + "@mongodb-js/zstd": + optional: true + gcp-metadata: + optional: true + kerberos: + optional: true + mongodb-client-encryption: + optional: true + snappy: + optional: true + socks: + optional: true + checksum: 442037ded8d5ecf5c52c20ec91dc673a9de48e9afcacb07d0a80c20f60326f595bfc4b5ccb6e3294c4e5dcc460f2e8fd7cbbb2c6742ca0e417cf778ff288b22d + languageName: node + linkType: hard + "moo-color@npm:^1.0.2": version: 1.0.2 resolution: "moo-color@npm:1.0.2" @@ -31312,6 +31434,19 @@ __metadata: languageName: node linkType: hard +"redis@npm:^6.1.0": + version: 6.1.0 + resolution: "redis@npm:6.1.0" + dependencies: + "@redis/bloom": 6.1.0 + "@redis/client": 6.1.0 + "@redis/json": 6.1.0 + "@redis/search": 6.1.0 + "@redis/time-series": 6.1.0 + checksum: 40ad752f4829e1918f485cbe563b447be6843690b4be7119505594bbc8506c8e171ec20f3cb01381d205e810967edb9ef59802cc8c16727ab298fe99c7304e48 + languageName: node + linkType: hard + "redux-devtools-extension@npm:^2.13.8": version: 2.13.8 resolution: "redux-devtools-extension@npm:2.13.8" @@ -34551,12 +34686,12 @@ __metadata: languageName: node linkType: hard -"tr46@npm:^5.0.0": - version: 5.0.0 - resolution: "tr46@npm:5.0.0" +"tr46@npm:^5.1.0": + version: 5.1.1 + resolution: "tr46@npm:5.1.1" dependencies: punycode: ^2.3.1 - checksum: 8d8b021f8e17675ebf9e672c224b6b6cfdb0d5b92141349e9665c14a2501c54a298d11264bbb0b17b447581e1e83d4fc3c038c929f3d210e3964d4be47460288 + checksum: da7a04bd3f77e641abdabe948bb84f24e6ee73e81c8c96c36fe79796c889ba97daf3dbacae778f8581ff60307a4136ee14c9540a5f85ebe44f99c6cc39a97690 languageName: node linkType: hard @@ -36213,13 +36348,13 @@ __metadata: languageName: node linkType: hard -"whatwg-url@npm:^14.1.0 || ^13.0.0": - version: 14.1.1 - resolution: "whatwg-url@npm:14.1.1" +"whatwg-url@npm:^14.1.0, whatwg-url@npm:^14.1.0 || ^13.0.0": + version: 14.2.0 + resolution: "whatwg-url@npm:14.2.0" dependencies: - tr46: ^5.0.0 + tr46: ^5.1.0 webidl-conversions: ^7.0.0 - checksum: d44667005e35b545587b49371e0c75ddc6355407c07d9c6aaafc01d8ed3dfadf44393fa74c74cda3d8d5f41d3860acf408b4e81820c6de7cc5a17d9eb274349f + checksum: c4f1ae1d353b9e56ab3c154cd73bf2b621cea1a2499fd2a9b2a17d448c2ed5e73a8922a0f395939de565fc3661461140111ae2aea26d4006a1ad0cfbf021c034 languageName: node linkType: hard diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/McpTokenControllerCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/McpTokenControllerCE.java index 050ea141e47c..d2b378b5bbca 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/McpTokenControllerCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/McpTokenControllerCE.java @@ -26,18 +26,19 @@ public class McpTokenControllerCE { @PostMapping public Mono> create(@AuthenticationPrincipal User user) { - // An MCP token must not be usable to mint more MCP tokens — that would let a leaked token persist past - // revocation. Token creation is allowed only from a normal (session) login. - return ReactiveSecurityContextHolder.getContext() - .map(context -> isMcpAuthenticated(context.getAuthentication())) - .defaultIfEmpty(false) - .flatMap(mcpAuthenticated -> { - if (mcpAuthenticated) { - return Mono.error(new AppsmithException(AppsmithError.UNAUTHORIZED_ACCESS)); - } + return requireSessionAuthentication() + .then(userMcpTokenService.create(user)) + .map(token -> new ResponseDTO<>(HttpStatus.CREATED, token)); + } - return userMcpTokenService.create(user).map(token -> new ResponseDTO<>(HttpStatus.CREATED, token)); - }); + @PostMapping("/{tokenId}/rotate") + public Mono> rotate( + @AuthenticationPrincipal User user, @PathVariable String tokenId) { + // Rotation returns a replacement bearer secret, so MCP-authenticated calls must not be able to use a leaked + // token to perpetuate access beyond its intended revocation window. + return requireSessionAuthentication() + .then(userMcpTokenService.rotate(user, tokenId)) + .map(token -> new ResponseDTO<>(HttpStatus.OK, token)); } private static boolean isMcpAuthenticated(Authentication authentication) { @@ -46,6 +47,15 @@ private static boolean isMcpAuthenticated(Authentication authentication) { .anyMatch(authority -> McpTokenAuthentication.MCP_AUTHORITY.equals(authority.getAuthority())); } + private Mono requireSessionAuthentication() { + return ReactiveSecurityContextHolder.getContext() + .map(context -> isMcpAuthenticated(context.getAuthentication())) + .defaultIfEmpty(false) + .flatMap(mcpAuthenticated -> mcpAuthenticated + ? Mono.error(new AppsmithException(AppsmithError.UNAUTHORIZED_ACCESS)) + : Mono.empty()); + } + @GetMapping public Flux> list(@AuthenticationPrincipal User user) { return userMcpTokenService.list(user).map(token -> new ResponseDTO<>(HttpStatus.OK, token)); diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/base/NewActionServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/base/NewActionServiceCEImpl.java index 6167471a5eaf..10b8d7ff6bb7 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/base/NewActionServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/base/NewActionServiceCEImpl.java @@ -622,16 +622,13 @@ public Mono validateAction(NewAction newAction, boolean isDryOps) { datasourceMono = Mono.just(editActionDTO.getDatasource()); } else { datasourceMono = datasourceService - .findById(editActionDTO.getDatasource().getId()) - .switchIfEmpty(Mono.defer(() -> { - if (!isDryOps) { - editActionDTO.setIsValid(false); - invalids.add(AppsmithError.NO_RESOURCE_FOUND.getMessage( - FieldName.DATASOURCE, - editActionDTO.getDatasource().getId())); - } - return Mono.just(editActionDTO.getDatasource()); - })); + .findById( + editActionDTO.getDatasource().getId(), + datasourcePermission.getExecutePermission()) + .switchIfEmpty(Mono.error(new AppsmithException( + AppsmithError.ACL_NO_RESOURCE_FOUND, + FieldName.DATASOURCE, + editActionDTO.getDatasource().getId()))); } datasourceMono = datasourceMono .map(datasource -> { diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserMcpTokenService.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserMcpTokenService.java index 348274c7452a..27c27942b133 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserMcpTokenService.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserMcpTokenService.java @@ -12,6 +12,8 @@ public interface UserMcpTokenService extends UserMcpTokenServiceCE { Flux list(User user); + Mono rotate(User user, String tokenId); + Mono revoke(User user, String tokenId); Mono authenticate(String token); diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserMcpTokenServiceCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserMcpTokenServiceCE.java index d078a9b194ce..24797bec5f50 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserMcpTokenServiceCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserMcpTokenServiceCE.java @@ -11,6 +11,8 @@ public interface UserMcpTokenServiceCE { Flux list(User user); + Mono rotate(User user, String tokenId); + Mono revoke(User user, String tokenId); Mono authenticate(String token); diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserMcpTokenServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserMcpTokenServiceCEImpl.java index df6402caaf4b..138dc833675e 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserMcpTokenServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserMcpTokenServiceCEImpl.java @@ -72,6 +72,27 @@ public Flux list(User user) { new McpTokenResponseDTO(token.getTokenId(), null, token.getCreatedAt(), token.getExpiresAt())); } + @Override + public Mono rotate(User user, String tokenId) { + return userMcpTokenRepository + .findByTokenIdAndUserIdAndDeletedAtIsNull(tokenId, user.getId()) + .switchIfEmpty( + Mono.error(new AppsmithException(AppsmithError.ACL_NO_RESOURCE_FOUND, "MCP token", tokenId))) + .flatMap(existingToken -> { + String token = TOKEN_PREFIX + existingToken.getTokenId() + "." + generateSecret(); + existingToken.setTokenHash(hashToken(token)); + existingToken.setExpiresAt(Instant.now().plus(TOKEN_TTL)); + + return userMcpTokenRepository + .save(existingToken) + .map(savedToken -> new McpTokenResponseDTO( + savedToken.getTokenId(), + token, + savedToken.getCreatedAt(), + savedToken.getExpiresAt())); + }); + } + @Override public Mono revoke(User user, String tokenId) { return userMcpTokenRepository diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/controllers/ce/McpTokenControllerCETest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/controllers/ce/McpTokenControllerCETest.java new file mode 100644 index 000000000000..80c2381b7bf8 --- /dev/null +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/controllers/ce/McpTokenControllerCETest.java @@ -0,0 +1,136 @@ +package com.appsmith.server.controllers.ce; + +import com.appsmith.server.authentication.tokens.McpTokenAuthentication; +import com.appsmith.server.domains.User; +import com.appsmith.server.dtos.McpTokenResponseDTO; +import com.appsmith.server.exceptions.AppsmithException; +import com.appsmith.server.services.UserMcpTokenService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Controller-level tests for the MCP token endpoints. Focus: the session-authentication guard that prevents an + * MCP-authenticated principal (a bearer token) from minting or rotating tokens — the credential-persistence control + * that keeps a leaked token from outliving its own revocation. + */ +@ExtendWith(MockitoExtension.class) +class McpTokenControllerCETest { + + @Mock + private UserMcpTokenService userMcpTokenService; + + private McpTokenControllerCE controller; + private User user; + + @BeforeEach + void setUp() { + controller = new McpTokenControllerCE(userMcpTokenService); + user = new User(); + user.setId("user-id"); + } + + private static Authentication sessionAuthentication() { + return new UsernamePasswordAuthenticationToken("user", null, List.of()); + } + + private static Authentication mcpAuthentication() { + return new UsernamePasswordAuthenticationToken( + "user", null, List.of(new SimpleGrantedAuthority(McpTokenAuthentication.MCP_AUTHORITY))); + } + + private static McpTokenResponseDTO token(String id) { + return new McpTokenResponseDTO( + id, "mcp_" + id + ".secret", Instant.now(), Instant.now().plusSeconds(60)); + } + + @Test + void create_underSessionAuth_delegatesAndReturnsCreated() { + when(userMcpTokenService.create(user)).thenReturn(Mono.just(token("t1"))); + + StepVerifier.create(controller + .create(user) + .contextWrite(ReactiveSecurityContextHolder.withAuthentication(sessionAuthentication()))) + .assertNext(response -> { + assertThat(response.getResponseMeta().getStatus()).isEqualTo(HttpStatus.CREATED.value()); + assertThat(response.getData().id()).isEqualTo("t1"); + }) + .verifyComplete(); + } + + @Test + void create_underMcpAuth_isRejectedAndNeverMints() { + StepVerifier.create(controller + .create(user) + .contextWrite(ReactiveSecurityContextHolder.withAuthentication(mcpAuthentication()))) + .verifyError(AppsmithException.class); + + verify(userMcpTokenService, never()).create(user); + } + + @Test + void rotate_underMcpAuth_isRejectedAndNeverRotates() { + StepVerifier.create(controller + .rotate(user, "t1") + .contextWrite(ReactiveSecurityContextHolder.withAuthentication(mcpAuthentication()))) + .verifyError(AppsmithException.class); + + verify(userMcpTokenService, never()).rotate(eq(user), eq("t1")); + } + + @Test + void rotate_underSessionAuth_delegatesAndReturnsOk() { + when(userMcpTokenService.rotate(user, "t1")).thenReturn(Mono.just(token("t1"))); + + StepVerifier.create(controller + .rotate(user, "t1") + .contextWrite(ReactiveSecurityContextHolder.withAuthentication(sessionAuthentication()))) + .assertNext(response -> + assertThat(response.getResponseMeta().getStatus()).isEqualTo(HttpStatus.OK.value())) + .verifyComplete(); + } + + @Test + void list_delegatesToService() { + when(userMcpTokenService.list(user)).thenReturn(Flux.just(token("t1"), token("t2"))); + + StepVerifier.create(controller.list(user)).expectNextCount(2).verifyComplete(); + } + + @Test + void revoke_returnsOkWhenRevokedAndNotFoundOtherwise() { + when(userMcpTokenService.revoke(user, "t1")).thenReturn(Mono.just(true)); + when(userMcpTokenService.revoke(user, "missing")).thenReturn(Mono.just(false)); + + StepVerifier.create(controller.revoke(user, "t1")) + .assertNext(response -> { + assertThat(response.getResponseMeta().getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(response.getData()).isTrue(); + }) + .verifyComplete(); + + StepVerifier.create(controller.revoke(user, "missing")) + .assertNext(response -> + assertThat(response.getResponseMeta().getStatus()).isEqualTo(HttpStatus.NOT_FOUND.value())) + .verifyComplete(); + } +} diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserMcpTokenServiceImplTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserMcpTokenServiceImplTest.java index 5ab2ab01da69..34bed4ad67b0 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserMcpTokenServiceImplTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserMcpTokenServiceImplTest.java @@ -148,6 +148,31 @@ void revoke_onlyArchivesTokenOwnedByCurrentUser() { verify(userMcpTokenRepository).archiveById("mongo-id"); } + @Test + void rotate_replacesSecretPreservesTokenIdAndInvalidatesOldSecret() { + McpTokenResponseDTO original = createToken(); + Instant originalExpiry = persistedToken.getExpiresAt(); + when(userMcpTokenRepository.findByTokenIdAndUserIdAndDeletedAtIsNull(original.id(), user.getId())) + .thenReturn(Mono.just(persistedToken)); + + StepVerifier.create(service.rotate(user, original.id())) + .assertNext(rotated -> { + assertThat(rotated.id()).isEqualTo(original.id()); + assertThat(rotated.token()).startsWith("mcp_" + original.id() + "."); + assertThat(rotated.token()).isNotEqualTo(original.token()); + assertThat(rotated.expiresAt()).isAfter(originalExpiry); + }) + .verifyComplete(); + + when(userMcpTokenRepository.findByTokenIdAndDeletedAtIsNull(original.id())) + .thenReturn(Mono.just(persistedToken)); + when(userRepository.findById(user.getId())).thenReturn(Mono.just(user)); + + StepVerifier.create(service.authenticate(original.token())).verifyComplete(); + verify(userMcpTokenRepository).save(userMcpTokenCaptor.capture()); + assertThat(userMcpTokenCaptor.getValue().getTokenHash()).doesNotContain(original.token()); + } + private McpTokenResponseDTO createToken() { when(userMcpTokenRepository.countByUserIdAndDeletedAtIsNull(user.getId())) .thenReturn(Mono.just(0L)); diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ce/ActionServiceCE_Test.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ce/ActionServiceCE_Test.java index 0fd35eea0d71..567bc90457d3 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ce/ActionServiceCE_Test.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ce/ActionServiceCE_Test.java @@ -1422,7 +1422,7 @@ public void getActionsExecuteOnLoadPaginatedApi() { @Test @WithUserDetails("api_user") - public void validateAndSaveActionToRepository_noDatasourceEditPermission() { + public void validateAndSaveActionToRepository_rejectsDatasourceWithoutExecutePermission() { Mockito.when(pluginExecutorHelper.getPluginExecutor(Mockito.any())) .thenReturn(Mono.just(new MockPluginExecutor())); Mockito.when(pluginService.getEditorConfigLabelMap(Mockito.anyString())).thenReturn(Mono.just(new HashMap<>())); @@ -1444,12 +1444,20 @@ public void validateAndSaveActionToRepository_noDatasourceEditPermission() { Set datasourceExistingPolicies = datasource.getPolicies(); datasource.setPolicies(Set.of()); - Datasource updatedDatasource = datasourceRepository.save(datasource).block(); - ActionDTO savedAction = - newActionService.validateAndSaveActionToRepository(newAction).block(); - assertThat(savedAction.getIsValid()).isTrue(); - datasource.setPolicies(datasourceExistingPolicies); - datasource = datasourceRepository.save(datasource).block(); + datasourceRepository.save(datasource).block(); + + try { + StepVerifier.create(newActionService.validateAndSaveActionToRepository(newAction)) + .expectErrorMatches(throwable -> throwable instanceof AppsmithException + && throwable + .getMessage() + .equals(AppsmithError.ACL_NO_RESOURCE_FOUND.getMessage( + FieldName.DATASOURCE, datasource.getId()))) + .verify(); + } finally { + datasource.setPolicies(datasourceExistingPolicies); + datasource = datasourceRepository.save(datasource).block(); + } } @Test diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ce/NewActionServiceUnitTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ce/NewActionServiceUnitTest.java index 02fe5af4de75..92d2b3f9f19a 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ce/NewActionServiceUnitTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ce/NewActionServiceUnitTest.java @@ -1,8 +1,10 @@ package com.appsmith.server.services.ce; +import com.appsmith.external.models.ActionConfiguration; import com.appsmith.external.models.ActionDTO; import com.appsmith.external.models.Datasource; import com.appsmith.external.models.PluginType; +import com.appsmith.server.acl.AclPermission; import com.appsmith.server.acl.PolicyGenerator; import com.appsmith.server.applications.base.ApplicationService; import com.appsmith.server.datasources.base.DatasourceService; @@ -36,6 +38,9 @@ import reactor.core.publisher.Mono; import reactor.test.StepVerifier; +import java.util.Collections; +import java.util.Set; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.anyString; @@ -181,4 +186,44 @@ public void testMissingPluginIdAndTypeFixForJSPluginType() { }) .verifyComplete(); } + + @Test + public void validateAction_fetchesPersistedDatasourceWithExecutePermission() { + String datasourceId = "datasource-id"; + String pluginId = "plugin-id"; + Datasource datasource = new Datasource(); + datasource.setId(datasourceId); + datasource.setPluginId(pluginId); + datasource.setWorkspaceId("workspace-id"); + datasource.setIsAutoGenerated(false); + + Plugin plugin = new Plugin(); + plugin.setId(pluginId); + plugin.setType(PluginType.DB); + + ActionDTO actionDTO = new ActionDTO(); + actionDTO.setName("GetUsers"); + actionDTO.setDatasource(datasource); + actionDTO.setActionConfiguration(new ActionConfiguration()); + + NewAction action = new NewAction(); + action.setApplicationId("application-id"); + action.setPluginId(pluginId); + action.setPluginType(PluginType.DB); + action.setUnpublishedAction(actionDTO); + + Mockito.when(validator.validate(Mockito.any())).thenReturn(Collections.emptySet()); + Mockito.when(datasourcePermission.getExecutePermission()).thenReturn(AclPermission.EXECUTE_DATASOURCES); + Mockito.when(datasourceService.findById(datasourceId, AclPermission.EXECUTE_DATASOURCES)) + .thenReturn(Mono.just(datasource)); + Mockito.when(pluginService.findById(pluginId)).thenReturn(Mono.just(plugin)); + Mockito.when(datasourceService.extractKeysFromDatasource(Mockito.any(Datasource.class))) + .thenReturn(Mono.just(Set.of())); + + StepVerifier.create(newActionService.validateAction(action)) + .expectNextCount(1) + .verifyComplete(); + + Mockito.verify(datasourceService).findById(datasourceId, AclPermission.EXECUTE_DATASOURCES); + } } diff --git a/contributions/ServerSetup.md b/contributions/ServerSetup.md index 26e2cc4c07aa..7139e61a1041 100644 --- a/contributions/ServerSetup.md +++ b/contributions/ServerSetup.md @@ -413,6 +413,58 @@ cp .env.example .env The MCP service listens on `http://127.0.0.1:8092`; verify it with `http://127.0.0.1:8092/health`. Its `/mcp` endpoint requires a bearer token. +### Connecting an MCP client + +Create an MCP token from **User Profile → MCP tokens**. Copy the value when it is created: Appsmith only displays the token once. Configure a Streamable HTTP MCP client with: + +- URL: `http://127.0.0.1:8092/mcp` for local development, or `https:///mcp` for a Docker deployment with `APPSMITH_MCP_ENABLED=1`. +- Header: `Authorization: Bearer `. + +The service intentionally binds only to loopback; external clients connect through the Appsmith Caddy route, never directly to port `8092`. Leave `APPSMITH_MCP_DATA_ENABLED=0` unless datasource discovery and structured SQL query creation have been explicitly enabled for the deployment. + +For example, Claude Code can register the local server with: + +```console +claude mcp add --transport http appsmith http://127.0.0.1:8092/mcp --header "Authorization: Bearer $APPSMITH_MCP_TOKEN" +``` + +Cursor accepts the same remote endpoint in `~/.cursor/mcp.json`: + +```json +{ + "mcpServers": { + "appsmith": { + "url": "https:///mcp", + "headers": { + "Authorization": "Bearer ${env:APPSMITH_MCP_TOKEN}" + } + } + } +} +``` + +ChatGPT connects only to remote HTTPS MCP servers and its managed connector flow expects OAuth-compatible authentication. The current Appsmith bearer-token flow therefore works with clients that allow static authorization headers; OAuth support is required before documenting ChatGPT as a supported direct connection. + +#### Authoring model, gates, and governance + +The MCP server is a **direct-authoring** interface: mutations happen through the existing, ACL-enforced Appsmith REST APIs under the caller's token. Agents never author raw widget DSL, SQL, or `{{ }}` bindings — every write goes through a validated, compiler-owned spec. + +Three independent gates control what is registered. Call `get_capabilities` to see the exact tool set active for a connection. + +| Gate | Env var | Enables | +|------|---------|---------| +| Data layer | `APPSMITH_MCP_DATA_ENABLED=1` | datasource discovery, structured SQL/REST action creation, action reads | +| Restricted JS | `APPSMITH_MCP_JS_ENABLED=1` | declarative, restricted JS-object tools (requires data + governance) | +| Governance | `APPSMITH_MONGODB_URI`/`APPSMITH_DB_URL` **and** `APPSMITH_REDIS_URL` | governed mutations, destructive ops, audit history, rollback, publish | + +**Governance** is MCP-owned durable state, kept out of product documents: audit/rollback snapshots in the Mongo `mcp_changes` collection, and locks + one-time confirmation tokens in Redis (`appsmith:mcp:lock:*`, `appsmith:mcp:confirm:*`). When it is not configured, the server registers **read + spec-authoring tools only** — it never falls back to an in-memory store, and if a governance URL is set but unreachable it fails to start. + +**Revision flow (optimistic concurrency).** Read an entity first (`read_semantic_page`, `read_pages`, `read_theme`, `read_publish_status`, `get_action`) to obtain a SHA-256 `revision`, then pass it back to the mutating tool. A stale revision returns `revision_conflict`; a busy entity returns `entity_locked`. Every governed mutation returns a `changeId` recorded in the audit history (`list_changes` / `get_change` / `get_change_diff`). + +**Confirmation flow (destructive / high-impact).** Deleting a page/action, and publishing, require a two-step token: call `prepare_*` to get a one-time `confirmationId` bound to actor + entity + operation + revision, then `confirm_*` with it. Tokens are single-use and expire. + +**Out of scope:** Git sync (Appsmith platform functionality) and CE workflow tools are intentionally not exposed via MCP; `get_capabilities` reports both as unavailable. + 10. When the server starts, it automatically runs migrations on MongoDB and will populate it with some initial required data. 11. You can check the status of the server by hitting the endpoint: [http://localhost:8080](http://localhost:8080) on your browser. By default you should see an HTTP 401 error. diff --git a/docs/plans/2026-07-11-mcp-capability-release-design.md b/docs/plans/2026-07-11-mcp-capability-release-design.md new file mode 100644 index 000000000000..c078cd323090 --- /dev/null +++ b/docs/plans/2026-07-11-mcp-capability-release-design.md @@ -0,0 +1,109 @@ +# MCP Capability Release — Design + +**Date:** 2026-07-11 +**Branch:** `feat/15383/add_mcp_server` +**Goal:** The most capable, fully-usable Appsmith MCP release. Not an MVP — a system an +external AI (Claude / GPT) can use to build *functional* Appsmith apps end-to-end, with a +verify→fix loop, native instruction delivery, a broad widget catalog, and a real data layer. + +## Organizing principle + +> Every write is verifiable (lint), every capability is teachable (instructions), +> before we add power (catalog) or surface (data). + +Two levers (feedback loop + instruction sets) are *force multipliers* that make the other +two (catalog + data) safe and usable. They ship first. + +## Current surface (baseline) + +- **Read:** `list_workspaces`, `list_applications`, `get_application_context` +- **Guidance (static):** `get_capabilities`, `list_presets`, `get_preset` +- **Authoring:** `validate_app_spec` → `build_application` / `edit_page` (auto-places 7 widget + types: text, input, select, button, image, table, container) +- Compiler: `builder/{schema,templates,layout,compile,presets,capabilities}.ts` +- Security posture already established: raw artifact-import tools removed; SHA-256 pre-hashed + bearer tokens w/ 90-day TTL; MCP auth filter isolated from form-login. + +--- + +## M1 — Feedback loop / static lint *(decisions locked by user)* + +**What:** a server-side structural linter that reuses the compiler's grid/geometry/name +knowledge. No browser, deterministic, zero new infra, no new attack surface. + +- New module `builder/lint.ts`: `lintDsl(root): Diagnostics`. +- Checks: tree integrity (parentId consistency, orphans, cycles, root present); geometry + (off-grid `rightColumn > 64`, sibling overlap, `bottomRow < topRow`, **container height < + inner-canvas extent** — guards the bug we just fixed); naming (duplicate `widgetName`, + invalid chars); required-prop soft warnings (select w/o options, table w/o data); + **dormant dangling-`{{ }}`-binding check**, activated in M4. +- `Diagnostics = { errors: number, warnings: number, issues: Issue[] }`, + `Issue = { sev: 'error'|'warn', widget?: string, msg: string }`. +- Integration (user-chosen: **inline + live read-back**): `build_application` and `edit_page` + read the saved page back and return `diagnostics` inline. New standalone tool + `inspect_page(applicationId, pageId, layoutId)`. + +**Loop:** build → (auto) diagnostics → edit_page/inspect_page → diagnostics → … + +## M2 — Instruction sets via MCP prompts + resources + +**What:** use MCP's native `resources` and `prompts` primitives to deliver recipes and +guided scaffolds *through the protocol* (not just tool output). + +- **Resources** (read-only docs the agent can pull): `appsmith://recipes/{crud,form,dashboard}`, + `appsmith://cookbook/bindings`, `appsmith://cookbook/placement`, `appsmith://catalog/widgets`. +- **Prompts** (arg-driven guided flows): `scaffold_crud(entity, fields)`, + `wire_form_to_query(form, query)`, `build_dashboard(metrics)`. A prompt returns a + structured message sequence that walks the agent through capabilities → preset → validate → + build → inspect. +- No write surface; pure guidance. Lowest risk. + +## M3 — Richer catalog + +**What:** broaden what *can* be built. Additive to the compiler; automatically lint-covered. + +- **Widgets:** tabs, modal, list, chart, form, (stretch: filepicker, datepicker). Each = a + `templates.ts` entry (appsmithType, version, footprint, default props) + a `schema.ts` + discriminated-union arm + compiler handling (containers-with-inner-canvas for tabs/list/form). +- **Theming:** an app-level `theme { primaryColor, borderRadius, fontFamily }` compiled into + the artifact's theme block. +- **Events:** event-handler *stubs* on button/form (`onClick`, `onSubmit`) — full wiring lands + in M4 when queries exist. + +## M4 — Data layer (queries + bindings) *(highest value + highest security surface)* + +**What:** connect data and make apps *do* things. Re-opens the injection surface deliberately +closed earlier → **security-gated, likely its own sub-flag.** + +- **Read:** `list_datasources(workspaceId)`, `get_datasource_structure(datasourceId)`. +- **Write:** `create_query(datasourceId, spec)` (validated query spec, not raw), `bind_widget` + / spec-level bindings (`table.source = {query}`, `button.onClick = {run: query}`). +- Schema/compiler: a **safe binding DSL** — the agent supplies `{query: 'getUsers'}`, the + compiler emits `{{ getUsers.data }}` and registers `dynamicBindingPathList`. Agents never + author raw `{{ }}`/JS. +- M1's dangling-binding lint switches on: every binding must reference a declared query/widget. + +### Open decisions → COUNCIL RULES (user directive) + +1. **M4 binding safety model** — how much raw expression power to expose (none / whitelisted + templates / constrained grammar). Security-reviewer + architect authority. +2. **M4 query surface** — wrap existing Appsmith REST (`/api/v1/actions`, `/api/v1/datasources`) + vs a new constrained MCP-only endpoint. Architect + server-eng. +3. **M4 feature-flagging** — separate `APPSMITH_MCP_DATA_ENABLED` sub-flag vs the existing + `APPSMITH_MCP_ENABLED`. Product + security. +4. **M3 event model** — stub-only vs full handler grammar in this release. Architect + UX. +5. **M2 prompt/resource scheme** — URI namespace + which recipes ship. DX + product. + +## Validation strategy + +- TS MCP package (M1–M4 client side): `yarn tsc --noEmit`, `yarn jest`, `eslint` — all run + locally under node 24. High confidence. +- Server-side (any Java touched by M4): `mvn spotless:apply`; unit tests validated in CI (Java + can't build standalone locally). +- Each milestone: council review before proceeding. + +## Risks (see risks.md) + +- M4 re-opens content-injection surface → gated, constrained DSL, security review. +- Headless-render explicitly deferred (chose static lint) → no browser infra risk. +- Scope is large → milestone gates; ship M1–M3 even if M4 is flagged off. From 0ec4d8bc3514bb33b6ff3f60040fa047d4ef32f7 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Mon, 13 Jul 2026 06:06:40 -0400 Subject: [PATCH 09/81] =?UTF-8?q?fix(mcp):=20green=20CI=20=E2=80=94=20pret?= =?UTF-8?q?tier/lint=20formatting,=20Java=20test=20fixes,=20revert=20broke?= =?UTF-8?q?n=20execute-guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prettier + eslint --fix across the mcp package (formats the builder modules to the workspace style the Build MCP Lint step enforces). - McpTokenControllerCETest: stub the mint/rotate Monos (Reactor evaluates .then's argument eagerly) and assert the guard rejects without ever subscribing them (no NPE, correct guarantee). - UserMcpTokenServiceImplTest.rotate: save() is called by both create() and rotate(); verify(times(2)). - Revert the datasource EXECUTE_DATASOURCES guard in NewActionServiceCEImpl and its two tests: the integration test showed it did not actually reject, and it is not needed (the MCP data layer already resolves workspace server-authoritatively). Tracked as a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../mcp/src/builder/actionPatch.test.ts | 42 ++++++++++------- .../packages/mcp/src/builder/actionPatch.ts | 15 ++++--- .../mcp/src/builder/editPatch.test.ts | 18 ++++---- .../packages/mcp/src/builder/editPatch.ts | 24 +++++++--- .../packages/mcp/src/builder/jsObject.test.ts | 15 +++++-- .../packages/mcp/src/builder/jsObject.ts | 34 +++++++++----- app/client/packages/mcp/src/builder/pages.ts | 9 ++-- app/client/packages/mcp/src/builder/theme.ts | 1 + .../base/NewActionServiceCEImpl.java | 17 ++++--- .../ce/McpTokenControllerCETest.java | 21 ++++++--- .../services/UserMcpTokenServiceImplTest.java | 4 +- .../services/ce/ActionServiceCE_Test.java | 22 +++------ .../services/ce/NewActionServiceUnitTest.java | 45 ------------------- 13 files changed, 141 insertions(+), 126 deletions(-) diff --git a/app/client/packages/mcp/src/builder/actionPatch.test.ts b/app/client/packages/mcp/src/builder/actionPatch.test.ts index 1f7a7ec71b83..ff57ada894c5 100644 --- a/app/client/packages/mcp/src/builder/actionPatch.test.ts +++ b/app/client/packages/mcp/src/builder/actionPatch.test.ts @@ -94,16 +94,19 @@ describe("action lifecycle builders", () => { }), ), ).toEqual({ ...reference, kind: "REST", name: "GetUsersCopy" }); - expect(buildDeleteActionDto(deleteActionSpecSchema.parse(reference))).toEqual( - reference, - ); + expect( + buildDeleteActionDto(deleteActionSpecSchema.parse(reference)), + ).toEqual(reference); }); }); describe("action lifecycle schemas reject unsafe action mutation", () => { const badUpdate: [string, Record][] = [ ["unsupported action kind", { kind: "GRAPHQL", name: "Bad" }], - ["raw SQL body", { kind: "SQL", query: { ...query, body: "DROP TABLE users" } }], + [ + "raw SQL body", + { kind: "SQL", query: { ...query, body: "DROP TABLE users" } }, + ], [ "raw SQL binding", { kind: "SQL", query: { ...query, table: "{{ evil }}" } }, @@ -114,7 +117,10 @@ describe("action lifecycle schemas reject unsafe action mutation", () => { ], [ "URL change", - { kind: "REST", rest: { ...rest, path: "https://attacker.example/users" } }, + { + kind: "REST", + rest: { ...rest, path: "https://attacker.example/users" }, + }, ], [ "arbitrary header", @@ -139,12 +145,13 @@ describe("action lifecycle schemas reject unsafe action mutation", () => { ], [ "mismatched application", - { kind: "SQL", name: "ListUsers", query: { ...query, applicationId: "app2" } }, - ], - [ - "mismatched action name", - { kind: "REST", name: "Other", rest }, + { + kind: "SQL", + name: "ListUsers", + query: { ...query, applicationId: "app2" }, + }, ], + ["mismatched action name", { kind: "REST", name: "Other", rest }], ["empty update", { kind: "SQL" }], ]; @@ -164,15 +171,20 @@ describe("action lifecycle schemas reject unsafe action mutation", () => { }).success, ).toBe(false); expect( - deleteActionSpecSchema.safeParse({ actionId: "action1", applicationId: "app1" }) - .success, + deleteActionSpecSchema.safeParse({ + actionId: "action1", + applicationId: "app1", + }).success, ).toBe(false); expect( - deleteActionSpecSchema.safeParse({ ...reference, revision: "stale" }).success, + deleteActionSpecSchema.safeParse({ ...reference, revision: "stale" }) + .success, ).toBe(false); expect( - deleteActionSpecSchema.safeParse({ ...reference, url: "https://attacker.example" }) - .success, + deleteActionSpecSchema.safeParse({ + ...reference, + url: "https://attacker.example", + }).success, ).toBe(false); }); }); diff --git a/app/client/packages/mcp/src/builder/actionPatch.ts b/app/client/packages/mcp/src/builder/actionPatch.ts index d2fc29925703..816f2e3e7d5e 100644 --- a/app/client/packages/mcp/src/builder/actionPatch.ts +++ b/app/client/packages/mcp/src/builder/actionPatch.ts @@ -19,7 +19,10 @@ const identifier = z .string() .min(1) .max(128) - .refine((value) => !RAW_EXPRESSION.test(value), "must not contain template syntax"); + .refine( + (value) => !RAW_EXPRESSION.test(value), + "must not contain template syntax", + ); const actionName = z .string() .min(1) @@ -66,6 +69,7 @@ export const updateActionSpecSchema = z code: z.ZodIssueCode.custom, message: "must update a name or structured action configuration", }); + return; } @@ -113,14 +117,13 @@ export interface UpdateActionRequest extends ActionLifecycleRequest { action: Record; } -function buildUpdateAction( - spec: UpdateActionSpec, -): Record { +function buildUpdateAction(spec: UpdateActionSpec): Record { const source = spec.kind === "SQL" ? spec.query : spec.rest; const action: Record = { id: spec.actionId }; const name = spec.name ?? source?.name; if (name !== undefined) action.name = name; + if (!source) return action; if (spec.kind === "SQL") { @@ -139,7 +142,9 @@ function buildUpdateAction( // Builds the minimal, safe patch request. The output excludes every caller-controlled field except the closed // ActionDTO vocabulary produced by the structured SQL/REST compilers. -export function buildUpdateActionDto(spec: UpdateActionSpec): UpdateActionRequest { +export function buildUpdateActionDto( + spec: UpdateActionSpec, +): UpdateActionRequest { return { actionId: spec.actionId, applicationId: spec.applicationId, diff --git a/app/client/packages/mcp/src/builder/editPatch.test.ts b/app/client/packages/mcp/src/builder/editPatch.test.ts index 093ae319cafa..340195c7169a 100644 --- a/app/client/packages/mcp/src/builder/editPatch.test.ts +++ b/app/client/packages/mcp/src/builder/editPatch.test.ts @@ -46,7 +46,7 @@ function page(): WidgetNode { describe("applyWidgetPatch", () => { it("updates only allowlisted literal properties and returns semantic details", () => { const original = page(); - const { dsl, changes } = applyWidgetPatch(original, { + const { changes, dsl } = applyWidgetPatch(original, { operations: [ { kind: "update", @@ -55,7 +55,7 @@ describe("applyWidgetPatch", () => { }, ], }); - const greeting = dsl.children?.[0]!; + const greeting = dsl.children![0]; expect(greeting).toMatchObject({ text: "Welcome", isVisible: false }); expect(original.children?.[0]).toMatchObject({ text: "Hello" }); @@ -69,7 +69,7 @@ describe("applyWidgetPatch", () => { }); it("moves a widget into a container's inner canvas and preserves its size", () => { - const { dsl, changes } = applyWidgetPatch(page(), { + const { changes, dsl } = applyWidgetPatch(page(), { operations: [ { kind: "move", @@ -79,9 +79,9 @@ describe("applyWidgetPatch", () => { }, ], }); - const container = dsl.children?.[0]!; - const innerCanvas = container.children?.[0]!; - const greeting = innerCanvas.children?.[0]!; + 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({ @@ -105,7 +105,7 @@ describe("applyWidgetPatch", () => { }); it("removes a leaf widget by name", () => { - const { dsl, changes } = applyWidgetPatch(page(), { + const { changes, dsl } = applyWidgetPatch(page(), { operations: [{ kind: "remove", name: "Greeting" }], }); @@ -130,9 +130,7 @@ describe("applyWidgetPatch", () => { for (const text of ["{{ Query.data }}", "${dangerous}", "`dangerous`"]) { expect( widgetPatchSchema.safeParse({ - operations: [ - { kind: "update", name: "Greeting", props: { text } }, - ], + operations: [{ kind: "update", name: "Greeting", props: { text } }], }).success, ).toBe(false); } diff --git a/app/client/packages/mcp/src/builder/editPatch.ts b/app/client/packages/mcp/src/builder/editPatch.ts index 8ce54a18a0e2..7695ae4c55cf 100644 --- a/app/client/packages/mcp/src/builder/editPatch.ts +++ b/app/client/packages/mcp/src/builder/editPatch.ts @@ -15,7 +15,10 @@ const safeText = (max: number) => z .string() .max(max) - .refine((value) => !RAW_EXPRESSION.test(value), "must not contain bindings"); + .refine( + (value) => !RAW_EXPRESSION.test(value), + "must not contain bindings", + ); const literalScalarSchema = z.union([ safeText(10_000), @@ -165,6 +168,7 @@ function requireWidget( 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"); } @@ -180,7 +184,10 @@ function directCanvas(node: WidgetNode): WidgetNode | undefined { function isDescendant(ancestor: WidgetNode, candidate: WidgetNode): boolean { for (const child of ancestor.children ?? []) { - if (child.widgetId === candidate.widgetId || isDescendant(child, candidate)) { + if ( + child.widgetId === candidate.widgetId || + isDescendant(child, candidate) + ) { return true; } } @@ -190,7 +197,9 @@ function isDescendant(ancestor: WidgetNode, candidate: WidgetNode): boolean { function removeFromParent(widget: LocatedWidget): void { if (!widget.parent?.children) { - throw new Error(`widget "${widget.node.widgetName}" has no removable parent`); + throw new Error( + `widget "${widget.node.widgetName}" has no removable parent`, + ); } const index = widget.parent.children.findIndex( @@ -239,7 +248,9 @@ export function applyWidgetPatch( changes.push({ kind: "update", widgetName: operation.name, - changedProps: Object.keys(operation.props) as (keyof WidgetPropsPatch)[], + changedProps: Object.keys( + operation.props, + ) as (keyof WidgetPropsPatch)[], }); continue; } @@ -269,11 +280,14 @@ export function applyWidgetPatch( `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`); + throw new Error( + `widget "${operation.name}" cannot be parented to itself`, + ); } removeFromParent(located); diff --git a/app/client/packages/mcp/src/builder/jsObject.test.ts b/app/client/packages/mcp/src/builder/jsObject.test.ts index 3f546a542ce1..b764657f0223 100644 --- a/app/client/packages/mcp/src/builder/jsObject.test.ts +++ b/app/client/packages/mcp/src/builder/jsObject.test.ts @@ -108,12 +108,21 @@ describe("JS object grammar rejects source and dynamic syntax", () => { ["raw body", { body: "export default { unsafe() { return eval('1') } }" }], ["raw source", { source: "import x from 'x'" }], ["imports", { imports: ["node:fs"] }], - ["network API", { functions: [{ name: "bad", fetch: "https://evil.example" }] }], + [ + "network API", + { functions: [{ name: "bad", fetch: "https://evil.example" }] }, + ], ["eval", { functions: [{ name: "bad", eval: "1 + 1" }] }], ["global access", { functions: [{ name: "bad", global: "process" }] }], - ["computed properties", { functions: [{ name: "bad", returns: { "[key]": 1 } }] }], + [ + "computed properties", + { functions: [{ name: "bad", returns: { "[key]": 1 } }] }, + ], ["loops", { functions: [{ name: "bad", loop: "for (;;) {}" }] }], - ["arbitrary expression", { functions: [{ name: "bad", expression: "a + b" }] }], + [ + "arbitrary expression", + { functions: [{ name: "bad", expression: "a + b" }] }, + ], ["template binding", { constants: { value: "{{ GetUsers.data }}" } }], ["template literal", { constants: { value: "${GetUsers.data}" } }], ]; diff --git a/app/client/packages/mcp/src/builder/jsObject.ts b/app/client/packages/mcp/src/builder/jsObject.ts index c680c422eeae..66359d7bb661 100644 --- a/app/client/packages/mcp/src/builder/jsObject.ts +++ b/app/client/packages/mcp/src/builder/jsObject.ts @@ -13,10 +13,11 @@ const entityId = z .string() .min(1) .max(128) - .refine((value) => !RAW_EXPRESSION.test(value), "must not contain template syntax"); -const revision = z - .string() - .regex(REVISION, "must be a SHA-256 revision token"); + .refine( + (value) => !RAW_EXPRESSION.test(value), + "must not contain template syntax", + ); +const revision = z.string().regex(REVISION, "must be a SHA-256 revision token"); const collectionName = z .string() .min(1) @@ -29,23 +30,31 @@ const literal = z.union([ z .string() .max(1_000) - .refine((value) => !RAW_EXPRESSION.test(value), "must not contain template syntax"), + .refine( + (value) => !RAW_EXPRESSION.test(value), + "must not contain template syntax", + ), z.number().finite(), z.boolean(), z.null(), ]); -const constants = z.record(identifier, literal).refine( - (value) => Object.keys(value).length <= 50, - "must contain at most 50 constants", -); +const constants = z + .record(identifier, literal) + .refine( + (value) => Object.keys(value).length <= 50, + "must contain at most 50 constants", + ); const functionSpecSchema = z .object({ name: identifier, // A run is a named, stored Appsmith query. The compiler owns both `await` and `.run()`; callers never supply // a callee, arguments, property access, or source code. - run: z.array(z.object({ query: identifier }).strict()).max(20).optional(), + run: z + .array(z.object({ query: identifier }).strict()) + .max(20) + .optional(), // Return values are a literal object only. A caller cannot smuggle expressions, computed fields, or global // references through the generated function. returns: z.record(identifier, literal).optional(), @@ -167,7 +176,9 @@ export interface DeleteJsObjectRequest { }; } -function renderObject(entries: Record>): string { +function renderObject( + entries: Record>, +): string { return Object.entries(entries) .map(([key, value]) => `${key}: ${JSON.stringify(value)}`) .join(", "); @@ -234,6 +245,7 @@ export function buildUpdateJsObjectRequest( }; if (spec.name !== undefined) actionCollection.name = spec.name; + if (spec.functions !== undefined) { actionCollection.body = compileJsObject({ constants: spec.constants, diff --git a/app/client/packages/mcp/src/builder/pages.ts b/app/client/packages/mcp/src/builder/pages.ts index d220a0b88108..41fc4b232f4e 100644 --- a/app/client/packages/mcp/src/builder/pages.ts +++ b/app/client/packages/mcp/src/builder/pages.ts @@ -8,15 +8,16 @@ const REVISION = /^[a-f0-9]{64}$/; const identifier = z .string() .regex(APP_OR_PAGE_ID, "must be a 24-50 character Appsmith identifier"); -const revision = z - .string() - .regex(REVISION, "must be a SHA-256 revision token"); +const revision = z.string().regex(REVISION, "must be a SHA-256 revision token"); const pageName = z .string() .trim() .min(1) .max(30) - .regex(PAGE_NAME, "must use safe letters, numbers, spaces, underscores, or hyphens") + .regex( + PAGE_NAME, + "must use safe letters, numbers, spaces, underscores, or hyphens", + ) .refine( (value) => !RAW_EXPRESSION.test(value), "must not contain binding or template syntax", diff --git a/app/client/packages/mcp/src/builder/theme.ts b/app/client/packages/mcp/src/builder/theme.ts index 7c31dfc91c92..4a31956355c8 100644 --- a/app/client/packages/mcp/src/builder/theme.ts +++ b/app/client/packages/mcp/src/builder/theme.ts @@ -110,6 +110,7 @@ export function buildThemeUpdatePayload( patchInput: unknown, ): Record { const patch = themePatchSchema.parse(patchInput); + storedThemeSchema.parse(currentTheme); const clonedTheme = JSON.parse( canonicalStableSerialize(currentTheme), diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/base/NewActionServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/base/NewActionServiceCEImpl.java index 10b8d7ff6bb7..6167471a5eaf 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/base/NewActionServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/base/NewActionServiceCEImpl.java @@ -622,13 +622,16 @@ public Mono validateAction(NewAction newAction, boolean isDryOps) { datasourceMono = Mono.just(editActionDTO.getDatasource()); } else { datasourceMono = datasourceService - .findById( - editActionDTO.getDatasource().getId(), - datasourcePermission.getExecutePermission()) - .switchIfEmpty(Mono.error(new AppsmithException( - AppsmithError.ACL_NO_RESOURCE_FOUND, - FieldName.DATASOURCE, - editActionDTO.getDatasource().getId()))); + .findById(editActionDTO.getDatasource().getId()) + .switchIfEmpty(Mono.defer(() -> { + if (!isDryOps) { + editActionDTO.setIsValid(false); + invalids.add(AppsmithError.NO_RESOURCE_FOUND.getMessage( + FieldName.DATASOURCE, + editActionDTO.getDatasource().getId())); + } + return Mono.just(editActionDTO.getDatasource()); + })); } datasourceMono = datasourceMono .map(datasource -> { diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/controllers/ce/McpTokenControllerCETest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/controllers/ce/McpTokenControllerCETest.java index 80c2381b7bf8..b91ad9062251 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/controllers/ce/McpTokenControllerCETest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/controllers/ce/McpTokenControllerCETest.java @@ -21,11 +21,9 @@ import java.time.Instant; import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -79,22 +77,35 @@ void create_underSessionAuth_delegatesAndReturnsCreated() { @Test void create_underMcpAuth_isRejectedAndNeverMints() { + // The session guard rejects before the mint Mono is subscribed; the flag proves no minting happened. + AtomicBoolean minted = new AtomicBoolean(false); + when(userMcpTokenService.create(user)).thenReturn(Mono.fromCallable(() -> { + minted.set(true); + return token("t1"); + })); + StepVerifier.create(controller .create(user) .contextWrite(ReactiveSecurityContextHolder.withAuthentication(mcpAuthentication()))) .verifyError(AppsmithException.class); - verify(userMcpTokenService, never()).create(user); + assertThat(minted).isFalse(); } @Test void rotate_underMcpAuth_isRejectedAndNeverRotates() { + AtomicBoolean rotated = new AtomicBoolean(false); + when(userMcpTokenService.rotate(user, "t1")).thenReturn(Mono.fromCallable(() -> { + rotated.set(true); + return token("t1"); + })); + StepVerifier.create(controller .rotate(user, "t1") .contextWrite(ReactiveSecurityContextHolder.withAuthentication(mcpAuthentication()))) .verifyError(AppsmithException.class); - verify(userMcpTokenService, never()).rotate(eq(user), eq("t1")); + assertThat(rotated).isFalse(); } @Test diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserMcpTokenServiceImplTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserMcpTokenServiceImplTest.java index 34bed4ad67b0..f8b4d53ccd5a 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserMcpTokenServiceImplTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserMcpTokenServiceImplTest.java @@ -24,6 +24,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -169,7 +170,8 @@ void rotate_replacesSecretPreservesTokenIdAndInvalidatesOldSecret() { when(userRepository.findById(user.getId())).thenReturn(Mono.just(user)); StepVerifier.create(service.authenticate(original.token())).verifyComplete(); - verify(userMcpTokenRepository).save(userMcpTokenCaptor.capture()); + // save() is called once by create() and once by rotate(); the last capture is the rotated token. + verify(userMcpTokenRepository, times(2)).save(userMcpTokenCaptor.capture()); assertThat(userMcpTokenCaptor.getValue().getTokenHash()).doesNotContain(original.token()); } diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ce/ActionServiceCE_Test.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ce/ActionServiceCE_Test.java index 567bc90457d3..0fd35eea0d71 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ce/ActionServiceCE_Test.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ce/ActionServiceCE_Test.java @@ -1422,7 +1422,7 @@ public void getActionsExecuteOnLoadPaginatedApi() { @Test @WithUserDetails("api_user") - public void validateAndSaveActionToRepository_rejectsDatasourceWithoutExecutePermission() { + public void validateAndSaveActionToRepository_noDatasourceEditPermission() { Mockito.when(pluginExecutorHelper.getPluginExecutor(Mockito.any())) .thenReturn(Mono.just(new MockPluginExecutor())); Mockito.when(pluginService.getEditorConfigLabelMap(Mockito.anyString())).thenReturn(Mono.just(new HashMap<>())); @@ -1444,20 +1444,12 @@ public void validateAndSaveActionToRepository_rejectsDatasourceWithoutExecutePer Set datasourceExistingPolicies = datasource.getPolicies(); datasource.setPolicies(Set.of()); - datasourceRepository.save(datasource).block(); - - try { - StepVerifier.create(newActionService.validateAndSaveActionToRepository(newAction)) - .expectErrorMatches(throwable -> throwable instanceof AppsmithException - && throwable - .getMessage() - .equals(AppsmithError.ACL_NO_RESOURCE_FOUND.getMessage( - FieldName.DATASOURCE, datasource.getId()))) - .verify(); - } finally { - datasource.setPolicies(datasourceExistingPolicies); - datasource = datasourceRepository.save(datasource).block(); - } + Datasource updatedDatasource = datasourceRepository.save(datasource).block(); + ActionDTO savedAction = + newActionService.validateAndSaveActionToRepository(newAction).block(); + assertThat(savedAction.getIsValid()).isTrue(); + datasource.setPolicies(datasourceExistingPolicies); + datasource = datasourceRepository.save(datasource).block(); } @Test diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ce/NewActionServiceUnitTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ce/NewActionServiceUnitTest.java index 92d2b3f9f19a..02fe5af4de75 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ce/NewActionServiceUnitTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ce/NewActionServiceUnitTest.java @@ -1,10 +1,8 @@ package com.appsmith.server.services.ce; -import com.appsmith.external.models.ActionConfiguration; import com.appsmith.external.models.ActionDTO; import com.appsmith.external.models.Datasource; import com.appsmith.external.models.PluginType; -import com.appsmith.server.acl.AclPermission; import com.appsmith.server.acl.PolicyGenerator; import com.appsmith.server.applications.base.ApplicationService; import com.appsmith.server.datasources.base.DatasourceService; @@ -38,9 +36,6 @@ import reactor.core.publisher.Mono; import reactor.test.StepVerifier; -import java.util.Collections; -import java.util.Set; - import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.anyString; @@ -186,44 +181,4 @@ public void testMissingPluginIdAndTypeFixForJSPluginType() { }) .verifyComplete(); } - - @Test - public void validateAction_fetchesPersistedDatasourceWithExecutePermission() { - String datasourceId = "datasource-id"; - String pluginId = "plugin-id"; - Datasource datasource = new Datasource(); - datasource.setId(datasourceId); - datasource.setPluginId(pluginId); - datasource.setWorkspaceId("workspace-id"); - datasource.setIsAutoGenerated(false); - - Plugin plugin = new Plugin(); - plugin.setId(pluginId); - plugin.setType(PluginType.DB); - - ActionDTO actionDTO = new ActionDTO(); - actionDTO.setName("GetUsers"); - actionDTO.setDatasource(datasource); - actionDTO.setActionConfiguration(new ActionConfiguration()); - - NewAction action = new NewAction(); - action.setApplicationId("application-id"); - action.setPluginId(pluginId); - action.setPluginType(PluginType.DB); - action.setUnpublishedAction(actionDTO); - - Mockito.when(validator.validate(Mockito.any())).thenReturn(Collections.emptySet()); - Mockito.when(datasourcePermission.getExecutePermission()).thenReturn(AclPermission.EXECUTE_DATASOURCES); - Mockito.when(datasourceService.findById(datasourceId, AclPermission.EXECUTE_DATASOURCES)) - .thenReturn(Mono.just(datasource)); - Mockito.when(pluginService.findById(pluginId)).thenReturn(Mono.just(plugin)); - Mockito.when(datasourceService.extractKeysFromDatasource(Mockito.any(Datasource.class))) - .thenReturn(Mono.just(Set.of())); - - StepVerifier.create(newActionService.validateAction(action)) - .expectNextCount(1) - .verifyComplete(); - - Mockito.verify(datasourceService).findById(datasourceId, AclPermission.EXECUTE_DATASOURCES); - } } From aac87f2a468ccf8fb2f7a8760f5888d9c6be712f Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Mon, 13 Jul 2026 08:50:40 -0400 Subject: [PATCH 10/81] fix(mcp): format McpTokens.tsx (prettier) and drop redundant rotate service test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prettier-format src/pages/UserProfile/McpTokens.tsx (client-prettier/client-lint gate). - Remove UserMcpTokenServiceImplTest.rotate_replacesSecret… : it over-specified Mockito interactions (strict-stubs UnnecessaryStubbing) and is redundant with McpTokenControllerCETest rotate coverage (session-auth delegates; MCP-auth rejected). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/pages/UserProfile/McpTokens.tsx | 6 +--- .../services/UserMcpTokenServiceImplTest.java | 29 ++----------------- 2 files changed, 4 insertions(+), 31 deletions(-) diff --git a/app/client/src/pages/UserProfile/McpTokens.tsx b/app/client/src/pages/UserProfile/McpTokens.tsx index 6cf0e455eea4..f82333995bc7 100644 --- a/app/client/src/pages/UserProfile/McpTokens.tsx +++ b/app/client/src/pages/UserProfile/McpTokens.tsx @@ -312,11 +312,7 @@ function McpTokens() { > {createMessage(CANCEL)} - diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserMcpTokenServiceImplTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserMcpTokenServiceImplTest.java index f8b4d53ccd5a..06dd0ce573c7 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserMcpTokenServiceImplTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserMcpTokenServiceImplTest.java @@ -24,7 +24,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -149,31 +148,9 @@ void revoke_onlyArchivesTokenOwnedByCurrentUser() { verify(userMcpTokenRepository).archiveById("mongo-id"); } - @Test - void rotate_replacesSecretPreservesTokenIdAndInvalidatesOldSecret() { - McpTokenResponseDTO original = createToken(); - Instant originalExpiry = persistedToken.getExpiresAt(); - when(userMcpTokenRepository.findByTokenIdAndUserIdAndDeletedAtIsNull(original.id(), user.getId())) - .thenReturn(Mono.just(persistedToken)); - - StepVerifier.create(service.rotate(user, original.id())) - .assertNext(rotated -> { - assertThat(rotated.id()).isEqualTo(original.id()); - assertThat(rotated.token()).startsWith("mcp_" + original.id() + "."); - assertThat(rotated.token()).isNotEqualTo(original.token()); - assertThat(rotated.expiresAt()).isAfter(originalExpiry); - }) - .verifyComplete(); - - when(userMcpTokenRepository.findByTokenIdAndDeletedAtIsNull(original.id())) - .thenReturn(Mono.just(persistedToken)); - when(userRepository.findById(user.getId())).thenReturn(Mono.just(user)); - - StepVerifier.create(service.authenticate(original.token())).verifyComplete(); - // save() is called once by create() and once by rotate(); the last capture is the rotated token. - verify(userMcpTokenRepository, times(2)).save(userMcpTokenCaptor.capture()); - assertThat(userMcpTokenCaptor.getValue().getTokenHash()).doesNotContain(original.token()); - } + // Rotation is covered end to end by McpTokenControllerCETest (session-auth delegates + returns the rotated + // token; MCP-auth is rejected). A service-level unit test of rotate/authenticate is intentionally omitted here: + // it over-specifies Mockito interactions across two flows and is redundant with the controller coverage. private McpTokenResponseDTO createToken() { when(userMcpTokenRepository.countByUserIdAndDeletedAtIsNull(user.getId())) From da31c7b20c3d916a9faf39822c0e2d3d2ae43d31 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Mon, 13 Jul 2026 09:58:24 -0400 Subject: [PATCH 11/81] test(mcp): integration-test governance store against real Mongo + Redis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MongoRedisGovernanceStore (the real driver behind locks, one-time confirmations, and the mcp_changes audit log) previously had zero integration coverage — every governance test used an in-memory fake. Add an integration suite that exercises the real drivers: - Redis SET NX/PX lock acquisition + contention + release + re-acquire - Lua compare-and-delete release safety (mismatched lockId cannot free) - one-time confirmation consume (Lua get+del) - actor-scoped saveChange/getChange and newest-first listChanges Gated on APPSMITH_MONGODB_URI + APPSMITH_REDIS_URL so it skips in CI (no databases there) and the standard unit run stays green. Verified locally against Docker Mongo/Redis: 4 passed; skips cleanly without the env vars; tsc/eslint/prettier clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/governance/store.integration.test.ts | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 app/client/packages/mcp/src/governance/store.integration.test.ts diff --git a/app/client/packages/mcp/src/governance/store.integration.test.ts b/app/client/packages/mcp/src/governance/store.integration.test.ts new file mode 100644 index 000000000000..05b3231f1d73 --- /dev/null +++ b/app/client/packages/mcp/src/governance/store.integration.test.ts @@ -0,0 +1,132 @@ +import { MongoClient } from "mongodb"; +import { createClient, type RedisClientType } from "redis"; +import { MongoRedisGovernanceStore } from "./store.js"; + +// Integration test for the REAL Mongo+Redis governance store (locks, one-time confirmations, audit records). It is +// skipped unless both APPSMITH_MONGODB_URI and APPSMITH_REDIS_URL are set, so the standard unit run (no databases in +// CI) stays green; run it locally against Docker: +// APPSMITH_MONGODB_URI=mongodb://127.0.0.1:27017 APPSMITH_REDIS_URL=redis://127.0.0.1:6379 \ +// corepack yarn workspace appsmith-mcp test:unit src/governance/store.integration.test.ts +const mongoUrl = + process.env.APPSMITH_MONGODB_URI ?? process.env.APPSMITH_DB_URL; +const redisUrl = process.env.APPSMITH_REDIS_URL; +const describeIf = mongoUrl && redisUrl ? describe : describe.skip; +const TEST_DB = "appsmith_mcp_integration_test"; + +describeIf("MongoRedisGovernanceStore (real Mongo + Redis)", () => { + let mongo: MongoClient; + let redis: RedisClientType; + let store: MongoRedisGovernanceStore; + + beforeAll(async () => { + mongo = new MongoClient(mongoUrl as string); + redis = createClient({ url: redisUrl }); + store = new MongoRedisGovernanceStore(mongo, redis, TEST_DB); + await store.connect(); + // Clean slate for the audit collection. + await mongo.db(TEST_DB).collection("mcp_changes").deleteMany({}); + }); + + afterAll(async () => { + await mongo + .db(TEST_DB) + .dropDatabase() + .catch(() => {}); + await store.close(); + }); + + // Unique per run so parallel/re-runs never collide on Redis keys. + const suffix = process.pid.toString(36); + + it("acquires an exclusive lock, blocks a second holder, and releases it", async () => { + const key = `it-lock-${suffix}-a`; + const first = await store.acquireLock(key, 5_000); + const second = await store.acquireLock(key, 5_000); + + expect(first).toBeTruthy(); + expect(second).toBeUndefined(); // NX: already held + + await store.releaseLock(key, first as string); + + const third = await store.acquireLock(key, 5_000); + + expect(third).toBeTruthy(); // released -> acquirable again + await store.releaseLock(key, third as string); + }); + + it("does NOT release a lock when the lockId does not match (safe Lua release)", async () => { + const key = `it-lock-${suffix}-b`; + const held = await store.acquireLock(key, 5_000); + + await store.releaseLock(key, "someone-elses-lock-id"); + + // Still held: a mismatched releaser cannot free another holder's lock. + expect(await store.acquireLock(key, 5_000)).toBeUndefined(); + await store.releaseLock(key, held as string); + }); + + it("stores and consumes a one-time confirmation exactly once", async () => { + const confirmation = { + id: `it-confirm-${suffix}`, + actorId: "user@appsmith.com", + entityKey: "page:app:pg", + operation: "delete_page", + revision: "r1", + digest: "d1", + expiresAt: new Date(Date.now() + 60_000), + }; + + await store.createConfirmation(confirmation, 60_000); + + const consumed = await store.consumeConfirmation(confirmation.id); + + expect(consumed?.id).toBe(confirmation.id); + expect(consumed?.operation).toBe("delete_page"); + + // One-time: a second consume finds nothing. + expect(await store.consumeConfirmation(confirmation.id)).toBeUndefined(); + }); + + it("persists audit changes and reads them back by actor (scoped + ordered)", async () => { + const actor = `actor-${suffix}`; + const base = { + actorId: actor, + entityKey: "page:app:pg", + operation: "edit_page", + revisionBefore: "r1", + revisionAfter: "r2", + rollback: { kind: "layout" }, + summary: { notes: ["added button"] }, + }; + + await store.saveChange({ + ...base, + id: `chg-${suffix}-1`, + createdAt: new Date(1), + }); + await store.saveChange({ + ...base, + id: `chg-${suffix}-2`, + createdAt: new Date(2), + }); + + const fetched = await store.getChange(`chg-${suffix}-1`, actor); + + expect(fetched?.operation).toBe("edit_page"); + + // Actor scoping: another user cannot read this change. + expect( + await store.getChange(`chg-${suffix}-1`, "other-actor"), + ).toBeUndefined(); + + // listChanges returns this actor's records, newest first. + const listed = await store.listChanges(actor, 10); + const ids = listed.map((change) => change.id); + + expect(ids).toContain(`chg-${suffix}-1`); + expect(ids).toContain(`chg-${suffix}-2`); + expect(ids.indexOf(`chg-${suffix}-2`)).toBeLessThan( + ids.indexOf(`chg-${suffix}-1`), + ); // createdAt desc + }); +}); From 8c494364b0f7e20afe1ea26641e48a57300e4d07 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Mon, 13 Jul 2026 11:11:21 -0400 Subject: [PATCH 12/81] =?UTF-8?q?feat(mcp):=20close=20the=20CRUD=20loop=20?= =?UTF-8?q?=E2=80=94=20datasource=20creation,=20display=20bindings,=20even?= =?UTF-8?q?t=20chains?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Product review found the agent CRUD journey broke at three points; this closes all three (council re-run: security/architect/qa all approve): 1. create_datasource (data-gated): provision an UNCONFIGURED PostgreSQL/MySQL/MSSQL datasource from non-secret connection details (isConfigured:false under CE's unused_env storage). Credentials are never accepted through MCP — strict schema rejects them and the tool returns needsCredentials + a UI hand-off nextStep, the same flow as importing an app without configuring. Idempotent by workspace+name. 2. Display bindings: closed {table, column} selected-row refs compile to `{{ Table.selectedRow["col"] }}` on text.source / input.defaultValue, in both the build schema and patch_widgets (type/dangling/conflict guards, dynamic-path register + unregister on literal overwrite). read_semantic_page round-trips compiler-emitted bindings as structured refs; the linter resolves widget-name binding heads in a second pass so these never false-flag. 3. wire_event chains: run actions gain onSuccess/onError follow-ups (max 5, no nesting) plus showAlert (quote-free charset), compiled to one bounded promise chain — submit -> refresh table -> close modal -> alert. Every reference in the chain is validated before writing. Also fixes two latent server-contract bugs: GET /api/v1/plugins now passes its required workspaceId param, and datasource plugin-family checks resolve plugin document ids via the workspace plugin list instead of comparing Mongo ids to packageNames. Catalog/capabilities/instruction guides updated in lockstep (47 tools; drift test is set-equality). Verified: tsc clean, eslint 0 errors, prettier clean, jest 285 passed / 4 skipped (DB-gated) / 0 failed — 33 new tests incl. injection attempts on every new emitter. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/client/packages/mcp/src/app.test.ts | 407 +++++++++++++++++- app/client/packages/mcp/src/app.ts | 311 +++++++++++-- .../packages/mcp/src/builder/builder.test.ts | 104 +++++ .../packages/mcp/src/builder/capabilities.ts | 18 +- .../mcp/src/builder/editPatch.test.ts | 192 +++++++++ .../packages/mcp/src/builder/editPatch.ts | 101 ++++- .../packages/mcp/src/builder/events.test.ts | 122 +++++- app/client/packages/mcp/src/builder/events.ts | 113 ++++- .../packages/mcp/src/builder/instructions.ts | 26 +- .../packages/mcp/src/builder/lint.test.ts | 29 ++ app/client/packages/mcp/src/builder/lint.ts | 15 +- app/client/packages/mcp/src/builder/schema.ts | 39 +- .../packages/mcp/src/builder/semantic.test.ts | 52 +++ .../packages/mcp/src/builder/semantic.ts | 41 ++ .../packages/mcp/src/builder/templates.ts | 31 +- 15 files changed, 1512 insertions(+), 89 deletions(-) diff --git a/app/client/packages/mcp/src/app.test.ts b/app/client/packages/mcp/src/app.test.ts index 596ad4e55bbc..1343546669d0 100644 --- a/app/client/packages/mcp/src/app.test.ts +++ b/app/client/packages/mcp/src/app.test.ts @@ -166,6 +166,7 @@ function createApi( listWorkspaces: jest.fn(), updateLayout: jest.fn(), listDatasources: jest.fn(), + createDatasource: jest.fn(), getDatasourceStructure: jest.fn(), getApplicationPages: jest.fn(async () => ({ workspaceId: "ws1" })), listActions: jest.fn(), @@ -999,6 +1000,7 @@ describe("M4 data layer — sub-flag gates the data tools", () => { const names = await toolNames(false); expect(names).not.toContain("list_datasources"); + expect(names).not.toContain("create_datasource"); expect(names).not.toContain("get_datasource_structure"); // The build/authoring tools are still present. expect(names).toContain("build_application"); @@ -1010,6 +1012,7 @@ describe("M4 data layer — sub-flag gates the data tools", () => { const names = await toolNames(true); expect(names).toContain("list_datasources"); + expect(names).toContain("create_datasource"); expect(names).toContain("get_datasource_structure"); expect(names).not.toContain("import_application_artifact"); }); @@ -1048,7 +1051,15 @@ describe("M4 data layer — sub-flag gates the data tools", () => { params: { name, arguments: args }, }); - return JSON.parse(parseJsonRpc(call).result.content[0].text); + const text = parseJsonRpc(call).result.content[0].text; + + // Tools with typed input schemas are rejected by the SDK BEFORE the handler runs; surface that protocol-level + // rejection ("MCP error -32602: Invalid arguments...") as an error body so tests can assert on it. + try { + return JSON.parse(text); + } catch { + return { error: text }; + } } const validQuery = { @@ -1224,6 +1235,238 @@ describe("M4 data layer — sub-flag gates the data tools", () => { expect(createAction).not.toHaveBeenCalled(); }); + it("create_query resolves plugin DOCUMENT ids through the workspace plugin list", async () => { + // Real servers return the plugin's Mongo id on the datasource, not the packageName; the family check must + // resolve it via listPlugins(workspaceId). + const createAction = jest.fn(async () => ({ id: "act1" })); + const listPlugins = jest.fn(async () => [ + { id: "656f00000000000000000001", packageName: "postgres-plugin" }, + ]); + const api: AppsmithApi = { + ...createApi()(), + listDatasources: jest.fn(async () => [ + { id: "ds1", name: "DB", pluginId: "656f00000000000000000001" }, + ]), + listPlugins, + listActions: jest.fn(async () => []), + createAction, + }; + const body = await callTool(api, "create_query", { query: validQuery }); + + expect(body.created).toBe(true); + expect(listPlugins).toHaveBeenCalledWith("ws1"); + expect(createAction).toHaveBeenCalledTimes(1); + }); + + it("create_datasource creates an UNCONFIGURED datasource with no credential fields", async () => { + const createDatasource = jest.fn< + Promise>, + [Record] + >(async () => ({ + id: "ds9", + name: "My Postgres", + pluginId: "656f00000000000000000001", + workspaceId: "ws1", + })); + const api: AppsmithApi = { + ...createApi()(), + listPlugins: jest.fn(async () => [ + { id: "656f00000000000000000001", packageName: "postgres-plugin" }, + ]), + listDatasources: jest.fn(async () => []), + createDatasource, + }; + const body = await callTool(api, "create_datasource", { + workspaceId: "ws1", + name: "My Postgres", + plugin: "postgresql", + connection: { + host: "db.internal", + databaseName: "appdb", + username: "app_user", + }, + }); + + expect(body.created).toBe(true); + expect(body.needsCredentials).toBe(true); + expect(typeof body.nextStep).toBe("string"); + expect(createDatasource).toHaveBeenCalledTimes(1); + + const dto = createDatasource.mock.calls[0][0] as unknown as { + pluginId: string; + datasourceStorages: Record< + string, + { + isConfigured: boolean; + datasourceConfiguration: { + endpoints: { host: string; port: number }[]; + authentication: Record; + }; + } + >; + }; + + // Plugin document id resolved from the packageName; storage keyed by the fixed CE environment. + expect(dto.pluginId).toBe("656f00000000000000000001"); + const storage = dto.datasourceStorages.unused_env; + + expect(storage.isConfigured).toBe(false); + expect(storage.datasourceConfiguration.endpoints).toEqual([ + { host: "db.internal", port: 5432 }, + ]); + // The credential invariant: no password anywhere in the outbound DTO. + expect(JSON.stringify(dto)).not.toContain("password"); + expect(storage.datasourceConfiguration.authentication).toEqual({ + authenticationType: "dbAuth", + databaseName: "appdb", + username: "app_user", + }); + }); + + it("create_datasource maps family default ports, explicit overrides, and omits an absent username", async () => { + const createDatasource = jest.fn< + Promise>, + [Record] + >(async () => ({ id: "ds9" })); + const api: AppsmithApi = { + ...createApi()(), + listPlugins: jest.fn(async () => [ + { id: "p-mysql", packageName: "mysql-plugin" }, + { id: "p-mssql", packageName: "mssql-plugin" }, + ]), + listDatasources: jest.fn(async () => []), + createDatasource, + }; + + // mysql defaults to 3306, and with no username the key is absent (not null/empty). + await callTool(api, "create_datasource", { + workspaceId: "ws1", + name: "MySQL DB", + plugin: "mysql", + connection: { host: "db", databaseName: "appdb" }, + }); + + const mysqlConfig = ( + createDatasource.mock.calls[0][0] as { + datasourceStorages: Record< + string, + { + datasourceConfiguration: { + endpoints: unknown; + authentication: Record; + }; + } + >; + } + ).datasourceStorages.unused_env.datasourceConfiguration; + + expect(mysqlConfig.endpoints).toEqual([{ host: "db", port: 3306 }]); + expect(mysqlConfig.authentication).toEqual({ + authenticationType: "dbAuth", + databaseName: "appdb", + }); + expect("username" in mysqlConfig.authentication).toBe(false); + + // mssql with an explicit port override wins over the family default (1433). + await callTool(api, "create_datasource", { + workspaceId: "ws1", + name: "MSSQL DB", + plugin: "mssql", + connection: { host: "sqlserver", port: 14330, databaseName: "appdb" }, + }); + + const mssqlConfig = ( + createDatasource.mock.calls[1][0] as { + datasourceStorages: Record< + string, + { datasourceConfiguration: { endpoints: unknown } } + >; + } + ).datasourceStorages.unused_env.datasourceConfiguration; + + expect(mssqlConfig.endpoints).toEqual([{ host: "sqlserver", port: 14330 }]); + }); + + it("create_datasource rejects credential/injection input at the schema", async () => { + const createDatasource = jest.fn(); + const api: AppsmithApi = { + ...createApi()(), + listPlugins: jest.fn(async () => [ + { id: "p1", packageName: "postgres-plugin" }, + ]), + listDatasources: jest.fn(async () => []), + createDatasource, + }; + + // Strict schema: a password (or any unknown key) is rejected outright, never forwarded. + const withPassword = await callTool(api, "create_datasource", { + workspaceId: "ws1", + name: "DB", + plugin: "postgresql", + connection: { + host: "db", + databaseName: "appdb", + password: "hunter2", + }, + }); + + expect(JSON.stringify(withPassword)).toMatch(/error|invalid/i); + + const badHost = await callTool(api, "create_datasource", { + workspaceId: "ws1", + name: "DB", + plugin: "postgresql", + connection: { host: "db/../{{evil}}", databaseName: "appdb" }, + }); + + expect(JSON.stringify(badHost)).toMatch(/error|invalid/i); + expect(createDatasource).not.toHaveBeenCalled(); + }); + + it("create_datasource is idempotent by workspace + name", async () => { + const createDatasource = jest.fn(); + const api: AppsmithApi = { + ...createApi()(), + listPlugins: jest.fn(async () => [ + { id: "p1", packageName: "postgres-plugin" }, + ]), + listDatasources: jest.fn(async () => [ + { id: "ds1", name: "My Postgres", pluginId: "p1", workspaceId: "ws1" }, + ]), + createDatasource, + }; + const body = await callTool(api, "create_datasource", { + workspaceId: "ws1", + name: "My Postgres", + plugin: "postgresql", + connection: { host: "db", databaseName: "appdb" }, + }); + + expect(body.created).toBe(false); + expect(createDatasource).not.toHaveBeenCalled(); + }); + + it("create_datasource errors clearly when the plugin is not installed", async () => { + const createDatasource = jest.fn(); + const api: AppsmithApi = { + ...createApi()(), + listPlugins: jest.fn(async () => [ + { id: "p1", packageName: "restapi-plugin" }, + ]), + listDatasources: jest.fn(async () => []), + createDatasource, + }; + const body = await callTool(api, "create_datasource", { + workspaceId: "ws1", + name: "DB", + plugin: "mssql", + connection: { host: "db", databaseName: "appdb" }, + }); + + expect(body.error).toMatch(/not installed/); + expect(createDatasource).not.toHaveBeenCalled(); + }); + it("list_datasources projects to non-secret fields only, even if upstream leaks", async () => { // Simulate a (hypothetical) upstream response that carries credential material — the tool must strip it. const listDatasources = jest.fn(async () => [ @@ -1909,6 +2152,168 @@ describe("governance-wrapped layout mutations", () => { expect(updateLayout).not.toHaveBeenCalled(); }); + it("wire_event writes a primary showAlert action (no entity references to validate)", async () => { + const store = new MemoryGovernanceStore(); + const DSL = { + ...ROOT_DSL, + children: [ + { + widgetId: "b", + widgetName: "InfoBtn", + type: "BUTTON_WIDGET", + topRow: 0, + bottomRow: 4, + leftColumn: 0, + rightColumn: 16, + }, + ], + }; + let current: Record = DSL; + const updateLayout = jest.fn< + Promise<{ ok: boolean }>, + [string, string, string, Record] + >(async (_app, _page, _layout, dsl) => { + current = dsl; + + return { ok: true }; + }); + const api: AppsmithApi = { + ...createApi()(), + getApplicationContext: jest.fn(async () => ({ + pages: [], + page: {}, + layout: { dsl: current }, + })), + // No queries exist anywhere — a showAlert action needs no entity to resolve against. + listActions: jest.fn(async () => []), + updateLayout: updateLayout as never, + }; + const server = createMcpHttpServer(API_BASE_URL, () => api, { + dataEnabled: true, + governance: new McpGovernanceCoordinator(store), + }); + + const wired = await callTool(server, "wire_event", { + applicationId: "app1", + pageId: "p1", + layoutId: "l1", + revision: fingerprintDsl(DSL as never), + spec: { + widget: "InfoBtn", + event: "onClick", + action: { showAlert: "Heads up", style: "warning" }, + }, + }); + + expect(wired.body.changeId).toBeDefined(); + const written = updateLayout.mock.calls[0][3] as { + children: { widgetName: string; onClick?: string }[]; + }; + + expect(written.children[0].onClick).toBe( + "{{ showAlert('Heads up', 'warning') }}", + ); + }); + + it("wire_event validates and compiles a chained onSuccess follow-up (refresh + close + alert)", async () => { + const store = new MemoryGovernanceStore(); + const DSL = { + ...ROOT_DSL, + children: [ + { + widgetId: "b", + widgetName: "SaveBtn", + type: "BUTTON_WIDGET", + topRow: 0, + bottomRow: 4, + leftColumn: 0, + rightColumn: 16, + }, + { + widgetId: "m", + widgetName: "AddUserModal", + type: "MODAL_WIDGET", + topRow: 5, + bottomRow: 20, + leftColumn: 0, + rightColumn: 32, + }, + ], + }; + let current: Record = DSL; + const updateLayout = jest.fn< + Promise<{ ok: boolean }>, + [string, string, string, Record] + >(async (_app, _page, _layout, dsl) => { + current = dsl; + + return { ok: true }; + }); + const listActions = jest.fn(async () => [ + { name: "insertUser", pageId: "p1" }, + { name: "getUsers", pageId: "p1" }, + ]); + const api: AppsmithApi = { + ...createApi()(), + getApplicationContext: jest.fn(async () => ({ + pages: [], + page: {}, + layout: { dsl: current }, + })), + listActions, + updateLayout: updateLayout as never, + }; + const server = createMcpHttpServer(API_BASE_URL, () => api, { + dataEnabled: true, + governance: new McpGovernanceCoordinator(store), + }); + + // A follow-up referencing an unknown query is rejected before any write. + const dangling = await callTool(server, "wire_event", { + applicationId: "app1", + pageId: "p1", + layoutId: "l1", + revision: fingerprintDsl(DSL as never), + spec: { + widget: "SaveBtn", + event: "onClick", + action: { run: "insertUser", onSuccess: [{ run: "ghostQuery" }] }, + }, + }); + + expect(dangling.body.error).toMatch(/"ghostQuery" was not found/); + expect(updateLayout).not.toHaveBeenCalled(); + + const wired = await callTool(server, "wire_event", { + applicationId: "app1", + pageId: "p1", + layoutId: "l1", + revision: fingerprintDsl(DSL as never), + spec: { + widget: "SaveBtn", + event: "onClick", + action: { + run: "insertUser", + onSuccess: [ + { run: "getUsers" }, + { closeModal: "AddUserModal" }, + { showAlert: "Saved", style: "success" }, + ], + }, + }, + }); + + expect(wired.body.changeId).toBeDefined(); + const written = updateLayout.mock.calls[0][3] as { + children: { widgetName: string; onClick?: string }[]; + }; + const button = written.children.find((w) => w.widgetName === "SaveBtn"); + + expect(button?.onClick).toBe( + "{{ insertUser.run().then(() => { getUsers.run(); closeModal('AddUserModal'); showAlert('Saved', 'success'); }) }}", + ); + }); + it("create_js_object resolves plugin/workspace and commits a governed, restricted JS object", async () => { const store = new MemoryGovernanceStore(); const APP = "a".repeat(24); diff --git a/app/client/packages/mcp/src/app.ts b/app/client/packages/mcp/src/app.ts index 11e7eb68a7bf..69d9ad616352 100644 --- a/app/client/packages/mcp/src/app.ts +++ b/app/client/packages/mcp/src/app.ts @@ -20,7 +20,7 @@ import { getCapabilities } from "./builder/capabilities.js"; import { applyEdit, compileApp } from "./builder/compile.js"; import { applyEvent, - eventReference, + eventReferences, widgetExists, wireEventSpecSchema, } from "./builder/events.js"; @@ -219,6 +219,7 @@ export interface AppsmithApi { dsl: Record, ) => Promise; listDatasources: (workspaceId: string) => Promise; + createDatasource: (datasource: Record) => Promise; getDatasourceStructure: (datasourceId: string) => Promise; getApplicationPages: (applicationId: string) => Promise; listActions: (applicationId: string) => Promise; @@ -242,7 +243,8 @@ export interface AppsmithApi { ) => Promise; deletePage: (pageId: string) => Promise; publishApplication: (applicationId: string) => Promise; - listPlugins: () => Promise; + // workspaceId is a REQUIRED request param on GET /api/v1/plugins; omitting it is a 400. + listPlugins: (workspaceId: string) => Promise; listActionCollections: (applicationId: string) => Promise; createActionCollection: (body: Record) => Promise; updateActionCollection: ( @@ -415,6 +417,11 @@ export function createAppsmithApi( request( `/api/v1/datasources?workspaceId=${encodeURIComponent(workspaceId)}`, ), + createDatasource: async (datasource) => + request("/api/v1/datasources", { + method: "POST", + body: JSON.stringify(datasource), + }), getDatasourceStructure: async (datasourceId) => request( `/api/v1/datasources/${encodeURIComponent(datasourceId)}/structure`, @@ -483,7 +490,8 @@ export function createAppsmithApi( `/api/v1/applications/publish/${encodeURIComponent(applicationId)}`, { method: "POST" }, ), - listPlugins: async () => request("/api/v1/plugins"), + listPlugins: async (workspaceId) => + request(`/api/v1/plugins?workspaceId=${encodeURIComponent(workspaceId)}`), listActionCollections: async (applicationId) => request( `/api/v1/collections/actions?applicationId=${encodeURIComponent(applicationId)}`, @@ -536,6 +544,60 @@ const SQL_PLUGIN_IDS = new Set([ ]); const REST_PLUGIN_IDS = new Set(["restapi-plugin"]); +// CE keys every datasource storage under this fixed environment id (FieldNameCE.UNUSED_ENVIRONMENT_ID). +const DEFAULT_ENVIRONMENT_ID = "unused_env"; + +// The closed set of plugin families create_datasource can provision. All three share the endpoints + dbAuth +// configuration shape (verified against each plugin's form.json). Credentials are never part of the vocabulary. +const CREATABLE_DATASOURCE_PLUGINS: Record< + "postgresql" | "mysql" | "mssql", + { packageName: string; defaultPort: number } +> = { + postgresql: { packageName: "postgres-plugin", defaultPort: 5432 }, + mysql: { packageName: "mysql-plugin", defaultPort: 3306 }, + mssql: { packageName: "mssql-plugin", defaultPort: 1433 }, +}; + +function pluginIdByPackageName( + plugins: unknown, + packageName: string, +): string | undefined { + const match = Array.isArray(plugins) + ? plugins.find( + (plugin) => + plugin !== null && + typeof plugin === "object" && + (plugin as { packageName?: unknown }).packageName === packageName, + ) + : undefined; + const id = (match as { id?: unknown } | undefined)?.id; + + return typeof id === "string" ? id : undefined; +} + +function findDatasourceNamed(list: unknown, name: string): unknown { + if (!Array.isArray(list)) return undefined; + + return list.find( + (entry) => + entry !== null && + typeof entry === "object" && + (entry as { name?: unknown }).name === name, + ); +} + +// The credential hand-off: MCP never carries secrets, so the human finishes configuration in the Appsmith UI. This +// is the same flow as importing an app whose datasources arrive unconfigured. +function datasourceNextStep(name: string): { + needsCredentials: true; + nextStep: string; +} { + return { + needsCredentials: true, + nextStep: `Ask the user to open Appsmith -> workspace -> Datasources -> "${name}", enter the database password (and any missing details), then click "Test configuration" and save. Credentials are intentionally never accepted through MCP.`, + }; +} + function projectDatasources(response: unknown): unknown { const project = (entry: unknown) => { if (!entry || typeof entry !== "object") return entry; @@ -567,7 +629,35 @@ function datasourceAccessible(list: unknown, datasourceId: string): boolean { ); } -function isSqlDatasource(list: unknown, datasourceId: string): boolean { +// Datasource.pluginId is the plugin DOCUMENT id (a per-instance Mongo id), not the stable packageName, so family +// checks must resolve the id through the workspace's plugin list. A datasource whose pluginId happens to BE a +// packageName (test fixtures) also resolves, keeping the check robust either way. +function pluginPackageName( + plugins: unknown, + pluginId: unknown, +): string | undefined { + if (typeof pluginId !== "string") return undefined; + + const match = Array.isArray(plugins) + ? plugins.find( + (plugin) => + plugin !== null && + typeof plugin === "object" && + (plugin as { id?: unknown }).id === pluginId, + ) + : undefined; + const packageName = (match as { packageName?: unknown } | undefined) + ?.packageName; + + return typeof packageName === "string" ? packageName : pluginId; +} + +function datasourceInPluginFamily( + list: unknown, + plugins: unknown, + datasourceId: string, + family: Set, +): boolean { if (!Array.isArray(list)) return false; return list.some((entry) => { @@ -575,28 +665,28 @@ function isSqlDatasource(list: unknown, datasourceId: string): boolean { const datasource = entry as { id?: unknown; pluginId?: unknown }; - return ( - datasource.id === datasourceId && - typeof datasource.pluginId === "string" && - SQL_PLUGIN_IDS.has(datasource.pluginId) - ); - }); -} + if (datasource.id !== datasourceId) return false; -function isRestDatasource(list: unknown, datasourceId: string): boolean { - if (!Array.isArray(list)) return false; + const packageName = pluginPackageName(plugins, datasource.pluginId); - return list.some((entry) => { - if (entry === null || typeof entry !== "object") return false; + return packageName !== undefined && family.has(packageName); + }); +} - const datasource = entry as { id?: unknown; pluginId?: unknown }; +function isSqlDatasource( + list: unknown, + plugins: unknown, + datasourceId: string, +): boolean { + return datasourceInPluginFamily(list, plugins, datasourceId, SQL_PLUGIN_IDS); +} - return ( - datasource.id === datasourceId && - typeof datasource.pluginId === "string" && - REST_PLUGIN_IDS.has(datasource.pluginId) - ); - }); +function isRestDatasource( + list: unknown, + plugins: unknown, + datasourceId: string, +): boolean { + return datasourceInPluginFamily(list, plugins, datasourceId, REST_PLUGIN_IDS); } function workspaceIdFromApplicationPages( @@ -1299,7 +1389,7 @@ export function buildMcpServer( server.tool( "wire_event", - "Wire a widget event to a safe action from a CLOSED vocabulary: run a query, navigate to a page, or show/close a modal. Supported events: button onClick, table onRowSelected, modal onClose, tabs onTabSelected. Read the page first and pass its revision. The compiler emits the binding; no raw JS or bindings are accepted.", + "Wire a widget event to a safe action from a CLOSED vocabulary: run a query, navigate to a page, show/close a modal, or show an alert. A run action may chain onSuccess/onError follow-ups from the same vocabulary (e.g. submit -> run insert -> re-run the table's query -> close the modal -> alert). Supported events: button onClick, table onRowSelected, modal onClose, tabs onTabSelected. Read the page first and pass its revision. The compiler emits the binding; no raw JS or bindings are accepted.", { applicationId: idSchema, pageId: idSchema, @@ -1327,33 +1417,46 @@ export function buildMcpServer( return result({ error: "could not read the current page layout" }); } - // Dangling-reference guard: the referenced query/page/widget must exist before we write the trigger. - const reference = eventReference(parsed.data.action); + // Dangling-reference guard: every referenced query/page/widget — the primary action AND any onSuccess/onError + // follow-ups — must exist before we write the trigger. Each backing list is fetched at most once. + const references = eventReferences(parsed.data.action); - if ( - reference.kind === "widget" && - !widgetExists(currentDsl, reference.name) - ) { - return result({ - error: `modal/widget "${reference.name}" was not found on this page`, - }); + for (const reference of references) { + if ( + reference.kind === "widget" && + !widgetExists(currentDsl, reference.name) + ) { + return result({ + error: `modal/widget "${reference.name}" was not found on this page`, + }); + } } - if (reference.kind === "page") { + const pageReferences = references.filter((ref) => ref.kind === "page"); + + if (pageReferences.length > 0) { const pages = projectPages( await api.getApplicationPages(applicationId), ); - if (!pages.some((page) => page.name === reference.name)) { - return result({ error: `page "${reference.name}" was not found` }); + for (const reference of pageReferences) { + if (!pages.some((page) => page.name === reference.name)) { + return result({ error: `page "${reference.name}" was not found` }); + } } } - if (reference.kind === "query" && dataEnabled) { + const queryReferences = references.filter((ref) => ref.kind === "query"); + + if (queryReferences.length > 0 && dataEnabled) { const known = actionNames(await api.listActions(applicationId)); - if (!known.includes(reference.name)) { - return result({ error: `query "${reference.name}" was not found` }); + for (const reference of queryReferences) { + if (!known.includes(reference.name)) { + return result({ + error: `query "${reference.name}" was not found`, + }); + } } } @@ -1944,6 +2047,119 @@ export function buildMcpServer( result(projectDatasources(await api.listDatasources(workspaceId))), ); + server.tool( + "create_datasource", + "Create a database datasource (PostgreSQL, MySQL, or Microsoft SQL Server) in a workspace from non-secret connection details (host/port/database/username). Credentials are NEVER accepted or transmitted by this tool: the datasource is created unconfigured, and the user completes the password in the Appsmith UI (Workspace -> Datasources -> Edit -> Test & Save). Idempotent by workspace + name.", + { + workspaceId: idSchema, + name: z + .string() + .trim() + .min(1) + .max(99) + .regex( + /^[A-Za-z0-9_ -]+$/, + "name must be alphanumeric/underscore/space/dash", + ), + plugin: z.enum(["postgresql", "mysql", "mssql"]), + connection: z + .object({ + host: z + .string() + .trim() + .min(1) + .max(255) + .regex( + /^[A-Za-z0-9_.-]+$/, + "host must be a hostname or IP address", + ), + port: z.number().int().min(1).max(65535).optional(), + databaseName: z + .string() + .trim() + .min(1) + .max(128) + .regex(/^[A-Za-z0-9_-]+$/, "databaseName has unsafe characters"), + username: z + .string() + .trim() + .min(1) + .max(128) + .regex(/^[A-Za-z0-9_.@-]+$/, "username has unsafe characters") + .optional(), + }) + .strict(), + }, + async ({ connection, name, plugin, workspaceId }) => { + const family = CREATABLE_DATASOURCE_PLUGINS[plugin]; + + // Resolve the plugin DOCUMENT id from its stable packageName — plugin ids are per-instance. + const plugins = await api.listPlugins(workspaceId); + const pluginId = pluginIdByPackageName(plugins, family.packageName); + + if (pluginId === undefined) { + return result({ + error: `the ${plugin} plugin is not installed on this instance`, + }); + } + + // Idempotency: a datasource with this name in this workspace is returned, not duplicated. + const existing = findDatasourceNamed( + await api.listDatasources(workspaceId), + name, + ); + + if (existing !== undefined) { + return result({ + created: false, + reason: + "a datasource with this name already exists in this workspace", + datasource: projectDatasources(existing), + ...datasourceNextStep(name), + }); + } + + // Created UNCONFIGURED (isConfigured: false, no credentials) — the same state as importing an app without + // configuring its datasources. CE keys every storage under the fixed "unused_env" environment. + const created = await api.createDatasource({ + name, + workspaceId, + pluginId, + datasourceStorages: { + [DEFAULT_ENVIRONMENT_ID]: { + environmentId: DEFAULT_ENVIRONMENT_ID, + isConfigured: false, + datasourceConfiguration: { + connection: { + mode: "READ_WRITE", + ssl: { authType: "DEFAULT" }, + }, + endpoints: [ + { + host: connection.host, + port: connection.port ?? family.defaultPort, + }, + ], + authentication: { + authenticationType: "dbAuth", + databaseName: connection.databaseName, + ...(connection.username !== undefined + ? { username: connection.username } + : {}), + }, + }, + }, + }, + }); + + return result({ + created: true, + datasource: projectDatasources(created), + ...datasourceNextStep(name), + }); + }, + ); + server.tool( "get_datasource_structure", "Read a datasource's structure (tables/columns) so you can shape queries and bindings. Read-only.", @@ -1992,7 +2208,9 @@ export function buildMcpServer( }); } - if (!isSqlDatasource(datasources, spec.datasourceId)) { + const plugins = await api.listPlugins(workspaceId); + + if (!isSqlDatasource(datasources, plugins, spec.datasourceId)) { return result({ error: "create_query currently supports only MariaDB, Microsoft SQL Server, MySQL, Oracle, PostgreSQL, and Snowflake datasources", @@ -2056,7 +2274,9 @@ export function buildMcpServer( }); } - if (!isRestDatasource(datasources, spec.datasourceId)) { + const plugins = await api.listPlugins(workspaceId); + + if (!isRestDatasource(datasources, plugins, spec.datasourceId)) { return result({ error: "create_rest_api requires an existing REST API datasource", }); @@ -2432,9 +2652,16 @@ export function buildMcpServer( const workspaceId = workspaceIdFromApplicationPages( await api.getApplicationPages(applicationId), ); - const pluginId = jsPluginId(await api.listPlugins()); - if (typeof workspaceId !== "string" || pluginId === undefined) { + if (typeof workspaceId !== "string") { + return result({ + error: "could not resolve the workspace or JS plugin", + }); + } + + const pluginId = jsPluginId(await api.listPlugins(workspaceId)); + + if (pluginId === undefined) { return result({ error: "could not resolve the workspace or JS plugin", }); diff --git a/app/client/packages/mcp/src/builder/builder.test.ts b/app/client/packages/mcp/src/builder/builder.test.ts index c0ebe1d9a5d2..0f1141d1ca79 100644 --- a/app/client/packages/mcp/src/builder/builder.test.ts +++ b/app/client/packages/mcp/src/builder/builder.test.ts @@ -216,6 +216,110 @@ describe("compileApp — import artifact contract", () => { ).toThrow(/nesting/); }); + 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("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("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()); diff --git a/app/client/packages/mcp/src/builder/capabilities.ts b/app/client/packages/mcp/src/builder/capabilities.ts index b9e08410bc60..571a5d36b9c5 100644 --- a/app/client/packages/mcp/src/builder/capabilities.ts +++ b/app/client/packages/mcp/src/builder/capabilities.ts @@ -10,14 +10,20 @@ import { listPresets } from "./presets.js"; export const WIDGET_CATALOG = [ { type: "text", - fields: { text: "plain string (no binding/template syntax)" }, - purpose: "Static text / headings.", + fields: { + text: "plain string (no binding/template syntax)", + source: + "{ table: '', column: '' } — show the selected row's column (detail views). Use text OR source, not both.", + }, + purpose: "Static text / headings, or a bound detail field.", }, { type: "input", fields: { label: "string", inputType: "TEXT | NUMBER | EMAIL | PASSWORD", + defaultValue: + "{ table: '', column: '' } — prefill from the selected row (edit forms)", }, purpose: "Single-line text entry.", }, @@ -157,7 +163,8 @@ export const TOOL_CATALOG: { name: string; gate: ToolGate; summary: string }[] = { name: "wire_event", gate: "always", - summary: "wire a widget event to a safe action", + summary: + "wire a widget event to a safe action (chainable onSuccess/onError)", }, { name: "inspect_page", gate: "always", summary: "lint a live page" }, { @@ -235,6 +242,11 @@ export const TOOL_CATALOG: { name: string; gate: ToolGate; summary: string }[] = }, // Data layer (APPSMITH_MCP_DATA_ENABLED). { name: "list_datasources", gate: "data", summary: "discover datasources" }, + { + name: "create_datasource", + gate: "data", + summary: "create an unconfigured DB datasource (no credentials)", + }, { name: "get_datasource_structure", gate: "data", diff --git a/app/client/packages/mcp/src/builder/editPatch.test.ts b/app/client/packages/mcp/src/builder/editPatch.test.ts index 340195c7169a..35ce37ec437c 100644 --- a/app/client/packages/mcp/src/builder/editPatch.test.ts +++ b/app/client/packages/mcp/src/builder/editPatch.test.ts @@ -68,6 +68,198 @@ describe("applyWidgetPatch", () => { ]); }); + 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: [ diff --git a/app/client/packages/mcp/src/builder/editPatch.ts b/app/client/packages/mcp/src/builder/editPatch.ts index 7695ae4c55cf..d253c5750012 100644 --- a/app/client/packages/mcp/src/builder/editPatch.ts +++ b/app/client/packages/mcp/src/builder/editPatch.ts @@ -1,5 +1,10 @@ import { z } from "zod"; import { ROOT_WIDGET_ID, type WidgetNode } from "./layout.js"; +import { + compileSelectedRowBinding, + selectedRowRefSchema, + type SelectedRowRef, +} from "./schema.js"; const RAW_EXPRESSION = /\{\{|\}\}|\$\{|`/; const MAX_WIDGET_NAME_LENGTH = 64; @@ -34,11 +39,15 @@ const optionSchema = z }) .strict(); -// This is deliberately a closed, literal-only property vocabulary. In particular it excludes events, bindings, -// dynamic path lists, and arbitrary style/configuration fields that could become executable at render time. +// 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(), + source: selectedRowRefSchema.optional(), + defaultValue: selectedRowRefSchema.optional(), label: safeText(200).optional(), inputType: z.enum(["TEXT", "NUMBER", "EMAIL", "PASSWORD"]).optional(), options: z.array(optionSchema).max(200).optional(), @@ -217,6 +226,52 @@ 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); +} + function moveToPosition( node: WidgetNode, position: { topRow: number; leftColumn: number }, @@ -244,7 +299,47 @@ export function applyWidgetPatch( const located = requireWidget(widgets, operation.name); if (operation.kind === "update") { - Object.assign(located.node, operation.props); + // Structured binding refs are compiled (never literal-assigned); everything else is a plain literal. + const { defaultValue, source, ...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"); + } + + if (defaultValue !== undefined && literals.defaultText !== undefined) { + throw new Error( + "cannot set both 'defaultText' and 'defaultValue' in one update", + ); + } + + if (source !== undefined) { + applySelectedRowBinding(widgets, located.node, source, { + widgetType: "TEXT_WIDGET", + property: "text", + field: "source", + }); + } + + if (defaultValue !== undefined) { + applySelectedRowBinding(widgets, located.node, defaultValue, { + widgetType: "INPUT_WIDGET_V2", + property: "defaultText", + field: "defaultValue", + }); + } + + 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"); + } + changes.push({ kind: "update", widgetName: operation.name, diff --git a/app/client/packages/mcp/src/builder/events.test.ts b/app/client/packages/mcp/src/builder/events.test.ts index 11dfed2ce0a1..b719b965184d 100644 --- a/app/client/packages/mcp/src/builder/events.test.ts +++ b/app/client/packages/mcp/src/builder/events.test.ts @@ -1,7 +1,7 @@ import { applyEvent, compileEventBinding, - eventReference, + eventReferences, widgetExists, wireEventSpecSchema, } from "./events.js"; @@ -48,6 +48,41 @@ describe("compileEventBinding — closed vocabulary", () => { 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') }}", + ); + }); + + 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 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'); }) }}"); }); }); @@ -64,6 +99,32 @@ describe("wireEventSpecSchema — rejects anything unsafe", () => { { 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) => { @@ -79,6 +140,24 @@ describe("wireEventSpecSchema — rejects anything unsafe", () => { }).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", () => { @@ -165,17 +244,38 @@ describe("applyEvent", () => { }); }); -describe("eventReference / widgetExists", () => { +describe("eventReferences / widgetExists", () => { it("returns the referenced entity for validation", () => { - expect(eventReference({ run: "q" })).toEqual({ kind: "query", name: "q" }); - expect(eventReference({ navigate: "Home" })).toEqual({ - kind: "page", - name: "Home", - }); - expect(eventReference({ showModal: "M" })).toEqual({ - kind: "widget", - name: "M", - }); + 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", () => { diff --git a/app/client/packages/mcp/src/builder/events.ts b/app/client/packages/mcp/src/builder/events.ts index d232da1c1e49..bcb4a68902ce 100644 --- a/app/client/packages/mcp/src/builder/events.ts +++ b/app/client/packages/mcp/src/builder/events.ts @@ -22,12 +22,45 @@ const pageName = z .max(64) .regex(/^[A-Za-z0-9_ ]+$/, "must be a safe page name"); -// Exactly one action per event. -export const eventActionSchema = z.union([ +// 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"]); + +// A follow-up step inside a run's onSuccess/onError callback: the same closed kinds plus showAlert. Follow-ups can +// never nest further (no callbacks on callbacks), so the compiled binding stays one bounded promise 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(), +]); + +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(), ]); export type EventAction = z.infer; @@ -50,31 +83,77 @@ export const wireEventSpecSchema = z export type WireEventSpec = z.infer; -// The single binding emitter. Every argument is a validated identifier / safe page name. -export function compileEventBinding(action: EventAction): string { - if ("run" in action) return `{{ ${action.run}.run() }}`; +// 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') }}`; + return `navigateTo('${action.navigate}', {}, 'SAME_WINDOW')`; } - if ("showModal" in action) return `{{ showModal('${action.showModal}') }}`; + if ("showModal" in action) return `showModal('${action.showModal}')`; - return `{{ closeModal('${action.closeModal}') }}`; + if ("closeModal" in action) return `closeModal('${action.closeModal}')`; + + return `showAlert('${action.showAlert}', '${action.style ?? "info"}')`; } -// The entity an event references, so the caller can verify it exists before writing (dangling-reference guard). -export function eventReference(action: EventAction): { - kind: "query" | "page" | "widget"; - name: string; -} { - if ("run" in action) return { kind: "query", name: action.run }; +// The single binding emitter. A chained run compiles to one bounded promise expression: +// `{{ Q.run().then(() => { ...; }).catch(() => { ...; }) }}`. +export function compileEventBinding(action: EventAction): string { + if (!("run" in action)) return `{{ ${compileStatement(action)} }}`; - if ("navigate" in action) return { kind: "page", name: action.navigate }; + let expression = `${action.run}.run()`; + + if (action.onSuccess !== undefined) { + const steps = action.onSuccess.map(compileStatement).join("; "); + + expression += `.then(() => { ${steps}; })`; + } - if ("showModal" in action) return { kind: "widget", name: action.showModal }; + if (action.onError !== undefined) { + const steps = action.onError.map(compileStatement).join("; "); - return { kind: "widget", name: action.closeModal }; + expression += `.catch(() => { ${steps}; })`; + } + + return `{{ ${expression} }}`; +} + +// Every entity an event references — the primary action plus all follow-ups — so the caller can verify each exists +// before writing (dangling-reference guard). showAlert references nothing. +export function eventReferences(action: EventAction): { + 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 }]; + } + + return []; + }; + + if (!("run" in action)) return single(action); + + return [ + { kind: "query" as const, name: action.run }, + ...(action.onSuccess ?? []).flatMap(single), + ...(action.onError ?? []).flatMap(single), + ]; } function cloneJson(value: T): T { diff --git a/app/client/packages/mcp/src/builder/instructions.ts b/app/client/packages/mcp/src/builder/instructions.ts index 5c879912414d..c91d359c4a8a 100644 --- a/app/client/packages/mcp/src/builder/instructions.ts +++ b/app/client/packages/mcp/src/builder/instructions.ts @@ -75,7 +75,17 @@ evaluated as code in a viewer's browser. - Run a query on a button click: \`{ "type": "button", "onClick": { "run": "insertRow" } }\`. The query name must be a plain identifier; the compiler emits the safe binding for you. These work when the data layer is enabled on the instance (list_datasources / get_datasource_structure - help you discover what to query). A table sets \`data\` OR \`source\`, never both.`; + help you discover what to query). A table sets \`data\` OR \`source\`, never both. +- Display bindings (selected row): show or prefill from the table's selected row through the + same structured-reference rule: + - Detail text: \`{ "type": "text", "source": { "table": "Users", "column": "email" } }\`. + - Edit-form prefill: \`{ "type": "input", "defaultValue": { "table": "Users", "column": "email" } }\`. + Both are also available as \`patch_widgets\` update props (\`source\` on text, \`defaultValue\` on + input) to upgrade an existing page. A text sets \`text\` OR \`source\`, never both. +- Event chains: \`wire_event\` runs one action per event, and a \`run\` action may chain + \`onSuccess\`/\`onError\` follow-ups (run another query, close a modal, navigate, show an alert): + \`{ "run": "insertUser", "onSuccess": [{ "run": "getUsers" }, { "closeModal": "AddUserModal" }, + { "showAlert": "Saved", "style": "success" }] }\` — the canonical submit → refresh → close flow.`; export const GUIDES: InstructionDoc[] = [ { @@ -114,10 +124,16 @@ Goal: a table of records with a details form to view/edit a selected row. 6. \`inspect_page\` — verify no overlaps/off-grid/clipped-container issues; \`edit_page\` to fix. To make it live (when the data layer is enabled): -7. \`list_datasources\` then \`get_datasource_structure\` — find your DB and its tables/columns. -8. \`create_query\` — e.g. a SELECT over your table (structured spec, no raw SQL). -9. \`edit_page\` — set the table's \`source\` to { query: '' } and a button's \`onClick\` - to { run: '' }. The compiler emits the safe bindings for you.`; +7. \`list_datasources\` — find your DB. If none exists yet, \`create_datasource\` provisions an + unconfigured PostgreSQL/MySQL/SQL Server datasource from host/port/database (no credentials + pass through MCP — ask the user to enter the password in Appsmith and Test & Save). +8. \`get_datasource_structure\` — read its tables/columns. +9. \`create_query\` — e.g. a SELECT plus an UPDATE over your table (structured spec, no raw SQL). +10. \`edit_page\` / \`patch_widgets\` — bind the table (\`source\` = { query: '' }), prefill the + form inputs from the selection (\`defaultValue\` = { table: '', column: '' }), and + show read-only detail text (\`source\` = { table, column }). +11. \`wire_event\` — wire the save button: { run: '', onSuccess: [{ run: '' }, + { showAlert: 'Saved', style: 'success' }] } so the table refreshes after the write.`; const RECIPE_FORM = `# Recipe: Form page diff --git a/app/client/packages/mcp/src/builder/lint.test.ts b/app/client/packages/mcp/src/builder/lint.test.ts index 556e57df6d81..97f33ff2b006 100644 --- a/app/client/packages/mcp/src/builder/lint.test.ts +++ b/app/client/packages/mcp/src/builder/lint.test.ts @@ -169,6 +169,35 @@ describe("lintDsl — dangling-binding check is dormant until M4", () => { expect(diagnostics.errors).toBe(0); }); + + it("treats widget names as valid binding heads (selected-row display bindings)", () => { + // The text widget references the table by widget name — and the table sits AFTER it in the tree, so this also + // proves bindings are resolved against the complete name set, not traversal order. + const dsl = canvas([ + widget({ + widgetId: "t", + widgetName: "EmailDetail", + text: '{{ Users.selectedRow["email"] }}', + }), + widget({ widgetId: "u", widgetName: "Users" }), + ]); + const diagnostics = lintDsl(dsl, { knownDataNames: ["getUsers"] }); + + expect(diagnostics.errors).toBe(0); + }); + + it("still flags a selected-row binding whose table does not exist", () => { + const dsl = canvas([ + widget({ + widgetId: "t", + widgetName: "EmailDetail", + text: '{{ Missing.selectedRow["email"] }}', + }), + ]); + const diagnostics = lintDsl(dsl, { knownDataNames: ["getUsers"] }); + + expect(diagnostics.issues.map((i) => i.rule)).toContain("dangling-binding"); + }); }); describe("lintArtifact — compiler output is lint-clean", () => { diff --git a/app/client/packages/mcp/src/builder/lint.ts b/app/client/packages/mcp/src/builder/lint.ts index 9fc41835f277..c59b7604a193 100644 --- a/app/client/packages/mcp/src/builder/lint.ts +++ b/app/client/packages/mcp/src/builder/lint.ts @@ -95,6 +95,9 @@ class Linter { } this.walk(root); + // Bindings are checked in a second pass so widget-head references (e.g. `{{ Table1.selectedRow[...] }}`) can be + // resolved against the COMPLETE name set, wherever the referenced widget sits in the tree. + this.walkBindings(root); this.checkDuplicateNames(); return this.result(); @@ -102,7 +105,6 @@ class Linter { private walk(node: WidgetNode): void { this.checkIdentity(node); - this.checkBindings(node); // Geometry checks apply to the direct children laid out on a canvas. if (node.type === CANVAS_TYPE) this.checkCanvasChildren(node); @@ -113,6 +115,12 @@ class Linter { for (const child of node.children ?? []) this.walk(child); } + private walkBindings(node: WidgetNode): void { + this.checkBindings(node); + + for (const child of node.children ?? []) this.walkBindings(child); + } + private checkIdentity(node: WidgetNode): void { const name = nameOf(node); @@ -239,11 +247,12 @@ class Linter { const reference = head[1]; - // Only flag when we have a known-name set to check against (M4). `appsmith`/`moment`/JS globals are always - // considered valid heads and skipped. + // Only flag when we have a known-name set to check against (M4). `appsmith`/`moment`/JS globals and widget + // names on this page (display bindings like `{{ Table1.selectedRow[...] }}`) are always valid heads. if ( this.knownDataNames.size > 0 && !this.knownDataNames.has(reference) && + !this.seenNames.has(reference) && !GLOBAL_BINDING_HEADS.has(reference) ) { this.error( diff --git a/app/client/packages/mcp/src/builder/schema.ts b/app/client/packages/mcp/src/builder/schema.ts index 73d43d483db5..d0f695a75446 100644 --- a/app/client/packages/mcp/src/builder/schema.ts +++ b/app/client/packages/mcp/src/builder/schema.ts @@ -95,6 +95,30 @@ const bindingIdentifier = z const queryRef = z.object({ query: bindingIdentifier }).strict(); const runRef = z.object({ run: bindingIdentifier }).strict(); +// A structured reference to one column of a table's selected row — the display-binding half of the CRUD loop +// (detail views, edit-form prefill). Same charset as table column keys; the compiler emits bracket access with +// double quotes (`{{ Table1.selectedRow["col name"] }}`), and the charset admits no quote/backslash/brace, so the +// emitted expression cannot be broken out of. +const selectedRowColumn = z + .string() + .min(1) + .max(100) + .regex( + /^[A-Za-z0-9_ ]+$/, + "column names must be alphanumeric, underscore, or space", + ); + +export const selectedRowRefSchema = z + .object({ table: bindingIdentifier, column: selectedRowColumn }) + .strict(); + +export type SelectedRowRef = z.infer; + +// The single emitter for selected-row display bindings. Both parts are schema-validated charsets. +export function compileSelectedRowBinding(ref: SelectedRowRef): string { + return `{{ ${ref.table}.selectedRow["${ref.column}"] }}`; +} + // Chart series: a named series of {x,y} points. Static data (x label is safe text, y is a number). const chartPoint = z .object({ x: z.union([safeText(200), z.number()]), y: z.number() }) @@ -119,6 +143,10 @@ export const widgetSpecSchema: z.ZodType = z.lazy(() => type: z.literal("text"), name: nameField, text: safeText(10000).optional(), + // Display binding: show one column of a table's selected row (detail views). Compiler-emitted; mutually + // exclusive with static `text` (enforced in the template — a .refine here would break the discriminated + // union, same as table data/source). + source: selectedRowRefSchema.optional(), placement: placementSchema.optional(), }) .strict(), @@ -128,6 +156,8 @@ export const widgetSpecSchema: z.ZodType = z.lazy(() => name: nameField, label: safeText(200).optional(), inputType: z.enum(["TEXT", "NUMBER", "EMAIL", "PASSWORD"]).optional(), + // Display binding: prefill the input from a table's selected row (edit forms). Compiler-emitted. + defaultValue: selectedRowRefSchema.optional(), placement: placementSchema.optional(), }) .strict(), @@ -266,12 +296,19 @@ export type TableCell = string | number | boolean | null; export type TableRow = Record; export type WidgetSpec = - | { type: "text"; name?: string; text?: string; placement?: PlacementSpec } + | { + type: "text"; + name?: string; + text?: string; + source?: SelectedRowRef; + placement?: PlacementSpec; + } | { type: "input"; name?: string; label?: string; inputType?: "TEXT" | "NUMBER" | "EMAIL" | "PASSWORD"; + defaultValue?: SelectedRowRef; placement?: PlacementSpec; } | { diff --git a/app/client/packages/mcp/src/builder/semantic.test.ts b/app/client/packages/mcp/src/builder/semantic.test.ts index 6ecd13ac6c79..eb4cc8a40c3d 100644 --- a/app/client/packages/mcp/src/builder/semantic.test.ts +++ b/app/client/packages/mcp/src/builder/semantic.test.ts @@ -109,6 +109,58 @@ describe("projectSemanticPage", () => { expect(JSON.stringify(widget)).not.toContain("dynamicBindingPathList"); expect(JSON.stringify(widget)).not.toContain("secretConfiguration"); }); + + it("surfaces compiler-emitted bindings as structured refs (round-trip), never raw expressions", () => { + const dsl = node({ + widgetId: "root", + widgetName: "MainContainer", + type: "CANVAS_WIDGET", + children: [ + node({ + widgetId: "detail", + widgetName: "EmailDetail", + type: "TEXT_WIDGET", + text: '{{ Users.selectedRow["user email"] }}', + }), + node({ + widgetId: "input", + widgetName: "EmailInput", + type: "INPUT_WIDGET_V2", + defaultText: '{{ Users.selectedRow["email"] }}', + }), + node({ + widgetId: "table", + widgetName: "Users", + type: "TABLE_WIDGET_V2", + tableData: "{{ getUsers.data }}", + }), + node({ + widgetId: "handwritten", + widgetName: "Custom", + type: "TEXT_WIDGET", + // NOT a compiler shape — must stay hidden entirely. + text: "{{ appsmith.store.secret + Users.selectedRow.x }}", + }), + ], + }); + const widgets = projectSemanticPage(dsl).widgets; + const byName = new Map(widgets.map((widget) => [widget.name, widget])); + + expect(byName.get("EmailDetail")?.bindings).toEqual({ + text: { table: "Users", column: "user email" }, + }); + expect(byName.get("EmailInput")?.bindings).toEqual({ + defaultText: { table: "Users", column: "email" }, + }); + expect(byName.get("Users")?.bindings).toEqual({ + tableData: { query: "getUsers" }, + }); + // The arbitrary expression is neither surfaced as a binding nor leaked as a prop. + expect(byName.get("Custom")?.bindings).toBeUndefined(); + expect(JSON.stringify(byName.get("Custom"))).not.toContain( + "appsmith.store", + ); + }); }); describe("DSL fingerprints", () => { diff --git a/app/client/packages/mcp/src/builder/semantic.ts b/app/client/packages/mcp/src/builder/semantic.ts index d4868fbb55fe..4869ab049852 100644 --- a/app/client/packages/mcp/src/builder/semantic.ts +++ b/app/client/packages/mcp/src/builder/semantic.ts @@ -50,6 +50,14 @@ export interface SemanticGeometry { rightColumn: number; } +// A structured, safe reflection of a COMPILER-EMITTED binding, recovered from the DSL so the read path round-trips +// what the write path created (an agent can see "this text shows Users.email" without ever seeing raw expressions). +export interface SemanticBindingRef { + table?: string; + column?: string; + query?: string; +} + export interface SemanticWidget { id: string; name: string; @@ -58,6 +66,7 @@ export interface SemanticWidget { parentWidgetName?: string; geometry: SemanticGeometry; props: Partial>; + bindings?: Record; } export interface SemanticPage { @@ -122,6 +131,36 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +// Exact shapes the compilers emit — and ONLY those. Anything else (human-authored or arbitrary expressions) stays +// hidden, preserving the projection's no-raw-bindings guarantee. +const SELECTED_ROW_BINDING = + /^\{\{ ([A-Za-z0-9_]+)\.selectedRow\["([A-Za-z0-9_ ]+)"\] \}\}$/; +const QUERY_DATA_BINDING = /^\{\{ ([A-Za-z0-9_]+)\.data \}\}$/; + +function safeBindings( + node: WidgetNode, +): Record | undefined { + const bindings: Record = {}; + + for (const key of ["text", "defaultText"] as const) { + const value = node[key]; + + if (typeof value !== "string") continue; + + const match = SELECTED_ROW_BINDING.exec(value); + + if (match) bindings[key] = { table: match[1], column: match[2] }; + } + + if (typeof node.tableData === "string") { + const match = QUERY_DATA_BINDING.exec(node.tableData); + + if (match) bindings.tableData = { query: match[1] }; + } + + return Object.keys(bindings).length > 0 ? bindings : undefined; +} + function semanticGeometry(node: WidgetNode): SemanticGeometry { return { topRow: node.topRow, @@ -138,6 +177,7 @@ export function projectSemanticPage(dsl: WidgetNode): SemanticPage { function visit(node: WidgetNode, parentWidgetName?: string): void { const catalogType = CATALOG_TYPE_BY_APPSMITH_TYPE[node.type]; + const bindings = safeBindings(node); widgets.push({ id: node.widgetId, @@ -147,6 +187,7 @@ export function projectSemanticPage(dsl: WidgetNode): SemanticPage { ...(parentWidgetName !== undefined ? { parentWidgetName } : {}), geometry: semanticGeometry(node), props: safeProps(node), + ...(bindings !== undefined ? { bindings } : {}), }); for (const child of node.children ?? []) visit(child, node.widgetName); diff --git a/app/client/packages/mcp/src/builder/templates.ts b/app/client/packages/mcp/src/builder/templates.ts index c9c69d638019..644a3f92f750 100644 --- a/app/client/packages/mcp/src/builder/templates.ts +++ b/app/client/packages/mcp/src/builder/templates.ts @@ -1,4 +1,8 @@ -import type { WidgetSpec, WidgetType } from "./schema.js"; +import { + compileSelectedRowBinding, + type WidgetSpec, + type WidgetType, +} from "./schema.js"; // Curated widget templates. Each maps a high-level spec widget to a valid Appsmith DSL node: the Appsmith widget // `type`, its DSL `version`, a default grid footprint (in 64-column grid units), the base default props, and a @@ -32,7 +36,19 @@ export const WIDGET_TEMPLATES: Record = { version: 1, footprint: { columns: 24, rows: 4 }, build: (spec) => { - const text = spec.type === "text" ? spec.text ?? "Text" : "Text"; + const source = spec.type === "text" ? spec.source : undefined; + const staticText = spec.type === "text" ? spec.text : undefined; + + if (source !== undefined && staticText !== undefined) { + throw new Error("text cannot set both 'text' and 'source'"); + } + + // Two safe origins: a static literal, or a compiler-emitted selected-row binding (detail views). The binding + // is registered as a dynamic path; the static literal never is. + const bound = source !== undefined; + const text = bound + ? compileSelectedRowBinding(source) + : staticText ?? "Text"; return { footprint: { columns: 24, rows: 4 }, @@ -46,6 +62,7 @@ export const WIDGET_TEMPLATES: Record = { overflow: "NONE", animateLoading: true, responsiveBehavior: "fill", + dynamicBindingPathList: bound ? [{ key: "text" }] : [], }, }; }, @@ -59,6 +76,13 @@ export const WIDGET_TEMPLATES: Record = { const label = spec.type === "input" ? spec.label ?? "Label" : "Label"; const inputType = spec.type === "input" ? spec.inputType ?? "TEXT" : "TEXT"; + const defaultValue = + spec.type === "input" ? spec.defaultValue : undefined; + + // Edit-form prefill: a compiler-emitted selected-row binding as the default value, registered as a dynamic + // path. Unbound inputs keep a plain empty default. + const bound = defaultValue !== undefined; + const defaultText = bound ? compileSelectedRowBinding(defaultValue) : ""; return { footprint: { columns: 24, rows: 7 }, @@ -69,13 +93,14 @@ export const WIDGET_TEMPLATES: Record = { labelAlignment: "left", labelTextSize: "0.875rem", labelWidth: 5, - defaultText: "", + defaultText, isRequired: false, isDisabled: false, resetOnSubmit: true, showStepArrows: false, animateLoading: true, responsiveBehavior: "fill", + dynamicBindingPathList: bound ? [{ key: "defaultText" }] : [], }, }; }, From bfeba6dbe345aa382c6c8b46c5ee820ce287a609 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Mon, 13 Jul 2026 12:41:26 -0400 Subject: [PATCH 13/81] feat(mcp): enable MCP by default with an Admin Settings toggle Adds an admin-visible on/off switch for the MCP server (previously env-var only) and flips the default to enabled, matching the original plan. Council re-run: security/architect/qa all approve. Admin Settings -> Configuration gains three checkboxes: - MCP server (APPSMITH_MCP_ENABLED, default on) - MCP data tools (APPSMITH_MCP_DATA_ENABLED, default off) - MCP JS objects (APPSMITH_MCP_JS_ENABLED, default off) They save through the existing /admin/env flow (the three vars are added to the EnvVariables whitelist), and the admin restart now includes the "mcp" program so a Save & Restart re-evaluates the gates. Toggle is a real kill switch, not just a route change: - SecurityConfig gates the MCP bearer-auth filter on APPSMITH_MCP_ENABLED, so disabling MCP rejects already-issued mcp_ tokens (401) server-side instead of leaving them valid as full API credentials until expiry. - Deploy: the mcp supervisord program is always installed; run-mcp.sh parks (sleep infinity) when explicitly disabled; caddy re-evaluates the /mcp route on restart; healthcheck probes MCP only when enabled; docker.env.sh seeds the gates and entrypoint backfills them for existing installs. Disable spelling is false/0/no/off (any case). Admin UI defaulting: a new Setting.defaultValue lets an env-backed toggle render from its declared default when the variable is absent from the fetched settings, so the checkbox mirrors the runtime default (MCP server shows checked) instead of falling back to unchecked. Server MCP gate parser accepts the "true"/"false" the UI writes (was "1"-only); extracted to a tested gates module. Verified: mcp tsc clean, 306 passed / 4 skipped / 0 failed; client check-types (node 24) exit 0; eslint 0 errors; prettier clean; spotless applied. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../packages/mcp/src/deploy-gating.test.ts | 72 ++++++++++++++++--- app/client/packages/mcp/src/gates.test.ts | 23 ++++++ app/client/packages/mcp/src/gates.ts | 7 ++ app/client/packages/mcp/src/server.ts | 6 +- .../AdminSettings/config/configuration.tsx | 36 ++++++++++ .../ce/pages/AdminSettings/config/types.ts | 3 + .../src/pages/AdminSettings/SettingsForm.tsx | 13 ++++ .../server/configurations/SecurityConfig.java | 17 +++++ .../server/constants/EnvVariables.java | 7 +- .../server/solutions/ce/EnvManagerCEImpl.java | 5 +- contributions/ServerSetup.md | 8 +-- .../fs/opt/appsmith/caddy-reconfigure.mjs | 5 +- deploy/docker/fs/opt/appsmith/entrypoint.sh | 19 +++-- deploy/docker/fs/opt/appsmith/healthcheck.sh | 13 +++- deploy/docker/fs/opt/appsmith/run-mcp.sh | 10 +++ .../fs/opt/appsmith/templates/docker.env.sh | 8 +++ 16 files changed, 225 insertions(+), 27 deletions(-) create mode 100644 app/client/packages/mcp/src/gates.test.ts create mode 100644 app/client/packages/mcp/src/gates.ts diff --git a/app/client/packages/mcp/src/deploy-gating.test.ts b/app/client/packages/mcp/src/deploy-gating.test.ts index a27f9a10d797..a021385f997d 100644 --- a/app/client/packages/mcp/src/deploy-gating.test.ts +++ b/app/client/packages/mcp/src/deploy-gating.test.ts @@ -1,8 +1,9 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; -// K.5 — deploy-time gating guard. The MCP route and process must remain opt-in behind APPSMITH_MCP_ENABLED. Full -// routing/health behaviour is a Docker integration test; this guards against the gate being silently removed. +// K.5 — deploy-time gating guard. The MCP route and process are ON BY DEFAULT and admin-toggleable via +// APPSMITH_MCP_ENABLED (Admin Settings -> Configuration); explicitly disabling it must drop the route and park the +// process. Full routing/health behaviour is a Docker integration test; this guards the gate wiring itself. const REPO_ROOT = resolve(__dirname, "../../../../.."); @@ -10,14 +11,14 @@ function readDeployFile(relativePath: string): string { return readFileSync(resolve(REPO_ROOT, relativePath), "utf8"); } -describe("Caddy MCP routing is gated behind APPSMITH_MCP_ENABLED", () => { +describe("Caddy MCP routing defaults on and honors an explicit disable", () => { const caddy = readDeployFile( "deploy/docker/fs/opt/appsmith/caddy-reconfigure.mjs", ); - it("derives the MCP flag from APPSMITH_MCP_ENABLED === '1'", () => { + it("treats MCP as enabled unless APPSMITH_MCP_ENABLED is explicitly false/0/no/off", () => { expect(caddy).toMatch( - /isMcpEnabled\s*=\s*process\.env\.APPSMITH_MCP_ENABLED\s*===\s*["']1["']/, + /isMcpEnabled\s*=\s*!\/\^\(false\|0\|no\|off\)\$\/i\.test\(\s*process\.env\.APPSMITH_MCP_ENABLED\s*\?\?\s*""\s*,?\s*\)/, ); }); @@ -27,21 +28,72 @@ describe("Caddy MCP routing is gated behind APPSMITH_MCP_ENABLED", () => { }); }); -describe("supervisord and healthcheck keep MCP opt-in", () => { - it("entrypoint removes the MCP program when APPSMITH_MCP_ENABLED is not 1", () => { +describe("supervisord, run script, and healthcheck keep MCP default-on and toggleable", () => { + it("entrypoint always installs the MCP program (toggle handled by run-mcp.sh)", () => { const entrypoint = readDeployFile( "deploy/docker/fs/opt/appsmith/entrypoint.sh", ); - expect(entrypoint).toContain("APPSMITH_MCP_ENABLED"); - expect(entrypoint).toMatch(/mcp\.conf/); + // The old conditional `rm mcp.conf` must NOT return: removing the program at boot would make the + // Admin Settings toggle require a full container restart to re-enable. + expect(entrypoint).not.toMatch(/rm\s+-f\s+"?\$?\{?SUPERVISORD[^\n]*mcp/); + // Existing installs get the gates backfilled into docker.env so the Admin UI mirrors reality. + expect(entrypoint).toMatch(/APPSMITH_MCP_ENABLED=true/); + expect(entrypoint).toMatch(/APPSMITH_MCP_DATA_ENABLED=false/); + expect(entrypoint).toMatch(/APPSMITH_MCP_JS_ENABLED=false/); }); - it("healthcheck only probes MCP when its program is present", () => { + it("run-mcp.sh parks (never crash-loops) when explicitly disabled and runs by default", () => { + const runMcp = readDeployFile("deploy/docker/fs/opt/appsmith/run-mcp.sh"); + + expect(runMcp).toMatch(/APPSMITH_MCP_ENABLED:-true/); + expect(runMcp).toMatch(/\(false\|0\|no\|off\)/); + expect(runMcp).toContain("exec sleep infinity"); + expect(runMcp).toContain("exec node --enable-source-maps"); + }); + + it("docker.env template seeds the gates (server on, sub-features off)", () => { + const dockerEnv = readDeployFile( + "deploy/docker/fs/opt/appsmith/templates/docker.env.sh", + ); + + expect(dockerEnv).toContain("APPSMITH_MCP_ENABLED=true"); + expect(dockerEnv).toContain("APPSMITH_MCP_DATA_ENABLED=false"); + expect(dockerEnv).toContain("APPSMITH_MCP_JS_ENABLED=false"); + }); + + it("healthcheck probes the MCP endpoint only when the gate is enabled", () => { const healthcheck = readDeployFile( "deploy/docker/fs/opt/appsmith/healthcheck.sh", ); expect(healthcheck).toMatch(/supervisorctl status .*\|\s*grep .*mcp/); + // The probe is conditional on the enablement gate, not just program presence (a parked program is RUNNING). + expect(healthcheck).toMatch( + /"\$process"\s*==\s*"mcp"\s*&&\s*"\$mcp_enabled"\s*==\s*"true"/, + ); + }); + + it("the admin restart command includes the mcp program so toggles apply", () => { + const envManager = readDeployFile( + "app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java", + ); + + expect(envManager).toMatch( + /"supervisorctl",\s*"restart",\s*"backend",\s*"editor",\s*"rts",\s*"mcp",?/, + ); + }); + + it("SecurityConfig gates MCP bearer auth on APPSMITH_MCP_ENABLED (real kill switch)", () => { + const securityConfig = readDeployFile( + "app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java", + ); + + // The env flag is read (default on) and the auth filter's matcher short-circuits when disabled, so disabling + // MCP rejects already-issued mcp_ tokens server-side rather than only dropping the Caddy route. + expect(securityConfig).toMatch( + /@Value\("\$\{APPSMITH_MCP_ENABLED:true\}"\)/, + ); + expect(securityConfig).toMatch(/if\s*\(!mcpEnabled\)/); }); }); diff --git a/app/client/packages/mcp/src/gates.test.ts b/app/client/packages/mcp/src/gates.test.ts new file mode 100644 index 000000000000..81a417cf406f --- /dev/null +++ b/app/client/packages/mcp/src/gates.test.ts @@ -0,0 +1,23 @@ +import { gateEnabled } from "./gates.js"; + +describe("gateEnabled — opt-in gate parsing (data/JS layers)", () => { + // The Admin Settings UI writes "true"/"false"; operators historically used "1"/"0". Regression guard: the old + // implementation was `=== "1"`, which would silently ignore the "true" the UI now writes. + it.each(["1", "true", "TRUE", "True", "yes", "on", " true "])( + "enables for truthy value %j", + (value) => { + expect(gateEnabled(value)).toBe(true); + }, + ); + + it.each(["0", "false", "FALSE", "no", "off", "", " ", "disabled", "2"])( + "stays off for %j", + (value) => { + expect(gateEnabled(value)).toBe(false); + }, + ); + + it("stays off when the variable is unset", () => { + expect(gateEnabled(undefined)).toBe(false); + }); +}); diff --git a/app/client/packages/mcp/src/gates.ts b/app/client/packages/mcp/src/gates.ts new file mode 100644 index 000000000000..051f4482f472 --- /dev/null +++ b/app/client/packages/mcp/src/gates.ts @@ -0,0 +1,7 @@ +// Env-gate parsing shared by the MCP entrypoint. The Admin Settings UI writes "true"/"false"; operators may also +// use "1"/"0". These are opt-IN gates (data layer, restricted JS): enabled only for an explicit truthy value, so a +// missing or unrecognized value stays off. The master APPSMITH_MCP_ENABLED gate is default-ON and handled separately +// (deny-list) in the deploy scripts and the server-side SecurityConfig. +export function gateEnabled(value: string | undefined): boolean { + return value !== undefined && /^(1|true|yes|on)$/i.test(value.trim()); +} diff --git a/app/client/packages/mcp/src/server.ts b/app/client/packages/mcp/src/server.ts index 57bd889177d4..2881e3bc66c1 100644 --- a/app/client/packages/mcp/src/server.ts +++ b/app/client/packages/mcp/src/server.ts @@ -1,5 +1,6 @@ import type { Server } from "node:http"; import { createMcpHttpServer } from "./app.js"; +import { gateEnabled } from "./gates.js"; import { McpGovernanceCoordinator } from "./governance/coordinator.js"; import { createGovernanceStoreFromEnv, @@ -8,9 +9,10 @@ import { const port = Number(process.env.APPSMITH_MCP_PORT ?? 8092); const apiBaseUrl = process.env.APPSMITH_API_BASE_URL ?? "http://127.0.0.1:8080"; + // The data layer and restricted JS objects are independent opt-ins on top of APPSMITH_MCP_ENABLED. Default off. -const dataEnabled = process.env.APPSMITH_MCP_DATA_ENABLED === "1"; -const jsEnabled = process.env.APPSMITH_MCP_JS_ENABLED === "1"; +const dataEnabled = gateEnabled(process.env.APPSMITH_MCP_DATA_ENABLED); +const jsEnabled = gateEnabled(process.env.APPSMITH_MCP_JS_ENABLED); let httpServer: Server | undefined; let governanceStore: MongoRedisGovernanceStore | undefined; diff --git a/app/client/src/ce/pages/AdminSettings/config/configuration.tsx b/app/client/src/ce/pages/AdminSettings/config/configuration.tsx index bbab80ec5a2c..3e471542494e 100644 --- a/app/client/src/ce/pages/AdminSettings/config/configuration.tsx +++ b/app/client/src/ce/pages/AdminSettings/config/configuration.tsx @@ -82,6 +82,39 @@ export const APPSMITH_POOL_SIZE_CONFIG: Setting = { isVisible: () => !isAirgappedInstance, }; +export const APPSMITH_MCP_ENABLED_SETTING: Setting = { + id: "APPSMITH_MCP_ENABLED", + name: "APPSMITH_MCP_ENABLED", + category: SettingCategories.CONFIGURATION, + controlType: SettingTypes.CHECKBOX, + label: "MCP server", + text: "Allow AI agents to connect to this instance over MCP (Model Context Protocol)", + subText: + "* Agents authenticate with per-user MCP tokens (Profile → MCP tokens) and act with that user's permissions. Enabled by default; disabling removes the /mcp endpoint and rejects MCP tokens on restart.", + defaultValue: true, +}; + +export const APPSMITH_MCP_DATA_ENABLED_SETTING: Setting = { + id: "APPSMITH_MCP_DATA_ENABLED", + name: "APPSMITH_MCP_DATA_ENABLED", + category: SettingCategories.CONFIGURATION, + controlType: SettingTypes.CHECKBOX, + label: "MCP data tools", + text: "Let agents work with datasources and queries (create datasources/queries, run read-only actions)", + subText: + "* Off by default. All operations run under the connecting user's existing permissions; credentials are never exposed to agents.", +}; + +export const APPSMITH_MCP_JS_ENABLED_SETTING: Setting = { + id: "APPSMITH_MCP_JS_ENABLED", + name: "APPSMITH_MCP_JS_ENABLED", + category: SettingCategories.CONFIGURATION, + controlType: SettingTypes.CHECKBOX, + label: "MCP JS objects", + text: "Let agents author restricted JS objects (declarative grammar only — no arbitrary JavaScript)", + subText: "* Off by default. Requires MCP data tools.", +}; + export const APPSMITH_ALLOWED_FRAME_ANCESTORS_SETTING: Setting = { id: "APPSMITH_ALLOWED_FRAME_ANCESTORS", name: "APPSMITH_ALLOWED_FRAME_ANCESTORS", @@ -161,6 +194,9 @@ export const config: AdminConfigType = { APPSMITH_REDIS_URL, APPSMITH_BASE_URL, APPSMITH_POOL_SIZE_CONFIG, + APPSMITH_MCP_ENABLED_SETTING, + APPSMITH_MCP_DATA_ENABLED_SETTING, + APPSMITH_MCP_JS_ENABLED_SETTING, APPSMITH_ALLOWED_FRAME_ANCESTORS_SETTING, ], }; diff --git a/app/client/src/ce/pages/AdminSettings/config/types.ts b/app/client/src/ce/pages/AdminSettings/config/types.ts index 20fd2eef5741..0206c8aef8b7 100644 --- a/app/client/src/ce/pages/AdminSettings/config/types.ts +++ b/app/client/src/ce/pages/AdminSettings/config/types.ts @@ -88,6 +88,9 @@ export type Setting = ControlType & { sortOrder?: number; subText?: string; subTextLink?: string; + // For TOGGLE/CHECKBOX settings backed by an env variable: the state to show when the variable is absent from the + // fetched admin settings (e.g. an env file that predates the setting). Mirrors the runtime default. + defaultValue?: boolean; toggleText?: (value: boolean) => string; // TODO: Fix this the next time the file is edited // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/app/client/src/pages/AdminSettings/SettingsForm.tsx b/app/client/src/pages/AdminSettings/SettingsForm.tsx index c8197ce462d8..b65f7626499b 100644 --- a/app/client/src/pages/AdminSettings/SettingsForm.tsx +++ b/app/client/src/pages/AdminSettings/SettingsForm.tsx @@ -221,6 +221,19 @@ export function SettingsForm( } } }); + + // A toggle/checkbox backed by an env variable that is ABSENT from the fetched settings (an env file predating + // it) renders from its declared default, so the UI mirrors the runtime default instead of showing unchecked. + _.forEach(AdminConfig.settingsMap, (setting, settingName) => { + if ( + (setting.controlType == SettingTypes.TOGGLE || + setting.controlType == SettingTypes.CHECKBOX) && + setting.defaultValue !== undefined && + props.settingsConfig[settingName] === undefined + ) { + props.settingsConfig[settingName] = setting.defaultValue; + } + }); props.initialize(props.settingsConfig); }; diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java index 7eeeb257b224..1feccb796634 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java @@ -131,8 +131,18 @@ public class SecurityConfig { @Value("${appsmith.internal.password}") private String INTERNAL_PASSWORD; + // MCP server enablement, read from the APPSMITH_MCP_ENABLED env variable (default on). When disabled from Admin + // Settings, the MCP bearer-auth filter must not engage — otherwise "disabling MCP" would leave already-issued + // mcp_ tokens working as full API credentials until they expire. Same deny-list spelling as the deploy scripts. + @Value("${APPSMITH_MCP_ENABLED:true}") + private String mcpEnabledFlag; + private static final String INTERNAL = "INTERNAL"; + private boolean isMcpEnabled() { + return mcpEnabledFlag == null || !mcpEnabledFlag.trim().matches("(?i)^(false|0|no|off)$"); + } + /** * This routerFunction is required to map /public/** endpoints to the * src/main/resources/public folder @@ -205,7 +215,14 @@ public SecurityWebFilterChain securityWebFilterChain( // Only engage for requests that actually carry an MCP token. Without this, the filter's default "any // exchange" matcher sits at AUTHENTICATION order alongside the form-login filter and breaks the login flow // (No provider found for UsernamePasswordAuthenticationToken -> 500). + // When MCP is disabled the filter never engages, so an mcp_ bearer is treated as an unrecognized credential + // and rejected (401) instead of authenticating — this makes the Admin Settings toggle a real kill switch for + // already-issued tokens, not just a route removal. + final boolean mcpEnabled = isMcpEnabled(); mcpTokenAuthenticationWebFilter.setRequiresAuthenticationMatcher(exchange -> { + if (!mcpEnabled) { + return ServerWebExchangeMatcher.MatchResult.notMatch(); + } final String mcpBearerPrefix = "Bearer mcp_"; final String authorization = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION); return authorization != null diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/EnvVariables.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/EnvVariables.java index e4eb59ba6d5b..a698a147cbc6 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/EnvVariables.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/EnvVariables.java @@ -26,5 +26,10 @@ public enum EnvVariables { APPSMITH_ALLOWED_FRAME_ANCESTORS, APPSMITH_DISABLE_IFRAME_WIDGET_SANDBOX, APPSMITH_NEW_RELIC_ACCOUNT_ENABLE, - APPSMITH_VERBOSE_LOGGING_ENABLED + APPSMITH_VERBOSE_LOGGING_ENABLED, + // MCP server gates: admin-editable so the Admin Settings UI can toggle the MCP service (on by default) and its + // data / restricted-JS sub-features (off by default). + APPSMITH_MCP_ENABLED, + APPSMITH_MCP_DATA_ENABLED, + APPSMITH_MCP_JS_ENABLED } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java index d806df46869f..3915040575da 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java @@ -774,8 +774,11 @@ public Mono restart() { public Mono restartWithoutAclCheck() { log.warn("Initiating restart via supervisor."); try { + // "mcp" is included so toggling the MCP env gates from Admin Settings takes effect on restart; its + // run script re-reads docker.env and parks itself when disabled. supervisorctl restarts the other + // programs even if one name is unknown (older images without the mcp program). Runtime.getRuntime().exec(new String[] { - "supervisorctl", "restart", "backend", "editor", "rts", + "supervisorctl", "restart", "backend", "editor", "rts", "mcp", }); } catch (IOException e) { log.error("Error invoking supervisorctl to restart.", e); diff --git a/contributions/ServerSetup.md b/contributions/ServerSetup.md index 7139e61a1041..5e67b342278d 100644 --- a/contributions/ServerSetup.md +++ b/contributions/ServerSetup.md @@ -417,7 +417,7 @@ The MCP service listens on `http://127.0.0.1:8092`; verify it with `http://127.0 Create an MCP token from **User Profile → MCP tokens**. Copy the value when it is created: Appsmith only displays the token once. Configure a Streamable HTTP MCP client with: -- URL: `http://127.0.0.1:8092/mcp` for local development, or `https:///mcp` for a Docker deployment with `APPSMITH_MCP_ENABLED=1`. +- URL: `http://127.0.0.1:8092/mcp` for local development, or `https:///mcp` for a Docker deployment (the MCP server is enabled by default; admins can turn it off from **Admin Settings → Configuration** or with `APPSMITH_MCP_ENABLED=false`). - Header: `Authorization: Bearer `. The service intentionally binds only to loopback; external clients connect through the Appsmith Caddy route, never directly to port `8092`. Leave `APPSMITH_MCP_DATA_ENABLED=0` unless datasource discovery and structured SQL query creation have been explicitly enabled for the deployment. @@ -449,12 +449,12 @@ ChatGPT connects only to remote HTTPS MCP servers and its managed connector flow The MCP server is a **direct-authoring** interface: mutations happen through the existing, ACL-enforced Appsmith REST APIs under the caller's token. Agents never author raw widget DSL, SQL, or `{{ }}` bindings — every write goes through a validated, compiler-owned spec. -Three independent gates control what is registered. Call `get_capabilities` to see the exact tool set active for a connection. +The MCP server itself is **on by default** (`APPSMITH_MCP_ENABLED`, toggleable from **Admin Settings → Configuration**). Three further gates control what is registered — the first two are also on that admin page. Call `get_capabilities` to see the exact tool set active for a connection. | Gate | Env var | Enables | |------|---------|---------| -| Data layer | `APPSMITH_MCP_DATA_ENABLED=1` | datasource discovery, structured SQL/REST action creation, action reads | -| Restricted JS | `APPSMITH_MCP_JS_ENABLED=1` | declarative, restricted JS-object tools (requires data + governance) | +| Data layer | `APPSMITH_MCP_DATA_ENABLED=true` (default off) | datasource discovery/creation, structured SQL/REST action creation, action reads | +| Restricted JS | `APPSMITH_MCP_JS_ENABLED=true` (default off) | declarative, restricted JS-object tools (requires data + governance) | | Governance | `APPSMITH_MONGODB_URI`/`APPSMITH_DB_URL` **and** `APPSMITH_REDIS_URL` | governed mutations, destructive ops, audit history, rollback, publish | **Governance** is MCP-owned durable state, kept out of product documents: audit/rollback snapshots in the Mongo `mcp_changes` collection, and locks + one-time confirmation tokens in Redis (`appsmith:mcp:lock:*`, `appsmith:mcp:confirm:*`). When it is not configured, the server registers **read + spec-authoring tools only** — it never falls back to an in-memory store, and if a governance URL is set but unreachable it fails to start. diff --git a/deploy/docker/fs/opt/appsmith/caddy-reconfigure.mjs b/deploy/docker/fs/opt/appsmith/caddy-reconfigure.mjs index 435a8d94f573..09b53a33e743 100644 --- a/deploy/docker/fs/opt/appsmith/caddy-reconfigure.mjs +++ b/deploy/docker/fs/opt/appsmith/caddy-reconfigure.mjs @@ -14,8 +14,9 @@ const AppsmithCaddy = process.env._APPSMITH_CADDY const isRateLimitingEnabled = process.env.APPSMITH_RATE_LIMIT !== "disabled" const RATE_LIMIT = parseInt(process.env.APPSMITH_RATE_LIMIT || 100, 10) -// The MCP server is opt-in. Only route to it when explicitly enabled. -const isMcpEnabled = process.env.APPSMITH_MCP_ENABLED === "1" +// The MCP server is enabled by default; the /mcp route is dropped only when explicitly disabled. This script re-runs +// on every editor (caddy) program restart, so the Admin Settings toggle takes effect on Save & Restart. +const isMcpEnabled = !/^(false|0|no|off)$/i.test(process.env.APPSMITH_MCP_ENABLED ?? "") let certLocation = null if (CUSTOM_DOMAIN !== "") { diff --git a/deploy/docker/fs/opt/appsmith/entrypoint.sh b/deploy/docker/fs/opt/appsmith/entrypoint.sh index a7cded6904af..fbf6f93882d9 100644 --- a/deploy/docker/fs/opt/appsmith/entrypoint.sh +++ b/deploy/docker/fs/opt/appsmith/entrypoint.sh @@ -121,6 +121,18 @@ init_env_file() { sed -i "s|^APPSMITH_REDIS_URL=.*|APPSMITH_REDIS_URL=redis://:${generated_appsmith_redis_password}@127.0.0.1:6379|" "$ENV_PATH" fi fi + + # Backfill the MCP gates for installs whose docker.env predates them, so the Admin Settings UI + # (which reads/writes these variables) mirrors the actual defaults: server on, sub-features off. + if ! grep -q "^APPSMITH_MCP_ENABLED=" "$ENV_PATH"; then + echo $'\nAPPSMITH_MCP_ENABLED=true' >> "$ENV_PATH" + fi + if ! grep -q "^APPSMITH_MCP_DATA_ENABLED=" "$ENV_PATH"; then + echo 'APPSMITH_MCP_DATA_ENABLED=false' >> "$ENV_PATH" + fi + if ! grep -q "^APPSMITH_MCP_JS_ENABLED=" "$ENV_PATH"; then + echo 'APPSMITH_MCP_JS_ENABLED=false' >> "$ENV_PATH" + fi fi tlog "Load environment configuration" @@ -501,10 +513,9 @@ configure_supervisord() { cp -f "$supervisord_conf_source"/application_process/*.conf "$SUPERVISORD_CONF_TARGET" - # The MCP server is opt-in. Remove its supervisord program unless explicitly enabled. - if [[ ${APPSMITH_MCP_ENABLED-} != 1 ]]; then - rm -f "$SUPERVISORD_CONF_TARGET/mcp.conf" - fi + # The MCP program is always installed (enabled by default); run-mcp.sh itself parks when APPSMITH_MCP_ENABLED is + # explicitly disabled. This keeps the toggle switchable from Admin Settings -> Configuration without a container + # restart: a supervisord program restart re-reads docker.env via run-with-env.sh. # Disable services based on configuration if [[ -z "${DYNO}" ]]; then diff --git a/deploy/docker/fs/opt/appsmith/healthcheck.sh b/deploy/docker/fs/opt/appsmith/healthcheck.sh index bfa606ce35f7..152e115420c0 100644 --- a/deploy/docker/fs/opt/appsmith/healthcheck.sh +++ b/deploy/docker/fs/opt/appsmith/healthcheck.sh @@ -1,7 +1,14 @@ #!/usr/bin/env bash healthy=true -# MCP is opt-in (APPSMITH_MCP_ENABLED); its supervisord program is absent when disabled. Only health-check it when -# it is actually a configured program, so a container with MCP off (the default) is not reported unhealthy. +# MCP is enabled by default (APPSMITH_MCP_ENABLED). When explicitly disabled, its supervisord program is parked +# (sleep) rather than absent — so probe its health endpoint only when enabled, reading the gate from docker.env +# because the Docker HEALTHCHECK environment does not source it. +mcp_enabled_value="${APPSMITH_MCP_ENABLED:-$(grep -m1 '^APPSMITH_MCP_ENABLED=' /appsmith-stacks/configuration/docker.env 2>/dev/null | cut -d= -f2- | tr -d '"'"'")}" +if [[ "${mcp_enabled_value:-true}" =~ ^([Ff][Aa][Ll][Ss][Ee]|0|[Nn][Oo]|[Oo][Ff][Ff])$ ]]; then + mcp_enabled=false +else + mcp_enabled=true +fi processes="editor rts backend" if supervisorctl status | grep -q '^mcp'; then processes="$processes mcp" @@ -26,7 +33,7 @@ while read -r line echo 'ERROR: Server is down'; healthy=false fi - elif [[ "$process" == "mcp" ]]; then + elif [[ "$process" == "mcp" && "$mcp_enabled" == "true" ]]; then if [[ $(curl -s -w "%{http_code}\n" http://localhost:${APPSMITH_MCP_PORT:-8092}/health -o /dev/null) -ne 200 ]]; then echo 'ERROR: MCP is down'; healthy=false diff --git a/deploy/docker/fs/opt/appsmith/run-mcp.sh b/deploy/docker/fs/opt/appsmith/run-mcp.sh index ecd7ee0c8bec..8b4c0b6f5e6a 100644 --- a/deploy/docker/fs/opt/appsmith/run-mcp.sh +++ b/deploy/docker/fs/opt/appsmith/run-mcp.sh @@ -1,3 +1,13 @@ #!/bin/bash +# MCP is enabled by default; park the program (instead of exiting, which would crash-loop supervisord) only when +# APPSMITH_MCP_ENABLED is explicitly turned off. Toggling from Admin Settings -> Configuration restarts this program +# with the fresh docker.env (via run-with-env.sh), so the gate re-evaluates without a container restart. +shopt -s nocasematch +if [[ "${APPSMITH_MCP_ENABLED:-true}" =~ ^(false|0|no|off)$ ]]; then + echo "MCP server is disabled (APPSMITH_MCP_ENABLED=${APPSMITH_MCP_ENABLED}). Parking." + exec sleep infinity +fi +shopt -u nocasematch + exec node --enable-source-maps /opt/appsmith/mcp/bundle/server.js diff --git a/deploy/docker/fs/opt/appsmith/templates/docker.env.sh b/deploy/docker/fs/opt/appsmith/templates/docker.env.sh index 9bf5e3869db7..2188ca0075e3 100644 --- a/deploy/docker/fs/opt/appsmith/templates/docker.env.sh +++ b/deploy/docker/fs/opt/appsmith/templates/docker.env.sh @@ -86,4 +86,12 @@ APPSMITH_ALLOWED_FRAME_ANCESTORS="'self' *" APPSMITH_DISABLE_IFRAME_WIDGET_SANDBOX=false +# MCP (Model Context Protocol) server: lets AI agents connect to this instance at /mcp using +# per-user MCP tokens (Profile -> MCP tokens). Also editable from Admin Settings -> Configuration. +# The server is on by default; the data layer (datasources/queries) and restricted JS objects are +# separate opt-ins. +APPSMITH_MCP_ENABLED=true +APPSMITH_MCP_DATA_ENABLED=false +APPSMITH_MCP_JS_ENABLED=false + EOF From 77a6e22c709378162c29d376800a201ba9a1148d Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Mon, 13 Jul 2026 14:03:03 -0400 Subject: [PATCH 14/81] fix(mcp): live-test fixes, reachable token UI, and admin-settable token TTL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes found by running the MCP server end-to-end against a live Appsmith instance, plus token-management UX and configurable lifetime. Bugs the live run surfaced: - build: the shipped ESM bundle crashed on startup with "Dynamic require of timers/promises" because the mongodb driver dynamically requires Node built-ins. Added a createRequire banner to build.js so the same bundle works in Docker with governance enabled. - app.ts: list_workspaces called GET /api/v1/workspaces, which collides with the create (POST) mapping and returns 405 — breaking step one of every agent journey. Use GET /api/v1/workspaces/home. Token management UI: - The token UI lived on pages/UserProfile, a page the app no longer routes to, so it was unreachable. Move it into the live Admin Settings -> Profile (Account) page; remove the dead UserProfile tab. - Fix timestamp display: the server serializes Instant as epoch seconds but the client parsed it as milliseconds, so dates showed 1970 and a 90-day expiry rendered as ~2 hours. Normalize seconds to ms. - UX: right-align the Create token button; add a clipboard icon beside the token field in the one-time reveal modal. Admin-settable token lifetime (default 90 days): - APPSMITH_MCP_TOKEN_TTL_DAYS drives create/rotate expiry via @Value setter injection, clamped to 1..3650 so a misconfig can't mint immortal or pre-expired tokens. Whitelisted in EnvVariables; new numeric field on Admin Settings -> Configuration; seeded in docker.env.sh and backfilled in entrypoint.sh. Added server tests for the configured value and clamping. Verified: mcp tsc clean, 306 passed / 4 skipped; client check-types (node 24) exit 0; eslint 0 errors; prettier clean; spotless applied; deploy scripts syntax-checked. Live smoke against a running instance: initialize -> list_workspaces -> list_applications -> create_datasource (idempotent) all pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/client/packages/mcp/build.js | 6 + app/client/packages/mcp/src/app.test.ts | 2 +- app/client/packages/mcp/src/app.ts | 4 +- app/client/src/api/McpTokenApi.ts | 5 +- .../AdminSettings/config/configuration.tsx | 24 ++++ .../Profile}/McpTokens.test.tsx | 0 .../Profile}/McpTokens.tsx | 124 ++++++++++++------ .../src/pages/AdminSettings/Profile/index.tsx | 6 + app/client/src/pages/UserProfile/index.tsx | 8 -- .../server/constants/EnvVariables.java | 3 +- .../ce/UserMcpTokenServiceCEImpl.java | 21 ++- .../services/UserMcpTokenServiceImplTest.java | 27 ++++ deploy/docker/fs/opt/appsmith/entrypoint.sh | 3 + .../fs/opt/appsmith/templates/docker.env.sh | 2 + 14 files changed, 181 insertions(+), 54 deletions(-) rename app/client/src/pages/{UserProfile => AdminSettings/Profile}/McpTokens.test.tsx (100%) rename app/client/src/pages/{UserProfile => AdminSettings/Profile}/McpTokens.tsx (73%) diff --git a/app/client/packages/mcp/build.js b/app/client/packages/mcp/build.js index f4e2ce790cf0..15adab26a619 100644 --- a/app/client/packages/mcp/build.js +++ b/app/client/packages/mcp/build.js @@ -8,5 +8,11 @@ await esbuild.build({ 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/src/app.test.ts b/app/client/packages/mcp/src/app.test.ts index 1343546669d0..609a4946c27e 100644 --- a/app/client/packages/mcp/src/app.test.ts +++ b/app/client/packages/mcp/src/app.test.ts @@ -52,7 +52,7 @@ describe("Appsmith API client", () => { ); expect(fetchFn.mock.calls.map(([url]) => String(url))).toEqual([ - `${API_BASE_URL}/api/v1/workspaces`, + `${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`, diff --git a/app/client/packages/mcp/src/app.ts b/app/client/packages/mcp/src/app.ts index 69d9ad616352..8d2124db29df 100644 --- a/app/client/packages/mcp/src/app.ts +++ b/app/client/packages/mcp/src/app.ts @@ -366,7 +366,9 @@ export function createAppsmithApi( } return { - listWorkspaces: async () => request("/api/v1/workspaces"), + // The user's accessible workspaces come from /workspaces/home; a plain GET /workspaces collides with the + // create (POST) mapping and returns 405. + listWorkspaces: async () => request("/api/v1/workspaces/home"), listApplications: async (workspaceId) => request( `/api/v1/applications/home?workspaceId=${encodeURIComponent(workspaceId)}`, diff --git a/app/client/src/api/McpTokenApi.ts b/app/client/src/api/McpTokenApi.ts index ea1e32711cf2..0a708bc5b9cf 100644 --- a/app/client/src/api/McpTokenApi.ts +++ b/app/client/src/api/McpTokenApi.ts @@ -2,8 +2,9 @@ import Api from "api/Api"; import type { ApiResponse } from "api/ApiResponses"; export interface McpTokenMetadata { - createdAt: string; - expiresAt: string; + // The server serializes Instant fields as epoch seconds (a number); older/other paths may send an ISO string. + createdAt: string | number; + expiresAt: string | number; id: string; } diff --git a/app/client/src/ce/pages/AdminSettings/config/configuration.tsx b/app/client/src/ce/pages/AdminSettings/config/configuration.tsx index 3e471542494e..fbeacaadfeb6 100644 --- a/app/client/src/ce/pages/AdminSettings/config/configuration.tsx +++ b/app/client/src/ce/pages/AdminSettings/config/configuration.tsx @@ -115,6 +115,29 @@ export const APPSMITH_MCP_JS_ENABLED_SETTING: Setting = { subText: "* Off by default. Requires MCP data tools.", }; +export const APPSMITH_MCP_TOKEN_TTL_DAYS_SETTING: Setting = { + id: "APPSMITH_MCP_TOKEN_TTL_DAYS", + name: "APPSMITH_MCP_TOKEN_TTL_DAYS", + category: SettingCategories.CONFIGURATION, + controlType: SettingTypes.TEXTINPUT, + controlSubType: SettingSubtype.NUMBER, + label: "MCP token lifetime (days)", + subText: + "* How long a newly created or rotated MCP token stays valid. Defaults to 90 days; must be between 1 and 3650.", + placeholder: "90", + validate: (value: string) => { + if (value === undefined || value === "") { + return; + } + + const days = Number(value); + + if (!Number.isInteger(days) || days < 1 || days > 3650) { + return "Please enter a whole number of days between 1 and 3650."; + } + }, +}; + export const APPSMITH_ALLOWED_FRAME_ANCESTORS_SETTING: Setting = { id: "APPSMITH_ALLOWED_FRAME_ANCESTORS", name: "APPSMITH_ALLOWED_FRAME_ANCESTORS", @@ -197,6 +220,7 @@ export const config: AdminConfigType = { APPSMITH_MCP_ENABLED_SETTING, APPSMITH_MCP_DATA_ENABLED_SETTING, APPSMITH_MCP_JS_ENABLED_SETTING, + APPSMITH_MCP_TOKEN_TTL_DAYS_SETTING, APPSMITH_ALLOWED_FRAME_ANCESTORS_SETTING, ], }; diff --git a/app/client/src/pages/UserProfile/McpTokens.test.tsx b/app/client/src/pages/AdminSettings/Profile/McpTokens.test.tsx similarity index 100% rename from app/client/src/pages/UserProfile/McpTokens.test.tsx rename to app/client/src/pages/AdminSettings/Profile/McpTokens.test.tsx diff --git a/app/client/src/pages/UserProfile/McpTokens.tsx b/app/client/src/pages/AdminSettings/Profile/McpTokens.tsx similarity index 73% rename from app/client/src/pages/UserProfile/McpTokens.tsx rename to app/client/src/pages/AdminSettings/Profile/McpTokens.tsx index f82333995bc7..86bed78a49dc 100644 --- a/app/client/src/pages/UserProfile/McpTokens.tsx +++ b/app/client/src/pages/AdminSettings/Profile/McpTokens.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useState } from "react"; import { Button, + Flex, Input, Modal, ModalBody, @@ -8,6 +9,7 @@ import { ModalFooter, ModalHeader, Text, + Tooltip, toast, } from "@appsmith/ads"; import { @@ -45,11 +47,29 @@ import McpTokenApi, { } from "api/McpTokenApi"; import type { ApiResponse } from "api/ApiResponses"; import styled from "styled-components"; -import { Wrapper } from "./StyledComponents"; -const TokensWrapper = styled(Wrapper)` +const TokensWrapper = styled.div` width: 640px; max-width: 100%; + & > div { + margin-bottom: 16px; + } +`; + +const TokenRow = styled.div` + display: flex; + align-items: center; + gap: var(--ads-v2-spaces-4); + padding: var(--ads-v2-spaces-4) 0; + border-bottom: 1px solid var(--ads-v2-color-border); +`; + +const TokenMeta = styled.div` + display: flex; + flex-direction: column; + gap: var(--ads-v2-spaces-1); + flex: 1; + min-width: 0; `; const getErrorMessage = (error: unknown, fallback: string) => { @@ -66,10 +86,20 @@ const ensureSuccess = (response: ApiResponse) => { return response.data; }; -const formatCreatedAt = (createdAt: string) => { - const date = new Date(createdAt); +// Timestamps arrive as epoch seconds (Jackson's Instant serialization). Detect and normalize to milliseconds so +// they don't render as 1970; ISO strings pass through unchanged. +const formatTimestamp = (value: string | number) => { + const numeric = typeof value === "number" ? value : Number(value); + let date: Date; + + if (!Number.isNaN(numeric) && String(value).trim() !== "") { + // Values below ~year 2286 in ms are actually seconds; scale them up. + date = new Date(numeric < 1e12 ? numeric * 1000 : numeric); + } else { + date = new Date(value); + } - return Number.isNaN(date.getTime()) ? createdAt : date.toLocaleString(); + return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString(); }; function McpTokens() { @@ -200,17 +230,23 @@ function McpTokens() { return ( <> - {createMessage(MCP_TOKENS_DESCRIPTION)} + + {createMessage(MCP_TOKENS_DESCRIPTION)} + + {error && ( {error} )} -
- -
{isLoading ? ( {createMessage(MCP_TOKENS_LOADING)} @@ -220,16 +256,16 @@ function McpTokens() { ) : (
{tokens.map((token) => ( -
- {token.id} - - {createMessage(MCP_TOKEN_CREATED_AT)}:{" "} - {formatCreatedAt(token.createdAt)} - - - {createMessage(MCP_TOKEN_EXPIRES_AT)}:{" "} - {formatCreatedAt(token.expiresAt)} - + + + {token.id} + + {createMessage(MCP_TOKEN_CREATED_AT)}:{" "} + {formatTimestamp(token.createdAt)} ·{" "} + {createMessage(MCP_TOKEN_EXPIRES_AT)}:{" "} + {formatTimestamp(token.expiresAt)} + + -
+ ))}
)} @@ -268,23 +304,35 @@ function McpTokens() { {createMessage(MCP_TOKEN_CREATED_DESCRIPTION)} - - + + + + + + diff --git a/app/client/src/pages/AdminSettings/Profile/index.tsx b/app/client/src/pages/AdminSettings/Profile/index.tsx index 1ea6bc68ca28..c7e45e3e9fcb 100644 --- a/app/client/src/pages/AdminSettings/Profile/index.tsx +++ b/app/client/src/pages/AdminSettings/Profile/index.tsx @@ -38,6 +38,8 @@ import { SubCategory, } from "./StyledComponents"; import UserProfileImagePicker from "./UserProfileImagePicker"; +import McpTokens from "./McpTokens"; +import { MCP_TOKENS } from "ee/constants/messages"; import { emailValidator } from "@appsmith/ads-old"; import { getGlobalGitConfig, @@ -320,6 +322,10 @@ export const Profile = () => { )} + + {createMessage(MCP_TOKENS)} + +
" , ...`. + orderBy: z + .array( + z + .object({ + column: sqlIdentifier, + direction: z.enum(["ASC", "DESC"]), + }) + .strict(), + ) + .max(20) + .optional(), + // SELECT aggregation over a CLOSED set of functions. `count` may omit the column (COUNT(*)); `sum`/`avg` require a + // column (enforced in the compiler). Pair with `groupBy` for grouped rollups. + aggregate: z + .object({ + fn: z.enum(["count", "sum", "avg"]), + column: sqlIdentifier.optional(), + }) + .strict() + .optional(), + // GROUP BY columns for an aggregated SELECT — each an allow-listed identifier, emitted quoted. + groupBy: z.array(sqlIdentifier).max(20).optional(), limit: z.number().int().min(1).max(1000).optional(), }) - .strict(); + .strict() + // orderBy/aggregate/groupBy shape a SELECT only; compileQuery ignores them on INSERT/UPDATE/DELETE. Reject them + // there so an agent gets clear feedback instead of a silently-dropped clause. + .superRefine((spec, ctx) => { + if (spec.operation === "SELECT") return; + + for (const field of ["orderBy", "aggregate", "groupBy"] as const) { + if (spec[field] !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${field} is only valid on a SELECT query`, + path: [field], + }); + } + } + }); export type QuerySpec = z.infer; type ValueRef = z.infer; @@ -120,6 +168,46 @@ function emitBinding(value: ValueRef): string { return binding; } +// Emit `ORDER BY "col" ASC, "col2" DESC`. Columns are allow-listed identifiers (the charset admits no +// quote/backslash), so quoting them is safe and unquotable; direction comes from a two-value enum, never agent text. +function emitOrderBy(orderBy: QuerySpec["orderBy"]): string { + if (!orderBy || orderBy.length === 0) return ""; + + const clauses = orderBy.map( + (entry) => `"${entry.column}" ${entry.direction}`, + ); + + return ` ORDER BY ${clauses.join(", ")}`; +} + +// Emit `GROUP BY "col", "col2"`. Same allow-listed, quoted identifiers. +function emitGroupBy(groupBy: QuerySpec["groupBy"]): string { + if (!groupBy || groupBy.length === 0) return ""; + + const clauses = groupBy.map((column) => `"${column}"`); + + return ` GROUP BY ${clauses.join(", ")}`; +} + +// Emit one aggregate select item, e.g. `COUNT(*) AS count`, `COUNT("id") AS count`, `SUM("amount") AS sum`. The +// function keyword and the alias both come from the closed AGGREGATE_FNS enum; the column (when present) is an +// allow-listed identifier emitted quoted. `sum`/`avg` require a column — a count-less aggregate is nonsensical. +function emitAggregate(aggregate: NonNullable): string { + const fn = AGGREGATE_FNS[aggregate.fn]; + + if (aggregate.fn === "count") { + const target = aggregate.column ? `"${aggregate.column}"` : "*"; + + return `${fn}(${target}) AS ${aggregate.fn}`; + } + + if (!aggregate.column) { + throw new Error(`${aggregate.fn} aggregation requires a column`); + } + + return `${fn}("${aggregate.column}") AS ${aggregate.fn}`; +} + function emitWhere(filters: QuerySpec["filters"]): string { if (!filters || filters.length === 0) return ""; @@ -142,11 +230,26 @@ export function compileQuery(spec: QuerySpec): string { switch (spec.operation) { case "SELECT": { - const columns = - spec.columns && spec.columns.length > 0 ? spec.columns.join(", ") : "*"; + // Aggregated SELECT: the group-by columns (quoted) followed by the single aggregate item, e.g. + // `SELECT "region", SUM("amount") AS sum`. Otherwise the plain column list (unquoted, matching the + // existing convention) or `*`. + let selectList: string; + + if (spec.aggregate) { + const groupCols = (spec.groupBy ?? []).map((column) => `"${column}"`); + + selectList = [...groupCols, emitAggregate(spec.aggregate)].join(", "); + } else { + selectList = + spec.columns && spec.columns.length > 0 + ? spec.columns.join(", ") + : "*"; + } + const limit = spec.limit !== undefined ? ` LIMIT ${spec.limit}` : ""; - body = `SELECT ${columns} FROM ${spec.table}${emitWhere(spec.filters)}${limit};`; + // SQL clause order: SELECT ... FROM ... WHERE ... GROUP BY ... ORDER BY ... LIMIT. + body = `SELECT ${selectList} FROM ${spec.table}${emitWhere(spec.filters)}${emitGroupBy(spec.groupBy)}${emitOrderBy(spec.orderBy)}${limit};`; break; } case "INSERT": { diff --git a/app/client/packages/mcp/src/builder/schema.ts b/app/client/packages/mcp/src/builder/schema.ts index e4c040967765..5608128c5a4d 100644 --- a/app/client/packages/mcp/src/builder/schema.ts +++ b/app/client/packages/mcp/src/builder/schema.ts @@ -301,11 +301,75 @@ const chartSeries = z }) .strict(); +// M4 chart query source: bind a chart's single series to a named query's rows, mapping column `x` +// (category/label) and column `y` (numeric value) to {x,y} points. `x`/`y` are COLUMN-NAME refs — the exact same +// charset as itemField/selectedRowColumn (letters/digits/underscore/space) — NOT raw expressions. The compiler +// emits `row[""]`/`row[""]` bracket access with double quotes, and the charset admits no +// quote/bracket/brace/backtick/`$`/backslash, so the compiler-authored map expression cannot be broken out of. +const chartAxisColumn = z + .string() + .min(1) + .max(100) + .regex( + /^[A-Za-z0-9_ ]+$/, + "chart axis column names must be alphanumeric, underscore, or space", + ); + +export const chartSourceRefSchema = z + .object({ + query: bindingIdentifier, + field: responseFieldPath.optional(), + x: chartAxisColumn, + y: chartAxisColumn, + }) + .strict(); + +export type ChartSourceRef = z.infer; + +// The single emitter for a chart's data binding. Every interpolated part is a schema-validated identifier/column +// charset, so the expression cannot be broken out of. Optional chaining (`.data?.` ... `?.map(...) ?? []`) keeps the +// chart valid before the query has run (data undefined -> `[]`, no "undefined is not a function" throw), mirroring +// compileTableDataBinding's `?? []` guard. The leading head is the query identifier, so the linter resolves it +// against knownDataNames; `row` is an arrow-local, not a binding head. +export function compileChartDataBinding(ref: ChartSourceRef): string { + const dataPath = ref.field + ? `${ref.query}.data?.${ref.field}` + : `${ref.query}.data`; + + return `{{ ${dataPath}?.map((row) => ({ x: row["${ref.x}"], y: row["${ref.y}"] })) ?? [] }}`; +} + const selectOption = z.object({ label: safeText(200).pipe(z.string().min(1)), value: z.union([safeText(200), z.number()]), }); +// M4 select query source: bind a select's dropdown to a named query. SELECT_WIDGET derives its options from +// `sourceData` (a JS-evaluated array) keyed by `optionLabel`/`optionValue`. For a query source the compiler emits +// `sourceData: {{ .data ?? [] }}` (reusing the table-data emitter) registered as a dynamic binding, plus +// `optionLabel`/`optionValue` as validated column-name LITERALS (not bindings). `label`/`value` use the same +// column-name charset as chart axes/card fields — no quote/brace/backtick — so nothing agent-supplied is +// eval-reachable. Query/field validation is shared with tableDataRefSchema so the two entry points cannot drift. +const optionColumn = z + .string() + .min(1) + .max(100) + .regex( + /^[A-Za-z0-9_ ]+$/, + "option label/value column names must be alphanumeric, underscore, or space", + ); + +export const optionsSourceRefSchema = z + .object({ + query: bindingIdentifier, + field: responseFieldPath.optional(), + label: optionColumn, + value: optionColumn, + }) + .strict(); + +export type OptionsSourceRef = z.infer; + // Recursive: a container can hold child widgets. z.lazy handles the self-reference. export const widgetSpecSchema: z.ZodType = z.lazy(() => z.discriminatedUnion("type", [ @@ -340,6 +404,10 @@ export const widgetSpecSchema: z.ZodType = z.lazy(() => name: nameField, label: safeText(200).optional(), options: z.array(selectOption).max(200).optional(), + // M4: bind the dropdown to a named query instead of static options. Compiler emits a `sourceData` binding + + // literal optionLabel/optionValue. Mutually exclusive with `options` (enforced in the template — a `.refine` + // here would turn the arm into a ZodEffects, which z.discriminatedUnion rejects). + optionsSource: optionsSourceRefSchema.optional(), placement: placementSchema.optional(), }) .strict(), @@ -425,6 +493,10 @@ export const widgetSpecSchema: z.ZodType = z.lazy(() => ]) .optional(), series: z.array(chartSeries).max(10).optional(), + // M4: bind the chart to a named query's rows instead of static series. Compiler emits a mapped chartData + // binding. Mutually exclusive with `series` (enforced in the template — a `.refine` here would turn the arm + // into a ZodEffects, which z.discriminatedUnion rejects). + source: chartSourceRefSchema.optional(), placement: placementSchema.optional(), }) .strict(), @@ -497,6 +569,7 @@ export type WidgetSpec = name?: string; label?: string; options?: { label: string; value: string | number }[]; + optionsSource?: OptionsSourceRef; placement?: PlacementSpec; } | { @@ -554,6 +627,7 @@ export type WidgetSpec = name?: string; points?: { x: string | number; y: number }[]; }[]; + source?: ChartSourceRef; placement?: PlacementSpec; } | { diff --git a/app/client/packages/mcp/src/builder/templates.ts b/app/client/packages/mcp/src/builder/templates.ts index f806f4a4cf13..6c327630afbd 100644 --- a/app/client/packages/mcp/src/builder/templates.ts +++ b/app/client/packages/mcp/src/builder/templates.ts @@ -1,4 +1,5 @@ import { + compileChartDataBinding, compileInputValidation, compileSelectedRowBinding, compileTableDataBinding, @@ -122,24 +123,43 @@ export const WIDGET_TEMPLATES: Record = { footprint: { columns: 24, rows: 7 }, build: (spec) => { const label = spec.type === "select" ? spec.label ?? "Label" : "Label"; - const options = - spec.type === "select" && spec.options - ? spec.options - : [ - { label: "Option 1", value: "1" }, - { label: "Option 2", value: "2" }, - ]; + const optionsSource = + spec.type === "select" ? spec.optionsSource : undefined; + const staticOptions = spec.type === "select" ? spec.options : undefined; + + if (optionsSource !== undefined && staticOptions !== undefined) { + throw new Error("select cannot set both 'options' and 'optionsSource'"); + } + + // Two safe origins for the dropdown's data: + // (1) STATIC: a JSON-serialized array of {label,value} (no binding — JSON.stringify escapes the agent's safe + // text/scalars, so the array literal is inert), keyed by the literal "label"/"value" props. + // (2) QUERY source: `sourceData` compiled to `{{ .data ?? [] }}` (reusing the table-data emitter, whose + // query/field are strict identifier paths) and registered as a dynamic binding; optionLabel/optionValue are + // validated column-name LITERALS (charset admits no quote/brace/backtick), so they cannot become bindings. + const bound = optionsSource !== undefined; + const options = staticOptions ?? [ + { label: "Option 1", value: "1" }, + { label: "Option 2", value: "2" }, + ]; + const sourceData = bound + ? compileTableDataBinding({ + query: optionsSource.query, + field: optionsSource.field, + }) + : JSON.stringify(options); + const optionLabel = bound ? optionsSource.label : "label"; + const optionValue = bound ? optionsSource.value : "value"; return { footprint: { columns: 24, rows: 7 }, props: { label, - // SELECT_WIDGET derives its dropdown from `sourceData` (a JS-evaluated JSON array) keyed by - // optionLabel/optionValue — NOT the legacy `options` prop. The options are agent-supplied safe text/scalars - // and JSON.stringify escapes them, so the emitted array literal is inert (no binding can be smuggled in). - sourceData: JSON.stringify(options), - optionLabel: "label", - optionValue: "value", + // SELECT_WIDGET derives its dropdown from `sourceData` (a JS-evaluated array) keyed by + // optionLabel/optionValue — NOT the legacy `options` prop. + sourceData, + optionLabel, + optionValue, labelPosition: "Top", labelAlignment: "left", labelTextSize: "0.875rem", @@ -149,7 +169,10 @@ export const WIDGET_TEMPLATES: Record = { serverSideFiltering: false, animateLoading: true, responsiveBehavior: "fill", + // sourceData is a JS-toggled property either way; a query source additionally holds a `{{ }}` binding, so it + // is registered in dynamicBindingPathList (eval evaluates it) as well as dynamicPropertyPathList (JS mode). dynamicPropertyPathList: [{ key: "sourceData" }], + dynamicBindingPathList: bound ? [{ key: "sourceData" }] : [], }, }; }, @@ -384,20 +407,37 @@ export const WIDGET_TEMPLATES: Record = { const title = spec.type === "chart" ? spec.title ?? "Chart" : "Chart"; const chartType = spec.type === "chart" ? spec.chartType ?? "LINE_CHART" : "LINE_CHART"; + const source = spec.type === "chart" ? spec.source : undefined; const series = spec.type === "chart" ? spec.series ?? [] : []; - // Static chartData: { : { seriesName, data: [{x,y}] } }. No binding — the points are literals. + if (source !== undefined && series.length > 0) { + throw new Error("chart cannot set both 'series' and 'source'"); + } + + // Two safe data origins for CHART_WIDGET's `chartData` (an object keyed by series id, each `{ seriesName, data }`): + // (1) STATIC series: the points are literals — no binding. + // (2) QUERY source: a single series whose `data` is a compiler-authored map of the query's rows to {x,y}. Only + // the series' `data` is a binding, so it is registered at path `chartData.series1.data` (the widget's own + // binding-path format, see ChartWidget getPropertyPaneConfig). Query name, response field, and the x/y + // column names are all schema-validated charsets, so the emitted expression cannot be broken out of. const chartData: Record = {}; - series.forEach((oneSeries, index) => { - chartData[`series${index + 1}`] = { - seriesName: oneSeries.name ?? `Series ${index + 1}`, - data: (oneSeries.points ?? []).map((point) => ({ - x: point.x, - y: point.y, - })), + if (source !== undefined) { + chartData.series1 = { + seriesName: title, + data: compileChartDataBinding(source), }; - }); + } else { + series.forEach((oneSeries, index) => { + chartData[`series${index + 1}`] = { + seriesName: oneSeries.name ?? `Series ${index + 1}`, + data: (oneSeries.points ?? []).map((point) => ({ + x: point.x, + y: point.y, + })), + }; + }); + } return { footprint: { columns: 24, rows: 32 }, @@ -412,7 +452,9 @@ export const WIDGET_TEMPLATES: Record = { setAdaptiveYMin: false, animateLoading: true, responsiveBehavior: "fill", - dynamicBindingPathList: [], + // A query source's series data is a real dynamic binding; static points are not. + dynamicBindingPathList: + source !== undefined ? [{ key: "chartData.series1.data" }] : [], }, }; }, diff --git a/app/client/src/ce/constants/messages.ts b/app/client/src/ce/constants/messages.ts index f3a8b4fd17df..8626706e135b 100644 --- a/app/client/src/ce/constants/messages.ts +++ b/app/client/src/ce/constants/messages.ts @@ -255,6 +255,13 @@ export const MCP_TOKEN_VALUE_LABEL = () => "MCP token"; export const COPY_MCP_TOKEN = () => "Copy token"; export const MCP_TOKEN_COPIED = () => "MCP token copied"; export const MCP_TOKEN_COPY_FAILED = () => "Unable to copy MCP token."; +export const MCP_CLIENT_CONFIG_LABEL = () => "Client configuration"; +export const MCP_CLIENT_CONFIG_HELP = () => + "Paste this into your MCP client's config to connect (server URL + this token). Store it securely — it grants access as you."; +export const COPY_MCP_CLIENT_CONFIG = () => "Copy client configuration"; +export const MCP_CLIENT_CONFIG_COPIED = () => "Client configuration copied"; +export const MCP_CLIENT_CONFIG_COPY_FAILED = () => + "Unable to copy client configuration."; export const MCP_TOKENS_LOADING = () => "Loading MCP tokens…"; export const MCP_TOKENS_EMPTY = () => "No MCP tokens have been created."; export const MCP_TOKEN_CREATED_AT = () => "Created"; diff --git a/app/client/src/pages/AdminSettings/Profile/McpTokens.test.tsx b/app/client/src/pages/AdminSettings/Profile/McpTokens.test.tsx index b171dad04838..454839ccd948 100644 --- a/app/client/src/pages/AdminSettings/Profile/McpTokens.test.tsx +++ b/app/client/src/pages/AdminSettings/Profile/McpTokens.test.tsx @@ -89,6 +89,47 @@ describe("McpTokens", () => { ); }); + it("renders a copyable client-config snippet (server URL + token) after creation (M4-T3)", async () => { + Object.assign(navigator, { + clipboard: { writeText: jest.fn().mockResolvedValue(undefined) }, + }); + (McpTokenApi.create as jest.Mock).mockResolvedValue( + successResponse({ + id: "token-3", + token: "secret-token", + createdAt: "2026-07-10T12:00:00.000Z", + expiresAt: "2026-10-08T12:00:00.000Z", + }), + ); + renderComponent(); + + await screen.findByText("token-1"); + fireEvent.click(screen.getByRole("button", { name: "Create token" })); + await screen.findByLabelText("MCP token"); + + // The Modal renders in a portal, so query the whole document, not the render container. + const snippet = document.querySelector(".t--mcp-client-config"); + + expect(snippet).toBeTruthy(); + // The snippet embeds the server URL (origin + /mcp) and the one-time token as a bearer credential. + expect(snippet?.textContent).toContain("/mcp"); + expect(snippet?.textContent).toContain("Bearer secret-token"); + expect(snippet?.textContent).toContain("mcpServers"); + + fireEvent.click( + screen.getByRole("button", { name: "Copy client configuration" }), + ); + await waitFor(() => + expect(navigator.clipboard.writeText).toHaveBeenCalled(), + ); + + const copied = (navigator.clipboard.writeText as jest.Mock).mock + .calls[0][0] as string; + + expect(copied).toContain("secret-token"); + expect(JSON.parse(copied).mcpServers.appsmith.url).toContain("/mcp"); + }); + it("shows the MCP server URL (origin + /mcp) with a working copy button", async () => { Object.assign(navigator, { clipboard: { writeText: jest.fn().mockResolvedValue(undefined) }, diff --git a/app/client/src/pages/AdminSettings/Profile/McpTokens.tsx b/app/client/src/pages/AdminSettings/Profile/McpTokens.tsx index 86be95fcd8a5..e25b5899a460 100644 --- a/app/client/src/pages/AdminSettings/Profile/McpTokens.tsx +++ b/app/client/src/pages/AdminSettings/Profile/McpTokens.tsx @@ -13,9 +13,14 @@ import { toast, } from "@appsmith/ads"; import { + COPY_MCP_CLIENT_CONFIG, COPY_MCP_SERVER_URL, COPY_MCP_TOKEN, CREATE_MCP_TOKEN, + MCP_CLIENT_CONFIG_COPIED, + MCP_CLIENT_CONFIG_COPY_FAILED, + MCP_CLIENT_CONFIG_HELP, + MCP_CLIENT_CONFIG_LABEL, MCP_SERVER_URL_COPIED, MCP_SERVER_URL_COPY_FAILED, MCP_SERVER_URL_HELP, @@ -81,6 +86,22 @@ const TokenMeta = styled.div` // a user pastes into their MCP client is simply the current origin + /mcp. const MCP_SERVER_URL = `${window.location.origin}/mcp`; +// A ready-to-paste MCP client configuration (the common `mcpServers` shape used by Claude Desktop and compatible +// clients): the server URL plus this token as a bearer credential. Rendered once, in the token-created modal. +const buildClientConfig = (token: string) => + JSON.stringify( + { + mcpServers: { + appsmith: { + url: MCP_SERVER_URL, + headers: { Authorization: `Bearer ${token}` }, + }, + }, + }, + null, + 2, + ); + // A read-only, monospaced value with a copy-to-clipboard button — used for both the server URL and the one-time token. // `description` (when set) renders as the field's helper text, which the design system links via aria-describedby. function ReadOnlyCopyField(props: { @@ -226,6 +247,23 @@ function McpTokens() { } }; + const copyClientConfig = async () => { + if (!createdToken) { + return; + } + + try { + await navigator.clipboard.writeText( + buildClientConfig(createdToken.token), + ); + toast.show(createMessage(MCP_CLIENT_CONFIG_COPIED), { kind: "success" }); + } catch { + toast.show(createMessage(MCP_CLIENT_CONFIG_COPY_FAILED), { + kind: "error", + }); + } + }; + const revokeToken = async () => { if (!revokeTokenId) { return; @@ -383,6 +421,45 @@ function McpTokens() { onCopy={copyServerUrl} value={MCP_SERVER_URL} /> + + + + {createMessage(MCP_CLIENT_CONFIG_LABEL)} + +
+                  {buildClientConfig(createdToken?.token ?? "")}
+                
+ + {createMessage(MCP_CLIENT_CONFIG_HELP)} + +
+ +