Compare commits

...

25 Commits

Author SHA1 Message Date
github-actions[bot]
00ef639455 release(dashboard): 2.38.4 (#3574)
Co-authored-by: dbarrosop <dbarrosop@users.noreply.github.com>
2025-10-09 10:38:57 +02:00
David Barroso
a54da9c072 fix(dashboard): remove NODE_ENV from restricted env vars (#3573) 2025-10-09 10:36:25 +02:00
David Barroso
63edfa2600 feat(cli): MCP refactor and documentation prior to official release (#3571) 2025-10-09 08:55:10 +02:00
David Barroso
381baf2e51 feat(docs): added react urql guide (#3570) 2025-10-08 11:02:50 +02:00
dependabot[bot]
951ce168e8 chore(ci): bump github/codeql-action from 3 to 4 (#3569)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-08 08:34:39 +02:00
github-actions[bot]
be8f4e5b1b release(dashboard): 2.38.3 (#3563)
Co-authored-by: dbm03 <dbm03@users.noreply.github.com>
2025-10-07 15:03:40 +02:00
David BM
010573cc31 fix(dashboard): improve remote schema preview search (#3558) 2025-10-07 14:37:21 +02:00
David BM
629bbe7a78 fix(dashboard): remote schema edit graphql customizations, default value for root field namespace is empty (#3565) 2025-10-06 14:56:46 +02:00
David BM
166889be1b fix(dashboard): show paused banner in Run page (#3564) 2025-10-06 10:04:30 +02:00
David BM
c80f6292c6 fix(dashboard): show paused banner in remote schemas/database page if project is paused (#3557) 2025-10-06 08:59:49 +02:00
David Barroso
5c7a6788b4 feat(cli): mcp: added support for environment variables in the configuration (#3556) 2025-10-03 10:24:38 +02:00
David Barroso
6ae4e17ffe feat(cli): mcp: move configuration to .nhost folder and integrate cloud credentials (#3555) 2025-10-03 10:17:41 +02:00
David Barroso
515fde79a3 chore(nixops): update nhost-cli (#3554) 2025-10-02 16:59:16 +02:00
David Barroso
545d0e33d9 feat(cli): added mcp server functionality from mcp-nhost (#3550) 2025-10-02 14:53:55 +02:00
github-actions[bot]
9d1853742e release(cli): 1.33.0 (#3547)
Co-authored-by: dbarrosop <dbarrosop@users.noreply.github.com>
2025-10-02 14:32:21 +02:00
David Barroso
c0beb07b77 chore(cli): update certs (#3552) 2025-10-02 14:29:55 +02:00
David Barroso
3a79db6277 fix(cli): fix breaking change in go-getter dependency (#3551) 2025-10-02 14:03:00 +02:00
David Barroso
3378739967 fix(cli): disable tls on AUTH_SERVER_URL when auth uses custom port (#3549) 2025-10-02 11:13:05 +02:00
David Barroso
e31ac82a55 fix(examples/docker-compose): added missing .env.example (#3548) 2025-10-02 10:55:26 +02:00
David Barroso
bdd88161c6 feat(cli): migrate from urfave/v2 to urfave/v3 (#3545) 2025-10-02 09:13:35 +02:00
github-actions[bot]
17e8acb368 release(cli): 1.32.2 (#3544)
Co-authored-by: dbarrosop <dbarrosop@users.noreply.github.com>
2025-10-01 08:40:31 +02:00
David Barroso
a2bc1fee6f chore(cli): remove hasura- prefix from auth/storage images (#3538) 2025-10-01 08:33:06 +02:00
github-actions[bot]
ba2ac461e1 release(dashboard): 2.38.2 (#3541)
Co-authored-by: dbarrosop <dbarrosop@users.noreply.github.com>
2025-09-30 17:01:40 +02:00
github-actions[bot]
d2cc79e838 release(services/storage): 0.8.1 (#3543)
Co-authored-by: dbarrosop <dbarrosop@users.noreply.github.com>
2025-09-30 16:45:02 +02:00
David Barroso
31a30cd460 fix(storage): pass buildVersion correctly (#3542) 2025-09-30 16:39:23 +02:00
2019 changed files with 302213 additions and 683186 deletions

View File

@@ -27,6 +27,9 @@ on:
# nhost-js
- packages/nhost-js/**
# cli
- cli/**
push:
branches:
- main

View File

@@ -23,4 +23,5 @@ jobs:
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
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

@@ -21,7 +21,7 @@ on:
- 'vendor/**'
# storage
- 'storage/**'
- 'services/storage/**'
push:
branches:
- main

View File

@@ -7,6 +7,8 @@ linters:
settings:
funlen:
lines: 65
wsl_v5:
allow-whole-block: true
disable:
- canonicalheader
- depguard

View File

@@ -2,6 +2,29 @@
All notable changes to this project will be documented in this file.
## [cli@1.33.0] - 2025-10-02
### 🚀 Features
- *(cli)* Migrate from urfave/v2 to urfave/v3 (#3545)
### 🐛 Bug Fixes
- *(cli)* Disable tls on AUTH_SERVER_URL when auth uses custom port (#3549)
- *(cli)* Fix breaking change in go-getter dependency (#3551)
### ⚙️ Miscellaneous Tasks
- *(cli)* Update certs (#3552)
## [cli@1.32.2] - 2025-10-01
### ⚙️ Miscellaneous Tasks
- *(cli)* Remove hasura- prefix from auth/storage images (#3538)
## [cli@1.32.1] - 2025-09-29
### ⚙️ Miscellaneous Tasks

View File

@@ -12,9 +12,9 @@ It's recommended to use the Nhost CLI and the [Nhost GitHub Integration](https:/
- [Nhost Dashboard](https://github.com/nhost/nhost/tree/main/dashboard)
- [Postgres Database](https://www.postgresql.org/)
- [Hasura's GraphQL Engine](https://github.com/hasura/graphql-engine)
- [Hasura Auth](https://github.com/nhost/hasura-auth)
- [Hasura Storage](https://github.com/nhost/hasura-storage)
- [GraphQL Engine](https://github.com/hasura/graphql-engine)
- [Auth](https://github.com/nhost/nhost/main/auth)
- [Storage](https://github.com/nhost/nhost/main/storage)
- [Nhost Serverless Functions](https://github.com/nhost/functions)
- [Minio S3](https://github.com/minio/minio)
- [Mailhog](https://github.com/mailhog/MailHog)
@@ -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

@@ -10,7 +10,7 @@ import (
"github.com/nhost/nhost/cli/nhostclient"
"github.com/nhost/nhost/cli/nhostclient/graphql"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
func sanitizeName(name string) string {
@@ -55,28 +55,28 @@ func New(
}
}
func FromCLI(cCtx *cli.Context) *CliEnv {
func FromCLI(cmd *cli.Command) *CliEnv {
cwd, err := os.Getwd()
if err != nil {
panic(err)
}
return &CliEnv{
stdout: cCtx.App.Writer,
stderr: cCtx.App.ErrWriter,
stdout: cmd.Writer,
stderr: cmd.ErrWriter,
Path: NewPathStructure(
cwd,
cCtx.String(flagRootFolder),
cCtx.String(flagDotNhostFolder),
cCtx.String(flagNhostFolder),
cmd.String(flagRootFolder),
cmd.String(flagDotNhostFolder),
cmd.String(flagNhostFolder),
),
authURL: cCtx.String(flagAuthURL),
graphqlURL: cCtx.String(flagGraphqlURL),
branch: cCtx.String(flagBranch),
projectName: sanitizeName(cCtx.String(flagProjectName)),
authURL: cmd.String(flagAuthURL),
graphqlURL: cmd.String(flagGraphqlURL),
branch: cmd.String(flagBranch),
projectName: sanitizeName(cmd.String(flagProjectName)),
nhclient: nil,
nhpublicclient: nil,
localSubdomain: cCtx.String(flagLocalSubdomain),
localSubdomain: cmd.String(flagLocalSubdomain),
}
}

View File

@@ -6,7 +6,7 @@ import (
"path/filepath"
"github.com/go-git/go-git/v5"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
const (
@@ -53,42 +53,42 @@ func Flags() ([]cli.Flag, error) {
&cli.StringFlag{ //nolint:exhaustruct
Name: flagAuthURL,
Usage: "Nhost auth URL",
EnvVars: []string{"NHOST_CLI_AUTH_URL"},
Sources: cli.EnvVars("NHOST_CLI_AUTH_URL"),
Value: "https://otsispdzcwxyqzbfntmj.auth.eu-central-1.nhost.run/v1",
Hidden: true,
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagGraphqlURL,
Usage: "Nhost GraphQL URL",
EnvVars: []string{"NHOST_CLI_GRAPHQL_URL"},
Sources: cli.EnvVars("NHOST_CLI_GRAPHQL_URL"),
Value: "https://otsispdzcwxyqzbfntmj.graphql.eu-central-1.nhost.run/v1",
Hidden: true,
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagBranch,
Usage: "Git branch name. If not set, it will be detected from the current git repository. This flag is used to dynamically create docker volumes for each branch. If you want to have a static volume name or if you are not using git, set this flag to a static value.", //nolint:lll
EnvVars: []string{"BRANCH"},
Sources: cli.EnvVars("BRANCH"),
Value: branch,
Hidden: false,
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagRootFolder,
Usage: "Root folder of project\n\t",
EnvVars: []string{"NHOST_ROOT_FOLDER"},
Sources: cli.EnvVars("NHOST_ROOT_FOLDER"),
Value: workingDir,
Category: "Project structure",
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagDotNhostFolder,
Usage: "Path to .nhost folder\n\t",
EnvVars: []string{"NHOST_DOT_NHOST_FOLDER"},
Sources: cli.EnvVars("NHOST_DOT_NHOST_FOLDER"),
Value: dotNhostFolder,
Category: "Project structure",
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagNhostFolder,
Usage: "Path to nhost folder\n\t",
EnvVars: []string{"NHOST_NHOST_FOLDER"},
Sources: cli.EnvVars("NHOST_NHOST_FOLDER"),
Value: nhostFolder,
Category: "Project structure",
},
@@ -96,13 +96,13 @@ func Flags() ([]cli.Flag, error) {
Name: flagProjectName,
Usage: "Project name",
Value: filepath.Base(fullWorkingDir),
EnvVars: []string{"NHOST_PROJECT_NAME"},
Sources: cli.EnvVars("NHOST_PROJECT_NAME"),
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagLocalSubdomain,
Usage: "Local subdomain to reach the development environment",
Value: "local",
EnvVars: []string{"NHOST_LOCAL_SUBDOMAIN"},
Sources: cli.EnvVars("NHOST_LOCAL_SUBDOMAIN"),
},
}, nil
}

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

@@ -8,7 +8,7 @@ import (
"github.com/nhost/be/services/mimir/model"
"github.com/nhost/nhost/cli/clienv"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
func CommandApply() *cli.Command {
@@ -22,38 +22,42 @@ func CommandApply() *cli.Command {
Name: flagSubdomain,
Usage: "Subdomain of the Nhost project to apply configuration to. Defaults to linked project",
Required: true,
EnvVars: []string{"NHOST_SUBDOMAIN"},
Sources: cli.EnvVars("NHOST_SUBDOMAIN"),
},
&cli.BoolFlag{ //nolint:exhaustruct
Name: flagYes,
Usage: "Skip confirmation",
EnvVars: []string{"NHOST_YES"},
Sources: cli.EnvVars("NHOST_YES"),
},
},
}
}
func commandApply(cCtx *cli.Context) error {
ce := clienv.FromCLI(cCtx)
func commandApply(ctx context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
proj, err := ce.GetAppInfo(cCtx.Context, cCtx.String(flagSubdomain))
proj, err := ce.GetAppInfo(ctx, cmd.String(flagSubdomain))
if err != nil {
return fmt.Errorf("failed to get app info: %w", err)
return cli.Exit(fmt.Sprintf("Failed to get app info: %v", err), 1)
}
ce.Infoln("Validating configuration...")
cfg, _, err := ValidateRemote(
cCtx.Context,
ctx,
ce,
proj.GetSubdomain(),
proj.GetID(),
)
if err != nil {
return err
return cli.Exit(err.Error(), 1)
}
return Apply(cCtx.Context, ce, proj.ID, cfg, cCtx.Bool(flagYes))
if err := Apply(ctx, ce, proj.ID, cfg, cmd.Bool(flagYes)); err != nil {
return cli.Exit(err.Error(), 1)
}
return nil
}
func Apply(

View File

@@ -1,6 +1,6 @@
package config
import "github.com/urfave/cli/v2"
import "github.com/urfave/cli/v3"
const flagSubdomain = "subdomain"
@@ -9,7 +9,7 @@ func Command() *cli.Command {
Name: "config",
Aliases: []string{},
Usage: "Perform config operations",
Subcommands: []*cli.Command{
Commands: []*cli.Command{
CommandDefault(),
CommandExample(),
CommandApply(),

View File

@@ -1,6 +1,7 @@
package config
import (
"context"
"fmt"
"os"
@@ -8,7 +9,7 @@ import (
"github.com/nhost/nhost/cli/project"
"github.com/nhost/nhost/cli/project/env"
"github.com/pelletier/go-toml/v2"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
func CommandDefault() *cli.Command {
@@ -21,8 +22,8 @@ func CommandDefault() *cli.Command {
}
}
func commandDefault(cCtx *cli.Context) error {
ce := clienv.FromCLI(cCtx)
func commandDefault(_ context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
if err := os.MkdirAll(ce.Path.NhostFolder(), 0o755); err != nil { //nolint:mnd
return fmt.Errorf("failed to create nhost folder: %w", err)

View File

@@ -13,7 +13,7 @@ import (
"github.com/nhost/be/services/mimir/model"
"github.com/nhost/nhost/cli/clienv"
"github.com/pelletier/go-toml/v2"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
"github.com/wI2L/jsondiff"
)
@@ -31,13 +31,13 @@ func CommandEdit() *cli.Command {
&cli.StringFlag{ //nolint:exhaustruct
Name: flagSubdomain,
Usage: "If specified, edit this subdomain's overlay, otherwise edit base configuation",
EnvVars: []string{"NHOST_SUBDOMAIN"},
Sources: cli.EnvVars("NHOST_SUBDOMAIN"),
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagEditor,
Usage: "Editor to use",
Value: "vim",
EnvVars: []string{"EDITOR"},
Sources: cli.EnvVars("EDITOR"),
},
},
}
@@ -139,11 +139,11 @@ func GenerateJSONPatch(origfilepath, newfilepath, dst string) error {
return nil
}
func edit(cCtx *cli.Context) error {
ce := clienv.FromCLI(cCtx)
func edit(ctx context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
if cCtx.String(flagSubdomain) == "" {
if err := EditFile(cCtx.Context, cCtx.String(flagEditor), ce.Path.NhostToml()); err != nil {
if cmd.String(flagSubdomain) == "" {
if err := EditFile(ctx, cmd.String(flagEditor), ce.Path.NhostToml()); err != nil {
return fmt.Errorf("failed to edit config: %w", err)
}
@@ -163,17 +163,17 @@ func edit(cCtx *cli.Context) error {
tmpfileName := filepath.Join(tmpdir, "nhost.toml")
if err := CopyConfig[model.ConfigConfig](
ce.Path.NhostToml(), tmpfileName, ce.Path.Overlay(cCtx.String(flagSubdomain)),
ce.Path.NhostToml(), tmpfileName, ce.Path.Overlay(cmd.String(flagSubdomain)),
); err != nil {
return fmt.Errorf("failed to copy config: %w", err)
}
if err := EditFile(cCtx.Context, cCtx.String(flagEditor), tmpfileName); err != nil {
if err := EditFile(ctx, cmd.String(flagEditor), tmpfileName); err != nil {
return fmt.Errorf("failed to edit config: %w", err)
}
if err := GenerateJSONPatch(
ce.Path.NhostToml(), tmpfileName, ce.Path.Overlay(cCtx.String(flagSubdomain)),
ce.Path.NhostToml(), tmpfileName, ce.Path.Overlay(cmd.String(flagSubdomain)),
); err != nil {
return fmt.Errorf("failed to generate json patch: %w", err)
}

View File

@@ -1,13 +1,14 @@
package config
import (
"context"
"fmt"
"github.com/nhost/be/services/mimir/model"
"github.com/nhost/be/services/mimir/schema"
"github.com/nhost/nhost/cli/clienv"
"github.com/pelletier/go-toml/v2"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
func CommandExample() *cli.Command {
@@ -22,8 +23,8 @@ func CommandExample() *cli.Command {
func ptr[T any](v T) *T { return &v }
func commandExample(cCtx *cli.Context) error { //nolint:funlen,maintidx
ce := clienv.FromCLI(cCtx)
func commandExample(_ context.Context, cmd *cli.Command) error { //nolint:funlen,maintidx
ce := clienv.FromCLI(cmd)
//nolint:mnd
cfg := model.ConfigConfig{

View File

@@ -13,7 +13,7 @@ import (
"github.com/nhost/nhost/cli/project/env"
"github.com/nhost/nhost/cli/system"
"github.com/pelletier/go-toml/v2"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
const (
@@ -36,21 +36,21 @@ func CommandPull() *cli.Command {
&cli.StringFlag{ //nolint:exhaustruct
Name: flagSubdomain,
Usage: "Pull this subdomain's configuration. Defaults to linked project",
EnvVars: []string{"NHOST_SUBDOMAIN"},
Sources: cli.EnvVars("NHOST_SUBDOMAIN"),
},
&cli.BoolFlag{ //nolint:exhaustruct
Name: flagYes,
Usage: "Skip confirmation",
EnvVars: []string{"NHOST_YES"},
Sources: cli.EnvVars("NHOST_YES"),
},
},
}
}
func commandPull(cCtx *cli.Context) error {
ce := clienv.FromCLI(cCtx)
func commandPull(ctx context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
skipConfirmation := cCtx.Bool(flagYes)
skipConfirmation := cmd.Bool(flagYes)
if !skipConfirmation {
if err := verifyFile(ce, ce.Path.NhostToml()); err != nil {
@@ -66,12 +66,12 @@ func commandPull(cCtx *cli.Context) error {
}
}
proj, err := ce.GetAppInfo(cCtx.Context, cCtx.String(flagSubdomain))
proj, err := ce.GetAppInfo(ctx, cmd.String(flagSubdomain))
if err != nil {
return fmt.Errorf("failed to get app info: %w", err)
}
_, err = Pull(cCtx.Context, ce, proj, writeSecrets)
_, err = Pull(ctx, ce, proj, writeSecrets)
return err
}

View File

@@ -1,13 +1,14 @@
package config
import (
"context"
"fmt"
"github.com/nhost/be/services/mimir/model"
"github.com/nhost/nhost/cli/clienv"
"github.com/nhost/nhost/cli/project/env"
"github.com/pelletier/go-toml/v2"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
func CommandShow() *cli.Command {
@@ -21,14 +22,14 @@ func CommandShow() *cli.Command {
&cli.StringFlag{ //nolint:exhaustruct
Name: flagSubdomain,
Usage: "Show this subdomain's rendered configuration. Defaults to base configuration",
EnvVars: []string{"NHOST_SUBDOMAIN"},
Sources: cli.EnvVars("NHOST_SUBDOMAIN"),
},
},
}
}
func commandShow(c *cli.Context) error {
ce := clienv.FromCLI(c)
func commandShow(_ context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
var secrets model.Secrets
if err := clienv.UnmarshalFile(ce.Path.Secrets(), &secrets, env.Unmarshal); err != nil {
@@ -38,7 +39,7 @@ func commandShow(c *cli.Context) error {
)
}
cfg, err := Validate(ce, c.String(flagSubdomain), secrets)
cfg, err := Validate(ce, cmd.String(flagSubdomain), secrets)
if err != nil {
return err
}

View File

@@ -13,7 +13,7 @@ import (
"github.com/nhost/nhost/cli/clienv"
"github.com/nhost/nhost/cli/project/env"
"github.com/pelletier/go-toml/v2"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
jsonpatch "gopkg.in/evanphx/json-patch.v5"
)
@@ -27,24 +27,24 @@ func CommandValidate() *cli.Command {
&cli.StringFlag{ //nolint:exhaustruct
Name: flagSubdomain,
Usage: "Validate this subdomain's configuration. Defaults to linked project",
EnvVars: []string{"NHOST_SUBDOMAIN"},
Sources: cli.EnvVars("NHOST_SUBDOMAIN"),
},
},
}
}
func commandValidate(cCtx *cli.Context) error {
ce := clienv.FromCLI(cCtx)
func commandValidate(ctx context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
subdomain := cCtx.String(flagSubdomain)
subdomain := cmd.String(flagSubdomain)
if subdomain != "" && subdomain != "local" {
proj, err := ce.GetAppInfo(cCtx.Context, cCtx.String(flagSubdomain))
proj, err := ce.GetAppInfo(ctx, cmd.String(flagSubdomain))
if err != nil {
return fmt.Errorf("failed to get app info: %w", err)
}
_, _, err = ValidateRemote(
cCtx.Context,
ctx,
ce,
proj.GetSubdomain(),
proj.GetID(),

View File

@@ -9,7 +9,7 @@ import (
"github.com/google/uuid"
"github.com/nhost/be/services/mimir/graph"
cors "github.com/rs/cors/wrapper/gin"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
const (
@@ -48,27 +48,27 @@ func Command() *cli.Command {
Name: enablePlaygroundFlag,
Usage: "enable graphql playground (under /v1)",
Category: "server",
EnvVars: []string{"ENABLE_PLAYGROUND"},
Sources: cli.EnvVars("ENABLE_PLAYGROUND"),
},
&cli.StringFlag{ //nolint: exhaustruct
Name: storageLocalConfigPath,
Usage: "Path to the local mimir config file",
Value: "/tmp/root/nhost/nhost.toml",
Category: "plugins",
EnvVars: []string{"STORAGE_LOCAL_CONFIG_PATH"},
Sources: cli.EnvVars("STORAGE_LOCAL_CONFIG_PATH"),
},
&cli.StringFlag{ //nolint: exhaustruct
Name: storageLocalSecretsPath,
Usage: "Path to the local mimir secrets file",
Value: "/tmp/root/.secrets",
Category: "plugins",
EnvVars: []string{"STORAGE_LOCAL_SECRETS_PATH"},
Sources: cli.EnvVars("STORAGE_LOCAL_SECRETS_PATH"),
},
&cli.StringSliceFlag{ //nolint: exhaustruct
Name: storageLocalRunServicesPath,
Usage: "Path to the local mimir run services files",
Category: "plugins",
EnvVars: []string{"STORAGE_LOCAL_RUN_SERVICES_PATH"},
Sources: cli.EnvVars("STORAGE_LOCAL_RUN_SERVICES_PATH"),
},
},
Action: serve,
@@ -103,14 +103,14 @@ func runServicesFiles(runServices ...string) map[string]string {
return m
}
func serve(cCtx *cli.Context) error {
logger := getLogger(cCtx.Bool(debugFlag), cCtx.Bool(logFormatJSONFlag))
logger.Info(cCtx.App.Name + " v" + cCtx.App.Version)
logFlags(logger, cCtx)
func serve(_ context.Context, cmd *cli.Command) error {
logger := getLogger(cmd.Bool(debugFlag), cmd.Bool(logFormatJSONFlag))
logger.Info(cmd.Root().Name + " v" + cmd.Root().Version)
logFlags(logger, cmd)
configFile := cCtx.String(storageLocalConfigPath)
secretsFile := cCtx.String(storageLocalSecretsPath)
runServices := runServicesFiles(cCtx.StringSlice(storageLocalRunServicesPath)...)
configFile := cmd.String(storageLocalConfigPath)
secretsFile := cmd.String(storageLocalSecretsPath)
runServices := runServicesFiles(cmd.StringSlice(storageLocalRunServicesPath)...)
st := NewLocal(configFile, secretsFile, runServices)
@@ -131,13 +131,13 @@ func serve(cCtx *cli.Context) error {
resolver,
dummyMiddleware,
dummyMiddleware2,
cCtx.Bool(enablePlaygroundFlag),
cCtx.App.Version,
cmd.Bool(enablePlaygroundFlag),
cmd.Root().Version,
[]graphql.FieldMiddleware{},
gin.Recovery(),
cors.Default(),
)
if err := r.Run(cCtx.String(bindFlag)); err != nil {
if err := r.Run(cmd.String(bindFlag)); err != nil {
return fmt.Errorf("failed to run gin: %w", err)
}

View File

@@ -5,7 +5,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
func getLogger(debug bool, formatJSON bool) *logrus.Logger {
@@ -29,15 +29,15 @@ func getLogger(debug bool, formatJSON bool) *logrus.Logger {
return logger
}
func logFlags(logger logrus.FieldLogger, cCtx *cli.Context) {
func logFlags(logger logrus.FieldLogger, cmd *cli.Command) {
fields := logrus.Fields{}
for _, flag := range cCtx.App.Flags {
for _, flag := range cmd.Root().Flags {
name := flag.Names()[0]
fields[name] = cCtx.Generic(name)
fields[name] = cmd.Value(name)
}
for _, flag := range cCtx.Command.Flags {
for _, flag := range cmd.Flags {
name := flag.Names()[0]
if strings.Contains(name, "pass") ||
strings.Contains(name, "token") ||
@@ -47,7 +47,7 @@ func logFlags(logger logrus.FieldLogger, cCtx *cli.Context) {
continue
}
fields[name] = cCtx.Generic(name)
fields[name] = cmd.Value(name)
}
logger.WithFields(fields).Info("started with settings")

View File

@@ -1,6 +1,6 @@
package deployments
import "github.com/urfave/cli/v2"
import "github.com/urfave/cli/v3"
const flagSubdomain = "subdomain"
@@ -9,7 +9,7 @@ func commonFlags() []cli.Flag {
&cli.StringFlag{ //nolint:exhaustruct
Name: flagSubdomain,
Usage: "Project's subdomain to operate on, defaults to linked project",
EnvVars: []string{"NHOST_SUBDOMAIN"},
Sources: cli.EnvVars("NHOST_SUBDOMAIN"),
},
}
}
@@ -19,7 +19,7 @@ func Command() *cli.Command {
Name: "deployments",
Aliases: []string{},
Usage: "Manage deployments",
Subcommands: []*cli.Command{
Commands: []*cli.Command{
CommandList(),
CommandLogs(),
CommandNew(),

View File

@@ -1,12 +1,13 @@
package deployments
import (
"context"
"fmt"
"time"
"github.com/nhost/nhost/cli/clienv"
"github.com/nhost/nhost/cli/nhostclient/graphql"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
func CommandList() *cli.Command {
@@ -77,21 +78,21 @@ func printDeployments(ce *clienv.CliEnv, deployments []*graphql.ListDeployments_
ce.Println("%s", clienv.Table(id, date, duration, status, user, ref, message))
}
func commandList(cCtx *cli.Context) error {
ce := clienv.FromCLI(cCtx)
func commandList(ctx context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
proj, err := ce.GetAppInfo(cCtx.Context, cCtx.String(flagSubdomain))
proj, err := ce.GetAppInfo(ctx, cmd.String(flagSubdomain))
if err != nil {
return fmt.Errorf("failed to get app info: %w", err)
}
cl, err := ce.GetNhostClient(cCtx.Context)
cl, err := ce.GetNhostClient(ctx)
if err != nil {
return fmt.Errorf("failed to get nhost client: %w", err)
}
deployments, err := cl.ListDeployments(
cCtx.Context,
ctx,
proj.ID,
)
if err != nil {

View File

@@ -8,7 +8,7 @@ import (
"github.com/nhost/nhost/cli/clienv"
"github.com/nhost/nhost/cli/nhostclient"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
const (
@@ -101,28 +101,28 @@ func showLogsFollow(
}
}
func commandLogs(cCtx *cli.Context) error {
deploymentID := cCtx.Args().First()
func commandLogs(ctx context.Context, cmd *cli.Command) error {
deploymentID := cmd.Args().First()
if deploymentID == "" {
return errors.New("deployment_id is required") //nolint:err113
}
ce := clienv.FromCLI(cCtx)
ce := clienv.FromCLI(cmd)
cl, err := ce.GetNhostClient(cCtx.Context)
cl, err := ce.GetNhostClient(ctx)
if err != nil {
return fmt.Errorf("failed to get nhost client: %w", err)
}
if cCtx.Bool(flagFollow) {
ctx, cancel := context.WithTimeout(cCtx.Context, cCtx.Duration(flagTimeout))
if cmd.Bool(flagFollow) {
ctxWithTimeout, cancel := context.WithTimeout(ctx, cmd.Duration(flagTimeout))
defer cancel()
if _, err := showLogsFollow(ctx, ce, cl, deploymentID); err != nil {
if _, err := showLogsFollow(ctxWithTimeout, ce, cl, deploymentID); err != nil {
return err
}
} else {
if err := showLogsSimple(cCtx.Context, ce, cl, deploymentID); err != nil {
if err := showLogsSimple(ctx, ce, cl, deploymentID); err != nil {
return err
}
}

View File

@@ -7,7 +7,7 @@ import (
"github.com/nhost/nhost/cli/clienv"
"github.com/nhost/nhost/cli/nhostclient/graphql"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
const (
@@ -40,7 +40,7 @@ func CommandNew() *cli.Command {
&cli.StringFlag{ //nolint:exhaustruct
Name: flagRef,
Usage: "Git reference",
EnvVars: []string{"GITHUB_SHA"},
Sources: cli.EnvVars("GITHUB_SHA"),
Required: true,
},
&cli.StringFlag{ //nolint:exhaustruct
@@ -51,7 +51,7 @@ func CommandNew() *cli.Command {
&cli.StringFlag{ //nolint:exhaustruct
Name: flagUser,
Usage: "Commit user name",
EnvVars: []string{"GITHUB_ACTOR"},
Sources: cli.EnvVars("GITHUB_ACTOR"),
Required: true,
},
&cli.StringFlag{ //nolint:exhaustruct
@@ -67,28 +67,28 @@ func ptr[i any](v i) *i {
return &v
}
func commandNew(cCtx *cli.Context) error {
ce := clienv.FromCLI(cCtx)
func commandNew(ctx context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
cl, err := ce.GetNhostClient(cCtx.Context)
cl, err := ce.GetNhostClient(ctx)
if err != nil {
return fmt.Errorf("failed to get nhost client: %w", err)
}
proj, err := ce.GetAppInfo(cCtx.Context, cCtx.String(flagSubdomain))
proj, err := ce.GetAppInfo(ctx, cmd.String(flagSubdomain))
if err != nil {
return fmt.Errorf("failed to get app info: %w", err)
}
resp, err := cl.InsertDeployment(
cCtx.Context,
ctx,
graphql.DeploymentsInsertInput{
App: nil,
AppID: ptr(proj.ID),
CommitMessage: ptr(cCtx.String(flagMessage)),
CommitSha: ptr(cCtx.String(flagRef)),
CommitUserAvatarURL: ptr(cCtx.String(flagUserAvatarURL)),
CommitUserName: ptr(cCtx.String(flagUser)),
CommitMessage: ptr(cmd.String(flagMessage)),
CommitSha: ptr(cmd.String(flagRef)),
CommitUserAvatarURL: ptr(cmd.String(flagUserAvatarURL)),
CommitUserName: ptr(cmd.String(flagUser)),
DeploymentStatus: ptr("SCHEDULED"),
},
)
@@ -98,13 +98,13 @@ func commandNew(cCtx *cli.Context) error {
ce.Println("Deployment created: %s", resp.InsertDeployment.ID)
if cCtx.Bool(flagFollow) {
if cmd.Bool(flagFollow) {
ce.Println("")
ctx, cancel := context.WithTimeout(cCtx.Context, cCtx.Duration(flagTimeout))
ctxWithTimeout, cancel := context.WithTimeout(ctx, cmd.Duration(flagTimeout))
defer cancel()
status, err := showLogsFollow(ctx, ce, cl, resp.InsertDeployment.ID)
status, err := showLogsFollow(ctxWithTimeout, ce, cl, resp.InsertDeployment.ID)
if err != nil {
return fmt.Errorf("error streaming logs: %w", err)
}

View File

@@ -15,7 +15,7 @@ import (
"github.com/nhost/nhost/cli/cmd/software"
"github.com/nhost/nhost/cli/dockercompose"
"github.com/nhost/nhost/cli/nhostclient/graphql"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
const (
@@ -34,19 +34,19 @@ func CommandCloud() *cli.Command {
Name: flagHTTPPort,
Usage: "HTTP port to listen on",
Value: defaultHTTPPort,
EnvVars: []string{"NHOST_HTTP_PORT"},
Sources: cli.EnvVars("NHOST_HTTP_PORT"),
},
&cli.BoolFlag{ //nolint:exhaustruct
Name: flagDisableTLS,
Usage: "Disable TLS",
Value: false,
EnvVars: []string{"NHOST_DISABLE_TLS"},
Sources: cli.EnvVars("NHOST_DISABLE_TLS"),
},
&cli.BoolFlag{ //nolint:exhaustruct
Name: flagApplySeeds,
Usage: "Apply seeds. If the .nhost folder does not exist, seeds will be applied regardless of this flag",
Value: false,
EnvVars: []string{"NHOST_APPLY_SEEDS"},
Sources: cli.EnvVars("NHOST_APPLY_SEEDS"),
},
&cli.UintFlag{ //nolint:exhaustruct
Name: flagsHasuraConsolePort,
@@ -56,42 +56,42 @@ func CommandCloud() *cli.Command {
&cli.StringFlag{ //nolint:exhaustruct
Name: flagDashboardVersion,
Usage: "Dashboard version to use",
Value: "nhost/dashboard:2.33.0",
EnvVars: []string{"NHOST_DASHBOARD_VERSION"},
Value: "nhost/dashboard:2.38.0",
Sources: cli.EnvVars("NHOST_DASHBOARD_VERSION"),
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagConfigserverImage,
Hidden: true,
Value: "",
EnvVars: []string{"NHOST_CONFIGSERVER_IMAGE"},
Sources: cli.EnvVars("NHOST_CONFIGSERVER_IMAGE"),
},
&cli.BoolFlag{ //nolint:exhaustruct
Name: flagDownOnError,
Usage: "Skip confirmation",
EnvVars: []string{"NHOST_YES"},
Sources: cli.EnvVars("NHOST_YES"),
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagCACertificates,
Usage: "Mounts and everrides path to CA certificates in the containers",
EnvVars: []string{"NHOST_CA_CERTIFICATES"},
Sources: cli.EnvVars("NHOST_CA_CERTIFICATES"),
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagSubdomain,
Usage: "Project's subdomain to operate on, defaults to linked project",
EnvVars: []string{"NHOST_SUBDOMAIN"},
Sources: cli.EnvVars("NHOST_SUBDOMAIN"),
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagPostgresURL,
Usage: "Postgres URL",
Required: true,
EnvVars: []string{"NHOST_POSTGRES_URL"},
Sources: cli.EnvVars("NHOST_POSTGRES_URL"),
},
},
}
}
func commandCloud(cCtx *cli.Context) error {
ce := clienv.FromCLI(cCtx)
func commandCloud(ctx context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
if !clienv.PathExists(ce.Path.NhostToml()) {
return errors.New( //nolint:err113
@@ -105,38 +105,38 @@ func commandCloud(cCtx *cli.Context) error {
)
}
proj, err := ce.GetAppInfo(cCtx.Context, cCtx.String(flagSubdomain))
proj, err := ce.GetAppInfo(ctx, cmd.String(flagSubdomain))
if err != nil {
return fmt.Errorf("failed to get app info: %w", err)
}
configserverImage := cCtx.String(flagConfigserverImage)
configserverImage := cmd.String(flagConfigserverImage)
if configserverImage == "" {
configserverImage = "nhost/cli:" + cCtx.App.Version
configserverImage = "nhost/cli:" + cmd.Root().Version
}
applySeeds := cCtx.Bool(flagApplySeeds)
applySeeds := cmd.Bool(flagApplySeeds)
return Cloud(
cCtx.Context,
ctx,
ce,
cCtx.App.Version,
cCtx.Uint(flagHTTPPort),
!cCtx.Bool(flagDisableTLS),
cmd.Root().Version,
cmd.Uint(flagHTTPPort),
!cmd.Bool(flagDisableTLS),
applySeeds,
dockercompose.ExposePorts{
Auth: cCtx.Uint(flagAuthPort),
Storage: cCtx.Uint(flagStoragePort),
Graphql: cCtx.Uint(flagsHasuraPort),
Console: cCtx.Uint(flagsHasuraConsolePort),
Functions: cCtx.Uint(flagsFunctionsPort),
Auth: cmd.Uint(flagAuthPort),
Storage: cmd.Uint(flagStoragePort),
Graphql: cmd.Uint(flagsHasuraPort),
Console: cmd.Uint(flagsHasuraConsolePort),
Functions: cmd.Uint(flagsFunctionsPort),
},
cCtx.String(flagDashboardVersion),
cmd.String(flagDashboardVersion),
configserverImage,
cCtx.String(flagCACertificates),
cCtx.Bool(flagDownOnError),
cmd.String(flagCACertificates),
cmd.Bool(flagDownOnError),
proj,
cCtx.String(flagPostgresURL),
cmd.String(flagPostgresURL),
)
}

View File

@@ -1,9 +1,11 @@
package dev
import (
"context"
"github.com/nhost/nhost/cli/clienv"
"github.com/nhost/nhost/cli/dockercompose"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
func CommandCompose() *cli.Command {
@@ -17,9 +19,9 @@ func CommandCompose() *cli.Command {
}
}
func commandCompose(cCtx *cli.Context) error {
ce := clienv.FromCLI(cCtx)
func commandCompose(ctx context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
dc := dockercompose.New(ce.Path.WorkingDir(), ce.Path.DockerCompose(), ce.ProjectName())
return dc.Wrapper(cCtx.Context, cCtx.Args().Slice()...) //nolint:wrapcheck
return dc.Wrapper(ctx, cmd.Args().Slice()...) //nolint:wrapcheck
}

View File

@@ -1,13 +1,13 @@
package dev
import "github.com/urfave/cli/v2"
import "github.com/urfave/cli/v3"
func Command() *cli.Command {
return &cli.Command{ //nolint:exhaustruct
Name: "dev",
Aliases: []string{},
Usage: "Operate local development environment",
Subcommands: []*cli.Command{
Commands: []*cli.Command{
CommandCompose(),
CommandHasura(),
},

View File

@@ -1,9 +1,11 @@
package dev
import (
"context"
"github.com/nhost/nhost/cli/clienv"
"github.com/nhost/nhost/cli/dockercompose"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
const (
@@ -26,12 +28,12 @@ func CommandDown() *cli.Command {
}
}
func commandDown(cCtx *cli.Context) error {
ce := clienv.FromCLI(cCtx)
func commandDown(ctx context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
dc := dockercompose.New(ce.Path.WorkingDir(), ce.Path.DockerCompose(), ce.ProjectName())
if err := dc.Stop(cCtx.Context, cCtx.Bool(flagVolumes)); err != nil {
if err := dc.Stop(ctx, cmd.Bool(flagVolumes)); err != nil {
ce.Warnln("failed to stop Nhost development environment: %s", err)
}

View File

@@ -1,13 +1,14 @@
package dev
import (
"context"
"fmt"
"github.com/nhost/be/services/mimir/model"
"github.com/nhost/nhost/cli/clienv"
"github.com/nhost/nhost/cli/dockercompose"
"github.com/pelletier/go-toml/v2"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
func CommandHasura() *cli.Command {
@@ -21,8 +22,8 @@ func CommandHasura() *cli.Command {
}
}
func commandHasura(cCtx *cli.Context) error {
ce := clienv.FromCLI(cCtx)
func commandHasura(ctx context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
cfg := &model.ConfigConfig{} //nolint:exhaustruct
if err := clienv.UnmarshalFile(ce.Path.NhostToml(), cfg, toml.Unmarshal); err != nil {
@@ -32,10 +33,10 @@ func commandHasura(cCtx *cli.Context) error {
docker := dockercompose.NewDocker()
return docker.HasuraWrapper( //nolint:wrapcheck
cCtx.Context,
ctx,
ce.LocalSubdomain(),
ce.Path.NhostFolder(),
*cfg.Hasura.Version,
cCtx.Args().Slice()...,
cmd.Args().Slice()...,
)
}

View File

@@ -1,9 +1,11 @@
package dev
import (
"context"
"github.com/nhost/nhost/cli/clienv"
"github.com/nhost/nhost/cli/dockercompose"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
func CommandLogs() *cli.Command {
@@ -17,12 +19,12 @@ func CommandLogs() *cli.Command {
}
}
func commandLogs(cCtx *cli.Context) error {
ce := clienv.FromCLI(cCtx)
func commandLogs(ctx context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
dc := dockercompose.New(ce.Path.WorkingDir(), ce.Path.DockerCompose(), ce.ProjectName())
if err := dc.Logs(cCtx.Context, cCtx.Args().Slice()...); err != nil {
if err := dc.Logs(ctx, cmd.Args().Slice()...); err != nil {
ce.Warnln("%s", err)
}

View File

@@ -19,7 +19,7 @@ import (
"github.com/nhost/nhost/cli/cmd/software"
"github.com/nhost/nhost/cli/dockercompose"
"github.com/nhost/nhost/cli/project/env"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
func deptr[T any](t *T) T { //nolint:ireturn
@@ -63,25 +63,25 @@ func CommandUp() *cli.Command { //nolint:funlen
Name: flagHTTPPort,
Usage: "HTTP port to listen on",
Value: defaultHTTPPort,
EnvVars: []string{"NHOST_HTTP_PORT"},
Sources: cli.EnvVars("NHOST_HTTP_PORT"),
},
&cli.BoolFlag{ //nolint:exhaustruct
Name: flagDisableTLS,
Usage: "Disable TLS",
Value: false,
EnvVars: []string{"NHOST_DISABLE_TLS"},
Sources: cli.EnvVars("NHOST_DISABLE_TLS"),
},
&cli.UintFlag{ //nolint:exhaustruct
Name: flagPostgresPort,
Usage: "Postgres port to listen on",
Value: defaultPostgresPort,
EnvVars: []string{"NHOST_POSTGRES_PORT"},
Sources: cli.EnvVars("NHOST_POSTGRES_PORT"),
},
&cli.BoolFlag{ //nolint:exhaustruct
Name: flagApplySeeds,
Usage: "Apply seeds. If the .nhost folder does not exist, seeds will be applied regardless of this flag",
Value: false,
EnvVars: []string{"NHOST_APPLY_SEEDS"},
Sources: cli.EnvVars("NHOST_APPLY_SEEDS"),
},
&cli.UintFlag{ //nolint:exhaustruct
Name: flagAuthPort,
@@ -111,39 +111,39 @@ func CommandUp() *cli.Command { //nolint:funlen
&cli.StringFlag{ //nolint:exhaustruct
Name: flagDashboardVersion,
Usage: "Dashboard version to use",
Value: "nhost/dashboard:2.33.0",
EnvVars: []string{"NHOST_DASHBOARD_VERSION"},
Value: "nhost/dashboard:2.38.0",
Sources: cli.EnvVars("NHOST_DASHBOARD_VERSION"),
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagConfigserverImage,
Hidden: true,
Value: "",
EnvVars: []string{"NHOST_CONFIGSERVER_IMAGE"},
Sources: cli.EnvVars("NHOST_CONFIGSERVER_IMAGE"),
},
&cli.StringSliceFlag{ //nolint:exhaustruct
Name: flagRunService,
Usage: "Run service to add to the development environment. Can be passed multiple times. Comma-separated values are also accepted. Format: /path/to/run-service.toml[:overlay_name]", //nolint:lll
EnvVars: []string{"NHOST_RUN_SERVICE"},
Sources: cli.EnvVars("NHOST_RUN_SERVICE"),
},
&cli.BoolFlag{ //nolint:exhaustruct
Name: flagDownOnError,
Usage: "Skip confirmation",
EnvVars: []string{"NHOST_YES"},
Sources: cli.EnvVars("NHOST_YES"),
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagCACertificates,
Usage: "Mounts and everrides path to CA certificates in the containers",
EnvVars: []string{"NHOST_CA_CERTIFICATES"},
Sources: cli.EnvVars("NHOST_CA_CERTIFICATES"),
},
},
Subcommands: []*cli.Command{
Commands: []*cli.Command{
CommandCloud(),
},
}
}
func commandUp(cCtx *cli.Context) error {
ce := clienv.FromCLI(cCtx)
func commandUp(ctx context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
// projname to be root directory
@@ -159,33 +159,33 @@ func commandUp(cCtx *cli.Context) error {
)
}
configserverImage := cCtx.String(flagConfigserverImage)
configserverImage := cmd.String(flagConfigserverImage)
if configserverImage == "" {
configserverImage = "nhost/cli:" + cCtx.App.Version
configserverImage = "nhost/cli:" + cmd.Root().Version
}
applySeeds := cCtx.Bool(flagApplySeeds) || !clienv.PathExists(ce.Path.DotNhostFolder())
applySeeds := cmd.Bool(flagApplySeeds) || !clienv.PathExists(ce.Path.DotNhostFolder())
return Up(
cCtx.Context,
ctx,
ce,
cCtx.App.Version,
cCtx.Uint(flagHTTPPort),
!cCtx.Bool(flagDisableTLS),
cCtx.Uint(flagPostgresPort),
cmd.Root().Version,
cmd.Uint(flagHTTPPort),
!cmd.Bool(flagDisableTLS),
cmd.Uint(flagPostgresPort),
applySeeds,
dockercompose.ExposePorts{
Auth: cCtx.Uint(flagAuthPort),
Storage: cCtx.Uint(flagStoragePort),
Graphql: cCtx.Uint(flagsHasuraPort),
Console: cCtx.Uint(flagsHasuraConsolePort),
Functions: cCtx.Uint(flagsFunctionsPort),
Auth: cmd.Uint(flagAuthPort),
Storage: cmd.Uint(flagStoragePort),
Graphql: cmd.Uint(flagsHasuraPort),
Console: cmd.Uint(flagsHasuraConsolePort),
Functions: cmd.Uint(flagsFunctionsPort),
},
cCtx.String(flagDashboardVersion),
cmd.String(flagDashboardVersion),
configserverImage,
cCtx.String(flagCACertificates),
cCtx.StringSlice(flagRunService),
cCtx.Bool(flagDownOnError),
cmd.String(flagCACertificates),
cmd.StringSlice(flagRunService),
cmd.Bool(flagDownOnError),
)
}

View File

@@ -8,7 +8,7 @@ import (
"os/exec"
"github.com/nhost/nhost/cli/clienv"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
const (
@@ -35,13 +35,13 @@ func CommandConfigure() *cli.Command {
&cli.StringFlag{ //nolint:exhaustruct
Name: flagDockerConfig,
Usage: "Path to docker config file",
EnvVars: []string{"DOCKER_CONFIG"},
Sources: cli.EnvVars("DOCKER_CONFIG"),
Value: home + "/.docker/config.json",
},
&cli.BoolFlag{ //nolint:exhaustruct
Name: flagNoInteractive,
Usage: "Do not prompt for confirmation",
EnvVars: []string{"NO_INTERACTIVE"},
Sources: cli.EnvVars("NO_INTERACTIVE"),
Value: false,
},
},
@@ -140,21 +140,21 @@ func configureDocker(dockerConfig string) error {
return nil
}
func actionConfigure(c *cli.Context) error {
ce := clienv.FromCLI(c)
func actionConfigure(ctx context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
if err := writeScript(c.Context, ce); err != nil {
if err := writeScript(ctx, ce); err != nil {
return err
}
if c.Bool(flagNoInteractive) {
return configureDocker(c.String(flagDockerConfig))
if cmd.Bool(flagNoInteractive) {
return configureDocker(cmd.String(flagDockerConfig))
}
//nolint:lll
ce.PromptMessage(
"I am about to configure docker to authenticate with Nhost's registry. This will modify your docker config file on %s. Should I continue? [y/N] ",
c.String(flagDockerConfig),
cmd.String(flagDockerConfig),
)
v, err := ce.PromptInput(false)
@@ -163,7 +163,7 @@ func actionConfigure(c *cli.Context) error {
}
if v == "y" || v == "Y" {
return configureDocker(c.String(flagDockerConfig))
return configureDocker(cmd.String(flagDockerConfig))
}
return nil

View File

@@ -1,13 +1,13 @@
package dockercredentials
import "github.com/urfave/cli/v2"
import "github.com/urfave/cli/v3"
func Command() *cli.Command {
return &cli.Command{ //nolint:exhaustruct
Name: "docker-credentials",
Aliases: []string{},
Usage: "Perform docker-credentials operations",
Subcommands: []*cli.Command{
Commands: []*cli.Command{
CommandGet(),
CommandErase(),
CommandStore(),

View File

@@ -1,7 +1,9 @@
package dockercredentials
import (
"github.com/urfave/cli/v2"
"context"
"github.com/urfave/cli/v3"
)
func CommandErase() *cli.Command {
@@ -14,7 +16,7 @@ func CommandErase() *cli.Command {
}
}
func actionErase(c *cli.Context) error {
_, _ = c.App.Writer.Write([]byte("Please, use the nhost CLI to logout\n"))
func actionErase(_ context.Context, cmd *cli.Command) error {
_, _ = cmd.Root().Writer.Write([]byte("Please, use the nhost CLI to logout\n"))
return nil
}

View File

@@ -8,7 +8,7 @@ import (
"os"
"github.com/nhost/nhost/cli/clienv"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
const (
@@ -26,14 +26,14 @@ func CommandGet() *cli.Command {
&cli.StringFlag{ //nolint:exhaustruct
Name: flagAuthURL,
Usage: "Nhost auth URL",
EnvVars: []string{"NHOST_CLI_AUTH_URL"},
Sources: cli.EnvVars("NHOST_CLI_AUTH_URL"),
Value: "https://otsispdzcwxyqzbfntmj.auth.eu-central-1.nhost.run/v1",
Hidden: true,
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagGraphqlURL,
Usage: "Nhost GraphQL URL",
EnvVars: []string{"NHOST_CLI_GRAPHQL_URL"},
Sources: cli.EnvVars("NHOST_CLI_GRAPHQL_URL"),
Value: "https://otsispdzcwxyqzbfntmj.graphql.eu-central-1.nhost.run/v1",
Hidden: true,
},
@@ -69,15 +69,15 @@ type response struct {
Secret string `json:"Secret"`
}
func actionGet(c *cli.Context) error {
scanner := bufio.NewScanner(c.App.Reader)
func actionGet(ctx context.Context, cmd *cli.Command) error {
scanner := bufio.NewScanner(cmd.Root().Reader)
var input string
for scanner.Scan() {
input += scanner.Text()
}
token, err := getToken(c.Context, c.String(flagAuthURL), c.String(flagGraphqlURL))
token, err := getToken(ctx, cmd.String(flagAuthURL), cmd.String(flagGraphqlURL))
if err != nil {
return err
}
@@ -91,7 +91,7 @@ func actionGet(c *cli.Context) error {
return fmt.Errorf("failed to marshal response: %w", err)
}
if _, err = c.App.Writer.Write(b); err != nil {
if _, err = cmd.Root().Writer.Write(b); err != nil {
return fmt.Errorf("failed to write response: %w", err)
}

View File

@@ -1,7 +1,9 @@
package dockercredentials
import (
"github.com/urfave/cli/v2"
"context"
"github.com/urfave/cli/v3"
)
func CommandStore() *cli.Command {
@@ -14,7 +16,7 @@ func CommandStore() *cli.Command {
}
}
func actionStore(c *cli.Context) error {
_, _ = c.App.Writer.Write([]byte("Please, use the nhost CLI to login\n"))
func actionStore(_ context.Context, cmd *cli.Command) error {
_, _ = cmd.Root().Writer.Write([]byte("Please, use the nhost CLI to login\n"))
return nil
}

View File

@@ -0,0 +1,92 @@
package config
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/nhost/nhost/cli/mcp/config"
"github.com/pelletier/go-toml/v2"
"github.com/urfave/cli/v3"
)
const (
flagConfirm = "confirm"
)
func Command() *cli.Command {
return &cli.Command{ //nolint:exhaustruct
Name: "config",
Usage: "Generate and save configuration file",
Flags: []cli.Flag{
&cli.BoolFlag{ //nolint:exhaustruct
Name: flagConfirm,
Usage: "Skip confirmation prompt",
Value: false,
Sources: cli.EnvVars("CONFIRM"),
},
},
Commands: []*cli.Command{
{
Name: "dump",
Usage: "Dump the configuration to stdout for verification",
Flags: []cli.Flag{},
Action: actionDump,
},
},
Action: action,
}
}
//nolint:forbidigo
func action(_ context.Context, cmd *cli.Command) error {
cfg, err := config.RunWizard()
if err != nil {
return cli.Exit(fmt.Sprintf("failed to run wizard: %s", err), 1)
}
tomlData, err := toml.Marshal(cfg)
if err != nil {
return cli.Exit(fmt.Sprintf("failed to marshal config: %s", err), 1)
}
fmt.Println("Configuration Preview:")
fmt.Println("---------------------")
fmt.Println(string(tomlData))
fmt.Println()
filePath := config.GetConfigPath(cmd)
fmt.Printf("Save configuration to %s?\n", filePath)
fmt.Print("Proceed? (y/N): ")
var confirm string
if _, err := fmt.Scanln(&confirm); err != nil {
return cli.Exit(fmt.Sprintf("failed to read input: %s", err), 1)
}
if confirm != "y" && confirm != "Y" {
fmt.Println("Operation cancelled.")
return nil
}
dir := filepath.Dir(filePath)
if err := os.MkdirAll(dir, 0o755); err != nil { //nolint:mnd
return fmt.Errorf("failed to create config directory: %w", err)
}
data, err := toml.Marshal(cfg)
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
if err := os.WriteFile(filePath, data, 0o600); err != nil { //nolint:mnd
return fmt.Errorf("failed to write config file: %w", err)
}
fmt.Println("\nConfiguration saved successfully!")
fmt.Println("Note: Review the documentation for additional configuration options,")
fmt.Println(" especially for fine-tuning LLM access permissions.")
return nil
}

View File

@@ -0,0 +1,35 @@
package config
import (
"context"
"fmt"
"github.com/nhost/nhost/cli/mcp/config"
"github.com/pelletier/go-toml/v2"
"github.com/urfave/cli/v3"
)
func actionDump(_ context.Context, cmd *cli.Command) error {
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
return cli.Exit("failed to load config file "+err.Error(), 1)
}
b, err := toml.Marshal(cfg)
if err != nil {
return cli.Exit("failed to marshal config file "+err.Error(), 1)
}
fmt.Println("Configuration Preview:") //nolint:forbidigo
fmt.Println("---------------------") //nolint:forbidigo
fmt.Println(string(b)) //nolint:forbidigo
fmt.Println() //nolint:forbidigo
return nil
}

117
cli/cmd/mcp/gen/gen.go Normal file
View File

@@ -0,0 +1,117 @@
package gen
import (
"context"
"fmt"
"github.com/nhost/nhost/cli/mcp/graphql"
"github.com/nhost/nhost/cli/mcp/nhost/auth"
"github.com/urfave/cli/v3"
)
const (
flagNhostAuthURL = "nhost-auth-url"
flagNhostGraphqlURL = "nhost-graphql-url"
flagNhostPAT = "nhost-pat"
flagWithMutations = "with-mutations"
)
func Command() *cli.Command {
return &cli.Command{ //nolint:exhaustruct
Name: "gen",
Usage: "Generate GraphQL schema for Nhost Cloud",
Flags: []cli.Flag{
&cli.StringFlag{ //nolint:exhaustruct
Name: flagNhostAuthURL,
Usage: "Nhost auth URL",
Hidden: true,
Value: "https://otsispdzcwxyqzbfntmj.auth.eu-central-1.nhost.run/v1",
Sources: cli.EnvVars("NHOST_AUTH_URL"),
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagNhostGraphqlURL,
Usage: "Nhost GraphQL URL",
Hidden: true,
Value: "https://otsispdzcwxyqzbfntmj.graphql.eu-central-1.nhost.run/v1",
Sources: cli.EnvVars("NHOST_GRAPHQL_URL"),
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagNhostPAT,
Usage: "Personal Access Token",
Required: true,
Sources: cli.EnvVars("NHOST_PAT"),
},
&cli.BoolFlag{ //nolint:exhaustruct
Name: flagWithMutations,
Usage: "Include mutations in the generated schema",
Value: false,
Sources: cli.EnvVars("WITH_MUTATIONS"),
},
},
Action: action,
}
}
func action(ctx context.Context, cmd *cli.Command) error {
interceptor, err := auth.WithPAT(
cmd.String(flagNhostAuthURL), cmd.String(flagNhostPAT),
)
if err != nil {
return cli.Exit(err.Error(), 1)
}
var introspection graphql.ResponseIntrospection
if err := graphql.Query(
ctx,
cmd.String(flagNhostGraphqlURL),
graphql.IntrospectionQuery,
nil,
&introspection,
nil,
nil,
interceptor,
); err != nil {
return cli.Exit(err.Error(), 1)
}
filter := graphql.Filter{
AllowQueries: []graphql.Queries{
{
Name: "organizations",
DisableNesting: true,
},
{
Name: "organization",
DisableNesting: true,
},
{
Name: "app",
DisableNesting: true,
},
{
Name: "apps",
DisableNesting: true,
},
{
Name: "config",
DisableNesting: false,
},
},
AllowMutations: []graphql.Queries{},
}
if cmd.Bool(flagWithMutations) {
filter.AllowMutations = []graphql.Queries{
{
Name: "updateConfig",
DisableNesting: false,
},
}
}
schema := graphql.ParseSchema(introspection, filter)
fmt.Print(schema) //nolint:forbidigo
return nil
}

33
cli/cmd/mcp/mcp.go Normal file
View File

@@ -0,0 +1,33 @@
package mcp
import (
"github.com/nhost/nhost/cli/cmd/mcp/config"
"github.com/nhost/nhost/cli/cmd/mcp/gen"
"github.com/nhost/nhost/cli/cmd/mcp/start"
"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(),
gen.Command(),
},
}
}

401
cli/cmd/mcp/mcp_test.go Normal file
View File

@@ -0,0 +1,401 @@
package mcp_test
import (
"bytes"
"context"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
"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/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,paralleltest
loginCmd := user.CommandLogin()
mcpCmd := nhostmcp.Command()
buf := bytes.NewBuffer(nil)
mcpCmd.Writer = buf
go func() {
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",
"start",
"--bind=:9000",
"--config-file=testdata/sample.toml",
},
); err != nil {
panic(err)
}
}()
time.Sleep(time.Second)
transportClient, err := transport.NewSSE("http://localhost:9000/sse")
if err != nil {
t.Fatalf("failed to create transport client: %v", err)
}
mcpClient := client.NewClient(transportClient)
if err := mcpClient.Start(t.Context()); err != nil {
t.Fatalf("failed to start mcp client: %v", err)
}
defer mcpClient.Close()
initRequest := mcp.InitializeRequest{} //nolint:exhaustruct
initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
initRequest.Params.ClientInfo = mcp.Implementation{
Name: "example-client",
Version: "1.0.0",
}
res, err := mcpClient.Initialize(
context.Background(),
initRequest,
)
if err != nil {
t.Fatalf("failed to initialize mcp client: %v", err)
}
if diff := cmp.Diff(
res,
//nolint:tagalign
&mcp.InitializeResult{
ProtocolVersion: "2025-06-18",
Capabilities: mcp.ServerCapabilities{
Elicitation: nil,
Experimental: nil,
Logging: nil,
Prompts: 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\""
}{
ListChanged: true,
},
},
ServerInfo: mcp.Implementation{
Name: "mcp",
Version: "",
},
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,
},
},
); diff != "" {
t.Errorf("ServerInfo mismatch (-want +got):\n%s", diff)
}
tools, err := mcpClient.ListTools(
context.Background(),
mcp.ListToolsRequest{}, //nolint:exhaustruct
)
if err != nil {
t.Fatalf("failed to list tools: %v", err)
}
if diff := cmp.Diff(
tools,
//nolint:exhaustruct,lll
&mcp.ListToolsResult{
Tools: []mcp.Tool{
{
Name: "cloud-graphql-query",
Description: cloud.ToolGraphqlQueryInstructions,
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 Cloud Platform",
ReadOnlyHint: ptr(false),
DestructiveHint: ptr(true),
IdempotentHint: ptr(false),
OpenWorldHint: ptr(true),
},
},
{
Name: "get-schema",
Description: schemas.ToolGetGraphqlSchemaInstructions,
InputSchema: mcp.ToolInputSchema{
Type: "object",
Properties: map[string]any{
"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"),
},
"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"),
},
"queries": map[string]any{
"description": string("list of queries to fetch"),
"type": string("array"),
},
"summary": map[string]any{
"default": bool(true),
"description": string("only return a summary of the schema"),
"type": string("boolean"),
},
},
Required: []string{"role", "subdomain"},
},
Annotations: mcp.ToolAnnotation{
Title: "Get GraphQL/API schema for various services",
ReadOnlyHint: ptr(true),
DestructiveHint: ptr(false),
IdempotentHint: ptr(true),
OpenWorldHint: ptr(true),
},
},
{
Name: "graphql-query",
Description: project.ToolGraphqlQueryInstructions,
InputSchema: mcp.ToolInputSchema{
Type: "object",
Properties: map[string]any{
"query": map[string]any{
"description": "graphql query to perform",
"type": "string",
},
"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. 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{
"description": string("Overrides X-Hasura-User-Id in the GraphQL query/mutation. Credentials must allow it (i.e. admin secret must be in use)"),
"type": string("string"),
},
"variables": map[string]any{
"description": "variables to use in the query",
"type": "string",
},
},
Required: []string{"query", "subdomain", "role"},
},
Annotations: mcp.ToolAnnotation{
Title: "Perform GraphQL Query on Nhost Project running on Nhost Cloud",
ReadOnlyHint: ptr(false),
DestructiveHint: ptr(true),
IdempotentHint: ptr(false),
OpenWorldHint: ptr(true),
},
},
{
Name: "manage-graphql",
Description: project.ToolManageGraphqlInstructions,
InputSchema: mcp.ToolInputSchema{
Type: "object",
Properties: map[string]any{
"body": map[string]any{
"description": "The body for the HTTP request",
"type": "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{"subdomain", "path", "body"},
},
Annotations: mcp.ToolAnnotation{
Title: "Manage GraphQL's Metadata on an Nhost Development Project",
ReadOnlyHint: ptr(false),
DestructiveHint: ptr(true),
IdempotentHint: ptr(true),
OpenWorldHint: ptr(true),
},
},
{
Name: "search",
Description: docs.ToolSearchInstructions,
InputSchema: mcp.ToolInputSchema{
Type: "object",
Properties: map[string]any{
"query": map[string]any{
"description": string("The search query"),
"type": string("string"),
},
},
Required: []string{"query"},
},
Annotations: mcp.ToolAnnotation{
Title: "Search Nhost Docs",
ReadOnlyHint: ptr(true),
IdempotentHint: ptr(true),
DestructiveHint: ptr(false),
OpenWorldHint: ptr(true),
},
},
},
},
cmpopts.SortSlices(func(a, b mcp.Tool) bool {
return a.Name < b.Name
}),
); diff != "" {
t.Errorf("ListToolsResult mismatch (-want +got):\n%s", diff)
}
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(
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)
}
if res.Capabilities.Prompts != nil {
prompts, err := mcpClient.ListPrompts(
context.Background(),
mcp.ListPromptsRequest{}, //nolint:exhaustruct
)
if err != nil {
t.Fatalf("failed to list prompts: %v", err)
}
if diff := cmp.Diff(
prompts,
//nolint:exhaustruct
&mcp.ListPromptsResult{
Prompts: []mcp.Prompt{},
},
); diff != "" {
t.Errorf("ListPromptsResult mismatch (-want +got):\n%s", diff)
}
}
}

208
cli/cmd/mcp/start/start.go Normal file
View File

@@ -0,0 +1,208 @@
package start
import (
"context"
"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/project"
"github.com/nhost/nhost/cli/mcp/tools/schemas"
"github.com/urfave/cli/v3"
)
const (
flagNhostAuthURL = "nhost-auth-url"
flagNhostGraphqlURL = "nhost-graphql-url"
flagBind = "bind"
)
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 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:
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 {
return &cli.Command{ //nolint:exhaustruct
Name: "start",
Usage: "Starts the MCP server",
Flags: []cli.Flag{
&cli.StringFlag{ //nolint:exhaustruct
Name: flagNhostAuthURL,
Usage: "Nhost auth URL",
Hidden: true,
Value: "https://otsispdzcwxyqzbfntmj.auth.eu-central-1.nhost.run/v1",
Category: "Cloud Platform",
Sources: cli.EnvVars("NHOST_AUTH_URL"),
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagNhostGraphqlURL,
Usage: "Nhost GraphQL URL",
Hidden: true,
Value: "https://otsispdzcwxyqzbfntmj.graphql.eu-central-1.nhost.run/v1",
Category: "Cloud Platform",
Sources: cli.EnvVars("NHOST_GRAPHQL_URL"),
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagBind,
Usage: "Bind address in the form $host:$port. If omitted use stdio",
Required: false,
Category: "General",
Sources: cli.EnvVars("BIND"),
},
},
Action: action,
}
}
func action(ctx context.Context, cmd *cli.Command) error {
cfg, err := getConfig(cmd)
if err != nil {
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),
cmd.String(flagNhostGraphqlURL),
); err != nil {
return cli.Exit(fmt.Sprintf("failed to register cloud 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)
}
d.Register(mcpServer)
return start(mcpServer, cmd.String(flagBind))
}
func getConfig(cmd *cli.Command) (*config.Config, error) {
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
return nil, cli.Exit("failed to load config file "+err.Error(), 1)
}
return cfg, nil
}
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,
creds.PersonalAccessToken,
)
if err != nil {
return fmt.Errorf("failed to create PAT interceptor: %w", err)
}
cloudTool := cloud.NewTool(
graphqlURL, cfg.Cloud.EnableMutations, interceptor,
)
if err := cloudTool.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 := project.NewTool(cfg)
if err := projectTool.Register(mcpServer); err != nil {
return fmt.Errorf("failed to register tool: %w", err)
}
return nil
}
func start(
mcpServer *server.MCPServer,
bind string,
) error {
if bind != "" {
sseServer := server.NewSSEServer(mcpServer, server.WithBaseURL(bind))
if err := sseServer.Start(bind); err != nil {
return cli.Exit(fmt.Sprintf("failed to serve tcp: %v", err), 1)
}
} else {
if err := server.ServeStdio(mcpServer); err != nil {
return cli.Exit(fmt.Sprintf("failed to serve stdio: %v", err), 1)
}
}
return nil
}

29
cli/cmd/mcp/testdata/sample.toml vendored Normal file
View File

@@ -0,0 +1,29 @@
[cloud]
enable_mutations = true
[[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 = ['*']
[[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

@@ -9,12 +9,12 @@ import (
"os"
"path/filepath"
"github.com/hashicorp/go-getter"
"github.com/hashicorp/go-getter/v2"
"github.com/nhost/be/services/mimir/model"
"github.com/nhost/nhost/cli/clienv"
"github.com/nhost/nhost/cli/cmd/config"
"github.com/nhost/nhost/cli/dockercompose"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
"gopkg.in/yaml.v3"
)
@@ -75,14 +75,14 @@ func CommandInit() *cli.Command {
Name: flagRemote,
Usage: "Initialize pulling configuration, migrations and metadata from the linked project",
Value: false,
EnvVars: []string{"NHOST_REMOTE"},
Sources: cli.EnvVars("NHOST_REMOTE"),
},
},
}
}
func commandInit(cCtx *cli.Context) error {
ce := clienv.FromCLI(cCtx)
func commandInit(ctx context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
if clienv.PathExists(ce.Path.NhostFolder()) {
return errors.New("nhost folder already exists") //nolint:err113
@@ -98,12 +98,12 @@ func commandInit(cCtx *cli.Context) error {
return fmt.Errorf("failed to initialize configuration: %w", err)
}
if cCtx.Bool(flagRemote) {
if err := InitRemote(cCtx.Context, ce); err != nil {
if cmd.Bool(flagRemote) {
if err := InitRemote(ctx, ce); err != nil {
return fmt.Errorf("failed to initialize remote project: %w", err)
}
} else {
if err := initInit(cCtx.Context, ce.Path); err != nil {
if err := initInit(ctx, ce.Path); err != nil {
return fmt.Errorf("failed to initialize project: %w", err)
}
}
@@ -129,17 +129,12 @@ func initInit(
return err
}
getclient := &getter.Client{ //nolint:exhaustruct
Ctx: ctx,
Src: "github.com/nhost/hasura-auth/email-templates",
Dst: "nhost/emails",
Mode: getter.ClientModeAny,
Detectors: []getter.Detector{
&getter.GitHubDetector{},
},
}
if err := getclient.Get(); err != nil {
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",
Dst: "nhost/emails",
DisableSymlinks: true,
}); err != nil {
return fmt.Errorf("failed to download email templates: %w", err)
}

View File

@@ -1,11 +1,12 @@
package project
import (
"context"
"fmt"
"os"
"github.com/nhost/nhost/cli/clienv"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
func CommandLink() *cli.Command {
@@ -18,14 +19,14 @@ func CommandLink() *cli.Command {
}
}
func commandLink(cCtx *cli.Context) error {
ce := clienv.FromCLI(cCtx)
func commandLink(ctx context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
if err := os.MkdirAll(ce.Path.DotNhostFolder(), 0o755); err != nil { //nolint:mnd
return fmt.Errorf("failed to create .nhost folder: %w", err)
}
_, err := ce.Link(cCtx.Context)
_, err := ce.Link(ctx)
return err //nolint:wrapcheck
}

View File

@@ -5,7 +5,7 @@ import (
"fmt"
"github.com/nhost/nhost/cli/clienv"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
func CommandList() *cli.Command {
@@ -18,9 +18,9 @@ func CommandList() *cli.Command {
}
}
func commandList(cCtx *cli.Context) error {
ce := clienv.FromCLI(cCtx)
return List(cCtx.Context, ce)
func commandList(ctx context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
return List(ctx, ce)
}
func List(ctx context.Context, ce *clienv.CliEnv) error {

View File

@@ -1,13 +1,14 @@
package run
import (
"context"
"encoding/json"
"fmt"
"github.com/nhost/be/services/mimir/model"
"github.com/nhost/nhost/cli/clienv"
"github.com/nhost/nhost/cli/nhostclient/graphql"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
func CommandConfigDeploy() *cli.Command {
@@ -23,13 +24,13 @@ func CommandConfigDeploy() *cli.Command {
Usage: "Service configuration file",
Value: "nhost-run-service.toml",
Required: true,
EnvVars: []string{"NHOST_RUN_SERVICE_CONFIG"},
Sources: cli.EnvVars("NHOST_RUN_SERVICE_CONFIG"),
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagServiceID,
Usage: "Service ID to update. Applies overlay of the same name",
Required: true,
EnvVars: []string{"NHOST_RUN_SERVICE_ID"},
Sources: cli.EnvVars("NHOST_RUN_SERVICE_ID"),
},
},
}
@@ -49,23 +50,23 @@ func transform[T, V any](t *T) (*V, error) {
return &v, nil
}
func commandConfigDeploy(cCtx *cli.Context) error {
ce := clienv.FromCLI(cCtx)
func commandConfigDeploy(ctx context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
cl, err := ce.GetNhostClient(cCtx.Context)
cl, err := ce.GetNhostClient(ctx)
if err != nil {
return fmt.Errorf("failed to get nhost client: %w", err)
}
secrets, appID, err := getRemoteSecrets(cCtx.Context, cl, cCtx.String(flagServiceID))
secrets, appID, err := getRemoteSecrets(ctx, cl, cmd.String(flagServiceID))
if err != nil {
return err
}
cfg, err := Validate(
ce,
cCtx.String(flagConfig),
cCtx.String(flagServiceID),
cmd.String(flagConfig),
cmd.String(flagServiceID),
secrets,
true,
)
@@ -81,9 +82,9 @@ func commandConfigDeploy(cCtx *cli.Context) error {
}
if _, err := cl.ReplaceRunServiceConfig(
cCtx.Context,
ctx,
appID,
cCtx.String(flagServiceID),
cmd.String(flagServiceID),
*replaceConfig,
); err != nil {
return fmt.Errorf("failed to replace service config: %w", err)

View File

@@ -1,6 +1,7 @@
package run
import (
"context"
"fmt"
"os"
"path/filepath"
@@ -8,7 +9,7 @@ import (
"github.com/nhost/be/services/mimir/model"
"github.com/nhost/nhost/cli/clienv"
"github.com/nhost/nhost/cli/cmd/config"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
const flagEditor = "editor"
@@ -25,31 +26,31 @@ func CommandConfigEdit() *cli.Command {
Usage: "Service configuration file",
Value: "nhost-run-service.toml",
Required: true,
EnvVars: []string{"NHOST_RUN_SERVICE_CONFIG"},
Sources: cli.EnvVars("NHOST_RUN_SERVICE_CONFIG"),
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagEditor,
Usage: "Editor to use",
Value: "vim",
EnvVars: []string{"EDITOR"},
Sources: cli.EnvVars("EDITOR"),
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagOverlayName,
Usage: "If specified, apply this overlay",
EnvVars: []string{"NHOST_RUN_SERVICE_ID", "NHOST_SERVICE_OVERLAY_NAME"},
Sources: cli.EnvVars("NHOST_RUN_SERVICE_ID", "NHOST_SERVICE_OVERLAY_NAME"),
},
},
Action: commandConfigEdit,
}
}
func commandConfigEdit(cCtx *cli.Context) error {
ce := clienv.FromCLI(cCtx)
func commandConfigEdit(ctx context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
overlayName := cCtx.String(flagOverlayName)
overlayName := cmd.String(flagOverlayName)
if overlayName == "" {
if err := config.EditFile(
cCtx.Context, cCtx.String(flagEditor), cCtx.String(flagConfig),
ctx, cmd.String(flagEditor), cmd.String(flagConfig),
); err != nil {
return fmt.Errorf("failed to edit config: %w", err)
}
@@ -58,7 +59,7 @@ func commandConfigEdit(cCtx *cli.Context) error {
}
if err := os.MkdirAll(ce.Path.RunServiceOverlaysFolder(
cCtx.String(flagConfig),
cmd.String(flagConfig),
), 0o755); err != nil { //nolint:mnd
return fmt.Errorf("failed to create json patches directory: %w", err)
}
@@ -72,21 +73,21 @@ func commandConfigEdit(cCtx *cli.Context) error {
tmpfileName := filepath.Join(tmpdir, "nhost.toml")
if err := config.CopyConfig[model.ConfigRunServiceConfig](
cCtx.String(flagConfig),
cmd.String(flagConfig),
tmpfileName,
ce.Path.RunServiceOverlay(cCtx.String(flagConfig), overlayName),
ce.Path.RunServiceOverlay(cmd.String(flagConfig), overlayName),
); err != nil {
return fmt.Errorf("failed to copy config: %w", err)
}
if err := config.EditFile(cCtx.Context, cCtx.String(flagEditor), tmpfileName); err != nil {
if err := config.EditFile(ctx, cmd.String(flagEditor), tmpfileName); err != nil {
return fmt.Errorf("failed to edit config: %w", err)
}
if err := config.GenerateJSONPatch(
cCtx.String(flagConfig),
cmd.String(flagConfig),
tmpfileName,
ce.Path.RunServiceOverlay(cCtx.String(flagConfig), overlayName),
ce.Path.RunServiceOverlay(cmd.String(flagConfig), overlayName),
); err != nil {
return fmt.Errorf("failed to generate json patch: %w", err)
}

View File

@@ -1,12 +1,13 @@
package run
import (
"context"
"fmt"
"github.com/nhost/be/services/mimir/model"
"github.com/nhost/nhost/cli/clienv"
"github.com/pelletier/go-toml/v2"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
const flagImage = "image"
@@ -22,29 +23,29 @@ func CommandConfigEditImage() *cli.Command {
Aliases: []string{},
Usage: "Service configuration file",
Value: "nhost-run-service.toml",
EnvVars: []string{"NHOST_RUN_SERVICE_CONFIG"},
Sources: cli.EnvVars("NHOST_RUN_SERVICE_CONFIG"),
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagImage,
Aliases: []string{},
Usage: "Image to use",
Required: true,
EnvVars: []string{"NHOST_RUN_SERVICE_IMAGE"},
Sources: cli.EnvVars("NHOST_RUN_SERVICE_IMAGE"),
},
},
Action: commandConfigEditImage,
}
}
func commandConfigEditImage(cCtx *cli.Context) error {
func commandConfigEditImage(_ context.Context, cmd *cli.Command) error {
var cfg model.ConfigRunServiceConfig
if err := clienv.UnmarshalFile(cCtx.String(flagConfig), &cfg, toml.Unmarshal); err != nil {
if err := clienv.UnmarshalFile(cmd.String(flagConfig), &cfg, toml.Unmarshal); err != nil {
return fmt.Errorf("failed to unmarshal config: %w", err)
}
cfg.Image.Image = cCtx.String(flagImage)
cfg.Image.Image = cmd.String(flagImage)
if err := clienv.MarshalFile(cfg, cCtx.String(flagConfig), toml.Marshal); err != nil {
if err := clienv.MarshalFile(cfg, cmd.String(flagConfig), toml.Marshal); err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}

View File

@@ -1,13 +1,14 @@
package run
import (
"context"
"fmt"
"github.com/nhost/be/services/mimir/model"
"github.com/nhost/be/services/mimir/schema"
"github.com/nhost/nhost/cli/clienv"
"github.com/pelletier/go-toml/v2"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
func ptr[T any](v T) *T {
@@ -24,8 +25,8 @@ func CommandConfigExample() *cli.Command {
}
}
func commandConfigExample(cCtx *cli.Context) error { //nolint:funlen
ce := clienv.FromCLI(cCtx)
func commandConfigExample(_ context.Context, cmd *cli.Command) error { //nolint:funlen
ce := clienv.FromCLI(cmd)
//nolint:mnd
cfg := &model.ConfigRunServiceConfig{

View File

@@ -1,13 +1,14 @@
package run
import (
"context"
"encoding/json"
"fmt"
"github.com/nhost/be/services/mimir/model"
"github.com/nhost/nhost/cli/clienv"
"github.com/pelletier/go-toml/v2"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
const flagServiceID = "service-id"
@@ -23,36 +24,36 @@ func CommandConfigPull() *cli.Command {
Aliases: []string{},
Usage: "Service configuration file",
Value: "nhost-run-service.toml",
EnvVars: []string{"NHOST_RUN_SERVICE_CONFIG"},
Sources: cli.EnvVars("NHOST_RUN_SERVICE_CONFIG"),
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagServiceID,
Usage: "Service ID to update",
Required: true,
EnvVars: []string{"NHOST_RUN_SERVICE_ID"},
Sources: cli.EnvVars("NHOST_RUN_SERVICE_ID"),
},
},
Action: commandConfigPull,
}
}
func commandConfigPull(cCtx *cli.Context) error {
ce := clienv.FromCLI(cCtx)
func commandConfigPull(ctx context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
cl, err := ce.GetNhostClient(cCtx.Context)
cl, err := ce.GetNhostClient(ctx)
if err != nil {
return fmt.Errorf("failed to get nhost client: %w", err)
}
appID, err := getAppIDFromServiceID(cCtx.Context, cl, cCtx.String(flagServiceID))
appID, err := getAppIDFromServiceID(ctx, cl, cmd.String(flagServiceID))
if err != nil {
return err
}
resp, err := cl.GetRunServiceConfigRawJSON(
cCtx.Context,
ctx,
appID,
cCtx.String(flagServiceID),
cmd.String(flagServiceID),
false,
)
if err != nil {
@@ -64,7 +65,7 @@ func commandConfigPull(cCtx *cli.Context) error {
return fmt.Errorf("failed to unmarshal config: %w", err)
}
if err := clienv.MarshalFile(v, cCtx.String(flagConfig), toml.Marshal); err != nil {
if err := clienv.MarshalFile(v, cmd.String(flagConfig), toml.Marshal); err != nil {
return fmt.Errorf("failed to save config to file: %w", err)
}

View File

@@ -1,13 +1,14 @@
package run
import (
"context"
"fmt"
"github.com/nhost/be/services/mimir/model"
"github.com/nhost/nhost/cli/clienv"
"github.com/nhost/nhost/cli/project/env"
"github.com/pelletier/go-toml/v2"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
func CommandConfigShow() *cli.Command {
@@ -23,19 +24,19 @@ func CommandConfigShow() *cli.Command {
Aliases: []string{},
Usage: "Service configuration file",
Value: "nhost-run-service.toml",
EnvVars: []string{"NHOST_RUN_SERVICE_CONFIG"},
Sources: cli.EnvVars("NHOST_RUN_SERVICE_CONFIG"),
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagOverlayName,
Usage: "If specified, apply this overlay",
EnvVars: []string{"NHOST_RUN_SERVICE_ID", "NHOST_SERVICE_OVERLAY_NAME"},
Sources: cli.EnvVars("NHOST_RUN_SERVICE_ID", "NHOST_SERVICE_OVERLAY_NAME"),
},
},
}
}
func commandConfigShow(cCtx *cli.Context) error {
ce := clienv.FromCLI(cCtx)
func commandConfigShow(_ context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
var secrets model.Secrets
if err := clienv.UnmarshalFile(ce.Path.Secrets(), &secrets, env.Unmarshal); err != nil {
@@ -47,8 +48,8 @@ func commandConfigShow(cCtx *cli.Context) error {
cfg, err := Validate(
ce,
cCtx.String(flagConfig),
cCtx.String(flagOverlayName),
cmd.String(flagConfig),
cmd.String(flagOverlayName),
secrets,
false,
)

View File

@@ -15,7 +15,7 @@ import (
"github.com/nhost/nhost/cli/nhostclient/graphql"
"github.com/nhost/nhost/cli/project/env"
"github.com/pelletier/go-toml/v2"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
const (
@@ -35,17 +35,17 @@ func CommandConfigValidate() *cli.Command {
Aliases: []string{},
Usage: "Service configuration file",
Value: "nhost-run-service.toml",
EnvVars: []string{"NHOST_RUN_SERVICE_CONFIG"},
Sources: cli.EnvVars("NHOST_RUN_SERVICE_CONFIG"),
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagOverlayName,
Usage: "If specified, apply this overlay",
EnvVars: []string{"NHOST_SERVICE_OVERLAY_NAME"},
Sources: cli.EnvVars("NHOST_SERVICE_OVERLAY_NAME"),
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagServiceID,
Usage: "If specified, apply this overlay and remote secrets for this service",
EnvVars: []string{"NHOST_RUN_SERVICE_ID"},
Sources: cli.EnvVars("NHOST_RUN_SERVICE_ID"),
},
},
}
@@ -157,35 +157,35 @@ func getRemoteSecrets(
return respToSecrets(secretsResp.GetAppSecrets()), appID, nil
}
func commandConfigValidate(cCtx *cli.Context) error {
func commandConfigValidate(ctx context.Context, cmd *cli.Command) error {
var (
overlayName string
serviceID string
)
switch {
case cCtx.String(flagServiceID) != "" && cCtx.String(flagOverlayName) != "":
case cmd.String(flagServiceID) != "" && cmd.String(flagOverlayName) != "":
return errors.New("cannot specify both service id and overlay name") //nolint:err113
case cCtx.String(flagServiceID) != "":
serviceID = cCtx.String(flagServiceID)
case cmd.String(flagServiceID) != "":
serviceID = cmd.String(flagServiceID)
overlayName = serviceID
case cCtx.String(flagOverlayName) != "":
overlayName = cCtx.String(flagOverlayName)
case cmd.String(flagOverlayName) != "":
overlayName = cmd.String(flagOverlayName)
}
ce := clienv.FromCLI(cCtx)
ce := clienv.FromCLI(cmd)
var secrets model.Secrets
ce.Infoln("Getting secrets...")
if serviceID != "" {
cl, err := ce.GetNhostClient(cCtx.Context)
cl, err := ce.GetNhostClient(ctx)
if err != nil {
return fmt.Errorf("failed to get nhost client: %w", err)
}
secrets, _, err = getRemoteSecrets(cCtx.Context, cl, serviceID)
secrets, _, err = getRemoteSecrets(ctx, cl, serviceID)
if err != nil {
return err
}
@@ -202,7 +202,7 @@ func commandConfigValidate(cCtx *cli.Context) error {
if _, err := Validate(
ce,
cCtx.String(flagConfig),
cmd.String(flagConfig),
overlayName,
secrets,
false,

View File

@@ -1,13 +1,14 @@
package run
import (
"context"
"fmt"
"regexp"
"github.com/nhost/be/services/mimir/model"
"github.com/nhost/nhost/cli/clienv"
"github.com/nhost/nhost/cli/project/env"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
const (
@@ -28,17 +29,17 @@ func CommandEnv() *cli.Command {
Aliases: []string{},
Usage: "Service configuration file",
Value: "nhost-run-service.toml",
EnvVars: []string{"NHOST_RUN_SERVICE_CONFIG"},
Sources: cli.EnvVars("NHOST_RUN_SERVICE_CONFIG"),
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagOverlayName,
Usage: "If specified, apply this overlay",
EnvVars: []string{"NHOST_RUN_SERVICE_ID", "NHOST_SERVICE_OVERLAY_NAME"},
Sources: cli.EnvVars("NHOST_RUN_SERVICE_ID", "NHOST_SERVICE_OVERLAY_NAME"),
},
&cli.BoolFlag{ //nolint:exhaustruct
Name: flagDevPrependExport,
Usage: "Prepend 'export' to each line",
EnvVars: []string{"NHOST_RuN_SERVICE_ENV_PREPEND_EXPORT"},
Sources: cli.EnvVars("NHOST_RuN_SERVICE_ENV_PREPEND_EXPORT"),
},
},
}
@@ -49,8 +50,8 @@ func escape(s string) string {
return re.ReplaceAllString(s, "\\$0")
}
func commandConfigDev(cCtx *cli.Context) error {
ce := clienv.FromCLI(cCtx)
func commandConfigDev(_ context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
var secrets model.Secrets
if err := clienv.UnmarshalFile(ce.Path.Secrets(), &secrets, env.Unmarshal); err != nil {
@@ -62,8 +63,8 @@ func commandConfigDev(cCtx *cli.Context) error {
cfg, err := Validate(
ce,
cCtx.String(flagConfig),
cCtx.String(flagOverlayName),
cmd.String(flagConfig),
cmd.String(flagOverlayName),
secrets,
false,
)
@@ -73,7 +74,7 @@ func commandConfigDev(cCtx *cli.Context) error {
for _, v := range cfg.GetEnvironment() {
value := escape(v.Value)
if cCtx.Bool(flagDevPrependExport) {
if cmd.Bool(flagDevPrependExport) {
ce.Println("export %s=\"%s\"", v.Name, value)
} else {
ce.Println("%s=\"%s\"", v.Name, value)

View File

@@ -1,13 +1,13 @@
package run
import "github.com/urfave/cli/v2"
import "github.com/urfave/cli/v3"
func Command() *cli.Command {
return &cli.Command{ //nolint:exhaustruct
Name: "run",
Aliases: []string{},
Usage: "Perform operations on Nhost Run",
Subcommands: []*cli.Command{
Commands: []*cli.Command{
CommandConfigShow(),
CommandConfigDeploy(),
CommandConfigEdit(),

View File

@@ -1,11 +1,12 @@
package secrets //nolint:dupl
import (
"context"
"errors"
"fmt"
"github.com/nhost/nhost/cli/clienv"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
func CommandCreate() *cli.Command {
@@ -19,28 +20,28 @@ func CommandCreate() *cli.Command {
}
}
func commandCreate(cCtx *cli.Context) error {
if cCtx.NArg() != 2 { //nolint:mnd
func commandCreate(ctx context.Context, cmd *cli.Command) error {
if cmd.NArg() != 2 { //nolint:mnd
return errors.New("invalid number of arguments") //nolint:err113
}
ce := clienv.FromCLI(cCtx)
ce := clienv.FromCLI(cmd)
proj, err := ce.GetAppInfo(cCtx.Context, cCtx.String(flagSubdomain))
proj, err := ce.GetAppInfo(ctx, cmd.String(flagSubdomain))
if err != nil {
return fmt.Errorf("failed to get app info: %w", err)
}
cl, err := ce.GetNhostClient(cCtx.Context)
cl, err := ce.GetNhostClient(ctx)
if err != nil {
return fmt.Errorf("failed to get nhost client: %w", err)
}
if _, err := cl.CreateSecret(
cCtx.Context,
ctx,
proj.ID,
cCtx.Args().Get(0),
cCtx.Args().Get(1),
cmd.Args().Get(0),
cmd.Args().Get(1),
); err != nil {
return fmt.Errorf("failed to create secret: %w", err)
}

View File

@@ -1,11 +1,12 @@
package secrets
import (
"context"
"errors"
"fmt"
"github.com/nhost/nhost/cli/clienv"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
func CommandDelete() *cli.Command {
@@ -19,27 +20,27 @@ func CommandDelete() *cli.Command {
}
}
func commandDelete(cCtx *cli.Context) error {
if cCtx.NArg() != 1 {
func commandDelete(ctx context.Context, cmd *cli.Command) error {
if cmd.NArg() != 1 {
return errors.New("invalid number of arguments") //nolint:err113
}
ce := clienv.FromCLI(cCtx)
ce := clienv.FromCLI(cmd)
proj, err := ce.GetAppInfo(cCtx.Context, cCtx.String(flagSubdomain))
proj, err := ce.GetAppInfo(ctx, cmd.String(flagSubdomain))
if err != nil {
return fmt.Errorf("failed to get app info: %w", err)
}
cl, err := ce.GetNhostClient(cCtx.Context)
cl, err := ce.GetNhostClient(ctx)
if err != nil {
return fmt.Errorf("failed to get nhost client: %w", err)
}
if _, err := cl.DeleteSecret(
cCtx.Context,
ctx,
proj.ID,
cCtx.Args().Get(0),
cmd.Args().Get(0),
); err != nil {
return fmt.Errorf("failed to delete secret: %w", err)
}

View File

@@ -1,10 +1,11 @@
package secrets
import (
"context"
"fmt"
"github.com/nhost/nhost/cli/clienv"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
func CommandList() *cli.Command {
@@ -17,21 +18,21 @@ func CommandList() *cli.Command {
}
}
func commandList(cCtx *cli.Context) error {
ce := clienv.FromCLI(cCtx)
func commandList(ctx context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
proj, err := ce.GetAppInfo(cCtx.Context, cCtx.String(flagSubdomain))
proj, err := ce.GetAppInfo(ctx, cmd.String(flagSubdomain))
if err != nil {
return fmt.Errorf("failed to get app info: %w", err)
}
cl, err := ce.GetNhostClient(cCtx.Context)
cl, err := ce.GetNhostClient(ctx)
if err != nil {
return fmt.Errorf("failed to get nhost client: %w", err)
}
secrets, err := cl.GetSecrets(
cCtx.Context,
ctx,
proj.ID,
)
if err != nil {

View File

@@ -1,6 +1,6 @@
package secrets
import "github.com/urfave/cli/v2"
import "github.com/urfave/cli/v3"
const flagSubdomain = "subdomain"
@@ -9,7 +9,7 @@ func commonFlags() []cli.Flag {
&cli.StringFlag{ //nolint:exhaustruct
Name: flagSubdomain,
Usage: "Project's subdomain to operate on, defaults to linked project",
EnvVars: []string{"NHOST_SUBDOMAIN"},
Sources: cli.EnvVars("NHOST_SUBDOMAIN"),
},
}
}
@@ -19,7 +19,7 @@ func Command() *cli.Command {
Name: "secrets",
Aliases: []string{},
Usage: "Manage secrets",
Subcommands: []*cli.Command{
Commands: []*cli.Command{
CommandCreate(),
CommandDelete(),
CommandList(),

View File

@@ -1,11 +1,12 @@
package secrets //nolint:dupl
import (
"context"
"errors"
"fmt"
"github.com/nhost/nhost/cli/clienv"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
func CommandUpdate() *cli.Command {
@@ -19,28 +20,28 @@ func CommandUpdate() *cli.Command {
}
}
func commandUpdate(cCtx *cli.Context) error {
if cCtx.NArg() != 2 { //nolint:mnd
func commandUpdate(ctx context.Context, cmd *cli.Command) error {
if cmd.NArg() != 2 { //nolint:mnd
return errors.New("invalid number of arguments") //nolint:err113
}
ce := clienv.FromCLI(cCtx)
ce := clienv.FromCLI(cmd)
proj, err := ce.GetAppInfo(cCtx.Context, cCtx.String(flagSubdomain))
proj, err := ce.GetAppInfo(ctx, cmd.String(flagSubdomain))
if err != nil {
return fmt.Errorf("failed to get app info: %w", err)
}
cl, err := ce.GetNhostClient(cCtx.Context)
cl, err := ce.GetNhostClient(ctx)
if err != nil {
return fmt.Errorf("failed to get nhost client: %w", err)
}
if _, err := cl.UpdateSecret(
cCtx.Context,
ctx,
proj.ID,
cCtx.Args().Get(0),
cCtx.Args().Get(1),
cmd.Args().Get(0),
cmd.Args().Get(1),
); err != nil {
return fmt.Errorf("failed to update secret: %w", err)
}

View File

@@ -1,6 +1,6 @@
package software
import "github.com/urfave/cli/v2"
import "github.com/urfave/cli/v3"
const (
devVersion = "dev"
@@ -11,7 +11,7 @@ func Command() *cli.Command {
Name: "sw",
Aliases: []string{},
Usage: "Perform software management operations",
Subcommands: []*cli.Command{
Commands: []*cli.Command{
CommandUninstall(),
CommandUpgrade(),
CommandVersion(),

View File

@@ -1,11 +1,12 @@
package software
import (
"context"
"fmt"
"os"
"github.com/nhost/nhost/cli/clienv"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
const (
@@ -22,29 +23,29 @@ func CommandUninstall() *cli.Command {
&cli.BoolFlag{ //nolint:exhaustruct
Name: forceFlag,
Usage: "Force uninstall without confirmation",
EnvVars: []string{"NHOST_FORCE_UNINSTALL"},
Sources: cli.EnvVars("NHOST_FORCE_UNINSTALL"),
DefaultText: "false",
},
},
}
}
func commandUninstall(cCtx *cli.Context) error {
ce := clienv.FromCLI(cCtx)
func commandUninstall(_ context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
path, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to find installed CLI: %w", err)
}
if cCtx.App.Version == devVersion || cCtx.App.Version == "" {
if cmd.Root().Version == devVersion || cmd.Root().Version == "" {
// we fake it in dev mode
path = "/tmp/nhost"
}
ce.Infoln("Found Nhost cli in %s", path)
if !cCtx.Bool(forceFlag) {
if !cmd.Bool(forceFlag) {
ce.PromptMessage("Are you sure you want to uninstall Nhost CLI? [y/N] ")
resp, err := ce.PromptInput(false)

View File

@@ -1,6 +1,7 @@
package software
import (
"context"
"fmt"
"os"
"runtime"
@@ -8,7 +9,7 @@ import (
"github.com/nhost/nhost/cli/clienv"
"github.com/nhost/nhost/cli/software"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
func CommandUpgrade() *cli.Command {
@@ -20,12 +21,12 @@ func CommandUpgrade() *cli.Command {
}
}
func commandUpgrade(cCtx *cli.Context) error {
ce := clienv.FromCLI(cCtx)
func commandUpgrade(ctx context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
mgr := software.NewManager()
releases, err := mgr.GetReleases(cCtx.Context, cCtx.App.Version)
releases, err := mgr.GetReleases(ctx, cmd.Root().Version)
if err != nil {
return fmt.Errorf("failed to get releases: %w", err)
}
@@ -36,7 +37,7 @@ func commandUpgrade(cCtx *cli.Context) error {
}
latest := releases[0]
if latest.TagName == cCtx.App.Version {
if latest.TagName == cmd.Root().Version {
ce.Infoln("You have the latest version. Hurray!")
return nil
}
@@ -71,20 +72,20 @@ func commandUpgrade(cCtx *cli.Context) error {
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
if err := mgr.DownloadAsset(cCtx.Context, url, tmpFile); err != nil {
if err := mgr.DownloadAsset(ctx, url, tmpFile); err != nil {
return fmt.Errorf("failed to download asset: %w", err)
}
return install(cCtx, ce, tmpFile.Name())
return install(cmd, ce, tmpFile.Name())
}
func install(cCtx *cli.Context, ce *clienv.CliEnv, tmpFile string) error {
func install(cmd *cli.Command, ce *clienv.CliEnv, tmpFile string) error {
curBin, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to find installed CLI: %w", err)
}
if cCtx.App.Version == devVersion || cCtx.App.Version == "" {
if cmd.Root().Version == devVersion || cmd.Root().Version == "" {
// we are in dev mode, we fake curBin for testing
curBin = "/tmp/nhost"
}

View File

@@ -12,7 +12,7 @@ import (
"github.com/nhost/nhost/cli/nhostclient/graphql"
"github.com/nhost/nhost/cli/project/env"
"github.com/nhost/nhost/cli/software"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
func CommandVersion() *cli.Command {
@@ -111,11 +111,11 @@ func CheckVersions(
checkServiceVersion(
ce, graphql.SoftwareTypeEnumAuth, *cfg.GetAuth().GetVersion(), swv,
"https://github.com/nhost/hasura-auth/releases",
"https://github.com/nhost/nhost/releases",
)
checkServiceVersion(
ce, graphql.SoftwareTypeEnumStorage, *cfg.GetStorage().GetVersion(), swv,
"https://github.com/nhost/hasura-storage/releases",
"https://github.com/nhost/nhost/releases",
)
checkServiceVersion(
ce, graphql.SoftwareTypeEnumPostgreSQL, *cfg.GetPostgres().GetVersion(), swv,
@@ -132,8 +132,8 @@ func CheckVersions(
return checkCLIVersion(ctx, ce, appVersion)
}
func commandVersion(cCtx *cli.Context) error {
ce := clienv.FromCLI(cCtx)
func commandVersion(ctx context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
var (
cfg *model.ConfigConfig
@@ -157,5 +157,5 @@ func commandVersion(cCtx *cli.Context) error {
ce.Warnln("🟡 No Nhost project found")
}
return CheckVersions(cCtx.Context, ce, cfg, cCtx.App.Version)
return CheckVersions(ctx, ce, cfg, cmd.Root().Version)
}

View File

@@ -1,8 +1,10 @@
package user
import (
"context"
"github.com/nhost/nhost/cli/clienv"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
)
const (
@@ -21,26 +23,26 @@ func CommandLogin() *cli.Command {
&cli.StringFlag{ //nolint:exhaustruct
Name: flagPAT,
Usage: "Use this Personal Access Token instead of generating a new one with your email/password",
EnvVars: []string{"NHOST_PAT"},
Sources: cli.EnvVars("NHOST_PAT"),
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagEmail,
Usage: "Email address",
EnvVars: []string{"NHOST_EMAIL"},
Sources: cli.EnvVars("NHOST_EMAIL"),
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagPassword,
Usage: "Password",
EnvVars: []string{"NHOST_PASSWORD"},
Sources: cli.EnvVars("NHOST_PASSWORD"),
},
},
}
}
func commandLogin(cCtx *cli.Context) error {
ce := clienv.FromCLI(cCtx)
func commandLogin(ctx context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
_, err := ce.Login(
cCtx.Context, cCtx.String(flagPAT), cCtx.String(flagEmail), cCtx.String(flagPassword),
ctx, cmd.String(flagPAT), cmd.String(flagEmail), cmd.String(flagPassword),
)
return err //nolint:wrapcheck

View File

@@ -41,7 +41,7 @@ func auth( //nolint:funlen
envars, err := appconfig.HasuraAuthEnv(
cfg,
"http://graphql:8080/v1/graphql",
URL(subdomain, "auth", httpPort, useTLS)+"/v1",
URL(subdomain, "auth", httpPort, useTLS && exposePort == 0)+"/v1",
"postgres://nhost_hasura@postgres:5432/local",
"postgres://nhost_auth_admin@postgres:5432/local",
&model.ConfigSmtp{
@@ -67,7 +67,7 @@ func auth( //nolint:funlen
}
svc := &Service{
Image: "nhost/hasura-auth:" + *cfg.Auth.Version,
Image: "nhost/auth:" + *cfg.Auth.Version,
DependsOn: map[string]DependsOn{
"graphql": {
Condition: "service_healthy",

View File

@@ -10,7 +10,7 @@ import (
func expectedAuth() *Service {
//nolint:lll
return &Service{
Image: "nhost/hasura-auth:0.31.0",
Image: "nhost/auth:0.31.0",
Command: nil,
DependsOn: map[string]DependsOn{
"graphql": {Condition: "service_healthy"},
@@ -132,7 +132,7 @@ func expectedAuth() *Service {
"AUTH_RATE_LIMIT_SMS_INTERVAL": "5m",
"AUTH_REFRESH_TOKEN_EXPIRES_IN": "99",
"AUTH_REQUIRE_ELEVATED_CLAIM": "required",
"AUTH_SERVER_URL": "http://dev.auth.local.nhost.run:1336/v1",
"AUTH_SERVER_URL": "https://dev.auth.local.nhost.run:1336/v1",
"AUTH_SMS_PASSWORDLESS_ENABLED": "true",
"AUTH_SMS_PROVIDER": "twilio",
"AUTH_SMS_TWILIO_ACCOUNT_SID": "smsAccountSid",
@@ -186,7 +186,7 @@ func expectedAuth() *Service {
"traefik.http.routers.auth.entrypoints": "web",
"traefik.http.routers.auth.rule": "(HostRegexp(`^.+\\.auth\\.local\\.nhost\\.run$`) || Host(`local.auth.nhost.run`))",
"traefik.http.routers.auth.service": "auth",
"traefik.http.routers.auth.tls": "false",
"traefik.http.routers.auth.tls": "true",
"traefik.http.services.auth.loadbalancer.server.port": "4000",
},
Ports: nil,
@@ -216,7 +216,7 @@ func TestAuth(t *testing.T) {
{
name: "default",
cfg: getConfig,
useTlS: false,
useTlS: true,
exposePort: 0,
expected: expectedAuth,
},
@@ -227,11 +227,11 @@ func TestAuth(t *testing.T) {
cfg.Auth.Version = ptr("0.21.3")
return cfg
},
useTlS: false,
useTlS: true,
exposePort: 0,
expected: func() *Service {
svc := expectedAuth()
svc.Image = "nhost/hasura-auth:0.21.3"
svc.Image = "nhost/auth:0.21.3"
svc.Labels["traefik.http.middlewares.replace-auth.replacepathregex.regex"] = "/v1(/|$$)(.*)"
svc.Labels["traefik.http.middlewares.replace-auth.replacepathregex.replacement"] = "/$$2"
svc.Labels["traefik.http.routers.auth.middlewares"] = "replace-auth"
@@ -243,7 +243,7 @@ func TestAuth(t *testing.T) {
{
name: "custom port",
cfg: getConfig,
useTlS: false,
useTlS: true,
exposePort: 8080,
expected: func() *Service {
svc := expectedAuth()

View File

@@ -49,7 +49,7 @@ func storage( //nolint:funlen
}
return &Service{
Image: "nhost/hasura-storage:" + *cfg.GetStorage().GetVersion(),
Image: "nhost/storage:" + *cfg.GetStorage().GetVersion(),
DependsOn: map[string]DependsOn{
"minio": {
Condition: "service_started",

View File

@@ -9,7 +9,7 @@ import (
func expectedStorage() *Service {
return &Service{
Image: "nhost/hasura-storage:0.2.5",
Image: "nhost/storage:0.2.5",
DependsOn: map[string]DependsOn{
"graphql": {Condition: "service_healthy"},
"minio": {Condition: "service_started"},

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 }}'

6
cli/gen_nhost_schema.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/bin/sh
# This script generates the GraphQL schema for Nhost and saves it to a file.
# This is only needed if the filter in cmd/gen/gen.go is changed.
go run main.go mcp gen > tools/cloud/schema.graphql
go run main.go mcp gen --with-mutations > tools/cloud/schema-with-mutations.graphql

View File

@@ -1,24 +1,25 @@
package main
import (
"errors"
"context"
"fmt"
"log"
"os"
"github.com/Yamashou/gqlgenc/clientv2"
"github.com/nhost/nhost/cli/clienv"
"github.com/nhost/nhost/cli/cmd/config"
"github.com/nhost/nhost/cli/cmd/configserver"
"github.com/nhost/nhost/cli/cmd/deployments"
"github.com/nhost/nhost/cli/cmd/dev"
"github.com/nhost/nhost/cli/cmd/dockercredentials"
"github.com/nhost/nhost/cli/cmd/mcp"
"github.com/nhost/nhost/cli/cmd/project"
"github.com/nhost/nhost/cli/cmd/run"
"github.com/nhost/nhost/cli/cmd/secrets"
"github.com/nhost/nhost/cli/cmd/software"
"github.com/nhost/nhost/cli/cmd/user"
"github.com/urfave/cli/v2"
docs "github.com/urfave/cli-docs/v3"
"github.com/urfave/cli/v3"
)
var Version string
@@ -29,11 +30,11 @@ func main() {
panic(err)
}
app := &cli.App{ //nolint: exhaustruct
Name: "nhost",
EnableBashCompletion: true,
Version: Version,
Description: "Nhost CLI tool",
app := &cli.Command{ //nolint: exhaustruct
Name: "nhost",
EnableShellCompletion: true,
Version: Version,
Description: "Nhost CLI tool",
Commands: []*cli.Command{
config.Command(),
configserver.Command(),
@@ -43,6 +44,7 @@ func main() {
dev.CommandDown(),
dev.CommandLogs(),
dockercredentials.Command(),
mcp.Command(),
project.CommandInit(),
project.CommandList(),
project.CommandLink(),
@@ -50,18 +52,7 @@ func main() {
secrets.Command(),
software.Command(),
user.CommandLogin(),
{
Name: "docs",
Hidden: true,
Action: func(ctx *cli.Context) error {
s, err := ctx.App.ToMarkdown()
if err != nil {
return fmt.Errorf("failed to generate docs: %w", err)
}
fmt.Println(s) //nolint:forbidigo
return nil
},
},
markdownDocs(),
},
Metadata: map[string]any{
"Author": "Nhost",
@@ -70,13 +61,29 @@ func main() {
Flags: flags,
}
if err := app.Run(os.Args); err != nil {
var graphqlErr *clientv2.ErrorResponse
if errors.As(err, &graphqlErr) {
log.Fatal(graphqlErr.GqlErrors)
}
if err := app.Run(context.Background(), os.Args); err != nil {
log.Fatal(err)
}
}
func markdownDocs() *cli.Command {
return &cli.Command{ //nolint:exhaustruct
Name: "docs",
Usage: "Generate markdown documentation for the CLI",
Action: func(_ context.Context, cmd *cli.Command) error {
md, err := docs.ToMarkdown(cmd.Root())
if err != nil {
return cli.Exit("failed to generate markdown documentation: "+err.Error(), 1)
}
fmt.Println("---") //nolint:forbidigo
fmt.Println("title: Nhost CLI Reference") //nolint:forbidigo
fmt.Println("icon: terminal") //nolint:forbidigo
fmt.Println("---") //nolint:forbidigo
fmt.Println() //nolint:forbidigo
fmt.Println(md) //nolint:forbidigo
return nil
},
}
}

201
cli/mcp/config/config.go Normal file
View File

@@ -0,0 +1,201 @@
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"
)
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"`
// 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 {
// 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 ProjectList []Project
func (pl ProjectList) Get(subdomain string) (*Project, error) {
for _, p := range pl {
if p.Subdomain == subdomain {
return &p, nil
}
}
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 {
// Project's subdomain
Subdomain string `json:"subdomain" toml:"subdomain"`
// 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"`
// PAT to operate against the project. Note this PAT must belong to this project.
// 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"`
// List of mutations that are allowed to be executed against the project.
// 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 (p *Project) GetAuthURL() string {
if p.AuthURL != "" {
return p.AuthURL
}
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) {
content, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
interpolated := interpolateEnv(string(content), os.Getenv)
decoder := toml.NewDecoder(strings.NewReader(interpolated))
decoder.DisallowUnknownFields()
var config Config
if err := decoder.Decode(&config); err != nil {
var (
decodeErr *toml.DecodeError
strictErr *toml.StrictMissingError
)
if errors.As(err, &decodeErr) {
return nil, errors.New("\n" + decodeErr.String()) //nolint:err113
} else if errors.As(err, &strictErr) {
return nil, errors.New("\n" + strictErr.String()) //nolint:err113
}
return nil, fmt.Errorf("failed to unmarshal config file: %w", err)
}
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)
}
}

207
cli/mcp/config/wizard.go Normal file
View File

@@ -0,0 +1,207 @@
package config
import (
"bufio"
"fmt"
"os"
"strings"
)
//nolint:forbidigo
func RunWizard() (*Config, error) {
reader := bufio.NewReader(os.Stdin)
fmt.Println("Welcome to the Nhost MCP Configuration Wizard!")
fmt.Println("==============================================")
fmt.Println()
cloudConfig := wizardCloud(reader)
fmt.Println()
localConfig := wizardLocal(reader)
fmt.Println()
projects := wizardProject(reader)
if localConfig != nil {
projects = append(projects, *localConfig)
}
fmt.Println()
return &Config{
Cloud: cloudConfig,
Projects: projects,
}, nil
}
//nolint:forbidigo
func wizardCloud(reader *bufio.Reader) *Cloud {
fmt.Println("1. Nhost Cloud Access")
fmt.Println(" This allows LLMs to manage your Nhost projects and organizations.")
fmt.Println(" You can view and configure projects as you would in the dashboard.")
if promptYesNo(reader, "Enable Nhost Cloud access?") {
fmt.Println(" Note: If you haven't already, run `nhost login` to authenticate.")
return &Cloud{
EnableMutations: true,
}
}
return nil
}
//nolint:forbidigo
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.")
fmt.Println(" This gives LLMs context to generate code to interact with your Nhost project.")
if promptYesNo(reader, "Enable local development access?") {
adminSecret := promptString(reader, "Enter Admin Secret (default: nhost-admin-secret):")
if adminSecret == "" {
adminSecret = "nhost-admin-secret" //nolint:gosec
}
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: "",
}
}
return nil
}
//nolint:forbidigo
func wizardProject(reader *bufio.Reader) []Project {
projects := make([]Project, 0)
fmt.Println("3. Project-Specific Access")
fmt.Println(" Configure LLM access to your projects' GraphQL APIs.")
fmt.Println(
" This allows using agents to query and analyze your data and even to add new data",
)
fmt.Println(
" You can control which queries and mutations are allowed per project. See the docs",
)
fmt.Println(" for more details on how to configure this.")
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,
"Select authentication method:",
[]string{"Admin Secret", "PAT"},
)
if authType == "Admin Secret" {
adminSecret := promptString(reader, "Project Admin Secret:")
project.AdminSecret = &adminSecret
} else {
pat := promptString(reader, "Project PAT:")
project.PAT = &pat
}
projects = append(projects, project)
if !promptYesNo(reader, "Add another project?") {
break
}
}
}
return projects
}
//nolint:forbidigo
func promptString(reader *bufio.Reader, prompt string) string {
fmt.Print(prompt + " ")
input, _ := reader.ReadString('\n')
return strings.TrimSpace(input)
}
//nolint:forbidigo
func promptYesNo(reader *bufio.Reader, prompt string) bool {
for {
fmt.Printf("%s (y/n) ", prompt)
input, _ := reader.ReadString('\n')
input = strings.ToLower(strings.TrimSpace(input))
if input == "y" || input == "yes" {
return true
}
if input == "n" || input == "no" {
return false
}
fmt.Println("Please answer with 'y' or 'n'")
}
}
//nolint:forbidigo
func promptChoice(reader *bufio.Reader, prompt string, options []string) string {
for {
fmt.Printf("%s\n", prompt)
for i, opt := range options {
fmt.Printf("%d) %s\n", i+1, opt)
}
fmt.Print("Enter number: ")
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
if num := strings.TrimSpace(input); num != "" {
switch num {
case "1":
return options[0]
case "2":
return options[1]
}
}
fmt.Println("Please select a valid option")
}
}

View File

@@ -0,0 +1,96 @@
package graphql
const IntrospectionQuery = `
query IntrospectionQuery {
__schema {
queryType {
...FullType
}
mutationType {
...FullType
}
types {
...FullType
}
}
}
fragment FullType on __Type {
kind
name
description
fields(includeDeprecated: true) {
name
description
args {
...InputValue
}
type {
...TypeRef
}
}
inputFields {
...InputValue
}
interfaces {
...TypeRef
}
enumValues(includeDeprecated: true) {
name
description
}
possibleTypes {
...TypeRef
}
}
fragment InputValue on __InputValue {
name
description
type {
...TypeRef
}
defaultValue
}
fragment TypeRef on __Type {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
}
}
}
}
}
}
}
}
}
}
`

456
cli/mcp/graphql/parse.go Normal file
View File

@@ -0,0 +1,456 @@
package graphql
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
type Filter struct {
AllowQueries []Queries
AllowMutations []Queries
}
type Queries struct {
Name string
DisableNesting bool
}
// getTypeName returns the GraphQL type name with modifiers (non-null, list).
func getTypeName(t Type) string {
if t.Kind == KindNonNull {
return getTypeName(*t.OfType) + "!"
}
if t.Kind == KindList {
return "[" + getTypeName(*t.OfType) + "]"
}
return *t.Name
}
// ParseSchema converts an introspection query result into a GraphQL SDL string.
func ParseSchema(response ResponseIntrospection, filter Filter) string { //nolint:cyclop
availableTypes := make(map[string]Type)
// Process all types in the schema
for _, t := range response.Data.Schema.Types {
gatherAllTypes(t, availableTypes)
}
neededQueries := make(map[string]Field)
neededTypes := make(map[string]Type)
for _, query := range response.Data.Schema.QueryType.Fields {
if filter.AllowQueries == nil {
neededQueries[query.Name] = query
collectNeededTypesFromQuery(query, neededTypes, availableTypes, true)
continue
}
for _, q := range filter.AllowQueries {
if query.Name == q.Name {
neededQueries[query.Name] = query
collectNeededTypesFromQuery(query, neededTypes, availableTypes, !q.DisableNesting)
}
}
}
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 {
neededMutations[mutation.Name] = mutation
collectNeededTypesFromQuery(mutation, neededTypes, availableTypes, true)
continue
}
for _, q := range filter.AllowMutations {
if mutation.Name == q.Name {
neededMutations[mutation.Name] = mutation
collectNeededTypesFromQuery(
mutation,
neededTypes,
availableTypes,
!q.DisableNesting,
)
}
}
}
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 {
filtered := make([]InputValue, 0, len(args))
for _, arg := range args {
if arg.Type.Kind == KindInputObject || arg.Type.Kind == KindObject {
k := fmt.Sprintf("%s:%s", arg.Type.Kind, *arg.Type.Name)
if _, ok := neededTypes[k]; !ok {
continue
}
}
filtered = append(filtered, arg)
}
return filtered
}
func filterNestedFields(
fields []Field, neededTypes map[string]Type,
) []Field {
filtered := make([]Field, 0, len(fields))
for _, field := range fields {
if field.Type.Kind == KindInputObject || field.Type.Kind == KindObject {
k := fmt.Sprintf("%s:%s", field.Type.Kind, *field.Type.Name)
if _, ok := neededTypes[k]; !ok {
continue
}
}
filtered = append(filtered, field)
}
return filtered
}
func filterInputNestedFields(
fields []InputValue, neededTypes map[string]Type,
) []InputValue {
filtered := make([]InputValue, 0, len(fields))
for _, field := range fields {
if field.Type.Kind == KindInputObject || field.Type.Kind == KindObject {
k := fmt.Sprintf("%s:%s", field.Type.Kind, *field.Type.Name)
if _, ok := neededTypes[k]; !ok {
continue
}
}
filtered = append(filtered, field)
}
return filtered
}
func gatherAllTypes(t Type, availableTypes map[string]Type) {
key := fmt.Sprintf("%s:%s", t.Kind, *t.Name)
availableTypes[key] = t
}
// collectNeededTypesFromQuery recursively collects all types needed for a given field.
func collectNeededTypesFromQuery(
field Field,
neededTypes map[string]Type,
availableType map[string]Type,
enableNesting bool,
) {
collectType(field.Type, neededTypes, availableType, enableNesting, false)
for _, arg := range field.Args {
collectType(arg.Type, neededTypes, availableType, enableNesting, false)
}
}
func collectType(
t Type,
neededTypes map[string]Type,
availableTypes map[string]Type,
enableNesting bool,
nested bool,
) {
if t.Kind == KindNonNull || t.Kind == KindList {
collectType(*t.OfType, neededTypes, availableTypes, enableNesting, nested)
return
}
key := fmt.Sprintf("%s:%s", t.Kind, *t.Name)
availableType, ok := availableTypes[key]
if !ok {
panic(fmt.Sprintf("type %s not found in available types", key))
}
if _, exists := neededTypes[key]; exists {
return
}
switch t.Kind {
case KindObject, KindInputObject:
if !enableNesting && nested {
return
}
neededTypes[key] = availableType
collectTypeObject(availableType, neededTypes, availableTypes, enableNesting)
case KindList, KindNonNull:
case KindScalar, KindEnum:
neededTypes[key] = availableType
collectTypeSimple(*t.Name, neededTypes, availableTypes, enableNesting)
}
}
func collectTypeObject(
availableType Type,
neededTypes map[string]Type,
availableTypes map[string]Type,
enableNesting bool,
) {
for _, field := range availableType.Fields {
collectType(field.Type, neededTypes, availableTypes, enableNesting, true)
}
for _, inputField := range availableType.InputFields {
collectType(inputField.Type, neededTypes, availableTypes, enableNesting, true)
}
for _, iface := range availableType.Interfaces {
collectType(iface, neededTypes, availableTypes, enableNesting, true)
}
for _, possibleType := range availableType.PossibleTypes {
collectType(possibleType, neededTypes, availableTypes, enableNesting, true)
}
if availableType.OfType != nil {
collectType(*availableType.OfType, neededTypes, availableTypes, enableNesting, true)
}
}
func collectTypeSimple(
name string,
neededTypes map[string]Type,
availableTypes map[string]Type,
enableNesting bool,
) {
keyComparisonExp := string(KindInputObject) + ":" + name + "_comparison_exp"
if _, exists := neededTypes[keyComparisonExp]; !exists {
availableComparisonExpType, ok := availableTypes[keyComparisonExp]
if ok {
collectType(
availableComparisonExpType, neededTypes, availableTypes, enableNesting, true,
)
neededTypes[keyComparisonExp] = availableComparisonExpType
}
}
}
type typeInfo struct {
kind string
name string
typ Type
}
func getSortedTypes(neededTypes map[string]Type) []typeInfo {
// Sort types by kind and name
sortedTypes := make([]typeInfo, 0, len(neededTypes))
for key, t := range neededTypes {
parts := strings.Split(key, ":")
sortedTypes = append(sortedTypes, typeInfo{
kind: parts[0],
name: parts[1],
typ: t,
})
}
sort.Slice(sortedTypes, func(i, j int) bool {
if sortedTypes[i].kind != sortedTypes[j].kind {
return sortedTypes[i].kind < sortedTypes[j].kind
}
return sortedTypes[i].name < sortedTypes[j].name
})
return sortedTypes
}
func render(
neededQueries map[string]Field,
neededMutations map[string]Field,
neededTypes map[string]Type,
) string {
// render in graphql's SDL format
var sdl strings.Builder
sortedTypes := getSortedTypes(neededTypes)
for _, t := range sortedTypes {
if t.kind == string(KindScalar) {
sdl.WriteString("scalar " + t.name + "\n\n")
}
}
for _, t := range sortedTypes {
if t.kind == string(KindObject) {
renderType(&sdl, t, neededTypes)
}
}
// Render input objects
for _, t := range sortedTypes {
if t.kind == string(KindInputObject) {
sdl.WriteString("input " + t.name + " {\n ")
sdl.WriteString(renderInputFields(t.typ.InputFields, neededTypes))
sdl.WriteString("\n}\n\n")
}
}
// Render queries
if len(neededQueries) > 0 {
sdl.WriteString("type Query {\n ")
renderQuery(&sdl, neededQueries, neededTypes)
sdl.WriteString("\n}\n\n")
}
// Render mutations
if len(neededMutations) > 0 {
sdl.WriteString("type Mutation {\n ")
renderQuery(&sdl, neededMutations, neededTypes)
sdl.WriteString("\n}\n\n")
}
return sdl.String()
}
func renderArgs(
args []InputValue, neededTypes map[string]Type,
) string {
if len(args) == 0 {
return ""
}
argStrings := make([]string, 0, len(args))
args = filterNestedArgs(args, neededTypes)
for _, arg := range args {
argStr := arg.Name + ": " + getTypeName(arg.Type)
if arg.DefaultValue != nil {
argStr += " = " + *arg.DefaultValue
}
argStrings = append(argStrings, argStr)
}
return "(" + strings.Join(argStrings, ", ") + ")"
}
func renderFields(
fields []Field, neededTypes map[string]Type,
) string {
if len(fields) == 0 {
return ""
}
fieldStrings := make([]string, 0, len(fields))
fields = filterNestedFields(fields, neededTypes)
for _, field := range fields {
fieldStr := field.Name
if len(field.Args) > 0 {
fieldStr += renderArgs(field.Args, neededTypes)
}
fieldStr += ": " + getTypeName(field.Type)
if field.Description != nil {
fieldStr = `"""` + *field.Description + `"""` + "\n" + fieldStr
}
fieldStrings = append(fieldStrings, fieldStr)
}
return strings.Join(fieldStrings, "\n ")
}
func renderInputFields(
fields []InputValue, neededTypes map[string]Type,
) string {
if len(fields) == 0 {
return ""
}
fieldStrings := make([]string, 0, len(fields))
fields = filterInputNestedFields(fields, neededTypes)
for _, field := range fields {
fieldStr := field.Name + ": " + getTypeName(field.Type)
if field.DefaultValue != nil {
fieldStr += " = " + *field.DefaultValue
}
if field.Description != nil {
fieldStr = `"""` + *field.Description + `"""` + "\n " + fieldStr
}
fieldStrings = append(fieldStrings, fieldStr)
}
return strings.Join(fieldStrings, "\n ")
}
func renderType(sdl *strings.Builder, t typeInfo, neededTypes map[string]Type) {
sdl.WriteString("type " + t.name)
if len(t.typ.Interfaces) > 0 {
var ifaces []string
for _, iface := range t.typ.Interfaces {
ifaces = append(ifaces, *iface.Name)
}
sdl.WriteString(" implements " + strings.Join(ifaces, " & "))
}
sdl.WriteString(" {\n ")
sdl.WriteString(renderFields(t.typ.Fields, neededTypes))
sdl.WriteString("\n}\n\n")
}
func renderQuery(
sdl *strings.Builder,
queries map[string]Field,
neededTypes map[string]Type,
) {
toRender := make([]Field, 0, len(queries))
for _, q := range queries {
toRender = append(toRender, q)
}
sort.Slice(toRender, func(i, j int) bool {
return toRender[i].Name < toRender[j].Name
})
sdl.WriteString(renderFields(toRender, neededTypes))
}

View File

@@ -0,0 +1,108 @@
package graphql_test
import (
"encoding/json"
"os"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/nhost/nhost/cli/mcp/graphql"
)
func TestParse(t *testing.T) {
t.Parallel()
b, err := os.ReadFile("testdata/schema.json")
if err != nil {
t.Fatalf("failed to read file: %v", err)
}
var response graphql.ResponseIntrospection
if err := json.Unmarshal(b, &response); err != nil {
t.Fatalf("failed to unmarshal json: %v", err)
}
cases := []struct {
name string
filter graphql.Filter
}{
{
name: "without_filter",
filter: graphql.Filter{
AllowQueries: nil,
AllowMutations: nil,
},
},
{
name: "with_filter",
filter: graphql.Filter{
AllowQueries: []graphql.Queries{
{
Name: "app",
DisableNesting: false,
},
{
Name: "apps",
DisableNesting: false,
},
},
AllowMutations: []graphql.Queries{
{
Name: "updateApp",
DisableNesting: false,
},
{
Name: "updateConfig",
DisableNesting: false,
},
},
},
},
{
name: "with_filter_and_disable_nesting",
filter: graphql.Filter{
AllowQueries: []graphql.Queries{
{
Name: "app",
DisableNesting: true,
},
{
Name: "apps",
DisableNesting: true,
},
},
AllowMutations: []graphql.Queries{
{
Name: "updateApp",
DisableNesting: true,
},
{
Name: "updateConfig",
DisableNesting: false,
},
},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := graphql.ParseSchema(response, tc.filter)
// if err := os.WriteFile("testdata/"+tc.name+".graphql", []byte(got), 0o644); err != nil {
// t.Fatalf("failed to write file: %v", err)
// }
b, err := os.ReadFile("testdata/" + tc.name + ".graphql")
if err != nil {
t.Fatalf("failed to read file: %v", err)
}
if diff := cmp.Diff(string(b), got); diff != "" {
t.Errorf("ParseSchema() mismatch (-want +got):\n%s", diff)
}
})
}
}

165
cli/mcp/graphql/query.go Normal file
View File

@@ -0,0 +1,165 @@
package graphql
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"slices"
"github.com/vektah/gqlparser/v2/ast"
"github.com/vektah/gqlparser/v2/parser"
)
var (
ErrQueryingGraphqlEndpoint = errors.New("error querying graphql endpoint")
ErrGraphqlContainErrors = errors.New("graphql response contains errors")
ErrQueryNotAllowed = errors.New("query not allowed")
)
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) {
return fmt.Errorf("%w: %s", ErrQueryNotAllowed, v.Name)
}
if err := checkAllowedOperation(v.SelectionSet, allowed); err != nil {
return err
}
}
}
return nil
}
func CheckAllowedGraphqlQuery( //nolint:cyclop
allowedQueries []string,
allowedMutations []string,
queryString string,
) error {
if allowedQueries == nil && allowedMutations == nil {
// nil means nothing allowed
return fmt.Errorf("%w: %s", ErrQueryNotAllowed, queryString)
}
if len(allowedQueries) == 0 && len(allowedMutations) == 0 {
// no queries or mutations allowed
return fmt.Errorf("%w: %s", ErrQueryNotAllowed, queryString)
}
query, err := parser.ParseQuery(&ast.Source{
Name: "schema.graphql",
Input: queryString,
BuiltIn: false,
})
if err != nil {
return fmt.Errorf("failed to parse query: %w", err)
}
for _, operation := range query.Operations {
if operation.Operation == ast.Subscription {
return fmt.Errorf("%w: %s", ErrQueryNotAllowed, queryString)
}
var (
selectionSet ast.SelectionSet
allowed []string
)
if operation.Operation == ast.Query {
selectionSet = operation.SelectionSet
allowed = allowedQueries
}
if operation.Operation == ast.Mutation {
selectionSet = operation.SelectionSet
allowed = allowedMutations
}
if err := checkAllowedOperation(selectionSet, allowed); err != nil {
return fmt.Errorf("%w: %w", ErrQueryNotAllowed, err)
}
}
return nil
}
func Query[T any]( //nolint:cyclop
ctx context.Context,
graphqlURL string,
query string,
variables map[string]any,
response *Response[T],
allowedQueries []string,
allowedMutations []string,
requestInterceptor ...func(ctx context.Context, req *http.Request) error,
) error {
if err := CheckAllowedGraphqlQuery(allowedQueries, allowedMutations, query); err != nil {
return err
}
requestBody, err := json.Marshal(map[string]any{
"query": query,
"variables": variables,
})
if err != nil {
return fmt.Errorf("failed to marshal request body: %w", err)
}
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
graphqlURL,
bytes.NewBuffer(requestBody),
)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
for _, interceptor := range requestInterceptor {
if err := interceptor(ctx, request); err != nil {
return fmt.Errorf("failed to intercept request: %w", err)
}
}
client := &http.Client{} //nolint:exhaustruct
resp, err := client.Do(request)
if err != nil {
return fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("%w: %s\n%s", ErrQueryingGraphqlEndpoint, resp.Status, b)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
if err := json.Unmarshal(body, response); err != nil {
return fmt.Errorf("failed to unmarshal response body: %w", err)
}
if len(response.Errors) > 0 {
return fmt.Errorf("%w: %s", ErrGraphqlContainErrors, body)
}
return nil
}

View File

@@ -0,0 +1,155 @@
package graphql_test
import (
"errors"
"testing"
"github.com/nhost/nhost/cli/mcp/graphql"
)
func TestCheckAllowedGraphqlQuery(t *testing.T) {
t.Parallel()
cases := []struct {
name string
query string
allowedQueries []string
allowedMutations []string
expectedError error
}{
{
name: "nil, nil",
query: `query { user(id: 1) { name } }`,
allowedQueries: nil,
allowedMutations: 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",
query: `query { user(id: 1) { name } }`,
allowedQueries: []string{},
allowedMutations: []string{},
expectedError: graphql.ErrQueryNotAllowed,
},
{
name: "no mutation allowed",
query: `mutation { user(id: 1) { name } }`,
allowedQueries: []string{},
allowedMutations: []string{},
expectedError: graphql.ErrQueryNotAllowed,
},
{
name: "query allowed",
query: `query { user(id: 1) { name } }`,
allowedQueries: []string{"user"},
allowedMutations: []string{},
expectedError: nil,
},
{
name: "query not allowed",
query: `query { projects(id: 1) { name } }`,
allowedQueries: []string{"user"},
allowedMutations: []string{},
expectedError: graphql.ErrQueryNotAllowed,
},
{
name: "mutation allowed",
query: `mutation { user(id: 1) { name } }`,
allowedQueries: []string{},
allowedMutations: []string{"user"},
expectedError: nil,
},
{
name: "mutation not allowed",
query: `mutation { projects(id: 1) { name } }`,
allowedQueries: []string{},
allowedMutations: []string{"user"},
expectedError: graphql.ErrQueryNotAllowed,
},
{
name: "multiple query allowed",
query: `query { user(id: 1) { name } projects(id: 1) { name } }`,
allowedQueries: []string{"user", "projects"},
allowedMutations: []string{},
expectedError: nil,
},
{
name: "multiple query not allowed",
query: `query { user(id: 1) { name } projects(id: 1) { name } }`,
allowedQueries: []string{"user"},
allowedMutations: []string{},
expectedError: graphql.ErrQueryNotAllowed,
},
{
name: "multiple mutation allowed",
query: `mutation { user(id: 1) { name } projects(id: 1) { name } }`,
allowedQueries: []string{},
allowedMutations: []string{"user", "projects"},
expectedError: nil,
},
{
name: "multiple mutation not allowed",
query: `mutation { user(id: 1) { name } projects(id: 1) { name } }`,
allowedQueries: []string{},
allowedMutations: []string{"user"},
expectedError: graphql.ErrQueryNotAllowed,
},
{
name: "nested query allowed",
query: `query { user(id: 1) { name projects(id: 1) { name } } }`,
allowedQueries: []string{"user", "projects"},
allowedMutations: []string{},
expectedError: nil,
},
{
name: "nested query not allowed",
query: `query { user(id: 1) { name projects(id: 1) { name } } }`,
allowedQueries: []string{"user"},
allowedMutations: []string{},
expectedError: graphql.ErrQueryNotAllowed,
},
{
name: "nested mutation allowed",
query: `mutation { user(id: 1) { name projects(id: 1) { name } } }`,
allowedQueries: []string{},
allowedMutations: []string{"user", "projects"},
expectedError: nil,
},
{
name: "nested mutation not allowed",
query: `mutation { user(id: 1) { name projects(id: 1) { name } } }`,
allowedQueries: []string{"user"},
allowedMutations: []string{},
expectedError: graphql.ErrQueryNotAllowed,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := graphql.CheckAllowedGraphqlQuery(
tc.allowedQueries,
tc.allowedMutations,
tc.query,
)
if !errors.Is(err, tc.expectedError) {
t.Errorf("expected error %v, got %v", tc.expectedError, err)
}
})
}
}

196156
cli/mcp/graphql/testdata/schema.json vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

102
cli/mcp/graphql/types.go Normal file
View File

@@ -0,0 +1,102 @@
package graphql
import (
"encoding/json"
"fmt"
)
type Kind string
func (k *Kind) UnmarshalJSON(data []byte) error {
var kind string
if err := json.Unmarshal(data, &kind); err != nil {
return err //nolint:wrapcheck
}
switch kind {
case string(KindObject),
string(KindNonNull),
string(KindList),
string(KindScalar),
string(KindEnum),
string(KindInputObject):
*k = Kind(kind)
default:
return fmt.Errorf("invalid kind: %s", kind) //nolint:err113
}
return nil
}
const (
KindObject Kind = "OBJECT"
KindNonNull Kind = "NON_NULL"
KindList Kind = "LIST"
KindScalar Kind = "SCALAR"
KindEnum Kind = "ENUM"
KindInputObject Kind = "INPUT_OBJECT"
)
type ResponseIntrospection = Response[IntrospectionResponse]
type Response[T any] struct {
Data T `json:"data"`
Errors []Errors `json:"errors"`
}
type Extensions struct {
Path string `json:"path"`
Code string `json:"code"`
}
type Errors struct {
Message string `json:"message"`
Extensions Extensions `json:"extensions"`
}
type IntrospectionResponse struct {
Schema Schema `json:"__schema"`
}
// Schema represents the GraphQL schema.
type Schema struct {
QueryType Type `json:"queryType"`
MutationType *Type `json:"mutationType"`
Types []Type `json:"types"`
}
// Type represents a GraphQL type (__Type).
type Type struct {
Kind Kind `json:"kind"`
Name *string `json:"name"`
Description *string `json:"description"`
Fields []Field `json:"fields"`
InputFields []InputValue `json:"inputFields"`
Interfaces []Type `json:"interfaces"`
EnumValues []EnumValue `json:"enumValues"`
PossibleTypes []Type `json:"possibleTypes"`
// For TypeRef fragment
OfType *Type `json:"ofType"`
}
// Field represents a field in a GraphQL type.
type Field struct {
Name string `json:"name"`
Description *string `json:"description"`
Args []InputValue `json:"args"`
Type Type `json:"type"`
}
// InputValue represents an input value in a GraphQL schema.
type InputValue struct {
Name string `json:"name"`
Description *string `json:"description"`
Type Type `json:"type"`
DefaultValue *string `json:"defaultValue"`
}
// EnumValue represents an enum value in a GraphQL schema.
type EnumValue struct {
Name string `json:"name"`
Description *string `json:"description"`
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
//go:generate oapi-codegen -generate types,client -response-type-suffix R -package auth -o auth.gen.go openapi.yaml
package auth

View File

@@ -0,0 +1,82 @@
package auth
import (
"context"
"errors"
"fmt"
"net/http"
"time"
)
var ErrSigninIn = errors.New("error during sign in")
func WithPAT(
url string,
pat string,
) (func(ctx context.Context, req *http.Request) error, error) {
authc, err := NewClientWithResponses(url)
if err != nil {
return nil, fmt.Errorf("couldn't not create auth client: %w", err)
}
session := &struct {
AccessToken string
ExpiresAt time.Time
}{
AccessToken: "",
ExpiresAt: time.Time{},
}
return func(ctx context.Context, req *http.Request) error {
if time.Now().Add(time.Minute).After(session.ExpiresAt) {
resp, err := authc.PostSigninPatWithResponse(
ctx,
SignInPATRequest{
PersonalAccessToken: pat,
},
)
if err != nil {
return fmt.Errorf("failed to sign in with PAT: %w", err)
}
if resp.StatusCode() != http.StatusOK {
return fmt.Errorf("%w: %s\n%s", ErrSigninIn, resp.Status(), resp.Body)
}
session.AccessToken = resp.JSON200.Session.AccessToken
session.ExpiresAt = time.Now().Add(
time.Second * time.Duration(resp.JSON200.Session.AccessTokenExpiresIn))
}
req.Header.Add("Authorization", "Bearer "+session.AccessToken)
return nil
}, nil
}
func WithAdminSecret(
adminSecret string,
) func(ctx context.Context, req *http.Request) error {
return func(_ context.Context, req *http.Request) error {
req.Header.Add("X-Hasura-Admin-Secret", adminSecret)
return nil
}
}
func WithRole(
role string,
) func(ctx context.Context, req *http.Request) error {
return func(_ context.Context, req *http.Request) error {
req.Header.Add("X-Hasura-Role", role)
return nil
}
}
func WithUserID(
userID string,
) func(ctx context.Context, req *http.Request) error {
return func(_ context.Context, req *http.Request) error {
req.Header.Add("X-Hasura-User-Id", userID)
return nil
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
//go:generate oapi-codegen -generate types,client -response-type-suffix R -package graphql -o graphql.gen.go openapi.yaml
package graphql
import (
_ "embed"
)
//go:embed openapi.yaml
var Schema string

File diff suppressed because it is too large Load Diff

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
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,829 @@
scalar Boolean
scalar ConfigEmail
scalar ConfigHasuraAPIs
scalar ConfigInt32
scalar ConfigLocale
scalar ConfigPort
scalar ConfigUint
scalar ConfigUint32
scalar ConfigUint8
scalar ConfigUrl
scalar ConfigUserRole
scalar Float
scalar Int
scalar String
scalar jsonb
scalar timestamptz
scalar uuid
type ConfigAI {
autoEmbeddings: ConfigAIAutoEmbeddings
openai: ConfigAIOpenai!
resources: ConfigAIResources!
version: String
webhookSecret: String!
}
type ConfigAIAutoEmbeddings {
synchPeriodMinutes: ConfigUint32
}
type ConfigAIOpenai {
apiKey: String!
organization: String
}
type ConfigAIResources {
compute: ConfigComputeResources!
}
type ConfigAuth {
elevatedPrivileges: ConfigAuthElevatedPrivileges
method: ConfigAuthMethod
misc: ConfigAuthMisc
rateLimit: ConfigAuthRateLimit
redirections: ConfigAuthRedirections
resources: ConfigResources
session: ConfigAuthSession
signUp: ConfigAuthSignUp
totp: ConfigAuthTotp
user: ConfigAuthUser
version: String
}
type ConfigAuthElevatedPrivileges {
mode: String
}
type ConfigAuthMethod {
anonymous: ConfigAuthMethodAnonymous
emailPassword: ConfigAuthMethodEmailPassword
emailPasswordless: ConfigAuthMethodEmailPasswordless
oauth: ConfigAuthMethodOauth
otp: ConfigAuthMethodOtp
smsPasswordless: ConfigAuthMethodSmsPasswordless
webauthn: ConfigAuthMethodWebauthn
}
type ConfigAuthMethodAnonymous {
enabled: Boolean
}
type ConfigAuthMethodEmailPassword {
emailVerificationRequired: Boolean
hibpEnabled: Boolean
passwordMinLength: ConfigUint8
}
type ConfigAuthMethodEmailPasswordless {
enabled: Boolean
}
type ConfigAuthMethodOauth {
apple: ConfigAuthMethodOauthApple
azuread: ConfigAuthMethodOauthAzuread
bitbucket: ConfigStandardOauthProvider
discord: ConfigStandardOauthProviderWithScope
facebook: ConfigStandardOauthProviderWithScope
github: ConfigStandardOauthProviderWithScope
gitlab: ConfigStandardOauthProviderWithScope
google: ConfigStandardOauthProviderWithScope
linkedin: ConfigStandardOauthProviderWithScope
spotify: ConfigStandardOauthProviderWithScope
strava: ConfigStandardOauthProviderWithScope
twitch: ConfigStandardOauthProviderWithScope
twitter: ConfigAuthMethodOauthTwitter
windowslive: ConfigStandardOauthProviderWithScope
workos: ConfigAuthMethodOauthWorkos
}
type ConfigAuthMethodOauthApple {
audience: String
clientId: String
enabled: Boolean
keyId: String
privateKey: String
scope: [String!]
teamId: String
}
type ConfigAuthMethodOauthAzuread {
clientId: String
clientSecret: String
enabled: Boolean
tenant: String
}
type ConfigAuthMethodOauthTwitter {
consumerKey: String
consumerSecret: String
enabled: Boolean
}
type ConfigAuthMethodOauthWorkos {
clientId: String
clientSecret: String
connection: String
enabled: Boolean
organization: String
}
type ConfigAuthMethodOtp {
email: ConfigAuthMethodOtpEmail
}
type ConfigAuthMethodOtpEmail {
enabled: Boolean
}
type ConfigAuthMethodSmsPasswordless {
enabled: Boolean
}
type ConfigAuthMethodWebauthn {
attestation: ConfigAuthMethodWebauthnAttestation
enabled: Boolean
relyingParty: ConfigAuthMethodWebauthnRelyingParty
}
type ConfigAuthMethodWebauthnAttestation {
timeout: ConfigUint32
}
type ConfigAuthMethodWebauthnRelyingParty {
id: String
name: String
origins: [ConfigUrl!]
}
type ConfigAuthMisc {
concealErrors: Boolean
}
type ConfigAuthRateLimit {
bruteForce: ConfigRateLimit
emails: ConfigRateLimit
global: ConfigRateLimit
signups: ConfigRateLimit
sms: ConfigRateLimit
}
type ConfigAuthRedirections {
allowedUrls: [String!]
clientUrl: ConfigUrl
}
type ConfigAuthSession {
accessToken: ConfigAuthSessionAccessToken
refreshToken: ConfigAuthSessionRefreshToken
}
type ConfigAuthSessionAccessToken {
customClaims: [ConfigAuthsessionaccessTokenCustomClaims!]
expiresIn: ConfigUint32
}
type ConfigAuthSessionRefreshToken {
expiresIn: ConfigUint32
}
type ConfigAuthSignUp {
disableNewUsers: Boolean
enabled: Boolean
turnstile: ConfigAuthSignUpTurnstile
}
type ConfigAuthSignUpTurnstile {
secretKey: String!
}
type ConfigAuthTotp {
enabled: Boolean
issuer: String
}
type ConfigAuthUser {
email: ConfigAuthUserEmail
emailDomains: ConfigAuthUserEmailDomains
gravatar: ConfigAuthUserGravatar
locale: ConfigAuthUserLocale
roles: ConfigAuthUserRoles
}
type ConfigAuthUserEmail {
allowed: [ConfigEmail!]
blocked: [ConfigEmail!]
}
type ConfigAuthUserEmailDomains {
allowed: [String!]
blocked: [String!]
}
type ConfigAuthUserGravatar {
default: String
enabled: Boolean
rating: String
}
type ConfigAuthUserLocale {
allowed: [ConfigLocale!]
default: ConfigLocale
}
type ConfigAuthUserRoles {
allowed: [ConfigUserRole!]
default: ConfigUserRole
}
type ConfigAuthsessionaccessTokenCustomClaims {
key: String!
value: String!
}
type ConfigAutoscaler {
maxReplicas: ConfigUint8!
}
type ConfigClaimMap {
claim: String!
default: String
path: String
value: String
}
type ConfigComputeResources {
cpu: ConfigUint32!
memory: ConfigUint32!
}
type ConfigConfig {
ai: ConfigAI
auth: ConfigAuth
functions: ConfigFunctions
global: ConfigGlobal
graphql: ConfigGraphql
hasura: ConfigHasura!
observability: ConfigObservability!
postgres: ConfigPostgres!
provider: ConfigProvider
storage: ConfigStorage
}
type ConfigFunctions {
node: ConfigFunctionsNode
rateLimit: ConfigRateLimit
resources: ConfigFunctionsResources
}
type ConfigFunctionsNode {
version: Int
}
type ConfigFunctionsResources {
networking: ConfigNetworking
}
type ConfigGlobal {
environment: [ConfigGlobalEnvironmentVariable!]
}
type ConfigGlobalEnvironmentVariable {
name: String!
value: String!
}
type ConfigGrafana {
adminPassword: String!
alerting: ConfigGrafanaAlerting
contacts: ConfigGrafanaContacts
smtp: ConfigGrafanaSmtp
}
type ConfigGrafanaAlerting {
enabled: Boolean
}
type ConfigGrafanaContacts {
discord: [ConfigGrafanacontactsDiscord!]
emails: [String!]
pagerduty: [ConfigGrafanacontactsPagerduty!]
slack: [ConfigGrafanacontactsSlack!]
webhook: [ConfigGrafanacontactsWebhook!]
}
type ConfigGrafanaSmtp {
host: String!
password: String!
port: ConfigPort!
sender: String!
user: String!
}
type ConfigGrafanacontactsDiscord {
avatarUrl: String!
url: String!
}
type ConfigGrafanacontactsPagerduty {
class: String!
component: String!
group: String!
integrationKey: String!
severity: String!
}
type ConfigGrafanacontactsSlack {
endpointURL: String!
iconEmoji: String!
iconURL: String!
mentionChannel: String!
mentionGroups: [String!]!
mentionUsers: [String!]!
recipient: String!
token: String!
url: String!
username: String!
}
type ConfigGrafanacontactsWebhook {
authorizationCredentials: String!
authorizationScheme: String!
httpMethod: String!
maxAlerts: Int!
password: String!
url: String!
username: String!
}
type ConfigGraphql {
security: ConfigGraphqlSecurity
}
type ConfigGraphqlSecurity {
forbidAminSecret: Boolean
maxDepthQueries: ConfigUint
}
type ConfigHasura {
adminSecret: String!
authHook: ConfigHasuraAuthHook
events: ConfigHasuraEvents
jwtSecrets: [ConfigJWTSecret!]
logs: ConfigHasuraLogs
rateLimit: ConfigRateLimit
resources: ConfigResources
settings: ConfigHasuraSettings
version: String
webhookSecret: String!
}
type ConfigHasuraAuthHook {
mode: String
sendRequestBody: Boolean
url: String!
}
type ConfigHasuraEvents {
httpPoolSize: ConfigUint32
}
type ConfigHasuraLogs {
level: String
}
type ConfigHasuraSettings {
corsDomain: [ConfigUrl!]
devMode: Boolean
enableAllowList: Boolean
enableConsole: Boolean
enableRemoteSchemaPermissions: Boolean
enabledAPIs: [ConfigHasuraAPIs!]
inferFunctionPermissions: Boolean
liveQueriesMultiplexedRefetchInterval: ConfigUint32
stringifyNumericTypes: Boolean
}
type ConfigIngress {
fqdn: [String!]
tls: ConfigIngressTls
}
type ConfigIngressTls {
clientCA: String
}
type ConfigJWTSecret {
allowed_skew: ConfigUint32
audience: String
claims_format: String
claims_map: [ConfigClaimMap!]
claims_namespace: String
claims_namespace_path: String
header: String
issuer: String
jwk_url: ConfigUrl
key: String
kid: String
signingKey: String
type: String
}
type ConfigNetworking {
ingresses: [ConfigIngress!]
}
type ConfigObservability {
grafana: ConfigGrafana!
}
type ConfigPostgres {
pitr: ConfigPostgresPitr
resources: ConfigPostgresResources!
settings: ConfigPostgresSettings
version: String
}
type ConfigPostgresPitr {
retention: ConfigUint8
}
type ConfigPostgresResources {
compute: ConfigResourcesCompute
enablePublicAccess: Boolean
replicas: Int
storage: ConfigPostgresResourcesStorage!
}
type ConfigPostgresResourcesStorage {
capacity: ConfigUint32!
}
type ConfigPostgresSettings {
archiveTimeout: ConfigInt32
checkpointCompletionTarget: Float
defaultStatisticsTarget: ConfigInt32
effectiveCacheSize: String
effectiveIOConcurrency: ConfigInt32
hugePages: String
jit: String
maintenanceWorkMem: String
maxConnections: ConfigInt32
maxParallelMaintenanceWorkers: ConfigInt32
maxParallelWorkers: ConfigInt32
maxParallelWorkersPerGather: ConfigInt32
maxReplicationSlots: ConfigInt32
maxWalSenders: ConfigInt32
maxWalSize: String
maxWorkerProcesses: ConfigInt32
minWalSize: String
randomPageCost: Float
sharedBuffers: String
walBuffers: String
walLevel: String
workMem: String
}
type ConfigProvider {
sms: ConfigSms
smtp: ConfigSmtp
}
type ConfigRateLimit {
interval: String!
limit: ConfigUint32!
}
type ConfigResources {
autoscaler: ConfigAutoscaler
compute: ConfigResourcesCompute
networking: ConfigNetworking
replicas: ConfigUint8
}
type ConfigResourcesCompute {
cpu: ConfigUint32!
memory: ConfigUint32!
}
type ConfigSms {
accountSid: String!
authToken: String!
messagingServiceId: String!
provider: String
}
type ConfigSmtp {
host: String!
method: String!
password: String!
port: ConfigPort!
secure: Boolean!
sender: String!
user: String!
}
type ConfigStandardOauthProvider {
clientId: String
clientSecret: String
enabled: Boolean
}
type ConfigStandardOauthProviderWithScope {
audience: String
clientId: String
clientSecret: String
enabled: Boolean
scope: [String!]
}
type ConfigStorage {
antivirus: ConfigStorageAntivirus
rateLimit: ConfigRateLimit
resources: ConfigResources
version: String
}
type ConfigStorageAntivirus {
server: String
}
type apps {
appSecrets: [ConfigEnvironmentVariable!]!
"""An array relationship"""
appStates(distinct_on: [appStateHistory_select_column!], limit: Int, offset: Int, order_by: [appStateHistory_order_by!]): [appStateHistory!]!
automaticDeploys: Boolean!
"""An array relationship"""
backups(distinct_on: [backups_select_column!], limit: Int, offset: Int, order_by: [backups_order_by!]): [backups!]!
config(resolve: Boolean!): ConfigConfig
createdAt: timestamptz!
creatorUserId: uuid
"""An array relationship"""
deployments(distinct_on: [deployments_select_column!], limit: Int, offset: Int, order_by: [deployments_order_by!]): [deployments!]!
desiredState: Int!
"""An array relationship"""
featureFlags(distinct_on: [featureFlags_select_column!], limit: Int, offset: Int, order_by: [featureFlags_order_by!]): [featureFlags!]!
githubRepositoryId: uuid
id: uuid!
isLocked: Boolean
isLockedReason: String
metadataFunctions(path: String): jsonb!
name: String!
nhostBaseFolder: String!
"""An object relationship"""
organization: organizations
organizationID: uuid
"""An object relationship"""
region: regions!
repositoryProductionBranch: String!
"""An array relationship"""
runServices(distinct_on: [run_service_select_column!], limit: Int, offset: Int, order_by: [run_service_order_by!]): [run_service!]!
"""An aggregate relationship"""
runServices_aggregate(distinct_on: [run_service_select_column!], limit: Int, offset: Int, order_by: [run_service_order_by!]): run_service_aggregate!
slug: String!
subdomain: String!
updatedAt: timestamptz!
workspaceId: uuid
}
type organizations {
"""An array relationship"""
allowedPrivateRegions(distinct_on: [regions_allowed_organization_select_column!], limit: Int, offset: Int, order_by: [regions_allowed_organization_order_by!]): [regions_allowed_organization!]!
"""An array relationship"""
apps(distinct_on: [apps_select_column!], limit: Int, offset: Int, order_by: [apps_order_by!], where: apps_bool_exp): [apps!]!
createdAt: timestamptz!
current_threshold: organization_costs_thresholds_enum!
id: uuid!
"""An array relationship"""
invites(distinct_on: [organization_member_invites_select_column!], limit: Int, offset: Int, order_by: [organization_member_invites_order_by!]): [organization_member_invites!]!
"""An array relationship"""
members(distinct_on: [organization_members_select_column!], limit: Int, offset: Int, order_by: [organization_members_order_by!]): [organization_members!]!
name: String!
"""An object relationship"""
plan: plans!
planID: uuid!
slug: String!
status: organization_status_enum!
threshold: Int!
updatedAt: timestamptz!
}
input Boolean_comparison_exp {
_eq: Boolean
_gt: Boolean
_gte: Boolean
_in: [Boolean!]
_is_null: Boolean
_lt: Boolean
_lte: Boolean
_neq: Boolean
_nin: [Boolean!]
}
input Int_comparison_exp {
_eq: Int
_gt: Int
_gte: Int
_in: [Int!]
_is_null: Boolean
_lt: Int
_lte: Int
_neq: Int
_nin: [Int!]
}
input String_comparison_exp {
_eq: String
_gt: String
_gte: String
"""does the column match the given case-insensitive pattern"""
_ilike: String
_in: [String!]
"""does the column match the given POSIX regular expression, case insensitive"""
_iregex: String
_is_null: Boolean
"""does the column match the given pattern"""
_like: String
_lt: String
_lte: String
_neq: String
"""does the column NOT match the given case-insensitive pattern"""
_nilike: String
_nin: [String!]
"""does the column NOT match the given POSIX regular expression, case insensitive"""
_niregex: String
"""does the column NOT match the given pattern"""
_nlike: String
"""does the column NOT match the given POSIX regular expression, case sensitive"""
_nregex: String
"""does the column NOT match the given SQL regular expression"""
_nsimilar: String
"""does the column match the given POSIX regular expression, case sensitive"""
_regex: String
"""does the column match the given SQL regular expression"""
_similar: String
}
input apps_bool_exp {
_and: [apps_bool_exp!]
_not: apps_bool_exp
_or: [apps_bool_exp!]
automaticDeploys: Boolean_comparison_exp
createdAt: timestamptz_comparison_exp
creatorUserId: uuid_comparison_exp
desiredState: Int_comparison_exp
githubRepositoryId: uuid_comparison_exp
id: uuid_comparison_exp
isLocked: Boolean_comparison_exp
isLockedReason: String_comparison_exp
metadataFunctions: jsonb_comparison_exp
name: String_comparison_exp
nhostBaseFolder: String_comparison_exp
organization: organizations_bool_exp
organizationID: uuid_comparison_exp
repositoryProductionBranch: String_comparison_exp
slug: String_comparison_exp
subdomain: String_comparison_exp
updatedAt: timestamptz_comparison_exp
workspaceId: uuid_comparison_exp
}
input apps_order_by {
automaticDeploys: order_by
createdAt: order_by
creatorUserId: order_by
desiredState: order_by
githubRepositoryId: order_by
id: order_by
isLocked: order_by
isLockedReason: order_by
metadataFunctions: order_by
name: order_by
nhostBaseFolder: order_by
organization: organizations_order_by
organizationID: order_by
repositoryProductionBranch: order_by
slug: order_by
subdomain: order_by
updatedAt: order_by
workspaceId: order_by
}
input jsonb_comparison_exp {
"""is the column contained in the given json value"""
_contained_in: jsonb
"""does the column contain the given json value at the top level"""
_contains: jsonb
_eq: jsonb
_gt: jsonb
_gte: jsonb
"""does the string exist as a top-level key in the column"""
_has_key: String
"""do all of these strings exist as top-level keys in the column"""
_has_keys_all: [String!]
"""do any of these strings exist as top-level keys in the column"""
_has_keys_any: [String!]
_in: [jsonb!]
_is_null: Boolean
_lt: jsonb
_lte: jsonb
_neq: jsonb
_nin: [jsonb!]
}
input organization_costs_thresholds_enum_comparison_exp {
_eq: organization_costs_thresholds_enum
_in: [organization_costs_thresholds_enum!]
_is_null: Boolean
_neq: organization_costs_thresholds_enum
_nin: [organization_costs_thresholds_enum!]
}
input organization_status_enum_comparison_exp {
_eq: organization_status_enum
_in: [organization_status_enum!]
_is_null: Boolean
_neq: organization_status_enum
_nin: [organization_status_enum!]
}
input organizations_bool_exp {
_and: [organizations_bool_exp!]
_not: organizations_bool_exp
_or: [organizations_bool_exp!]
apps: apps_bool_exp
createdAt: timestamptz_comparison_exp
current_threshold: organization_costs_thresholds_enum_comparison_exp
id: uuid_comparison_exp
name: String_comparison_exp
planID: uuid_comparison_exp
slug: String_comparison_exp
status: organization_status_enum_comparison_exp
threshold: Int_comparison_exp
updatedAt: timestamptz_comparison_exp
}
input organizations_order_by {
createdAt: order_by
current_threshold: order_by
id: order_by
name: order_by
planID: order_by
slug: order_by
status: order_by
threshold: order_by
updatedAt: order_by
}
input timestamptz_comparison_exp {
_eq: timestamptz
_gt: timestamptz
_gte: timestamptz
_in: [timestamptz!]
_is_null: Boolean
_lt: timestamptz
_lte: timestamptz
_neq: timestamptz
_nin: [timestamptz!]
}
input uuid_comparison_exp {
_eq: uuid
_gt: uuid
_gte: uuid
_in: [uuid!]
_is_null: Boolean
_lt: uuid
_lte: uuid
_neq: uuid
_nin: [uuid!]
}
type Query {
"""fetch data from the table: "apps" using primary key columns"""
app(id: uuid!): apps
"""An array relationship"""
apps(distinct_on: [apps_select_column!], limit: Int, offset: Int, order_by: [apps_order_by!], where: apps_bool_exp): [apps!]!
config(appID: uuid!, resolve: Boolean!): ConfigConfig
"""fetch data from the table: "organizations" using primary key columns"""
organization(id: uuid!): organizations
"""An array relationship"""
organizations(distinct_on: [organizations_select_column!], limit: Int, offset: Int, order_by: [organizations_order_by!], where: organizations_bool_exp): [organizations!]!
}

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,809 @@
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.46.0-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.7.2"
// 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.38.1"
// 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
}
}
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
}

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