Merge branch 'main' into chore/sdk-react-apollo-and-migration-guide

This commit is contained in:
Nuno Pato
2025-11-16 16:50:43 -01:00
committed by GitHub
3024 changed files with 294855 additions and 63393 deletions

View File

@@ -7,6 +7,8 @@ assignees: ''
---
> **Note:** Bug reports that are clearly AI-generated will not be accepted and will be closed immediately. Please write your bug report in your own words.
**Describe the bug**
A clear and concise description of what the bug is.

View File

@@ -7,6 +7,8 @@ assignees: ''
---
> **Note:** Feature requests that are clearly AI-generated will not be accepted and will be closed immediately. Please write your feature request in your own words.
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]

View File

@@ -8,6 +8,8 @@
--- Delete everything below this line before submitting your PR ---
> **Note on AI-assisted contributions:** Contributions with the help of AI are permitted, but you are ultimately responsible for the quality of your submission and for ensuring it follows our contributing guidelines. **The PR description must be written in your own words and be clear and concise**. Please ensure you remove any superfluous code comments introduced by AI tools before submitting. PRs that clearly violate this rule will be closed without further review.
### PR title format
The PR title must follow the following pattern:
@@ -22,6 +24,7 @@ Where `TYPE` is:
Where `PKG` is:
- `auth`: For changes to the Nhost Auth service
- `ci`: For general changes to the build and/or CI/CD pipeline
- `cli`: For changes to the Nhost CLI
- `codegen`: For changes to the code generator
@@ -29,10 +32,11 @@ Where `PKG` is:
- `deps`: For changes to dependencies
- `docs`: For changes to the documentation
- `examples`: For changes to the examples
- `internal/lib`: For changes to Nhost's common libraries (internal)
- `mintlify-openapi`: For changes to the Mintlify OpenAPI tool
- `nhost-js`: For changes to the Nhost JavaScript SDK
- `nixops`: For changes to the NixOps
- `storage`: For changes to the Nhost Storage
- `storage`: For changes to the Nhost Storage service
Where `SUMMARY` is a short description of what the PR does.

View File

@@ -17,7 +17,7 @@ runs:
# Define valid types and packages
VALID_TYPES="feat|fix|chore"
VALID_PKGS="ci|cli|codegen|dashboard|deps|docs|examples|mintlify-openapi|nhost-js|nixops|storage"
VALID_PKGS="auth|ci|cli|codegen|dashboard|deps|docs|examples|internal\/lib|mintlify-openapi|nhost-js|nixops|storage"
# Check if title matches the pattern TYPE(PKG): SUMMARY
if [[ ! "$PR_TITLE" =~ ^(${VALID_TYPES})\((${VALID_PKGS})\):\ .+ ]]; then
@@ -31,11 +31,11 @@ runs:
echo " - chore: mark this pull request as a maintenance item"
echo ""
echo "Valid PKGs:"
echo " - ci, cli, codegen, dashboard, deps, docs, examples,"
echo " - auth, ci, cli, codegen, dashboard, deps, docs, examples,"
echo " - mintlify-openapi, nhost-js, nixops, storage"
echo ""
echo "Example: feat(cli): add new command for database migrations"
exit 1
fi
echo "✅ PR title is valid!"
echo "✅ PR title is valid!"

84
.github/workflows/auth_checks.yaml vendored Normal file
View File

@@ -0,0 +1,84 @@
---
name: "auth: check and build"
on:
pull_request_target:
paths:
- '.github/workflows/auth_checks.yaml'
- '.github/workflows/wf_check.yaml'
- '.github/workflows/wf_build_artifacts.yaml'
# common build
- 'flake.nix'
- 'flake.lock'
- 'nixops/**'
- 'build/**'
# common go
- '.golangci.yaml'
- 'go.mod'
- 'go.sum'
- 'internal/lib/**'
- 'vendor/**'
# auth
- 'services/auth/**'
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('push-{0}', github.sha) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
check-permissions:
runs-on: ubuntu-latest
steps:
- run: |
echo "github.event_name: ${{ github.event_name }}"
echo "github.event.pull_request.author_association: ${{ github.event.pull_request.author_association }}"
- name: "This task will run and fail if user has no permissions and label safe_to_test isn't present"
if: "github.event_name == 'pull_request_target' && ! ( contains(github.event.pull_request.labels.*.name, 'safe_to_test') || contains(fromJson('[\"OWNER\", \"MEMBER\", \"COLLABORATOR\"]'), github.event.pull_request.author_association) )"
run: |
exit 1
tests:
uses: ./.github/workflows/wf_check.yaml
needs:
- check-permissions
with:
NAME: auth
PATH: services/auth
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
NHOST_PAT: ${{ secrets.NHOST_PAT }}
build_artifacts:
uses: ./.github/workflows/wf_build_artifacts.yaml
needs:
- check-permissions
with:
NAME: auth
PATH: services/auth
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
DOCKER: true
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
remove_label:
runs-on: ubuntu-latest
needs:
- check-permissions
steps:
- uses: actions-ecosystem/action-remove-labels@v1
with:
labels: |
safe_to_test
if: contains(github.event.pull_request.labels.*.name, 'safe_to_test')

60
.github/workflows/auth_wf_release.yaml vendored Normal file
View File

@@ -0,0 +1,60 @@
---
name: "auth: release"
on:
workflow_call:
inputs:
GIT_REF:
required: true
type: string
VERSION:
required: true
type: string
secrets:
AWS_ACCOUNT_ID:
required: true
NIX_CACHE_PUB_KEY:
required: true
NIX_CACHE_PRIV_KEY:
required: true
DOCKER_USERNAME:
required: true
DOCKER_PASSWORD:
required: true
jobs:
build_artifacts:
uses: ./.github/workflows/wf_build_artifacts.yaml
with:
NAME: auth
PATH: services/auth
GIT_REF: ${{ inputs.GIT_REF }}
VERSION: ${{ inputs.VERSION }}
DOCKER: true
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }}
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
push-docker-hub:
uses: ./.github/workflows/wf_docker_push_image.yaml
needs:
- build_artifacts
with:
NAME: auth
PATH: services/auth
VERSION: ${{ inputs.VERSION }}
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
push-docker-ecr:
uses: ./.github/workflows/wf_docker_push_image_ecr.yaml
needs:
- build_artifacts
with:
NAME: auth
PATH: services/auth
VERSION: ${{ inputs.VERSION }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }}
CONTAINER_REGISTRY: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.eu-central-1.amazonaws.com

View File

@@ -33,6 +33,20 @@ jobs:
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Extracted project: $PROJECT, version: $VERSION"
auth:
needs: extract-project
if: needs.extract-project.outputs.project == 'auth'
uses: ./.github/workflows/auth_wf_release.yaml
with:
GIT_REF: ${{ github.sha }}
VERSION: ${{ needs.extract-project.outputs.version }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
cli:
needs: extract-project
if: needs.extract-project.outputs.project == 'cli'

View File

@@ -13,7 +13,7 @@ jobs:
strategy:
matrix:
project: [cli, dashboard, packages/nhost-js, services/storage]
project: [cli, dashboard, packages/nhost-js, services/auth, services/storage]
permissions:
id-token: write
@@ -40,7 +40,7 @@ jobs:
cd ${{ matrix.project }}
TAG_NAME=$(make release-tag-name)
VERSION=$(nix develop .\#cliff -c make changelog-next-version)
if git tag | grep -q "$TAG_NAME@$VERSION"; then
if git tag | grep -qx "$TAG_NAME@$VERSION"; then
echo "Tag $TAG_NAME@$VERSION already exists, skipping release preparation"
else
echo "Tag $TAG_NAME@$VERSION does not exist, proceeding with release preparation"

View File

@@ -1,8 +1,7 @@
---
name: "cli: check and build"
on:
# pull_request_target:
pull_request:
pull_request_target:
paths:
- '.github/workflows/cli_checks.yaml'
- '.github/workflows/wf_check.yaml'
@@ -50,7 +49,7 @@ jobs:
with:
NAME: cli
PATH: cli
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
@@ -65,7 +64,7 @@ jobs:
with:
NAME: cli
PATH: cli
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
DOCKER: true
secrets:
@@ -81,7 +80,7 @@ jobs:
with:
NAME: cli
PATH: cli
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}

View File

@@ -63,7 +63,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: "Get artifacts"
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
path: ~/artifacts

View File

@@ -1,8 +1,7 @@
---
name: "codegen: check and build"
on:
# pull_request_target:
pull_request:
pull_request_target:
paths:
- '.github/workflows/wf_check.yaml'
- '.github/workflows/codegen_checks.yaml'
@@ -48,7 +47,7 @@ jobs:
with:
NAME: codegen
PATH: tools/codegen
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
@@ -62,7 +61,7 @@ jobs:
with:
NAME: codegen
PATH: tools/codegen
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
DOCKER: false
secrets:

View File

@@ -1,7 +1,7 @@
---
name: "dashboard: check and build"
on:
pull_request:
pull_request_target:
paths:
- '.github/workflows/wf_build_artifacts.yaml'
- '.github/workflows/wf_check.yaml'
@@ -54,7 +54,7 @@ jobs:
- check-permissions
with:
NAME: dashboard
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
ENVIRONMENT: preview
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
@@ -73,7 +73,7 @@ jobs:
with:
NAME: dashboard
PATH: dashboard
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
DOCKER: true
OS_MATRIX: '["blacksmith-2vcpu-ubuntu-2404"]'
@@ -91,7 +91,7 @@ jobs:
with:
NAME: dashboard
PATH: dashboard
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
@@ -107,7 +107,7 @@ jobs:
with:
NAME: dashboard
PATH: dashboard
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
NHOST_TEST_DASHBOARD_URL: ${{ needs.deploy-vercel.outputs.preview-url }}
NHOST_TEST_PROJECT_NAME: ${{ vars.NHOST_TEST_PROJECT_NAME }}
NHOST_TEST_ORGANIZATION_NAME: ${{ vars.NHOST_TEST_ORGANIZATION_NAME }}
@@ -126,8 +126,10 @@ jobs:
NHOST_TEST_USER_EMAIL: ${{ secrets.NHOST_TEST_USER_EMAIL }}
NHOST_TEST_USER_PASSWORD: ${{ secrets.NHOST_TEST_USER_PASSWORD }}
NHOST_TEST_PROJECT_ADMIN_SECRET: ${{ secrets.NHOST_TEST_PROJECT_ADMIN_SECRET }}
NHOST_TEST_FREE_USER_EMAILS: ${{ secrets.NHOST_TEST_FREE_USER_EMAILS }}
NHOST_TEST_ONBOARDING_USER: ${{ secrets.NHOST_TEST_ONBOARDING_USER }}
PLAYWRIGHT_REPORT_ENCRYPTION_KEY: ${{ secrets.PLAYWRIGHT_REPORT_ENCRYPTION_KEY }}
NHOST_TEST_STAGING_SUBDOMAIN: ${{ secrets.NHOST_TEST_STAGING_SUBDOMAIN }}
NHOST_TEST_STAGING_REGION: ${{ secrets.NHOST_TEST_STAGING_REGION }}
remove_label:
runs-on: ubuntu-latest

View File

@@ -52,12 +52,16 @@ on:
required: true
NHOST_TEST_USER_PASSWORD:
required: true
NHOST_TEST_ONBOARDING_USER:
required: true
NHOST_TEST_PROJECT_ADMIN_SECRET:
required: true
NHOST_TEST_FREE_USER_EMAILS:
required: true
PLAYWRIGHT_REPORT_ENCRYPTION_KEY:
required: true
NHOST_TEST_STAGING_SUBDOMAIN:
required: true
NHOST_TEST_STAGING_REGION:
required: true
concurrency:
group: dashboard-e2e-staging
@@ -77,7 +81,10 @@ env:
NHOST_TEST_USER_EMAIL: ${{ secrets.NHOST_TEST_USER_EMAIL }}
NHOST_TEST_USER_PASSWORD: ${{ secrets.NHOST_TEST_USER_PASSWORD }}
NHOST_TEST_PROJECT_ADMIN_SECRET: ${{ secrets.NHOST_TEST_PROJECT_ADMIN_SECRET }}
NHOST_TEST_FREE_USER_EMAILS: ${{ secrets.NHOST_TEST_FREE_USER_EMAILS }}
NHOST_TEST_ONBOARDING_USER: ${{ secrets.NHOST_TEST_ONBOARDING_USER }}
NHOST_TEST_STAGING_SUBDOMAIN: ${{ secrets.NHOST_TEST_STAGING_SUBDOMAIN }}
NHOST_TEST_STAGING_REGION: ${{ secrets.NHOST_TEST_STAGING_REGION }}
jobs:
tests:
@@ -141,7 +148,7 @@ jobs:
rm playwright-report.tar.gz
- name: Upload encrypted Playwright report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
if: failure()
with:
name: encrypted-playwright-report-${{ github.run_id }}

View File

@@ -88,6 +88,7 @@ jobs:
- name: Bump version in source code
run: |
find cli -type f -exec sed -i 's/"nhost\/dashboard:[^"]*"/"nhost\/dashboard:${{ inputs.VERSION }}"/g' {} +
sed -i 's/nhost\/dashboard:[^)]*/nhost\/dashboard:${{ inputs.VERSION }}/g' docs/reference/cli/commands.mdx
- name: "Create Pull Request"
uses: peter-evans/create-pull-request@v7

View File

@@ -1,7 +1,7 @@
---
name: "docs: check and build"
on:
pull_request:
pull_request_target:
paths:
- '.github/workflows/wf_check.yaml'
- '.github/workflows/dashboard_checks.yaml'
@@ -28,6 +28,10 @@ on:
# nhost-js
- packages/nhost-js/**
# apis
- 'services/auth/docs/openapi.yaml'
- 'services/storage/controller/openapi.yaml'
# cli
- cli/**
push:
@@ -58,7 +62,7 @@ jobs:
with:
NAME: docs
PATH: docs
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}

View File

@@ -1,8 +1,7 @@
---
name: "examples/demos: check and build"
on:
# pull_request_target:
pull_request:
pull_request_target:
paths:
- '.github/workflows/wf_check.yaml'
- '.github/workflows/examples_demos_checks.yaml'
@@ -64,7 +63,7 @@ jobs:
with:
NAME: demos
PATH: examples/demos
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
@@ -78,7 +77,7 @@ jobs:
with:
NAME: demos
PATH: examples/demos
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
DOCKER: false
OS_MATRIX: '["blacksmith-2vcpu-ubuntu-2404"]'

View File

@@ -1,8 +1,7 @@
---
name: "examples/guides: check and build"
on:
# pull_request_target:
pull_request:
pull_request_target:
paths:
- '.github/workflows/wf_check.yaml'
- '.github/workflows/examples_guides_checks.yaml'
@@ -64,7 +63,7 @@ jobs:
with:
NAME: guides
PATH: examples/guides
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
@@ -78,7 +77,7 @@ jobs:
with:
NAME: guides
PATH: examples/guides
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
DOCKER: false
OS_MATRIX: '["blacksmith-2vcpu-ubuntu-2404"]'

View File

@@ -1,8 +1,7 @@
---
name: "examples/tutorials: check and build"
on:
# pull_request_target:
pull_request:
pull_request_target:
paths:
- '.github/workflows/wf_check.yaml'
- '.github/workflows/examples_tutorials_checks.yaml'
@@ -64,7 +63,7 @@ jobs:
with:
NAME: tutorials
PATH: examples/tutorials
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
@@ -78,7 +77,7 @@ jobs:
with:
NAME: tutorials
PATH: examples/tutorials
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
DOCKER: false
OS_MATRIX: '["blacksmith-2vcpu-ubuntu-2404"]'

View File

@@ -1,7 +1,7 @@
---
name: "gen: AI review"
on:
pull_request:
pull_request_target:
types: [opened, reopened, ready_for_review]
issue_comment:
jobs:
@@ -16,11 +16,13 @@ jobs:
steps:
- name: PR Agent action step
id: pragent
uses: Codium-ai/pr-agent@v0.30
uses: Codium-ai/pr-agent@v0.31
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
config.model: ${{ vars.GEN_AI_MODEL }}
config.model_turbo: $${{ vars.GEN_AI_MODEL_TURBO }}
config.max_model_tokens: 200000
config.custom_model_max_tokens: 200000
ignore.glob: "['pnpm-lock.yaml','**/pnpm-lock.yaml', 'vendor/**','**/client_gen.go','**/models_gen.go','**/generated.go','**/*.gen.go']"

View File

@@ -26,7 +26,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -37,7 +37,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@v4
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -51,4 +51,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v4

View File

@@ -1,8 +1,7 @@
---
name: "nhost-js: check and build"
on:
# pull_request_target:
pull_request:
pull_request_target:
paths:
- '.github/workflows/wf_check.yaml'
- '.github/workflows/nhost-js_checks.yaml'
@@ -34,6 +33,10 @@ on:
# nhost-js
- 'packages/nhost-js/**'
# apis
- 'services/auth/docs/openapi.yaml'
- 'services/storage/controller/openapi.yaml'
push:
branches:
- main
@@ -61,7 +64,7 @@ jobs:
with:
NAME: nhost-js
PATH: packages/nhost-js
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
@@ -75,7 +78,7 @@ jobs:
with:
NAME: nhost-js
PATH: packages/nhost-js
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
DOCKER: false
secrets:

View File

@@ -1,8 +1,7 @@
---
name: "nixops: check and build"
on:
# pull_request_target:
pull_request:
pull_request_target:
paths:
- '.github/workflows/wf_check.yaml'
- '.github/workflows/nixops_checks.yaml'
@@ -40,7 +39,7 @@ jobs:
with:
NAME: nixops
PATH: nixops
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
@@ -54,9 +53,9 @@ jobs:
with:
NAME: nixops
PATH: nixops
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
DOCKER: false
DOCKER: true
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}

View File

@@ -0,0 +1,35 @@
---
name: "nixops: release"
on:
push:
branches:
- main
paths:
- 'flake.lock'
- 'nixops/project.nix'
jobs:
build_artifacts:
uses: ./.github/workflows/wf_build_artifacts.yaml
with:
NAME: nixops
PATH: nixops
GIT_REF: ${{ inputs.GIT_REF }}
VERSION: latest
DOCKER: true
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
push-docker:
uses: ./.github/workflows/wf_docker_push_image.yaml
needs:
- build_artifacts
with:
NAME: nixops
PATH: nixops
VERSION: latest
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}

View File

@@ -1,8 +1,7 @@
---
name: "storage: check and build"
on:
# pull_request_target:
pull_request:
pull_request_target:
paths:
- '.github/workflows/storage_checks.yaml'
- '.github/workflows/wf_check.yaml'
@@ -18,6 +17,7 @@ on:
- '.golangci.yaml'
- 'go.mod'
- 'go.sum'
- 'internal/lib/**'
- 'vendor/**'
# storage
@@ -49,7 +49,7 @@ jobs:
with:
NAME: storage
PATH: services/storage
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
@@ -64,7 +64,7 @@ jobs:
with:
NAME: storage
PATH: services/storage
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
DOCKER: true
secrets:

View File

@@ -85,7 +85,7 @@ jobs:
zip -r result.zip result
- name: "Push artifact to artifact repository"
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: ${{ inputs.NAME }}-artifact-${{ steps.vars.outputs.ARCH }}-${{ steps.vars.outputs.VERSION }}
path: ${{ inputs.PATH }}/result.zip
@@ -100,7 +100,7 @@ jobs:
if: ${{ ( inputs.DOCKER ) }}
- name: "Push docker image to artifact repository"
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: ${{ inputs.NAME }}-docker-image-${{ steps.vars.outputs.ARCH }}-${{ steps.vars.outputs.VERSION }}
path: ${{ inputs.PATH }}/result

View File

@@ -44,7 +44,7 @@ jobs:
echo "VERSION=$(make get-version VER=${{ inputs.VERSION }})" >> $GITHUB_OUTPUT
- name: "Get artifacts"
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
path: ~/artifacts

View File

@@ -55,7 +55,7 @@ jobs:
echo "VERSION=$(make get-version VER=${{ inputs.VERSION }})" >> $GITHUB_OUTPUT
- name: "Get artifacts"
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
path: ~/artifacts

View File

@@ -7,6 +7,8 @@ linters:
settings:
funlen:
lines: 65
wsl_v5:
allow-whole-block: true
disable:
- canonicalheader
- depguard
@@ -30,6 +32,7 @@ linters:
- linters:
- funlen
- ireturn
- goconst
path: _test\.go
- linters:
- lll

View File

@@ -16,6 +16,15 @@ Contributions are made to Nhost repos via Issues and Pull Requests (PRs). A few
- We work hard to make sure issues are handled on time, but it could take a while to investigate the root cause depending on the impact. A friendly ping in the comment thread to the submitter or a contributor can help draw attention if your issue is blocking.
- If you've never contributed before, see [the first-timer's guide](https://github.com/firstcontributions/first-contributions) for resources and tips on getting started.
### AI-Assisted Contributions
We have specific policies regarding AI-assisted contributions:
- **Issues**: Bug reports and feature requests that are clearly AI-generated will not be accepted and will be closed immediately. Please write your issues in your own words to ensure they are clear, specific, and contain the necessary context.
- **Pull Requests**: Contributions with the help of AI are permitted, but you are ultimately responsible for the quality of your submission and for ensuring it follows our contributing guidelines. The PR description must be written in your own words. Additionally, please remove any superfluous code comments introduced by AI tools before submitting. PRs that clearly violate this rule will be closed without further review.
In all cases, contributors must ensure their submissions are thoughtful, well-tested, and meet the project's quality standards.
### Issues
Issues should be used to report problems with Nhost, request a new feature, or discuss potential changes before a PR is created.
@@ -24,28 +33,20 @@ If you find an Issue that addresses the problem you're having, please add your r
### Pull Requests
Please have a look at our [developers guide](https://github.com/nhost/nhost/blob/main/DEVELOPERS.md) to start coding!
PRs to our libraries are always welcome and can be a quick way to get your fix or improvement slated for the next release. In general, PRs should:
- Only fix/add the functionality in question **OR** address wide-spread whitespace/style issues, not both.
- Add unit or integration tests for fixed or changed functionality (if a test suite exists).
- Address a single concern in the least number of changed lines as possible.
- Include documentation in the repo or on our [docs site](https://docs.nhost.io).
- Be accompanied by a complete Pull Request template (loaded automatically when a PR is created).
## Monorepo Structure
For changes that address core functionality or require breaking changes (e.g., a major release), it's best to open an Issue to discuss your proposal first. This is not required but can save time creating and reviewing changes.
This repository is a monorepo that contains multiple packages and applications. The structure is as follows:
In general, we follow the ["fork-and-pull" Git workflow](https://github.com/susam/gitpr)
- `cli` - The Nhost CLI
- `dashboard` - The Nhost Dashboard
- `docs` - Documentation
- `examples` - Various example projects
- `packages/nhost-js` - The Nhost JavaScript/TypeScript SDK
- `services/auth` - Nhost Authentication service
- `services/storage` - Nhost Storage service
- `tools/codegen` - Internal code generation tool to build the SDK
- `tools/mintlify-openapi` - Internal tool to generate reference documentation for Mintlify from an OpenAPI spec.
1. Fork the repository to your own Github account
2. Clone the project to your machine
3. Create a branch locally with a succinct but descriptive name. All changes should be part of a branch and submitted as a pull request - your branches should be prefixed with one of:
- `bug/` for bug fixes
- `feat/` for features
- `chore/` for configuration changes
- `docs/` for documentation changes
4. Commit changes to the branch
5. Following any formatting and testing guidelines specific to this repo
6. Push changes to your fork
7. Open a PR in our repository and follow the PR template to review the changes efficiently.
For details about those projects and how to contribure, please refer to their respective `README.md` and `CONTRIBUTING.md` files.

View File

@@ -1,100 +0,0 @@
# Developer Guide
## Requirements
### Node.js v20 or later
### [pnpm](https://pnpm.io/) package manager
The easiest way to install `pnpm` if it's not installed on your machine yet is to use `npm`:
```sh
$ npm install -g pnpm
```
### [Nhost CLI](https://docs.nhost.io/platform/cli/local-development)
- The CLI is primarily used for running the E2E tests
- Please refer to the [installation guide](https://docs.nhost.io/platform/cli/local-development) if you have not installed it yet
## File Structure
The repository is organized as a monorepo, with the following structure (only relevant folders are shown):
```
assets/ # Assets used in the README
config/ # Configuration files for the monorepo
dashboard/ # Dashboard
docs/ # Documentation website
examples/ # Example projects
packages/ # Core packages
integrations/ # These are packages that rely on the core packages
```
## Get started
### Installation
First, clone this repository:
```sh
git clone https://github.com/nhost/nhost
```
Then, install the dependencies with `pnpm`:
```sh
$ cd nhost
$ pnpm install
```
### Development
Although package references are correctly updated on the fly for TypeScript, example projects and the dashboard won't see the changes because they are depending on the build output. To fix this, you can run packages in development mode.
Running packages in development mode from the root folder is as simple as:
```sh
$ pnpm dev
```
Our packages are linked together using [PNPM's workspace](https://pnpm.io/workspaces) feature. Next.js and Vite automatically detect changes in the dependencies and rebuild everything, so the changes will be reflected in the examples and the dashboard.
**Note:** It's possible that Next.js or Vite throw an error when you run `pnpm dev`. Restarting the process should fix it.
### Use Examples
Examples are a great way to test your changes in practice. Make sure you've `pnpm dev` running in your terminal and then run an example.
Let's follow the instructions to run [react-apollo example](https://github.com/nhost/nhost/blob/main/examples/react-apollo/README.md).
## Edit Documentation
The easier way to contribute to our documentation is to go to the `docs` folder and follow the [instructions to start local development](https://github.com/nhost/nhost/blob/main/docs/README.md):
```sh
$ cd docs
# not necessary if you've already done this step somewhere in the repository
$ pnpm install
$ pnpm start
```
## Run Test Suites
### Unit Tests
You can run the unit tests with the following command from the repository root:
```sh
$ pnpm test
```
### E2E Tests
Each package that defines end-to-end tests embeds their own Nhost configuration, that will be automatically when running the tests. As a result, you must make sure you are not running the Nhost CLI before running the tests.
You can run the e2e tests with the following command from the repository root:
```sh
$ pnpm e2e
```

16
Makefile Normal file
View File

@@ -0,0 +1,16 @@
.PHONY: envrc-install
envrc-install: ## Copy envrc.sample to all project folders
@for f in $$(find . -name "project.nix"); do \
echo "Copying envrc.sample to $$(dirname $$f)/.envrc"; \
cp ./envrc.sample $$(dirname $$f)/.envrc; \
done
.PHONY: nixops-container-env
nixops-container-env: ## Enter a NixOS container environment
docker run \
-it \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ./:/build \
-w /build \
nixops:0.0.0-dev \
bash

View File

@@ -12,7 +12,7 @@
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://nhost.io/blog">Blog</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://twitter.com/nhost">Twitter</a>
<a href="https://x.com/nhost">X</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://nhost.io/discord">Discord</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
@@ -33,10 +33,10 @@ Nhost consists of open source software:
- Database: [PostgreSQL](https://www.postgresql.org/)
- Instant GraphQL API: [Hasura](https://hasura.io/)
- Authentication: [Hasura Auth](https://github.com/nhost/hasura-auth/)
- Storage: [Hasura Storage](https://github.com/nhost/hasura-storage)
- Authentication: [Auth](https://github.com/nhost/nhost/tree/main/services/auth)
- Storage: [Storage](https://github.com/nhost/nhost/tree/main/services/storage)
- Serverless Functions: Node.js (JavaScript and TypeScript)
- [Nhost CLI](https://docs.nhost.io/platform/cli/local-development) for local development
- [Nhost CLI](https://github.com/nhost/nhost/tree/main/cli) for local development
## Architecture of Nhost
@@ -107,7 +107,6 @@ Nhost is frontend agnostic, which means Nhost works with all frontend frameworks
# Resources
- Start developing locally with the [Nhost CLI](https://docs.nhost.io/platform/cli/local-development)
## Nhost Clients
- [JavaScript/TypeScript](https://docs.nhost.io/reference/javascript/nhost-js/main)

View File

@@ -2,5 +2,8 @@
// $schema provides code completion hints to IDEs.
"$schema": "https://github.com/IBM/audit-ci/raw/main/docs/schema.json",
"moderate": true,
"allowlist": ["vue-template-compiler", { "id": "CVE-2025-48068", "path": "next" }]
"allowlist": [
"GHSA-9965-vmph-33xx", // https://github.com/advisories/GHSA-9965-vmph-33xx Update package once have a fix
"GHSA-7mvr-c777-76hp" // https://github.com/advisories/GHSA-7mvr-c777-76hp Update package once Nix side is also updated
]
}

View File

@@ -54,6 +54,11 @@ get-version: ## Return version
@echo $(VERSION)
.PHONY: develop
develop: ## Start a nix develop shell
nix develop .\#$(NAME)
.PHONY: _check-pre
_check-pre: ## Pre-checks before running nix flake check
@@ -105,6 +110,11 @@ build-docker-image: ## Build docker container for native architecture
skopeo copy --insecure-policy dir:./result docker-daemon:$(NAME):$(VERSION)
.PHONY: build-docker-image-import-bare
build-docker-image-import-bare:
skopeo copy --insecure-policy dir:./result docker-daemon:$(NAME):$(VERSION)
.PHONY: dev-env-up
dev-env-up: _dev-env-build _dev-env-up ## Starts development environment

View File

@@ -1,7 +1,85 @@
## [cli@1.34.7] - 2025-11-13
### ⚙️ Miscellaneous Tasks
- *(cli)* Bump nhost/dashboard to 2.42.0 (#3693)
## [cli@1.34.6] - 2025-11-13
### 🐛 Bug Fixes
- *(cli)* Mcp: specify items type for arrays in tools (#3687)
### ⚙️ Miscellaneous Tasks
- *(cli)* Update bindings (#3689)
## [cli@1.34.5] - 2025-11-06
### ⚙️ Miscellaneous Tasks
- *(nixops)* Bump go to 1.25.3 and nixpkgs due to CVEs (#3652)
- *(cli)* Udpate certs and schema (#3675)
- *(cli)* Bump nhost/dashboard to 2.41.0 (#3669)
# Changelog
All notable changes to this project will be documented in this file.
## [cli@1.34.4] - 2025-10-28
### 🐛 Bug Fixes
- *(cli)* Update NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL correctly (#3643)
## [cli@1.34.3] - 2025-10-27
### ⚙️ Miscellaneous Tasks
- *(cli)* Update schema (#3622)
- *(cli)* Bump nhost/dashboard to 2.40.0 (#3629)
## [cli@1.34.2] - 2025-10-20
### ⚙️ Miscellaneous Tasks
- *(cli)* Minor fix to download script when specifying version (#3602)
- *(cli)* Update schema (#3613)
## [cli@1.34.1] - 2025-10-13
### 🐛 Bug Fixes
- *(cli)* Remove references to mcp-nhost (#3575)
- *(cli)* Workaround os.Rename issues when src and dst are on different partitions (#3599)
### ⚙️ Miscellaneous Tasks
- *(auth)* Change some references to deprecated hasura-auth (#3584)
- *(docs)* Udpated README.md and CONTRIBUTING.md (#3587)
## [cli@1.34.0] - 2025-10-09
### 🚀 Features
- *(cli)* Added mcp server functionality from mcp-nhost (#3550)
- *(cli)* Mcp: move configuration to .nhost folder and integrate cloud credentials (#3555)
- *(cli)* Mcp: added support for environment variables in the configuration (#3556)
- *(cli)* MCP refactor and documentation prior to official release (#3571)
### 🐛 Bug Fixes
- *(dashboard)* Remove NODE_ENV from restricted env vars (#3573)
### ⚙️ Miscellaneous Tasks
- *(nixops)* Update nhost-cli (#3554)
- *(cli)* Bump nhost/dashboard to 2.38.4 (#3539)
## [cli@1.33.0] - 2025-10-02
### 🚀 Features

84
cli/CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,84 @@
# Developer Guide
## Requirements
We use nix to manage the development environment, the build process and for running tests.
### With Nix (Recommended)
Run `nix develop \#cli` to get a complete development environment.
### Without Nix
Check `project.nix` (checkDeps, buildInputs, buildNativeInputs) for manual dependency installation. Alternatively, you can run `make nixops-container-env` in the root of the repository to enter a Docker container with nix and all dependencies pre-installed (note it is a large image).
## Development Workflow
### Running Tests
**With Nix:**
```bash
make dev-env-up
make check
```
**Without Nix:**
```bash
# Start development environment
make dev-env-up
# Lint Go code
golangci-lint run ./...
# Run tests
go test -v ./...
```
### Formatting
Format code before committing:
```bash
golines -w --base-formatter=gofumpt .
```
## Building
### Local Build
Build the project (output in `./result`):
```bash
make build
```
### Docker Image
Build and import Docker image with skopeo:
```bash
make build-docker-image
```
If you run the command above inside the dockerized nixops-container-env and you get an error like:
```
FATA[0000] writing blob: io: read/write on closed pipe
```
then you need to run the following command outside of the container (needs skopeo installed on the host):
```bash
cd cli
make build-docker-image-import-bare
```
### Multi-Platform Builds
Build for multiple platforms (Darwin/Linux, ARM64/AMD64):
```bash
make build-multiplatform
```
This produces binaries for:
- darwin/arm64
- darwin/amd64
- linux/arm64
- linux/amd64

View File

@@ -1,199 +0,0 @@
# nhost mcp
A Model Context Protocol (MCP) server implementation for interacting with Nhost Cloud projects and services.
## Overview
MCP-Nhost is designed to provide a unified interface for managing Nhost projects through the Model Context Protocol. It enables seamless interaction with Nhost Cloud services, offering a robust set of tools for project management and configuration.
## Available Tools
The following tools are currently exposed through the MCP interface:
1. **cloud-get-graphql-schema**
- Provides the GraphQL schema for the Nhost Cloud platform
- Gives access to queries and mutations available for cloud management
2. **cloud-graphql-query**
- Executes GraphQL queries and mutations against the Nhost Cloud platform
- Enables project and organization management
- Allows querying and updating project configurations
- Mutations require enabling them when starting the server
3. **local-get-graphql-schema**
- Retrieves the GraphQL schema for local Nhost development projects
- Provides access to project-specific queries and mutations
- Helps understand available operations for local development helping generating code
- Uses "user" role unless specified otherwise
4. **local-graphql-query**
- Executes GraphQL queries against local Nhost development projects
- Enables testing and development of project-specific operations
- Supports both queries and mutations for local development
- Uses "user" role unless specified otherwise
5. **local-config-server-get-schema**
- Retrieves the GraphQL schema for the local config server
- Helps understand available configuration options for local projects
6. **local-config-server-query**
- Executes GraphQL queries against the local config server
- Enables querying and modifying local project configuration
- Changes require running 'nhost up' to take effect
7. **local-get-management-graphql-schema**
- Retrieves the GraphQL management schema for local projects
- Useful for understanding how to manage Hasura metadata, migrations, and permissions
- Provides insight into available management operations before using the management tool
8. **local-manage-graphql**
- Interacts with GraphQL's management endpoints for local projects
- Manages Hasura metadata, migrations, permissions, and remote schemas
- Creates and applies database migrations
- Handles data and schema changes through proper migration workflows
- Manages roles and permissions
9. **project-get-graphql-schema**
- Retrieves the GraphQL schema for Nhost Cloud projects
- Provides access to project-specific queries and mutations
- Uses "user" role unless specified otherwise
10. **project-graphql-query**
- Executes GraphQL queries against Nhost Cloud projects
- Enables interaction with live project data
- Supports both queries and mutations (need to be allowed)
- Uses "user" role unless specified otherwise
11. **search**
- Searches Nhost's official documentation
- Provides information about Nhost features, APIs, and guides
- Helps find relevant documentation for implementing features or solving issues
- Returns links to detailed documentation pages
## Screenshots and Examples
You can find screenshots and examples of the current features and tools in the [screenshots](docs/mcp/screenshots.md) file.
## Installing
To install mcp-nhost, you can use the following command:
```bash
sudo curl -L https://raw.githubusercontent.com/nhost/mcp-nhost/main/get.sh | bash
```
## Configuring
After installing mcp-nhost, you will need to configure it. You can do this by running the command `mcp-nhost config` in your terminal. See [CONFIG.md](docs/mcp/CONFIG.md) for more details.
## Configuring clients
#### Cursor
1. Go to "Cursor Settings"
2. Click on "MCP"
3. Click on "+ Add new global MCP server"
4. Add the following object inside `"mcpServers"`:
```json
"mcp-nhost": {
"command": "/usr/local/bin/mcp-nhost",
"args": [
"start",
],
}
```
## CLI Usage
For help on how to use the CLI, you can run:
```bash
mcp-nhost --help
```
Or check [USAGE.md](docs/mcp/USAGE.md) for more details.
## Troubleshooting
If you run into issues using the MCP server you can try running the tools yourself. For example:
```
# cloud-get-graphql-schema
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"cloud-get-graphql-schema","arguments":{}},"id":1}' | mcp-nhost start
# cloud-graphql-query
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"cloud-graphql-query","arguments":{"query":"{ apps { id subdomain name } }"}},"id":1}' | mcp-nhost start
# local-get-graphql-schema
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"local-get-graphql-schema","arguments":{"role":"user"}},"id":1}' | mcp-nhost start
# local-graphql-query
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"local-graphql-query","arguments":{"query":"{ users { id } }", "role":"admin"}},"id":1}' | mcp-nhost start
# local-config-server-get-schema
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"local-config-server-get-schema","arguments":{}},"id":1}' | mcp-nhost start
# local-config-server-query
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"local-config-server-query","arguments":{"query":"{ config(appID: \"00000000-0000-0000-0000-000000000000\", resolve: true) { postgres { version } } }"}},"id":1}' | mcp-nhost start
# local-get-management-graphql-schema
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"local-get-management-graphql-schema","arguments":{}},"id":1}' | mcp-nhost start
# local-manage-graphql
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"local-manage-graphql","arguments":{"body":"{\"type\":\"export_metadata\",\"args\":{}}","endpoint":"https://local.hasura.local.nhost.run/v1/metadata"}},"id":1}' | mcp-nhost start
# project-get-graphql-schema - set projectSubdomain to your own project
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"project-get-graphql-schema","arguments":{"projectSubdomain":"replaceMe", "role": "user"}},"id":1}' | mcp-nhost start
# project-graphql-query - set projectSubdomain to your own project
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"project-graphql-query","arguments":{"projectSubdomain":"replaceMe","query":"{ users { id } }", "role":"admin"}},"id":1}' | mcp-nhost start
# search
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"search","arguments":{"query":"how to enable magic links"}},"id":1}' | mcp-nhost start
```
## Roadmap
- ✅ Cloud platform: Basic project and organization management
- ✅ Cloud projects: Configuration management
- ✅ Local projects: Configuration management
- ✅ Local projects: Graphql Schema awareness and query execution
- ✅ Cloud projects: Schema awareness and query execution
- ✅ Local projects: Create migrations
- ✅ Local projects: Manage permissions and relationships
- ✅ Documentation: integrate or document use of mintlify's mcp server
- ✅ Local projects: Auth and Storage schema awareness (maybe via mintlify?)
- ✅ Cloud projects: Auth and Storage schema awareness (maybe via mintlify?)
- 🔄 Local projects: Manage more metadata
If you have any suggestions or feature requests, please feel free to open an issue for discussion.
## Security and Privacy
### Enhanced Protection Layer
The MCP server is designed with security at its core, providing an additional protection layer beyond your existing GraphQL permissions. Key security features include:
- **Authentication enforcement** for all requests
- **Permission and role respect** based on your existing authorization system and the credentials provided
- **Query/mutation filtering** to further restrict allowed operations
### Granular Access Control
One of the MCP server's key security advantages is the ability to specify exactly which operations can pass through, even for authenticated users:
```toml
[[projects]]
subdomain = "my-blog"
region = "eu-central-1"
pat = "nhp_project_specific_pat"
allow_queries = ["getBlogs", "getCommends"]
allow_mutations = ["insertBlog", "insertComment"]
```
With the configuration above, an LLM will be able to only execute the queries and mutations above on behalf of a user even if the user has broader permissions in the Nhost project.
## Contributing
We welcome contributions to mcp-nhost! If you have suggestions, bug reports, or feature requests, please open an issue or submit a pull request.

View File

@@ -51,11 +51,18 @@ nhost up
nhost up --ui nhost
```
## MCP Server
The Nhost cli ships with an MCP server that lets you interact with your Nhost projects through AI assistants using the Model Context Protocol. It provides secure, controlled access to your GraphQL data, project configuration, and documentation—with granular permissions that let you specify exactly which queries and mutations an LLM can execute. For development, it streamlines your workflow by enabling AI-assisted schema management, metadata changes, and migrations, while providing direct access to your GraphQL schema for intelligent query building.
You can read more about the MCP server in the [MCP Server documentation](https://docs.nhost.io/platform/cli/mcp/overview).
## Documentation
- [Get started with Nhost CLI (longer version)](https://docs.nhost.io/platform/overview/get-started-with-nhost-cli)
- [Nhost CLI](https://docs.nhost.io/platform/cli)
- [Reference](https://docs.nhost.io/reference/cli)
- [MCP Server](https://docs.nhost.io/platform/cli/mcp/overview)
## Build from Source

View File

@@ -29,3 +29,12 @@ func (ce *CliEnv) LoadSession(
return session, nil
}
func (ce *CliEnv) Credentials() (credentials.Credentials, error) {
var creds credentials.Credentials
if err := UnmarshalFile(ce.Path.AuthFile(), &creds, json.Unmarshal); err != nil {
return credentials.Credentials{}, err
}
return creds, nil
}

View File

@@ -56,7 +56,7 @@ func CommandCloud() *cli.Command {
&cli.StringFlag{ //nolint:exhaustruct
Name: flagDashboardVersion,
Usage: "Dashboard version to use",
Value: "nhost/dashboard:2.38.0",
Value: "nhost/dashboard:2.42.0",
Sources: cli.EnvVars("NHOST_DASHBOARD_VERSION"),
},
&cli.StringFlag{ //nolint:exhaustruct

View File

@@ -111,7 +111,7 @@ func CommandUp() *cli.Command { //nolint:funlen
&cli.StringFlag{ //nolint:exhaustruct
Name: flagDashboardVersion,
Usage: "Dashboard version to use",
Value: "nhost/dashboard:2.38.0",
Value: "nhost/dashboard:2.42.0",
Sources: cli.EnvVars("NHOST_DASHBOARD_VERSION"),
},
&cli.StringFlag{ //nolint:exhaustruct

View File

@@ -12,8 +12,7 @@ import (
)
const (
flagConfigFile = "config-file"
flagConfirm = "confirm"
flagConfirm = "confirm"
)
func Command() *cli.Command {
@@ -21,12 +20,6 @@ func Command() *cli.Command {
Name: "config",
Usage: "Generate and save configuration file",
Flags: []cli.Flag{
&cli.StringFlag{ //nolint:exhaustruct
Name: flagConfigFile,
Usage: "Configuration file path",
Value: config.GetConfigPath(),
Sources: cli.EnvVars("CONFIG_FILE"),
},
&cli.BoolFlag{ //nolint:exhaustruct
Name: flagConfirm,
Usage: "Skip confirmation prompt",
@@ -36,16 +29,9 @@ func Command() *cli.Command {
},
Commands: []*cli.Command{
{
Name: "dump",
Usage: "Dump the configuration to stdout for verification",
Flags: []cli.Flag{
&cli.StringFlag{ //nolint:exhaustruct
Name: flagConfigFile,
Usage: "Path to the config file",
Value: config.GetConfigPath(),
Sources: cli.EnvVars("CONFIG_FILE"),
},
},
Name: "dump",
Usage: "Dump the configuration to stdout for verification",
Flags: []cli.Flag{},
Action: actionDump,
},
},
@@ -70,7 +56,7 @@ func action(_ context.Context, cmd *cli.Command) error {
fmt.Println(string(tomlData))
fmt.Println()
filePath := cmd.String(flagConfigFile)
filePath := config.GetConfigPath(cmd)
fmt.Printf("Save configuration to %s?\n", filePath)
fmt.Print("Proceed? (y/N): ")

View File

@@ -10,14 +10,14 @@ import (
)
func actionDump(_ context.Context, cmd *cli.Command) error {
configPath := cmd.String(flagConfigFile)
configPath := config.GetConfigPath(cmd)
if configPath == "" {
return cli.Exit("config file path is required", 1)
}
cfg, err := config.Load(configPath)
if err != nil {
fmt.Println("Please, run `mcp-nhost config` to configure the service.") //nolint:forbidigo
fmt.Println("Please, run `nhost mcp config` to configure the service.") //nolint:forbidigo
return cli.Exit("failed to load config file "+err.Error(), 1)
}

View File

@@ -7,11 +7,23 @@ import (
"github.com/urfave/cli/v3"
)
const (
flagConfigFile = "config-file"
)
func Command() *cli.Command {
return &cli.Command{ //nolint:exhaustruct
Name: "mcp",
Aliases: []string{},
Usage: "Model Context Protocol (MCP) related commands",
Flags: []cli.Flag{
&cli.StringFlag{ //nolint:exhaustruct
Name: flagConfigFile,
Usage: "Configuration file path. Defaults to $NHOST_DOT_NHOST_FOLDER/nhost-mcp.toml",
Value: "",
Sources: cli.EnvVars("NHOST_MCP_CONFIG_FILE"),
},
},
Commands: []*cli.Command{
config.Command(),
start.Command(),

View File

@@ -13,26 +13,39 @@ import (
"github.com/mark3labs/mcp-go/mcp"
nhostmcp "github.com/nhost/nhost/cli/cmd/mcp"
"github.com/nhost/nhost/cli/cmd/mcp/start"
"github.com/nhost/nhost/cli/cmd/user"
"github.com/nhost/nhost/cli/mcp/resources"
"github.com/nhost/nhost/cli/mcp/tools/cloud"
"github.com/nhost/nhost/cli/mcp/tools/docs"
"github.com/nhost/nhost/cli/mcp/tools/local"
"github.com/nhost/nhost/cli/mcp/tools/project"
"github.com/nhost/nhost/cli/mcp/tools/schemas"
)
func ptr[T any](v T) *T {
return &v
}
func TestStart(t *testing.T) { //nolint:cyclop,maintidx
t.Parallel()
cmd := nhostmcp.Command()
func TestStart(t *testing.T) { //nolint:cyclop,maintidx,paralleltest
loginCmd := user.CommandLogin()
mcpCmd := nhostmcp.Command()
buf := bytes.NewBuffer(nil)
cmd.Writer = buf
mcpCmd.Writer = buf
go func() {
if err := cmd.Run(
t.Setenv("HOME", t.TempDir())
if err := loginCmd.Run(
context.Background(),
[]string{
"main",
"--pat=user-pat",
},
); err != nil {
panic(err)
}
if err := mcpCmd.Run(
context.Background(),
[]string{
"main",
@@ -54,7 +67,7 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx
mcpClient := client.NewClient(transportClient)
if err := mcpClient.Start(context.Background()); err != nil {
if err := mcpClient.Start(t.Context()); err != nil {
t.Fatalf("failed to start mcp client: %v", err)
}
defer mcpClient.Close()
@@ -84,8 +97,14 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx
Experimental: nil,
Logging: nil,
Prompts: nil,
Resources: nil,
Sampling: nil,
Resources: &struct {
Subscribe bool "json:\"subscribe,omitempty\""
ListChanged bool "json:\"listChanged,omitempty\""
}{
Subscribe: false,
ListChanged: false,
},
Sampling: nil,
Tools: &struct {
ListChanged bool "json:\"listChanged,omitempty\""
}{
@@ -96,7 +115,23 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx
Name: "mcp",
Version: "",
},
Instructions: start.ServerInstructions,
Instructions: start.ServerInstructions + `
Configured projects:
- local (local): Local development project running via the Nhost CLI
- asdasdasdasdasd (eu-central-1): Staging project for my awesome app
- qweqweqweqweqwe (us-east-1): Production project for my awesome app
The following resources are available:
- schema://nhost-cloud: Schema to interact with the Nhost Cloud. Projects are equivalent
to apps in the schema. IDs are typically uuids.
- schema://graphql-management: GraphQL's management schema for an Nhost project.
This tool is useful to properly understand how manage hasura metadata, migrations,
permissions, remote schemas, etc.
- schema://nhost.toml: Cuelang schema for the nhost.toml configuration file. Run nhost
config validate after making changes to your nhost.toml file to ensure it is valid.
`,
Result: mcp.Result{
Meta: nil,
},
@@ -118,22 +153,6 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx
//nolint:exhaustruct,lll
&mcp.ListToolsResult{
Tools: []mcp.Tool{
{
Name: "cloud-get-graphql-schema",
Description: cloud.ToolGetGraphqlSchemaInstructions,
InputSchema: mcp.ToolInputSchema{
Type: "object",
Properties: nil,
Required: nil,
},
Annotations: mcp.ToolAnnotation{
Title: "Get GraphQL Schema for Nhost Cloud Platform",
ReadOnlyHint: ptr(true),
DestructiveHint: ptr(false),
IdempotentHint: ptr(true),
OpenWorldHint: ptr(true),
},
},
{
Name: "cloud-graphql-query",
Description: cloud.ToolGraphqlQueryInstructions,
@@ -160,24 +179,40 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx
},
},
{
Name: "local-config-server-get-schema",
Description: local.ToolConfigServerSchemaInstructions,
Name: "get-schema",
Description: schemas.ToolGetGraphqlSchemaInstructions,
InputSchema: mcp.ToolInputSchema{
Type: "object",
Properties: map[string]any{
"includeMutations": map[string]any{
"description": "include mutations in the schema",
"type": "boolean",
"role": map[string]any{
"description": string("role to use when executing queries. Keep in mind the schema depends on the role so if you retrieved the schema for a different role previously retrieve it for this role beforehand as it might differ"),
"type": string("string"),
},
"includeQueries": map[string]any{
"description": "include queries in the schema",
"type": "boolean",
"subdomain": map[string]any{
"description": string("Project to get the GraphQL schema for. Required when service is `project`"),
"enum": []any{string("local"), string("asdasdasdasdasd"), string("qweqweqweqweqwe")},
"type": string("string"),
},
"mutations": map[string]any{
"description": string("list of mutations to fetch"),
"type": string("array"),
"items": map[string]any{"type": string("string")},
},
"queries": map[string]any{
"description": string("list of queries to fetch"),
"type": string("array"),
"items": map[string]any{"type": string("string")},
},
"summary": map[string]any{
"default": bool(true),
"description": string("only return a summary of the schema"),
"type": string("boolean"),
},
},
Required: []string{"includeQueries", "includeMutations"},
Required: []string{"role", "subdomain"},
},
Annotations: mcp.ToolAnnotation{
Title: "Get GraphQL Schema for Nhost Config Server",
Title: "Get GraphQL/API schema for various services",
ReadOnlyHint: ptr(true),
DestructiveHint: ptr(false),
IdempotentHint: ptr(true),
@@ -185,109 +220,7 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx
},
},
{
Name: "local-config-server-query",
Description: local.ToolConfigServerQueryInstructions,
InputSchema: mcp.ToolInputSchema{
Type: "object",
Properties: map[string]any{
"query": map[string]any{
"description": "graphql query to perform",
"type": "string",
},
"variables": map[string]any{
"description": "variables to use in the query",
"type": "string",
},
},
Required: []string{"query"},
},
Annotations: mcp.ToolAnnotation{
Title: "Perform GraphQL Query on Nhost Config Server",
ReadOnlyHint: ptr(false),
DestructiveHint: ptr(true),
IdempotentHint: ptr(false),
OpenWorldHint: ptr(true),
},
},
{
Name: "local-get-graphql-schema",
Description: local.ToolGetGraphqlSchemaInstructions,
InputSchema: mcp.ToolInputSchema{
Type: "object",
Properties: map[string]any{
"role": map[string]any{
"description": "role to use when executing queries. Default to user but make sure the user is aware",
"type": "string",
},
},
Required: []string{"role"},
},
Annotations: mcp.ToolAnnotation{
Title: "Get GraphQL Schema for Nhost Development Project",
ReadOnlyHint: ptr(true),
DestructiveHint: ptr(false),
IdempotentHint: ptr(true),
OpenWorldHint: ptr(true),
},
},
{
Name: "local-graphql-query",
Description: local.ToolGraphqlQueryInstructions,
InputSchema: mcp.ToolInputSchema{
Type: "object",
Properties: map[string]any{
"query": map[string]any{
"description": "graphql query to perform",
"type": "string",
},
"role": map[string]any{
"description": "role to use when executing queries. Default to user but make sure the user is aware. Keep in mind the schema depends on the role so if you retrieved the schema for a different role previously retrieve it for this role beforehand as it might differ",
"type": "string",
},
"variables": map[string]any{
"additionalProperties": true,
"description": "variables to use in the query",
"properties": map[string]any{},
"type": "object",
},
},
Required: []string{"query", "role"},
},
Annotations: mcp.ToolAnnotation{
Title: "Perform GraphQL Query on Nhost Development Project",
ReadOnlyHint: ptr(false),
DestructiveHint: ptr(true),
IdempotentHint: ptr(false),
OpenWorldHint: ptr(true),
},
},
{
Name: "project-get-graphql-schema",
Description: project.ToolGetGraphqlSchemaInstructions,
InputSchema: mcp.ToolInputSchema{
Type: "object",
Properties: map[string]any{
"projectSubdomain": map[string]any{
"description": "Project to get the GraphQL schema for. Must be one of asdasdasdasdasd, qweqweqweqweqwe, otherwise you don't have access to it. You can use cloud-* tools to resolve subdomains and map them to names",
"type": "string",
},
"role": map[string]any{
"description": "role to use when executing queries. Default to user but make sure the user is aware",
"type": "string",
},
},
Required: []string{"role", "projectSubdomain"},
},
Annotations: mcp.ToolAnnotation{
Title: "Get GraphQL Schema for Nhost Project running on Nhost Cloud",
ReadOnlyHint: ptr(true),
DestructiveHint: ptr(false),
IdempotentHint: ptr(true),
OpenWorldHint: ptr(true),
},
},
{
Name: "project-graphql-query",
Name: "graphql-query",
Description: project.ToolGraphqlQueryInstructions,
InputSchema: mcp.ToolInputSchema{
Type: "object",
@@ -296,12 +229,17 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx
"description": "graphql query to perform",
"type": "string",
},
"projectSubdomain": map[string]any{
"description": "Project to get the GraphQL schema for. Must be one of asdasdasdasdasd, qweqweqweqweqwe, otherwise you don't have access to it. You can use cloud-* tools to resolve subdomains and map them to names",
"subdomain": map[string]any{
"description": "Project to perform the GraphQL query against",
"type": "string",
"enum": []any{
string("local"),
string("asdasdasdasdasd"),
string("qweqweqweqweqwe"),
},
},
"role": map[string]any{
"description": "role to use when executing queries. Default to user but make sure the user is aware. Keep in mind the schema depends on the role so if you retrieved the schema for a different role previously retrieve it for this role beforehand as it might differ",
"description": "role to use when executing queries. Keep in mind the schema depends on the role so if you retrieved the schema for a different role previously retrieve it for this role beforehand as it might differ",
"type": "string",
},
"userId": map[string]any{
@@ -313,7 +251,7 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx
"type": "string",
},
},
Required: []string{"query", "projectSubdomain", "role"},
Required: []string{"query", "subdomain", "role"},
},
Annotations: mcp.ToolAnnotation{
Title: "Perform GraphQL Query on Nhost Project running on Nhost Cloud",
@@ -324,36 +262,30 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx
},
},
{
Name: "local-get-management-graphql-schema",
Description: local.ToolGetGraphqlManagementSchemaInstructions,
InputSchema: mcp.ToolInputSchema{
Type: "object",
Properties: nil,
},
Annotations: mcp.ToolAnnotation{
Title: "Get GraphQL's Management Schema for Nhost Development Project",
ReadOnlyHint: ptr(true),
IdempotentHint: ptr(true),
DestructiveHint: ptr(false),
OpenWorldHint: ptr(true),
},
},
{
Name: "local-manage-graphql",
Description: local.ToolManageGraphqlInstructions,
Name: "manage-graphql",
Description: project.ToolManageGraphqlInstructions,
InputSchema: mcp.ToolInputSchema{
Type: "object",
Properties: map[string]any{
"body": map[string]any{
"description": string("The body for the HTTP request"),
"type": string("string"),
"description": "The body for the HTTP request",
"type": "string",
},
"endpoint": map[string]any{
"description": string("The GraphQL management endpoint to query. Use https://local.hasura.local.nhost.run as base URL"),
"type": string("string"),
"path": map[string]any{
"description": "The path for the HTTP request",
"type": "string",
},
"subdomain": map[string]any{
"description": "Project to perform the GraphQL management operation against",
"type": "string",
"enum": []any{
string("local"),
string("asdasdasdasdasd"),
string("qweqweqweqweqwe"),
},
},
},
Required: []string{"endpoint", "body"},
Required: []string{"subdomain", "path", "body"},
},
Annotations: mcp.ToolAnnotation{
Title: "Manage GraphQL's Metadata on an Nhost Development Project",
@@ -393,24 +325,60 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx
t.Errorf("ListToolsResult mismatch (-want +got):\n%s", diff)
}
if res.Capabilities.Resources != nil {
resources, err := mcpClient.ListResources(
context.Background(),
mcp.ListResourcesRequest{}, //nolint:exhaustruct
)
if err != nil {
t.Fatalf("failed to list resources: %v", err)
}
resourceList, err := mcpClient.ListResources(
context.Background(),
mcp.ListResourcesRequest{}, //nolint:exhaustruct
)
if err != nil {
t.Fatalf("failed to list resources: %v", err)
}
if diff := cmp.Diff(
resources,
//nolint:exhaustruct
&mcp.ListResourcesResult{
Resources: []mcp.Resource{},
if diff := cmp.Diff(
resourceList,
//nolint:exhaustruct
&mcp.ListResourcesResult{
Resources: []mcp.Resource{
{
Annotated: mcp.Annotated{
Annotations: &mcp.Annotations{
Audience: []mcp.Role{"agent"},
Priority: 9,
},
},
URI: "schema://graphql-management",
Name: "graphql-management",
Description: resources.GraphqlManagementDescription,
MIMEType: "text/plain",
},
{
Annotated: mcp.Annotated{
Annotations: &mcp.Annotations{
Audience: []mcp.Role{"agent"},
Priority: 9,
},
},
URI: "schema://nhost-cloud",
Name: "nhost-cloud",
Description: resources.CloudDescription,
MIMEType: "text/plain",
},
{
Annotated: mcp.Annotated{
Annotations: &mcp.Annotations{
Audience: []mcp.Role{"agent"},
Priority: 9,
},
},
URI: "schema://nhost.toml",
Name: "nhost.toml",
Description: resources.NhostTomlResourceDescription,
MIMEType: "text/plain",
},
},
); diff != "" {
t.Errorf("ListResourcesResult mismatch (-want +got):\n%s", diff)
}
},
); diff != "" {
t.Errorf("ListResourcesResult mismatch (-want +got):\n%s", diff)
}
if res.Capabilities.Prompts != nil {

View File

@@ -5,17 +5,18 @@ import (
"fmt"
"github.com/mark3labs/mcp-go/server"
"github.com/nhost/nhost/cli/clienv"
"github.com/nhost/nhost/cli/mcp/config"
"github.com/nhost/nhost/cli/mcp/nhost/auth"
"github.com/nhost/nhost/cli/mcp/resources"
"github.com/nhost/nhost/cli/mcp/tools/cloud"
"github.com/nhost/nhost/cli/mcp/tools/docs"
"github.com/nhost/nhost/cli/mcp/tools/local"
"github.com/nhost/nhost/cli/mcp/tools/project"
"github.com/nhost/nhost/cli/mcp/tools/schemas"
"github.com/urfave/cli/v3"
)
const (
flagConfigFile = "config-file"
flagNhostAuthURL = "nhost-auth-url"
flagNhostGraphqlURL = "nhost-graphql-url"
flagBind = "bind"
@@ -25,22 +26,21 @@ const (
// this seems to be largely ignored by clients, or at least by cursor.
// we also need to look into roots and resources as those might be helpful.
ServerInstructions = `
This is an MCP server to interact with Nhost Cloud and with projects running on it and
also with Nhost local development projects.
This is an MCP server to interact with the Nhost Cloud and with Nhost projects.
Important notes to anyone using this MCP server. Do not use this MCP server without
following these instructions:
Important notes to anyone using this MCP server. Do not use this MCP server without
following these instructions:
1. Make sure you are clear on which environment the user wants to operate against.
2. Before attempting to call any tool *-graphql-query, always get the schema using the
*-get-graphql-schema tool
3. Apps and projects are the same and while users may talk about projects in the GraphQL
api those are referred as apps.
4. IDs are always UUIDs so if you have anything else (like an app/project name) you may need
to first get the ID using the *-graphql-query tool.
5. If you have an error querying the GraphQL API, please check the schema again. The schema may
have changed and the query you are using may be invalid.
`
1. Make sure you are clear on which environment the user wants to operate against.
2. Before attempting to call any tool, always make sure you list resources, roots, and
resource templates to understand what is available.
3. Apps and projects are the same and while users may talk about projects in Nhost's GraphQL
api those are referred as apps.
4. If you have an error querying the GraphQL API, please check the schema again. The schema may
have changed and the query you are using may be invalid.
5. Always follow the instructions provided by each tool. If you need to deviate from these
instructions, please, confirm with the user before doing so.
`
)
func Command() *cli.Command {
@@ -48,12 +48,6 @@ func Command() *cli.Command {
Name: "start",
Usage: "Starts the MCP server",
Flags: []cli.Flag{
&cli.StringFlag{ //nolint:exhaustruct
Name: flagConfigFile,
Usage: "Path to the config file",
Value: config.GetConfigPath(),
Sources: cli.EnvVars("CONFIG_FILE"),
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagNhostAuthURL,
Usage: "Nhost auth URL",
@@ -88,14 +82,25 @@ func action(ctx context.Context, cmd *cli.Command) error {
return err
}
ServerInstructions := ServerInstructions
ServerInstructions += "\n\n"
ServerInstructions += cfg.Projects.Instructions()
ServerInstructions += "\n"
ServerInstructions += resources.Instructions()
mcpServer := server.NewMCPServer(
cmd.Root().Name,
cmd.Root().Version,
server.WithInstructions(ServerInstructions),
)
if err := resources.Register(cfg, mcpServer); err != nil {
return cli.Exit(fmt.Sprintf("failed to register resources: %s", err), 1)
}
if cfg.Cloud != nil {
if err := registerCloud(
cmd,
mcpServer,
cfg,
cmd.String(flagNhostAuthURL),
@@ -105,18 +110,15 @@ func action(ctx context.Context, cmd *cli.Command) error {
}
}
if cfg.Local != nil {
if err := registerLocal(mcpServer, cfg); err != nil {
return cli.Exit(fmt.Sprintf("failed to register local tools: %s", err), 1)
}
}
if len(cfg.Projects) > 0 {
if err := registerProjectTool(mcpServer, cfg); err != nil {
return cli.Exit(fmt.Sprintf("failed to register project tools: %s", err), 1)
}
}
resources := schemas.NewTool(cfg)
resources.Register(mcpServer)
d, err := docs.NewTool(ctx)
if err != nil {
return cli.Exit(fmt.Sprintf("failed to initialize docs tools: %s", err), 1)
@@ -128,14 +130,14 @@ func action(ctx context.Context, cmd *cli.Command) error {
}
func getConfig(cmd *cli.Command) (*config.Config, error) {
configPath := cmd.String(flagConfigFile)
configPath := config.GetConfigPath(cmd)
if configPath == "" {
return nil, cli.Exit("config file path is required", 1)
}
cfg, err := config.Load(configPath)
if err != nil {
fmt.Println("Please, run `mcp-nhost config` to configure the service.") //nolint:forbidigo
fmt.Println("Please, run `nhost mcp config` to configure the service.") //nolint:forbidigo
return nil, cli.Exit("failed to load config file "+err.Error(), 1)
}
@@ -143,14 +145,22 @@ func getConfig(cmd *cli.Command) (*config.Config, error) {
}
func registerCloud(
cmd *cli.Command,
mcpServer *server.MCPServer,
cfg *config.Config,
authURL string,
graphqlURL string,
) error {
ce := clienv.FromCLI(cmd)
creds, err := ce.Credentials()
if err != nil {
return fmt.Errorf("failed to load credentials: %w", err)
}
interceptor, err := auth.WithPAT(
authURL,
cfg.Cloud.PAT,
creds.PersonalAccessToken,
)
if err != nil {
return fmt.Errorf("failed to create PAT interceptor: %w", err)
@@ -167,33 +177,11 @@ func registerCloud(
return nil
}
func registerLocal(
mcpServer *server.MCPServer,
cfg *config.Config,
) error {
interceptor := auth.WithAdminSecret(cfg.Local.AdminSecret)
localTool := local.NewTool(
*cfg.Local.GraphqlURL,
*cfg.Local.ConfigServerURL,
interceptor,
)
if err := localTool.Register(mcpServer); err != nil {
return fmt.Errorf("failed to register tools: %w", err)
}
return nil
}
func registerProjectTool(
mcpServer *server.MCPServer,
cfg *config.Config,
) error {
projectTool, err := project.NewTool(cfg.Projects)
if err != nil {
return fmt.Errorf("failed to initialize tool: %w", err)
}
projectTool := project.NewTool(cfg)
if err := projectTool.Register(mcpServer); err != nil {
return fmt.Errorf("failed to register tool: %w", err)
}

View File

@@ -1,13 +1,20 @@
[cloud]
pat = 'your-personal-access-token'
enable_mutations = true
[local]
[[projects]]
subdomain = 'local'
region = 'local'
description = 'Local development project running via the Nhost CLI'
admin_secret = 'nhost-admin-secret'
manage_metadata = true
allow_queries = ['*']
allow_mutations = ['*']
[[projects]]
subdomain = 'asdasdasdasdasd'
region = 'eu-central-1'
description = 'Staging project for my awesome app'
manage_metadata = false
admin_secret = 'your-admin-secret-1'
allow_queries = ['*']
allow_mutations = ['*']
@@ -15,6 +22,8 @@ allow_mutations = ['*']
[[projects]]
subdomain = 'qweqweqweqweqwe'
region = 'us-east-1'
description = 'Production project for my awesome app'
manage_metadata = false
pat = 'pat-for-qweqweqweqweqwe'
allow_queries = ['getComments']
allow_mutations = ['insertComment', 'updateComment', 'deleteComment']

View File

@@ -131,7 +131,7 @@ func initInit(
getclient := &getter.Client{} //nolint:exhaustruct
if _, err := getclient.Get(ctx, &getter.Request{ //nolint:exhaustruct
Src: "git::https://github.com/nhost/hasura-auth.git//email-templates",
Src: "git::https://github.com/nhost/nhost.git//services/auth/email-templates",
Dst: "nhost/emails",
DisableSymlinks: true,
}); err != nil {

View File

@@ -2,10 +2,13 @@ package software
import (
"context"
"errors"
"fmt"
"io"
"os"
"runtime"
"strings"
"syscall"
"github.com/nhost/nhost/cli/clienv"
"github.com/nhost/nhost/cli/software"
@@ -92,8 +95,8 @@ func install(cmd *cli.Command, ce *clienv.CliEnv, tmpFile string) error {
ce.Infoln("Copying to %s...", curBin)
if err := os.Rename(tmpFile, curBin); err != nil {
return fmt.Errorf("failed to rename %s to %s: %w", tmpFile, curBin, err)
if err := moveOrCopyFile(tmpFile, curBin); err != nil {
return fmt.Errorf("failed to move %s to %s: %w", tmpFile, curBin, err)
}
ce.Infoln("Setting permissions...")
@@ -104,3 +107,55 @@ func install(cmd *cli.Command, ce *clienv.CliEnv, tmpFile string) error {
return nil
}
func moveOrCopyFile(src, dst string) error {
if err := os.Rename(src, dst); err != nil {
var linkErr *os.LinkError
// this happens when moving across different filesystems
if errors.As(err, &linkErr) && errors.Is(linkErr.Err, syscall.EXDEV) {
if err := hardMove(src, dst); err != nil {
return fmt.Errorf("failed to hard move: %w", err)
}
return nil
}
return fmt.Errorf("failed to rename: %w", err)
}
return nil
}
func hardMove(src, dst string) error {
srcFile, err := os.Open(src)
if err != nil {
return fmt.Errorf("failed to open source file: %w", err)
}
defer srcFile.Close()
dstFile, err := os.Create(dst)
if err != nil {
return fmt.Errorf("failed to create destination file: %w", err)
}
defer dstFile.Close()
if _, err := io.Copy(dstFile, srcFile); err != nil {
return fmt.Errorf("failed to copy file contents: %w", err)
}
fi, err := os.Stat(src)
if err != nil {
return fmt.Errorf("failed to stat source file: %w", err)
}
err = os.Chmod(dst, fi.Mode())
if err != nil {
return fmt.Errorf("failed to set file permissions: %w", err)
}
if err := os.Remove(src); err != nil {
return fmt.Errorf("failed to remove source file: %w", err)
}
return nil
}

View File

@@ -47,7 +47,7 @@ func auth( //nolint:funlen
&model.ConfigSmtp{
User: "user",
Password: "password",
Sender: "hasura-auth@example.com",
Sender: "auth@example.com",
Host: "mailhog",
Port: 1025, //nolint:mnd
Secure: false,
@@ -56,6 +56,7 @@ func auth( //nolint:funlen
false,
false,
"00000000-0000-0000-0000-000000000000",
"5181f67e2844e4b60d571fa346cac9c37fc00d1ff519212eae6cead138e639ba",
)
if err != nil {
return nil, fmt.Errorf("failed to get hasura env vars: %w", err)

View File

@@ -33,6 +33,7 @@ func expectedAuth() *Service {
"AUTH_DISABLE_SIGNUP": "false",
"AUTH_EMAIL_PASSWORDLESS_ENABLED": "true",
"AUTH_EMAIL_SIGNIN_EMAIL_VERIFIED_REQUIRED": "true",
"AUTH_ENCRYPTION_KEY": "5181f67e2844e4b60d571fa346cac9c37fc00d1ff519212eae6cead138e639ba",
"AUTH_GRAVATAR_DEFAULT": "gravatarDefault",
"AUTH_GRAVATAR_ENABLED": "true",
"AUTH_GRAVATAR_RATING": "gravatarRating",
@@ -52,6 +53,7 @@ func expectedAuth() *Service {
"AUTH_PROVIDER_APPLE_ENABLED": "true",
"AUTH_PROVIDER_APPLE_KEY_ID": "appleKeyId",
"AUTH_PROVIDER_APPLE_PRIVATE_KEY": "applePrivateKey",
"AUTH_PROVIDER_APPLE_SCOPE": "",
"AUTH_PROVIDER_APPLE_TEAM_ID": "appleTeamId",
"AUTH_PROVIDER_AZUREAD_CLIENT_ID": "azureadClientId",
"AUTH_PROVIDER_AZUREAD_CLIENT_SECRET": "azureadClientSecret",
@@ -74,9 +76,12 @@ func expectedAuth() *Service {
"AUTH_PROVIDER_FACEBOOK_CLIENT_SECRET": "facebookClientSecret",
"AUTH_PROVIDER_FACEBOOK_ENABLED": "true",
"AUTH_PROVIDER_FACEBOOK_SCOPE": "email",
"AUTH_PROVIDER_GITHUB_AUDIENCE": "audience",
"AUTH_PROVIDER_GITHUB_CLIENT_ID": "githubClientId",
"AUTH_PROVIDER_GITHUB_CLIENT_SECRET": "githubClientSecret",
"AUTH_PROVIDER_GITHUB_ENABLED": "true",
"AUTH_PROVIDER_GITHUB_SCOPE": "user:email",
"AUTH_PROVIDER_GITLAB_AUDIENCE": "audience",
"AUTH_PROVIDER_GITLAB_CLIENT_ID": "gitlabClientId",
"AUTH_PROVIDER_GITLAB_CLIENT_SECRET": "gitlabClientSecret",
"AUTH_PROVIDER_GITLAB_ENABLED": "true",
@@ -96,6 +101,7 @@ func expectedAuth() *Service {
"AUTH_PROVIDER_SPOTIFY_CLIENT_SECRET": "spotifyClientSecret",
"AUTH_PROVIDER_SPOTIFY_ENABLED": "true",
"AUTH_PROVIDER_SPOTIFY_SCOPE": "user-read-email",
"AUTH_PROVIDER_STRAVA_AUDIENCE": "audience",
"AUTH_PROVIDER_STRAVA_CLIENT_ID": "stravaClientId",
"AUTH_PROVIDER_STRAVA_CLIENT_SECRET": "stravaClientSecret",
"AUTH_PROVIDER_STRAVA_ENABLED": "true",
@@ -143,7 +149,7 @@ func expectedAuth() *Service {
"AUTH_SMTP_PASS": "password",
"AUTH_SMTP_PORT": "1025",
"AUTH_SMTP_SECURE": "false",
"AUTH_SMTP_SENDER": "hasura-auth@example.com",
"AUTH_SMTP_SENDER": "auth@example.com",
"AUTH_SMTP_USER": "user",
"AUTH_USER_DEFAULT_ALLOWED_ROLES": "user,admin",
"AUTH_USER_DEFAULT_ROLE": "user",

View File

@@ -344,7 +344,7 @@ func dashboard(
subdomain, "hasura", httpPort, useTLS,
) + "/console",
"NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL": URL(
subdomain, "hasura", httpPort, useTLS),
subdomain, "hasura", httpPort, useTLS) + "/apis/migrate",
"NEXT_PUBLIC_NHOST_STORAGE_URL": URL(
subdomain, "storage", httpPort, useTLS) + "/v1",
},
@@ -459,7 +459,7 @@ func mailhog(subdomain, volumeName string, useTLS bool) *Service {
"SMTP_PASS": "password",
"SMTP_PORT": "1025",
"SMTP_SECURE": "false",
"SMTP_SENDER": "hasura-auth@example.com",
"SMTP_SENDER": "auth@example.com",
"SMTP_USER": "user",
},
ExtraHosts: extraHosts(subdomain),

View File

@@ -1,80 +0,0 @@
# Configuration
This document describes all available configuration options for the Nhost MCP tool. The configuration file uses TOML format.
## TOML
```toml
# Cloud configuration for managing Nhost Cloud projects and organizations
# Remove section to disable this access
[cloud]
# Personal Access Token (PAT) for Nhost Cloud API authentication
# Get one at: https://app.nhost.io/account
pat = "your-pat-here"
# Enable mutations on Nhost Cloud configurations
# When false, only queries are allowed
enable_mutations = true
# Local configuration for interacting with Nhost CLI projects
# Remove section to disable access
[local]
# Admin secret for local project authentication
admin_secret = "your-admin-secret"
# Optional: Custom config server URL
# Default: https://local.dashboard.local.nhost.run/v1/configserver/graphql
config_server_url = "your-custom-url"
# Optional: Custom GraphQL URL
# Default: https://local.graphql.local.nhost.run/v1
graphql_url = "your-custom-url"
# Project-specific configurations
[[projects]]
# Project subdomain (required)
subdomain = "your-project-subdomain"
# Project region (required)
region = "your-project-region"
# Authentication: Use either admin_secret or pat
# Admin secret for project access
admin_secret = "your-project-admin-secret"
# OR
# Project-specific PAT
pat = "your-project-pat"
# List of allowed GraphQL queries
# Use ["*"] to allow all queries, [] to disable all
allow_queries = ["*"]
# List of allowed GraphQL mutations
# Use ["*"] to allow all mutations, [] to disable all
# Only effective if mutations are enabled for the project
allow_mutations = ["*"]
```
## Example Configuration
```toml
[cloud]
pat = "1234567890abcdef"
enable_mutations = true
[local]
admin_secret = "nhost-admin-secret"
[[projects]]
subdomain = "my-app"
region = "eu-central-1"
admin_secret = "project-admin-secret"
allow_queries = ["*"]
allow_mutations = ["createUser", "updateUser"]
[[projects]]
subdomain = "another-app"
region = "us-east-1"
pat = "nhp_project_specific_pat"
allow_queries = ["getUsers", "getPosts"]
allow_mutations = []
```

View File

@@ -1,96 +0,0 @@
# NAME
nhost-mcp - Nhost's Model Context Protocol (MCP) server
# SYNOPSIS
nhost-mcp
```
[--help|-h]
[--version|-v]
```
**Usage**:
```
nhost-mcp [GLOBAL OPTIONS] [command [COMMAND OPTIONS]] [ARGUMENTS...]
```
# GLOBAL OPTIONS
**--help, -h**: show help
**--version, -v**: print the version
# COMMANDS
## docs
Generate markdown documentation for the CLI
**--help, -h**: show help
### help, h
Shows a list of commands or help for one command
## config
Generate and save configuration file
**--config-file**="": Configuration file path (default: /Users/dbarroso/.config/nhost/mcp-nhost.toml)
**--confirm**: Skip confirmation prompt
**--help, -h**: show help
### help, h
Shows a list of commands or help for one command
## start
Starts the MCP server
**--bind**="": Bind address in the form <host>:<port>. If omitted use stdio
**--config-file**="": Path to the config file (default: /Users/dbarroso/.config/nhost/mcp-nhost.toml)
**--help, -h**: show help
### help, h
Shows a list of commands or help for one command
## gen
Generate GraphQL schema for Nhost Cloud
**--help, -h**: show help
**--nhost-pat**="": Personal Access Token
**--with-mutations**: Include mutations in the generated schema
### help, h
Shows a list of commands or help for one command
## upgrade
Checks if there is a new version and upgrades it
**--confirm**: Confirm the upgrade without prompting
**--help, -h**: show help
### help, h
Shows a list of commands or help for one command
## help, h
Shows a list of commands or help for one command

View File

@@ -1,49 +0,0 @@
# Screenshots
Listing cloud projects:
<img src="screenshots/101-cloud-projects.png" width="600" alt="listing cloud projects">
Changing cloud project's configuration:
<img src="screenshots/102-cloud-project-config.png" width="600" alt="changing cloud project's configuration">
Querying cloud project's configuration:
<img src="screenshots/103-cloud-project-config2.png" width="600" alt="querying cloud project's configuration">
Querying local project's schema:
<img src="screenshots/201-local-schema.png" width="600" alt="querying local project's schema">
Generating code from local project's schema:
<img src="screenshots/202-local-code.png" alt="generating code from local project's schema">
Resulting code:
<img src="screenshots/203-result.png" alt="resulting code">
Querying local project's configuration:
<img src="screenshots/204-local-config-query.png" width="600" alt="querying local project's configuration">
Modifying local project's configuration:
<img src="screenshots/205-local-config-change.png" width="600" alt="modifying local project's configuration">
Querying cloud project's schema:
<img src="screenshots/301-project-schema.png" width="600" alt="project schema">
Querying cloud project's data:
<img src="screenshots/302-project-query.png" width="600" alt="project data">
Managing cloud project's data:
<img src="screenshots/303-project-mutation.png" width="600" alt="project mutation">
Analysing cloud project's data:
<img src="screenshots/304-project-data-analysis.png" width="600" alt="project data analysis">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 819 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

View File

@@ -3,6 +3,10 @@
name = 'GREET'
value = 'Sayonara'
[[global.environment]]
name = 'NODE_ENV'
value = 'production'
[hasura]
version = 'v2.46.0-ce'
adminSecret = '{{ secrets.HASURA_GRAPHQL_ADMIN_SECRET }}'
@@ -63,11 +67,6 @@ default = "00000000-0000-0000-0000-000000000000"
expiresIn = 2592000
[auth.method]
[auth.method.anonymous]
enabled = false
[auth.method.emailPasswordless]
enabled = false
[auth.method.emailPassword]
hibpEnabled = false
@@ -139,46 +138,11 @@ version = '14.18-20250728-1'
[postgres.resources.storage]
capacity = 1
[postgres.settings]
maxConnections = 100
sharedBuffers = '256MB'
effectiveCacheSize = '768MB'
maintenanceWorkMem = '64MB'
checkpointCompletionTarget = 0.9
walBuffers = '-1'
defaultStatisticsTarget = 100
randomPageCost = 1.1
effectiveIOConcurrency = 200
workMem = '1310kB'
hugePages = 'off'
minWalSize = '80MB'
maxWalSize = '1GB'
maxWorkerProcesses = 8
maxParallelWorkersPerGather = 2
maxParallelWorkers = 8
maxParallelMaintenanceWorkers = 2
[provider]
[storage]
version = '0.7.1'
[ai]
version = '0.8.0'
webhookSecret = '{{ secrets.GRAPHITE_WEBHOOK_SECRET }}'
[ai.resources]
[ai.resources.compute]
cpu = 125
memory = 256
[ai.openai]
organization = ''
apiKey = '{{ secrets.OPENAI_API_KEY }}'
[ai.autoEmbeddings]
synchPeriodMinutes = 5
[observability]
[observability.grafana]
adminPassword = '{{ secrets.GRAFANA_ADMIN_PASSWORD }}'

View File

@@ -44,7 +44,7 @@ if [[ "$version" == "latest" ]]; then
release=$(curl --silent https://api.github.com/repos/nhost/nhost/releases\?per_page=100 | grep tag_name | grep \"cli\@ | head -n 1 | sed 's/.*"tag_name": "\([^"]*\)".*/\1/')
version=$( echo $release | sed 's/.*@//')
else
release="cli@$release"
release="cli@$version"
fi
# check version exists

View File

@@ -1,58 +1,78 @@
package config
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/nhost/nhost/cli/clienv"
"github.com/nhost/nhost/cli/mcp/nhost/auth"
"github.com/pelletier/go-toml/v2"
"github.com/urfave/cli/v3"
)
func ptr[T any](v T) *T {
return &v
}
const (
DefaultLocalConfigServerURL = "https://local.dashboard.local.nhost.run/v1/configserver/graphql"
DefaultLocalGraphqlURL = "https://local.graphql.local.nhost.run/v1"
)
var ErrProjectNotConfigured = errors.New("project not configured")
type Config struct {
// If configured allows managing the cloud. For instance, this allows you to configure
// projects, list projects, organizations, and so on.
Cloud *Cloud `json:"cloud,omitempty" toml:"cloud"`
// If configured allows working with a local project running via the CLI. This includes
// configuring it, working with the schema, migrations, etc.
Local *Local `json:"local,omitempty" toml:"local"`
// Projects is a list of projects that you want to allow access to. This grants access to the
// GraphQL schema allowing it to inspect it and run allowed queries and mutations.
Projects []Project `json:"projects" toml:"projects"`
// Projects is a list of projects that you want to allow access to. This grants access
// to the GraphQL schema allowing it to inspect it and run allowed queries and mutations.
Projects ProjectList `json:"projects" toml:"projects"`
}
type Cloud struct {
// Personal Access Token to authenticate with the Nhost Cloud API. You can get one
// on the following URL: https://app.nhost.io/account
PAT string `json:"pat" toml:"pat"`
// If enabled you can run mutations against the Nhost Cloud to manipulate project's configurations
// amongst other things. Queries are always allowed if this section is configured.
EnableMutations bool `json:"enable_mutations" toml:"enable_mutations"`
}
type Local struct {
// Admin secret to use when running against a local project.
AdminSecret string `json:"admin_secret" toml:"admin_secret"`
type ProjectList []Project
// GraphQL URL to use when running against a local project.
// Defaults to "https://local.dashboard.local.nhost.run/v1/configserver/graphql"
ConfigServerURL *string `json:"config_server_url,omitempty" toml:"config_server_url,omitempty"`
func (pl ProjectList) Get(subdomain string) (*Project, error) {
for _, p := range pl {
if p.Subdomain == subdomain {
return &p, nil
}
}
// GraphQL URL to use when running against a local project.
// Defaults to "https://local.graphql.local.nhost.run/v1"
GraphqlURL *string `json:"graphql_url,omitempty" toml:"graphql_url,omitempty"`
return nil, fmt.Errorf("%w: %s", ErrProjectNotConfigured, subdomain)
}
func (pl ProjectList) Subdomains() []string {
subdomains := make([]string, 0, len(pl))
for _, p := range pl {
subdomains = append(subdomains, p.Subdomain)
}
return subdomains
}
func (pl ProjectList) Instructions() string {
if len(pl) == 0 {
return "No projects configured. Please, run `nhost mcp config` to configure your projects."
}
var sb strings.Builder
sb.WriteString("Configured projects:\n")
for _, p := range pl {
sb.WriteString(fmt.Sprintf("- %s (%s): %s\n", p.Subdomain, p.Region, p.Description))
}
return sb.String()
}
type Project struct {
@@ -62,6 +82,9 @@ type Project struct {
// Project's region
Region string `json:"region" toml:"region"`
// Project's description
Description string `json:"description,omitempty" toml:"description,omitempty"`
// Admin secret to operate against the project.
// Either admin secret or PAT is required.
AdminSecret *string `json:"admin_secret,omitempty" toml:"admin_secret,omitempty"`
@@ -70,6 +93,10 @@ type Project struct {
// Either admin secret or PAT is required.
PAT *string `json:"pat,omitempty" toml:"pat,omitempty"`
// If enabled, allows managing the project's metadata (tables, relationships,
// permissions, etc).
ManageMetadata bool `json:"manage_metadata,omitempty" toml:"manage_metadata,omitempty"`
// List of queries that are allowed to be executed against the project.
// If empty, no queries are allowed. Use [*] to allow all queries.
AllowQueries []string `json:"allow_queries" toml:"allow_queries"`
@@ -78,30 +105,80 @@ type Project struct {
// If empty, no mutations are allowed. Use [*] to allow all mutations.
// Note that this is only used if the project is configured to allow mutations.
AllowMutations []string `json:"allow_mutations" toml:"allow_mutations"`
// GraphQL URL to use when running against the project. Defaults to constructed URL with
// the subdomain and region.
GraphqlURL string `json:"graphql_url,omitzero" toml:"graphql_url,omitzero"`
// Auth URL to use when running against the project. Defaults to constructed URL with
// the subdomain and region.
AuthURL string `json:"auth_url,omitzero" toml:"auth_url,omitzero"`
// Hasura's base URL. Defaults to constructed URL with the subdomain and region.
HasuraURL string `json:"hasura_url,omitzero" toml:"hasura_url,omitzero"`
}
func GetConfigPath() string {
configHome := os.Getenv("XDG_CONFIG_HOME")
if configHome == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
return "mcp-nhost.toml"
}
configHome = filepath.Join(homeDir, ".config")
func (p *Project) GetAuthURL() string {
if p.AuthURL != "" {
return p.AuthURL
}
return filepath.Join(configHome, "nhost", "mcp-nhost.toml")
return fmt.Sprintf("https://%s.auth.%s.nhost.run/v1", p.Subdomain, p.Region)
}
func (p *Project) GetGraphqlURL() string {
if p.GraphqlURL != "" {
return p.GraphqlURL
}
return fmt.Sprintf("https://%s.graphql.%s.nhost.run/v1", p.Subdomain, p.Region)
}
func (p *Project) GetHasuraURL() string {
if p.HasuraURL != "" {
return p.HasuraURL
}
return fmt.Sprintf("https://%s.hasura.%s.nhost.run", p.Subdomain, p.Region)
}
func (p *Project) GetAuthInterceptor() (func(ctx context.Context, req *http.Request) error, error) {
if p.AdminSecret != nil {
return auth.WithAdminSecret(*p.AdminSecret), nil
} else if p.PAT != nil {
interceptor, err := auth.WithPAT(p.GetAuthURL(), *p.PAT)
if err != nil {
return nil, fmt.Errorf("failed to create PAT interceptor: %w", err)
}
return interceptor, nil
}
return func(_ context.Context, _ *http.Request) error {
return nil
}, nil
}
func GetConfigPath(cmd *cli.Command) string {
configPath := cmd.String("config-file")
if configPath != "" {
return configPath
}
ce := clienv.FromCLI(cmd)
return filepath.Join(ce.Path.DotNhostFolder(), "mcp-nhost.toml")
}
func Load(path string) (*Config, error) {
f, err := os.OpenFile(path, os.O_RDONLY, 0o600) //nolint:mnd
content, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to open config file: %w", err)
return nil, fmt.Errorf("failed to read config file: %w", err)
}
defer f.Close()
decoder := toml.NewDecoder(f)
interpolated := interpolateEnv(string(content), os.Getenv)
decoder := toml.NewDecoder(strings.NewReader(interpolated))
decoder.DisallowUnknownFields()
var config Config
@@ -120,15 +197,5 @@ func Load(path string) (*Config, error) {
return nil, fmt.Errorf("failed to unmarshal config file: %w", err)
}
if config.Local != nil {
if config.Local.GraphqlURL == nil {
config.Local.GraphqlURL = ptr(DefaultLocalGraphqlURL)
}
if config.Local.ConfigServerURL == nil {
config.Local.ConfigServerURL = ptr(DefaultLocalConfigServerURL)
}
}
return &config, nil
}

View File

@@ -0,0 +1,60 @@
package config
import "strings"
// interpolateEnv replaces environment variables in the format $VAR.
// Supports escaping $ with $$ or \$.
func interpolateEnv(s string, getenv func(string) string) string { //nolint:cyclop
var result strings.Builder
result.Grow(len(s))
for i := 0; i < len(s); i++ {
switch {
case s[i] == '\\' && i+1 < len(s) && s[i+1] == '$':
// Handle \$ escape sequence
result.WriteByte('$')
i++ // skip the $
case s[i] == '$' && i+1 < len(s) && s[i+1] == '$':
// Handle $$ escape sequence
result.WriteByte('$')
i++ // skip the second $
case s[i] == '$':
// Start of variable substitution
i++
if i >= len(s) {
result.WriteByte('$')
break
}
// Extract variable name
start := i
for i < len(s) && (isAlphaNumUnderscore(s[i])) {
i++
}
if i == start {
// No valid variable name found
result.WriteByte('$')
i--
} else {
varName := s[start:i]
if value := getenv(varName); value != "" {
result.WriteString(value)
}
i-- // Back up one because the loop will increment
}
default:
result.WriteByte(s[i])
}
}
return result.String()
}
func isAlphaNumUnderscore(c byte) bool {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'
}

View File

@@ -0,0 +1,256 @@
package config //nolint:testpackage
import (
"os"
"testing"
"github.com/google/go-cmp/cmp"
)
func TestInterpolateEnv(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
envVars map[string]string
expected string
}{
{
name: "simple variable substitution",
input: "admin_secret = \"$SECRET\"",
envVars: map[string]string{"SECRET": "mysecret"},
expected: "admin_secret = \"mysecret\"",
},
{
name: "multiple variables",
input: "$VAR1 and $VAR2",
envVars: map[string]string{"VAR1": "hello", "VAR2": "world"},
expected: "hello and world",
},
{
name: "variable with underscores",
input: "$MY_VAR_123",
envVars: map[string]string{"MY_VAR_123": "value"},
expected: "value",
},
{
name: "escaped with $$",
input: "price = $$100",
envVars: map[string]string{},
expected: "price = $100",
},
{
name: "escaped with backslash",
input: "price = \\$100",
envVars: map[string]string{},
expected: "price = $100",
},
{
name: "mix of escaped and variable",
input: "$$SECRET is $SECRET",
envVars: map[string]string{"SECRET": "hidden"},
expected: "$SECRET is hidden",
},
{
name: "undefined variable",
input: "value = $UNDEFINED",
envVars: map[string]string{},
expected: "value = ",
},
{
name: "variable at end",
input: "end$VAR",
envVars: map[string]string{"VAR": "value"},
expected: "endvalue",
},
{
name: "dollar sign alone at end",
input: "end$",
envVars: map[string]string{},
expected: "end$",
},
{
name: "dollar sign with non-alphanum",
input: "$ hello",
envVars: map[string]string{},
expected: "$ hello",
},
{
name: "no variables",
input: "plain text without variables",
envVars: map[string]string{},
expected: "plain text without variables",
},
{
name: "empty string",
input: "",
envVars: map[string]string{},
expected: "",
},
{
name: "multiple escapes in a row",
input: "$$$$",
envVars: map[string]string{},
expected: "$$",
},
{
name: "variable surrounded by text",
input: "prefix$VAR suffix",
envVars: map[string]string{"VAR": "middle"},
expected: "prefixmiddle suffix",
},
{
name: "backslash escape followed by variable",
input: "\\$100 costs $PRICE",
envVars: map[string]string{"PRICE": "$50"},
expected: "$100 costs $50",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Create isolated getenv function
getenv := func(key string) string {
return tt.envVars[key]
}
result := interpolateEnv(tt.input, getenv)
if result != tt.expected {
t.Errorf("interpolateEnv() = %q, want %q", result, tt.expected)
}
})
}
}
func TestInterpolateEnvRealWorld(t *testing.T) {
t.Parallel()
envVars := map[string]string{
"ADMIN_SECRET": "super-secret-key",
"SUBDOMAIN": "myapp",
}
getenv := func(key string) string {
return envVars[key]
}
input := `[[projects]]
subdomain = "local"
region = "local"
admin_secret = "$ADMIN_SECRET"
[[projects]]
subdomain = "$SUBDOMAIN"
admin_secret = "$ADMIN_SECRET"
# Price is $$100
`
expected := `[[projects]]
subdomain = "local"
region = "local"
admin_secret = "super-secret-key"
[[projects]]
subdomain = "myapp"
admin_secret = "super-secret-key"
# Price is $100
`
result := interpolateEnv(input, getenv)
if result != expected {
t.Errorf("interpolateEnv() = %q, want %q", result, expected)
}
}
func TestIsAlphaNumUnderscore(t *testing.T) {
t.Parallel()
tests := []struct {
char byte
expected bool
}{
{'a', true},
{'z', true},
{'A', true},
{'Z', true},
{'0', true},
{'9', true},
{'_', true},
{'-', false},
{'.', false},
{'$', false},
{' ', false},
{'/', false},
}
for _, tt := range tests {
t.Run(string(tt.char), func(t *testing.T) {
t.Parallel()
result := isAlphaNumUnderscore(tt.char)
if result != tt.expected {
t.Errorf("isAlphaNumUnderscore(%q) = %v, want %v", tt.char, result, tt.expected)
}
})
}
}
func ptr[T any](v T) *T {
return &v
}
func TestLoadWithInterpolation(t *testing.T) {
// Create a temporary config file
content := `[[projects]]
admin_secret = "$TEST_ADMIN_SECRET"
[[projects]]
subdomain = "myapp"
region = "us-east-1"
admin_secret = "$TEST_PROJECT_SECRET"
allow_queries = ["*"]
`
tmpfile, err := os.CreateTemp(t.TempDir(), "config-*.toml")
if err != nil {
t.Fatal(err)
}
defer os.Remove(tmpfile.Name())
if _, err := tmpfile.WriteString(content); err != nil {
t.Fatal(err)
}
if err := tmpfile.Close(); err != nil {
t.Fatal(err)
}
// Set environment variables
t.Setenv("TEST_ADMIN_SECRET", "local-secret")
t.Setenv("TEST_PROJECT_SECRET", "project-secret")
// Load config
cfg, err := Load(tmpfile.Name())
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if diff := cmp.Diff(cfg, &Config{
Cloud: nil,
Projects: ProjectList{
{ //nolint:exhaustruct
AdminSecret: ptr("local-secret"),
},
{ //nolint:exhaustruct
Subdomain: "myapp",
Region: "us-east-1",
AdminSecret: ptr("project-secret"),
AllowQueries: []string{"*"},
},
},
}); diff != "" {
t.Errorf("diff = %s", diff)
}
}

View File

@@ -25,11 +25,14 @@ func RunWizard() (*Config, error) {
projects := wizardProject(reader)
if localConfig != nil {
projects = append(projects, *localConfig)
}
fmt.Println()
return &Config{
Cloud: cloudConfig,
Local: localConfig,
Projects: projects,
}, nil
}
@@ -41,13 +44,9 @@ func wizardCloud(reader *bufio.Reader) *Cloud {
fmt.Println(" You can view and configure projects as you would in the dashboard.")
if promptYesNo(reader, "Enable Nhost Cloud access?") {
pat := promptString(
reader,
"Enter Personal Access Token (from https://app.nhost.io/account):",
)
fmt.Println(" Note: If you haven't already, run `nhost login` to authenticate.")
return &Cloud{
PAT: pat,
EnableMutations: true,
}
}
@@ -56,7 +55,7 @@ func wizardCloud(reader *bufio.Reader) *Cloud {
}
//nolint:forbidigo
func wizardLocal(reader *bufio.Reader) *Local {
func wizardLocal(reader *bufio.Reader) *Project {
fmt.Println("2. Local Development Access")
fmt.Println(" This allows LLMs to interact with your local Nhost environment,")
fmt.Println(" including project configuration and GraphQL API access.")
@@ -68,10 +67,18 @@ func wizardLocal(reader *bufio.Reader) *Local {
adminSecret = "nhost-admin-secret" //nolint:gosec
}
return &Local{
AdminSecret: adminSecret,
ConfigServerURL: nil,
GraphqlURL: nil,
return &Project{
Subdomain: "local",
Region: "local",
Description: "Local development project running via the Nhost CLI",
AdminSecret: &adminSecret,
PAT: nil,
ManageMetadata: true,
AllowQueries: []string{"*"},
AllowMutations: []string{"*"},
AuthURL: "",
GraphqlURL: "",
HasuraURL: "",
}
}
@@ -95,16 +102,29 @@ func wizardProject(reader *bufio.Reader) []Project {
if promptYesNo(reader, "Configure project access?") {
for {
project := Project{
Description: "",
Subdomain: "",
Region: "",
AdminSecret: nil,
PAT: nil,
ManageMetadata: false,
AllowQueries: []string{"*"},
AllowMutations: []string{"*"},
GraphqlURL: "",
AuthURL: "",
HasuraURL: "",
}
project.Subdomain = promptString(reader, "Project subdomain:")
project.Region = promptString(reader, "Project region:")
project.Description = promptString(
reader,
"Project description to provide additional information to LLMs:",
)
project.ManageMetadata = promptYesNo(
reader,
"Allow managing metadata (tables, relationships, permissions, etc)?",
)
authType := promptChoice(
reader,

View File

@@ -1,6 +1,7 @@
package graphql
import (
"encoding/json"
"fmt"
"sort"
"strings"
@@ -30,7 +31,7 @@ func getTypeName(t Type) string {
}
// ParseSchema converts an introspection query result into a GraphQL SDL string.
func ParseSchema(response ResponseIntrospection, filter Filter) string {
func ParseSchema(response ResponseIntrospection, filter Filter) string { //nolint:cyclop
availableTypes := make(map[string]Type)
// Process all types in the schema
@@ -58,6 +59,9 @@ func ParseSchema(response ResponseIntrospection, filter Filter) string {
}
neededMutations := make(map[string]Field)
if response.Data.Schema.MutationType == nil {
return render(neededQueries, neededMutations, neededTypes)
}
for _, mutation := range response.Data.Schema.MutationType.Fields {
if filter.AllowMutations == nil {
@@ -83,6 +87,30 @@ func ParseSchema(response ResponseIntrospection, filter Filter) string {
return render(neededQueries, neededMutations, neededTypes)
}
func SummarizeSchema(response ResponseIntrospection) string {
summary := map[string][]string{
"query": make([]string, len(response.Data.Schema.QueryType.Fields)),
}
for i, query := range response.Data.Schema.QueryType.Fields {
summary["query"][i] = query.Name
}
if response.Data.Schema.MutationType != nil {
summary["mutation"] = make([]string, len(response.Data.Schema.MutationType.Fields))
for _, mutation := range response.Data.Schema.MutationType.Fields {
summary["mutation"] = append(summary["mutation"], mutation.Name)
}
}
b, err := json.MarshalIndent(summary, "", " ")
if err != nil {
return fmt.Sprintf("failed to marshal summary: %v", err)
}
return string(b)
}
func filterNestedArgs(
args []InputValue, neededTypes map[string]Type,
) []InputValue {

View File

@@ -24,6 +24,10 @@ func checkAllowedOperation(
selectionSet ast.SelectionSet,
allowed []string,
) error {
if slices.Contains(allowed, "*") {
return nil
}
for _, v := range selectionSet {
if v, ok := v.(*ast.Field); ok {
if len(v.SelectionSet) > 0 && !slices.Contains(allowed, v.Name) {
@@ -45,8 +49,8 @@ func CheckAllowedGraphqlQuery( //nolint:cyclop
queryString string,
) error {
if allowedQueries == nil && allowedMutations == nil {
// nil means unrestricted
return nil
// nil means nothing allowed
return fmt.Errorf("%w: %s", ErrQueryNotAllowed, queryString)
}
if len(allowedQueries) == 0 && len(allowedMutations) == 0 {

View File

@@ -22,7 +22,21 @@ func TestCheckAllowedGraphqlQuery(t *testing.T) {
query: `query { user(id: 1) { name } }`,
allowedQueries: nil,
allowedMutations: nil,
expectedError: nil,
expectedError: graphql.ErrQueryNotAllowed,
},
{
name: "nil,",
query: `query { user(id: 1) { name } }`,
allowedQueries: nil,
allowedMutations: []string{"user"},
expectedError: graphql.ErrQueryNotAllowed,
},
{
name: ",nil",
query: `mutation { user(id: 1) { name } }`,
allowedQueries: []string{"user"},
allowedMutations: nil,
expectedError: graphql.ErrQueryNotAllowed,
},
{
name: "no query allowed",

View File

@@ -1,6 +1,6 @@
// Package auth provides primitives to interact with the openapi HTTP API.
//
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version 2.4.1 DO NOT EDIT.
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version 2.5.0 DO NOT EDIT.
package auth
import (

View File

@@ -1,6 +1,6 @@
// Package graphql provides primitives to interact with the openapi HTTP API.
//
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version 2.4.1 DO NOT EDIT.
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version 2.5.0 DO NOT EDIT.
package graphql
import (

View File

@@ -0,0 +1,69 @@
package resources
import (
"context"
_ "embed"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/nhost/nhost/cli/mcp/config"
)
//go:embed cloud_schema.graphql
var schemaGraphql string
//go:embed cloud_schema-with-mutations.graphql
var schemaGraphqlWithMutations string
const (
CloudResourceURI = "schema://nhost-cloud"
CloudDescription = `Schema to interact with the Nhost Cloud. Projects are equivalent
to apps in the schema. IDs are typically uuids.`
)
type Cloud struct {
schema string
}
func NewCloud(cfg *config.Config) *Cloud {
schema := schemaGraphql
if cfg.Cloud.EnableMutations {
schema = schemaGraphqlWithMutations
}
return &Cloud{
schema: schema,
}
}
func (t *Cloud) Register(server *server.MCPServer) {
server.AddResource(
mcp.Resource{
URI: CloudResourceURI,
Name: "nhost-cloud",
Annotated: mcp.Annotated{
Annotations: &mcp.Annotations{
Audience: []mcp.Role{"agent"},
Priority: 9.0, //nolint:mnd
},
},
Description: CloudDescription,
MIMEType: "text/plain",
Meta: nil,
},
t.handle,
)
}
func (t *Cloud) handle(
_ context.Context, request mcp.ReadResourceRequest,
) ([]mcp.ResourceContents, error) {
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: request.Params.URI,
MIMEType: "text/plain",
Text: t.schema,
Meta: nil,
},
}, nil
}

View File

@@ -0,0 +1,54 @@
package resources
import (
"context"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/nhost/nhost/cli/mcp/nhost/graphql"
)
const (
GraphqlManagementResourceURI = "schema://graphql-management"
GraphqlManagementDescription = `GraphQL's management schema for an Nhost project.
This tool is useful to properly understand how manage hasura metadata, migrations,
permissions, remote schemas, etc.`
)
type GraphqlManagement struct{}
func NewGraphqlManagement() *GraphqlManagement {
return &GraphqlManagement{}
}
func (t *GraphqlManagement) Register(server *server.MCPServer) {
server.AddResource(
mcp.Resource{
URI: GraphqlManagementResourceURI,
Name: "graphql-management",
Annotated: mcp.Annotated{
Annotations: &mcp.Annotations{
Audience: []mcp.Role{"agent"},
Priority: 9.0, //nolint:mnd
},
},
Description: GraphqlManagementDescription,
MIMEType: "text/plain",
Meta: nil,
},
t.handle,
)
}
func (t *GraphqlManagement) handle(
_ context.Context, request mcp.ReadResourceRequest,
) ([]mcp.ResourceContents, error) {
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: request.Params.URI,
MIMEType: "text/plain",
Text: graphql.Schema,
Meta: nil,
},
}, nil
}

View File

@@ -0,0 +1,57 @@
package resources
import (
"context"
_ "embed"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
//go:embed nhost_toml_schema.cue
var schemaNhostToml string
const (
NhostTomlResourceURI = "schema://nhost.toml"
NhostTomlResourceDescription = `Cuelang schema for the nhost.toml configuration file. Run nhost
config validate after making changes to your nhost.toml file to ensure it is valid.`
)
type NhostToml struct{}
func NewNhostToml() *NhostToml {
return &NhostToml{}
}
func (t *NhostToml) Register(server *server.MCPServer) {
server.AddResource(
mcp.Resource{
URI: NhostTomlResourceURI,
Name: "nhost.toml",
Annotated: mcp.Annotated{
Annotations: &mcp.Annotations{
Audience: []mcp.Role{"agent"},
Priority: 9.0, //nolint:mnd
},
},
Description: NhostTomlResourceDescription,
MIMEType: "text/plain",
Meta: nil,
},
t.handle,
)
}
//go:generate cp ../../../vendor/github.com/nhost/be/services/mimir/schema/schema.cue nhost_toml_schema.cue
func (t *NhostToml) handle(
_ context.Context, request mcp.ReadResourceRequest,
) ([]mcp.ResourceContents, error) {
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: request.Params.URI,
MIMEType: "text/plain",
Text: schemaNhostToml,
Meta: nil,
},
}, nil
}

View File

@@ -0,0 +1,812 @@
package schema
import (
"list"
"math"
"net"
"strings"
"time"
)
// main entrypoint to the configuration
#Config: {
// Global configuration that applies to all services
global: #Global
// Configuration for hasura
hasura: #Hasura
// Advanced configuration for GraphQL
graphql?: #Graphql
// Configuration for functions service
functions: #Functions
// Configuration for auth service
auth: #Auth
// Configuration for postgres service
postgres: #Postgres
// Configuration for third party providers like SMTP, SMS, etc.
provider: #Provider
// Configuration for storage service
storage: #Storage
// Configuration for graphite service
ai?: #AI
// Configuration for observability service
observability: #Observability
_totalResourcesCPU: (
hasura.resources.replicas*hasura.resources.compute.cpu +
auth.resources.replicas*auth.resources.compute.cpu +
storage.resources.replicas*storage.resources.compute.cpu +
postgres.resources.compute.cpu) @cuegraph(skip)
_totalResourcesMemory: (
hasura.resources.replicas*hasura.resources.compute.memory +
auth.resources.replicas*auth.resources.compute.memory +
storage.resources.replicas*storage.resources.compute.memory +
postgres.resources.compute.memory) @cuegraph(skip)
_validateResourcesTotalCpuMemoryRatioMustBe1For2: (
_totalResourcesCPU*2.048 & _totalResourcesMemory*1.0) @cuegraph(skip)
_validateResourcesTotalCpuMin1000: (
hasura.resources.compute.cpu+
auth.resources.compute.cpu+
storage.resources.compute.cpu+
postgres.resources.compute.cpu) >= 1000 & true @cuegraph(skip)
_validateAllResourcesAreSetOrNot: (
((hasura.resources.compute != _|_) == (auth.resources.compute != _|_)) &&
((auth.resources.compute != _|_) == (storage.resources.compute != _|_)) &&
((storage.resources.compute != _|_) == (postgres.resources.compute != _|_))) & true @cuegraph(skip)
_validateNetworkingMustBeNullOrNotSet: !storage.resources.networking | storage.resources.networking == null @cuegraph(skip)
_isProviderSMTPSet: provider.smtp != _|_ @cuegraph(skip)
_isAuthRateLimitEmailsDefault: auth.rateLimit.emails.limit == 10 && auth.rateLimit.emails.interval == "1h" @cuegraph(skip)
_validateAuthRateLimitEmailsIsDefaultOrSMTPSettingsSet: (_isProviderSMTPSet | _isAuthRateLimitEmailsDefault) & true @cuegraph(skip)
}
// Global configuration that applies to all services
#Global: {
// User-defined environment variables that are spread over all services
environment: [...#GlobalEnvironmentVariable] | *[]
}
#GlobalEnvironmentVariable: {
// Name of the environment variable
name: =~"(?i)^[a-z_]{1,}[a-z0-9_]*" & !~"(?i)^NHOST_" & !~"(?i)^HASURA_"
// Value of the environment variable
value: string
}
#Graphql: {
security: #GraphqlSecurity
}
#GraphqlSecurity: {
forbidAminSecret: bool | *false
maxDepthQueries: uint | *0 // 0 disables the check
}
#Networking: {
ingresses: [#Ingress] | *[]
}
#Ingress: {
fqdn: [string & net.FQDN & strings.MinRunes(1) & strings.MaxRunes(63)]
tls?: {
clientCA?: string
}
}
#Autoscaler: {
maxReplicas: uint8 & >=2 & <=100
}
// Resource configuration for a service
#Resources: {
compute?: #ResourcesCompute
// Number of replicas for a service
replicas: uint8 & >=1 & <=10 | *1
autoscaler?: #Autoscaler
_validateReplicasMustBeSmallerThanMaxReplicas: (replicas <= autoscaler.maxReplicas) & true @cuegraph(skip)
_validateMultipleReplicasNeedsCompute: (
replicas == 1 && autoscaler == _|_ |
compute != _|_) & true @cuegraph(skip)
_validateMultipleReplicasRatioMustBe1For2: (
replicas == 1 && autoscaler == _|_ |
(compute.cpu*2.048 == compute.memory)) & true @cuegraph(skip)
networking?: #Networking | null
}
#ResourcesCompute: {
// milicpus, 1000 milicpus = 1 cpu
cpu: uint32 & >=250 & <=30000
// MiB: 128MiB to 30GiB
memory: uint32 & >=128 & <=62464
// validate CPU steps of 250 milicpus
_validateCPUSteps250: (mod(cpu, 250) == 0) & true @cuegraph(skip)
// validate memory steps of 128 MiB
_validateMemorySteps128: (mod(memory, 128) == 0) & true @cuegraph(skip)
}
// Configuration for hasura service
#Hasura: {
// Version of hasura, you can see available versions in the URL below:
// https://hub.docker.com/r/hasura/graphql-engine/tags
version: string | *"v2.48.5-ce"
// JWT Secrets configuration
jwtSecrets: [#JWTSecret]
// Admin secret
adminSecret: string
// Webhook secret
webhookSecret: string
// Configuration for hasura services
// Reference: https://hasura.io/docs/latest/deployment/graphql-engine-flags/reference/
settings: {
// HASURA_GRAPHQL_CORS_DOMAIN
corsDomain: [...#Url] | *["*"]
// HASURA_GRAPHQL_DEV_MODE
devMode: bool | *true
// HASURA_GRAPHQL_ENABLE_ALLOWLIST
enableAllowList: bool | *false
// HASURA_GRAPHQL_ENABLE_CONSOLE
enableConsole: bool | *true
// HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS
enableRemoteSchemaPermissions: bool | *false
// HASURA_GRAPHQL_ENABLED_APIS
enabledAPIs: [...#HasuraAPIs] | *["metadata", "graphql", "pgdump", "config"]
// HASURA_GRAPHQL_INFER_FUNCTION_PERMISSIONS
inferFunctionPermissions: bool | *true
// HASURA_GRAPHQL_LIVE_QUERIES_MULTIPLEXED_REFETCH_INTERVAL
liveQueriesMultiplexedRefetchInterval: uint32 | *1000
// HASURA_GRAPHQL_STRINGIFY_NUMERIC_TYPES
stringifyNumericTypes: bool | *false
}
authHook?: {
// HASURA_GRAPHQL_AUTH_HOOK
url: string
// HASURA_GRAPHQL_AUTH_HOOK_MODE
mode: "GET" | *"POST"
// HASURA_GRAPHQL_AUTH_HOOK_SEND_REQUEST_BODY
sendRequestBody: bool | *true
}
logs: {
// HASURA_GRAPHQL_LOG_LEVEL
level: "debug" | "info" | "error" | *"warn"
}
events: {
// HASURA_GRAPHQL_EVENTS_HTTP_POOL_SIZE
httpPoolSize: uint32 & >=1 & <=100 | *100
}
// Resources for the service
resources?: #Resources
rateLimit?: #RateLimit
}
// APIs for hasura
#HasuraAPIs: "metadata" | "graphql" | "pgdump" | "config"
// Configuration for storage service
#Storage: {
// Version of storage service, you can see available versions in the URL below:
// https://hub.docker.com/r/nhost/hasura-storage/tags
//
// Releases:
//
// https://github.com/nhost/hasura-storage/releases
version: string | *"0.9.1"
// Networking (custom domains at the moment) are not allowed as we need to do further
// configurations in the CDN. We will enable it again in the future.
resources?: #Resources & {networking?: null}
antivirus?: {
server: "tcp://run-clamav:3310"
}
rateLimit?: #RateLimit
}
// Configuration for functions service
#Functions: {
node: {
version: 20 | *22
}
resources?: {
networking?: #Networking
}
rateLimit?: #RateLimit
}
// Configuration for postgres service
#Postgres: {
// Version of postgres, you can see available versions in the URL below:
// https://hub.docker.com/r/nhost/postgres/tags
version: string | *"14.18-20250728-1"
// Resources for the service
resources: {
compute?: #ResourcesCompute
storage: {
capacity: uint32 & >=1 & <=1000 // GiB
}
replicas?: 1
enablePublicAccess?: bool | *false
}
settings?: {
jit: "off" | "on" | *"on"
maxConnections: int32 | *100
sharedBuffers: string | *"128MB"
effectiveCacheSize: string | *"4GB"
maintenanceWorkMem: string | *"64MB"
checkpointCompletionTarget: number | *0.9
walBuffers: string | *"-1"
defaultStatisticsTarget: int32 | *100
randomPageCost: number | *4.0
effectiveIOConcurrency: int32 | *1
workMem: string | *"4MB"
hugePages: string | *"try"
minWalSize: string | *"80MB"
maxWalSize: string | *"1GB"
maxWorkerProcesses: int32 | *8
maxParallelWorkersPerGather: int32 | *2
maxParallelWorkers: int32 | *8
maxParallelMaintenanceWorkers: int32 | *2
walLevel: string | *"replica"
maxWalSenders: int32 | *10
maxReplicationSlots: int32 | *10
archiveTimeout: int32 & >=300 & <=1073741823 | *300
trackIoTiming: "on" | *"off"
// if pitr is on we need walLevel to set to replica or logical
_validateWalLevelIsLogicalOrReplicaIfPitrIsEnabled: ( pitr == _|_ | walLevel == "replica" | walLevel == "logical") & true @cuegraph(skip)
}
pitr?: {
retention: uint8 & 7
}
}
// Configuration for auth service
// You can find more information about the configuration here:
// https://github.com/nhost/hasura-auth/blob/main/docs/environment-variables.md
#Auth: {
// Version of auth, you can see available versions in the URL below:
// https://hub.docker.com/r/nhost/hasura-auth/tags
//
// Releases:
//
// https://github.com/nhost/hasura-auth/releases
version: string | *"0.43.0"
// Resources for the service
resources?: #Resources
elevatedPrivileges: {
mode: "recommended" | "required" | *"disabled"
}
redirections: {
// AUTH_CLIENT_URL
clientUrl: #Url | *"http://localhost:3000"
// AUTH_ACCESS_CONTROL_ALLOWED_REDIRECT_URLS
allowedUrls: [...string]
}
signUp: {
// Inverse of AUTH_DISABLE_SIGNUP
enabled: bool | *true
// AUTH_DISABLE_NEW_USERS
disableNewUsers: bool | *false
turnstile?: {
secretKey: string
}
}
user: {
roles: {
// AUTH_USER_DEFAULT_ROLE
default: #UserRole | *"user"
// AUTH_USER_DEFAULT_ALLOWED_ROLES
allowed: [...#UserRole] | *[default, "me"]
}
locale: {
// AUTH_LOCALE_DEFAULT
default: #Locale | *"en"
// AUTH_LOCALE_ALLOWED_LOCALES
allowed: [...#Locale] | *[default]
}
gravatar: {
// AUTH_GRAVATAR_ENABLED
enabled: bool | *true
// AUTH_GRAVATAR_DEFAULT
default: "404" | "mp" | "identicon" | "monsterid" | "wavatar" | "retro" | "robohash" | *"blank"
// AUTH_GRAVATAR_RATING
rating: "pg" | "r" | "x" | *"g"
}
email: {
// AUTH_ACCESS_CONTROL_ALLOWED_EMAILS
allowed: [...#Email]
// AUTH_ACCESS_CONTROL_BLOCKED_EMAILS
blocked: [...#Email]
}
emailDomains: {
// AUTH_ACCESS_CONTROL_ALLOWED_EMAIL_DOMAINS
allowed: [...string & net.FQDN]
// AUTH_ACCESS_CONTROL_BLOCKED_EMAIL_DOMAINS
blocked: [...string & net.FQDN]
}
}
session: {
accessToken: {
// AUTH_ACCESS_TOKEN_EXPIRES_IN
expiresIn: uint32 | *900
// AUTH_JWT_CUSTOM_CLAIMS
customClaims: [...{
key: =~"[a-zA-Z_]{1,}[a-zA-Z0-9_]*"
value: string
default?: string
}] | *[]
}
refreshToken: {
// AUTH_REFRESH_TOKEN_EXPIRES_IN
expiresIn: uint32 | *2592000
}
}
method: {
anonymous: {
enabled: bool | *false
}
emailPasswordless: {
enabled: bool | *false
}
otp: {
email: {
enabled: bool | *false
}
}
emailPassword: {
// Disabling email+password sign in is not implmented yet
// enabled: bool | *true
hibpEnabled: bool | *false
emailVerificationRequired: bool | *true
passwordMinLength: uint8 & >=3 | *9
}
smsPasswordless: {
enabled: bool | *false
}
oauth: {
apple: {
enabled: bool | *false
if enabled {
clientId: string
keyId: string
teamId: string
privateKey: string
}
if !enabled {
clientId?: string
keyId?: string
teamId?: string
privateKey?: string
}
audience?: string
scope?: [...string]
}
azuread: {
#StandardOauthProvider
tenant: string | *"common"
}
bitbucket: #StandardOauthProvider
discord: #StandardOauthProviderWithScope
entraid: {
#StandardOauthProvider
tenant: string | *"common"
}
facebook: #StandardOauthProviderWithScope
github: #StandardOauthProviderWithScope
gitlab: #StandardOauthProviderWithScope
google: #StandardOauthProviderWithScope
linkedin: #StandardOauthProviderWithScope
spotify: #StandardOauthProviderWithScope
strava: #StandardOauthProviderWithScope
twitch: #StandardOauthProviderWithScope
twitter: {
enabled: bool | *false
if enabled {
consumerKey: string
consumerSecret: string
}
if !enabled {
consumerKey?: string
consumerSecret?: string
}
}
windowslive: #StandardOauthProviderWithScope
workos: {
#StandardOauthProvider
connection?: string
organization?: string
}
}
webauthn: {
enabled: bool | *false
relyingParty?: {
id: string | *""
name?: string
origins?: [...#Url] | *[redirections.clientUrl]
}
attestation: {
timeout: uint32 | *60000
}
}
}
totp: {
enabled: bool | *false
if enabled {
issuer: string
}
if !enabled {
issuer?: string
}
}
misc: {
concealErrors: bool | *false
}
rateLimit: #AuthRateLimit
}
#RateLimit: {
limit: uint32
interval: string & time.Duration
}
#AuthRateLimit: {
emails: #RateLimit | *{limit: 10, interval: "1h"}
sms: #RateLimit | *{limit: 10, interval: "1h"}
bruteForce: #RateLimit | *{limit: 10, interval: "5m"}
signups: #RateLimit | *{limit: 10, interval: "5m"}
global: #RateLimit | *{limit: 100, interval: "1m"}
}
#StandardOauthProvider: {
enabled: bool | *false
if enabled {
clientId: string
clientSecret: string
}
if !enabled {
clientId?: string
clientSecret?: string
}
}
#StandardOauthProviderWithScope: {
enabled: bool | *false
if enabled {
clientId: string
clientSecret: string
}
if !enabled {
clientId?: string
clientSecret?: string
}
audience?: string
scope?: [...string]
}
#Provider: {
smtp?: #Smtp
sms?: #Sms
}
#Smtp: {
user: string
password: string
sender: string
host: string & net.FQDN | net.IP
port: #Port
secure: bool
method: "LOGIN" | "CRAM-MD5" | "PLAIN"
}
#Sms: {
provider: "twilio"
accountSid: string
authToken: string
messagingServiceId: string
}
#UserRole: string
#Url: string
#Port: uint16
#Email: =~"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
#Locale: string & strings.MinRunes(2) & strings.MaxRunes(2)
// See https://hasura.io/docs/latest/auth/authentication/jwt/
#JWTSecret:
({
type: "HS384" | "HS512" | *"HS256"
key: string
} |
{
type: "RS256" | "RS384" | "RS512"
key: string
signingKey?: string
kid?: string
} |
{
jwk_url: #Url | *null
}) &
{
claims_format?: "stringified_json" | *"json"
audience?: string
issuer?: string
allowed_skew?: uint32
header?: string
} & {
claims_map?: [...#ClaimMap]
} &
({
claims_namespace: string | *"https://hasura.io/jwt/claims"
} |
{
claims_namespace_path: string
} | *{})
#ClaimMap: {
claim: string
{
value: string
} | {
path: string
default?: string
}
} & {}
#SystemConfig: {
auth: {
email: {
templates: {
s3Key?: string
}
}
}
graphql: {
// manually enable graphi on a per-service basis
// by default it follows the plan
featureAdvancedGraphql: bool | *false
}
postgres: {
enabled: bool | *true
majorVersion: "14" | "15" | "16" | "17" | *"14"
if enabled {
database: string
}
if !enabled {
database?: string
}
connectionString: {
backup: string
hasura: string
auth: string
storage: string
}
disk?: {
iops: uint32 | *3000
tput: uint32 | *125
}
encryptColumnKey?: string & =~"^[0-9a-fA-F]{64}$" // 32 bytes hex-encoded key
oldEncryptColumnKey?: string & =~"^[0-9a-fA-F]{64}$" // for key rotation
}
persistentVolumesEncrypted: bool | *false
}
#AI: {
version: string | *"0.8.0"
resources: {
compute: #ComputeResources
}
openai: {
organization?: string
apiKey: string
}
autoEmbeddings: {
synchPeriodMinutes: uint32 | *5
}
webhookSecret: string
}
#Observability: {
grafana: #Grafana
}
#Grafana: {
adminPassword: string
smtp?: {
host: string & net.FQDN | net.IP
port: #Port
sender: string
user: string
password: string
}
alerting: {
enabled: bool | *false
}
contacts: {
emails?: [...string]
pagerduty?: [...{
integrationKey: string
severity: string
class: string
component: string
group: string
}]
discord?: [...{
url: string
avatarUrl: string
}]
slack?: [...{
recipient: string
token: string
username: string
iconEmoji: string
iconURL: string
mentionUsers: [...string]
mentionGroups: [...string]
mentionChannel: string
url: string
endpointURL: string
}]
webhook?: [...{
url: string
httpMethod: string
username: string
password: string
authorizationScheme: string
authorizationCredentials: string
maxAlerts: int
}]
}
}
#RunServicePort: {
port: #Port
type: "http" | "grpc" | "tcp" | "udp"
publish: bool | *false
ingresses: [#Ingress] | *[]
_publish_supported_only_over_http: (
publish == false || type == "http" || type == "grpc" ) & true @cuegraph(skip)
rateLimit?: #RateLimit
}
#RunServiceName: =~"^[a-z]([-a-z0-9]*[a-z0-9])?$" & strings.MinRunes(1) & strings.MaxRunes(30)
// Resource configuration for a service
#ComputeResources: {
// milicpus, 1000 milicpus = 1 cpu
cpu: uint32 & >=62 & <=14000
// MiB: 128MiB to 30GiB
memory: uint32 & >=128 & <=28720
// validate memory steps of 128 MiB
_validateMemorySteps128: (mod(memory, 128) == 0) & true @cuegraph(skip)
}
// Resource configuration for a service
#RunServiceResources: {
compute: #ComputeResources
storage: [...{
name: #RunServiceName // name of the volume, changing it will cause data loss
capacity: uint32 & >=1 & <=1000 // GiB
path: string
}] | *[]
_storage_name_must_be_unique: list.UniqueItems([for s in storage {s.name}]) & true @cuegraph(skip)
_storage_path_must_be_unique: list.UniqueItems([for s in storage {s.path}]) & true @cuegraph(skip)
// Number of replicas for a service
replicas: uint8 & <=10
autoscaler?: #Autoscaler
_validateReplicasMustBeSmallerThanMaxReplicas: (replicas <= autoscaler.maxReplicas) & true @cuegraph(skip)
_replcas_cant_be_greater_than_1_when_using_storage: (len(storage) == 0 | (len(storage) > 0 & replicas <= 1 && autoscaler == _|_)) & true @cuegraph(skip)
_validate_cpu_memory_ratio_must_be_1_for_2: (math.Abs(compute.memory-compute.cpu*2.048) <= 1.024) & true @cuegraph(skip)
}
#RunServiceImage: {
image: string
// content of "auths", i.e., { "auths": $THIS }
pullCredentials?: string
}
#HealthCheck: {
port: #Port
initialDelaySeconds: int | *30
probePeriodSeconds: int | *60
}
#EnvironmentVariable: {
// Name of the environment variable
name: =~"(?i)^[a-z_]{1,}[a-z0-9_]*"
// Value of the environment variable
value: string
}
#RunServiceConfig: {
name: #RunServiceName
image: #RunServiceImage
command: [...string]
environment: [...#EnvironmentVariable] | *[]
ports?: [...#RunServicePort] | *[]
resources: #RunServiceResources
healthCheck?: #HealthCheck
}

View File

@@ -0,0 +1,40 @@
package resources
import (
"fmt"
"github.com/mark3labs/mcp-go/server"
"github.com/nhost/nhost/cli/mcp/config"
)
func Instructions() string {
return "The following resources are available:\n\n" +
fmt.Sprintf("- %s: %s\n", CloudResourceURI, CloudDescription) +
fmt.Sprintf("- %s: %s\n", GraphqlManagementResourceURI, GraphqlManagementDescription) +
fmt.Sprintf("- %s: %s\n", NhostTomlResourceURI, NhostTomlResourceDescription)
}
func Register(cfg *config.Config, server *server.MCPServer) error {
nt := NewNhostToml()
nt.Register(server)
if cfg.Cloud != nil {
ct := NewCloud(cfg)
ct.Register(server)
}
enableGraphlManagement := false
for _, project := range cfg.Projects {
if project.ManageMetadata {
enableGraphlManagement = true
break
}
}
if enableGraphlManagement {
gmt := NewGraphqlManagement()
gmt.Register(server)
}
return nil
}

View File

@@ -2,18 +2,11 @@ package cloud
import (
"context"
_ "embed"
"net/http"
"github.com/mark3labs/mcp-go/server"
)
//go:embed schema.graphql
var schemaGraphql string
//go:embed schema-with-mutations.graphql
var schemaGraphqlWithMutations string
type Tool struct {
graphqlURL string
withMutations bool
@@ -33,7 +26,6 @@ func NewTool(
}
func (t *Tool) Register(mcpServer *server.MCPServer) error {
t.registerGetGraphqlSchema(mcpServer)
t.registerGraphqlQuery(mcpServer)
return nil

View File

@@ -1,42 +0,0 @@
package cloud
import (
"context"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ToolGetGraphqlSchemaName = "cloud-get-graphql-schema"
//nolint:lll
ToolGetGraphqlSchemaInstructions = `Get GraphQL schema for the Nhost Cloud allowing operations on projects and organizations. Retrieve the schema before using the tool to understand the available queries and mutations. Projects are equivalent to apps in the schema. IDs are typically uuids`
)
func (t *Tool) registerGetGraphqlSchema(mcpServer *server.MCPServer) {
schemaTool := mcp.NewTool(
ToolGetGraphqlSchemaName,
mcp.WithDescription(ToolGetGraphqlSchemaInstructions),
mcp.WithToolAnnotation(
mcp.ToolAnnotation{
Title: "Get GraphQL Schema for Nhost Cloud Platform",
ReadOnlyHint: ptr(true),
DestructiveHint: ptr(false),
IdempotentHint: ptr(true),
OpenWorldHint: ptr(true),
},
),
)
mcpServer.AddTool(schemaTool, t.handleGetGraphqlSchema)
}
func (t *Tool) handleGetGraphqlSchema(
_ context.Context, _ mcp.CallToolRequest,
) (*mcp.CallToolResult, error) {
schema := schemaGraphql
if t.withMutations {
schema = schemaGraphqlWithMutations
}
return mcp.NewToolResultStructured(schema, schema), nil
}

View File

@@ -12,7 +12,7 @@ import (
const (
ToolGraphqlQueryName = "cloud-graphql-query"
//nolint:lll
ToolGraphqlQueryInstructions = `Execute a GraphQL query against the Nhost Cloud to perform operations on projects and organizations. It also allows configuring projects hosted on Nhost Cloud. Make sure you got the schema before attempting to execute any query. If you get an error while performing a query refresh the schema in case something has changed or you did something wrong. If you get an error indicating mutations are not allowed the user may have disabled them in the server, don't retry and ask the user they need to pass --with-cloud-mutations when starting mcp-nhost to enable them. Projects are apps.`
ToolGraphqlQueryInstructions = `Execute a GraphQL query against the Nhost Cloud to perform operations on projects and organizations. It also allows configuring projects hosted on Nhost Cloud. Make sure you got the schema before attempting to execute any query. If you get an error while performing a query refresh the schema in case something has changed or you did something wrong. If you get an error indicating mutations are not allowed the user may have disabled them in the server, don't retry and ask the user they need to pass --with-cloud-mutations when starting nhost's mcp to enable them. Projects are apps.`
)
func ptr[T any](v T) *T {
@@ -25,7 +25,6 @@ type GraphqlQueryRequest struct {
}
func (t *Tool) registerGraphqlQuery(mcpServer *server.MCPServer) {
t.registerGetGraphqlSchema(mcpServer)
queryTool := mcp.NewTool(
ToolGraphqlQueryName,
mcp.WithDescription(ToolGraphqlQueryInstructions),
@@ -60,7 +59,7 @@ func (t *Tool) handleGraphqlQuery(
allowedMutations := []string{}
if t.withMutations {
allowedMutations = nil
allowedMutations = []string{"*"}
}
var resp graphql.Response[any]
@@ -70,7 +69,7 @@ func (t *Tool) handleGraphqlQuery(
args.Query,
args.Variables,
&resp,
nil,
[]string{"*"},
allowedMutations,
t.interceptors...,
); err != nil {

View File

@@ -1,87 +0,0 @@
package local
import (
"context"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/nhost/nhost/cli/mcp/graphql"
)
const (
ToolConfigServerSchemaName = "local-config-server-get-schema"
//nolint:lll
ToolConfigServerSchemaInstructions = `Get GraphQL schema for the local config server. This tool is useful when the user is developing a project and wants help changing the project's settings.`
)
func (t *Tool) registerGetConfigServerSchema(mcpServer *server.MCPServer) {
configServerSchemaTool := mcp.NewTool(
ToolConfigServerSchemaName,
mcp.WithDescription(ToolConfigServerSchemaInstructions),
mcp.WithToolAnnotation(
mcp.ToolAnnotation{
Title: "Get GraphQL Schema for Nhost Config Server",
ReadOnlyHint: ptr(true),
DestructiveHint: ptr(false),
IdempotentHint: ptr(true),
OpenWorldHint: ptr(true),
},
),
mcp.WithBoolean(
"includeQueries",
mcp.Description("include queries in the schema"),
mcp.Required(),
),
mcp.WithBoolean(
"includeMutations",
mcp.Description("include mutations in the schema"),
mcp.Required(),
),
)
mcpServer.AddTool(
configServerSchemaTool,
mcp.NewStructuredToolHandler(t.handleConfigGetServerSchema),
)
}
type ConfigServerGetSchemaRequest struct {
IncludeQueries bool `json:"includeQueries"`
IncludeMutations bool `json:"includeMutations"`
}
func (t *Tool) handleConfigGetServerSchema(
ctx context.Context, _ mcp.CallToolRequest, args ConfigServerGetSchemaRequest,
) (*mcp.CallToolResult, error) {
var introspection graphql.ResponseIntrospection
if err := graphql.Query(
ctx,
t.configServerURL,
graphql.IntrospectionQuery,
nil,
&introspection,
nil,
nil,
t.interceptors...,
); err != nil {
return mcp.NewToolResultErrorFromErr("failed to query GraphQL schema", err), nil
}
var allowQueries, allowMutations []graphql.Queries
if !args.IncludeQueries {
allowQueries = []graphql.Queries{}
}
if !args.IncludeMutations {
allowMutations = []graphql.Queries{}
}
schema := graphql.ParseSchema(
introspection,
graphql.Filter{
AllowQueries: allowQueries,
AllowMutations: allowMutations,
},
)
return mcp.NewToolResultStructured(schema, schema), nil
}

View File

@@ -1,79 +0,0 @@
package local
import (
"context"
"encoding/json"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/nhost/nhost/cli/mcp/graphql"
)
const (
ToolConfigServerQueryName = "local-config-server-query"
//nolint:lll
ToolConfigServerQueryInstructions = `Execute a GraphQL query against the local config server. This tool is useful to query and perform configuration changes on the local development project. Before using this tool, make sure to get the schema using the local-config-server-schema tool. To perform configuration changes this endpoint is all you need but to apply them you need to run 'nhost up' again. Ask the user for input when you need information about settings, for instance if the user asks to enable some oauth2 method and you need the client id or secret.`
)
type ConfigServerQueryRequest struct {
Query string `json:"query"`
Variables map[string]any `json:"variables,omitempty"`
}
func (t *Tool) registerConfigServerQuery(mcpServer *server.MCPServer) {
configServerQueryTool := mcp.NewTool(
ToolConfigServerQueryName,
mcp.WithDescription(ToolConfigServerQueryInstructions),
mcp.WithToolAnnotation(
mcp.ToolAnnotation{
Title: "Perform GraphQL Query on Nhost Config Server",
ReadOnlyHint: ptr(false),
DestructiveHint: ptr(true),
IdempotentHint: ptr(false),
OpenWorldHint: ptr(true),
},
),
mcp.WithString(
"query",
mcp.Description("graphql query to perform"),
mcp.Required(),
),
mcp.WithString(
"variables",
mcp.Description("variables to use in the query"),
),
)
mcpServer.AddTool(
configServerQueryTool,
mcp.NewStructuredToolHandler(t.handleConfigServerQuery),
)
}
func (t *Tool) handleConfigServerQuery(
ctx context.Context, _ mcp.CallToolRequest, args ConfigServerQueryRequest,
) (*mcp.CallToolResult, error) {
if args.Query == "" {
return mcp.NewToolResultError("query is required"), nil
}
var resp graphql.Response[any]
if err := graphql.Query(
ctx,
t.configServerURL,
args.Query,
args.Variables,
&resp,
nil,
nil,
t.interceptors...,
); err != nil {
return mcp.NewToolResultErrorFromErr("failed to query graphql endpoint", err), nil
}
b, err := json.Marshal(resp)
if err != nil {
return mcp.NewToolResultErrorFromErr("error marshalling response", err), nil
}
return mcp.NewToolResultStructured(resp, string(b)), nil
}

View File

@@ -1,42 +0,0 @@
package local
import (
"context"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/nhost/nhost/cli/mcp/nhost/graphql"
)
const (
ToolGetGraphqlManagementSchemaName = "local-get-management-graphql-schema"
ToolGetGraphqlManagementSchemaInstructions = `
Get GraphQL's management schema for an Nhost development project running locally via the Nhost
CLI. This tool is useful to properly understand how manage hasura metadata, migrations,
permissions, remote schemas, etc.
Use it before attempting to use ` + ToolManageGraphqlName
)
func (t *Tool) registerGetGraphqlManagementSchema(mcpServer *server.MCPServer) {
schemaTool := mcp.NewTool(
ToolGetGraphqlManagementSchemaName,
mcp.WithDescription(ToolGetGraphqlManagementSchemaInstructions),
mcp.WithToolAnnotation(
mcp.ToolAnnotation{
Title: "Get GraphQL's Management Schema for Nhost Development Project",
ReadOnlyHint: ptr(true),
DestructiveHint: ptr(false),
IdempotentHint: ptr(true),
OpenWorldHint: ptr(true),
},
),
)
mcpServer.AddTool(schemaTool, t.handleGetGraphqlManagementSchema)
}
func (t *Tool) handleGetGraphqlManagementSchema(
_ context.Context, _ mcp.CallToolRequest,
) (*mcp.CallToolResult, error) {
return mcp.NewToolResultText(graphql.Schema), nil
}

View File

@@ -1,81 +0,0 @@
package local
import (
"context"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/nhost/nhost/cli/mcp/graphql"
"github.com/nhost/nhost/cli/mcp/nhost/auth"
)
const (
ToolGetGraphqlSchemaName = "local-get-graphql-schema"
//nolint:lll
ToolGetGraphqlSchemaInstructions = `Get GraphQL schema for an Nhost development project running locally via the Nhost CLI. This tool is useful when the user is developing a project and wants help generating code to interact with their project's Graphql schema.`
)
type GetGraphqlSchemaRequest struct {
Role string `json:"role"`
}
func (t *Tool) registerGetGraphqlSchema(mcpServer *server.MCPServer) {
schemaTool := mcp.NewTool(
ToolGetGraphqlSchemaName,
mcp.WithDescription(ToolGetGraphqlSchemaInstructions),
mcp.WithToolAnnotation(
mcp.ToolAnnotation{
Title: "Get GraphQL Schema for Nhost Development Project",
ReadOnlyHint: ptr(true),
DestructiveHint: ptr(false),
IdempotentHint: ptr(true),
OpenWorldHint: ptr(true),
},
),
mcp.WithString(
"role",
mcp.Description(
"role to use when executing queries. Default to user but make sure the user is aware",
),
mcp.Required(),
),
)
mcpServer.AddTool(schemaTool, mcp.NewStructuredToolHandler(t.handleGetGraphqlSchema))
}
func (t *Tool) handleGetGraphqlSchema(
ctx context.Context, _ mcp.CallToolRequest, args GetGraphqlSchemaRequest,
) (*mcp.CallToolResult, error) {
if args.Role == "" {
return mcp.NewToolResultError("role is required"), nil
}
interceptors := append( //nolint:gocritic
t.interceptors,
auth.WithRole(args.Role),
)
var introspection graphql.ResponseIntrospection
if err := graphql.Query(
ctx,
t.graphqlURL,
graphql.IntrospectionQuery,
nil,
&introspection,
nil,
nil,
interceptors...,
); err != nil {
return mcp.NewToolResultErrorFromErr("failed to query GraphQL schema", err), nil
}
schema := graphql.ParseSchema(
introspection,
graphql.Filter{
AllowQueries: nil,
AllowMutations: nil,
},
)
return mcp.NewToolResultText(schema), nil
}

View File

@@ -1,37 +0,0 @@
package local
import (
"context"
"net/http"
"github.com/mark3labs/mcp-go/server"
)
type Tool struct {
graphqlURL string
configServerURL string
interceptors []func(ctx context.Context, req *http.Request) error
}
func NewTool(
graphqlURL string,
configServerURL string,
interceptors ...func(ctx context.Context, req *http.Request) error,
) *Tool {
return &Tool{
graphqlURL: graphqlURL,
configServerURL: configServerURL,
interceptors: interceptors,
}
}
func (t *Tool) Register(mcpServer *server.MCPServer) error {
t.registerGetGraphqlSchema(mcpServer)
t.registerGraphqlQuery(mcpServer)
t.registerGetConfigServerSchema(mcpServer)
t.registerConfigServerQuery(mcpServer)
t.registerGetGraphqlManagementSchema(mcpServer)
t.registerManageGraphql(mcpServer)
return nil
}

View File

@@ -1,99 +0,0 @@
package local
import (
"context"
"encoding/json"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/nhost/nhost/cli/mcp/graphql"
"github.com/nhost/nhost/cli/mcp/nhost/auth"
)
const (
ToolGraphqlQueryName = "local-graphql-query"
//nolint:lll
ToolGraphqlQueryInstructions = `Execute a GraphQL query against an Nhost development project running locally via the Nhost CLI. This tool is useful to test queries and mutations during development. If you run into issues executing queries, retrieve the schema using the local-get-graphql-schema tool in case the schema has changed.`
)
func ptr[T any](v T) *T {
return &v
}
type GraphqlQueryRequest struct {
Query string `json:"query"`
Variables map[string]any `json:"variables,omitempty"`
Role string `json:"role"`
}
func (t *Tool) registerGraphqlQuery(mcpServer *server.MCPServer) {
queryTool := mcp.NewTool(
ToolGraphqlQueryName,
mcp.WithDescription(ToolGraphqlQueryInstructions),
mcp.WithToolAnnotation(
mcp.ToolAnnotation{
Title: "Perform GraphQL Query on Nhost Development Project",
ReadOnlyHint: ptr(false),
DestructiveHint: ptr(true),
IdempotentHint: ptr(false),
OpenWorldHint: ptr(true),
},
),
mcp.WithString(
"query",
mcp.Description("graphql query to perform"),
mcp.Required(),
),
mcp.WithObject(
"variables",
mcp.Description("variables to use in the query"),
mcp.AdditionalProperties(true),
),
mcp.WithString(
"role",
mcp.Description(
"role to use when executing queries. Default to user but make sure the user is aware. Keep in mind the schema depends on the role so if you retrieved the schema for a different role previously retrieve it for this role beforehand as it might differ", //nolint:lll
),
mcp.Required(),
),
)
mcpServer.AddTool(queryTool, mcp.NewStructuredToolHandler(t.handleGraphqlQuery))
}
func (t *Tool) handleGraphqlQuery(
ctx context.Context, _ mcp.CallToolRequest, args GraphqlQueryRequest,
) (*mcp.CallToolResult, error) {
if args.Query == "" {
return mcp.NewToolResultError("query is required"), nil
}
if args.Role == "" {
return mcp.NewToolResultError("role is required"), nil
}
interceptors := append( //nolint:gocritic
t.interceptors,
auth.WithRole(args.Role),
)
var resp graphql.Response[any]
if err := graphql.Query(
ctx,
t.graphqlURL,
args.Query,
args.Variables,
&resp,
nil,
nil,
interceptors...,
); err != nil {
return mcp.NewToolResultErrorFromErr("failed to query graphql endpoint", err), nil
}
b, err := json.Marshal(resp)
if err != nil {
return mcp.NewToolResultErrorFromErr("error marshalling response", err), nil
}
return mcp.NewToolResultStructured(resp, string(b)), nil
}

View File

@@ -1,108 +0,0 @@
package project
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/nhost/nhost/cli/mcp/graphql"
"github.com/nhost/nhost/cli/mcp/nhost/auth"
)
const (
ToolGetGraphqlSchemaName = "project-get-graphql-schema"
ToolGetGraphqlSchemaInstructions = `Get GraphQL schema for an Nhost project running in the Nhost Cloud.`
)
var (
ErrNotFound = errors.New("not found")
ErrInvalidRequestBody = errors.New("invalid request body")
)
type GetGraphqlSchemaRequest struct {
Role string `json:"role"`
ProjectSubdomain string `json:"projectSubdomain"`
}
func (t *Tool) registerGetGraphqlSchemaTool(mcpServer *server.MCPServer, projects string) {
schemaTool := mcp.NewTool(
ToolGetGraphqlSchemaName,
mcp.WithDescription(ToolGetGraphqlSchemaInstructions),
mcp.WithToolAnnotation(
mcp.ToolAnnotation{
Title: "Get GraphQL Schema for Nhost Project running on Nhost Cloud",
ReadOnlyHint: ptr(true),
DestructiveHint: ptr(false),
IdempotentHint: ptr(true),
OpenWorldHint: ptr(true),
},
),
mcp.WithString(
"role",
mcp.Description(
"role to use when executing queries. Default to user but make sure the user is aware",
),
mcp.Required(),
),
mcp.WithString(
"projectSubdomain",
mcp.Description(
fmt.Sprintf(
"Project to get the GraphQL schema for. Must be one of %s, otherwise you don't have access to it. You can use cloud-* tools to resolve subdomains and map them to names", //nolint:lll
projects,
),
),
mcp.Required(),
),
)
mcpServer.AddTool(schemaTool, mcp.NewStructuredToolHandler(t.handleGetGraphqlSchema))
}
func (t *Tool) handleGetGraphqlSchema(
ctx context.Context, _ mcp.CallToolRequest, args GetGraphqlSchemaRequest,
) (*mcp.CallToolResult, error) {
if args.Role == "" {
return mcp.NewToolResultError("role is required"), nil
}
if args.ProjectSubdomain == "" {
return mcp.NewToolResultError("projectSubdomain is required"), nil
}
project, ok := t.projects[args.ProjectSubdomain]
if !ok {
return mcp.NewToolResultError("project not configured to be accessed by an LLM"), nil
}
interceptors := []func(ctx context.Context, req *http.Request) error{
project.authInterceptor,
auth.WithRole(args.Role),
}
var introspection graphql.ResponseIntrospection
if err := graphql.Query(
ctx,
project.graphqlURL,
graphql.IntrospectionQuery,
nil,
&introspection,
nil,
nil,
interceptors...,
); err != nil {
return mcp.NewToolResultErrorFromErr("failed to query GraphQL schema", err), nil
}
schema := graphql.ParseSchema(
introspection,
graphql.Filter{
AllowQueries: nil,
AllowMutations: nil,
},
)
return mcp.NewToolResultStructured(schema, schema), nil
}

View File

@@ -1,4 +1,4 @@
package local
package project
import (
"context"
@@ -10,22 +10,21 @@ import (
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/nhost/nhost/cli/mcp/nhost/auth"
)
const (
ToolManageGraphqlName = "local-manage-graphql"
ToolManageGraphqlName = "manage-graphql"
ToolManageGraphqlInstructions = `
Query GraphQL's management endpoints on an Nhost development project running locally via
the Nhost CLI. This tool is useful to manage hasura metadata, migrations, permissions,
remote schemas, database migrations, etc. It also allows to interact with the underlying
database directly.
Query GraphQL's management endpoints on an Nhost project running. This tool is useful to
manage hasura metadata, migrations, permissions, remote schemas, database migrations,
etc. It also allows to interact with the underlying database directly.
* Do not forget to use the base url in the endpoint.
* Before using this tool always describe in natural languate what you are about to do.
## Metadata changes
* When changing metadata always use the /apis/migrate endpoint
* When changing metadata ALWAYS use the /apis/migrate endpoint
* Always perform a bulk request to avoid
having to perform multiple requests
* The admin user always has full permissions to everything by default, no need to configure
@@ -55,8 +54,9 @@ const (
)
type ManageGraphqlRequest struct {
Endpoint string `json:"endpoint"`
Body string `json:"body"`
Body string `json:"body"`
Subdomain string `json:"subdomain"`
Path string `json:"path"`
}
func (t *Tool) registerManageGraphql(mcpServer *server.MCPServer) {
@@ -73,10 +73,14 @@ func (t *Tool) registerManageGraphql(mcpServer *server.MCPServer) {
},
),
mcp.WithString(
"endpoint",
mcp.Description(
"The GraphQL management endpoint to query. Use https://local.hasura.local.nhost.run as base URL",
),
"subdomain",
mcp.Description("Project to perform the GraphQL management operation against"),
mcp.Enum(t.cfg.Projects.Subdomains()...),
mcp.Required(),
),
mcp.WithString(
"path",
mcp.Description("The path for the HTTP request"),
mcp.Required(),
),
mcp.WithString(
@@ -137,20 +141,37 @@ func genericQuery(
func (t *Tool) handleManageGraphql(
ctx context.Context, _ mcp.CallToolRequest, args ManageGraphqlRequest,
) (*mcp.CallToolResult, error) {
if args.Endpoint == "" {
return mcp.NewToolResultError("endpoint is required"), nil
}
if args.Body == "" {
return mcp.NewToolResultError("body is required"), nil
}
if args.Subdomain == "" {
return mcp.NewToolResultError("projectSubdomain is required"), nil
}
project, err := t.cfg.Projects.Get(args.Subdomain)
if err != nil {
return mcp.NewToolResultErrorFromErr("failed to get project configuration", err), nil
}
if !project.ManageMetadata {
return mcp.NewToolResultError("project does not allow metadata management"), nil
}
if project.AdminSecret == nil {
return mcp.NewToolResultError("project does not have an admin secret configured"), nil
}
headers := http.Header{}
headers.Add("Content-Type", "application/json")
headers.Add("Accept", "application/json")
interceptors := []func(ctx context.Context, req *http.Request) error{
auth.WithAdminSecret(*project.AdminSecret),
}
response, err := genericQuery(
ctx, args.Endpoint, args.Body, http.MethodPost, headers, t.interceptors,
ctx, project.GetHasuraURL()+args.Path, args.Body, http.MethodPost, headers, interceptors,
)
if err != nil {
return mcp.NewToolResultErrorFromErr("failed to execute query", err), nil

View File

@@ -1,90 +1,25 @@
package project
import (
"context"
"fmt"
"net/http"
"slices"
"strings"
"github.com/mark3labs/mcp-go/server"
"github.com/nhost/nhost/cli/mcp/config"
"github.com/nhost/nhost/cli/mcp/nhost/auth"
)
type Project struct {
subdomain string
graphqlURL string
authInterceptor func(ctx context.Context, req *http.Request) error
allowQueries []string
allowMutations []string
}
type Tool struct {
projects map[string]Project
}
func allowedQueries(allowQueries []string) []string {
if len(allowQueries) == 1 && allowQueries[0] == "*" {
return nil
}
return allowQueries
cfg *config.Config
}
func NewTool(
projList []config.Project,
) (*Tool, error) {
projects := make(map[string]Project)
for _, proj := range projList {
authURL := fmt.Sprintf("https://%s.auth.%s.nhost.run/v1", proj.Subdomain, proj.Region)
graphqlURL := fmt.Sprintf("https://%s.graphql.%s.nhost.run/v1", proj.Subdomain, proj.Region)
var interceptor func(ctx context.Context, req *http.Request) error
switch {
case proj.AdminSecret != nil && *proj.AdminSecret != "":
interceptor = auth.WithAdminSecret(*proj.AdminSecret)
case proj.PAT != nil && *proj.PAT != "":
var err error
interceptor, err = auth.WithPAT(authURL, *proj.PAT)
if err != nil {
return nil,
fmt.Errorf("failed to create PAT interceptor for %s: %w", proj.Subdomain, err)
}
default:
return nil, fmt.Errorf( //nolint:err113
"project %s does not have a valid auth mechanism", proj.Subdomain)
}
projects[proj.Subdomain] = Project{
subdomain: proj.Subdomain,
graphqlURL: graphqlURL,
authInterceptor: interceptor,
allowQueries: allowedQueries(proj.AllowQueries),
allowMutations: allowedQueries(proj.AllowMutations),
}
}
cfg *config.Config,
) *Tool {
return &Tool{
projects: projects,
}, nil
cfg: cfg,
}
}
func (t *Tool) Register(mcpServer *server.MCPServer) error {
projectNames := make([]string, 0, len(t.projects))
for _, proj := range t.projects {
projectNames = append(projectNames, proj.subdomain)
}
slices.Sort(projectNames)
projectNamesStr := strings.Join(projectNames, ", ")
t.registerGetGraphqlSchemaTool(mcpServer, projectNamesStr)
t.registerGraphqlQuery(mcpServer, projectNamesStr)
t.registerGraphqlQuery(mcpServer)
t.registerManageGraphql(mcpServer)
return nil
}

View File

@@ -3,7 +3,6 @@ package project
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/mark3labs/mcp-go/mcp"
@@ -13,9 +12,9 @@ import (
)
const (
ToolGraphqlQueryName = "project-graphql-query"
ToolGraphqlQueryName = "graphql-query"
//nolint:lll
ToolGraphqlQueryInstructions = `Execute a GraphQL query against a Nhost project running in the Nhost Cloud. This tool is useful to query and mutate live data running on an online projec. If you run into issues executing queries, retrieve the schema using the project-get-graphql-schema tool in case the schema has changed. If you get an error indicating the query or mutation is not allowed the user may have disabled them in the server, don't retry and tell the user they need to enable them when starting mcp-nhost`
ToolGraphqlQueryInstructions = `Execute a GraphQL query against a Nhost project. This tool is useful to query and mutate data. If you run into issues executing queries, retrieve the schema again in case the schema has changed. If you get an error indicating the query or mutation is not allowed the user may have disabled them in the server, don't retry and tell the user they need to enable them when starting nhost's mcp`
)
func ptr[T any](v T) *T {
@@ -23,18 +22,18 @@ func ptr[T any](v T) *T {
}
type GraphqlQueryRequest struct {
Query string `json:"query"`
Variables map[string]any `json:"variables,omitempty"`
ProjectSubdomain string `json:"projectSubdomain"`
Role string `json:"role"`
UserID string `json:"userId,omitempty"`
Query string `json:"query"`
Variables map[string]any `json:"variables,omitempty"`
Subdomain string `json:"subdomain"`
Role string `json:"role"`
UserID string `json:"userId,omitempty"`
}
func (t *Tool) registerGraphqlQuery(mcpServer *server.MCPServer, projects string) {
func (t *Tool) registerGraphqlQuery(mcpServer *server.MCPServer) {
allowedMutations := false
for _, proj := range t.projects {
if proj.allowMutations == nil || len(proj.allowMutations) > 0 {
for _, proj := range t.cfg.Projects {
if proj.AllowMutations == nil || len(proj.AllowMutations) > 0 {
allowedMutations = true
break
}
@@ -62,19 +61,15 @@ func (t *Tool) registerGraphqlQuery(mcpServer *server.MCPServer, projects string
mcp.Description("variables to use in the query"),
),
mcp.WithString(
"projectSubdomain",
mcp.Description(
fmt.Sprintf(
"Project to get the GraphQL schema for. Must be one of %s, otherwise you don't have access to it. You can use cloud-* tools to resolve subdomains and map them to names", //nolint:lll
projects,
),
),
"subdomain",
mcp.Description("Project to perform the GraphQL query against"),
mcp.Enum(t.cfg.Projects.Subdomains()...),
mcp.Required(),
),
mcp.WithString(
"role",
mcp.Description(
"role to use when executing queries. Default to user but make sure the user is aware. Keep in mind the schema depends on the role so if you retrieved the schema for a different role previously retrieve it for this role beforehand as it might differ", //nolint:lll
"role to use when executing queries. Keep in mind the schema depends on the role so if you retrieved the schema for a different role previously retrieve it for this role beforehand as it might differ", //nolint:lll
),
mcp.Required(),
),
@@ -95,7 +90,7 @@ func (t *Tool) handleGraphqlQuery(
return mcp.NewToolResultError("query is required"), nil
}
if args.ProjectSubdomain == "" {
if args.Subdomain == "" {
return mcp.NewToolResultError("projectSubdomain is required"), nil
}
@@ -103,15 +98,18 @@ func (t *Tool) handleGraphqlQuery(
return mcp.NewToolResultError("role is required"), nil
}
project, ok := t.projects[args.ProjectSubdomain]
if !ok {
return mcp.NewToolResultError(
"this project is not configured to be accessed by an LLM",
), nil
project, err := t.cfg.Projects.Get(args.Subdomain)
if err != nil {
return mcp.NewToolResultErrorFromErr("failed to get project configuration", err), nil
}
authInterceptor, err := project.GetAuthInterceptor()
if err != nil {
return mcp.NewToolResultErrorFromErr("failed to get auth interceptor", err), nil
}
interceptors := []func(ctx context.Context, req *http.Request) error{
project.authInterceptor,
authInterceptor,
auth.WithRole(args.Role),
}
@@ -122,12 +120,12 @@ func (t *Tool) handleGraphqlQuery(
var resp graphql.Response[any]
if err := graphql.Query(
ctx,
project.graphqlURL,
project.GetGraphqlURL(),
args.Query,
args.Variables,
&resp,
project.allowQueries,
project.allowMutations,
project.AllowQueries,
project.AllowMutations,
interceptors...,
); err != nil {
return mcp.NewToolResultErrorFromErr("failed to query graphql endpoint", err), nil

View File

@@ -0,0 +1,94 @@
package schemas
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/nhost/nhost/cli/mcp/graphql"
"github.com/nhost/nhost/cli/mcp/nhost/auth"
)
const (
ToolGetGraphqlSchemaName = "project-get-graphql-schema"
ToolGetGraphqlSchemaInstructions = `Get GraphQL schema for an Nhost project running in the Nhost Cloud.`
)
var (
ErrNotFound = errors.New("not found")
ErrInvalidRequestBody = errors.New("invalid request body")
)
type GetGraphqlSchemaRequest struct {
Role string `json:"role"`
ProjectSubdomain string `json:"projectSubdomain"`
}
func toQueries(q []string) []graphql.Queries {
if q == nil {
return nil
}
queries := make([]graphql.Queries, len(q))
for i, v := range q {
queries[i] = graphql.Queries{
Name: v,
DisableNesting: false,
}
}
return queries
}
func (t *Tool) handleProjectGraphqlSchema(
ctx context.Context,
role string,
subdomain string,
summary bool,
queries, mutations []string,
) (string, error) {
project, err := t.cfg.Projects.Get(subdomain)
if err != nil {
return "", fmt.Errorf("failed to get project by subdomain: %w", err)
}
authInterceptor, err := project.GetAuthInterceptor()
if err != nil {
return "", fmt.Errorf("failed to get auth interceptor: %w", err)
}
interceptors := []func(ctx context.Context, req *http.Request) error{
authInterceptor,
auth.WithRole(role),
}
var introspection graphql.ResponseIntrospection
if err := graphql.Query(
ctx,
project.GetGraphqlURL(),
graphql.IntrospectionQuery,
nil,
&introspection,
[]string{"*"},
nil,
interceptors...,
); err != nil {
return "", fmt.Errorf("failed to query GraphQL schema: %w", err)
}
var schema string
if summary {
schema = graphql.SummarizeSchema(introspection)
} else {
schema = graphql.ParseSchema(
introspection,
graphql.Filter{
AllowQueries: toQueries(queries),
AllowMutations: toQueries(mutations),
},
)
}
return schema, nil
}

View File

@@ -0,0 +1,103 @@
package schemas
import (
"context"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/nhost/nhost/cli/mcp/config"
)
const (
ToolGetSchemaName = "get-schema"
ToolGetSchemaInstructions = `
Get GraphQL/API schemas to interact with various services. Use the "service" parameter to
specify which schema you want. Supported services are:
- project: Get GraphQL schema for an Nhost project. The "subdomain"
parameter is required to specify which project to get the schema for. The "role"
parameter can be passed to specify the role to use when fetching the schema (defaults
to admin).
`
)
func ptr[T any](v T) *T {
return &v
}
type Tool struct {
cfg *config.Config
}
func NewTool(cfg *config.Config) *Tool {
return &Tool{cfg: cfg}
}
func (t *Tool) Register(mcpServer *server.MCPServer) {
queryTool := mcp.NewTool(
ToolGetSchemaName,
mcp.WithDescription(ToolGetGraphqlSchemaInstructions),
mcp.WithToolAnnotation(
mcp.ToolAnnotation{
Title: "Get GraphQL/API schema for various services",
ReadOnlyHint: ptr(true),
DestructiveHint: ptr(false),
IdempotentHint: ptr(true),
OpenWorldHint: ptr(true),
},
),
mcp.WithString(
"role",
mcp.Description(
"role to use when executing queries. Keep in mind the schema depends on the role so if you retrieved the schema for a different role previously retrieve it for this role beforehand as it might differ", //nolint:lll
),
mcp.Required(),
),
mcp.WithString(
"subdomain",
mcp.Description(
"Project to get the GraphQL schema for. Required when service is `project`",
),
mcp.Enum(t.cfg.Projects.Subdomains()...),
mcp.Required(),
),
mcp.WithBoolean(
"summary",
mcp.Description("only return a summary of the schema"),
mcp.DefaultBool(true),
),
mcp.WithArray(
"queries",
mcp.WithStringItems(),
mcp.Description("list of queries to fetch"),
),
mcp.WithArray(
"mutations",
mcp.WithStringItems(),
mcp.Description("list of mutations to fetch"),
),
)
mcpServer.AddTool(queryTool, mcp.NewStructuredToolHandler(t.handle))
}
type HandleRequest struct {
Role string `json:"role,omitempty"`
Subdomain string `json:"subdomain,omitempty"`
Summary bool `json:"summary,omitempty"`
Queries []string `json:"queries,omitempty"`
Mutations []string `json:"mutations,omitempty"`
}
func (t *Tool) handle(
ctx context.Context, _ mcp.CallToolRequest, args HandleRequest,
) (*mcp.CallToolResult, error) {
schema, err := t.handleProjectGraphqlSchema(
ctx, args.Role, args.Subdomain, args.Summary, args.Queries, args.Mutations,
)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
return mcp.NewToolResultText(schema), nil
}

Some files were not shown because too many files have changed in this diff Show More