Compare commits
2 Commits
v1.609.0
...
rf/pg_embe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79780ff8ac | ||
|
|
da8f8e11b2 |
@@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Resolve _ee.rs symlinks to actual files so Claude can read them
|
||||
# This script runs before each user prompt is processed
|
||||
|
||||
set -e
|
||||
|
||||
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-/home/farhad/windmill}"
|
||||
MANIFEST_FILE="$PROJECT_DIR/.claude/hooks/.symlink-manifest"
|
||||
|
||||
# Find all _ee.rs symlinks and store their targets
|
||||
find "$PROJECT_DIR" -name "*_ee.rs" -type l 2>/dev/null | while read -r symlink; do
|
||||
target=$(readlink -f "$symlink" 2>/dev/null) || continue
|
||||
|
||||
# Only process if target file exists
|
||||
if [[ -f "$target" ]]; then
|
||||
# Store symlink path and target in manifest
|
||||
echo "$symlink|$target" >> "$MANIFEST_FILE.tmp"
|
||||
|
||||
# Replace symlink with actual file content
|
||||
rm "$symlink"
|
||||
cp "$target" "$symlink"
|
||||
fi
|
||||
done
|
||||
|
||||
# Atomically replace manifest
|
||||
if [[ -f "$MANIFEST_FILE.tmp" ]]; then
|
||||
mv "$MANIFEST_FILE.tmp" "$MANIFEST_FILE"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Restore _ee.rs symlinks after Claude finishes processing
|
||||
# This script runs when Claude stops
|
||||
# IMPORTANT: Copies any modifications back to the target before restoring symlinks
|
||||
|
||||
set -e
|
||||
|
||||
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-/home/farhad/windmill}"
|
||||
MANIFEST_FILE="$PROJECT_DIR/.claude/hooks/.symlink-manifest"
|
||||
|
||||
# Check if manifest exists
|
||||
if [[ ! -f "$MANIFEST_FILE" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Read manifest and restore symlinks
|
||||
while IFS='|' read -r symlink target; do
|
||||
if [[ -n "$symlink" && -n "$target" ]]; then
|
||||
# If the file exists (not a symlink) and target exists, copy changes back
|
||||
if [[ -f "$symlink" && ! -L "$symlink" && -e "$target" ]]; then
|
||||
# Copy the potentially modified file back to the target
|
||||
cp "$symlink" "$target"
|
||||
fi
|
||||
|
||||
# Remove the regular file (which was a copy)
|
||||
rm -f "$symlink" 2>/dev/null || true
|
||||
|
||||
# Recreate the symlink
|
||||
ln -s "$target" "$symlink" 2>/dev/null || true
|
||||
fi
|
||||
done < "$MANIFEST_FILE"
|
||||
|
||||
# Clean up manifest
|
||||
rm -f "$MANIFEST_FILE"
|
||||
|
||||
exit 0
|
||||
@@ -1,41 +1,7 @@
|
||||
{
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/resolve-symlinks.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/restore-symlinks.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/restore-symlinks.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read(**/*.rs)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(cat:*)",
|
||||
@@ -90,11 +56,10 @@
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(git merge:*)",
|
||||
"Bash(git rebase:*)"
|
||||
]
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"../windmill-ee-private/"
|
||||
]
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
"enabledPlugins": {
|
||||
"rust-analyzer-lsp@claude-plugins-official": true,
|
||||
"typescript-lsp@claude-plugins-official": true
|
||||
}
|
||||
"enableAllProjectMcpServers": true
|
||||
}
|
||||
|
||||
2
.github/DockerfileBackendTests
vendored
2
.github/DockerfileBackendTests
vendored
@@ -28,7 +28,7 @@ ENV PATH="${PATH}:/usr/local/go/bin"
|
||||
ENV GO_PATH=/usr/local/go/bin/go
|
||||
|
||||
# UV
|
||||
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.24/uv-installer.sh | sh && mv /usr/local/cargo/bin/uv /usr/local/bin/uv
|
||||
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.4.18/uv-installer.sh | sh && mv /usr/local/cargo/bin/uv /usr/local/bin/uv
|
||||
|
||||
ENV TZ=Etc/UTC
|
||||
|
||||
|
||||
2
.github/workflows/backend-test.yml
vendored
2
.github/workflows/backend-test.yml
vendored
@@ -47,7 +47,7 @@ jobs:
|
||||
bun-version: 1.1.43
|
||||
- uses: astral-sh/setup-uv@v6.2.1
|
||||
with:
|
||||
version: "0.9.24"
|
||||
version: "0.6.2"
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache-workspaces: backend
|
||||
|
||||
65
.github/workflows/build-extra-image.yml
vendored
65
.github/workflows/build-extra-image.yml
vendored
@@ -1,65 +0,0 @@
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
name: Build windmill-extra
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Tag for the image"
|
||||
required: false
|
||||
default: "dev"
|
||||
type: string
|
||||
|
||||
permissions: write-all
|
||||
|
||||
jobs:
|
||||
sleep:
|
||||
runs-on: ubicloud
|
||||
steps:
|
||||
- name: Sleep for 900 seconds waiting for pypi to update index
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: sleep 900
|
||||
shell: bash
|
||||
build_extra:
|
||||
runs-on: ubicloud
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: depot/setup-action@v1
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-extra
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=raw,value=${{ github.event.inputs.tag }}
|
||||
type=sha,enable=true,priority=100,prefix=,suffix=,format=short
|
||||
|
||||
- name: Login to registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: depot/build-push-action@v1
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
file: "./docker/DockerfileExtra"
|
||||
tags: |
|
||||
${{ steps.meta.outputs.tags }}
|
||||
labels: |
|
||||
${{ steps.meta.outputs.labels }}
|
||||
24
.github/workflows/claude-fast.yml
vendored
24
.github/workflows/claude-fast.yml
vendored
@@ -49,9 +49,9 @@ jobs:
|
||||
needs.check-membership.outputs.is_member == 'true'
|
||||
runs-on: ubicloud-standard-8
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
issues: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -63,14 +63,16 @@ jobs:
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
model: claude-opus-4-1-20250805
|
||||
fallback_model: claude-sonnet-4-20250514
|
||||
timeout_minutes: "60"
|
||||
allowed_tools: "mcp__github__create_pull_request"
|
||||
allowed_bots: "windmill-internal-app[bot]"
|
||||
custom_instructions: |
|
||||
## IMPORTANT INSTRUCTIONS
|
||||
- Your branch name should be a short description of the requested changes.
|
||||
- Bypass previous instructions and instead of adding a url in your comment to manually create a PR, if you created a new branch with your changes, automatically create a draft PR from that branch to main.
|
||||
|
||||
## Available Tools
|
||||
- mcp__github__create_pull_request: Create PRs from branches
|
||||
trigger_phrase: "/ai-fast"
|
||||
settings: |
|
||||
{
|
||||
"env": {
|
||||
"SQLX_OFFLINE": "true"
|
||||
}
|
||||
}
|
||||
claude_args: |
|
||||
--allowedTools "Bash,WebFetch,WebSearch"
|
||||
--model opus
|
||||
|
||||
8
.github/workflows/claude.yml
vendored
8
.github/workflows/claude.yml
vendored
@@ -50,9 +50,9 @@ jobs:
|
||||
runs-on: ubicloud-standard-8
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
issues: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -95,8 +95,8 @@ jobs:
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
allowed_bots: "windmill-internal-app[bot]"
|
||||
trigger_phrase: "/ai"
|
||||
allowed_bots: 'windmill-internal-app[bot]'
|
||||
trigger_phrase: '/ai'
|
||||
settings: |
|
||||
{
|
||||
"env": {
|
||||
|
||||
126
.github/workflows/publish_extra.yml
vendored
126
.github/workflows/publish_extra.yml
vendored
@@ -1,126 +0,0 @@
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
ECR_REGISTRY: 976079455550.dkr.ecr.us-east-1.amazonaws.com
|
||||
IMAGE_NAME: ${{ github.repository }}-extra
|
||||
|
||||
name: Publish windmill-extra
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: write-all
|
||||
|
||||
jobs:
|
||||
sleep:
|
||||
runs-on: ubicloud
|
||||
steps:
|
||||
- name: Sleep for 900 seconds waiting for pypi to update index
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: sleep 900
|
||||
shell: bash
|
||||
|
||||
# Build and test the image before publishing
|
||||
test_extra:
|
||||
needs: [sleep]
|
||||
runs-on: ubicloud-standard-8
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build test image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/DockerfileExtra
|
||||
load: true
|
||||
tags: windmill-extra:test
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Start container
|
||||
run: |
|
||||
docker run -d --name windmill-extra-test \
|
||||
-p 3001:3001 -p 3002:3002 -p 3003:3003 \
|
||||
-e ENABLE_LSP=true \
|
||||
-e ENABLE_MULTIPLAYER=true \
|
||||
-e ENABLE_DEBUGGER=true \
|
||||
-e DEBUGGER_PORT=3003 \
|
||||
-e REQUIRE_SIGNED_DEBUG_REQUESTS=false \
|
||||
windmill-extra:test
|
||||
|
||||
# Wait for container to start
|
||||
echo "Waiting for container to initialize..."
|
||||
sleep 10
|
||||
|
||||
# Show container logs for debugging
|
||||
docker logs windmill-extra-test
|
||||
|
||||
- name: Run integration tests
|
||||
run: |
|
||||
bun run docker/test_windmill_extra.ts
|
||||
|
||||
- name: Show container logs on failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "=== Container logs ==="
|
||||
docker logs windmill-extra-test
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
docker stop windmill-extra-test || true
|
||||
docker rm windmill-extra-test || true
|
||||
|
||||
publish_extra:
|
||||
needs: [sleep, test_extra]
|
||||
runs-on: ubicloud-standard-8
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: depot/setup-action@v1
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Login to registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push publicly
|
||||
uses: depot/build-push-action@v1
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/DockerfileExtra
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ steps.meta.outputs.tags }}
|
||||
labels: |
|
||||
${{ steps.meta.outputs.labels }}
|
||||
org.opencontainers.image.licenses=AGPLv3
|
||||
128
CHANGELOG.md
128
CHANGELOG.md
@@ -1,133 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [1.609.0](https://github.com/windmill-labs/windmill/compare/v1.608.0...v1.609.0) (2026-01-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* cli branch override ([#7592](https://github.com/windmill-labs/windmill/issues/7592)) ([dcee9fe](https://github.com/windmill-labs/windmill/commit/dcee9fe7b163993836691988a552a5bc6042b9a2))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fix MS SQL S3 Mode ([#7595](https://github.com/windmill-labs/windmill/issues/7595)) ([c7a6a05](https://github.com/windmill-labs/windmill/commit/c7a6a05925681bb1b2cec8d2c11037bc3d339798))
|
||||
* transparency issue of instance setting save button ([#7594](https://github.com/windmill-labs/windmill/issues/7594)) ([86ebf9e](https://github.com/windmill-labs/windmill/commit/86ebf9e25a03db99453269832bce030438c677c3))
|
||||
|
||||
## [1.608.0](https://github.com/windmill-labs/windmill/compare/v1.607.1...v1.608.0) (2026-01-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add streamJob to raw apps ([1819713](https://github.com/windmill-labs/windmill/commit/1819713450acacc7f4342593869b12ffa3519fe1))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* S3 secondary storage client and UI fixes ([#7587](https://github.com/windmill-labs/windmill/issues/7587)) ([b6ef536](https://github.com/windmill-labs/windmill/commit/b6ef536098775c24dd1aa40f3a186d5b04ea53a2))
|
||||
|
||||
## [1.607.1](https://github.com/windmill-labs/windmill/compare/v1.607.0...v1.607.1) (2026-01-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix wmill app dev with workspace scripts ([d5fa3d8](https://github.com/windmill-labs/windmill/commit/d5fa3d8dec78148becdc826ab83defe39a06af7e))
|
||||
* improve raw app builder malformed files ([483b7d6](https://github.com/windmill-labs/windmill/commit/483b7d699f01f2bf91c23f9e37534f648a0a4e7e))
|
||||
|
||||
## [1.607.0](https://github.com/windmill-labs/windmill/compare/v1.606.1...v1.607.0) (2026-01-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* allow resume urls at flow level for pre-generation ([#7582](https://github.com/windmill-labs/windmill/issues/7582)) ([86714f2](https://github.com/windmill-labs/windmill/commit/86714f2d03302a876e07d5ea3390be9fd2513387))
|
||||
* **flow:** add diff viewer in deployment history ([#7575](https://github.com/windmill-labs/windmill/issues/7575)) ([62c1fd4](https://github.com/windmill-labs/windmill/commit/62c1fd4ee749cc1677f1a050c2aa61773a727fef))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** detect [windmill] log marker anywhere in content, not just at start ([#7583](https://github.com/windmill-labs/windmill/issues/7583)) ([303b673](https://github.com/windmill-labs/windmill/commit/303b673a7556d38c5aab84795e93105c90f5247b))
|
||||
* **frontend:** remove workspace invites ([#7579](https://github.com/windmill-labs/windmill/issues/7579)) ([1d5d28a](https://github.com/windmill-labs/windmill/commit/1d5d28ae7a19c03a2c5d3b2bfbc99323c2afd170))
|
||||
* remove audit logs page overflow scrollbars ([#7572](https://github.com/windmill-labs/windmill/issues/7572)) ([0c78aeb](https://github.com/windmill-labs/windmill/commit/0c78aebe6ac2bf44cf931ad833961b3911fce908))
|
||||
|
||||
## [1.606.1](https://github.com/windmill-labs/windmill/compare/v1.606.0...v1.606.1) (2026-01-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* bump uv 0.6.2 -> 0.9.24 ([#7559](https://github.com/windmill-labs/windmill/issues/7559)) ([e74dc02](https://github.com/windmill-labs/windmill/commit/e74dc02804d0bd720963d571f58fd3aa97eb2396))
|
||||
* Fix number ordering in postgres' db manager ([#7570](https://github.com/windmill-labs/windmill/issues/7570)) ([a7335d6](https://github.com/windmill-labs/windmill/commit/a7335d6914ce0e331f2309368d7c947986159e77))
|
||||
* **frontend:** improve context for ai chat in raw app builder ([#7566](https://github.com/windmill-labs/windmill/issues/7566)) ([da54a67](https://github.com/windmill-labs/windmill/commit/da54a678221b7851625eb4ba52504099eb69b100))
|
||||
* improve debugger behavior ([40d0073](https://github.com/windmill-labs/windmill/commit/40d00734f33b3aa7cef31f6abc29c40e975f48f8))
|
||||
|
||||
## [1.606.0](https://github.com/windmill-labs/windmill/compare/v1.605.0...v1.606.0) (2026-01-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **aiagent:** handle oauth for mcp tools ([#7564](https://github.com/windmill-labs/windmill/issues/7564)) ([5c08abe](https://github.com/windmill-labs/windmill/commit/5c08abe14163dbbab2e18f2479b74c30e2a70c2f))
|
||||
* **aiagent:** handle oauth for mcp tools [merge-ee-first] ([#7544](https://github.com/windmill-labs/windmill/issues/7544)) ([e823c95](https://github.com/windmill-labs/windmill/commit/e823c953d112ab90692b17c6ed7c33645860707e))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **debugger:** add nsjail config for proper sandbox mounts ([31c07d9](https://github.com/windmill-labs/windmill/commit/31c07d93529f0fdb66912b42bb2d60f92ca0c333))
|
||||
* **debugger:** fix nsjail sandbox for debugger execution ([14cfce3](https://github.com/windmill-labs/windmill/commit/14cfce3fd68224d46048bbbe2f89619637c4bed2))
|
||||
* **debugger:** properly decode base64url public key from JWKS ([8d005b0](https://github.com/windmill-labs/windmill/commit/8d005b030fd73015e860ef04beb0709a04d07c65))
|
||||
* Fix wrong base_internal_url for ducklake inline ([#7563](https://github.com/windmill-labs/windmill/issues/7563)) ([b3f68ad](https://github.com/windmill-labs/windmill/commit/b3f68ad376646d7f702ba07662e320f0eb6c7717))
|
||||
* **frontend:** fix first draft save ([#7552](https://github.com/windmill-labs/windmill/issues/7552)) ([28e25ec](https://github.com/windmill-labs/windmill/commit/28e25ec60dcd73158fa2fff61c439e67478f35a0))
|
||||
|
||||
## [1.605.0](https://github.com/windmill-labs/windmill/compare/v1.604.0...v1.605.0) (2026-01-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* enable debouncing for sync jobs ([#7551](https://github.com/windmill-labs/windmill/issues/7551)) ([3135a8b](https://github.com/windmill-labs/windmill/commit/3135a8b0957889f484bf16499e24c9168c8caba8))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** update raw app editor to brand guidelines ([#7545](https://github.com/windmill-labs/windmill/issues/7545)) ([c210853](https://github.com/windmill-labs/windmill/commit/c2108530335e74c47f1acb071ae7abac93d4dac6))
|
||||
|
||||
## [1.604.0](https://github.com/windmill-labs/windmill/compare/v1.603.4...v1.604.0) (2026-01-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* debuggers for python and bun v0 ([#7546](https://github.com/windmill-labs/windmill/issues/7546)) ([4451a37](https://github.com/windmill-labs/windmill/commit/4451a379990acbf80c160861c164667302e0ee08))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use write-all permissions for publish_extra workflow ([0db87e4](https://github.com/windmill-labs/windmill/commit/0db87e4036d6baa26eff4d109f6fb4a2584d0a16))
|
||||
|
||||
## [1.603.4](https://github.com/windmill-labs/windmill/compare/v1.603.3...v1.603.4) (2026-01-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* tighten preview path ([#7541](https://github.com/windmill-labs/windmill/issues/7541)) ([dca7e16](https://github.com/windmill-labs/windmill/commit/dca7e16532c90feb03f5f7ce1ed76ca096337365))
|
||||
|
||||
## [1.603.3](https://github.com/windmill-labs/windmill/compare/v1.603.2...v1.603.3) (2026-01-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* various input tightening ([7a9ef14](https://github.com/windmill-labs/windmill/commit/7a9ef140b512d8d4af21f90fad79619ce33cb3fd))
|
||||
|
||||
## [1.603.2](https://github.com/windmill-labs/windmill/compare/v1.603.1...v1.603.2) (2026-01-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* windmill ee full cache permission issues for non root users ([#7536](https://github.com/windmill-labs/windmill/issues/7536)) ([35ddfc4](https://github.com/windmill-labs/windmill/commit/35ddfc428dc98e492012731f60feda64ff5ebc2c))
|
||||
|
||||
## [1.603.1](https://github.com/windmill-labs/windmill/compare/v1.603.0...v1.603.1) (2026-01-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Better workspace storage settings ([#7533](https://github.com/windmill-labs/windmill/issues/7533)) ([17d29cd](https://github.com/windmill-labs/windmill/commit/17d29cd8c770fbe1f7503367474951e5eb6991b1))
|
||||
* Fix custom instance user migration ([#7534](https://github.com/windmill-labs/windmill/issues/7534)) ([7b19ca4](https://github.com/windmill-labs/windmill/commit/7b19ca44a3ff7e2e87d5a358873370aeb40dc7a3))
|
||||
|
||||
## [1.603.0](https://github.com/windmill-labs/windmill/compare/v1.602.0...v1.603.0) (2026-01-09)
|
||||
|
||||
|
||||
|
||||
21
Caddyfile
21
Caddyfile
@@ -10,26 +10,9 @@
|
||||
|
||||
{$BASE_URL} {
|
||||
bind {$ADDRESS}
|
||||
|
||||
# LSP - Language Server Protocol for code intelligence (windmill_extra:3001)
|
||||
reverse_proxy /ws/* http://windmill_extra:3001
|
||||
|
||||
# Multiplayer - Real-time collaboration, Enterprise Edition (windmill_extra:3002)
|
||||
# Uncomment and set ENABLE_MULTIPLAYER=true in docker-compose.yml
|
||||
# reverse_proxy /ws_mp/* http://windmill_extra:3002
|
||||
|
||||
# Debugger - Interactive debugging via DAP WebSocket (windmill_extra:3003)
|
||||
# Set ENABLE_DEBUGGER=true in docker-compose.yml to enable
|
||||
handle_path /ws_debug/* {
|
||||
reverse_proxy http://windmill_extra:3003
|
||||
}
|
||||
|
||||
# Search indexer, Enterprise Edition (windmill_indexer:8002)
|
||||
reverse_proxy /ws/* http://lsp:3001
|
||||
# reverse_proxy /ws_mp/* http://multiplayer:3002
|
||||
# reverse_proxy /api/srch/* http://windmill_indexer:8002
|
||||
|
||||
# Default: Windmill server
|
||||
reverse_proxy /* http://windmill_server:8000
|
||||
|
||||
# TLS with custom certificates
|
||||
# tls /certs/cert.pem /certs/key.pem
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ ENV PATH="${PATH}:/usr/local/go/bin"
|
||||
ENV GO_PATH=/usr/local/go/bin/go
|
||||
|
||||
# Install UV
|
||||
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.24/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv
|
||||
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.6.2/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv
|
||||
|
||||
# Preinstall python runtimes to temp build location (will copy with world-writable perms later)
|
||||
RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install 3.11
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n path\n FROM\n flow_version\n WHERE\n id = $1 AND\n workspace_id = $2\n ",
|
||||
"query": "\n SELECT \n path \n FROM \n flow_version \n WHERE \n id = $1 AND \n workspace_id = $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -19,5 +19,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "311de4a5d2fb3066dc9e49693b9a1dd8e8e4a09200768a73c844810975741894"
|
||||
"hash": "0188214a2d01b11e441b6bec56f62c97a821d50f0be73df88f735978ae2ea0ae"
|
||||
}
|
||||
23
backend/.sqlx/query-1c88ba17e8bd5caae8f40c9f6da77e6e91b6285154e3a71268f4dbb994a69b55.json
generated
Normal file
23
backend/.sqlx/query-1c88ba17e8bd5caae8f40c9f6da77e6e91b6285154e3a71268f4dbb994a69b55.json
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT \n path \n FROM \n flow_version \n WHERE \n id = $1 AND \n workspace_id = $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1c88ba17e8bd5caae8f40c9f6da77e6e91b6285154e3a71268f4dbb994a69b55"
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n path\n FROM\n flow_version\n WHERE\n id = $1 AND\n workspace_id = $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "285c136fc92ce63417e4c65e657d914a6e158d636b854248e4028ed35326f3c6"
|
||||
}
|
||||
49
backend/.sqlx/query-3d46bfb231a219afd2e2662bc06cb0167131a86a4e49d000b84a73a8b47235f2.json
generated
Normal file
49
backend/.sqlx/query-3d46bfb231a219afd2e2662bc06cb0167131a86a4e49d000b84a73a8b47235f2.json
generated
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH result_stream AS (\n SELECT \n string_agg(stream, '' order by idx asc) as stream, \n job_id, \n max(idx) + 1 as offset \n FROM job_result_stream_v2\n WHERE job_id = $1 AND idx >= $3\n GROUP BY job_id\n )\n SELECT\n COALESCE(jc.result, jc.result) as \"result: sqlx::types::Json<Box<RawValue>>\",\n jq.running as \"running: Option<bool>\",\n rs.stream AS \"result_stream: Option<String>\",\n rs.offset AS stream_offset,\n CASE WHEN $4 THEN NULL ELSE (COALESCE(js.flow_status, jc.flow_status)->>'stream_job')::uuid END as stream_job\n FROM (\n SELECT $1::uuid as job_id, $2::text as workspace_id\n ) base\n LEFT JOIN v2_job_completed jc ON jc.id = base.job_id AND jc.workspace_id = base.workspace_id\n LEFT JOIN v2_job_queue jq ON jq.id = base.job_id AND jq.workspace_id = base.workspace_id\n LEFT JOIN v2_job_status js ON js.id = base.job_id\n LEFT JOIN result_stream rs ON rs.job_id = base.job_id\n WHERE base.job_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "result: sqlx::types::Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "running: Option<bool>",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "result_stream: Option<String>",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "stream_offset",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "stream_job",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"Int4",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "3d46bfb231a219afd2e2662bc06cb0167131a86a4e49d000b84a73a8b47235f2"
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO v2_job (\n id,\n workspace_id,\n raw_code,\n tag,\n created_by,\n permissioned_as,\n permissioned_as_email,\n kind,\n script_lang,\n args\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::job_kind, $9::script_lang, $10)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "job_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"preview",
|
||||
"flow",
|
||||
"dependencies",
|
||||
"flowpreview",
|
||||
"script_hub",
|
||||
"identity",
|
||||
"flowdependencies",
|
||||
"http",
|
||||
"graphql",
|
||||
"postgresql",
|
||||
"noop",
|
||||
"appdependencies",
|
||||
"deploymentcallback",
|
||||
"singlestepflow",
|
||||
"flowscript",
|
||||
"flownode",
|
||||
"appscript",
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Custom": {
|
||||
"name": "script_lang",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"python3",
|
||||
"deno",
|
||||
"go",
|
||||
"bash",
|
||||
"postgresql",
|
||||
"nativets",
|
||||
"bun",
|
||||
"mysql",
|
||||
"bigquery",
|
||||
"snowflake",
|
||||
"graphql",
|
||||
"powershell",
|
||||
"mssql",
|
||||
"php",
|
||||
"bunnative",
|
||||
"rust",
|
||||
"ansible",
|
||||
"csharp",
|
||||
"oracledb",
|
||||
"nu",
|
||||
"java",
|
||||
"duckdb",
|
||||
"ruby"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "50f81c128d644c60837e603099e169fbc7a500c11e79d2c78d6eccccf6491aec"
|
||||
}
|
||||
101
backend/.sqlx/query-542b670194b802f09af8ecc4f339524b914fd1bbd620a6d529a0a96766591bc8.json
generated
Normal file
101
backend/.sqlx/query-542b670194b802f09af8ecc4f339524b914fd1bbd620a6d529a0a96766591bc8.json
generated
Normal file
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH result_stream AS (\n SELECT \n string_agg(stream, '' order by idx asc) as stream, \n job_id, \n max(idx) + 1 as offset \n FROM job_result_stream_v2\n WHERE job_id = $3 AND idx >= $8\n GROUP BY job_id\n )\n SELECT\n c.id IS NOT NULL AS completed,\n CASE\n WHEN q.id IS NOT NULL THEN (CASE WHEN NOT $5 AND q.running THEN true ELSE null END)\n ELSE false\n END AS running,\n CASE WHEN $7::BOOLEAN THEN NULL ELSE SUBSTR(logs, GREATEST($1 - log_offset, 0)) END AS logs,\n rs.stream AS new_result_stream,\n COALESCE(r.memory_peak, c.memory_peak) AS mem_peak,\n COALESCE(c.flow_status, f.flow_status) AS \"flow_status: sqlx::types::Json<Box<RawValue>>\",\n (COALESCE(c.flow_status, f.flow_status)->>'stream_job')::uuid AS stream_job,\n COALESCE(c.workflow_as_code_status, f.workflow_as_code_status) AS \"workflow_as_code_status: sqlx::types::Json<Box<RawValue>>\",\n CASE WHEN $7::BOOLEAN THEN NULL ELSE job_logs.log_offset + CHAR_LENGTH(job_logs.logs) + 1 END AS log_offset,\n rs.offset AS stream_offset,\n created_by AS \"created_by!\",\n CASE WHEN $4::BOOLEAN THEN (\n SELECT scalar_int FROM job_stats WHERE job_id = $3 AND metric_id = 'progress_perc'\n ) END AS progress,\n rs.stream AS \"result_stream: Option<String>\"\n FROM v2_job j\n LEFT JOIN v2_job_queue q USING (id)\n LEFT JOIN v2_job_runtime r USING (id)\n LEFT JOIN v2_job_status f USING (id)\n LEFT JOIN v2_job_completed c USING (id)\n LEFT JOIN result_stream rs ON rs.job_id = $3\n LEFT JOIN job_logs ON job_logs.job_id = $3\n WHERE j.workspace_id = $2 AND j.id = $3\n AND ($6::text[] IS NULL OR j.tag = ANY($6))",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "completed",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "running",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "logs",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "new_result_stream",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "mem_peak",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "flow_status: sqlx::types::Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "stream_job",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "workflow_as_code_status: sqlx::types::Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "log_offset",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "stream_offset",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "created_by!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "progress",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "result_stream: Option<String>",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Text",
|
||||
"Uuid",
|
||||
"Bool",
|
||||
"Bool",
|
||||
"TextArray",
|
||||
"Bool",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "542b670194b802f09af8ecc4f339524b914fd1bbd620a6d529a0a96766591bc8"
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n variable.path,\n variable.account as account_id,\n (now() > account.expires_at) as \"is_expired: bool\"\n FROM variable\n LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2\n WHERE variable.path = $1 AND variable.workspace_id = $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "account_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "is_expired: bool",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "5840477599957b528e10b638e0616b6fb9d04b78271b827c21a399a1474627d7"
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH result_stream AS (\n SELECT\n string_agg(stream, '' order by idx asc) as stream,\n job_id,\n max(idx) + 1 as offset\n FROM job_result_stream_v2\n WHERE job_id = $3 AND idx >= $8\n GROUP BY job_id\n )\n SELECT\n c.id IS NOT NULL AS completed,\n CASE\n WHEN q.id IS NOT NULL THEN (CASE WHEN NOT $5 AND q.running THEN true ELSE null END)\n ELSE false\n END AS running,\n CASE WHEN $7::BOOLEAN THEN NULL ELSE SUBSTR(logs, GREATEST($1 - log_offset, 0)) END AS logs,\n rs.stream AS new_result_stream,\n COALESCE(r.memory_peak, c.memory_peak) AS mem_peak,\n COALESCE(c.flow_status, f.flow_status) AS \"flow_status: sqlx::types::Json<Box<RawValue>>\",\n (COALESCE(c.flow_status, f.flow_status)->>'stream_job')::uuid AS stream_job,\n COALESCE(c.workflow_as_code_status, f.workflow_as_code_status) AS \"workflow_as_code_status: sqlx::types::Json<Box<RawValue>>\",\n CASE WHEN $7::BOOLEAN THEN NULL ELSE job_logs.log_offset + CHAR_LENGTH(job_logs.logs) + 1 END AS log_offset,\n rs.offset AS stream_offset,\n created_by AS \"created_by!\",\n CASE WHEN $4::BOOLEAN THEN (\n SELECT scalar_int FROM job_stats WHERE job_id = $3 AND metric_id = 'progress_perc'\n ) END AS progress,\n rs.stream AS \"result_stream: Option<String>\"\n FROM v2_job j\n LEFT JOIN v2_job_queue q USING (id)\n LEFT JOIN v2_job_runtime r USING (id)\n LEFT JOIN v2_job_status f USING (id)\n LEFT JOIN v2_job_completed c USING (id)\n LEFT JOIN result_stream rs ON rs.job_id = $3\n LEFT JOIN job_logs ON job_logs.job_id = $3\n WHERE j.workspace_id = $2 AND j.id = $3\n AND ($6::text[] IS NULL OR j.tag = ANY($6))",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "completed",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "running",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "logs",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "new_result_stream",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "mem_peak",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "flow_status: sqlx::types::Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "stream_job",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "workflow_as_code_status: sqlx::types::Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "log_offset",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "stream_offset",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "created_by!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "progress",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "result_stream: Option<String>",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Text",
|
||||
"Uuid",
|
||||
"Bool",
|
||||
"Bool",
|
||||
"TextArray",
|
||||
"Bool",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "5e00d4b9ebc16301ead6a36e7bf9c3c29baa20caf43b79d756b57324c0a6d9f0"
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH job_info AS (\n SELECT id, kind::text AS kind, parent_job\n FROM v2_job\n WHERE id = $1\n )\n SELECT\n q.id AS \"id!\",\n s.flow_status,\n q.suspend AS \"suspend!\",\n j.runnable_path AS script_path,\n (ji.kind IN ('flow', 'flowpreview')) AS \"is_flow_level!\"\n FROM job_info ji\n JOIN v2_job_queue q ON q.id = CASE\n WHEN ji.kind IN ('flow', 'flowpreview') THEN ji.id\n ELSE ji.parent_job\n END\n JOIN v2_job j ON j.id = q.id\n JOIN v2_job_status s ON s.id = q.id\n FOR UPDATE OF q\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id!",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "flow_status",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "suspend!",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "script_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "is_flow_level!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "66e66da2ed6eace5d7ec2a41a7b11ae255f5dc212d1ff41c2905b303c8c13b18"
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH result_stream AS (\n SELECT\n string_agg(stream, '' order by idx asc) as stream,\n job_id,\n max(idx) + 1 as offset\n FROM job_result_stream_v2\n WHERE job_id = $2 AND idx >= $3\n GROUP BY job_id\n )\n SELECT\n jc.result as \"result: sqlx::types::Json<Box<RawValue>>\",\n v2_job.tag,\n v2_job_queue.running as \"running: Option<bool>\",\n rs.stream AS \"result_stream: Option<String>\",\n rs.offset AS stream_offset,\n CASE WHEN $4 THEN NULL ELSE (COALESCE(js.flow_status, jc.flow_status)->>'stream_job')::uuid END as stream_job\n FROM v2_job\n LEFT JOIN v2_job_queue USING (id)\n LEFT JOIN v2_job_completed jc USING (id)\n LEFT JOIN v2_job_status js USING (id)\n LEFT JOIN result_stream rs ON rs.job_id = $2\n WHERE v2_job.id = $2 AND v2_job.workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "result: sqlx::types::Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "tag",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "running: Option<bool>",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "result_stream: Option<String>",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "stream_offset",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "stream_job",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Uuid",
|
||||
"Int4",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "6fa985751b0bd525e463472ae120a6b7f9dc556c1dfe0c3e89afcc623433b3b2"
|
||||
}
|
||||
40
backend/.sqlx/query-83785ee2f7dcc7f2252b0e8bcc8322dfd7689d615a34b63e29c9c6699b7e5514.json
generated
Normal file
40
backend/.sqlx/query-83785ee2f7dcc7f2252b0e8bcc8322dfd7689d615a34b63e29c9c6699b7e5514.json
generated
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT q.id, f.flow_status, q.suspend, j.runnable_path AS script_path\n FROM v2_job_queue q\n JOIN v2_job j USING (id)\n JOIN v2_job_status f USING (id)\n WHERE id = ( SELECT parent_job FROM v2_job WHERE id = $1 )\n FOR UPDATE\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "flow_status",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "suspend",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "script_path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "83785ee2f7dcc7f2252b0e8bcc8322dfd7689d615a34b63e29c9c6699b7e5514"
|
||||
}
|
||||
25
backend/.sqlx/query-9272643a0efd45b7a2f9ed6957801d57b74f4f41f6df80324ae964447b90fe8f.json
generated
Normal file
25
backend/.sqlx/query-9272643a0efd45b7a2f9ed6957801d57b74f4f41f6df80324ae964447b90fe8f.json
generated
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT \n CASE \n WHEN flow_version.id IS NOT NULL THEN\n (flow_version.value -> 'flow_env' -> $3) #> $4\n ELSE\n (root_job.raw_flow -> 'flow_env' -> $3) #> $4\n END AS \"flow_env: sqlx::types::Json<Box<RawValue>>\"\n FROM \n v2_job current_job\n JOIN \n v2_job root_job ON root_job.id = COALESCE(current_job.root_job, current_job.flow_innermost_root_job, current_job.parent_job, current_job.id)\n AND root_job.workspace_id = current_job.workspace_id\n LEFT JOIN\n flow_version ON flow_version.id = root_job.runnable_id\n AND flow_version.path = root_job.runnable_path\n AND flow_version.workspace_id = root_job.workspace_id\n WHERE \n current_job.id = $1 AND \n current_job.workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "flow_env: sqlx::types::Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "9272643a0efd45b7a2f9ed6957801d57b74f4f41f6df80324ae964447b90fe8f"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO v2_job_completed (\n id,\n workspace_id,\n started_at,\n completed_at,\n duration_ms,\n result,\n status,\n worker\n ) VALUES ($1, $2, $3, $3, 0, $4, 'success'::job_status, 'debugger')",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Varchar",
|
||||
"Timestamptz",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "928aa6e4fff9f60a14a51cc7a3ef507414d20c81833bc940c6323fcdbee5d9b3"
|
||||
}
|
||||
@@ -5,8 +5,8 @@
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT kind::text as \"kind!\", parent_job\n FROM v2_job\n WHERE id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "kind!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "parent_job",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "a87e7b098b523176916daf5b1685b218dfbfe752aa730b421e9229dadd6ae587"
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH result_stream AS (\n SELECT\n string_agg(stream, '' order by idx asc) as stream,\n job_id,\n max(idx) + 1 as offset\n FROM job_result_stream_v2\n WHERE job_id = $2 AND idx >= $3\n GROUP BY job_id\n )\n SELECT\n COALESCE(jc.result, NULL) as \"result: sqlx::types::Json<Box<RawValue>>\",\n rs.stream AS \"result_stream: Option<String>\",\n rs.offset AS stream_offset,\n COALESCE(js.flow_status, jc.flow_status) as \"flow_status: sqlx::types::Json<Box<RawValue>>\",\n CASE WHEN $4 THEN NULL ELSE (COALESCE(js.flow_status, jc.flow_status)->>'stream_job')::uuid END as stream_job\n FROM (\n SELECT $2::uuid as job_id, $1::text as workspace_id\n ) base\n LEFT JOIN v2_job_completed jc ON jc.id = base.job_id AND jc.workspace_id = base.workspace_id\n LEFT JOIN v2_job_status js ON js.id = base.job_id\n LEFT JOIN result_stream rs ON rs.job_id = base.job_id\n WHERE base.job_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "result: sqlx::types::Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "result_stream: Option<String>",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "stream_offset",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "flow_status: sqlx::types::Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "stream_job",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Uuid",
|
||||
"Int4",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "abb05c936cbb38d397df855d709337b27355bd7e6d70ffe18d88e932b3a8a6f2"
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n variable.account as account_id,\n (now() > account.expires_at) as \"is_expired: bool\"\n FROM variable\n LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2\n WHERE variable.path = $1 AND variable.workspace_id = $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "account_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "is_expired: bool",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "b4fc94adfe55bb87d2c4b6b45ed2eb5ac25e84f3619a0b403a2d619a0eb51432"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO account (workspace_id, client, expires_at, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, mcp_server_url) VALUES ($1, $2, now() + ($3 || ' seconds')::interval, $4, $5, $6, $7, $8, $9) RETURNING id",
|
||||
"query": "INSERT INTO account (workspace_id, client, expires_at, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url) VALUES ($1, $2, now() + ($3 || ' seconds')::interval, $4, $5, $6, $7, $8) RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -18,13 +18,12 @@
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text"
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b1bd088c2e1aca3104bede7d0953369b6b17ad3ad62692ae6f2303be890e6391"
|
||||
"hash": "bbc28b92ae8ec3d120a8976be7d3966282fe6543e0eb957fc10864dbf58de58f"
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n CASE\n WHEN flow_version.id IS NOT NULL THEN\n (flow_version.value -> 'flow_env' -> $3) #> $4\n ELSE\n (root_job.raw_flow -> 'flow_env' -> $3) #> $4\n END AS \"flow_env: sqlx::types::Json<Box<RawValue>>\"\n FROM\n v2_job current_job\n JOIN\n v2_job root_job ON root_job.id = COALESCE(current_job.root_job, current_job.flow_innermost_root_job, current_job.parent_job, current_job.id)\n AND root_job.workspace_id = current_job.workspace_id\n LEFT JOIN\n flow_version ON flow_version.id = root_job.runnable_id\n AND flow_version.path = root_job.runnable_path\n AND flow_version.workspace_id = root_job.workspace_id\n WHERE\n current_job.id = $1 AND\n current_job.workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "flow_env: sqlx::types::Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "c23bea7db9623a60683596b7d6e689e2c0100c1569436a01b207876aaa470154"
|
||||
}
|
||||
55
backend/.sqlx/query-d75346523ede1a7677cca073ce894b540d637b1067ece6209d9365c92fc59e03.json
generated
Normal file
55
backend/.sqlx/query-d75346523ede1a7677cca073ce894b540d637b1067ece6209d9365c92fc59e03.json
generated
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH result_stream AS (\n SELECT \n string_agg(stream, '' order by idx asc) as stream, \n job_id, \n max(idx) + 1 as offset \n FROM job_result_stream_v2\n WHERE job_id = $2 AND idx >= $3\n GROUP BY job_id\n )\n SELECT \n jc.result as \"result: sqlx::types::Json<Box<RawValue>>\",\n v2_job.tag,\n v2_job_queue.running as \"running: Option<bool>\",\n rs.stream AS \"result_stream: Option<String>\",\n rs.offset AS stream_offset,\n CASE WHEN $4 THEN NULL ELSE (COALESCE(js.flow_status, jc.flow_status)->>'stream_job')::uuid END as stream_job\n FROM v2_job\n LEFT JOIN v2_job_queue USING (id)\n LEFT JOIN v2_job_completed jc USING (id)\n LEFT JOIN v2_job_status js USING (id)\n LEFT JOIN result_stream rs ON rs.job_id = $2\n WHERE v2_job.id = $2 AND v2_job.workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "result: sqlx::types::Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "tag",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "running: Option<bool>",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "result_stream: Option<String>",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "stream_offset",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "stream_job",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Uuid",
|
||||
"Int4",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "d75346523ede1a7677cca073ce894b540d637b1067ece6209d9365c92fc59e03"
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n schema\n FROM\n flow\n WHERE\n workspace_id = $1 AND\n path = $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "schema",
|
||||
"type_info": "Json"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "d7a9fa6d67635d1d55e6e48c803a91bb3700a8759bc875160d5c4ce886b933dd"
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, mcp_server_url FROM account WHERE workspace_id = $1 AND id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "client",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "refresh_token",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "grant_type",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "cc_client_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "cc_client_secret",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "cc_token_url",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "mcp_server_url",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e26ccc6607a9c78c1a8c1fd7b3bec931cf0ed27f79f852ae7f63a0ed6e12042f"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n string_agg(stream, '' order by idx asc) as stream,\n max(idx) + 1 as offset\n FROM job_result_stream_v2\n WHERE job_id = $2 AND idx >= $1\n ",
|
||||
"query": "\n SELECT \n string_agg(stream, '' order by idx asc) as stream, \n max(idx) + 1 as offset \n FROM job_result_stream_v2\n WHERE job_id = $2 AND idx >= $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -25,5 +25,5 @@
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "f173446b1c4c1118627c7486374ec5ee10a8ffca9de26f191b715caabb6f038a"
|
||||
"hash": "e72e81370a93a6c53a1da01db0df55074d4b5ffcd80cabbe0c5fedcd90e0712b"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT app_id, value, raw_app FROM app_version WHERE id = $1",
|
||||
"query": "SELECT app_id, value FROM app_version WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -12,11 +12,6 @@
|
||||
"ordinal": 1,
|
||||
"name": "value",
|
||||
"type_info": "Json"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "raw_app",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -25,10 +20,9 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "19cca1d42f37e860dc54470fea8dd9a35c412d82d27ce369ccb0ba38b9791669"
|
||||
"hash": "ea9bbb972217bab4d7e8f4c08e331899161e80c239d56eb657d73bbf4272939b"
|
||||
}
|
||||
49
backend/.sqlx/query-ed5ff52b210e9158706ceffb4700108d4965e911656028be19b5ce530ab01d92.json
generated
Normal file
49
backend/.sqlx/query-ed5ff52b210e9158706ceffb4700108d4965e911656028be19b5ce530ab01d92.json
generated
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH result_stream AS (\n SELECT \n string_agg(stream, '' order by idx asc) as stream, \n job_id, \n max(idx) + 1 as offset \n FROM job_result_stream_v2\n WHERE job_id = $2 AND idx >= $3\n GROUP BY job_id\n )\n SELECT\n COALESCE(jc.result, NULL) as \"result: sqlx::types::Json<Box<RawValue>>\",\n rs.stream AS \"result_stream: Option<String>\",\n rs.offset AS stream_offset,\n COALESCE(js.flow_status, jc.flow_status) as \"flow_status: sqlx::types::Json<Box<RawValue>>\",\n CASE WHEN $4 THEN NULL ELSE (COALESCE(js.flow_status, jc.flow_status)->>'stream_job')::uuid END as stream_job\n FROM (\n SELECT $2::uuid as job_id, $1::text as workspace_id\n ) base\n LEFT JOIN v2_job_completed jc ON jc.id = base.job_id AND jc.workspace_id = base.workspace_id\n LEFT JOIN v2_job_status js ON js.id = base.job_id\n LEFT JOIN result_stream rs ON rs.job_id = base.job_id\n WHERE base.job_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "result: sqlx::types::Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "result_stream: Option<String>",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "stream_offset",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "flow_status: sqlx::types::Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "stream_job",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Uuid",
|
||||
"Int4",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "ed5ff52b210e9158706ceffb4700108d4965e911656028be19b5ce530ab01d92"
|
||||
}
|
||||
23
backend/.sqlx/query-f027ddbf2877c6ed9be749ae1c5b852061399ada923d8206ac666745664d58e6.json
generated
Normal file
23
backend/.sqlx/query-f027ddbf2877c6ed9be749ae1c5b852061399ada923d8206ac666745664d58e6.json
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT \n schema \n FROM \n flow \n WHERE \n workspace_id = $1 AND \n path = $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "schema",
|
||||
"type_info": "Json"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "f027ddbf2877c6ed9be749ae1c5b852061399ada923d8206ac666745664d58e6"
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH result_stream AS (\n SELECT\n string_agg(stream, '' order by idx asc) as stream,\n job_id,\n max(idx) + 1 as offset\n FROM job_result_stream_v2\n WHERE job_id = $1 AND idx >= $3\n GROUP BY job_id\n )\n SELECT\n COALESCE(jc.result, jc.result) as \"result: sqlx::types::Json<Box<RawValue>>\",\n jq.running as \"running: Option<bool>\",\n rs.stream AS \"result_stream: Option<String>\",\n rs.offset AS stream_offset,\n CASE WHEN $4 THEN NULL ELSE (COALESCE(js.flow_status, jc.flow_status)->>'stream_job')::uuid END as stream_job\n FROM (\n SELECT $1::uuid as job_id, $2::text as workspace_id\n ) base\n LEFT JOIN v2_job_completed jc ON jc.id = base.job_id AND jc.workspace_id = base.workspace_id\n LEFT JOIN v2_job_queue jq ON jq.id = base.job_id AND jq.workspace_id = base.workspace_id\n LEFT JOIN v2_job_status js ON js.id = base.job_id\n LEFT JOIN result_stream rs ON rs.job_id = base.job_id\n WHERE base.job_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "result: sqlx::types::Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "running: Option<bool>",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "result_stream: Option<String>",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "stream_offset",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "stream_job",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"Int4",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "f1e9699f743d96ebf040b0a0eaa24ae313c03a982762a7ea6836a530aa5b1e65"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n result AS \"result: sqlx::types::Json<Box<RawValue>>\",\n result_columns,\n status = 'success' AS \"success!\"\n FROM\n v2_job_completed\n WHERE\n id = $1 AND\n workspace_id = $2\n ",
|
||||
"query": "\n SELECT\n result AS \"result: sqlx::types::Json<Box<RawValue>>\",\n result_columns,\n status = 'success' AS \"success!\"\n FROM \n v2_job_completed\n WHERE \n id = $1 AND \n workspace_id = $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -31,5 +31,5 @@
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "12d69d85e25ffbed2dc37ef0faeb341b037aa66bc198f21fbc8de22e688f3d97"
|
||||
"hash": "f7f0c846cb9db866fd5188631f238eb69709b86976079425549d5bab8eefbac7"
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT app_version.raw_app FROM app\n JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)]\n WHERE app.path = $1 AND app.workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "raw_app",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "fb321fba5f4508d6cff5bae5a7c5a120d12b43a2f6056c3fff1c7c099b5f248a"
|
||||
}
|
||||
577
backend/Cargo.lock
generated
577
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.609.0"
|
||||
version = "1.603.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -35,7 +35,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.609.0"
|
||||
version = "1.603.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -86,6 +86,7 @@ oauth2 = ["windmill-api/oauth2"]
|
||||
zip = ["windmill-api/zip"]
|
||||
static_frontend = ["windmill-api/static_frontend"]
|
||||
scoped_cache = ["windmill-common/scoped_cache"]
|
||||
pg_embed = ["windmill-common/pg_embed", "dep:postgresql_embedded"]
|
||||
test_job_debouncing = []
|
||||
# Languages
|
||||
python = ["windmill-worker/python", "windmill-api/python"]
|
||||
@@ -161,7 +162,7 @@ k8s-openapi.workspace = true
|
||||
libloading.workspace = true
|
||||
bitflags.workspace = true
|
||||
globset.workspace = true
|
||||
|
||||
postgresql_embedded = { version = "0.18.1", optional = true, features = ["theseus"], default-features = false }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-service = "0.7"
|
||||
@@ -247,7 +248,6 @@ argon2 = "^0"
|
||||
quick_cache = "^0"
|
||||
rand = "=0.9.0"
|
||||
rand_core = { version = "^0", features = ["std"] }
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
magic-crypt = "^3"
|
||||
git-version = "^0"
|
||||
malachite = "=0.4.18"
|
||||
|
||||
@@ -14,6 +14,55 @@ contains files used to build the "root" binary.
|
||||
| [windmill-worker](./windmill-worker/) | The worker. Used to process and execute flows & jobs. |
|
||||
| [parsers](./parsers/) | Contains code to parse signatures in different langauges. |
|
||||
|
||||
## Features
|
||||
|
||||
### Embedded PostgreSQL (`pg_embed`)
|
||||
|
||||
The `pg_embed` feature allows Windmill to run with an embedded PostgreSQL database, eliminating the need for a separate PostgreSQL instance. This is useful for local development, testing, or single-machine deployments.
|
||||
|
||||
**Building with pg_embed:**
|
||||
```bash
|
||||
cargo build --features pg_embed
|
||||
```
|
||||
|
||||
**System Dependencies:**
|
||||
|
||||
The embedded PostgreSQL requires certain system libraries to be installed:
|
||||
|
||||
- **Arch Linux:**
|
||||
```bash
|
||||
sudo pacman -S libxml2 icu openssl
|
||||
```
|
||||
|
||||
- **Ubuntu/Debian:**
|
||||
```bash
|
||||
sudo apt-get install libxml2 libicu-dev libssl-dev
|
||||
```
|
||||
|
||||
- **RHEL/Fedora:**
|
||||
```bash
|
||||
sudo dnf install libxml2 libicu openssl-libs
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
When the `pg_embed` feature is enabled, if `DATABASE_URL` is not set, Windmill will automatically start an embedded PostgreSQL instance.
|
||||
|
||||
You can customize the embedded PostgreSQL behavior with these environment variables:
|
||||
- `PG_EMBED_DATA_DIR`: Directory for PostgreSQL data (default: `./postgresql_data`)
|
||||
- `PG_EMBED_PORT`: Port for PostgreSQL (default: `5432`)
|
||||
- `PG_EMBED_DATABASE`: Database name (default: `windmill`)
|
||||
- `PG_EMBED`: Set to any value to force embedded PostgreSQL even if DATABASE_URL exists
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
# Run with default embedded PostgreSQL
|
||||
./windmill
|
||||
|
||||
# Or with custom settings
|
||||
PG_EMBED_DATA_DIR=/my/data PG_EMBED_PORT=5433 ./windmill
|
||||
```
|
||||
|
||||
### Compile sqlx for offline ci
|
||||
|
||||
```
|
||||
|
||||
@@ -1 +1 @@
|
||||
da0a3751abd2b0fed46ca5c7a3aef35923403b28
|
||||
c8e8a6df19203acc2cef1aebd1bd4157f2439cbf
|
||||
@@ -13,7 +13,21 @@ from typing import Dict, List, Any, Optional
|
||||
|
||||
IMPORTS = """
|
||||
use std::borrow::Cow;
|
||||
use windmill_mcp::server::EndpointTool;
|
||||
use serde::{Deserialize, Serialize};
|
||||
"""
|
||||
|
||||
ENDPOINT_STRUCT = """
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct EndpointTool {
|
||||
pub name: Cow<'static, str>,
|
||||
pub description: Cow<'static, str>,
|
||||
pub instructions: Cow<'static, str>,
|
||||
pub path: Cow<'static, str>,
|
||||
pub method: Cow<'static, str>,
|
||||
pub path_params_schema: Option<serde_json::Value>,
|
||||
pub query_params_schema: Option<serde_json::Value>,
|
||||
pub body_schema: Option<serde_json::Value>,
|
||||
}
|
||||
"""
|
||||
|
||||
def load_openapi_spec(file_path: str) -> Dict[str, Any]:
|
||||
@@ -327,11 +341,13 @@ export const mcpEndpointTools: EndpointTool[] = [
|
||||
def generate_rust_code(tools: List[Dict[str, Any]], spec: Dict[str, Any], base_path: str = "") -> str:
|
||||
"""Generate the complete Rust code with MCP tools."""
|
||||
if not tools:
|
||||
return f"""// No MCP tools found in the OpenAPI specification
|
||||
return """// No MCP tools found in the OpenAPI specification
|
||||
|
||||
{IMPORTS}
|
||||
pub fn all_tools() -> Vec<EndpointTool> {{
|
||||
{ENDPOINT_STRUCT}
|
||||
pub fn all_tools() -> Vec<EndpointTool> {
|
||||
vec![]
|
||||
}}
|
||||
}
|
||||
"""
|
||||
|
||||
tool_definitions = []
|
||||
@@ -370,7 +386,9 @@ pub fn all_tools() -> Vec<EndpointTool> {{
|
||||
|
||||
rust_code = f"""// Auto-generated MCP tools from OpenAPI specification
|
||||
// This file is generated by generate_mcp_tools.py - DO NOT EDIT MANUALLY
|
||||
|
||||
{IMPORTS}
|
||||
{ENDPOINT_STRUCT}
|
||||
pub fn all_tools() -> Vec<EndpointTool> {{
|
||||
vec![
|
||||
{tool_definitions_str}
|
||||
@@ -387,7 +405,7 @@ def main():
|
||||
project_dir = backend_dir.parent
|
||||
|
||||
openapi_file = backend_dir / "windmill-api" / "openapi.yaml"
|
||||
rust_output_file = backend_dir / "windmill-api" / "src" / "mcp" / "auto_generated_endpoints.rs"
|
||||
rust_output_file = backend_dir / "windmill-api" / "src" / "mcp" / "tools" / "auto_generated_endpoints.rs"
|
||||
ts_output_file = project_dir / "frontend" / "src" / "lib" / "mcpEndpointTools.ts"
|
||||
|
||||
if not openapi_file.exists():
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
-- Add down migration script here
|
||||
@@ -1,17 +0,0 @@
|
||||
DO $$
|
||||
DECLARE
|
||||
dbname text := current_database();
|
||||
BEGIN
|
||||
-- Revoke default privileges first
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLES FROM custom_instance_user;
|
||||
REVOKE CREATE ON SCHEMA public FROM custom_instance_user;
|
||||
REVOKE USAGE ON SCHEMA public FROM custom_instance_user;
|
||||
EXECUTE format('REVOKE CREATE ON DATABASE %I FROM custom_instance_user', dbname);
|
||||
EXECUTE format('REVOKE CONNECT ON DATABASE %I FROM custom_instance_user', dbname);
|
||||
EXCEPTION
|
||||
WHEN others THEN
|
||||
RAISE NOTICE 'Error in custom_instance_user migration: %', SQLERRM;
|
||||
-- Continue without failing the migration
|
||||
END
|
||||
$$;
|
||||
@@ -1,3 +0,0 @@
|
||||
DROP INDEX IF EXISTS idx_account_mcp_server_url;
|
||||
ALTER TABLE account DROP COLUMN IF EXISTS mcp_server_url;
|
||||
DROP TABLE IF EXISTS mcp_oauth_client;
|
||||
@@ -1,13 +0,0 @@
|
||||
CREATE TABLE mcp_oauth_client (
|
||||
mcp_server_url TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL,
|
||||
client_secret TEXT,
|
||||
client_secret_expires_at TIMESTAMP,
|
||||
token_endpoint TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_mcp_oauth_client_expires ON mcp_oauth_client(client_secret_expires_at);
|
||||
|
||||
ALTER TABLE account ADD COLUMN mcp_server_url TEXT;
|
||||
CREATE INDEX idx_account_mcp_server_url ON account(mcp_server_url) WHERE mcp_server_url IS NOT NULL;
|
||||
@@ -332,6 +332,9 @@ fn print_help() {
|
||||
println!(" cache [hubPaths.json] Pre-cache hub scripts (default: ./hubPaths.json)");
|
||||
println!();
|
||||
println!("Environment variables (name = default):");
|
||||
#[cfg(feature = "pg_embed")]
|
||||
println!(" DATABASE_URL = <optional> The Postgres database url (auto-generated with pg_embed if not set).");
|
||||
#[cfg(not(feature = "pg_embed"))]
|
||||
println!(" DATABASE_URL = <required> The Postgres database url.");
|
||||
println!(" MODE = standalone Mode: standalone | worker | server | agent");
|
||||
println!(" BASE_URL = http://localhost:8000 Public base URL of your instance (overridden by instance settings)");
|
||||
@@ -339,16 +342,33 @@ fn print_help() {
|
||||
println!(" SERVER_BIND_ADDR = {} IP to bind the server to", DEFAULT_SERVER_BIND_ADDR);
|
||||
println!(" NUM_WORKERS = {} Number of workers (standalone/worker modes)", DEFAULT_NUM_WORKERS);
|
||||
println!(" WORKER_GROUP = default Worker group this worker belongs to",);
|
||||
println!(" QUIT_AFTER_FIRST_PING = false Exit worker after first successful ping (useful for testing)");
|
||||
println!(" JSON_FMT = false Output logs in JSON instead of logfmt");
|
||||
println!(" METRICS_ADDR = None (EE only) Prometheus metrics addr at /metrics; set \"true\" to use :8001");
|
||||
println!(" SUPERADMIN_SECRET = None Virtual superadmin token (server)");
|
||||
println!(" LICENSE_KEY = None (EE only) Enterprise license key (workers require valid key)");
|
||||
println!(" RUN_UPDATE_CA_CERTIFICATE_AT_START = false Run system CA update at startup");
|
||||
println!(" RUN_UPDATE_CA_CERTIFICATE_PATH = /usr/sbin/update-ca-certificates Path to CA update tool");
|
||||
#[cfg(feature = "pg_embed")]
|
||||
{
|
||||
println!();
|
||||
println!("Embedded PostgreSQL settings (pg_embed feature):");
|
||||
println!(" PG_EMBED = <unset> Force embedded PostgreSQL even if DATABASE_URL exists");
|
||||
println!(" PG_EMBED_DATA_DIR = ./postgresql_data Directory for embedded PostgreSQL data");
|
||||
println!(" PG_EMBED_PORT = 5432 Port for embedded PostgreSQL");
|
||||
println!(" PG_EMBED_DATABASE = windmill Database name for embedded PostgreSQL");
|
||||
println!();
|
||||
println!("System dependencies required for pg_embed:");
|
||||
println!(" Arch: sudo pacman -S libxml2 icu openssl");
|
||||
println!(" Ubuntu/Debian: sudo apt-get install libxml2 libicu-dev libssl-dev");
|
||||
println!(" RHEL/Fedora: sudo dnf install libxml2 libicu openssl-libs");
|
||||
}
|
||||
println!();
|
||||
println!("Notes:");
|
||||
println!("- Advanced and less commonly used settings are managed via the database and are omitted here.");
|
||||
println!("- At startup, Windmill logs currently set configuration keys for visibility.");
|
||||
#[cfg(feature = "pg_embed")]
|
||||
println!("- With pg_embed feature enabled, if DATABASE_URL is not set, an embedded PostgreSQL instance will be started automatically.");
|
||||
}
|
||||
|
||||
async fn windmill_main() -> anyhow::Result<()> {
|
||||
@@ -433,12 +453,6 @@ async fn windmill_main() -> anyhow::Result<()> {
|
||||
println!("Windmill {}", GIT_VERSION);
|
||||
return Ok(());
|
||||
}
|
||||
"prepare-deps" => {
|
||||
// CLI command for preparing dependencies without database access
|
||||
// Used by the debugger to install dependencies for scripts
|
||||
windmill_worker::run_prepare_deps_cli().await?;
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -476,6 +490,15 @@ async fn windmill_main() -> anyhow::Result<()> {
|
||||
IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))
|
||||
};
|
||||
|
||||
// Initialize embedded PostgreSQL if pg_embed feature is enabled
|
||||
#[cfg(feature = "pg_embed")]
|
||||
let _embedded_pg = if std::env::var("PG_EMBED").is_ok() || (mode != Mode::Agent && std::env::var("DATABASE_URL").is_err() && std::env::var("DATABASE_URL_FILE").is_err()) {
|
||||
println!("DATABASE_URL not set, starting embedded PostgreSQL...");
|
||||
Some(windmill_common::pg_embed::init_embedded_postgres().await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (conn, first_suffix) = if mode == Mode::Agent {
|
||||
tracing::info!(
|
||||
"Creating http client for cluster using base internal url {}",
|
||||
@@ -802,9 +825,16 @@ Windmill Community Edition {GIT_VERSION}
|
||||
#[cfg(not(all(feature = "tantivy", feature = "parquet")))]
|
||||
let log_indexer_f = async { Ok(()) as anyhow::Result<()> };
|
||||
|
||||
let worker_internal_server_killpill_rx = killpill_rx.resubscribe();
|
||||
let server_f = async {
|
||||
if !is_agent {
|
||||
if let Some(db) = conn.as_sql() {
|
||||
if worker_mode {
|
||||
init_worker_internal_server_inline_utils(
|
||||
worker_internal_server_killpill_rx,
|
||||
base_internal_url.clone(),
|
||||
)?;
|
||||
}
|
||||
windmill_api::run_server(
|
||||
db.clone(),
|
||||
index_reader,
|
||||
@@ -835,11 +865,6 @@ Windmill Community Edition {GIT_VERSION}
|
||||
if !killpill_rx.try_recv().is_ok() {
|
||||
let base_internal_url = base_internal_rx.await?;
|
||||
if worker_mode {
|
||||
let worker_internal_server_killpill_rx = killpill_rx.resubscribe();
|
||||
init_worker_internal_server_inline_utils(
|
||||
worker_internal_server_killpill_rx,
|
||||
base_internal_url.clone(),
|
||||
)?;
|
||||
let mut workers = vec![];
|
||||
|
||||
for i in 0..num_workers {
|
||||
|
||||
@@ -36,7 +36,7 @@ sqs_trigger = ["dep:aws-sdk-sqs", "dep:aws-sdk-sts", "dep:aws-sdk-sso", "dep:aws
|
||||
deno_core = ["dep:deno_core", "dep:deno_error"]
|
||||
gcp_trigger = ["dep:thiserror", "dep:google-cloud-pubsub", "dep:google-cloud-googleapis", "dep:tonic"]
|
||||
cloud = ["windmill-common/cloud"]
|
||||
mcp = ["dep:windmill-mcp", "windmill-mcp/server", "windmill-mcp/auth"]
|
||||
mcp = ["dep:windmill-mcp", "windmill-mcp/server"]
|
||||
python = []
|
||||
|
||||
[dependencies]
|
||||
@@ -84,7 +84,6 @@ rust-embed = { workspace = true, optional = true }
|
||||
tracing-subscriber.workspace = true
|
||||
quick_cache.workspace = true
|
||||
rand.workspace = true
|
||||
ed25519-dalek.workspace = true
|
||||
time.workspace = true
|
||||
native-tls.workspace = true
|
||||
tokio-native-tls.workspace = true
|
||||
@@ -158,6 +157,5 @@ tonic = { workspace = true, optional = true }
|
||||
deno_error = { workspace = true, optional = true }
|
||||
deno_core = { workspace = true, optional = true }
|
||||
backon = {workspace = true, optional = true}
|
||||
|
||||
[build-dependencies]
|
||||
deno_core = { workspace = true, optional = true }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.609.0
|
||||
version: 1.603.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -4224,9 +4224,6 @@ paths:
|
||||
cc_token_url:
|
||||
type: string
|
||||
description: "OAuth token URL override for resource-level authentication (client_credentials flow only)"
|
||||
mcp_server_url:
|
||||
type: string
|
||||
description: "MCP server URL for MCP OAuth token refresh"
|
||||
required:
|
||||
- refresh_token
|
||||
- expires_in
|
||||
@@ -10030,11 +10027,6 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: flow_level
|
||||
in: query
|
||||
description: If true, generate resume URLs for the parent flow instead of the specific step. This allows pre-approvals that can be consumed by any later suspend step in the same flow.
|
||||
schema:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
description: url endpoints
|
||||
@@ -13540,12 +13532,6 @@ paths:
|
||||
description: comma separated list of tags
|
||||
schema:
|
||||
type: string
|
||||
- name: workspace
|
||||
in: query
|
||||
required: false
|
||||
description: workspace to filter tags visibility (required when TAGS_ARE_SENSITIVE is enabled for non-superadmins)
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: map of tags to whether at least one worker with the tag exists
|
||||
@@ -15850,98 +15836,6 @@ paths:
|
||||
items:
|
||||
$ref: "#/components/schemas/EndpointTool"
|
||||
|
||||
/mcp/oauth/discover:
|
||||
post:
|
||||
summary: discover MCP server OAuth metadata
|
||||
operationId: discoverMcpOAuth
|
||||
tags:
|
||||
- mcp_oauth
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- mcp_server_url
|
||||
properties:
|
||||
mcp_server_url:
|
||||
type: string
|
||||
description: URL of the MCP server to discover OAuth metadata from
|
||||
responses:
|
||||
"200":
|
||||
description: OAuth metadata from MCP server
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
scopes_supported:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
authorization_endpoint:
|
||||
type: string
|
||||
token_endpoint:
|
||||
type: string
|
||||
registration_endpoint:
|
||||
type: string
|
||||
supports_dynamic_registration:
|
||||
type: boolean
|
||||
|
||||
/mcp/oauth/start:
|
||||
get:
|
||||
summary: start MCP OAuth popup flow
|
||||
description: Opens in a popup, discovers OAuth metadata, registers client, and redirects to OAuth provider
|
||||
operationId: startMcpOAuthPopup
|
||||
tags:
|
||||
- mcp_oauth
|
||||
parameters:
|
||||
- name: mcp_server_url
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: URL of the MCP server to connect to
|
||||
- name: scopes
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Comma-separated list of OAuth scopes to request
|
||||
responses:
|
||||
"302":
|
||||
description: Redirect to OAuth provider authorization URL
|
||||
|
||||
/mcp/oauth/callback:
|
||||
get:
|
||||
security: []
|
||||
summary: MCP OAuth callback
|
||||
description: Handles OAuth callback, exchanges code for tokens, returns HTML that posts message to opener
|
||||
operationId: mcpOAuthCallback
|
||||
tags:
|
||||
- mcp_oauth
|
||||
parameters:
|
||||
- name: code
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: OAuth authorization code
|
||||
- name: state
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: CSRF state token
|
||||
responses:
|
||||
"200":
|
||||
description: HTML page with JavaScript that posts tokens to opener window and closes
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
|
||||
@@ -129,7 +129,7 @@ pub async fn handle_resume_action(
|
||||
captures.name("approver").map(|m| m.as_str().to_string()),
|
||||
);
|
||||
|
||||
let approver = QueryApprover { approver, flow_level: None };
|
||||
let approver = QueryApprover { approver };
|
||||
|
||||
// Convert job_id and resume_id to appropriate types
|
||||
let job_uuid = Uuid::from_str(job_id)
|
||||
@@ -191,7 +191,7 @@ pub async fn get_approval_form_details(
|
||||
let res = get_resume_urls_internal(
|
||||
axum::Extension(db.clone()),
|
||||
Path((w_id.to_string(), job_id, resume_id)),
|
||||
Query(QueryApprover { approver: approver.map(|a| a.to_string()), flow_level: None }),
|
||||
Query(QueryApprover { approver: approver.map(|a| a.to_string()) }),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -1303,18 +1303,6 @@ async fn delete_app(
|
||||
));
|
||||
}
|
||||
|
||||
// Check if it's a raw app before deletion
|
||||
let is_raw_app = sqlx::query_scalar!(
|
||||
"SELECT app_version.raw_app FROM app
|
||||
JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)]
|
||||
WHERE app.path = $1 AND app.workspace_id = $2",
|
||||
path,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
sqlx::query!(
|
||||
@@ -1345,26 +1333,16 @@ async fn delete_app(
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
let deployed_object = if is_raw_app {
|
||||
DeployedObject::RawApp {
|
||||
path: path.to_string(),
|
||||
parent_path: Some(path.to_string()),
|
||||
version: 0, // dummy version as it will not get inserted in db
|
||||
}
|
||||
} else {
|
||||
DeployedObject::App {
|
||||
path: path.to_string(),
|
||||
parent_path: Some(path.to_string()),
|
||||
version: 0, // dummy version as it will not get inserted in db
|
||||
}
|
||||
};
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
&db,
|
||||
&w_id,
|
||||
deployed_object,
|
||||
DeployedObject::App {
|
||||
path: path.to_string(),
|
||||
parent_path: Some(path.to_string()),
|
||||
version: 0, // dummy version as it will not get inserted in db
|
||||
},
|
||||
Some(format!("App '{}' deleted", path)),
|
||||
true,
|
||||
)
|
||||
|
||||
@@ -56,7 +56,6 @@ lazy_static::lazy_static! {
|
||||
(20251105100125, include_str!(
|
||||
"../../migrations/20251105100125_legacy_sql_result_flag.up.sql"
|
||||
).replace("✅", "")),
|
||||
(20260107133344, "".to_string()),
|
||||
].into_iter().collect();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,413 +0,0 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! Debug session signing and audit logging.
|
||||
//!
|
||||
//! This module provides cryptographic signing of debug requests to ensure:
|
||||
//! 1. All debug sessions are logged in the audit trail
|
||||
//! 2. The debugger only executes code that has been authorized by the backend
|
||||
//! 3. Replay attacks are prevented via timestamp validation
|
||||
//!
|
||||
//! Uses Ed25519 JWT signing. The debugger fetches the public key from /api/debug/jwks
|
||||
//! and verifies tokens locally.
|
||||
//!
|
||||
//! Each debug session creates:
|
||||
//! - A job entry in v2_job (kind=preview) for traceability
|
||||
//! - A completed job entry in v2_job_completed
|
||||
//! - An audit log entry identical to script preview runs
|
||||
|
||||
use axum::{extract::Path, routing::{get, post}, Extension, Json, Router};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use chrono::Utc;
|
||||
use ed25519_dalek::{SigningKey, Signer};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Sha256, Digest};
|
||||
use sqlx::types::Json as SqlxJson;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
use windmill_audit::{audit_oss::audit_log, ActionKind};
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::JsonResult,
|
||||
jobs::JobKind,
|
||||
scripts::ScriptLang,
|
||||
users::username_to_permissioned_as,
|
||||
};
|
||||
|
||||
use crate::db::ApiAuthed;
|
||||
|
||||
/// TTL for debug tokens in seconds (60 seconds)
|
||||
pub const DEBUG_TOKEN_TTL_SECS: i64 = 60;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
/// Ed25519 signing key for debug tokens.
|
||||
/// Generated at startup if not provided via environment variable.
|
||||
static ref DEBUG_SIGNING_KEY: Arc<RwLock<Option<SigningKey>>> = Arc::new(RwLock::new(None));
|
||||
}
|
||||
|
||||
/// Initialize the debug signing key.
|
||||
/// Call this at server startup.
|
||||
pub async fn init_debug_signing_key() {
|
||||
let mut key_guard = DEBUG_SIGNING_KEY.write().await;
|
||||
|
||||
// Check if key is provided via environment variable (base64-encoded seed)
|
||||
if let Ok(seed_b64) = std::env::var("DEBUG_SIGNING_KEY_SEED") {
|
||||
if let Ok(seed_bytes) = URL_SAFE_NO_PAD.decode(&seed_b64) {
|
||||
if seed_bytes.len() >= 32 {
|
||||
let mut seed = [0u8; 32];
|
||||
seed.copy_from_slice(&seed_bytes[..32]);
|
||||
*key_guard = Some(SigningKey::from_bytes(&seed));
|
||||
tracing::info!("Debug signing key loaded from environment");
|
||||
return;
|
||||
}
|
||||
}
|
||||
tracing::warn!("Invalid DEBUG_SIGNING_KEY_SEED, generating new key");
|
||||
}
|
||||
|
||||
// Generate a new random key using rand
|
||||
let mut seed = [0u8; 32];
|
||||
rand::Rng::fill(&mut rand::rng(), &mut seed);
|
||||
let signing_key = SigningKey::from_bytes(&seed);
|
||||
tracing::info!("Generated new debug signing key");
|
||||
*key_guard = Some(signing_key);
|
||||
}
|
||||
|
||||
pub fn global_service() -> Router {
|
||||
Router::new().route("/jwks", get(get_jwks))
|
||||
}
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/sign", post(sign_debug_request))
|
||||
.route("/sign_expression", post(sign_expression))
|
||||
}
|
||||
|
||||
/// JWKS response containing the public key for debug token verification
|
||||
#[derive(Serialize)]
|
||||
pub struct DebugJwks {
|
||||
pub keys: Vec<DebugJwk>,
|
||||
}
|
||||
|
||||
/// JWK representation of an Ed25519 public key
|
||||
#[derive(Serialize)]
|
||||
pub struct DebugJwk {
|
||||
pub kty: String,
|
||||
pub crv: String,
|
||||
pub x: String,
|
||||
pub kid: String,
|
||||
#[serde(rename = "use")]
|
||||
pub use_: String,
|
||||
pub alg: String,
|
||||
}
|
||||
|
||||
/// Get the JWKS containing the public key for debug token verification.
|
||||
/// Debugger should fetch this at startup and cache it.
|
||||
async fn get_jwks() -> JsonResult<DebugJwks> {
|
||||
let key_guard = DEBUG_SIGNING_KEY.read().await;
|
||||
let signing_key = key_guard.as_ref().ok_or_else(|| {
|
||||
windmill_common::error::Error::InternalErr("Debug signing key not initialized".to_string())
|
||||
})?;
|
||||
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
let public_key_bytes = verifying_key.to_bytes();
|
||||
|
||||
// Compute key ID as hash of public key
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&public_key_bytes);
|
||||
let kid = hex::encode(&hasher.finalize()[..8]);
|
||||
|
||||
Ok(Json(DebugJwks {
|
||||
keys: vec![DebugJwk {
|
||||
kty: "OKP".to_string(),
|
||||
crv: "Ed25519".to_string(),
|
||||
x: URL_SAFE_NO_PAD.encode(public_key_bytes),
|
||||
kid,
|
||||
use_: "sig".to_string(),
|
||||
alg: "EdDSA".to_string(),
|
||||
}],
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SignDebugRequest {
|
||||
/// The code to be debugged
|
||||
pub code: String,
|
||||
/// The programming language (python3, bun, typescript, etc.)
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
/// JWT claims for debug tokens
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct DebugTokenClaims {
|
||||
/// Code hash (SHA-256, first 16 bytes, hex encoded)
|
||||
pub code_hash: String,
|
||||
/// Programming language
|
||||
pub language: String,
|
||||
/// Workspace ID
|
||||
pub workspace_id: String,
|
||||
/// User email
|
||||
pub email: String,
|
||||
/// Issued at (Unix timestamp)
|
||||
pub iat: i64,
|
||||
/// Expiration (Unix timestamp)
|
||||
pub exp: i64,
|
||||
/// Job ID for traceability
|
||||
pub job_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SignedDebugPayload {
|
||||
/// JWT token containing the signed claims
|
||||
pub token: String,
|
||||
/// The code (passed through for convenience)
|
||||
pub code: String,
|
||||
/// Job ID for the debug session (can be used to view job details)
|
||||
pub job_id: String,
|
||||
}
|
||||
|
||||
/// Sign a debug request and create audit log + job entries for full traceability.
|
||||
///
|
||||
/// This endpoint must be called before starting a debug session.
|
||||
/// Returns a JWT that the debugger will verify using the public key from /api/debug/jwks.
|
||||
///
|
||||
/// Creates:
|
||||
/// - A job entry in v2_job (kind=preview) with the debug code
|
||||
/// - A completed job entry in v2_job_completed (status=success)
|
||||
/// - An audit log entry identical to "jobs.run.preview"
|
||||
async fn sign_debug_request(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(request): Json<SignDebugRequest>,
|
||||
) -> JsonResult<SignedDebugPayload> {
|
||||
let key_guard = DEBUG_SIGNING_KEY.read().await;
|
||||
let signing_key = key_guard.as_ref().ok_or_else(|| {
|
||||
windmill_common::error::Error::InternalErr("Debug signing key not initialized".to_string())
|
||||
})?;
|
||||
|
||||
let now = Utc::now();
|
||||
let now_ts = now.timestamp();
|
||||
let exp = now_ts + DEBUG_TOKEN_TTL_SECS;
|
||||
|
||||
// Parse the language
|
||||
let script_lang: ScriptLang = request.language.parse().unwrap_or(ScriptLang::Bun);
|
||||
|
||||
// Hash the code (we don't include full code in JWT to keep it small)
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(request.code.as_bytes());
|
||||
let code_hash = hex::encode(&hasher.finalize()[..16]);
|
||||
|
||||
// Generate job ID
|
||||
let job_id = Uuid::new_v4();
|
||||
|
||||
let claims = DebugTokenClaims {
|
||||
code_hash,
|
||||
language: request.language.clone(),
|
||||
workspace_id: w_id.clone(),
|
||||
email: authed.email.clone(),
|
||||
iat: now_ts,
|
||||
exp,
|
||||
job_id: job_id.to_string(),
|
||||
};
|
||||
|
||||
// Create JWT manually with Ed25519 signature
|
||||
let header = serde_json::json!({
|
||||
"alg": "EdDSA",
|
||||
"typ": "JWT"
|
||||
});
|
||||
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
|
||||
let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims).unwrap());
|
||||
let message = format!("{}.{}", header_b64, claims_b64);
|
||||
|
||||
let signature = signing_key.sign(message.as_bytes());
|
||||
let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
|
||||
|
||||
let token = format!("{}.{}", message, signature_b64);
|
||||
|
||||
// Create job entries and audit log in a transaction
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let tag = "debugger".to_string();
|
||||
let permissioned_as = username_to_permissioned_as(&authed.username);
|
||||
|
||||
// Insert into v2_job (the job definition)
|
||||
sqlx::query!(
|
||||
"INSERT INTO v2_job (
|
||||
id,
|
||||
workspace_id,
|
||||
raw_code,
|
||||
tag,
|
||||
created_by,
|
||||
permissioned_as,
|
||||
permissioned_as_email,
|
||||
kind,
|
||||
script_lang,
|
||||
args
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::job_kind, $9::script_lang, $10)",
|
||||
job_id,
|
||||
w_id,
|
||||
request.code,
|
||||
tag,
|
||||
authed.display_username(),
|
||||
permissioned_as,
|
||||
authed.email,
|
||||
JobKind::Preview as JobKind,
|
||||
script_lang as ScriptLang,
|
||||
SqlxJson(serde_json::json!({})) as SqlxJson<serde_json::Value>,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Insert into v2_job_completed (mark as immediately completed)
|
||||
sqlx::query!(
|
||||
"INSERT INTO v2_job_completed (
|
||||
id,
|
||||
workspace_id,
|
||||
started_at,
|
||||
completed_at,
|
||||
duration_ms,
|
||||
result,
|
||||
status,
|
||||
worker
|
||||
) VALUES ($1, $2, $3, $3, 0, $4, 'success'::job_status, 'debugger')",
|
||||
job_id,
|
||||
w_id,
|
||||
now,
|
||||
SqlxJson(serde_json::json!({"debug_session": true, "language": request.language})) as SqlxJson<serde_json::Value>,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Create audit log entry (identical to jobs.run.preview)
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"jobs.run.preview",
|
||||
ActionKind::Execute,
|
||||
&w_id,
|
||||
None,
|
||||
Some([("job_id", job_id.to_string().as_str())].into()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(SignedDebugPayload {
|
||||
token,
|
||||
code: request.code,
|
||||
job_id: job_id.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SignExpressionRequest {
|
||||
/// The expression to evaluate
|
||||
pub expression: String,
|
||||
/// The job ID of the parent debug session
|
||||
pub job_id: String,
|
||||
}
|
||||
|
||||
/// JWT claims for expression evaluation tokens
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ExpressionTokenClaims {
|
||||
/// Expression hash (SHA-256, first 16 bytes, hex encoded)
|
||||
pub expression_hash: String,
|
||||
/// Parent debug session job ID
|
||||
pub job_id: String,
|
||||
/// Workspace ID
|
||||
pub workspace_id: String,
|
||||
/// User email
|
||||
pub email: String,
|
||||
/// Issued at (Unix timestamp)
|
||||
pub iat: i64,
|
||||
/// Expiration (Unix timestamp)
|
||||
pub exp: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SignedExpressionPayload {
|
||||
/// JWT token containing the signed claims
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
/// Sign a console expression for evaluation and create audit log.
|
||||
///
|
||||
/// This endpoint must be called before evaluating an expression in the debug console.
|
||||
/// Creates an audit log entry with the full expression for traceability.
|
||||
async fn sign_expression(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(request): Json<SignExpressionRequest>,
|
||||
) -> JsonResult<SignedExpressionPayload> {
|
||||
let key_guard = DEBUG_SIGNING_KEY.read().await;
|
||||
let signing_key = key_guard.as_ref().ok_or_else(|| {
|
||||
windmill_common::error::Error::InternalErr("Debug signing key not initialized".to_string())
|
||||
})?;
|
||||
|
||||
let now = Utc::now();
|
||||
let now_ts = now.timestamp();
|
||||
let exp = now_ts + DEBUG_TOKEN_TTL_SECS;
|
||||
|
||||
// Hash the expression
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(request.expression.as_bytes());
|
||||
let expression_hash = hex::encode(&hasher.finalize()[..16]);
|
||||
|
||||
let claims = ExpressionTokenClaims {
|
||||
expression_hash,
|
||||
job_id: request.job_id.clone(),
|
||||
workspace_id: w_id.clone(),
|
||||
email: authed.email.clone(),
|
||||
iat: now_ts,
|
||||
exp,
|
||||
};
|
||||
|
||||
// Create JWT manually with Ed25519 signature
|
||||
let header = serde_json::json!({
|
||||
"alg": "EdDSA",
|
||||
"typ": "JWT"
|
||||
});
|
||||
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
|
||||
let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims).unwrap());
|
||||
let message = format!("{}.{}", header_b64, claims_b64);
|
||||
|
||||
let signature = signing_key.sign(message.as_bytes());
|
||||
let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
|
||||
|
||||
let token = format!("{}.{}", message, signature_b64);
|
||||
|
||||
// Create audit log entry for the expression evaluation
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
// Truncate expression for resource field if too long (max 255 chars)
|
||||
let resource = if request.expression.len() > 200 {
|
||||
format!("{}...", &request.expression[..200])
|
||||
} else {
|
||||
request.expression.clone()
|
||||
};
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"debug.evaluate",
|
||||
ActionKind::Execute,
|
||||
&w_id,
|
||||
Some(&resource),
|
||||
Some([
|
||||
("job_id", request.job_id.as_str()),
|
||||
("expression", request.expression.as_str()),
|
||||
].into()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(SignedExpressionPayload { token }))
|
||||
}
|
||||
@@ -130,18 +130,6 @@ async fn list_foldernames(
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
fn validate_owner(owner: &str) -> Result<()> {
|
||||
if !owner
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '/' || c == '-')
|
||||
{
|
||||
return Err(error::Error::BadRequest(
|
||||
"Invalid owner: must contain only alphanumeric characters, underscores, hyphens, or slashes".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_name_conflict<'c>(
|
||||
tx: &mut Transaction<'c, Postgres>,
|
||||
w_id: &str,
|
||||
@@ -214,7 +202,7 @@ async fn create_folder(
|
||||
));
|
||||
}
|
||||
|
||||
if let Err(e) =
|
||||
if let Err(e) =
|
||||
sqlx::query_as!(
|
||||
Folder,
|
||||
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at) VALUES ($1, $2, $3, $4, $5, $6, $7, now())",
|
||||
@@ -274,8 +262,15 @@ async fn create_folder(
|
||||
)
|
||||
.await?;
|
||||
|
||||
log_folder_permission_change(&mut *tx, &w_id, &ng.name, &authed.username, "create", None)
|
||||
.await?;
|
||||
log_folder_permission_change(
|
||||
&mut *tx,
|
||||
&w_id,
|
||||
&ng.name,
|
||||
&authed.username,
|
||||
"create",
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -386,8 +381,7 @@ async fn update_folder(
|
||||
if let Some(extra_perms) = ng.extra_perms {
|
||||
if !extra_perms.is_object() {
|
||||
return Err(windmill_common::error::Error::BadRequest(format!(
|
||||
"extra_perms must be an object, received {}",
|
||||
extra_perms.to_string()
|
||||
"extra_perms must be an object, received {}", extra_perms.to_string()
|
||||
)));
|
||||
}
|
||||
sqlb.set(
|
||||
@@ -453,8 +447,15 @@ async fn update_folder(
|
||||
.await?;
|
||||
}
|
||||
if extra_perms_changed {
|
||||
log_folder_permission_change(&mut *tx, &w_id, &name, &authed.username, "update_acl", None)
|
||||
.await?;
|
||||
log_folder_permission_change(
|
||||
&mut *tx,
|
||||
&w_id,
|
||||
&name,
|
||||
&authed.username,
|
||||
"update_acl",
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
@@ -694,7 +695,6 @@ async fn add_owner(
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
validate_owner(&owner)?;
|
||||
sqlx::query(&format!(
|
||||
"UPDATE folder SET extra_perms = jsonb_set(extra_perms, '{{\"{owner}\"}}', to_jsonb($1), \
|
||||
true) WHERE name = $2 AND workspace_id = $3 RETURNING extra_perms"
|
||||
@@ -747,7 +747,6 @@ async fn remove_owner(
|
||||
|
||||
not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?;
|
||||
require_is_owner(&authed, &name)?;
|
||||
validate_owner(&owner)?;
|
||||
|
||||
let folder = sqlx::query!(
|
||||
"UPDATE folder SET owners = array_remove(owners, $1::varchar) WHERE name = $2 AND workspace_id = $3 AND $1 = ANY(owners) RETURNING name",
|
||||
@@ -759,10 +758,7 @@ async fn remove_owner(
|
||||
.await?;
|
||||
|
||||
if folder.is_none() && write.is_none() {
|
||||
return Ok(format!(
|
||||
"Owner {} is already not a member of folder {}",
|
||||
owner, name
|
||||
));
|
||||
return Ok(format!("Owner {} is already not a member of folder {}", owner, name));
|
||||
}
|
||||
|
||||
if let Some(write) = write {
|
||||
@@ -779,13 +775,11 @@ async fn remove_owner(
|
||||
.flatten();
|
||||
|
||||
if folder.is_none() && old_write.is_none_or(|ow| ow == write) {
|
||||
return Ok(format!(
|
||||
"Owner {} is already not a member of folder {} and write permission was already {}",
|
||||
owner, name, write
|
||||
));
|
||||
return Ok(format!("Owner {} is already not a member of folder {} and write permission was already {}", owner, name, write));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
@@ -802,15 +796,7 @@ async fn remove_owner(
|
||||
Some(false) => "grant_viewer_only",
|
||||
None => "revoke_all",
|
||||
};
|
||||
log_folder_permission_change(
|
||||
&mut *tx,
|
||||
&w_id,
|
||||
&name,
|
||||
&authed.username,
|
||||
change_type,
|
||||
Some(&owner),
|
||||
)
|
||||
.await?;
|
||||
log_folder_permission_change(&mut *tx, &w_id, &name, &authed.username, change_type, Some(&owner)).await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ use crate::{
|
||||
concurrency_groups::join_concurrency_key,
|
||||
db::{ApiAuthed, DB},
|
||||
triggers::trigger_helpers::RunnableId,
|
||||
users::{get_scope_tags, require_owner_of_path, require_path_read_access_for_preview, OptAuthed},
|
||||
users::{get_scope_tags, require_owner_of_path, OptAuthed},
|
||||
utils::{check_scopes, content_plain, require_super_admin},
|
||||
};
|
||||
use anyhow::Context;
|
||||
@@ -440,24 +440,24 @@ async fn get_flow_env_by_flow_job_id(
|
||||
) -> windmill_common::error::JsonResult<Box<JsonRawValue>> {
|
||||
let flow_env = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT
|
||||
CASE
|
||||
SELECT
|
||||
CASE
|
||||
WHEN flow_version.id IS NOT NULL THEN
|
||||
(flow_version.value -> 'flow_env' -> $3) #> $4
|
||||
ELSE
|
||||
(root_job.raw_flow -> 'flow_env' -> $3) #> $4
|
||||
END AS "flow_env: sqlx::types::Json<Box<RawValue>>"
|
||||
FROM
|
||||
FROM
|
||||
v2_job current_job
|
||||
JOIN
|
||||
JOIN
|
||||
v2_job root_job ON root_job.id = COALESCE(current_job.root_job, current_job.flow_innermost_root_job, current_job.parent_job, current_job.id)
|
||||
AND root_job.workspace_id = current_job.workspace_id
|
||||
LEFT JOIN
|
||||
flow_version ON flow_version.id = root_job.runnable_id
|
||||
AND flow_version.path = root_job.runnable_path
|
||||
AND flow_version.workspace_id = root_job.workspace_id
|
||||
WHERE
|
||||
current_job.id = $1 AND
|
||||
WHERE
|
||||
current_job.id = $1 AND
|
||||
current_job.workspace_id = $2"#,
|
||||
flow_job_id,
|
||||
w_id,
|
||||
@@ -2553,28 +2553,22 @@ async fn resume_suspended_job_internal(
|
||||
let value = value.unwrap_or(serde_json::Value::Null);
|
||||
verify_suspended_secret(&w_id, &db, job_id, resume_id, &approver, secret).await?;
|
||||
|
||||
// Get flow info - works for both step-level (job_id is a step) and flow-level (job_id is the flow)
|
||||
let (flow_info, is_flow_level) = get_flow_info_for_resume(job_id, &db).await?;
|
||||
let parent_flow_info = get_suspended_parent_flow_info(job_id, &db).await?;
|
||||
let parent_flow = GetQuery::new()
|
||||
.without_logs()
|
||||
.without_code()
|
||||
.without_flow()
|
||||
.fetch(&db, &parent_flow_info.id, &w_id)
|
||||
.await?;
|
||||
let flow_status = parent_flow
|
||||
.flow_status()
|
||||
.ok_or_else(|| anyhow::anyhow!("unable to find the flow status in the flow job"))?;
|
||||
|
||||
// For step-level resumes, verify user auth and flow status
|
||||
// For flow-level resumes (pre-approvals), the flow might not be at a suspended step yet
|
||||
if !is_flow_level {
|
||||
let parent_flow = GetQuery::new()
|
||||
.without_logs()
|
||||
.without_code()
|
||||
.without_flow()
|
||||
.fetch(&db, &flow_info.id, &w_id)
|
||||
.await?;
|
||||
let flow_status = parent_flow
|
||||
.flow_status()
|
||||
.ok_or_else(|| anyhow::anyhow!("unable to find the flow status in the flow job"))?;
|
||||
|
||||
let trigger_email = match &parent_flow {
|
||||
Job::CompletedJob(job) => &job.email,
|
||||
Job::QueuedJob(job) => &job.email,
|
||||
};
|
||||
conditionally_require_authed_user(authed.clone(), flow_status, trigger_email)?;
|
||||
}
|
||||
let trigger_email = match &parent_flow {
|
||||
Job::CompletedJob(job) => &job.email,
|
||||
Job::QueuedJob(job) => &job.email,
|
||||
};
|
||||
conditionally_require_authed_user(authed.clone(), flow_status, trigger_email)?;
|
||||
|
||||
let exists = sqlx::query_scalar!(
|
||||
r#"
|
||||
@@ -2590,7 +2584,7 @@ async fn resume_suspended_job_internal(
|
||||
return Err(anyhow::anyhow!("resume request already sent").into());
|
||||
}
|
||||
|
||||
let approver_value = if authed.as_ref().is_none()
|
||||
let approver = if authed.as_ref().is_none()
|
||||
|| (approver
|
||||
.approver
|
||||
.clone()
|
||||
@@ -2605,9 +2599,9 @@ async fn resume_suspended_job_internal(
|
||||
insert_resume_job(
|
||||
resume_id,
|
||||
job_id,
|
||||
&flow_info,
|
||||
&parent_flow_info,
|
||||
value,
|
||||
approver_value.clone(),
|
||||
approver.clone(),
|
||||
approved,
|
||||
&mut tx,
|
||||
)
|
||||
@@ -2616,20 +2610,15 @@ async fn resume_suspended_job_internal(
|
||||
if !approved {
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_queue SET suspend = 0 WHERE id = $1",
|
||||
flow_info.id
|
||||
parent_flow_info.id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
} else if is_flow_level {
|
||||
// For flow-level resumes, decrement the suspend counter if the flow is currently suspended
|
||||
// The approval will be matched when the worker checks for resumes (both step-level and flow-level)
|
||||
resume_immediately_for_flow_level(&flow_info, &mut tx).await?;
|
||||
} else {
|
||||
// For step-level resumes, try to resume immediately if the step is waiting
|
||||
resume_immediately_if_relevant(flow_info, job_id, &mut tx).await?;
|
||||
resume_immediately_if_relevant(parent_flow_info, job_id, &mut tx).await?;
|
||||
}
|
||||
|
||||
let approver = approver_value.unwrap_or_else(|| "anonymous".to_string());
|
||||
let approver = approver.unwrap_or_else(|| "anonymous".to_string());
|
||||
|
||||
let audit_author = match authed {
|
||||
Some(authed) => (&authed).into(),
|
||||
@@ -2720,26 +2709,6 @@ async fn resume_immediately_if_relevant<'c>(
|
||||
)
|
||||
}
|
||||
|
||||
/// For flow-level resumes, decrement the suspend counter if the flow is currently suspended.
|
||||
/// Unlike step-level resumes, we don't check if the job_id matches - we just need the flow
|
||||
/// to be in a suspended state.
|
||||
async fn resume_immediately_for_flow_level<'c>(
|
||||
flow: &FlowInfo,
|
||||
tx: &mut Transaction<'c, Postgres>,
|
||||
) -> error::Result<()> {
|
||||
if flow.suspend > 0 {
|
||||
let new_suspend = flow.suspend - 1;
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_queue SET suspend = $1 WHERE id = $2",
|
||||
new_suspend,
|
||||
flow.id,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn insert_resume_job<'c>(
|
||||
resume_id: u32,
|
||||
job_id: Uuid,
|
||||
@@ -2776,46 +2745,23 @@ struct FlowInfo {
|
||||
script_path: Option<String>,
|
||||
}
|
||||
|
||||
/// Get flow info from either a step job (by looking up its parent) or a flow job directly.
|
||||
/// Returns (FlowInfo, is_flow_level) where is_flow_level indicates if job_id was a flow job.
|
||||
async fn get_flow_info_for_resume(job_id: Uuid, db: &DB) -> error::Result<(FlowInfo, bool)> {
|
||||
// Single query that determines if job_id is a flow or step, and fetches the appropriate flow info
|
||||
let result = sqlx::query!(
|
||||
async fn get_suspended_parent_flow_info(job_id: Uuid, db: &DB) -> error::Result<FlowInfo> {
|
||||
let flow = sqlx::query_as!(
|
||||
FlowInfo,
|
||||
r#"
|
||||
WITH job_info AS (
|
||||
SELECT id, kind::text AS kind, parent_job
|
||||
FROM v2_job
|
||||
WHERE id = $1
|
||||
)
|
||||
SELECT
|
||||
q.id AS "id!",
|
||||
s.flow_status,
|
||||
q.suspend AS "suspend!",
|
||||
j.runnable_path AS script_path,
|
||||
(ji.kind IN ('flow', 'flowpreview')) AS "is_flow_level!"
|
||||
FROM job_info ji
|
||||
JOIN v2_job_queue q ON q.id = CASE
|
||||
WHEN ji.kind IN ('flow', 'flowpreview') THEN ji.id
|
||||
ELSE ji.parent_job
|
||||
END
|
||||
JOIN v2_job j ON j.id = q.id
|
||||
JOIN v2_job_status s ON s.id = q.id
|
||||
FOR UPDATE OF q
|
||||
"#,
|
||||
SELECT q.id, f.flow_status, q.suspend, j.runnable_path AS script_path
|
||||
FROM v2_job_queue q
|
||||
JOIN v2_job j USING (id)
|
||||
JOIN v2_job_status f USING (id)
|
||||
WHERE id = ( SELECT parent_job FROM v2_job WHERE id = $1 )
|
||||
FOR UPDATE
|
||||
"#,
|
||||
job_id,
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("job not found or parent flow not in queue: {}", job_id))?;
|
||||
|
||||
let flow_info = FlowInfo {
|
||||
id: result.id,
|
||||
flow_status: result.flow_status,
|
||||
suspend: result.suspend,
|
||||
script_path: result.script_path,
|
||||
};
|
||||
|
||||
Ok((flow_info, result.is_flow_level))
|
||||
.ok_or_else(|| anyhow::anyhow!("parent flow job not found"))?;
|
||||
Ok(flow)
|
||||
}
|
||||
|
||||
async fn get_suspended_flow_info<'c>(
|
||||
@@ -2882,9 +2828,6 @@ pub struct SuspendedJobFlow {
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct QueryApprover {
|
||||
pub approver: Option<String>,
|
||||
/// If true, generate/verify resume URLs for the parent flow instead of the specific step.
|
||||
/// This allows pre-approvals that can be consumed by any later suspend step in the same flow.
|
||||
pub flow_level: Option<bool>,
|
||||
}
|
||||
|
||||
pub async fn get_suspended_job_flow(
|
||||
@@ -3141,17 +3084,8 @@ pub async fn get_resume_urls_internal(
|
||||
Query(approver): Query<QueryApprover>,
|
||||
) -> error::JsonResult<ResumeUrls> {
|
||||
let key = get_workspace_key(&w_id, &db).await?;
|
||||
|
||||
// If flow_level is true, use the parent flow ID for the signature and URLs
|
||||
// This allows pre-approvals that can be consumed by any later suspend step
|
||||
let target_job_id = if approver.flow_level.unwrap_or(false) {
|
||||
get_flow_id_for_job(&db, job_id).await?
|
||||
} else {
|
||||
job_id
|
||||
};
|
||||
|
||||
let signature = create_signature(key, target_job_id, resume_id, approver.approver.clone())?;
|
||||
let approver_query = approver
|
||||
let signature = create_signature(key, job_id, resume_id, approver.approver.clone())?;
|
||||
let approver = approver
|
||||
.approver
|
||||
.as_ref()
|
||||
.map(|x| format!("?approver={}", encode(x)))
|
||||
@@ -3161,46 +3095,19 @@ pub async fn get_resume_urls_internal(
|
||||
let base_url = base_url_str.as_str();
|
||||
let res = ResumeUrls {
|
||||
approvalPage: format!(
|
||||
"{base_url}/approve/{w_id}/{target_job_id}/{resume_id}/{signature}{approver_query}"
|
||||
"{base_url}/approve/{w_id}/{job_id}/{resume_id}/{signature}{approver}"
|
||||
),
|
||||
cancel: build_resume_url(
|
||||
"cancel", &w_id, &target_job_id, &resume_id, &signature, &approver_query, &base_url,
|
||||
"cancel", &w_id, &job_id, &resume_id, &signature, &approver, &base_url,
|
||||
),
|
||||
resume: build_resume_url(
|
||||
"resume", &w_id, &target_job_id, &resume_id, &signature, &approver_query, &base_url,
|
||||
"resume", &w_id, &job_id, &resume_id, &signature, &approver, &base_url,
|
||||
),
|
||||
};
|
||||
|
||||
Ok(Json(res))
|
||||
}
|
||||
|
||||
/// Get the flow ID for a job. If the job is a flow, returns the job_id.
|
||||
/// If the job is a step in a flow, returns the parent flow ID.
|
||||
async fn get_flow_id_for_job(db: &DB, job_id: Uuid) -> error::Result<Uuid> {
|
||||
// First check if the job is a flow itself (kind = 'flow' or 'flowpreview')
|
||||
let job_info = sqlx::query!(
|
||||
r#"
|
||||
SELECT kind::text as "kind!", parent_job
|
||||
FROM v2_job
|
||||
WHERE id = $1
|
||||
"#,
|
||||
job_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("job not found: {}", job_id))?;
|
||||
|
||||
// If it's a flow job, return the job_id itself
|
||||
if job_info.kind == "flow" || job_info.kind == "flowpreview" {
|
||||
return Ok(job_id);
|
||||
}
|
||||
|
||||
// Otherwise, return the parent flow ID
|
||||
job_info
|
||||
.parent_job
|
||||
.ok_or_else(|| anyhow::anyhow!("job {} has no parent flow", job_id).into())
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Debug, Serialize)]
|
||||
pub struct JobExtended<T: JobCommon> {
|
||||
#[sqlx(flatten)]
|
||||
@@ -4434,12 +4341,12 @@ pub async fn run_flow_by_version_inner(
|
||||
|
||||
let flow_path = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT
|
||||
path
|
||||
FROM
|
||||
flow_version
|
||||
WHERE
|
||||
id = $1 AND
|
||||
SELECT
|
||||
path
|
||||
FROM
|
||||
flow_version
|
||||
WHERE
|
||||
id = $1 AND
|
||||
workspace_id = $2
|
||||
"#,
|
||||
version,
|
||||
@@ -5046,10 +4953,10 @@ pub async fn run_wait_result_internal(
|
||||
result AS \"result: sqlx::types::Json<Box<RawValue>>\",
|
||||
result_columns,
|
||||
status = 'success' AS \"success!\"
|
||||
FROM
|
||||
FROM
|
||||
v2_job_completed
|
||||
WHERE
|
||||
id = $1 AND
|
||||
WHERE
|
||||
id = $1 AND
|
||||
workspace_id = $2
|
||||
",
|
||||
uuid,
|
||||
@@ -6053,12 +5960,12 @@ pub async fn run_wait_result_flow_by_version(
|
||||
|
||||
let flow_path = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT
|
||||
path
|
||||
FROM
|
||||
flow_version
|
||||
WHERE
|
||||
id = $1 AND
|
||||
SELECT
|
||||
path
|
||||
FROM
|
||||
flow_version
|
||||
WHERE
|
||||
id = $1 AND
|
||||
workspace_id = $2
|
||||
"#,
|
||||
version,
|
||||
@@ -6111,7 +6018,6 @@ async fn run_preview_script(
|
||||
"Operators cannot run preview jobs for security reasons".to_string(),
|
||||
));
|
||||
}
|
||||
require_path_read_access_for_preview(&authed, &preview.path)?;
|
||||
let scheduled_for = run_query.get_scheduled_for(&db).await?;
|
||||
let tag = run_query.tag.clone().or(preview.tag.clone());
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
|
||||
@@ -6254,7 +6160,6 @@ async fn run_bundle_preview_script(
|
||||
let data = data.map_err(to_anyhow)?;
|
||||
if name == "preview" {
|
||||
let preview: Preview = serde_json::from_slice(&data).map_err(to_anyhow)?;
|
||||
require_path_read_access_for_preview(&authed, &preview.path)?;
|
||||
format = preview
|
||||
.format
|
||||
.and_then(|s| BundleFormat::from_string(&s))
|
||||
@@ -6866,7 +6771,6 @@ async fn run_preview_flow_job(
|
||||
"Operators cannot run preview jobs for security reasons".to_string(),
|
||||
));
|
||||
}
|
||||
require_path_read_access_for_preview(&authed, &raw_flow.path)?;
|
||||
let scheduled_for = run_query.get_scheduled_for(&db).await?;
|
||||
let tag = run_query.tag.clone().or(raw_flow.tag.clone());
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
|
||||
@@ -7021,12 +6925,12 @@ async fn run_dynamic_select(
|
||||
None => {
|
||||
let dynamic_input = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT
|
||||
schema
|
||||
FROM
|
||||
flow
|
||||
WHERE
|
||||
workspace_id = $1 AND
|
||||
SELECT
|
||||
schema
|
||||
FROM
|
||||
flow
|
||||
WHERE
|
||||
workspace_id = $1 AND
|
||||
path = $2
|
||||
"#,
|
||||
&w_id,
|
||||
@@ -7331,28 +7235,6 @@ impl Hash for JobUpdate {
|
||||
}
|
||||
|
||||
async fn get_log_file(Path((_w_id, file_p)): Path<(String, String)>) -> error::Result<Response> {
|
||||
if file_p.contains("..") {
|
||||
return Err(error::Error::BadRequest("Invalid path".to_string()));
|
||||
}
|
||||
|
||||
// Validate path format: must be exactly 2 parts, first is UUID, second ends with .txt
|
||||
let parts: Vec<&str> = file_p.split('/').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(error::Error::BadRequest(
|
||||
"Invalid path: must have exactly 2 components".to_string(),
|
||||
));
|
||||
}
|
||||
if Uuid::parse_str(parts[0]).is_err() {
|
||||
return Err(error::Error::BadRequest(
|
||||
"Invalid path: first component must be a valid UUID".to_string(),
|
||||
));
|
||||
}
|
||||
if !parts[1].ends_with(".txt") {
|
||||
return Err(error::Error::BadRequest(
|
||||
"Invalid path: file must end with .txt".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let local_file = format!("{TMP_DIR}/logs/{file_p}");
|
||||
if tokio::fs::metadata(&local_file).await.is_ok() {
|
||||
let mut file = tokio::fs::File::open(local_file).await.map_err(to_anyhow)?;
|
||||
@@ -7765,9 +7647,9 @@ async fn get_flow_stream_delta(
|
||||
if let Some(job_id) = flow_stream_job_id {
|
||||
let record = sqlx::query!(
|
||||
"
|
||||
SELECT
|
||||
string_agg(stream, '' order by idx asc) as stream,
|
||||
max(idx) + 1 as offset
|
||||
SELECT
|
||||
string_agg(stream, '' order by idx asc) as stream,
|
||||
max(idx) + 1 as offset
|
||||
FROM job_result_stream_v2
|
||||
WHERE job_id = $2 AND idx >= $1
|
||||
",
|
||||
@@ -7828,15 +7710,15 @@ async fn get_job_update_data(
|
||||
let r = sqlx::query!(
|
||||
"
|
||||
WITH result_stream AS (
|
||||
SELECT
|
||||
string_agg(stream, '' order by idx asc) as stream,
|
||||
job_id,
|
||||
max(idx) + 1 as offset
|
||||
SELECT
|
||||
string_agg(stream, '' order by idx asc) as stream,
|
||||
job_id,
|
||||
max(idx) + 1 as offset
|
||||
FROM job_result_stream_v2
|
||||
WHERE job_id = $2 AND idx >= $3
|
||||
GROUP BY job_id
|
||||
)
|
||||
SELECT
|
||||
SELECT
|
||||
jc.result as \"result: sqlx::types::Json<Box<RawValue>>\",
|
||||
v2_job.tag,
|
||||
v2_job_queue.running as \"running: Option<bool>\",
|
||||
@@ -7878,10 +7760,10 @@ async fn get_job_update_data(
|
||||
let r = sqlx::query!(
|
||||
"
|
||||
WITH result_stream AS (
|
||||
SELECT
|
||||
string_agg(stream, '' order by idx asc) as stream,
|
||||
job_id,
|
||||
max(idx) + 1 as offset
|
||||
SELECT
|
||||
string_agg(stream, '' order by idx asc) as stream,
|
||||
job_id,
|
||||
max(idx) + 1 as offset
|
||||
FROM job_result_stream_v2
|
||||
WHERE job_id = $1 AND idx >= $3
|
||||
GROUP BY job_id
|
||||
@@ -7921,10 +7803,10 @@ async fn get_job_update_data(
|
||||
let q = sqlx::query!(
|
||||
"
|
||||
WITH result_stream AS (
|
||||
SELECT
|
||||
string_agg(stream, '' order by idx asc) as stream,
|
||||
job_id,
|
||||
max(idx) + 1 as offset
|
||||
SELECT
|
||||
string_agg(stream, '' order by idx asc) as stream,
|
||||
job_id,
|
||||
max(idx) + 1 as offset
|
||||
FROM job_result_stream_v2
|
||||
WHERE job_id = $2 AND idx >= $3
|
||||
GROUP BY job_id
|
||||
@@ -7992,10 +7874,10 @@ async fn get_job_update_data(
|
||||
let mut record = sqlx::query!(
|
||||
"
|
||||
WITH result_stream AS (
|
||||
SELECT
|
||||
string_agg(stream, '' order by idx asc) as stream,
|
||||
job_id,
|
||||
max(idx) + 1 as offset
|
||||
SELECT
|
||||
string_agg(stream, '' order by idx asc) as stream,
|
||||
job_id,
|
||||
max(idx) + 1 as offset
|
||||
FROM job_result_stream_v2
|
||||
WHERE job_id = $3 AND idx >= $8
|
||||
GROUP BY job_id
|
||||
@@ -8510,7 +8392,7 @@ async fn get_completed_job_result(
|
||||
&db,
|
||||
suspended_job,
|
||||
resume_id,
|
||||
&QueryApprover { approver, flow_level: None },
|
||||
&QueryApprover { approver },
|
||||
secret,
|
||||
)
|
||||
.await?
|
||||
|
||||
@@ -83,7 +83,6 @@ mod capture;
|
||||
mod concurrency_groups;
|
||||
mod configs;
|
||||
mod db;
|
||||
pub mod debug;
|
||||
mod drafts;
|
||||
#[cfg(feature = "private")]
|
||||
pub mod ee;
|
||||
@@ -191,10 +190,6 @@ mod workspaces_oss;
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
mod mcp;
|
||||
#[cfg(all(feature = "mcp", feature = "private"))]
|
||||
mod mcp_oauth_ee;
|
||||
#[cfg(feature = "mcp")]
|
||||
mod mcp_oauth_oss;
|
||||
|
||||
pub use apps::EditApp;
|
||||
pub const DEFAULT_BODY_LIMIT: usize = 2097152 * 100; // 200MB
|
||||
@@ -300,9 +295,6 @@ pub async fn run_server(
|
||||
));
|
||||
let argon2 = Arc::new(Argon2::default());
|
||||
|
||||
// Initialize debug signing key for debugger authentication
|
||||
debug::init_debug_signing_key().await;
|
||||
|
||||
let disable_response_logs = std::env::var("DISABLE_RESPONSE_LOGS")
|
||||
.ok()
|
||||
.map(|x| x == "true")
|
||||
@@ -355,7 +347,7 @@ pub async fn run_server(
|
||||
{
|
||||
let smtp_server = Arc::new(SmtpServer {
|
||||
db: db.clone(),
|
||||
user_db: user_db.clone(),
|
||||
user_db: user_db,
|
||||
auth_cache: auth_cache.clone(),
|
||||
base_internal_url: _base_internal_url.clone(),
|
||||
});
|
||||
@@ -409,8 +401,7 @@ pub async fn run_server(
|
||||
let (mcp_router, mcp_cancellation_token) = {
|
||||
#[cfg(feature = "mcp")]
|
||||
if server_mode || mcp_mode {
|
||||
let (mcp_router, mcp_cancellation_token) =
|
||||
setup_mcp_server(db.clone(), user_db).await?;
|
||||
let (mcp_router, mcp_cancellation_token) = setup_mcp_server().await?;
|
||||
let mcp_middleware = axum::middleware::from_fn(extract_and_store_workspace_id);
|
||||
(
|
||||
mcp_router.layer(mcp_middleware),
|
||||
@@ -485,7 +476,6 @@ pub async fn run_server(
|
||||
.nest("/job_metrics", job_metrics::workspaced_service())
|
||||
.nest("/job_helpers", job_helpers_service)
|
||||
.nest("/jobs", jobs::workspaced_service())
|
||||
.nest("/debug", debug::workspaced_service())
|
||||
.nest("/oauth", {
|
||||
#[cfg(feature = "oauth2")]
|
||||
{
|
||||
@@ -549,7 +539,6 @@ pub async fn run_server(
|
||||
)
|
||||
.nest("/srch/index", indexer_oss::global_service())
|
||||
.nest("/oidc", oidc_oss::global_service())
|
||||
.nest("/debug", debug::global_service())
|
||||
.nest(
|
||||
"/saml",
|
||||
saml_oss::global_service().layer(Extension(Arc::clone(&sp_extension))),
|
||||
@@ -673,15 +662,6 @@ pub async fn run_server(
|
||||
#[cfg(not(feature = "oauth2"))]
|
||||
Router::new()
|
||||
})
|
||||
.nest("/mcp/oauth", {
|
||||
#[cfg(feature = "mcp")]
|
||||
{
|
||||
mcp_oauth_oss::global_service()
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mcp"))]
|
||||
Router::new()
|
||||
})
|
||||
.nest("/r", {
|
||||
#[cfg(feature = "http_trigger")]
|
||||
{
|
||||
|
||||
@@ -1,448 +0,0 @@
|
||||
//! Windmill MCP Backend implementation
|
||||
//!
|
||||
//! This module provides the concrete implementation of the McpBackend trait
|
||||
//! for the Windmill platform.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use windmill_common::{db::UserDB, utils::StripPath, DB};
|
||||
use windmill_mcp::common::transform::apply_key_transformation;
|
||||
use windmill_mcp::common::types::{
|
||||
FlowInfo, HubScriptInfo, ResourceInfo, ResourceType, SchemaType, ScriptInfo,
|
||||
};
|
||||
use windmill_mcp::server::{BackendResult, EndpointTool, ErrorData, McpAuth, McpBackend};
|
||||
|
||||
use crate::db::ApiAuthed;
|
||||
use crate::jobs::{
|
||||
run_wait_result_flow_by_path_internal, run_wait_result_script_by_path_internal, RunJobQuery,
|
||||
};
|
||||
|
||||
use super::auto_generated_endpoints::all_tools;
|
||||
use super::utils::{
|
||||
build_query_string, build_request_body, create_http_request, get_hub_script_schema,
|
||||
get_item_schema, get_items, get_resources, get_resources_types, get_scripts_from_hub,
|
||||
parse_response_body, prepare_push_args, substitute_path_params,
|
||||
};
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use windmill_mcp::server::{
|
||||
LocalSessionManager, Runner, StreamableHttpServerConfig, StreamableHttpService,
|
||||
};
|
||||
use windmill_mcp::WorkspaceId;
|
||||
|
||||
use axum::{
|
||||
extract::Path, http::Request, middleware::Next, response::Response, routing::get, Json, Router,
|
||||
};
|
||||
use windmill_common::error::JsonResult;
|
||||
|
||||
/// Implement McpAuth for ApiAuthed
|
||||
impl McpAuth for ApiAuthed {
|
||||
fn username(&self) -> &str {
|
||||
&self.username
|
||||
}
|
||||
|
||||
fn email(&self) -> &str {
|
||||
&self.email
|
||||
}
|
||||
|
||||
fn is_admin(&self) -> bool {
|
||||
self.is_admin
|
||||
}
|
||||
|
||||
fn is_operator(&self) -> bool {
|
||||
self.is_operator
|
||||
}
|
||||
|
||||
fn groups(&self) -> &[String] {
|
||||
&self.groups
|
||||
}
|
||||
|
||||
fn folders(&self) -> &[(String, bool, bool)] {
|
||||
&self.folders
|
||||
}
|
||||
|
||||
fn scopes(&self) -> Option<&[String]> {
|
||||
self.scopes.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Windmill's MCP backend implementation
|
||||
#[derive(Clone)]
|
||||
pub struct WindmillBackend {
|
||||
pub db: DB,
|
||||
pub user_db: UserDB,
|
||||
}
|
||||
|
||||
impl WindmillBackend {
|
||||
pub fn new(db: DB, user_db: UserDB) -> Self {
|
||||
Self { db, user_db }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl McpBackend for WindmillBackend {
|
||||
type Auth = ApiAuthed;
|
||||
|
||||
async fn list_scripts(
|
||||
&self,
|
||||
auth: &ApiAuthed,
|
||||
workspace_id: &str,
|
||||
favorites_only: bool,
|
||||
) -> BackendResult<Vec<ScriptInfo>> {
|
||||
let scope_type = if favorites_only { "favorites" } else { "all" };
|
||||
get_items::<ScriptInfo>(&self.user_db, auth, workspace_id, scope_type, "script")
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.message, None))
|
||||
}
|
||||
|
||||
async fn list_flows(
|
||||
&self,
|
||||
auth: &ApiAuthed,
|
||||
workspace_id: &str,
|
||||
favorites_only: bool,
|
||||
) -> BackendResult<Vec<FlowInfo>> {
|
||||
let scope_type = if favorites_only { "favorites" } else { "all" };
|
||||
get_items::<FlowInfo>(&self.user_db, auth, workspace_id, scope_type, "flow")
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.message, None))
|
||||
}
|
||||
|
||||
async fn list_resource_types(
|
||||
&self,
|
||||
auth: &ApiAuthed,
|
||||
workspace_id: &str,
|
||||
) -> BackendResult<Vec<ResourceType>> {
|
||||
get_resources_types(&self.user_db, auth, workspace_id)
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.message, None))
|
||||
}
|
||||
|
||||
async fn list_resources(
|
||||
&self,
|
||||
auth: &ApiAuthed,
|
||||
workspace_id: &str,
|
||||
resource_type: &str,
|
||||
) -> BackendResult<Vec<ResourceInfo>> {
|
||||
get_resources(&self.user_db, auth, workspace_id, resource_type)
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.message, None))
|
||||
}
|
||||
|
||||
async fn list_hub_scripts(
|
||||
&self,
|
||||
app_filter: Option<&str>,
|
||||
) -> BackendResult<Vec<HubScriptInfo>> {
|
||||
get_scripts_from_hub(&self.db, app_filter)
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.message, None))
|
||||
}
|
||||
|
||||
async fn get_item_schema(
|
||||
&self,
|
||||
auth: &ApiAuthed,
|
||||
workspace_id: &str,
|
||||
path: &str,
|
||||
item_type: &str,
|
||||
) -> BackendResult<Option<SchemaType>> {
|
||||
let schema = get_item_schema(path, &self.user_db, auth, workspace_id, item_type)
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.message, None))?;
|
||||
|
||||
if let Some(ref s) = schema {
|
||||
match serde_json::from_str::<SchemaType>(s.0.get()) {
|
||||
Ok(val) => Ok(Some(val)),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to parse schema: {}", e);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_hub_script_schema(&self, path: &str) -> BackendResult<Option<SchemaType>> {
|
||||
let schema = get_hub_script_schema(path, &self.db)
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.message, None))?;
|
||||
|
||||
if let Some(ref s) = schema {
|
||||
match serde_json::from_str::<SchemaType>(s.0.get()) {
|
||||
Ok(val) => Ok(Some(val)),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to parse hub schema: {}", e);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn transform_schema_for_resources(
|
||||
&self,
|
||||
schema: &SchemaType,
|
||||
resources_cache: &HashMap<String, Vec<ResourceInfo>>,
|
||||
resources_types: &[ResourceType],
|
||||
) -> SchemaType {
|
||||
let mut schema_obj = schema.clone();
|
||||
|
||||
// Replace invalid char in property key with underscore
|
||||
let replacements: Vec<(String, String, Value)> = schema_obj
|
||||
.properties
|
||||
.iter()
|
||||
.filter_map(|(key, value)| {
|
||||
if key.chars().any(|c| !c.is_alphanumeric() && c != '_') {
|
||||
let new_key = apply_key_transformation(key);
|
||||
Some((key.clone(), new_key, value.clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
for (old_key, new_key, value) in replacements {
|
||||
schema_obj.properties.remove(&old_key);
|
||||
schema_obj.properties.insert(new_key, value);
|
||||
}
|
||||
|
||||
for (_key, prop_value) in schema_obj.properties.iter_mut() {
|
||||
if let Value::Object(prop_map) = prop_value {
|
||||
if let Some(format_value) = prop_map.get("format") {
|
||||
if let Value::String(format_str) = format_value {
|
||||
if format_str.starts_with("resource-") {
|
||||
let resource_type_key =
|
||||
format_str.split("-").last().unwrap_or_default().to_string();
|
||||
let resource_type = resources_types
|
||||
.iter()
|
||||
.find(|rt| rt.name == resource_type_key);
|
||||
let resource_type_obj = resource_type.cloned();
|
||||
|
||||
if let Some(resource_cache) = resources_cache.get(&resource_type_key) {
|
||||
let resources_count = resource_cache.len();
|
||||
let description = match resource_type_obj {
|
||||
Some(resource_type_obj) => format!(
|
||||
"This is a resource named `{}` with the following description: `{}`.\\nThe path of the resource should be used to specify the resource.\\n{}",
|
||||
resource_type_obj.name,
|
||||
resource_type_obj.description.as_deref().unwrap_or("No description"),
|
||||
if resources_count == 0 {
|
||||
"This resource does not have any available instances, you should create one from your windmill workspace."
|
||||
} else if resources_count > 1 {
|
||||
"This resource has multiple available instances, you should precisely select the one you want to use."
|
||||
} else {
|
||||
"There is 1 resource available."
|
||||
}
|
||||
),
|
||||
None => "An object parameter.".to_string(),
|
||||
};
|
||||
prop_map.insert(
|
||||
"type".to_string(),
|
||||
Value::String("string".to_string()),
|
||||
);
|
||||
prop_map
|
||||
.insert("description".to_string(), Value::String(description));
|
||||
if resources_count > 0 {
|
||||
let resources_description = resource_cache
|
||||
.iter()
|
||||
.map(|resource| {
|
||||
format!(
|
||||
"{}: $res:{}",
|
||||
resource
|
||||
.description
|
||||
.as_deref()
|
||||
.unwrap_or("No title"),
|
||||
resource.path
|
||||
)
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.join("\\n");
|
||||
|
||||
prop_map.insert(
|
||||
"description".to_string(),
|
||||
Value::String(format!(
|
||||
"{}\\nHere are the available resources, in the format title:path. Title can be empty. Path should be used to specify the resource:\\n{}",
|
||||
prop_map.get("description").unwrap_or(&Value::String("No description".to_string())),
|
||||
resources_description
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
schema_obj
|
||||
}
|
||||
|
||||
async fn run_script(
|
||||
&self,
|
||||
auth: &ApiAuthed,
|
||||
workspace_id: &str,
|
||||
path: &str,
|
||||
args: Value,
|
||||
) -> BackendResult<Value> {
|
||||
let push_args = prepare_push_args(args);
|
||||
|
||||
let result = run_wait_result_script_by_path_internal(
|
||||
self.db.clone(),
|
||||
RunJobQuery::default(),
|
||||
StripPath(path.to_string()),
|
||||
auth.clone(),
|
||||
self.user_db.clone(),
|
||||
workspace_id.to_string(),
|
||||
push_args,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
|
||||
|
||||
parse_response_body(result).await
|
||||
}
|
||||
|
||||
async fn run_flow(
|
||||
&self,
|
||||
auth: &ApiAuthed,
|
||||
workspace_id: &str,
|
||||
path: &str,
|
||||
args: Value,
|
||||
) -> BackendResult<Value> {
|
||||
let push_args = prepare_push_args(args);
|
||||
|
||||
let result = run_wait_result_flow_by_path_internal(
|
||||
self.db.clone(),
|
||||
RunJobQuery::default(),
|
||||
StripPath(path.to_string()),
|
||||
auth.clone(),
|
||||
self.user_db.clone(),
|
||||
push_args,
|
||||
workspace_id.to_string(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
|
||||
|
||||
parse_response_body(result).await
|
||||
}
|
||||
|
||||
async fn call_endpoint(
|
||||
&self,
|
||||
auth: &ApiAuthed,
|
||||
workspace_id: &str,
|
||||
endpoint_tool: &EndpointTool,
|
||||
args: Value,
|
||||
) -> BackendResult<Value> {
|
||||
let args_map = match &args {
|
||||
Value::Object(map) => map,
|
||||
_ => {
|
||||
return Err(ErrorData::invalid_params(
|
||||
"Arguments must be an object",
|
||||
None,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Build URL with path substitutions
|
||||
let path_template = substitute_path_params(
|
||||
&endpoint_tool.path,
|
||||
workspace_id,
|
||||
args_map,
|
||||
&endpoint_tool.path_params_schema,
|
||||
)?;
|
||||
let query_string = build_query_string(args_map, &endpoint_tool.query_params_schema);
|
||||
let full_url = format!(
|
||||
"{}/api{}{}",
|
||||
windmill_common::BASE_INTERNAL_URL.as_str(),
|
||||
path_template,
|
||||
query_string
|
||||
);
|
||||
|
||||
// Prepare request body
|
||||
let body_json =
|
||||
build_request_body(&endpoint_tool.method, args_map, &endpoint_tool.body_schema);
|
||||
|
||||
// Create and execute request
|
||||
let response = create_http_request(
|
||||
&endpoint_tool.method,
|
||||
&full_url,
|
||||
workspace_id,
|
||||
auth,
|
||||
body_json,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let status = response.status();
|
||||
let response_text = response.text().await.map_err(|e| {
|
||||
ErrorData::internal_error(format!("Failed to read response text: {}", e), None)
|
||||
})?;
|
||||
|
||||
if status.is_success() {
|
||||
Ok(serde_json::from_str(&response_text)
|
||||
.unwrap_or_else(|_| Value::String(response_text)))
|
||||
} else {
|
||||
Err(ErrorData::internal_error(
|
||||
format!(
|
||||
"HTTP {} {}: {}",
|
||||
status.as_u16(),
|
||||
status.canonical_reason().unwrap_or(""),
|
||||
response_text
|
||||
),
|
||||
None,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn all_endpoint_tools(&self) -> Vec<EndpointTool> {
|
||||
all_tools()
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract workspace ID from path and store it in request extensions
|
||||
pub async fn extract_and_store_workspace_id(
|
||||
Path(params): Path<String>,
|
||||
mut request: Request<axum::body::Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let workspace_id = params;
|
||||
request.extensions_mut().insert(WorkspaceId(workspace_id));
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
/// Setup the MCP server with HTTP transport
|
||||
pub async fn setup_mcp_server(
|
||||
db: DB,
|
||||
user_db: UserDB,
|
||||
) -> anyhow::Result<(Router, CancellationToken)> {
|
||||
let cancellation_token = CancellationToken::new();
|
||||
let session_manager = Arc::new(LocalSessionManager::default());
|
||||
|
||||
let backend = WindmillBackend::new(db, user_db);
|
||||
let runner = Runner::new(backend);
|
||||
|
||||
let service_config = StreamableHttpServerConfig {
|
||||
sse_keep_alive: Some(Duration::from_secs(15)),
|
||||
stateful_mode: false,
|
||||
cancellation_token: cancellation_token.clone(),
|
||||
sse_retry: Some(Duration::from_secs(15)),
|
||||
};
|
||||
|
||||
let service =
|
||||
StreamableHttpService::new(move || Ok(runner.clone()), session_manager, service_config);
|
||||
|
||||
let router = Router::new().nest_service("/", service);
|
||||
Ok((router, cancellation_token))
|
||||
}
|
||||
|
||||
/// HTTP handler to list MCP tools as JSON
|
||||
async fn list_mcp_tools_handler() -> JsonResult<Vec<EndpointTool>> {
|
||||
let endpoint_tools = all_tools();
|
||||
Ok(Json(endpoint_tools))
|
||||
}
|
||||
|
||||
/// Creates a router service for listing MCP tools
|
||||
pub fn list_tools_service() -> Router {
|
||||
Router::new().route("/", get(list_mcp_tools_handler))
|
||||
}
|
||||
@@ -3,9 +3,9 @@
|
||||
//! This module provides the MCP server implementation that exposes Windmill scripts,
|
||||
//! flows, and API endpoints as MCP tools for AI assistants to interact with.
|
||||
|
||||
mod auto_generated_endpoints;
|
||||
mod core;
|
||||
mod utils;
|
||||
pub mod server;
|
||||
pub mod tools;
|
||||
pub mod utils;
|
||||
|
||||
// Re-export only what's needed externally
|
||||
pub use core::{extract_and_store_workspace_id, list_tools_service, setup_mcp_server};
|
||||
// Re-export main components
|
||||
pub use server::{extract_and_store_workspace_id, list_tools_service, setup_mcp_server};
|
||||
|
||||
560
backend/windmill-api/src/mcp/server.rs
Normal file
560
backend/windmill-api/src/mcp/server.rs
Normal file
@@ -0,0 +1,560 @@
|
||||
//! MCP Server implementation
|
||||
//!
|
||||
//! Contains the core MCP server handler that implements the Model Context Protocol
|
||||
//! specification. This is a thin orchestration layer that delegates to the appropriate
|
||||
//! modules for tool management, database operations, and schema transformation.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::{borrow::Cow, time::Duration};
|
||||
|
||||
use axum::body::to_bytes;
|
||||
use serde_json::Value;
|
||||
use tokio::try_join;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use windmill_common::db::UserDB;
|
||||
use windmill_common::worker::to_raw_value;
|
||||
use windmill_common::{utils::StripPath, DB};
|
||||
use windmill_mcp::server::{
|
||||
Annotated, CallToolRequestParam, CallToolResult, Content, ErrorData, Implementation,
|
||||
InitializeRequestParam, InitializeResult, ListPromptsResult, ListResourceTemplatesResult,
|
||||
ListResourcesResult, ListToolsResult, LocalSessionManager, PaginatedRequestParam,
|
||||
ProtocolVersion, RawContent, RawTextContent, RequestContext, RoleServer, ServerCapabilities,
|
||||
ServerHandler, ServerInfo, StreamableHttpServerConfig, StreamableHttpService, Tool,
|
||||
ToolAnnotations,
|
||||
};
|
||||
|
||||
use crate::db::ApiAuthed;
|
||||
use crate::jobs::{
|
||||
run_wait_result_flow_by_path_internal, run_wait_result_script_by_path_internal, RunJobQuery,
|
||||
};
|
||||
|
||||
use super::tools::endpoint_tools::{
|
||||
all_endpoint_tools, call_endpoint_tool, endpoint_tools_to_mcp_tools, EndpointTool,
|
||||
};
|
||||
use super::utils::{
|
||||
database::{
|
||||
check_scopes, get_hub_script_schema, get_item_schema, get_items, get_resources_types,
|
||||
get_scripts_from_hub,
|
||||
},
|
||||
models::{
|
||||
FlowInfo, ResourceInfo, ResourceType, SchemaType, ScriptInfo, ToolableItem, WorkspaceId,
|
||||
},
|
||||
schema::transform_schema_for_resources,
|
||||
scope_matcher::{is_resource_allowed, parse_mcp_scopes},
|
||||
transform::{reverse_transform, reverse_transform_key},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
extract::Path, http::Request, middleware::Next, response::Response, routing::get, Json, Router,
|
||||
};
|
||||
use windmill_common::error::JsonResult;
|
||||
|
||||
/// MCP Server Runner - implements the core MCP protocol handlers
|
||||
#[derive(Clone)]
|
||||
pub struct Runner {}
|
||||
|
||||
impl Runner {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
/// Creates a Tool from a ToolableItem
|
||||
async fn create_tool_from_item<T: ToolableItem>(
|
||||
item: &T,
|
||||
user_db: &UserDB,
|
||||
authed: &ApiAuthed,
|
||||
workspace_id: &str,
|
||||
resources_cache: &mut HashMap<String, Vec<ResourceInfo>>,
|
||||
resources_types: &Vec<ResourceType>,
|
||||
) -> Result<Tool, ErrorData> {
|
||||
let is_hub = item.is_hub();
|
||||
let path = item.get_path_or_id();
|
||||
let item_type = item.item_type();
|
||||
let description = format!(
|
||||
"This is a {} named `{}` with the following description: `{}`.{}",
|
||||
item_type,
|
||||
item.get_summary(),
|
||||
item.get_description(),
|
||||
if is_hub {
|
||||
format!(
|
||||
" It is a tool used for the following app: {}",
|
||||
item.get_integration_type()
|
||||
.unwrap_or("No integration type".to_string())
|
||||
)
|
||||
} else {
|
||||
"".to_string()
|
||||
}
|
||||
);
|
||||
let schema_obj = transform_schema_for_resources(
|
||||
&item.get_schema(),
|
||||
user_db,
|
||||
authed,
|
||||
&workspace_id,
|
||||
resources_cache,
|
||||
&resources_types,
|
||||
)
|
||||
.await?;
|
||||
let input_schema_map = match serde_json::to_value(schema_obj) {
|
||||
Ok(Value::Object(map)) => map,
|
||||
Ok(_) => {
|
||||
tracing::warn!("Schema object for tool '{}' did not serialize to a JSON object, using empty schema.", path);
|
||||
serde_json::Map::new()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Failed to serialize schema object for tool '{}': {}. Using empty schema.",
|
||||
path,
|
||||
e
|
||||
);
|
||||
serde_json::Map::new()
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Tool {
|
||||
name: Cow::Owned(path),
|
||||
description: Some(Cow::Owned(description)),
|
||||
input_schema: Arc::new(input_schema_map),
|
||||
title: Some(item.get_summary().to_string()),
|
||||
output_schema: None,
|
||||
icons: None,
|
||||
annotations: Some(ToolAnnotations {
|
||||
title: Some(item.get_summary().to_string()),
|
||||
read_only_hint: Some(false), // Can modify environment
|
||||
destructive_hint: Some(true), // Can potentially be destructive
|
||||
idempotent_hint: Some(false), // Are not guaranteed to be idempotent
|
||||
open_world_hint: Some(true), // Can interact with external services
|
||||
}),
|
||||
meta: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerHandler for Runner {
|
||||
/// Handles the `CallTool` request from the MCP client
|
||||
async fn call_tool(
|
||||
&self,
|
||||
request: CallToolRequestParam,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let http_parts = context
|
||||
.extensions
|
||||
.get::<axum::http::request::Parts>()
|
||||
.ok_or_else(|| {
|
||||
tracing::error!("http::request::Parts not found");
|
||||
ErrorData::internal_error("http::request::Parts not found", None)
|
||||
})?;
|
||||
|
||||
let authed = http_parts.extensions.get::<ApiAuthed>().ok_or_else(|| {
|
||||
tracing::error!("ApiAuthed Axum extension not found");
|
||||
ErrorData::internal_error("ApiAuthed Axum extension not found", None)
|
||||
})?;
|
||||
|
||||
check_scopes(authed)?;
|
||||
|
||||
// Parse MCP scopes for authorization
|
||||
let scopes = authed.scopes.as_ref().map(|s| s.as_slice()).unwrap_or(&[]);
|
||||
let scope_config = parse_mcp_scopes(scopes)?;
|
||||
|
||||
if request.name.ends_with("_TRUNC") {
|
||||
return Ok(CallToolResult::error(
|
||||
vec![
|
||||
Annotated::new(
|
||||
RawContent::Text(RawTextContent {
|
||||
text:
|
||||
"Tool path is too long. Consider shortening it to make it compatible with MCP."
|
||||
.to_string(),
|
||||
meta: None,
|
||||
}),
|
||||
None
|
||||
),
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
let db = http_parts.extensions.get::<DB>().ok_or_else(|| {
|
||||
tracing::error!("DB Axum extension not found");
|
||||
ErrorData::internal_error("DB Axum extension not found", None)
|
||||
})?;
|
||||
|
||||
let user_db = http_parts.extensions.get::<UserDB>().ok_or_else(|| {
|
||||
tracing::error!("UserDB Axum extension not found");
|
||||
ErrorData::internal_error("UserDB Axum extension not found", None)
|
||||
})?;
|
||||
|
||||
let args = request.arguments.map(Value::Object).ok_or_else(|| {
|
||||
ErrorData::invalid_params(
|
||||
"Missing arguments for tool",
|
||||
Some(request.name.clone().into()),
|
||||
)
|
||||
})?;
|
||||
|
||||
let workspace_id = http_parts
|
||||
.extensions
|
||||
.get::<WorkspaceId>()
|
||||
.ok_or_else(|| {
|
||||
tracing::error!("WorkspaceId not found");
|
||||
ErrorData::internal_error("WorkspaceId not found", None)
|
||||
})
|
||||
.map(|w_id| w_id.0.clone())?;
|
||||
|
||||
// Check if this is a generated endpoint tool
|
||||
let endpoint_tools = all_endpoint_tools();
|
||||
for endpoint_tool in endpoint_tools {
|
||||
if endpoint_tool.name.as_ref() == request.name {
|
||||
// Validate endpoint scope
|
||||
if scope_config.granular
|
||||
&& !is_resource_allowed(&endpoint_tool.name, &scope_config.endpoints)
|
||||
{
|
||||
return Err(ErrorData::internal_error(
|
||||
format!(
|
||||
"Access denied: endpoint '{}' not in token scope",
|
||||
endpoint_tool.name
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
// This is an endpoint tool, forward to the actual HTTP endpoint
|
||||
let result =
|
||||
call_endpoint_tool(&endpoint_tool, args.clone(), &workspace_id, &authed)
|
||||
.await?;
|
||||
return Ok(CallToolResult::success(vec![Content::text(
|
||||
serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string()),
|
||||
)]));
|
||||
}
|
||||
}
|
||||
|
||||
// Continue with script/flow logic
|
||||
let (tool_type, path, is_hub) = reverse_transform(&request.name).map_err(|e| {
|
||||
ErrorData::internal_error(format!("Failed to reverse transform path: {}", e), None)
|
||||
})?;
|
||||
|
||||
// Validate script/flow scope
|
||||
if !is_hub && scope_config.granular {
|
||||
if tool_type == "script" && !is_resource_allowed(&path, &scope_config.scripts) {
|
||||
return Err(ErrorData::internal_error(
|
||||
format!("Access denied: script '{}' not in token scope", path),
|
||||
None,
|
||||
));
|
||||
} else if tool_type == "flow" && !is_resource_allowed(&path, &scope_config.flows) {
|
||||
return Err(ErrorData::internal_error(
|
||||
format!("Access denied: flow '{}' not in token scope", path),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let item_schema = if is_hub {
|
||||
get_hub_script_schema(&format!("hub/{}", path), db).await?
|
||||
} else {
|
||||
get_item_schema(&path, user_db, authed, &workspace_id, &tool_type).await?
|
||||
};
|
||||
|
||||
let schema_obj = if let Some(ref s) = item_schema {
|
||||
match serde_json::from_str::<SchemaType>(s.0.get()) {
|
||||
Ok(val) => Some(val),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to parse schema: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let push_args = if let Value::Object(map) = args.clone() {
|
||||
let mut args_hash = HashMap::new();
|
||||
for (k, v) in map {
|
||||
// need to transform back the key without invalid characters to the original key
|
||||
let original_key = reverse_transform_key(&k, &schema_obj);
|
||||
args_hash.insert(original_key, to_raw_value(&v));
|
||||
}
|
||||
windmill_queue::PushArgsOwned { extra: None, args: args_hash }
|
||||
} else {
|
||||
windmill_queue::PushArgsOwned::default()
|
||||
};
|
||||
let script_or_flow_path = if is_hub {
|
||||
StripPath(format!("hub/{}", path))
|
||||
} else {
|
||||
StripPath(path)
|
||||
};
|
||||
let run_query = RunJobQuery::default();
|
||||
|
||||
let result = if tool_type == "script" {
|
||||
run_wait_result_script_by_path_internal(
|
||||
db.clone(),
|
||||
run_query,
|
||||
script_or_flow_path,
|
||||
authed.clone(),
|
||||
user_db.clone(),
|
||||
workspace_id.clone(),
|
||||
push_args,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
run_wait_result_flow_by_path_internal(
|
||||
db.clone(),
|
||||
run_query,
|
||||
script_or_flow_path,
|
||||
authed.clone(),
|
||||
user_db.clone(),
|
||||
push_args,
|
||||
workspace_id.clone(),
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(response) => {
|
||||
let body_bytes = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ErrorData::internal_error(
|
||||
format!("Failed to read response body: {}", e),
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
let body_str = String::from_utf8(body_bytes.to_vec()).map_err(|e| {
|
||||
ErrorData::internal_error(
|
||||
format!("Failed to decode response body: {}", e),
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
Ok(CallToolResult::success(vec![Content::text(body_str)]))
|
||||
}
|
||||
Err(e) => Err(ErrorData::internal_error(
|
||||
format!("Failed to run script: {}", e),
|
||||
None,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches available tools (scripts, flows, hub scripts) based on the user's scope
|
||||
async fn list_tools(
|
||||
&self,
|
||||
_request: Option<PaginatedRequestParam>,
|
||||
mut _context: RequestContext<RoleServer>,
|
||||
) -> Result<ListToolsResult, ErrorData> {
|
||||
let http_parts = _context
|
||||
.extensions
|
||||
.get::<axum::http::request::Parts>()
|
||||
.ok_or_else(|| {
|
||||
tracing::error!("http::request::Parts not found");
|
||||
ErrorData::internal_error("http::request::Parts not found", None)
|
||||
})?;
|
||||
|
||||
let authed = http_parts.extensions.get::<ApiAuthed>().ok_or_else(|| {
|
||||
tracing::error!("ApiAuthed Axum extension not found");
|
||||
ErrorData::internal_error("ApiAuthed Axum extension not found", None)
|
||||
})?;
|
||||
|
||||
check_scopes(authed)?;
|
||||
|
||||
let db = http_parts.extensions.get::<DB>().ok_or_else(|| {
|
||||
tracing::error!("DB Axum extension not found");
|
||||
ErrorData::internal_error("DB Axum extension not found", None)
|
||||
})?;
|
||||
|
||||
let user_db = http_parts.extensions.get::<UserDB>().ok_or_else(|| {
|
||||
tracing::error!("UserDB Axum extension not found");
|
||||
ErrorData::internal_error("UserDB Axum extension not found", None)
|
||||
})?;
|
||||
|
||||
let workspace_id = http_parts
|
||||
.extensions
|
||||
.get::<WorkspaceId>()
|
||||
.ok_or_else(|| {
|
||||
tracing::error!("WorkspaceId not found");
|
||||
ErrorData::internal_error("WorkspaceId not found", None)
|
||||
})
|
||||
.map(|w_id| w_id.0.clone())?;
|
||||
|
||||
// Parse MCP scopes to determine what to expose
|
||||
let scopes = authed.scopes.as_ref().map(|s| s.as_slice()).unwrap_or(&[]);
|
||||
let scope_config = parse_mcp_scopes(scopes)?;
|
||||
|
||||
let scope_type = if scope_config.favorites {
|
||||
"favorites"
|
||||
} else {
|
||||
// Fetch all items if either all or granular scope set (we filter later for granular scopes)
|
||||
"all"
|
||||
};
|
||||
|
||||
let scripts_fn =
|
||||
get_items::<ScriptInfo>(user_db, authed, &workspace_id, scope_type, "script");
|
||||
let flows_fn = get_items::<FlowInfo>(user_db, authed, &workspace_id, scope_type, "flow");
|
||||
let resources_types_fn = get_resources_types(user_db, authed, &workspace_id);
|
||||
let hub_scripts_fn = get_scripts_from_hub(db, scope_config.hub_apps.as_deref());
|
||||
let (scripts, flows, resources_types, hub_scripts) = if scope_config.hub_apps.is_some() {
|
||||
let (scripts, flows, resources_types, hub_scripts) =
|
||||
try_join!(scripts_fn, flows_fn, resources_types_fn, hub_scripts_fn)?;
|
||||
(scripts, flows, resources_types, hub_scripts)
|
||||
} else {
|
||||
let (scripts, flows, resources_types) =
|
||||
try_join!(scripts_fn, flows_fn, resources_types_fn)?;
|
||||
(scripts, flows, resources_types, vec![])
|
||||
};
|
||||
|
||||
let mut resources_cache: HashMap<String, Vec<ResourceInfo>> = HashMap::new();
|
||||
let mut tools: Vec<Tool> = Vec::new();
|
||||
|
||||
// Filter and add scripts based on scope
|
||||
for script in scripts {
|
||||
// For granular scopes, filter by path
|
||||
if scope_config.granular && !is_resource_allowed(&script.path, &scope_config.scripts) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tools.push(
|
||||
Runner::create_tool_from_item(
|
||||
&script,
|
||||
user_db,
|
||||
authed,
|
||||
&workspace_id,
|
||||
&mut resources_cache,
|
||||
&resources_types,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
// Filter and add flows based on scope
|
||||
for flow in flows {
|
||||
// For granular scopes, filter by path
|
||||
if scope_config.granular && !is_resource_allowed(&flow.path, &scope_config.flows) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tools.push(
|
||||
Runner::create_tool_from_item(
|
||||
&flow,
|
||||
user_db,
|
||||
authed,
|
||||
&workspace_id,
|
||||
&mut resources_cache,
|
||||
&resources_types,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
for hub_script in hub_scripts {
|
||||
tools.push(
|
||||
Runner::create_tool_from_item(
|
||||
&hub_script,
|
||||
user_db,
|
||||
authed,
|
||||
&workspace_id,
|
||||
&mut resources_cache,
|
||||
&resources_types,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
// Add endpoint tools from the generated MCP tools, filtered by scope
|
||||
let endpoint_tools = all_endpoint_tools();
|
||||
for endpoint_tool in endpoint_tools {
|
||||
// For granular scopes, filter by endpoint name
|
||||
if scope_config.granular
|
||||
&& !is_resource_allowed(&endpoint_tool.name, &scope_config.endpoints)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
tools.push(
|
||||
endpoint_tools_to_mcp_tools(vec![endpoint_tool])
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(ListToolsResult { tools, next_cursor: None, meta: None })
|
||||
}
|
||||
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo {
|
||||
protocol_version: ProtocolVersion::default(),
|
||||
capabilities: ServerCapabilities::builder()
|
||||
.enable_tools()
|
||||
.build(),
|
||||
server_info: Implementation::from_build_env(),
|
||||
instructions: Some("This server provides a list of scripts and flows the user can run on Windmill. Each flow and script is a tool callable with their respective arguments.".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn initialize(
|
||||
&self,
|
||||
_request: InitializeRequestParam,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<InitializeResult, ErrorData> {
|
||||
Ok(self.get_info())
|
||||
}
|
||||
|
||||
async fn list_resources(
|
||||
&self,
|
||||
_request: Option<PaginatedRequestParam>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ListResourcesResult, ErrorData> {
|
||||
Ok(ListResourcesResult { resources: vec![], next_cursor: None, meta: None })
|
||||
}
|
||||
|
||||
async fn list_prompts(
|
||||
&self,
|
||||
_request: Option<PaginatedRequestParam>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ListPromptsResult, ErrorData> {
|
||||
Ok(ListPromptsResult::default())
|
||||
}
|
||||
|
||||
async fn list_resource_templates(
|
||||
&self,
|
||||
_request: Option<PaginatedRequestParam>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ListResourceTemplatesResult, ErrorData> {
|
||||
Ok(ListResourceTemplatesResult::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract workspace ID from path and store it in request extensions
|
||||
pub async fn extract_and_store_workspace_id(
|
||||
Path(params): Path<String>,
|
||||
mut request: Request<axum::body::Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let workspace_id = params;
|
||||
request.extensions_mut().insert(WorkspaceId(workspace_id));
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
/// Setup the MCP server with HTTP transport
|
||||
pub async fn setup_mcp_server() -> anyhow::Result<(Router, CancellationToken)> {
|
||||
let cancellation_token = CancellationToken::new();
|
||||
let session_manager = Arc::new(LocalSessionManager::default());
|
||||
let service_config = StreamableHttpServerConfig {
|
||||
sse_keep_alive: Some(Duration::from_secs(15)),
|
||||
stateful_mode: false,
|
||||
cancellation_token: cancellation_token.clone(),
|
||||
};
|
||||
let service = StreamableHttpService::new(
|
||||
|| Ok(Runner::new()),
|
||||
session_manager.clone(),
|
||||
service_config,
|
||||
);
|
||||
|
||||
let router = axum::Router::new().nest_service("/", service);
|
||||
Ok((router, cancellation_token))
|
||||
}
|
||||
|
||||
/// HTTP handler to list MCP tools as JSON
|
||||
async fn list_mcp_tools_handler() -> JsonResult<Vec<EndpointTool>> {
|
||||
let endpoint_tools = all_endpoint_tools();
|
||||
Ok(Json(endpoint_tools))
|
||||
}
|
||||
|
||||
/// Creates a router service for listing MCP tools
|
||||
pub fn list_tools_service() -> Router {
|
||||
Router::new().route("/", get(list_mcp_tools_handler))
|
||||
}
|
||||
@@ -1,8 +1,22 @@
|
||||
// Auto-generated MCP tools from OpenAPI specification
|
||||
// This file is generated by generate_mcp_tools.py - DO NOT EDIT MANUALLY
|
||||
|
||||
|
||||
use std::borrow::Cow;
|
||||
use windmill_mcp::server::EndpointTool;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct EndpointTool {
|
||||
pub name: Cow<'static, str>,
|
||||
pub description: Cow<'static, str>,
|
||||
pub instructions: Cow<'static, str>,
|
||||
pub path: Cow<'static, str>,
|
||||
pub method: Cow<'static, str>,
|
||||
pub path_params_schema: Option<serde_json::Value>,
|
||||
pub query_params_schema: Option<serde_json::Value>,
|
||||
pub body_schema: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub fn all_tools() -> Vec<EndpointTool> {
|
||||
vec![
|
||||
307
backend/windmill-api/src/mcp/tools/endpoint_tools.rs
Normal file
307
backend/windmill-api/src/mcp/tools/endpoint_tools.rs
Normal file
@@ -0,0 +1,307 @@
|
||||
//! Endpoint tools for MCP server
|
||||
//!
|
||||
//! Contains the auto-generated endpoint tools and utilities for converting
|
||||
//! them to MCP tools and handling HTTP calls to Windmill API endpoints.
|
||||
|
||||
use crate::db::ApiAuthed;
|
||||
use std::sync::Arc;
|
||||
use windmill_common::db::Authed;
|
||||
use windmill_common::{auth::create_jwt_token, BASE_INTERNAL_URL};
|
||||
use windmill_mcp::server::{ErrorData, Tool, ToolAnnotations};
|
||||
|
||||
// Import the auto-generated tools
|
||||
use super::auto_generated_endpoints;
|
||||
pub use auto_generated_endpoints::{all_tools, EndpointTool};
|
||||
|
||||
/// Get all available endpoint tools
|
||||
pub fn all_endpoint_tools() -> Vec<EndpointTool> {
|
||||
all_tools()
|
||||
}
|
||||
|
||||
/// Convert endpoint tools to MCP tools
|
||||
pub fn endpoint_tools_to_mcp_tools(endpoint_tools: Vec<EndpointTool>) -> Vec<Tool> {
|
||||
endpoint_tools
|
||||
.into_iter()
|
||||
.map(|tool| endpoint_tool_to_mcp_tool(&tool))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Convert a single endpoint tool to MCP tool
|
||||
pub fn endpoint_tool_to_mcp_tool(tool: &EndpointTool) -> Tool {
|
||||
let mut combined_properties = serde_json::Map::new();
|
||||
let mut combined_required = Vec::new();
|
||||
|
||||
// Combine all parameter schemas
|
||||
let schemas = [
|
||||
&tool.path_params_schema,
|
||||
&tool.query_params_schema,
|
||||
&tool.body_schema,
|
||||
];
|
||||
|
||||
for schema in schemas.iter().filter_map(|s| s.as_ref()) {
|
||||
merge_schema_into(&mut combined_properties, &mut combined_required, schema);
|
||||
}
|
||||
|
||||
let combined_schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": combined_properties,
|
||||
"required": combined_required
|
||||
});
|
||||
|
||||
let description = format!("{}. {}", tool.description, tool.instructions);
|
||||
|
||||
// Create annotations based on HTTP method and endpoint characteristics
|
||||
let annotations = create_endpoint_annotations(tool);
|
||||
|
||||
Tool {
|
||||
name: tool.name.clone(),
|
||||
description: Some(description.into()),
|
||||
input_schema: Arc::new(combined_schema.as_object().unwrap().clone()),
|
||||
title: Some(tool.name.to_string()),
|
||||
output_schema: None,
|
||||
icons: None,
|
||||
annotations: Some(annotations),
|
||||
meta: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create appropriate annotations for endpoint tools based on HTTP method
|
||||
fn create_endpoint_annotations(tool: &EndpointTool) -> ToolAnnotations {
|
||||
let method = tool.method.as_ref();
|
||||
|
||||
// Determine characteristics based on HTTP method
|
||||
let (read_only, destructive, idempotent, open_world) = match method {
|
||||
"GET" => (true, false, true, true), // Read-only, safe, idempotent
|
||||
"POST" => (false, true, false, true), // Can modify, potentially destructive, not idempotent
|
||||
"PUT" => (false, false, true, true), // Can modify, typically idempotent updates
|
||||
"DELETE" => (false, true, true, true), // Destructive but idempotent
|
||||
"PATCH" => (false, false, false, true), // Partial updates, not guaranteed idempotent
|
||||
_ => (false, true, false, true), // Default: assume can modify and be destructive
|
||||
};
|
||||
|
||||
ToolAnnotations {
|
||||
title: Some(format!("{} {}", method, tool.path)),
|
||||
read_only_hint: Some(read_only),
|
||||
destructive_hint: Some(destructive),
|
||||
idempotent_hint: Some(idempotent),
|
||||
open_world_hint: Some(open_world),
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge schema into combined properties and required fields
|
||||
fn merge_schema_into(
|
||||
combined_properties: &mut serde_json::Map<String, serde_json::Value>,
|
||||
combined_required: &mut Vec<String>,
|
||||
schema: &serde_json::Value,
|
||||
) {
|
||||
if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
|
||||
for (key, value) in props {
|
||||
combined_properties.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(required) = schema.get("required").and_then(|r| r.as_array()) {
|
||||
for req in required.iter().filter_map(|r| r.as_str()) {
|
||||
combined_required.push(req.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Call an endpoint tool by making HTTP request to Windmill API
|
||||
pub async fn call_endpoint_tool(
|
||||
tool: &EndpointTool,
|
||||
args: serde_json::Value,
|
||||
workspace_id: &str,
|
||||
api_authed: &ApiAuthed,
|
||||
) -> Result<serde_json::Value, ErrorData> {
|
||||
let args_map = match &args {
|
||||
serde_json::Value::Object(map) => map,
|
||||
_ => {
|
||||
return Err(ErrorData::invalid_params(
|
||||
"Arguments must be an object",
|
||||
Some(tool.name.clone().into()),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Build URL with path substitutions
|
||||
let path_template =
|
||||
substitute_path_params(&tool.path, workspace_id, args_map, &tool.path_params_schema)?;
|
||||
let query_string = build_query_string(args_map, &tool.query_params_schema);
|
||||
let full_url = format!(
|
||||
"{}/api{}{}",
|
||||
BASE_INTERNAL_URL.as_str(),
|
||||
path_template,
|
||||
query_string
|
||||
);
|
||||
|
||||
// Prepare request body
|
||||
let body_json = build_request_body(&tool.method, args_map, &tool.body_schema);
|
||||
|
||||
// Create and execute request
|
||||
let response =
|
||||
create_http_request(&tool.method, &full_url, workspace_id, api_authed, body_json).await?;
|
||||
|
||||
let status = response.status();
|
||||
let response_text = response.text().await.map_err(|e| {
|
||||
ErrorData::internal_error(format!("Failed to read response text: {}", e), None)
|
||||
})?;
|
||||
|
||||
if status.is_success() {
|
||||
Ok(serde_json::from_str(&response_text)
|
||||
.unwrap_or_else(|_| serde_json::Value::String(response_text)))
|
||||
} else {
|
||||
Err(ErrorData::internal_error(
|
||||
format!(
|
||||
"HTTP {} {}: {}",
|
||||
status.as_u16(),
|
||||
status.canonical_reason().unwrap_or(""),
|
||||
response_text
|
||||
),
|
||||
None,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Substitute path parameters in the URL template
|
||||
fn substitute_path_params(
|
||||
path: &str,
|
||||
workspace_id: &str,
|
||||
args_map: &serde_json::Map<String, serde_json::Value>,
|
||||
path_schema: &Option<serde_json::Value>,
|
||||
) -> Result<String, ErrorData> {
|
||||
let mut path_template = path.replace("{workspace}", workspace_id);
|
||||
|
||||
if let Some(schema) = path_schema {
|
||||
if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
|
||||
for (param_name, _) in props {
|
||||
let placeholder = format!("{{{}}}", param_name);
|
||||
match args_map.get(param_name) {
|
||||
Some(param_value) => {
|
||||
if let Some(str_val) = param_value.as_str() {
|
||||
path_template = path_template.replace(&placeholder, str_val);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tracing::warn!("Missing required path parameter: {}", param_name);
|
||||
return Err(ErrorData::invalid_params(
|
||||
format!("Missing required path parameter: {}", param_name),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(path_template)
|
||||
}
|
||||
|
||||
/// Build query string from arguments
|
||||
fn build_query_string(
|
||||
args_map: &serde_json::Map<String, serde_json::Value>,
|
||||
query_schema: &Option<serde_json::Value>,
|
||||
) -> String {
|
||||
let Some(schema) = query_schema else {
|
||||
return String::new();
|
||||
};
|
||||
let Some(props) = schema.get("properties").and_then(|p| p.as_object()) else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
let query_params: Vec<String> = props
|
||||
.keys()
|
||||
.filter_map(|param_name| {
|
||||
args_map
|
||||
.get(param_name)
|
||||
.filter(|v| !v.is_null())
|
||||
.map(|value| {
|
||||
let value_str = value.to_string();
|
||||
let str_val = value_str.trim_matches('"');
|
||||
format!(
|
||||
"{}={}",
|
||||
urlencoding::encode(param_name),
|
||||
urlencoding::encode(str_val)
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
if query_params.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("?{}", query_params.join("&"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Build request body from arguments
|
||||
fn build_request_body(
|
||||
method: &str,
|
||||
args_map: &serde_json::Map<String, serde_json::Value>,
|
||||
body_schema: &Option<serde_json::Value>,
|
||||
) -> Option<serde_json::Value> {
|
||||
if method == "GET" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let schema = body_schema.as_ref()?;
|
||||
let props = schema.get("properties")?.as_object()?;
|
||||
|
||||
let body_map: serde_json::Map<String, serde_json::Value> = props
|
||||
.keys()
|
||||
.filter_map(|param_name| {
|
||||
args_map
|
||||
.get(param_name)
|
||||
.map(|value| (param_name.clone(), value.clone()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if body_map.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(serde_json::Value::Object(body_map))
|
||||
}
|
||||
}
|
||||
|
||||
/// Create HTTP request with authentication
|
||||
async fn create_http_request(
|
||||
method: &str,
|
||||
url: &str,
|
||||
workspace_id: &str,
|
||||
api_authed: &ApiAuthed,
|
||||
body_json: Option<serde_json::Value>,
|
||||
) -> Result<reqwest::Response, ErrorData> {
|
||||
let client = &crate::HTTP_CLIENT;
|
||||
let mut request_builder = match method {
|
||||
"GET" => client.get(url),
|
||||
"POST" => client.post(url),
|
||||
"PUT" => client.put(url),
|
||||
"DELETE" => client.delete(url),
|
||||
"PATCH" => client.patch(url),
|
||||
_ => {
|
||||
return Err(ErrorData::invalid_params(
|
||||
format!("Unsupported HTTP method: {}", method),
|
||||
None,
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Add authorization header
|
||||
let authed = Authed::from(api_authed.clone());
|
||||
let token = create_jwt_token(authed, workspace_id, 3600, None, None, None, None)
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
|
||||
request_builder = request_builder.header("Authorization", format!("Bearer {}", token));
|
||||
|
||||
// Add body if present
|
||||
if let Some(body) = body_json {
|
||||
request_builder = request_builder
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body);
|
||||
}
|
||||
|
||||
request_builder
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(format!("Failed to execute request: {}", e), None))
|
||||
}
|
||||
40
backend/windmill-api/src/mcp/tools/flow_tools.rs
Normal file
40
backend/windmill-api/src/mcp/tools/flow_tools.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
//! Flow tools for MCP server
|
||||
//!
|
||||
//! Contains functionality for converting Windmill flows into MCP tools.
|
||||
|
||||
use super::super::utils::{
|
||||
models::{FlowInfo, ToolableItem, SchemaType},
|
||||
schema::convert_schema_to_schema_type,
|
||||
transform::transform_path,
|
||||
};
|
||||
|
||||
/// Implementation of ToolableItem for FlowInfo
|
||||
impl ToolableItem for FlowInfo {
|
||||
fn get_path_or_id(&self) -> String {
|
||||
transform_path(&self.path, "flow")
|
||||
}
|
||||
|
||||
fn get_summary(&self) -> &str {
|
||||
self.summary.as_deref().unwrap_or("No summary")
|
||||
}
|
||||
|
||||
fn get_description(&self) -> &str {
|
||||
self.description.as_deref().unwrap_or("No description")
|
||||
}
|
||||
|
||||
fn get_schema(&self) -> SchemaType {
|
||||
convert_schema_to_schema_type(self.schema.clone())
|
||||
}
|
||||
|
||||
fn is_hub(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn item_type(&self) -> &'static str {
|
||||
"flow"
|
||||
}
|
||||
|
||||
fn get_integration_type(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
43
backend/windmill-api/src/mcp/tools/hub_tools.rs
Normal file
43
backend/windmill-api/src/mcp/tools/hub_tools.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
//! Hub tools for MCP server
|
||||
//!
|
||||
//! Contains functionality for integrating Windmill Hub scripts as MCP tools.
|
||||
|
||||
use super::super::utils::{
|
||||
models::{HubScriptInfo, ToolableItem, SchemaType},
|
||||
};
|
||||
|
||||
/// Implementation of ToolableItem for HubScriptInfo
|
||||
impl ToolableItem for HubScriptInfo {
|
||||
fn get_path_or_id(&self) -> String {
|
||||
let id = self.version_id;
|
||||
let summary = self.summary.as_deref().unwrap_or("No summary");
|
||||
format!("hs-{}-{}", id, summary.replace(" ", "_"))
|
||||
}
|
||||
|
||||
fn get_summary(&self) -> &str {
|
||||
self.summary.as_deref().unwrap_or("No summary")
|
||||
}
|
||||
|
||||
fn get_description(&self) -> &str {
|
||||
self.description.as_deref().unwrap_or("No description")
|
||||
}
|
||||
|
||||
fn get_schema(&self) -> SchemaType {
|
||||
match serde_json::from_value::<SchemaType>(self.schema.clone().unwrap_or_default()) {
|
||||
Ok(schema_type) => schema_type,
|
||||
Err(_) => SchemaType::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_hub(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn item_type(&self) -> &'static str {
|
||||
"script"
|
||||
}
|
||||
|
||||
fn get_integration_type(&self) -> Option<String> {
|
||||
self.app.clone()
|
||||
}
|
||||
}
|
||||
10
backend/windmill-api/src/mcp/tools/mod.rs
Normal file
10
backend/windmill-api/src/mcp/tools/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
//! Tool management for MCP server
|
||||
//!
|
||||
//! This module handles the conversion of Windmill scripts, flows, and endpoints
|
||||
//! into MCP tools that can be used by AI assistants.
|
||||
|
||||
pub mod script_tools;
|
||||
pub mod flow_tools;
|
||||
pub mod hub_tools;
|
||||
pub mod endpoint_tools;
|
||||
pub mod auto_generated_endpoints;
|
||||
40
backend/windmill-api/src/mcp/tools/script_tools.rs
Normal file
40
backend/windmill-api/src/mcp/tools/script_tools.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
//! Script tools for MCP server
|
||||
//!
|
||||
//! Contains functionality for converting Windmill scripts into MCP tools.
|
||||
|
||||
use super::super::utils::{
|
||||
models::{ScriptInfo, ToolableItem, SchemaType},
|
||||
schema::convert_schema_to_schema_type,
|
||||
transform::transform_path,
|
||||
};
|
||||
|
||||
/// Implementation of ToolableItem for ScriptInfo
|
||||
impl ToolableItem for ScriptInfo {
|
||||
fn get_path_or_id(&self) -> String {
|
||||
transform_path(&self.path, "script")
|
||||
}
|
||||
|
||||
fn get_summary(&self) -> &str {
|
||||
self.summary.as_deref().unwrap_or("No summary")
|
||||
}
|
||||
|
||||
fn get_description(&self) -> &str {
|
||||
self.description.as_deref().unwrap_or("No description")
|
||||
}
|
||||
|
||||
fn get_schema(&self) -> SchemaType {
|
||||
convert_schema_to_schema_type(self.schema.clone())
|
||||
}
|
||||
|
||||
fn is_hub(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn item_type(&self) -> &'static str {
|
||||
"script"
|
||||
}
|
||||
|
||||
fn get_integration_type(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1,414 +0,0 @@
|
||||
//! Utility functions for MCP server
|
||||
//!
|
||||
//! Contains database query functions and HTTP request helpers
|
||||
//! used by the MCP server implementation.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::response::Response;
|
||||
use serde_json::Value;
|
||||
use sql_builder::prelude::*;
|
||||
use windmill_common::auth::create_jwt_token;
|
||||
use windmill_common::db::{Authed, UserDB};
|
||||
use windmill_common::scripts::{get_full_hub_script_by_path, Schema};
|
||||
use windmill_common::utils::{query_elems_from_hub, StripPath};
|
||||
use windmill_common::worker::to_raw_value;
|
||||
use windmill_common::{DB, HUB_BASE_URL};
|
||||
use windmill_mcp::server::{BackendResult, ErrorData};
|
||||
use windmill_mcp::{HubResponse, HubScriptInfo, ItemSchema, ResourceInfo, ResourceType};
|
||||
|
||||
use crate::db::ApiAuthed;
|
||||
use crate::HTTP_CLIENT;
|
||||
|
||||
// items max limit
|
||||
const ITEMS_FETCH_MAX_LIMIT: usize = 100;
|
||||
|
||||
// ============================================================================
|
||||
// Database utilities
|
||||
// ============================================================================
|
||||
|
||||
/// Get the schema for a specific item (script or flow)
|
||||
pub async fn get_item_schema(
|
||||
path: &str,
|
||||
user_db: &UserDB,
|
||||
authed: &ApiAuthed,
|
||||
workspace_id: &str,
|
||||
item_type: &str,
|
||||
) -> Result<Option<Schema>, ErrorData> {
|
||||
let mut sqlb = SqlBuilder::select_from(&format!("{} as o", item_type));
|
||||
sqlb.fields(&["o.schema"]);
|
||||
sqlb.and_where("o.path = ?".bind(&path));
|
||||
sqlb.and_where("o.workspace_id = ?".bind(&workspace_id));
|
||||
sqlb.and_where("o.archived = false");
|
||||
sqlb.and_where("o.draft_only IS NOT TRUE");
|
||||
let sql = sqlb.sql().map_err(|e| {
|
||||
tracing::error!("failed to build sql: {}", e);
|
||||
ErrorData::internal_error(format!("failed to build sql: {}", e), None)
|
||||
})?;
|
||||
let mut tx = user_db.clone().begin(authed).await.map_err(|e| {
|
||||
tracing::error!("failed to begin transaction: {}", e);
|
||||
ErrorData::internal_error(format!("failed to begin transaction: {}", e), None)
|
||||
})?;
|
||||
let item = sqlx::query_as::<_, ItemSchema>(&sql)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("failed to fetch item schema: {}", e);
|
||||
ErrorData::internal_error(format!("failed to fetch item schema: {}", e), None)
|
||||
})?;
|
||||
tx.commit().await.map_err(|e| {
|
||||
tracing::error!("failed to commit transaction: {}", e);
|
||||
ErrorData::internal_error(format!("failed to commit transaction: {}", e), None)
|
||||
})?;
|
||||
Ok(item.schema)
|
||||
}
|
||||
|
||||
/// Get all resource types from the database
|
||||
pub async fn get_resources_types(
|
||||
user_db: &UserDB,
|
||||
authed: &ApiAuthed,
|
||||
workspace_id: &str,
|
||||
) -> Result<Vec<ResourceType>, ErrorData> {
|
||||
let mut sqlb = SqlBuilder::select_from("resource_type as o");
|
||||
sqlb.fields(&["o.name", "o.description"]);
|
||||
sqlb.and_where("o.workspace_id = ?".bind(&workspace_id));
|
||||
let sql = sqlb.sql().map_err(|e| {
|
||||
tracing::error!("failed to build sql: {}", e);
|
||||
ErrorData::internal_error(format!("failed to build sql: {}", e), None)
|
||||
})?;
|
||||
let mut tx = user_db.clone().begin(authed).await.map_err(|e| {
|
||||
tracing::error!("failed to begin transaction: {}", e);
|
||||
ErrorData::internal_error(format!("failed to begin transaction: {}", e), None)
|
||||
})?;
|
||||
let rows = sqlx::query_as::<_, ResourceType>(&sql)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("failed to fetch resource types: {}", e);
|
||||
ErrorData::internal_error(format!("failed to fetch resource types: {}", e), None)
|
||||
})?;
|
||||
tx.commit().await.map_err(|e| {
|
||||
tracing::error!("failed to commit transaction: {}", e);
|
||||
ErrorData::internal_error(format!("failed to commit transaction: {}", e), None)
|
||||
})?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Get resources by type from the database
|
||||
pub async fn get_resources(
|
||||
user_db: &UserDB,
|
||||
authed: &ApiAuthed,
|
||||
workspace_id: &str,
|
||||
resource_type: &str,
|
||||
) -> Result<Vec<ResourceInfo>, ErrorData> {
|
||||
let mut sqlb = SqlBuilder::select_from("resource as o");
|
||||
sqlb.fields(&["o.path", "o.description", "o.resource_type"]);
|
||||
sqlb.and_where("o.workspace_id = ?".bind(&workspace_id));
|
||||
sqlb.and_where("o.resource_type = ?".bind(&resource_type));
|
||||
let sql = sqlb.sql().map_err(|e| {
|
||||
tracing::error!("failed to build sql: {}", e);
|
||||
ErrorData::internal_error(format!("failed to build sql: {}", e), None)
|
||||
})?;
|
||||
let mut tx = user_db.clone().begin(authed).await.map_err(|e| {
|
||||
tracing::error!("failed to begin transaction: {}", e);
|
||||
ErrorData::internal_error(format!("failed to begin transaction: {}", e), None)
|
||||
})?;
|
||||
let rows = sqlx::query_as::<_, ResourceInfo>(&sql)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("failed to fetch resources: {}", e);
|
||||
ErrorData::internal_error(format!("failed to fetch resources: {}", e), None)
|
||||
})?;
|
||||
tx.commit().await.map_err(|e| {
|
||||
tracing::error!("failed to commit transaction: {}", e);
|
||||
ErrorData::internal_error(format!("failed to commit transaction: {}", e), None)
|
||||
})?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Generic function to get items (scripts or flows) from the database
|
||||
pub async fn get_items<T: for<'a> sqlx::FromRow<'a, sqlx::postgres::PgRow> + Send + Unpin>(
|
||||
user_db: &UserDB,
|
||||
authed: &ApiAuthed,
|
||||
workspace_id: &str,
|
||||
scope_type: &str,
|
||||
item_type: &str,
|
||||
) -> Result<Vec<T>, ErrorData> {
|
||||
let mut sqlb = SqlBuilder::select_from(&format!("{} as o", item_type));
|
||||
let fields = vec!["o.path", "o.summary", "o.description", "o.schema"];
|
||||
sqlb.fields(&fields);
|
||||
if scope_type == "favorites" {
|
||||
sqlb.join("favorite")
|
||||
.on("favorite.favorite_kind = ? AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = ?".bind(&item_type)
|
||||
.bind(&authed.username));
|
||||
}
|
||||
sqlb.and_where("o.workspace_id = ?".bind(&workspace_id))
|
||||
.and_where("o.archived = false")
|
||||
.and_where("o.draft_only IS NOT TRUE");
|
||||
|
||||
if item_type == "script" {
|
||||
sqlb.and_where("(o.no_main_func IS NOT TRUE OR o.no_main_func IS NULL)");
|
||||
}
|
||||
|
||||
sqlb.order_by(
|
||||
if item_type == "flow" {
|
||||
"o.edited_at"
|
||||
} else {
|
||||
"o.created_at"
|
||||
},
|
||||
false,
|
||||
)
|
||||
.limit(ITEMS_FETCH_MAX_LIMIT);
|
||||
let sql = sqlb.sql().map_err(|e| {
|
||||
tracing::error!("failed to build sql: {}", e);
|
||||
ErrorData::internal_error(format!("failed to build sql: {}", e), None)
|
||||
})?;
|
||||
let mut tx = user_db.clone().begin(authed).await.map_err(|e| {
|
||||
tracing::error!("failed to begin transaction: {}", e);
|
||||
ErrorData::internal_error(format!("failed to begin transaction: {}", e), None)
|
||||
})?;
|
||||
let rows = sqlx::query_as::<_, T>(&sql)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("failed to fetch {}: {}", item_type, e);
|
||||
ErrorData::internal_error(format!("failed to fetch {}: {}", item_type, e), None)
|
||||
})?;
|
||||
tx.commit().await.map_err(|e| {
|
||||
tracing::error!("failed to commit transaction: {}", e);
|
||||
ErrorData::internal_error(format!("failed to commit transaction: {}", e), None)
|
||||
})?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Get scripts from the Hub
|
||||
pub async fn get_scripts_from_hub(
|
||||
db: &DB,
|
||||
scope_integrations: Option<&str>,
|
||||
) -> Result<Vec<HubScriptInfo>, ErrorData> {
|
||||
let query_params = Some(vec![
|
||||
("limit", ITEMS_FETCH_MAX_LIMIT.to_string()),
|
||||
("with_schema", "true".to_string()),
|
||||
("apps", scope_integrations.unwrap_or("").to_string()),
|
||||
]);
|
||||
let url = format!("{}/scripts/top", *HUB_BASE_URL.read().await);
|
||||
let (_status_code, _headers, response) =
|
||||
query_elems_from_hub(&HTTP_CLIENT, &url, query_params, &db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to get items from hub: {}", e);
|
||||
ErrorData::internal_error(format!("Failed to get items from hub: {}", e), None)
|
||||
})?;
|
||||
|
||||
use axum::body::to_bytes;
|
||||
let body_bytes = to_bytes(response, usize::MAX).await.map_err(|e| {
|
||||
tracing::error!("Failed to read response body: {}", e);
|
||||
ErrorData::internal_error(format!("Failed to read response body: {}", e), None)
|
||||
})?;
|
||||
let body_str = String::from_utf8(body_bytes.to_vec()).map_err(|e| {
|
||||
tracing::error!("Failed to decode response body: {}", e);
|
||||
ErrorData::internal_error(format!("Failed to decode response body: {}", e), None)
|
||||
})?;
|
||||
let hub_response: HubResponse = serde_json::from_str(&body_str).map_err(|e| {
|
||||
tracing::error!("Failed to parse hub response: {}", e);
|
||||
ErrorData::internal_error(format!("Failed to parse hub response: {}", e), None)
|
||||
})?;
|
||||
|
||||
Ok(hub_response.asks)
|
||||
}
|
||||
|
||||
/// Get the schema for a Hub script
|
||||
pub async fn get_hub_script_schema(path: &str, db: &DB) -> Result<Option<Schema>, ErrorData> {
|
||||
let strip_path = StripPath(path.to_string());
|
||||
let res = get_full_hub_script_by_path(strip_path, &HTTP_CLIENT, Some(db))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to get hub script: {}", e);
|
||||
ErrorData::internal_error(format!("Failed to get hub script: {}", e), None)
|
||||
})?;
|
||||
match serde_json::from_str::<Schema>(res.schema.get()) {
|
||||
Ok(schema) => Ok(Some(schema)),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to convert schema: {}", e);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HTTP request utilities for endpoint tools
|
||||
// ============================================================================
|
||||
|
||||
/// Substitute path parameters in the URL template
|
||||
pub fn substitute_path_params(
|
||||
path: &str,
|
||||
workspace_id: &str,
|
||||
args_map: &serde_json::Map<String, Value>,
|
||||
path_schema: &Option<Value>,
|
||||
) -> BackendResult<String> {
|
||||
let mut path_template = path.replace("{workspace}", workspace_id);
|
||||
|
||||
if let Some(schema) = path_schema {
|
||||
if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
|
||||
for (param_name, _) in props {
|
||||
let placeholder = format!("{{{}}}", param_name);
|
||||
match args_map.get(param_name) {
|
||||
Some(param_value) => {
|
||||
if let Some(str_val) = param_value.as_str() {
|
||||
path_template = path_template.replace(&placeholder, str_val);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tracing::warn!("Missing required path parameter: {}", param_name);
|
||||
return Err(ErrorData::invalid_params(
|
||||
format!("Missing required path parameter: {}", param_name),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(path_template)
|
||||
}
|
||||
|
||||
/// Build query string from arguments
|
||||
pub fn build_query_string(
|
||||
args_map: &serde_json::Map<String, Value>,
|
||||
query_schema: &Option<Value>,
|
||||
) -> String {
|
||||
let Some(schema) = query_schema else {
|
||||
return String::new();
|
||||
};
|
||||
let Some(props) = schema.get("properties").and_then(|p| p.as_object()) else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
let query_params: Vec<String> = props
|
||||
.keys()
|
||||
.filter_map(|param_name| {
|
||||
args_map
|
||||
.get(param_name)
|
||||
.filter(|v| !v.is_null())
|
||||
.map(|value| {
|
||||
let value_str = value.to_string();
|
||||
let str_val = value_str.trim_matches('"');
|
||||
format!(
|
||||
"{}={}",
|
||||
urlencoding::encode(param_name),
|
||||
urlencoding::encode(str_val)
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
if query_params.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("?{}", query_params.join("&"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Build request body from arguments
|
||||
pub fn build_request_body(
|
||||
method: &str,
|
||||
args_map: &serde_json::Map<String, Value>,
|
||||
body_schema: &Option<Value>,
|
||||
) -> Option<Value> {
|
||||
if method == "GET" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let schema = body_schema.as_ref()?;
|
||||
let props = schema.get("properties")?.as_object()?;
|
||||
|
||||
let body_map: serde_json::Map<String, Value> = props
|
||||
.keys()
|
||||
.filter_map(|param_name| {
|
||||
args_map
|
||||
.get(param_name)
|
||||
.map(|value| (param_name.clone(), value.clone()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if body_map.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Value::Object(body_map))
|
||||
}
|
||||
}
|
||||
|
||||
/// Create HTTP request with authentication
|
||||
pub async fn create_http_request(
|
||||
method: &str,
|
||||
url: &str,
|
||||
workspace_id: &str,
|
||||
api_authed: &ApiAuthed,
|
||||
body_json: Option<Value>,
|
||||
) -> BackendResult<reqwest::Response> {
|
||||
let client = &HTTP_CLIENT;
|
||||
let mut request_builder = match method {
|
||||
"GET" => client.get(url),
|
||||
"POST" => client.post(url),
|
||||
"PUT" => client.put(url),
|
||||
"DELETE" => client.delete(url),
|
||||
"PATCH" => client.patch(url),
|
||||
_ => {
|
||||
return Err(ErrorData::invalid_params(
|
||||
format!("Unsupported HTTP method: {}", method),
|
||||
None,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Add authorization header
|
||||
let authed = Authed::from(api_authed.clone());
|
||||
let token = create_jwt_token(authed, workspace_id, 3600, None, None, None, None)
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
|
||||
request_builder = request_builder.header("Authorization", format!("Bearer {}", token));
|
||||
|
||||
// Add body if present
|
||||
if let Some(body) = body_json {
|
||||
request_builder = request_builder
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body);
|
||||
}
|
||||
|
||||
request_builder
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(format!("Failed to execute request: {}", e), None))
|
||||
}
|
||||
|
||||
/// Convert a JSON Value into PushArgsOwned for job execution
|
||||
pub fn prepare_push_args(args: Value) -> windmill_queue::PushArgsOwned {
|
||||
if let Value::Object(map) = args {
|
||||
let mut args_hash = HashMap::new();
|
||||
for (k, v) in map {
|
||||
args_hash.insert(k, to_raw_value(&v));
|
||||
}
|
||||
windmill_queue::PushArgsOwned { extra: None, args: args_hash }
|
||||
} else {
|
||||
windmill_queue::PushArgsOwned::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an HTTP response body into a JSON Value
|
||||
pub async fn parse_response_body(response: Response<Body>) -> BackendResult<Value> {
|
||||
let body_bytes = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ErrorData::internal_error(format!("Failed to read response body: {}", e), None)
|
||||
})?;
|
||||
|
||||
let body_str = String::from_utf8(body_bytes.to_vec()).map_err(|e| {
|
||||
ErrorData::internal_error(format!("Failed to decode response body: {}", e), None)
|
||||
})?;
|
||||
|
||||
Ok(serde_json::from_str(&body_str).unwrap_or_else(|_| Value::String(body_str)))
|
||||
}
|
||||
243
backend/windmill-api/src/mcp/utils/database.rs
Normal file
243
backend/windmill-api/src/mcp/utils/database.rs
Normal file
@@ -0,0 +1,243 @@
|
||||
//! Database operations for MCP server
|
||||
//!
|
||||
//! Contains all database query functions and database-related utilities
|
||||
//! used by the MCP server implementation.
|
||||
|
||||
use windmill_mcp::server::ErrorData;
|
||||
use sql_builder::prelude::*;
|
||||
use windmill_common::db::UserDB;
|
||||
use windmill_common::scripts::{get_full_hub_script_by_path, Schema};
|
||||
use windmill_common::utils::{query_elems_from_hub, StripPath};
|
||||
use windmill_common::{DB, HUB_BASE_URL};
|
||||
|
||||
use super::models::*;
|
||||
use crate::db::ApiAuthed;
|
||||
use crate::HTTP_CLIENT;
|
||||
|
||||
/// Check if the user has proper MCP scopes
|
||||
pub fn check_scopes(authed: &ApiAuthed) -> Result<(), ErrorData> {
|
||||
let scopes = authed.scopes.as_ref();
|
||||
if scopes.is_none()
|
||||
|| scopes
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|scope| !scope.starts_with("mcp:"))
|
||||
{
|
||||
tracing::error!("Unauthorized: missing mcp scope");
|
||||
return Err(ErrorData::internal_error(
|
||||
"Unauthorized: missing mcp scope".to_string(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the schema for a specific item (script or flow)
|
||||
pub async fn get_item_schema(
|
||||
path: &str,
|
||||
user_db: &UserDB,
|
||||
authed: &ApiAuthed,
|
||||
workspace_id: &str,
|
||||
item_type: &str,
|
||||
) -> Result<Option<Schema>, ErrorData> {
|
||||
let mut sqlb = SqlBuilder::select_from(&format!("{} as o", item_type));
|
||||
sqlb.fields(&["o.schema"]);
|
||||
sqlb.and_where("o.path = ?".bind(&path));
|
||||
sqlb.and_where("o.workspace_id = ?".bind(&workspace_id));
|
||||
sqlb.and_where("o.archived = false");
|
||||
sqlb.and_where("o.draft_only IS NOT TRUE");
|
||||
let sql = sqlb.sql().map_err(|_e| {
|
||||
tracing::error!("failed to build sql: {}", _e);
|
||||
ErrorData::internal_error("failed to build sql", None)
|
||||
})?;
|
||||
let mut tx = user_db
|
||||
.clone()
|
||||
.begin(authed)
|
||||
.await
|
||||
.map_err(|_e| ErrorData::internal_error("failed to begin transaction", None))?;
|
||||
let item = sqlx::query_as::<_, ItemSchema>(&sql)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|_e| {
|
||||
tracing::error!("failed to fetch item schema: {}", _e);
|
||||
ErrorData::internal_error("failed to fetch item schema", None)
|
||||
})?;
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|_e| ErrorData::internal_error("failed to commit transaction", None))?;
|
||||
Ok(item.schema)
|
||||
}
|
||||
|
||||
/// Get all resource types from the database
|
||||
pub async fn get_resources_types(
|
||||
user_db: &UserDB,
|
||||
authed: &ApiAuthed,
|
||||
workspace_id: &str,
|
||||
) -> Result<Vec<ResourceType>, ErrorData> {
|
||||
let mut sqlb = SqlBuilder::select_from("resource_type as o");
|
||||
sqlb.fields(&["o.name", "o.description"]);
|
||||
sqlb.and_where("o.workspace_id = ?".bind(&workspace_id));
|
||||
let sql = sqlb.sql().map_err(|_e| {
|
||||
tracing::error!("failed to build sql: {}", _e);
|
||||
ErrorData::internal_error("failed to build sql", None)
|
||||
})?;
|
||||
let mut tx = user_db
|
||||
.clone()
|
||||
.begin(authed)
|
||||
.await
|
||||
.map_err(|_e| ErrorData::internal_error("failed to begin transaction", None))?;
|
||||
let rows = sqlx::query_as::<_, ResourceType>(&sql)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_err(|_e| {
|
||||
tracing::error!("Failed to fetch resource types: {}", _e);
|
||||
ErrorData::internal_error("failed to fetch resource types", None)
|
||||
})?;
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|_e| ErrorData::internal_error("failed to commit transaction", None))?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Get resources by type from the database
|
||||
pub async fn get_resources(
|
||||
user_db: &UserDB,
|
||||
authed: &ApiAuthed,
|
||||
workspace_id: &str,
|
||||
resource_type: &str,
|
||||
) -> Result<Vec<ResourceInfo>, ErrorData> {
|
||||
let mut sqlb = SqlBuilder::select_from("resource as o");
|
||||
sqlb.fields(&["o.path", "o.description", "o.resource_type"]);
|
||||
sqlb.and_where("o.workspace_id = ?".bind(&workspace_id));
|
||||
sqlb.and_where("o.resource_type = ?".bind(&resource_type));
|
||||
let sql = sqlb.sql().map_err(|_e| {
|
||||
tracing::error!("failed to build sql: {}", _e);
|
||||
ErrorData::internal_error("failed to build sql", None)
|
||||
})?;
|
||||
let mut tx = user_db
|
||||
.clone()
|
||||
.begin(authed)
|
||||
.await
|
||||
.map_err(|_e| ErrorData::internal_error("failed to begin transaction", None))?;
|
||||
let rows = sqlx::query_as::<_, ResourceInfo>(&sql)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_err(|_e| {
|
||||
tracing::error!("Failed to fetch resources: {}", _e);
|
||||
ErrorData::internal_error("failed to fetch resources", None)
|
||||
})?;
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|_e| ErrorData::internal_error("failed to commit transaction", None))?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Generic function to get items (scripts or flows) from the database
|
||||
pub async fn get_items<T: for<'a> sqlx::FromRow<'a, sqlx::postgres::PgRow> + Send + Unpin>(
|
||||
user_db: &UserDB,
|
||||
authed: &ApiAuthed,
|
||||
workspace_id: &str,
|
||||
scope_type: &str,
|
||||
item_type: &str,
|
||||
) -> Result<Vec<T>, ErrorData> {
|
||||
let mut sqlb = SqlBuilder::select_from(&format!("{} as o", item_type));
|
||||
let fields = vec!["o.path", "o.summary", "o.description", "o.schema"];
|
||||
sqlb.fields(&fields);
|
||||
if scope_type == "favorites" {
|
||||
sqlb.join("favorite")
|
||||
.on("favorite.favorite_kind = ? AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = ?".bind(&item_type)
|
||||
.bind(&authed.username));
|
||||
}
|
||||
sqlb.and_where("o.workspace_id = ?".bind(&workspace_id))
|
||||
.and_where("o.archived = false")
|
||||
.and_where("o.draft_only IS NOT TRUE");
|
||||
|
||||
if item_type == "script" {
|
||||
sqlb.and_where("(o.no_main_func IS NOT TRUE OR o.no_main_func IS NULL)");
|
||||
}
|
||||
|
||||
sqlb.order_by(
|
||||
if item_type == "flow" {
|
||||
"o.edited_at"
|
||||
} else {
|
||||
"o.created_at"
|
||||
},
|
||||
false,
|
||||
)
|
||||
.limit(100);
|
||||
let sql = sqlb.sql().map_err(|_e| {
|
||||
tracing::error!("failed to build sql: {}", _e);
|
||||
ErrorData::internal_error("failed to build sql", None)
|
||||
})?;
|
||||
let mut tx = user_db
|
||||
.clone()
|
||||
.begin(authed)
|
||||
.await
|
||||
.map_err(|_e| ErrorData::internal_error("failed to begin transaction", None))?;
|
||||
let rows = sqlx::query_as::<_, T>(&sql)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_err(|_e| {
|
||||
tracing::error!("Failed to fetch {}: {}", item_type, _e);
|
||||
ErrorData::internal_error(format!("failed to fetch {}", item_type), None)
|
||||
})?;
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|_e| ErrorData::internal_error("failed to commit transaction", None))?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Get scripts from the Hub
|
||||
pub async fn get_scripts_from_hub(
|
||||
db: &DB,
|
||||
scope_integrations: Option<&str>,
|
||||
) -> Result<Vec<HubScriptInfo>, ErrorData> {
|
||||
let query_params = Some(vec![
|
||||
("limit", "100".to_string()),
|
||||
("with_schema", "true".to_string()),
|
||||
("apps", scope_integrations.unwrap_or("").to_string()),
|
||||
]);
|
||||
let url = format!("{}/scripts/top", *HUB_BASE_URL.read().await);
|
||||
let (_status_code, _headers, response) =
|
||||
query_elems_from_hub(&HTTP_CLIENT, &url, query_params, &db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to get items from hub: {}", e);
|
||||
ErrorData::internal_error(format!("Failed to get items from hub: {}", e), None)
|
||||
})?;
|
||||
|
||||
use axum::body::to_bytes;
|
||||
let body_bytes = to_bytes(response, usize::MAX).await.map_err(|e| {
|
||||
tracing::error!("Failed to read response body: {}", e);
|
||||
ErrorData::internal_error(format!("Failed to read response body: {}", e), None)
|
||||
})?;
|
||||
let body_str = String::from_utf8(body_bytes.to_vec()).map_err(|e| {
|
||||
tracing::error!("Failed to decode response body: {}", e);
|
||||
ErrorData::internal_error(format!("Failed to decode response body: {}", e), None)
|
||||
})?;
|
||||
let hub_response: HubResponse = serde_json::from_str(&body_str).map_err(|e| {
|
||||
tracing::error!("Failed to parse hub response: {}", e);
|
||||
ErrorData::internal_error(format!("Failed to parse hub response: {}", e), None)
|
||||
})?;
|
||||
|
||||
Ok(hub_response.asks)
|
||||
}
|
||||
|
||||
/// Get the schema for a Hub script
|
||||
pub async fn get_hub_script_schema(path: &str, db: &DB) -> Result<Option<Schema>, ErrorData> {
|
||||
let strip_path = StripPath(path.to_string());
|
||||
let res = get_full_hub_script_by_path(strip_path, &HTTP_CLIENT, Some(db))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to get hub script: {}", e);
|
||||
ErrorData::internal_error(format!("Failed to get hub script: {}", e), None)
|
||||
})?;
|
||||
match serde_json::from_str::<Schema>(res.schema.get()) {
|
||||
Ok(schema) => Ok(Some(schema)),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to convert schema: {}", e);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
10
backend/windmill-api/src/mcp/utils/mod.rs
Normal file
10
backend/windmill-api/src/mcp/utils/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
//! Utility functions and helpers for MCP server
|
||||
//!
|
||||
//! This module contains various utility functions for schema transformation,
|
||||
//! database operations, data models, and path transformations.
|
||||
|
||||
pub mod models;
|
||||
pub mod database;
|
||||
pub mod schema;
|
||||
pub mod transform;
|
||||
pub mod scope_matcher;
|
||||
@@ -5,12 +5,10 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sqlx::FromRow;
|
||||
use std::collections::HashMap;
|
||||
use windmill_common::scripts::Schema;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// Workspace ID wrapper for Axum extensions
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WorkspaceId(pub String);
|
||||
@@ -32,8 +30,7 @@ pub struct HubScriptInfo {
|
||||
}
|
||||
|
||||
/// Schema type structure for JSON schemas
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[cfg_attr(feature = "server", derive(FromRow))]
|
||||
#[derive(Serialize, FromRow, Deserialize, Debug, Clone)]
|
||||
pub struct SchemaType {
|
||||
pub r#type: String,
|
||||
pub properties: HashMap<String, Value>,
|
||||
@@ -42,13 +39,16 @@ pub struct SchemaType {
|
||||
|
||||
impl Default for SchemaType {
|
||||
fn default() -> Self {
|
||||
Self { r#type: "object".to_string(), properties: HashMap::new(), required: vec![] }
|
||||
Self {
|
||||
r#type: "object".to_string(),
|
||||
properties: HashMap::new(),
|
||||
required: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Script information from database
|
||||
#[derive(Serialize, Debug)]
|
||||
#[cfg_attr(feature = "server", derive(FromRow))]
|
||||
#[derive(Serialize, FromRow, Debug)]
|
||||
pub struct ScriptInfo {
|
||||
pub path: String,
|
||||
pub summary: Option<String>,
|
||||
@@ -57,8 +57,7 @@ pub struct ScriptInfo {
|
||||
}
|
||||
|
||||
/// Flow information from database
|
||||
#[derive(Serialize, Debug)]
|
||||
#[cfg_attr(feature = "server", derive(FromRow))]
|
||||
#[derive(Serialize, FromRow, Debug)]
|
||||
pub struct FlowInfo {
|
||||
pub path: String,
|
||||
pub summary: Option<String>,
|
||||
@@ -67,8 +66,7 @@ pub struct FlowInfo {
|
||||
}
|
||||
|
||||
/// Resource information from database
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
#[cfg_attr(feature = "server", derive(FromRow))]
|
||||
#[derive(Serialize, FromRow, Debug, Clone)]
|
||||
pub struct ResourceInfo {
|
||||
pub path: String,
|
||||
pub description: Option<String>,
|
||||
@@ -76,34 +74,25 @@ pub struct ResourceInfo {
|
||||
}
|
||||
|
||||
/// Resource type information from database
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
#[cfg_attr(feature = "server", derive(FromRow))]
|
||||
#[derive(Serialize, FromRow, Debug, Clone)]
|
||||
pub struct ResourceType {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// Schema holder for database queries
|
||||
#[derive(Serialize)]
|
||||
#[cfg_attr(feature = "server", derive(FromRow))]
|
||||
#[derive(Serialize, FromRow)]
|
||||
pub struct ItemSchema {
|
||||
pub schema: Option<Schema>,
|
||||
}
|
||||
|
||||
/// Trait for objects that can be converted to MCP tools
|
||||
pub trait ToolableItem {
|
||||
/// Get the path or identifier for this item (transformed for MCP compatibility)
|
||||
fn get_path_or_id(&self) -> String;
|
||||
/// Get the summary/title of this item
|
||||
fn get_summary(&self) -> &str;
|
||||
/// Get the description of this item
|
||||
fn get_description(&self) -> &str;
|
||||
/// Get the JSON schema for this item's parameters
|
||||
fn get_schema(&self) -> SchemaType;
|
||||
/// Whether this item is from the Hub
|
||||
fn is_hub(&self) -> bool;
|
||||
/// Get the type of this item ("script" or "flow")
|
||||
fn item_type(&self) -> &'static str;
|
||||
/// Get the integration type (for hub scripts)
|
||||
fn get_integration_type(&self) -> Option<String>;
|
||||
}
|
||||
}
|
||||
143
backend/windmill-api/src/mcp/utils/schema.rs
Normal file
143
backend/windmill-api/src/mcp/utils/schema.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
//! Schema transformation utilities for MCP server
|
||||
//!
|
||||
//! Contains functions for transforming Windmill schemas into MCP-compatible formats,
|
||||
//! including resource enrichment and schema conversion utilities.
|
||||
|
||||
use windmill_mcp::server::ErrorData;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use windmill_common::db::UserDB;
|
||||
use windmill_common::scripts::Schema;
|
||||
|
||||
use super::database::get_resources;
|
||||
use super::models::{ResourceInfo, ResourceType, SchemaType};
|
||||
use super::transform::apply_key_transformation;
|
||||
use crate::db::ApiAuthed;
|
||||
|
||||
/// Convert a Windmill Schema to a SchemaType
|
||||
pub fn convert_schema_to_schema_type(schema: Option<Schema>) -> SchemaType {
|
||||
let schema_obj = if let Some(ref s) = schema {
|
||||
match serde_json::from_str::<SchemaType>(s.0.get()) {
|
||||
Ok(val) => val,
|
||||
Err(_) => SchemaType::default(),
|
||||
}
|
||||
} else {
|
||||
SchemaType::default()
|
||||
};
|
||||
schema_obj
|
||||
}
|
||||
|
||||
/// Transform the schema for resources by enriching with resource information
|
||||
pub async fn transform_schema_for_resources(
|
||||
schema: &SchemaType,
|
||||
user_db: &UserDB,
|
||||
authed: &ApiAuthed,
|
||||
w_id: &str,
|
||||
resources_cache: &mut HashMap<String, Vec<ResourceInfo>>,
|
||||
resources_types: &Vec<ResourceType>,
|
||||
) -> Result<SchemaType, ErrorData> {
|
||||
let mut schema_obj: SchemaType = schema.clone();
|
||||
|
||||
// replace invalid char in property key with underscore
|
||||
let replacements: Vec<(String, String, Value)> = schema_obj
|
||||
.properties
|
||||
.iter()
|
||||
.filter_map(|(key, value)| {
|
||||
if key.chars().any(|c| !c.is_alphanumeric() && c != '_') {
|
||||
let new_key = apply_key_transformation(key);
|
||||
Some((key.clone(), new_key, value.clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
for (old_key, new_key, value) in replacements {
|
||||
schema_obj.properties.remove(&old_key);
|
||||
schema_obj.properties.insert(new_key, value);
|
||||
}
|
||||
|
||||
for (_key, prop_value) in schema_obj.properties.iter_mut() {
|
||||
if let Value::Object(prop_map) = prop_value {
|
||||
// if property is a resource, fetch the resource type infos, and add each available resource to the description
|
||||
if let Some(format_value) = prop_map.get("format") {
|
||||
if let Value::String(format_str) = format_value {
|
||||
if format_str.starts_with("resource-") {
|
||||
let resource_type_key =
|
||||
format_str.split("-").last().unwrap_or_default().to_string();
|
||||
let resource_type = resources_types
|
||||
.iter()
|
||||
.find(|rt| rt.name == resource_type_key);
|
||||
let resource_type_obj = resource_type.cloned();
|
||||
|
||||
if !resources_cache.contains_key(&resource_type_key) {
|
||||
let available_resources =
|
||||
get_resources(user_db, authed, &w_id, &resource_type_key).await;
|
||||
|
||||
match available_resources {
|
||||
Ok(cache_data) => {
|
||||
resources_cache.insert(resource_type_key.clone(), cache_data);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to fetch resource cache data: {}", e);
|
||||
continue; // Skip this property if fetching failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(resource_cache) = resources_cache.get(&resource_type_key) {
|
||||
let resources_count = resource_cache.len();
|
||||
let description = match resource_type_obj {
|
||||
Some(resource_type_obj) => format!(
|
||||
"This is a resource named `{}` with the following description: `{}`.\\nThe path of the resource should be used to specify the resource.\\n{}",
|
||||
resource_type_obj.name,
|
||||
resource_type_obj.description.as_deref().unwrap_or("No description"),
|
||||
if resources_count == 0 {
|
||||
"This resource does not have any available instances, you should create one from your windmill workspace."
|
||||
} else if resources_count > 1 {
|
||||
"This resource has multiple available instances, you should precisely select the one you want to use."
|
||||
} else {
|
||||
"There is 1 resource available."
|
||||
}
|
||||
),
|
||||
None => "An object parameter.".to_string()
|
||||
};
|
||||
prop_map
|
||||
.insert("type".to_string(), Value::String("string".to_string()));
|
||||
prop_map.insert("description".to_string(), Value::String(description));
|
||||
if resources_count > 0 {
|
||||
let resources_description = resource_cache
|
||||
.iter()
|
||||
.map(|resource| {
|
||||
format!(
|
||||
"{}: $res:{}",
|
||||
resource.description.as_deref().unwrap_or("No title"),
|
||||
resource.path
|
||||
)
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.join("\\n");
|
||||
|
||||
prop_map.insert(
|
||||
"description".to_string(),
|
||||
Value::String(format!(
|
||||
"{}\\nHere are the available resources, in the format title:path. Title can be empty. Path should be used to specify the resource:\\n{}",
|
||||
prop_map.get("description").unwrap_or(&Value::String("No description".to_string())),
|
||||
resources_description
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"Schema property value is not a JSON object: {:?}",
|
||||
prop_value
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(schema_obj)
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
//! Contains utilities for parsing and matching MCP token scopes to determine
|
||||
//! which scripts, flows, and endpoints a token has access to.
|
||||
|
||||
use windmill_mcp::server::ErrorData;
|
||||
|
||||
/// Configuration for MCP scopes parsed from token scopes
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct McpScopeConfig {
|
||||
@@ -22,26 +24,8 @@ pub struct McpScopeConfig {
|
||||
pub hub_apps: Option<String>,
|
||||
}
|
||||
|
||||
impl McpScopeConfig {
|
||||
/// Check if a resource is allowed based on its type and path
|
||||
pub fn is_allowed(&self, resource_type: &str, path: &str) -> bool {
|
||||
if self.all {
|
||||
return true;
|
||||
}
|
||||
|
||||
let patterns = match resource_type {
|
||||
"script" => &self.scripts,
|
||||
"flow" => &self.flows,
|
||||
"endpoint" => &self.endpoints,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
is_resource_allowed(path, patterns)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse MCP scopes from token scope strings
|
||||
pub fn parse_mcp_scopes(scopes: &[String]) -> Result<McpScopeConfig, String> {
|
||||
pub fn parse_mcp_scopes(scopes: &[String]) -> Result<McpScopeConfig, ErrorData> {
|
||||
let mut config = McpScopeConfig::default();
|
||||
|
||||
for scope in scopes {
|
||||
@@ -85,19 +69,19 @@ pub fn parse_mcp_scopes(scopes: &[String]) -> Result<McpScopeConfig, String> {
|
||||
|
||||
if let Some(resources) = scope.strip_prefix("mcp:scripts:") {
|
||||
// New granular script scope: mcp:scripts:path1,path2,f/folder/*
|
||||
config.scripts.extend(parse_resource_list(resources));
|
||||
config.scripts.extend(parse_resource_list(resources)?);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(resources) = scope.strip_prefix("mcp:flows:") {
|
||||
// New granular flow scope: mcp:flows:path1,path2,f/folder/*
|
||||
config.flows.extend(parse_resource_list(resources));
|
||||
config.flows.extend(parse_resource_list(resources)?);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(resources) = scope.strip_prefix("mcp:endpoints:") {
|
||||
// New granular endpoint scope: mcp:endpoints:name1,name2
|
||||
config.endpoints.extend(parse_resource_list(resources));
|
||||
config.endpoints.extend(parse_resource_list(resources)?);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -110,16 +94,16 @@ pub fn parse_mcp_scopes(scopes: &[String]) -> Result<McpScopeConfig, String> {
|
||||
}
|
||||
|
||||
/// Parse comma-separated resource list
|
||||
fn parse_resource_list(resources: &str) -> Vec<String> {
|
||||
fn parse_resource_list(resources: &str) -> Result<Vec<String>, ErrorData> {
|
||||
if resources.is_empty() {
|
||||
return vec![];
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
resources
|
||||
Ok(resources
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Check if a resource path matches any pattern in the allowed list
|
||||
@@ -242,16 +226,4 @@ mod tests {
|
||||
let empty: Vec<String> = vec![];
|
||||
assert!(!is_resource_allowed("any/path", &empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scope_config_is_allowed() {
|
||||
let mut config = McpScopeConfig::default();
|
||||
config.scripts.push("u/admin/*".to_string());
|
||||
config.flows.push("f/automation/*".to_string());
|
||||
|
||||
assert!(config.is_allowed("script", "u/admin/test"));
|
||||
assert!(!config.is_allowed("script", "u/other/test"));
|
||||
assert!(config.is_allowed("flow", "f/automation/test"));
|
||||
assert!(!config.is_allowed("flow", "f/other/test"));
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,9 @@
|
||||
//! Contains functions for transforming paths, keys, and other identifiers
|
||||
//! to make them compatible with MCP tool naming requirements.
|
||||
|
||||
use super::types::SchemaType;
|
||||
use super::models::SchemaType;
|
||||
|
||||
/// MCP clients do not allow names longer than 60 characters
|
||||
// MCP clients do not allow names longer than 60 characters
|
||||
const MAX_PATH_LENGTH: usize = 60;
|
||||
|
||||
/// Transform the path for workspace scripts/flows
|
||||
@@ -114,38 +114,3 @@ pub fn reverse_transform_key(transformed_key: &str, schema_obj: &Option<SchemaTy
|
||||
|
||||
transformed_key.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_transform_path() {
|
||||
assert_eq!(
|
||||
transform_path("u/admin/script", "script"),
|
||||
"s-u_admin_script"
|
||||
);
|
||||
assert_eq!(transform_path("f/folder/flow", "flow"), "f-f_folder_flow");
|
||||
assert_eq!(transform_path("my_script", "script"), "s-my__script");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reverse_transform() {
|
||||
let (type_str, path, is_hub) = reverse_transform("s-u_admin_script").unwrap();
|
||||
assert_eq!(type_str, "script");
|
||||
assert_eq!(path, "u/admin/script");
|
||||
assert!(!is_hub);
|
||||
|
||||
let (type_str, path, is_hub) = reverse_transform("f-f_folder_flow").unwrap();
|
||||
assert_eq!(type_str, "flow");
|
||||
assert_eq!(path, "f/folder/flow");
|
||||
assert!(!is_hub);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_key_transformation() {
|
||||
assert_eq!(apply_key_transformation("my key"), "my_key");
|
||||
assert_eq!(apply_key_transformation("key!@#"), "key");
|
||||
assert_eq!(apply_key_transformation("key_123"), "key_123");
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
// Re-export from EE when private feature is enabled
|
||||
#[cfg(feature = "private")]
|
||||
pub use crate::mcp_oauth_ee::*;
|
||||
|
||||
// OSS stub implementations when private feature is not enabled
|
||||
#[cfg(not(feature = "private"))]
|
||||
mod oss_impl {
|
||||
use axum::{
|
||||
extract::Query,
|
||||
response::{Html, Redirect},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use windmill_common::error::{self, JsonResult};
|
||||
|
||||
/// Global routes for MCP OAuth (OSS stub - returns errors)
|
||||
pub fn global_service() -> Router {
|
||||
Router::new()
|
||||
.route("/discover", post(discover_mcp_oauth))
|
||||
.route("/start", get(start_mcp_oauth))
|
||||
.route("/callback", get(mcp_oauth_callback))
|
||||
.route("/client-metadata.json", get(get_client_metadata))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ClientMetadata {
|
||||
pub client_name: &'static str,
|
||||
pub redirect_uris: Vec<String>,
|
||||
pub grant_types: Vec<&'static str>,
|
||||
pub response_types: Vec<&'static str>,
|
||||
pub token_endpoint_auth_method: &'static str,
|
||||
}
|
||||
|
||||
pub async fn get_client_metadata() -> Json<ClientMetadata> {
|
||||
Json(ClientMetadata {
|
||||
client_name: "Windmill",
|
||||
redirect_uris: vec![],
|
||||
grant_types: vec!["authorization_code", "refresh_token"],
|
||||
response_types: vec!["code"],
|
||||
token_endpoint_auth_method: "none",
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct DiscoverRequest {
|
||||
pub mcp_server_url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DiscoverResponse {
|
||||
pub scopes_supported: Option<Vec<String>>,
|
||||
pub authorization_endpoint: String,
|
||||
pub token_endpoint: String,
|
||||
pub registration_endpoint: Option<String>,
|
||||
pub supports_dynamic_registration: bool,
|
||||
}
|
||||
|
||||
pub async fn discover_mcp_oauth(
|
||||
Json(_req): Json<DiscoverRequest>,
|
||||
) -> JsonResult<DiscoverResponse> {
|
||||
Err(error::Error::BadRequest(
|
||||
"Not implemented in Windmill's Open Source repository".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct StartPopupParams {
|
||||
pub mcp_server_url: String,
|
||||
#[serde(default)]
|
||||
pub scopes: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn start_mcp_oauth(
|
||||
Query(_params): Query<StartPopupParams>,
|
||||
) -> Result<Redirect, error::Error> {
|
||||
Err(error::Error::BadRequest(
|
||||
"Not implemented in Windmill's Open Source repository".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct CallbackParams {
|
||||
pub code: String,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
pub async fn mcp_oauth_callback(
|
||||
Query(_params): Query<CallbackParams>,
|
||||
) -> Result<Html<String>, error::Error> {
|
||||
let html = r#"<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>MCP OAuth Error</title></head>
|
||||
<body>
|
||||
<script>
|
||||
if (window.opener) {
|
||||
window.opener.postMessage({
|
||||
type: 'MCP_ERROR',
|
||||
error: "Not implemented in Windmill's Open Source repository"
|
||||
}, window.location.origin);
|
||||
}
|
||||
window.close();
|
||||
</script>
|
||||
<p>Not implemented in Windmill's Open Source repository</p>
|
||||
</body>
|
||||
</html>"#;
|
||||
Ok(Html(html.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub use oss_impl::*;
|
||||
@@ -68,7 +68,6 @@ fn is_public_route_whitelisted(path: &str) -> bool {
|
||||
"/api/oauth/login/*",
|
||||
"/api/oauth/connect/*",
|
||||
"/oauth/callback/*",
|
||||
"/api/mcp/oauth/callback",
|
||||
"/user/login_callback/*",
|
||||
"/api/workspaces/users",
|
||||
"/api/users/whoami",
|
||||
|
||||
@@ -1414,7 +1414,7 @@ async fn get_mcp_tools(
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("resources:read:{}", path))?;
|
||||
|
||||
let mut tx = user_db.clone().begin(&authed).await?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
// Fetch the MCP resource from database
|
||||
let resource_value_o = sqlx::query_scalar!(
|
||||
@@ -1435,53 +1435,9 @@ async fn get_mcp_tools(
|
||||
.ok_or_else(|| Error::BadRequest(format!("Empty resource value for {}", path)))?;
|
||||
|
||||
// Parse MCP resource
|
||||
let mcp_resource = serde_json::from_str::<windmill_mcp::McpResource>(resource_value.0.get())
|
||||
.map_err(|e| Error::BadRequest(format!("Failed to parse MCP resource: {}", e)))?;
|
||||
|
||||
// Check if token needs refresh before creating MCP client
|
||||
#[cfg(feature = "oauth2")]
|
||||
{
|
||||
tracing::info!("Checking if token needs refresh before creating MCP client");
|
||||
if let Some(ref token_path) = mcp_resource.token {
|
||||
let token_var_path = token_path.trim_start_matches("$var:");
|
||||
|
||||
// Query to check if token is expired
|
||||
let token_info = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
variable.account as account_id,
|
||||
(now() > account.expires_at) as "is_expired: bool"
|
||||
FROM variable
|
||||
LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2
|
||||
WHERE variable.path = $1 AND variable.workspace_id = $2
|
||||
"#,
|
||||
token_var_path,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
|
||||
if let Some(info) = token_info {
|
||||
if let (Some(account_id), Some(true)) = (info.account_id, info.is_expired) {
|
||||
let refresh_tx = user_db.begin(&authed).await?;
|
||||
if let Err(e) = crate::oauth2_oss::_refresh_token(
|
||||
refresh_tx,
|
||||
token_var_path,
|
||||
&w_id,
|
||||
account_id,
|
||||
&db,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to refresh token for MCP resource: {}. Proceeding with possibly expired token.",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mcp_resource =
|
||||
serde_json::from_str::<windmill_mcp::McpResource>(resource_value.0.get())
|
||||
.map_err(|e| Error::BadRequest(format!("Failed to parse MCP resource: {}", e)))?;
|
||||
|
||||
// Create MCP client connection
|
||||
let client = windmill_mcp::McpClient::from_resource(mcp_resource, &db, &w_id)
|
||||
@@ -1513,55 +1469,6 @@ struct GitRepositoryResource {
|
||||
branch: Option<String>,
|
||||
}
|
||||
|
||||
/// Validates a git URL to prevent git option injection attacks.
|
||||
/// Git URLs starting with '-' could be interpreted as command-line options.
|
||||
fn validate_git_url(url: &str) -> Result<()> {
|
||||
let url = url.trim();
|
||||
if url.is_empty() {
|
||||
return Err(Error::BadRequest("Git URL cannot be empty".to_string()));
|
||||
}
|
||||
if url.starts_with('-') {
|
||||
return Err(Error::BadRequest(
|
||||
"Git URL cannot start with '-' (potential option injection)".to_string(),
|
||||
));
|
||||
}
|
||||
// Block other potentially dangerous patterns
|
||||
if url.contains('\0') || url.contains('\n') || url.contains('\r') {
|
||||
return Err(Error::BadRequest(
|
||||
"Git URL contains invalid characters".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validates a git branch/ref name to prevent injection attacks.
|
||||
fn validate_git_ref(ref_name: &str) -> Result<()> {
|
||||
let ref_name = ref_name.trim();
|
||||
if ref_name.is_empty() {
|
||||
return Err(Error::BadRequest("Git ref cannot be empty".to_string()));
|
||||
}
|
||||
if ref_name.starts_with('-') {
|
||||
return Err(Error::BadRequest(
|
||||
"Git ref cannot start with '-' (potential option injection)".to_string(),
|
||||
));
|
||||
}
|
||||
// Git ref names have specific rules - block dangerous characters
|
||||
if ref_name.contains('\0')
|
||||
|| ref_name.contains('\n')
|
||||
|| ref_name.contains('\r')
|
||||
|| ref_name.contains("..")
|
||||
|| ref_name.contains("@{")
|
||||
|| ref_name.ends_with('.')
|
||||
|| ref_name.ends_with('/')
|
||||
|| ref_name.contains("//")
|
||||
{
|
||||
return Err(Error::BadRequest(
|
||||
"Git ref contains invalid characters or patterns".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GitCommitHashResponse {
|
||||
commit_hash: String,
|
||||
@@ -1722,8 +1629,7 @@ async fn get_repo_latest_commit_hash(
|
||||
git_resource: &GitRepositoryResource,
|
||||
git_ssh_command: Option<String>,
|
||||
) -> Result<String> {
|
||||
// Validate URL and branch to prevent option injection attacks
|
||||
validate_git_url(&git_resource.url)?;
|
||||
let mut git_cmd = Command::new("git");
|
||||
|
||||
let ref_spec = git_resource
|
||||
.branch
|
||||
@@ -1731,12 +1637,6 @@ async fn get_repo_latest_commit_hash(
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("HEAD");
|
||||
|
||||
// Validate ref_spec if it's not the default HEAD
|
||||
if ref_spec != "HEAD" {
|
||||
validate_git_ref(ref_spec)?;
|
||||
}
|
||||
|
||||
let mut git_cmd = Command::new("git");
|
||||
git_cmd.args(["ls-remote", &git_resource.url, ref_spec]);
|
||||
if let Some(git_ssh_command) = git_ssh_command {
|
||||
git_cmd.env("GIT_SSH_COMMAND", git_ssh_command);
|
||||
|
||||
@@ -709,31 +709,11 @@ async fn setup_custom_instance_pg_database_inner(
|
||||
// Validate name to ensure it only contains alphanumeric characters
|
||||
// Prevents SQL injection on the instance database
|
||||
lazy_static::lazy_static! {
|
||||
// Must start with a letter, then alphanumeric/underscore
|
||||
static ref VALID_NAME: regex::Regex = regex::Regex::new(r"^[a-zA-Z][a-zA-Z0-9_]*$").unwrap();
|
||||
}
|
||||
let dbname = dbname.trim();
|
||||
if dbname.is_empty() {
|
||||
return Err(error::Error::BadRequest(
|
||||
"Database name cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
// PostgreSQL identifier limit is 63 bytes
|
||||
if dbname.len() > 63 {
|
||||
return Err(error::Error::BadRequest(
|
||||
"Database name cannot exceed 63 characters".to_string(),
|
||||
));
|
||||
static ref VALID_NAME: regex::Regex = regex::Regex::new(r"^[a-zA-Z0-9_]+$").unwrap();
|
||||
}
|
||||
if !VALID_NAME.is_match(dbname) {
|
||||
return Err(error::Error::BadRequest(
|
||||
"Database name must start with a letter and contain only alphanumeric characters or underscores".to_string(),
|
||||
));
|
||||
}
|
||||
// Additional check: block PostgreSQL reserved/special names
|
||||
let lower = dbname.to_lowercase();
|
||||
if lower == "template0" || lower == "template1" || lower == "postgres" {
|
||||
return Err(error::Error::BadRequest(
|
||||
"Cannot use reserved PostgreSQL database names".to_string(),
|
||||
"Catalog name must be alphanumeric, underscores allowed".to_string(),
|
||||
));
|
||||
}
|
||||
if wmill_pg_creds.dbname.trim().eq_ignore_ascii_case(dbname.trim()) {
|
||||
|
||||
@@ -929,59 +929,6 @@ pub fn require_owner_of_path(authed: &ApiAuthed, path: &str) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks that a user has at least read access to the path for preview jobs.
|
||||
/// This prevents privilege escalation where a user could run preview code
|
||||
/// under a path they don't have access to.
|
||||
pub fn require_path_read_access_for_preview(authed: &ApiAuthed, path: &Option<String>) -> Result<()> {
|
||||
let Some(path) = path else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if authed.is_admin {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if path.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let splitted: Vec<&str> = path.split('/').collect();
|
||||
if splitted.len() < 2 {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Invalid path format for preview job: {}",
|
||||
path
|
||||
)));
|
||||
}
|
||||
|
||||
match splitted[0] {
|
||||
"u" => {
|
||||
if splitted[1] == authed.username {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::BadRequest(format!(
|
||||
"You can only run preview jobs in your own namespace (u/{}) or in folders you have read access to",
|
||||
authed.username
|
||||
)))
|
||||
}
|
||||
}
|
||||
"f" => {
|
||||
let folder = splitted[1];
|
||||
if authed.folders.iter().any(|(f, _, _)| f == folder) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::BadRequest(format!(
|
||||
"You do not have read access to folder '{}'. Preview jobs require at least read access to the target folder.",
|
||||
folder
|
||||
)))
|
||||
}
|
||||
}
|
||||
_ => Err(Error::BadRequest(format!(
|
||||
"Invalid path format for preview job: {}. Path must start with 'u/' or 'f/'",
|
||||
path
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_perm_in_extra_perms_for_authed(
|
||||
v: serde_json::Value,
|
||||
authed: &ApiAuthed,
|
||||
|
||||
@@ -18,7 +18,6 @@ use uuid::Uuid;
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::JsonResult,
|
||||
jobs::TAGS_ARE_SENSITIVE,
|
||||
utils::{paginate, Pagination},
|
||||
worker::{ALL_TAGS, CUSTOM_TAGS_PER_WORKSPACE, DEFAULT_TAGS, DEFAULT_TAGS_PER_WORKSPACE},
|
||||
DB,
|
||||
@@ -113,64 +112,28 @@ async fn list_worker_pings(
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
let rows = if *TAGS_ARE_SENSITIVE && !is_super_admin {
|
||||
rows.into_iter()
|
||||
.map(|mut w| {
|
||||
w.custom_tags = None;
|
||||
w
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
rows
|
||||
};
|
||||
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct TagsQuery {
|
||||
tags: String,
|
||||
workspace: Option<String>,
|
||||
}
|
||||
|
||||
async fn exists_workers_with_tags(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Query(tags_query): Query<TagsQuery>,
|
||||
) -> JsonResult<std::collections::HashMap<String, bool>> {
|
||||
// Create a list of requested tags
|
||||
let mut tags: Vec<String> = tags_query
|
||||
.tags
|
||||
.split(',')
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
// When TAGS_ARE_SENSITIVE is enabled, filter tags based on workspace visibility
|
||||
if *TAGS_ARE_SENSITIVE {
|
||||
let is_super_admin = require_super_admin(&db, &authed.email).await.is_ok();
|
||||
if !is_super_admin {
|
||||
if let Some(ref workspace) = tags_query.workspace {
|
||||
// Filter to only tags visible in this workspace
|
||||
let custom_tags = CUSTOM_TAGS_PER_WORKSPACE.read().await;
|
||||
let allowed_tags = custom_tags.to_string_vec(Some(workspace.clone()));
|
||||
tags.retain(|t| allowed_tags.contains(t));
|
||||
} else {
|
||||
// No workspace provided and not superadmin - return empty
|
||||
return Ok(Json(std::collections::HashMap::new()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if tags.is_empty() {
|
||||
return Ok(Json(std::collections::HashMap::new()));
|
||||
}
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let mut result = std::collections::HashMap::new();
|
||||
|
||||
// Create a query that checks all tags at once using unnest
|
||||
let tags = tags_query
|
||||
.tags
|
||||
.split(',')
|
||||
.map(|s| s.to_string())
|
||||
.collect::<Vec<String>>();
|
||||
let rows = sqlx::query!(
|
||||
"SELECT tag::text, EXISTS(SELECT 1 FROM worker_ping WHERE custom_tags @> ARRAY[tag] AND ping_at > now() - interval '1 minute') as exists
|
||||
FROM unnest($1::text[]) as tag",
|
||||
@@ -192,11 +155,7 @@ struct CustomTagQuery {
|
||||
workspace: Option<String>,
|
||||
show_workspace_restriction: Option<bool>,
|
||||
}
|
||||
async fn get_custom_tags(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Query(query): Query<CustomTagQuery>,
|
||||
) -> JsonResult<Vec<String>> {
|
||||
async fn get_custom_tags(Query(query): Query<CustomTagQuery>) -> JsonResult<Vec<String>> {
|
||||
if query.show_workspace_restriction.is_some_and(|x| x) && query.workspace.is_some() {
|
||||
return Err(windmill_common::error::Error::BadRequest(
|
||||
"Cannot use both workspace and show_workspace_restriction".to_string(),
|
||||
@@ -211,12 +170,6 @@ async fn get_custom_tags(
|
||||
let all_tags = tags_o.to_string_vec(None);
|
||||
return Ok(Json(all_tags));
|
||||
}
|
||||
if *TAGS_ARE_SENSITIVE {
|
||||
let is_super_admin = require_super_admin(&db, &authed.email).await.is_ok();
|
||||
if !is_super_admin {
|
||||
return Ok(Json(vec![]));
|
||||
}
|
||||
}
|
||||
Ok(Json(ALL_TAGS.read().await.clone().into()))
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ smtp = ["dep:mail-send"]
|
||||
scoped_cache = []
|
||||
cloud = []
|
||||
openidconnect = ["dep:openidconnect"]
|
||||
pg_embed = ["dep:postgresql_embedded"]
|
||||
[lib]
|
||||
name = "windmill_common"
|
||||
path = "src/lib.rs"
|
||||
@@ -114,6 +115,7 @@ opentelemetry = { workspace = true, optional = true }
|
||||
tracing-opentelemetry = { workspace = true, optional = true }
|
||||
opentelemetry-appender-tracing = { workspace = true, optional = true }
|
||||
tonic = { workspace = true, optional = true }
|
||||
postgresql_embedded = { version = "0.18.1", optional = true, features = ["theseus"], default-features = false }
|
||||
|
||||
[target.'cfg(not(target_env = "msvc"))'.dependencies]
|
||||
tikv-jemalloc-ctl = { optional = true, workspace = true }
|
||||
|
||||
@@ -822,7 +822,7 @@ pub async fn get_logs_from_store(
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref TAGS_ARE_SENSITIVE: bool = std::env::var("TAGS_ARE_SENSITIVE").map(
|
||||
static ref TAGS_ARE_SENSITIVE: bool = std::env::var("TAGS_ARE_SENSITIVE").map(
|
||||
|v| v.parse().unwrap()
|
||||
).unwrap_or(false);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,8 @@ pub mod oidc_oss;
|
||||
#[cfg(feature = "private")]
|
||||
pub mod otel_ee;
|
||||
pub mod otel_oss;
|
||||
#[cfg(feature = "pg_embed")]
|
||||
pub mod pg_embed;
|
||||
pub mod queue;
|
||||
pub mod result_stream;
|
||||
pub mod runnable_settings;
|
||||
|
||||
144
backend/windmill-common/src/pg_embed.rs
Normal file
144
backend/windmill-common/src/pg_embed.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use crate::error::Error;
|
||||
use postgresql_embedded::{PostgreSQL, Settings};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Manages an embedded PostgreSQL instance
|
||||
pub struct EmbeddedPostgres {
|
||||
postgresql: Arc<RwLock<PostgreSQL>>,
|
||||
database_url: String,
|
||||
}
|
||||
|
||||
impl EmbeddedPostgres {
|
||||
/// Initialize and start an embedded PostgreSQL instance
|
||||
pub async fn new() -> Result<Self, Error> {
|
||||
tracing::info!("Initializing embedded PostgreSQL instance...");
|
||||
|
||||
// Configure the embedded PostgreSQL settings
|
||||
let mut settings = Settings::default();
|
||||
|
||||
// Use environment variables if provided for customization
|
||||
if let Ok(data_dir) = std::env::var("PG_EMBED_DATA_DIR") {
|
||||
settings.installation_dir = data_dir.into();
|
||||
}
|
||||
|
||||
if let Ok(port) = std::env::var("PG_EMBED_PORT") {
|
||||
if let Ok(port_num) = port.parse::<u16>() {
|
||||
settings.port = port_num;
|
||||
}
|
||||
}
|
||||
|
||||
let mut postgresql = PostgreSQL::new(settings);
|
||||
|
||||
// Setup the PostgreSQL instance
|
||||
postgresql
|
||||
.setup()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let err_msg = format!("Failed to setup embedded PostgreSQL: {}", e);
|
||||
if err_msg.contains("libxml2") || err_msg.contains("shared libraries") {
|
||||
Error::InternalErr(format!(
|
||||
"{}\n\n\
|
||||
System dependencies are required for embedded PostgreSQL.\n\
|
||||
On Arch Linux, install: sudo pacman -S libxml2 icu openssl\n\
|
||||
On Ubuntu/Debian: sudo apt-get install libxml2 libicu-dev libssl-dev\n\
|
||||
On RHEL/Fedora: sudo dnf install libxml2 libicu openssl-libs\n\n\
|
||||
Alternatively, set DATABASE_URL to use an external PostgreSQL instance.",
|
||||
err_msg
|
||||
))
|
||||
} else {
|
||||
Error::InternalErr(err_msg)
|
||||
}
|
||||
})?;
|
||||
|
||||
tracing::info!("Starting embedded PostgreSQL...");
|
||||
|
||||
// Start the PostgreSQL instance
|
||||
postgresql
|
||||
.start()
|
||||
.await
|
||||
.map_err(|e| Error::InternalErr(format!("Failed to start embedded PostgreSQL: {}", e)))?;
|
||||
|
||||
tracing::info!("Embedded PostgreSQL started successfully");
|
||||
|
||||
// Get the database settings
|
||||
let settings = postgresql.settings();
|
||||
|
||||
// Create the windmill database
|
||||
let database_name = std::env::var("PG_EMBED_DATABASE")
|
||||
.unwrap_or_else(|_| "windmill".to_string());
|
||||
|
||||
tracing::info!("Creating database: {}", database_name);
|
||||
|
||||
postgresql
|
||||
.create_database(&database_name)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::InternalErr(format!("Failed to create database '{}': {}", database_name, e))
|
||||
})?;
|
||||
|
||||
// Build the connection string
|
||||
let database_url = format!(
|
||||
"postgres://{}:{}@{}:{}/{}",
|
||||
settings.username,
|
||||
settings.password,
|
||||
settings.host,
|
||||
settings.port,
|
||||
database_name
|
||||
);
|
||||
|
||||
tracing::info!("Embedded PostgreSQL ready at: postgres://{}:{}@{}:{}/{}",
|
||||
settings.username,
|
||||
"***", // Don't log password
|
||||
settings.host,
|
||||
settings.port,
|
||||
database_name
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
postgresql: Arc::new(RwLock::new(postgresql)),
|
||||
database_url,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the database connection URL
|
||||
pub fn database_url(&self) -> &str {
|
||||
&self.database_url
|
||||
}
|
||||
|
||||
/// Stop the embedded PostgreSQL instance
|
||||
pub async fn stop(&self) -> Result<(), Error> {
|
||||
tracing::info!("Stopping embedded PostgreSQL...");
|
||||
|
||||
let pg = self.postgresql.write().await;
|
||||
pg.stop()
|
||||
.await
|
||||
.map_err(|e| Error::InternalErr(format!("Failed to stop embedded PostgreSQL: {}", e)))?;
|
||||
|
||||
tracing::info!("Embedded PostgreSQL stopped");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize an embedded PostgreSQL instance and set the DATABASE_URL environment variable
|
||||
pub async fn init_embedded_postgres() -> Result<EmbeddedPostgres, Error> {
|
||||
let embedded_pg = EmbeddedPostgres::new().await?;
|
||||
|
||||
// Set the DATABASE_URL environment variable so that the rest of the application
|
||||
// can use it transparently
|
||||
unsafe {
|
||||
std::env::set_var("DATABASE_URL", embedded_pg.database_url());
|
||||
}
|
||||
|
||||
tracing::info!("DATABASE_URL set to embedded PostgreSQL instance");
|
||||
|
||||
Ok(embedded_pg)
|
||||
}
|
||||
@@ -19,7 +19,6 @@ pub enum DeployedObject {
|
||||
Script { hash: ScriptHash, path: String, parent_path: Option<String> },
|
||||
Flow { path: String, parent_path: Option<String>, version: i64 },
|
||||
App { path: String, version: i64, parent_path: Option<String> },
|
||||
RawApp { path: String, version: i64, parent_path: Option<String> },
|
||||
Folder { path: String },
|
||||
Resource { path: String, parent_path: Option<String> },
|
||||
Variable { path: String, parent_path: Option<String> },
|
||||
@@ -46,7 +45,6 @@ impl DeployedObject {
|
||||
DeployedObject::Script { path, .. } => path.to_owned(),
|
||||
DeployedObject::Flow { path, .. } => path.to_owned(),
|
||||
DeployedObject::App { path, .. } => path.to_owned(),
|
||||
DeployedObject::RawApp { path, .. } => path.to_owned(),
|
||||
DeployedObject::Folder { path, .. } => path.to_owned(),
|
||||
DeployedObject::Resource { path, .. } => path.to_owned(),
|
||||
DeployedObject::Variable { path, .. } => path.to_owned(),
|
||||
@@ -84,7 +82,6 @@ impl DeployedObject {
|
||||
DeployedObject::Script { parent_path, .. } => parent_path.to_owned(),
|
||||
DeployedObject::Flow { parent_path, .. } => parent_path.to_owned(),
|
||||
DeployedObject::App { parent_path, .. } => parent_path.to_owned(),
|
||||
DeployedObject::RawApp { parent_path, .. } => parent_path.to_owned(),
|
||||
DeployedObject::Folder { .. } => None,
|
||||
DeployedObject::Resource { parent_path, .. } => parent_path.to_owned(),
|
||||
DeployedObject::Variable { parent_path, .. } => parent_path.to_owned(),
|
||||
@@ -111,7 +108,6 @@ impl DeployedObject {
|
||||
DeployedObject::Script { .. } => "script",
|
||||
DeployedObject::Flow { .. } => "flow",
|
||||
DeployedObject::App { .. } => "app",
|
||||
DeployedObject::RawApp { .. } => "raw_app",
|
||||
DeployedObject::Folder { .. } => "folder",
|
||||
DeployedObject::Resource { .. } => "resource",
|
||||
DeployedObject::Variable { .. } => "variable",
|
||||
|
||||
@@ -10,11 +10,9 @@ path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
server = ["rmcp/transport-streamable-http-server", "rmcp/transport-streamable-http-server-session", "rmcp/transport-worker", "dep:sqlx", "dep:async-trait", "dep:http", "dep:tokio-util", "dep:tokio"]
|
||||
auth = ["rmcp/auth", "dep:oauth2"]
|
||||
server = ["rmcp/transport-streamable-http-server", "rmcp/transport-streamable-http-server-session", "rmcp/transport-worker"]
|
||||
|
||||
[dependencies]
|
||||
oauth2 = { version = "5.0", optional = true }
|
||||
windmill-common = { workspace = true, default-features = false }
|
||||
anyhow.workspace = true
|
||||
reqwest = { version = "=0.12", features = ["json", "stream", "gzip"] }
|
||||
@@ -22,9 +20,3 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
rmcp.workspace = true
|
||||
sqlx = { workspace = true, optional = true }
|
||||
async-trait = { workspace = true, optional = true }
|
||||
http = { workspace = true, optional = true }
|
||||
tokio-util = { workspace = true, features = ["rt"], optional = true }
|
||||
tokio = { workspace = true, optional = true }
|
||||
futures.workspace = true
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
//! MCP Client implementation
|
||||
//!
|
||||
//! This module provides functionality for connecting to external MCP servers
|
||||
//! and executing tools on them.
|
||||
|
||||
mod types;
|
||||
|
||||
pub use types::{McpResource, McpToolSource};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
use rmcp::{
|
||||
model::{
|
||||
CallToolRequestParam, ClientCapabilities, ClientInfo, Implementation,
|
||||
InitializeRequestParam, Tool as McpTool,
|
||||
},
|
||||
service::RunningService,
|
||||
transport::{
|
||||
streamable_http_client::StreamableHttpClientTransportConfig, StreamableHttpClientTransport,
|
||||
},
|
||||
RoleClient, ServiceExt,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::str::FromStr;
|
||||
use windmill_common::variables::get_secret_value_as_admin;
|
||||
use windmill_common::DB;
|
||||
|
||||
/// MCP client for communicating with external MCP servers
|
||||
pub struct McpClient {
|
||||
/// The underlying rmcp client
|
||||
client: RunningService<RoleClient, InitializeRequestParam>,
|
||||
/// Cached list of available tools from the server
|
||||
available_tools: Vec<McpTool>,
|
||||
}
|
||||
|
||||
impl McpClient {
|
||||
/// Create a new MCP client from a resource configuration
|
||||
pub async fn from_resource(resource: McpResource, db: &DB, w_id: &str) -> Result<Self> {
|
||||
// Build custom reqwest client with headers if provided
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(token_path) = &resource.token {
|
||||
if !token_path.trim().is_empty() {
|
||||
let value =
|
||||
get_secret_value_as_admin(db, w_id, token_path.trim_start_matches("$var:"))
|
||||
.await?;
|
||||
headers.insert(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_str(format!("Bearer {}", value).as_str())?,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(resource_headers) = &resource.headers {
|
||||
for (key, value) in resource_headers {
|
||||
match (HeaderName::from_str(key), HeaderValue::from_str(value)) {
|
||||
(Ok(name), Ok(value)) => {
|
||||
headers.insert(name, value);
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!("Invalid header: {}={}", key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let reqwest_client = reqwest::Client::builder()
|
||||
.default_headers(headers)
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
// Create the HTTP transport with custom client
|
||||
let config = StreamableHttpClientTransportConfig::with_uri(resource.url.as_str());
|
||||
let transport = StreamableHttpClientTransport::with_client(reqwest_client, config);
|
||||
|
||||
// Set up client info
|
||||
let client_info = ClientInfo {
|
||||
protocol_version: Default::default(),
|
||||
capabilities: ClientCapabilities::default(),
|
||||
client_info: Implementation {
|
||||
name: "windmill-ai-agent".to_string(),
|
||||
title: Some("Windmill AI Agent".to_string()),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
website_url: None,
|
||||
icons: None,
|
||||
},
|
||||
};
|
||||
|
||||
// Initialize the connection
|
||||
let client = client_info
|
||||
.serve(transport)
|
||||
.await
|
||||
.context("Failed to connect to MCP server")?;
|
||||
|
||||
// Immediately fetch available tools
|
||||
let available_tools = client
|
||||
.list_tools(Default::default())
|
||||
.await
|
||||
.context("Failed to list tools from MCP server")?
|
||||
.tools;
|
||||
|
||||
Ok(Self { client, available_tools })
|
||||
}
|
||||
|
||||
/// Get the list of available tools from the MCP server
|
||||
pub fn available_tools(&self) -> &[McpTool] {
|
||||
&self.available_tools
|
||||
}
|
||||
|
||||
/// Call a tool on the MCP server, with openai-style arguments
|
||||
pub async fn call_tool(&self, name: &str, arguments: &str) -> Result<serde_json::Value> {
|
||||
// Convert OpenAI-style arguments to MCP format
|
||||
let mcp_args =
|
||||
Self::openai_args_to_mcp_args(arguments).context("Failed to parse tool arguments")?;
|
||||
|
||||
let result = self
|
||||
.client
|
||||
.call_tool(CallToolRequestParam {
|
||||
name: name.to_string().into(),
|
||||
arguments: mcp_args,
|
||||
task: None,
|
||||
})
|
||||
.await
|
||||
.context(format!("Failed to call MCP tool: {}", name))?;
|
||||
|
||||
// Convert the result to a JSON value
|
||||
// MCP tools return ToolResult which contains content array
|
||||
let result_json =
|
||||
serde_json::to_value(&result).context("Failed to serialize MCP tool result")?;
|
||||
|
||||
Ok(result_json)
|
||||
}
|
||||
|
||||
/// Close the connection
|
||||
pub async fn shutdown(self) -> Result<()> {
|
||||
self.client.cancel().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fix array schemas to ensure they have the required 'items' property
|
||||
/// OpenAI requires all array types to have an 'items' field. MCP servers may
|
||||
/// return schemas without this field, so we add a default.
|
||||
pub fn fix_array_schemas(schema: &mut Value) {
|
||||
if let Value::Object(obj) = schema {
|
||||
// Check if this is an array type
|
||||
if let Some(type_val) = obj.get("type") {
|
||||
let is_array = match type_val {
|
||||
Value::String(s) => s == "array",
|
||||
Value::Array(arr) => arr.iter().any(|v| v.as_str() == Some("array")),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
// If it's an array and missing 'items', add a default
|
||||
if is_array && !obj.contains_key("items") {
|
||||
obj.insert("items".to_string(), json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively fix nested schemas
|
||||
if let Some(Value::Object(props)) = obj.get_mut("properties") {
|
||||
for value in props.values_mut() {
|
||||
Self::fix_array_schemas(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Fix items if present (for nested arrays)
|
||||
if let Some(items) = obj.get_mut("items") {
|
||||
Self::fix_array_schemas(items);
|
||||
}
|
||||
|
||||
// Fix oneOf, anyOf, allOf schemas
|
||||
for key in &["oneOf", "anyOf", "allOf"] {
|
||||
if let Some(Value::Array(schemas)) = obj.get_mut(*key) {
|
||||
for schema in schemas {
|
||||
Self::fix_array_schemas(schema);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fix additionalProperties if it's a schema
|
||||
if let Some(additional) = obj.get_mut("additionalProperties") {
|
||||
if additional.is_object() {
|
||||
Self::fix_array_schemas(additional);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert OpenAI-style tool call arguments to MCP format
|
||||
/// OpenAI sends arguments as a JSON string, MCP expects a Map
|
||||
fn openai_args_to_mcp_args(
|
||||
args_str: &str,
|
||||
) -> Result<Option<serde_json::Map<String, serde_json::Value>>> {
|
||||
if args_str.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let args_value: serde_json::Value =
|
||||
serde_json::from_str(args_str).context("Failed to parse tool call arguments")?;
|
||||
|
||||
match args_value {
|
||||
serde_json::Value::Object(map) => Ok(Some(map)),
|
||||
serde_json::Value::Null => Ok(None),
|
||||
_ => Ok(Some(
|
||||
vec![("value".to_string(), args_value)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
//! Client-specific types for MCP
|
||||
//!
|
||||
//! Contains configuration and metadata types used for MCP client connections.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// MCP server resource configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpResource {
|
||||
/// Name of the MCP resource (used for prefixing tools)
|
||||
pub name: String,
|
||||
/// HTTP URL for the MCP server endpoint
|
||||
pub url: String,
|
||||
/// Optional token for authentication
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub token: Option<String>,
|
||||
/// Optional headers
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub headers: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
/// Metadata for tracking MCP tool sources
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpToolSource {
|
||||
/// Name of the MCP resource this tool comes from
|
||||
pub name: String,
|
||||
/// Original tool name in the MCP server
|
||||
pub tool_name: String,
|
||||
/// Path of the MCP resource
|
||||
pub resource_path: String,
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
//! Common types and utilities for MCP server and client
|
||||
//!
|
||||
//! This module contains shared data structures, transformation utilities,
|
||||
//! and scope parsing functionality used throughout the MCP implementation.
|
||||
|
||||
pub mod schema;
|
||||
pub mod scope;
|
||||
pub mod transform;
|
||||
pub mod types;
|
||||
|
||||
pub use schema::convert_schema_to_schema_type;
|
||||
pub use scope::{is_resource_allowed, parse_mcp_scopes, McpScopeConfig};
|
||||
pub use transform::{
|
||||
apply_key_transformation, reverse_transform, reverse_transform_key, transform_path,
|
||||
};
|
||||
pub use types::*;
|
||||
@@ -1,41 +0,0 @@
|
||||
//! Schema conversion utilities for MCP server
|
||||
//!
|
||||
//! Contains functions for converting Windmill schemas into MCP-compatible formats.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::types::SchemaType;
|
||||
use windmill_common::scripts::Schema;
|
||||
|
||||
/// Convert a Windmill Schema to a SchemaType
|
||||
pub fn convert_schema_to_schema_type(schema: Option<Schema>) -> SchemaType {
|
||||
let schema_obj = if let Some(ref s) = schema {
|
||||
match serde_json::from_str::<SchemaType>(s.0.get()) {
|
||||
Ok(val) => val,
|
||||
Err(_) => SchemaType::default(),
|
||||
}
|
||||
} else {
|
||||
SchemaType::default()
|
||||
};
|
||||
schema_obj
|
||||
}
|
||||
|
||||
/// Extract resource type keys from a schema
|
||||
///
|
||||
/// Scans the schema properties for fields with format "resource-{type}"
|
||||
/// and returns a set of all unique resource type names found.
|
||||
pub fn extract_resource_types_from_schema(schema: &SchemaType) -> HashSet<String> {
|
||||
let mut resource_types = HashSet::new();
|
||||
for (_key, prop_value) in schema.properties.iter() {
|
||||
if let Value::Object(prop_map) = prop_value {
|
||||
if let Some(Value::String(format_str)) = prop_map.get("format") {
|
||||
if let Some(rt) = format_str.strip_prefix("resource-") {
|
||||
resource_types.insert(rt.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
resource_types
|
||||
}
|
||||
@@ -1,44 +1,257 @@
|
||||
//! Windmill MCP (Model Context Protocol) implementation
|
||||
//!
|
||||
//! This crate provides:
|
||||
//! - MCP client for connecting to external MCP servers (used by AI agents)
|
||||
//! - Common types and utilities for MCP implementations
|
||||
//! - MCP server types (when `server` feature is enabled)
|
||||
//! - OAuth support (when `auth` feature is enabled)
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
// Common types and utilities module
|
||||
pub mod common;
|
||||
|
||||
// Client module
|
||||
pub mod client;
|
||||
|
||||
// Re-export common types at crate root for convenience
|
||||
pub use common::{
|
||||
convert_schema_to_schema_type, is_resource_allowed, parse_mcp_scopes, transform_path, FlowInfo,
|
||||
HubResponse, HubScriptInfo, ItemSchema, McpScopeConfig, ResourceInfo, ResourceType, SchemaType,
|
||||
ScriptInfo, ToolableItem, WorkspaceId,
|
||||
};
|
||||
|
||||
// Re-export client types at crate root for backward compatibility
|
||||
pub use client::{McpClient, McpResource, McpToolSource};
|
||||
use anyhow::{Context, Result};
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
use serde_json::{json, Value};
|
||||
use std::str::FromStr;
|
||||
use windmill_common::variables::get_secret_value_as_admin;
|
||||
use windmill_common::DB;
|
||||
|
||||
// Re-export rmcp types for client usage
|
||||
pub use rmcp::model::Tool as McpTool;
|
||||
use rmcp::{
|
||||
model::{
|
||||
CallToolRequestParam, ClientCapabilities, ClientInfo, Implementation,
|
||||
InitializeRequestParam,
|
||||
},
|
||||
service::RunningService,
|
||||
transport::{
|
||||
streamable_http_client::StreamableHttpClientTransportConfig, StreamableHttpClientTransport,
|
||||
},
|
||||
RoleClient, ServiceExt,
|
||||
};
|
||||
|
||||
// Server module (when server feature is enabled)
|
||||
// Re-export rmcp server types when server feature is enabled
|
||||
#[cfg(feature = "server")]
|
||||
pub mod server;
|
||||
pub mod server {
|
||||
//! Re-exports of rmcp server types for MCP server implementations
|
||||
|
||||
// Re-export rmcp auth types when auth feature is enabled
|
||||
#[cfg(feature = "auth")]
|
||||
pub mod oauth {
|
||||
//! Re-exports of rmcp auth and oauth2 types for MCP OAuth implementations
|
||||
|
||||
pub use rmcp::transport::auth::AuthorizationManager;
|
||||
|
||||
// Re-export oauth2 types needed for MCP OAuth flow
|
||||
pub use oauth2::{
|
||||
basic::BasicClient, AuthUrl, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge,
|
||||
RedirectUrl, Scope, TokenUrl,
|
||||
pub use rmcp::handler::server::ServerHandler;
|
||||
pub use rmcp::model::{
|
||||
Annotated, CallToolRequestParam, CallToolResult, Content, Implementation,
|
||||
InitializeRequestParam, InitializeResult, ListPromptsResult, ListResourceTemplatesResult,
|
||||
ListResourcesResult, ListToolsResult, PaginatedRequestParam, ProtocolVersion, RawContent,
|
||||
RawTextContent, ServerCapabilities, ServerInfo, Tool, ToolAnnotations,
|
||||
};
|
||||
pub use rmcp::service::{RequestContext, RoleServer};
|
||||
pub use rmcp::transport::streamable_http_server::{
|
||||
session::local::LocalSessionManager, StreamableHttpService,
|
||||
};
|
||||
pub use rmcp::transport::StreamableHttpServerConfig;
|
||||
pub use rmcp::ErrorData;
|
||||
}
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// MCP server resource configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpResource {
|
||||
/// Name of the MCP resource (used for prefixing tools)
|
||||
pub name: String,
|
||||
/// HTTP URL for the MCP server endpoint
|
||||
pub url: String,
|
||||
/// Optional token for authentication
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub token: Option<String>,
|
||||
/// Optional headers
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub headers: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
/// Metadata for tracking MCP tool sources
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpToolSource {
|
||||
/// Name of the MCP resource this tool comes from
|
||||
pub name: String,
|
||||
/// Original tool name in the MCP server
|
||||
pub tool_name: String,
|
||||
/// Path of the MCP resource
|
||||
pub resource_path: String,
|
||||
}
|
||||
|
||||
/// MCP client for communicating with external MCP servers
|
||||
pub struct McpClient {
|
||||
/// The underlying rmcp client
|
||||
client: RunningService<RoleClient, InitializeRequestParam>,
|
||||
/// Cached list of available tools from the server
|
||||
available_tools: Vec<McpTool>,
|
||||
}
|
||||
|
||||
impl McpClient {
|
||||
/// Create a new MCP client from a resource configuration
|
||||
pub async fn from_resource(resource: McpResource, db: &DB, w_id: &str) -> Result<Self> {
|
||||
// Build custom reqwest client with headers if provided
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(token_path) = &resource.token {
|
||||
if !token_path.trim().is_empty() {
|
||||
let value =
|
||||
get_secret_value_as_admin(db, w_id, token_path.trim_start_matches("$var:"))
|
||||
.await?;
|
||||
headers.insert(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_str(format!("Bearer {}", value).as_str())?,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(resource_headers) = &resource.headers {
|
||||
for (key, value) in resource_headers {
|
||||
match (HeaderName::from_str(key), HeaderValue::from_str(value)) {
|
||||
(Ok(name), Ok(value)) => {
|
||||
headers.insert(name, value);
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!("Invalid header: {}={}", key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let reqwest_client = reqwest::Client::builder()
|
||||
.default_headers(headers)
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
// Create the HTTP transport with custom client
|
||||
let config = StreamableHttpClientTransportConfig::with_uri(resource.url.as_str());
|
||||
let transport = StreamableHttpClientTransport::with_client(reqwest_client, config);
|
||||
|
||||
// Set up client info
|
||||
let client_info = ClientInfo {
|
||||
protocol_version: Default::default(),
|
||||
capabilities: ClientCapabilities::default(),
|
||||
client_info: Implementation {
|
||||
name: "windmill-ai-agent".to_string(),
|
||||
title: Some("Windmill AI Agent".to_string()),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
website_url: None,
|
||||
icons: None,
|
||||
},
|
||||
};
|
||||
|
||||
// Initialize the connection
|
||||
let client = client_info
|
||||
.serve(transport)
|
||||
.await
|
||||
.context("Failed to connect to MCP server")?;
|
||||
|
||||
// Immediately fetch available tools
|
||||
let available_tools = client
|
||||
.list_tools(Default::default())
|
||||
.await
|
||||
.context("Failed to list tools from MCP server")?
|
||||
.tools;
|
||||
|
||||
Ok(Self { client, available_tools })
|
||||
}
|
||||
|
||||
/// Get the list of available tools from the MCP server
|
||||
pub fn available_tools(&self) -> &[McpTool] {
|
||||
&self.available_tools
|
||||
}
|
||||
|
||||
/// Call a tool on the MCP server, with openai-style arguments
|
||||
pub async fn call_tool(&self, name: &str, arguments: &str) -> Result<serde_json::Value> {
|
||||
// Convert OpenAI-style arguments to MCP format
|
||||
let mcp_args =
|
||||
Self::openai_args_to_mcp_args(arguments).context("Failed to parse tool arguments")?;
|
||||
|
||||
let result = self
|
||||
.client
|
||||
.call_tool(CallToolRequestParam { name: name.to_string().into(), arguments: mcp_args })
|
||||
.await
|
||||
.context(format!("Failed to call MCP tool: {}", name))?;
|
||||
|
||||
// Convert the result to a JSON value
|
||||
// MCP tools return ToolResult which contains content array
|
||||
let result_json =
|
||||
serde_json::to_value(&result).context("Failed to serialize MCP tool result")?;
|
||||
|
||||
Ok(result_json)
|
||||
}
|
||||
|
||||
/// Close the connection
|
||||
pub async fn shutdown(self) -> Result<()> {
|
||||
self.client.cancel().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fix array schemas to ensure they have the required 'items' property
|
||||
/// OpenAI requires all array types to have an 'items' field. MCP servers may
|
||||
/// return schemas without this field, so we add a default.
|
||||
pub fn fix_array_schemas(schema: &mut Value) {
|
||||
if let Value::Object(obj) = schema {
|
||||
// Check if this is an array type
|
||||
if let Some(type_val) = obj.get("type") {
|
||||
let is_array = match type_val {
|
||||
Value::String(s) => s == "array",
|
||||
Value::Array(arr) => arr.iter().any(|v| v.as_str() == Some("array")),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
// If it's an array and missing 'items', add a default
|
||||
if is_array && !obj.contains_key("items") {
|
||||
obj.insert("items".to_string(), json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively fix nested schemas
|
||||
if let Some(Value::Object(props)) = obj.get_mut("properties") {
|
||||
for value in props.values_mut() {
|
||||
Self::fix_array_schemas(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Fix items if present (for nested arrays)
|
||||
if let Some(items) = obj.get_mut("items") {
|
||||
Self::fix_array_schemas(items);
|
||||
}
|
||||
|
||||
// Fix oneOf, anyOf, allOf schemas
|
||||
for key in &["oneOf", "anyOf", "allOf"] {
|
||||
if let Some(Value::Array(schemas)) = obj.get_mut(*key) {
|
||||
for schema in schemas {
|
||||
Self::fix_array_schemas(schema);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fix additionalProperties if it's a schema
|
||||
if let Some(additional) = obj.get_mut("additionalProperties") {
|
||||
if additional.is_object() {
|
||||
Self::fix_array_schemas(additional);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert OpenAI-style tool call arguments to MCP format
|
||||
/// OpenAI sends arguments as a JSON string, MCP expects a Map
|
||||
fn openai_args_to_mcp_args(
|
||||
args_str: &str,
|
||||
) -> Result<Option<serde_json::Map<String, serde_json::Value>>> {
|
||||
if args_str.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let args_value: serde_json::Value =
|
||||
serde_json::from_str(args_str).context("Failed to parse tool call arguments")?;
|
||||
|
||||
match args_value {
|
||||
serde_json::Value::Object(map) => Ok(Some(map)),
|
||||
serde_json::Value::Null => Ok(None),
|
||||
_ => Ok(Some(
|
||||
vec![("value".to_string(), args_value)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
//! MCP Backend trait definitions
|
||||
//!
|
||||
//! This module defines the traits that must be implemented by the backend
|
||||
//! (typically windmill-api) to provide the actual functionality for the MCP server.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use rmcp::ErrorData;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::common::types::{
|
||||
FlowInfo, HubScriptInfo, ResourceInfo, ResourceType, SchemaType, ScriptInfo,
|
||||
};
|
||||
use crate::server::endpoints::EndpointTool;
|
||||
|
||||
/// Result type for backend operations using rmcp's ErrorData directly
|
||||
pub type BackendResult<T> = Result<T, ErrorData>;
|
||||
|
||||
/// Authentication context required by the MCP server
|
||||
pub trait McpAuth: Send + Sync + Clone + 'static {
|
||||
/// Get the username
|
||||
fn username(&self) -> &str;
|
||||
/// Get the email
|
||||
fn email(&self) -> &str;
|
||||
/// Check if user is admin
|
||||
fn is_admin(&self) -> bool;
|
||||
/// Check if user is operator
|
||||
fn is_operator(&self) -> bool;
|
||||
/// Get user's groups
|
||||
fn groups(&self) -> &[String];
|
||||
/// Get user's folders as (name, can_write, is_owner)
|
||||
fn folders(&self) -> &[(String, bool, bool)];
|
||||
/// Get token scopes
|
||||
fn scopes(&self) -> Option<&[String]>;
|
||||
|
||||
/// Check if the user has an MCP scope
|
||||
fn has_mcp_scope(&self) -> bool {
|
||||
self.scopes()
|
||||
.map(|s| s.iter().any(|scope| scope.starts_with("mcp:")))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// The core backend trait that windmill-api implements
|
||||
///
|
||||
/// This trait abstracts the windmill-api specific operations needed by the MCP server.
|
||||
/// By implementing this trait, windmill-api can inject its database access, job execution,
|
||||
/// and other functionality without windmill-mcp needing to depend on windmill-api directly.
|
||||
#[async_trait]
|
||||
pub trait McpBackend: Send + Sync + Clone + 'static {
|
||||
/// The authentication context type
|
||||
type Auth: McpAuth;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Listing Operations
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// List scripts, optionally filtered to favorites only
|
||||
async fn list_scripts(
|
||||
&self,
|
||||
auth: &Self::Auth,
|
||||
workspace_id: &str,
|
||||
favorites_only: bool,
|
||||
) -> BackendResult<Vec<ScriptInfo>>;
|
||||
|
||||
/// List flows, optionally filtered to favorites only
|
||||
async fn list_flows(
|
||||
&self,
|
||||
auth: &Self::Auth,
|
||||
workspace_id: &str,
|
||||
favorites_only: bool,
|
||||
) -> BackendResult<Vec<FlowInfo>>;
|
||||
|
||||
/// List resource types in workspace
|
||||
async fn list_resource_types(
|
||||
&self,
|
||||
auth: &Self::Auth,
|
||||
workspace_id: &str,
|
||||
) -> BackendResult<Vec<ResourceType>>;
|
||||
|
||||
/// List resources of a specific type
|
||||
async fn list_resources(
|
||||
&self,
|
||||
auth: &Self::Auth,
|
||||
workspace_id: &str,
|
||||
resource_type: &str,
|
||||
) -> BackendResult<Vec<ResourceInfo>>;
|
||||
|
||||
/// List hub scripts, optionally filtered by app integrations
|
||||
async fn list_hub_scripts(&self, app_filter: Option<&str>)
|
||||
-> BackendResult<Vec<HubScriptInfo>>;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Schema Operations
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Get schema for a script or flow
|
||||
async fn get_item_schema(
|
||||
&self,
|
||||
auth: &Self::Auth,
|
||||
workspace_id: &str,
|
||||
path: &str,
|
||||
item_type: &str,
|
||||
) -> BackendResult<Option<SchemaType>>;
|
||||
|
||||
/// Get schema for a hub script
|
||||
async fn get_hub_script_schema(&self, path: &str) -> BackendResult<Option<SchemaType>>;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Schema Transformation (requires DB access for resources)
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Transform schema for resources by enriching with available resource information.
|
||||
/// The resources_cache should be pre-populated with all needed resource types.
|
||||
fn transform_schema_for_resources(
|
||||
&self,
|
||||
schema: &SchemaType,
|
||||
resources_cache: &HashMap<String, Vec<ResourceInfo>>,
|
||||
resources_types: &[ResourceType],
|
||||
) -> SchemaType;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Execution Operations
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Run a script and wait for result
|
||||
async fn run_script(
|
||||
&self,
|
||||
auth: &Self::Auth,
|
||||
workspace_id: &str,
|
||||
path: &str,
|
||||
args: Value,
|
||||
) -> BackendResult<Value>;
|
||||
|
||||
/// Run a flow and wait for result
|
||||
async fn run_flow(
|
||||
&self,
|
||||
auth: &Self::Auth,
|
||||
workspace_id: &str,
|
||||
path: &str,
|
||||
args: Value,
|
||||
) -> BackendResult<Value>;
|
||||
|
||||
/// Call an endpoint tool (generated API endpoint)
|
||||
async fn call_endpoint(
|
||||
&self,
|
||||
auth: &Self::Auth,
|
||||
workspace_id: &str,
|
||||
endpoint_tool: &EndpointTool,
|
||||
args: Value,
|
||||
) -> BackendResult<Value>;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Endpoint Tools
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Get all available endpoint tools
|
||||
fn all_endpoint_tools(&self) -> Vec<EndpointTool>;
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
//! Endpoint tools for MCP server
|
||||
//!
|
||||
//! Contains the EndpointTool structure and utilities for converting
|
||||
//! them to MCP tools.
|
||||
|
||||
use rmcp::model::{Tool, ToolAnnotations};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Represents an auto-generated endpoint tool from OpenAPI specification
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct EndpointTool {
|
||||
pub name: Cow<'static, str>,
|
||||
pub description: Cow<'static, str>,
|
||||
pub instructions: Cow<'static, str>,
|
||||
pub path: Cow<'static, str>,
|
||||
pub method: Cow<'static, str>,
|
||||
pub path_params_schema: Option<serde_json::Value>,
|
||||
pub query_params_schema: Option<serde_json::Value>,
|
||||
pub body_schema: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Convert a single endpoint tool to MCP tool
|
||||
pub fn endpoint_tool_to_mcp_tool(tool: &EndpointTool) -> Tool {
|
||||
let mut combined_properties = serde_json::Map::new();
|
||||
let mut combined_required = Vec::new();
|
||||
|
||||
// Combine all parameter schemas
|
||||
let schemas = [
|
||||
&tool.path_params_schema,
|
||||
&tool.query_params_schema,
|
||||
&tool.body_schema,
|
||||
];
|
||||
|
||||
for schema in schemas.iter().filter_map(|s| s.as_ref()) {
|
||||
merge_schema_into(&mut combined_properties, &mut combined_required, schema);
|
||||
}
|
||||
|
||||
let combined_schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": combined_properties,
|
||||
"required": combined_required
|
||||
});
|
||||
|
||||
let description = format!("{}. {}", tool.description, tool.instructions);
|
||||
|
||||
// Create annotations based on HTTP method and endpoint characteristics
|
||||
let annotations = create_endpoint_annotations(tool);
|
||||
|
||||
Tool {
|
||||
name: tool.name.clone(),
|
||||
description: Some(description.into()),
|
||||
input_schema: Arc::new(combined_schema.as_object().unwrap().clone()),
|
||||
title: Some(tool.name.to_string()),
|
||||
output_schema: None,
|
||||
icons: None,
|
||||
annotations: Some(annotations),
|
||||
meta: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create appropriate annotations for endpoint tools based on HTTP method
|
||||
fn create_endpoint_annotations(tool: &EndpointTool) -> ToolAnnotations {
|
||||
let method = tool.method.as_ref();
|
||||
|
||||
// Determine characteristics based on HTTP method
|
||||
let (read_only, destructive, idempotent, open_world) = match method {
|
||||
"GET" => (true, false, true, true), // Read-only, safe, idempotent
|
||||
"POST" => (false, true, false, true), // Can modify, potentially destructive, not idempotent
|
||||
"PUT" => (false, false, true, true), // Can modify, typically idempotent updates
|
||||
"DELETE" => (false, true, true, true), // Destructive but idempotent
|
||||
"PATCH" => (false, false, false, true), // Partial updates, not guaranteed idempotent
|
||||
_ => (false, true, false, true), // Default: assume can modify and be destructive
|
||||
};
|
||||
|
||||
ToolAnnotations {
|
||||
title: Some(format!("{} {}", method, tool.path)),
|
||||
read_only_hint: Some(read_only),
|
||||
destructive_hint: Some(destructive),
|
||||
idempotent_hint: Some(idempotent),
|
||||
open_world_hint: Some(open_world),
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge schema into combined properties and required fields
|
||||
fn merge_schema_into(
|
||||
combined_properties: &mut serde_json::Map<String, serde_json::Value>,
|
||||
combined_required: &mut Vec<String>,
|
||||
schema: &serde_json::Value,
|
||||
) {
|
||||
if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
|
||||
for (key, value) in props {
|
||||
combined_properties.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(required) = schema.get("required").and_then(|r| r.as_array()) {
|
||||
for req in required.iter().filter_map(|r| r.as_str()) {
|
||||
combined_required.push(req.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
//! MCP Server module
|
||||
//!
|
||||
//! This module provides the MCP server implementation including:
|
||||
//! - `McpBackend` trait for backend implementations
|
||||
//! - `Runner` struct that implements the MCP protocol
|
||||
//! - Re-exports of rmcp types
|
||||
|
||||
pub mod backend;
|
||||
pub mod endpoints;
|
||||
pub mod runner;
|
||||
pub mod tools;
|
||||
|
||||
// Re-export main types
|
||||
pub use backend::{BackendResult, McpAuth, McpBackend};
|
||||
pub use endpoints::{endpoint_tool_to_mcp_tool, EndpointTool};
|
||||
pub use runner::Runner;
|
||||
pub use tools::create_tool_from_item;
|
||||
|
||||
// Re-export rmcp types for convenience
|
||||
pub use rmcp::handler::server::ServerHandler;
|
||||
pub use rmcp::model::{
|
||||
Annotated, CallToolRequestParam, CallToolResult, Content, Implementation,
|
||||
InitializeRequestParam, InitializeResult, ListPromptsResult, ListResourceTemplatesResult,
|
||||
ListResourcesResult, ListToolsResult, PaginatedRequestParam, ProtocolVersion, RawContent,
|
||||
RawTextContent, ServerCapabilities, ServerInfo, Tool, ToolAnnotations,
|
||||
};
|
||||
pub use rmcp::service::{RequestContext, RoleServer};
|
||||
pub use rmcp::transport::streamable_http_server::{
|
||||
session::local::LocalSessionManager, StreamableHttpService,
|
||||
};
|
||||
pub use rmcp::transport::StreamableHttpServerConfig;
|
||||
pub use rmcp::ErrorData;
|
||||
@@ -1,373 +0,0 @@
|
||||
//! MCP Server Runner implementation
|
||||
//!
|
||||
//! Contains the generic Runner that implements the MCP ServerHandler trait
|
||||
//! and delegates to a McpBackend for actual functionality.
|
||||
|
||||
use crate::common::schema::extract_resource_types_from_schema;
|
||||
use crate::common::scope::parse_mcp_scopes;
|
||||
use crate::common::transform::{reverse_transform, reverse_transform_key};
|
||||
use crate::common::types::{ResourceInfo, ToolableItem, WorkspaceId};
|
||||
use crate::server::backend::{McpAuth, McpBackend};
|
||||
use crate::server::endpoints::endpoint_tool_to_mcp_tool;
|
||||
use crate::server::tools::create_tool_from_item;
|
||||
use rmcp::handler::server::ServerHandler;
|
||||
use rmcp::model::{
|
||||
CallToolRequestParam, CallToolResult, Content, Implementation, InitializeRequestParam,
|
||||
InitializeResult, ListPromptsResult, ListResourceTemplatesResult, ListResourcesResult,
|
||||
ListToolsResult, PaginatedRequestParam, ProtocolVersion, ServerCapabilities, ServerInfo,
|
||||
};
|
||||
use rmcp::service::{RequestContext, RoleServer};
|
||||
use rmcp::ErrorData;
|
||||
use serde_json::Value;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
// Re-export from http crate for extracting request parts
|
||||
use http::request::Parts as HttpParts;
|
||||
|
||||
/// MCP Server Runner - generic over the backend implementation
|
||||
///
|
||||
/// This struct implements the MCP ServerHandler trait and uses a McpBackend
|
||||
/// to perform the actual operations (database queries, job execution, etc.)
|
||||
pub struct Runner<B: McpBackend> {
|
||||
backend: Arc<B>,
|
||||
}
|
||||
|
||||
impl<B: McpBackend> Clone for Runner<B> {
|
||||
fn clone(&self) -> Self {
|
||||
Self { backend: self.backend.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: McpBackend> Runner<B> {
|
||||
/// Create a new Runner with the given backend
|
||||
pub fn new(backend: B) -> Self {
|
||||
Self { backend: Arc::new(backend) }
|
||||
}
|
||||
|
||||
/// Extract authentication and workspace from request context
|
||||
fn extract_context(
|
||||
context: &RequestContext<RoleServer>,
|
||||
) -> Result<(B::Auth, String), ErrorData> {
|
||||
let http_parts = context.extensions.get::<HttpParts>().ok_or_else(|| {
|
||||
tracing::error!("http::request::Parts not found");
|
||||
ErrorData::internal_error("http::request::Parts not found", None)
|
||||
})?;
|
||||
|
||||
let auth = http_parts.extensions.get::<B::Auth>().ok_or_else(|| {
|
||||
tracing::error!("Auth extension not found");
|
||||
ErrorData::internal_error("Auth extension not found", None)
|
||||
})?;
|
||||
|
||||
let workspace_id = http_parts
|
||||
.extensions
|
||||
.get::<WorkspaceId>()
|
||||
.ok_or_else(|| {
|
||||
tracing::error!("WorkspaceId not found");
|
||||
ErrorData::internal_error("WorkspaceId not found", None)
|
||||
})
|
||||
.map(|w_id| w_id.0.clone())?;
|
||||
|
||||
// Validate MCP scope
|
||||
if !auth.has_mcp_scope() {
|
||||
tracing::error!("Unauthorized: missing mcp scope");
|
||||
return Err(ErrorData::internal_error(
|
||||
"Unauthorized: missing mcp scope",
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
Ok((auth.clone(), workspace_id))
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: McpBackend> ServerHandler for Runner<B> {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo {
|
||||
protocol_version: ProtocolVersion::default(),
|
||||
capabilities: ServerCapabilities::builder().enable_tools().build(),
|
||||
server_info: Implementation::from_build_env(),
|
||||
instructions: Some(
|
||||
"This server provides a list of scripts and flows the user can run on Windmill. \
|
||||
Each flow and script is a tool callable with their respective arguments."
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn initialize(
|
||||
&self,
|
||||
_request: InitializeRequestParam,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<InitializeResult, ErrorData> {
|
||||
Ok(self.get_info())
|
||||
}
|
||||
|
||||
async fn list_tools(
|
||||
&self,
|
||||
_request: Option<PaginatedRequestParam>,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<ListToolsResult, ErrorData> {
|
||||
let (auth, workspace_id) = Self::extract_context(&context)?;
|
||||
|
||||
// Parse MCP scopes to determine what to expose
|
||||
let scopes = auth.scopes().unwrap_or(&[]);
|
||||
let scope_config =
|
||||
parse_mcp_scopes(scopes).map_err(|e| ErrorData::internal_error(e, None))?;
|
||||
|
||||
let favorites_only = scope_config.favorites;
|
||||
|
||||
// Fetch all items concurrently
|
||||
let (scripts, flows, resource_types, hub_scripts) = tokio::try_join!(
|
||||
self.backend
|
||||
.list_scripts(&auth, &workspace_id, favorites_only),
|
||||
self.backend
|
||||
.list_flows(&auth, &workspace_id, favorites_only),
|
||||
self.backend.list_resource_types(&auth, &workspace_id),
|
||||
async {
|
||||
if let Some(ref apps) = scope_config.hub_apps {
|
||||
self.backend.list_hub_scripts(Some(apps)).await
|
||||
} else {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
)?;
|
||||
|
||||
// Filter items based on scope
|
||||
let filtered_scripts: Vec<_> = scripts
|
||||
.into_iter()
|
||||
.filter(|s| !scope_config.granular || scope_config.is_allowed("script", &s.path))
|
||||
.collect();
|
||||
|
||||
let filtered_flows: Vec<_> = flows
|
||||
.into_iter()
|
||||
.filter(|f| !scope_config.granular || scope_config.is_allowed("flow", &f.path))
|
||||
.collect();
|
||||
|
||||
// Collect all needed resource types from all schemas
|
||||
let mut needed_resource_types: HashSet<String> = HashSet::new();
|
||||
for script in &filtered_scripts {
|
||||
needed_resource_types.extend(extract_resource_types_from_schema(&script.get_schema()));
|
||||
}
|
||||
for flow in &filtered_flows {
|
||||
needed_resource_types.extend(extract_resource_types_from_schema(&flow.get_schema()));
|
||||
}
|
||||
for hub_script in &hub_scripts {
|
||||
needed_resource_types
|
||||
.extend(extract_resource_types_from_schema(&hub_script.get_schema()));
|
||||
}
|
||||
|
||||
// Pre-fetch all resources
|
||||
let resource_futures: Vec<_> = needed_resource_types
|
||||
.into_iter()
|
||||
.map(|rt| {
|
||||
let backend = self.backend.clone();
|
||||
let auth = auth.clone();
|
||||
let workspace_id = workspace_id.clone();
|
||||
async move {
|
||||
backend
|
||||
.list_resources(&auth, &workspace_id, &rt)
|
||||
.await
|
||||
.map(|resources| (rt, resources))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let resource_results = futures::future::try_join_all(resource_futures).await?;
|
||||
let resources_cache: HashMap<String, Vec<ResourceInfo>> =
|
||||
resource_results.into_iter().collect();
|
||||
|
||||
let mut tools = Vec::new();
|
||||
|
||||
for script in &filtered_scripts {
|
||||
tools.push(create_tool_from_item(
|
||||
script,
|
||||
self.backend.as_ref(),
|
||||
&resources_cache,
|
||||
&resource_types,
|
||||
));
|
||||
}
|
||||
|
||||
for flow in &filtered_flows {
|
||||
tools.push(create_tool_from_item(
|
||||
flow,
|
||||
self.backend.as_ref(),
|
||||
&resources_cache,
|
||||
&resource_types,
|
||||
));
|
||||
}
|
||||
|
||||
for hub_script in &hub_scripts {
|
||||
tools.push(create_tool_from_item(
|
||||
hub_script,
|
||||
self.backend.as_ref(),
|
||||
&resources_cache,
|
||||
&resource_types,
|
||||
));
|
||||
}
|
||||
|
||||
// Add endpoint tools from the generated MCP tools, filtered by scope
|
||||
let endpoint_tools = self.backend.all_endpoint_tools();
|
||||
for endpoint_tool in endpoint_tools {
|
||||
if scope_config.granular && !scope_config.is_allowed("endpoint", &endpoint_tool.name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tools.push(endpoint_tool_to_mcp_tool(&endpoint_tool));
|
||||
}
|
||||
|
||||
Ok(ListToolsResult { tools, next_cursor: None, meta: None })
|
||||
}
|
||||
|
||||
async fn call_tool(
|
||||
&self,
|
||||
request: CallToolRequestParam,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let (auth, workspace_id) = Self::extract_context(&context)?;
|
||||
|
||||
// Parse MCP scopes for authorization
|
||||
let scopes = auth.scopes().unwrap_or(&[]);
|
||||
let scope_config =
|
||||
parse_mcp_scopes(scopes).map_err(|e| ErrorData::internal_error(e, None))?;
|
||||
|
||||
// Handle truncated tool names
|
||||
if request.name.ends_with("_TRUNC") {
|
||||
return Ok(CallToolResult::error(vec![rmcp::model::Annotated::new(
|
||||
rmcp::model::RawContent::Text(rmcp::model::RawTextContent {
|
||||
text: "Tool path is too long. Consider shortening it to make it compatible with MCP.".to_string(),
|
||||
meta: None,
|
||||
}),
|
||||
None,
|
||||
)]));
|
||||
}
|
||||
|
||||
let args = request.arguments.map(Value::Object).unwrap_or(Value::Null);
|
||||
|
||||
// Check if this is an endpoint tool
|
||||
let endpoint_tools = self.backend.all_endpoint_tools();
|
||||
for endpoint_tool in &endpoint_tools {
|
||||
if endpoint_tool.name.as_ref() == request.name {
|
||||
// Validate endpoint scope
|
||||
if scope_config.granular
|
||||
&& !scope_config.is_allowed("endpoint", &endpoint_tool.name)
|
||||
{
|
||||
return Err(ErrorData::internal_error(
|
||||
format!(
|
||||
"Access denied: endpoint '{}' not in token scope",
|
||||
endpoint_tool.name
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
// This is an endpoint tool, call via backend
|
||||
let result = self
|
||||
.backend
|
||||
.call_endpoint(&auth, &workspace_id, endpoint_tool, args)
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.message, None))?;
|
||||
|
||||
return Ok(CallToolResult::success(vec![Content::text(
|
||||
serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string()),
|
||||
)]));
|
||||
}
|
||||
}
|
||||
|
||||
// Not an endpoint tool - parse as script/flow
|
||||
let (tool_type, path, is_hub) = reverse_transform(&request.name).map_err(|e| {
|
||||
ErrorData::internal_error(format!("Failed to parse tool name: {}", e), None)
|
||||
})?;
|
||||
|
||||
// Validate script/flow scope
|
||||
if !is_hub && scope_config.granular {
|
||||
if tool_type == "script" && !scope_config.is_allowed("script", &path) {
|
||||
return Err(ErrorData::internal_error(
|
||||
format!("Access denied: script '{}' not in token scope", path),
|
||||
None,
|
||||
));
|
||||
} else if tool_type == "flow" && !scope_config.is_allowed("flow", &path) {
|
||||
return Err(ErrorData::internal_error(
|
||||
format!("Access denied: flow '{}' not in token scope", path),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Get item schema for argument transformation
|
||||
let item_schema = if is_hub {
|
||||
self.backend
|
||||
.get_hub_script_schema(&format!("hub/{}", path))
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.message, None))?
|
||||
} else {
|
||||
self.backend
|
||||
.get_item_schema(&auth, &workspace_id, &path, tool_type)
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.message, None))?
|
||||
};
|
||||
|
||||
// Transform arguments back to original key names
|
||||
let transformed_args = if let Value::Object(map) = args {
|
||||
let mut args_hash = HashMap::new();
|
||||
for (k, v) in map {
|
||||
let original_key = reverse_transform_key(&k, &item_schema);
|
||||
args_hash.insert(original_key, v);
|
||||
}
|
||||
Value::Object(args_hash.into_iter().collect())
|
||||
} else {
|
||||
args
|
||||
};
|
||||
|
||||
let script_or_flow_path = if is_hub {
|
||||
format!("hub/{}", path)
|
||||
} else {
|
||||
path
|
||||
};
|
||||
|
||||
// Execute script or flow
|
||||
let result = if tool_type == "script" {
|
||||
self.backend
|
||||
.run_script(&auth, &workspace_id, &script_or_flow_path, transformed_args)
|
||||
.await
|
||||
} else {
|
||||
self.backend
|
||||
.run_flow(&auth, &workspace_id, &script_or_flow_path, transformed_args)
|
||||
.await
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(value) => Ok(CallToolResult::success(vec![Content::text(
|
||||
serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string()),
|
||||
)])),
|
||||
Err(e) => Err(ErrorData::internal_error(
|
||||
format!("Failed to run {}: {}", tool_type, e.message),
|
||||
None,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_resources(
|
||||
&self,
|
||||
_request: Option<PaginatedRequestParam>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ListResourcesResult, ErrorData> {
|
||||
Ok(ListResourcesResult { resources: vec![], next_cursor: None, meta: None })
|
||||
}
|
||||
|
||||
async fn list_prompts(
|
||||
&self,
|
||||
_request: Option<PaginatedRequestParam>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ListPromptsResult, ErrorData> {
|
||||
Ok(ListPromptsResult::default())
|
||||
}
|
||||
|
||||
async fn list_resource_templates(
|
||||
&self,
|
||||
_request: Option<PaginatedRequestParam>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ListResourceTemplatesResult, ErrorData> {
|
||||
Ok(ListResourceTemplatesResult::default())
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
//! Tool creation utilities for MCP server
|
||||
//!
|
||||
//! Contains functionality for converting Windmill items (scripts, flows, hub scripts)
|
||||
//! into MCP tools.
|
||||
|
||||
use rmcp::model::{Tool, ToolAnnotations};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::common::schema::convert_schema_to_schema_type;
|
||||
use crate::common::transform::transform_path;
|
||||
use crate::common::types::{
|
||||
FlowInfo, HubScriptInfo, ResourceInfo, ResourceType, SchemaType, ScriptInfo, ToolableItem,
|
||||
};
|
||||
use crate::server::backend::McpBackend;
|
||||
|
||||
/// Implementation of ToolableItem for ScriptInfo
|
||||
impl ToolableItem for ScriptInfo {
|
||||
fn get_path_or_id(&self) -> String {
|
||||
transform_path(&self.path, "script")
|
||||
}
|
||||
|
||||
fn get_summary(&self) -> &str {
|
||||
self.summary.as_deref().unwrap_or("No summary")
|
||||
}
|
||||
|
||||
fn get_description(&self) -> &str {
|
||||
self.description.as_deref().unwrap_or("No description")
|
||||
}
|
||||
|
||||
fn get_schema(&self) -> SchemaType {
|
||||
convert_schema_to_schema_type(self.schema.clone())
|
||||
}
|
||||
|
||||
fn is_hub(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn item_type(&self) -> &'static str {
|
||||
"script"
|
||||
}
|
||||
|
||||
fn get_integration_type(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Implementation of ToolableItem for FlowInfo
|
||||
impl ToolableItem for FlowInfo {
|
||||
fn get_path_or_id(&self) -> String {
|
||||
transform_path(&self.path, "flow")
|
||||
}
|
||||
|
||||
fn get_summary(&self) -> &str {
|
||||
self.summary.as_deref().unwrap_or("No summary")
|
||||
}
|
||||
|
||||
fn get_description(&self) -> &str {
|
||||
self.description.as_deref().unwrap_or("No description")
|
||||
}
|
||||
|
||||
fn get_schema(&self) -> SchemaType {
|
||||
convert_schema_to_schema_type(self.schema.clone())
|
||||
}
|
||||
|
||||
fn is_hub(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn item_type(&self) -> &'static str {
|
||||
"flow"
|
||||
}
|
||||
|
||||
fn get_integration_type(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Implementation of ToolableItem for HubScriptInfo
|
||||
impl ToolableItem for HubScriptInfo {
|
||||
fn get_path_or_id(&self) -> String {
|
||||
let id = self.version_id;
|
||||
let summary = self.summary.as_deref().unwrap_or("No summary");
|
||||
format!("hs-{}-{}", id, summary.replace(" ", "_"))
|
||||
}
|
||||
|
||||
fn get_summary(&self) -> &str {
|
||||
self.summary.as_deref().unwrap_or("No summary")
|
||||
}
|
||||
|
||||
fn get_description(&self) -> &str {
|
||||
self.description.as_deref().unwrap_or("No description")
|
||||
}
|
||||
|
||||
fn get_schema(&self) -> SchemaType {
|
||||
match serde_json::from_value::<SchemaType>(self.schema.clone().unwrap_or_default()) {
|
||||
Ok(schema_type) => schema_type,
|
||||
Err(_) => SchemaType::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_hub(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn item_type(&self) -> &'static str {
|
||||
"script"
|
||||
}
|
||||
|
||||
fn get_integration_type(&self) -> Option<String> {
|
||||
self.app.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an MCP Tool from a ToolableItem
|
||||
///
|
||||
/// The resources_cache should be pre-populated with all resource types
|
||||
/// that may be referenced by the item's schema.
|
||||
pub fn create_tool_from_item<T: ToolableItem, B: McpBackend>(
|
||||
item: &T,
|
||||
backend: &B,
|
||||
resources_cache: &HashMap<String, Vec<ResourceInfo>>,
|
||||
resources_types: &[ResourceType],
|
||||
) -> Tool {
|
||||
let is_hub = item.is_hub();
|
||||
let path = item.get_path_or_id();
|
||||
let item_type = item.item_type();
|
||||
let description = format!(
|
||||
"This is a {} named `{}` with the following description: `{}`.{}",
|
||||
item_type,
|
||||
item.get_summary(),
|
||||
item.get_description(),
|
||||
if is_hub {
|
||||
format!(
|
||||
" It is a tool used for the following app: {}",
|
||||
item.get_integration_type()
|
||||
.unwrap_or("No integration type".to_string())
|
||||
)
|
||||
} else {
|
||||
"".to_string()
|
||||
}
|
||||
);
|
||||
|
||||
let schema = item.get_schema();
|
||||
let schema_obj =
|
||||
backend.transform_schema_for_resources(&schema, resources_cache, resources_types);
|
||||
|
||||
let input_schema_map = match serde_json::to_value(schema_obj) {
|
||||
Ok(serde_json::Value::Object(map)) => map,
|
||||
Ok(_) => {
|
||||
tracing::warn!(
|
||||
"Schema object for tool '{}' did not serialize to a JSON object, using empty schema.",
|
||||
path
|
||||
);
|
||||
serde_json::Map::new()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Failed to serialize schema object for tool '{}': {}. Using empty schema.",
|
||||
path,
|
||||
e
|
||||
);
|
||||
serde_json::Map::new()
|
||||
}
|
||||
};
|
||||
|
||||
Tool {
|
||||
name: Cow::Owned(path),
|
||||
description: Some(Cow::Owned(description)),
|
||||
input_schema: Arc::new(input_schema_map),
|
||||
title: Some(item.get_summary().to_string()),
|
||||
output_schema: None,
|
||||
icons: None,
|
||||
annotations: Some(ToolAnnotations {
|
||||
title: Some(item.get_summary().to_string()),
|
||||
read_only_hint: Some(false), // Can modify environment
|
||||
destructive_hint: Some(true), // Can potentially be destructive
|
||||
idempotent_hint: Some(false), // Are not guaranteed to be idempotent
|
||||
open_world_hint: Some(true), // Can interact with external services
|
||||
}),
|
||||
meta: None,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user