This commit is contained in:
David Barroso
2025-10-06 11:57:42 +02:00
parent 5c7a6788b4
commit efb58f6565
35 changed files with 1377 additions and 1014 deletions

View File

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

View File

@@ -151,6 +151,9 @@ echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"project-graphql-
# search
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"search","arguments":{"query":"how to enable magic links"}},"id":1}' | mcp-nhost start
# resources
echo '{"jsonrpc":"2.0","method":"resources/read","params":{"uri":"schema://asdsadasd"},"id":1}' | go run main.go mcp start
```
## Roadmap

View File

@@ -16,8 +16,8 @@ import (
"github.com/nhost/nhost/cli/cmd/user"
"github.com/nhost/nhost/cli/mcp/tools/cloud"
"github.com/nhost/nhost/cli/mcp/tools/docs"
"github.com/nhost/nhost/cli/mcp/tools/local"
"github.com/nhost/nhost/cli/mcp/tools/project"
"github.com/nhost/nhost/cli/mcp/tools/schemas"
)
func ptr[T any](v T) *T {
@@ -108,7 +108,11 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx,paralleltest
Name: "mcp",
Version: "",
},
Instructions: start.ServerInstructions,
Instructions: start.ServerInstructions + `Configured projects:
- local (local): Local development project running via the Nhost CLI
- asdasdasdasdasd (eu-central-1): Staging project for my awesome app
- qweqweqweqweqwe (us-east-1): Production project for my awesome app
`,
Result: mcp.Result{
Meta: nil,
},
@@ -130,22 +134,6 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx,paralleltest
//nolint:exhaustruct,lll
&mcp.ListToolsResult{
Tools: []mcp.Tool{
{
Name: "cloud-get-graphql-schema",
Description: cloud.ToolGetGraphqlSchemaInstructions,
InputSchema: mcp.ToolInputSchema{
Type: "object",
Properties: nil,
Required: nil,
},
Annotations: mcp.ToolAnnotation{
Title: "Get GraphQL Schema for Nhost Cloud Platform",
ReadOnlyHint: ptr(true),
DestructiveHint: ptr(false),
IdempotentHint: ptr(true),
OpenWorldHint: ptr(true),
},
},
{
Name: "cloud-graphql-query",
Description: cloud.ToolGraphqlQueryInstructions,
@@ -172,24 +160,33 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx,paralleltest
},
},
{
Name: "local-config-server-get-schema",
Description: local.ToolConfigServerSchemaInstructions,
Name: "get-schema",
Description: schemas.ToolGetGraphqlSchemaInstructions,
InputSchema: mcp.ToolInputSchema{
Type: "object",
Properties: map[string]any{
"includeMutations": map[string]any{
"description": "include mutations in the schema",
"type": "boolean",
"role": map[string]any{
"default": string("user"),
"description": string("Role to use when fetching the schema. Useful only services `local` and `project`"),
"type": string("string"),
},
"includeQueries": map[string]any{
"description": "include queries in the schema",
"type": "boolean",
"service": map[string]any{
"enum": []any{
string("nhost"), string("config-schema"), string("graphql-management"),
string("project"),
},
"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"),
},
},
Required: []string{"includeQueries", "includeMutations"},
Required: []string{"service"},
},
Annotations: mcp.ToolAnnotation{
Title: "Get GraphQL Schema for Nhost Config Server",
Title: "Get GraphQL/API schema for various services",
ReadOnlyHint: ptr(true),
DestructiveHint: ptr(false),
IdempotentHint: ptr(true),
@@ -197,109 +194,7 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx,paralleltest
},
},
{
Name: "local-config-server-query",
Description: local.ToolConfigServerQueryInstructions,
InputSchema: mcp.ToolInputSchema{
Type: "object",
Properties: map[string]any{
"query": map[string]any{
"description": "graphql query to perform",
"type": "string",
},
"variables": map[string]any{
"description": "variables to use in the query",
"type": "string",
},
},
Required: []string{"query"},
},
Annotations: mcp.ToolAnnotation{
Title: "Perform GraphQL Query on Nhost Config Server",
ReadOnlyHint: ptr(false),
DestructiveHint: ptr(true),
IdempotentHint: ptr(false),
OpenWorldHint: ptr(true),
},
},
{
Name: "local-get-graphql-schema",
Description: local.ToolGetGraphqlSchemaInstructions,
InputSchema: mcp.ToolInputSchema{
Type: "object",
Properties: map[string]any{
"role": map[string]any{
"description": "role to use when executing queries. Default to user but make sure the user is aware",
"type": "string",
},
},
Required: []string{"role"},
},
Annotations: mcp.ToolAnnotation{
Title: "Get GraphQL Schema for Nhost Development Project",
ReadOnlyHint: ptr(true),
DestructiveHint: ptr(false),
IdempotentHint: ptr(true),
OpenWorldHint: ptr(true),
},
},
{
Name: "local-graphql-query",
Description: local.ToolGraphqlQueryInstructions,
InputSchema: mcp.ToolInputSchema{
Type: "object",
Properties: map[string]any{
"query": map[string]any{
"description": "graphql query to perform",
"type": "string",
},
"role": map[string]any{
"description": "role to use when executing queries. Default to user but make sure the user is aware. Keep in mind the schema depends on the role so if you retrieved the schema for a different role previously retrieve it for this role beforehand as it might differ",
"type": "string",
},
"variables": map[string]any{
"additionalProperties": true,
"description": "variables to use in the query",
"properties": map[string]any{},
"type": "object",
},
},
Required: []string{"query", "role"},
},
Annotations: mcp.ToolAnnotation{
Title: "Perform GraphQL Query on Nhost Development Project",
ReadOnlyHint: ptr(false),
DestructiveHint: ptr(true),
IdempotentHint: ptr(false),
OpenWorldHint: ptr(true),
},
},
{
Name: "project-get-graphql-schema",
Description: project.ToolGetGraphqlSchemaInstructions,
InputSchema: mcp.ToolInputSchema{
Type: "object",
Properties: map[string]any{
"projectSubdomain": map[string]any{
"description": "Project to get the GraphQL schema for. Must be one of asdasdasdasdasd, qweqweqweqweqwe, otherwise you don't have access to it. You can use cloud-* tools to resolve subdomains and map them to names",
"type": "string",
},
"role": map[string]any{
"description": "role to use when executing queries. Default to user but make sure the user is aware",
"type": "string",
},
},
Required: []string{"role", "projectSubdomain"},
},
Annotations: mcp.ToolAnnotation{
Title: "Get GraphQL Schema for Nhost Project running on Nhost Cloud",
ReadOnlyHint: ptr(true),
DestructiveHint: ptr(false),
IdempotentHint: ptr(true),
OpenWorldHint: ptr(true),
},
},
{
Name: "project-graphql-query",
Name: "graphql-query",
Description: project.ToolGraphqlQueryInstructions,
InputSchema: mcp.ToolInputSchema{
Type: "object",
@@ -308,13 +203,19 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx,paralleltest
"description": "graphql query to perform",
"type": "string",
},
"projectSubdomain": map[string]any{
"description": "Project to get the GraphQL schema for. Must be one of asdasdasdasdasd, qweqweqweqweqwe, otherwise you don't have access to it. You can use cloud-* tools to resolve subdomains and map them to names",
"subdomain": map[string]any{
"description": "Project to perform the GraphQL query against",
"type": "string",
"enum": []any{
string("local"),
string("asdasdasdasdasd"),
string("qweqweqweqweqwe"),
},
},
"role": map[string]any{
"description": "role to use when executing queries. Default to user but make sure the user is aware. Keep in mind the schema depends on the role so if you retrieved the schema for a different role previously retrieve it for this role beforehand as it might differ",
"type": "string",
"default": "user",
},
"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)"),
@@ -325,7 +226,7 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx,paralleltest
"type": "string",
},
},
Required: []string{"query", "projectSubdomain", "role"},
Required: []string{"query", "subdomain"},
},
Annotations: mcp.ToolAnnotation{
Title: "Perform GraphQL Query on Nhost Project running on Nhost Cloud",
@@ -336,36 +237,26 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx,paralleltest
},
},
{
Name: "local-get-management-graphql-schema",
Description: local.ToolGetGraphqlManagementSchemaInstructions,
InputSchema: mcp.ToolInputSchema{
Type: "object",
Properties: nil,
},
Annotations: mcp.ToolAnnotation{
Title: "Get GraphQL's Management Schema for Nhost Development Project",
ReadOnlyHint: ptr(true),
IdempotentHint: ptr(true),
DestructiveHint: ptr(false),
OpenWorldHint: ptr(true),
},
},
{
Name: "local-manage-graphql",
Description: local.ToolManageGraphqlInstructions,
Name: "manage-graphql",
Description: project.ToolManageGraphqlInstructions,
InputSchema: mcp.ToolInputSchema{
Type: "object",
Properties: map[string]any{
"body": map[string]any{
"description": string("The body for the HTTP request"),
"type": string("string"),
"description": "The body for the HTTP request",
"type": "string",
},
"endpoint": map[string]any{
"description": string("The GraphQL management endpoint to query. Use https://local.hasura.local.nhost.run as base URL"),
"type": string("string"),
"subdomain": map[string]any{
"description": "Project to perform the GraphQL management operation against",
"type": "string",
"enum": []any{
string("local"),
string("asdasdasdasdasd"),
string("qweqweqweqweqwe"),
},
},
},
Required: []string{"endpoint", "body"},
Required: []string{"subdomain", "body"},
},
Annotations: mcp.ToolAnnotation{
Title: "Manage GraphQL's Metadata on an Nhost Development Project",

View File

@@ -10,8 +10,8 @@ import (
"github.com/nhost/nhost/cli/mcp/nhost/auth"
"github.com/nhost/nhost/cli/mcp/tools/cloud"
"github.com/nhost/nhost/cli/mcp/tools/docs"
"github.com/nhost/nhost/cli/mcp/tools/local"
"github.com/nhost/nhost/cli/mcp/tools/project"
"github.com/nhost/nhost/cli/mcp/tools/schemas"
"github.com/urfave/cli/v3"
)
@@ -25,22 +25,22 @@ const (
// this seems to be largely ignored by clients, or at least by cursor.
// we also need to look into roots and resources as those might be helpful.
ServerInstructions = `
This is an MCP server to interact with Nhost Cloud and with projects running on it and
also with Nhost local development projects.
This is an MCP server to interact with Nhost Cloud and with projects running on it and
also with Nhost local development projects.
Important notes to anyone using this MCP server. Do not use this MCP server without
following these instructions:
Important notes to anyone using this MCP server. Do not use this MCP server without
following these instructions:
1. Make sure you are clear on which environment the user wants to operate against.
2. Before attempting to call any tool *-graphql-query, always get the schema using the
*-get-graphql-schema tool
3. Apps and projects are the same and while users may talk about projects in the GraphQL
api those are referred as apps.
4. IDs are always UUIDs so if you have anything else (like an app/project name) you may need
to first get the ID using the *-graphql-query tool.
5. If you have an error querying the GraphQL API, please check the schema again. The schema may
have changed and the query you are using may be invalid.
`
1. Make sure you are clear on which environment the user wants to operate against.
2. Before attempting to call any tool *-graphql-query, always make sure you read the various
resources and use the get-schema tool to get the required schemas
3. Apps and projects are the same and while users may talk about projects in the GraphQL
api those are referred as apps.
4. IDs are always UUIDs so if you have anything else (like an app/project name) you may need
to first get the ID using the *-graphql-query tool.
5. If you have an error querying the GraphQL API, please check the schema again. The schema may
have changed and the query you are using may be invalid.
`
)
func Command() *cli.Command {
@@ -82,6 +82,9 @@ func action(ctx context.Context, cmd *cli.Command) error {
return err
}
ServerInstructions := ServerInstructions
ServerInstructions += cfg.Projects.Instructions()
mcpServer := server.NewMCPServer(
cmd.Root().Name,
cmd.Root().Version,
@@ -100,18 +103,15 @@ func action(ctx context.Context, cmd *cli.Command) error {
}
}
if cfg.Local != nil {
if err := registerLocal(mcpServer, cfg); err != nil {
return cli.Exit(fmt.Sprintf("failed to register local tools: %s", err), 1)
}
}
if len(cfg.Projects) > 0 {
if err := registerProjectTool(mcpServer, cfg); err != nil {
return cli.Exit(fmt.Sprintf("failed to register project tools: %s", err), 1)
}
}
resources := schemas.NewTool(cfg)
resources.Register(mcpServer)
d, err := docs.NewTool(ctx)
if err != nil {
return cli.Exit(fmt.Sprintf("failed to initialize docs tools: %s", err), 1)
@@ -170,33 +170,11 @@ func registerCloud(
return nil
}
func registerLocal(
mcpServer *server.MCPServer,
cfg *config.Config,
) error {
interceptor := auth.WithAdminSecret(cfg.Local.AdminSecret)
localTool := local.NewTool(
*cfg.Local.GraphqlURL,
*cfg.Local.ConfigServerURL,
interceptor,
)
if err := localTool.Register(mcpServer); err != nil {
return fmt.Errorf("failed to register tools: %w", err)
}
return nil
}
func registerProjectTool(
mcpServer *server.MCPServer,
cfg *config.Config,
) error {
projectTool, err := project.NewTool(cfg.Projects)
if err != nil {
return fmt.Errorf("failed to initialize tool: %w", err)
}
projectTool := project.NewTool(cfg)
if err := projectTool.Register(mcpServer); err != nil {
return fmt.Errorf("failed to register tool: %w", err)
}

View File

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

View File

@@ -63,11 +63,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 +134,11 @@ version = '14.18-20250728-1'
[postgres.resources.storage]
capacity = 1
[postgres.settings]
maxConnections = 100
sharedBuffers = '256MB'
effectiveCacheSize = '768MB'
maintenanceWorkMem = '64MB'
checkpointCompletionTarget = 0.9
walBuffers = '-1'
defaultStatisticsTarget = 100
randomPageCost = 1.1
effectiveIOConcurrency = 200
workMem = '1310kB'
hugePages = 'off'
minWalSize = '80MB'
maxWalSize = '1GB'
maxWorkerProcesses = 8
maxParallelWorkersPerGather = 2
maxParallelWorkers = 8
maxParallelMaintenanceWorkers = 2
[provider]
[storage]
version = '0.7.1'
[ai]
version = '0.8.0'
webhookSecret = '{{ secrets.GRAPHITE_WEBHOOK_SECRET }}'
[ai.resources]
[ai.resources.compute]
cpu = 125
memory = 256
[ai.openai]
organization = ''
apiKey = '{{ secrets.OPENAI_API_KEY }}'
[ai.autoEmbeddings]
synchPeriodMinutes = 5
[observability]
[observability.grafana]
adminPassword = '{{ secrets.GRAFANA_ADMIN_PASSWORD }}'

View File

@@ -1,38 +1,35 @@
package config
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/nhost/nhost/cli/clienv"
"github.com/nhost/nhost/cli/mcp/nhost/auth"
"github.com/pelletier/go-toml/v2"
"github.com/urfave/cli/v3"
)
func ptr[T any](v T) *T {
return &v
}
const (
DefaultLocalConfigServerURL = "https://local.dashboard.local.nhost.run/v1/configserver/graphql"
DefaultLocalGraphqlURL = "https://local.graphql.local.nhost.run/v1"
)
var ErrProjectNotConfigured = errors.New("project not configured")
type Config struct {
// If configured allows managing the cloud. For instance, this allows you to configure
// projects, list projects, organizations, and so on.
Cloud *Cloud `json:"cloud,omitempty" toml:"cloud"`
// If configured allows working with a local project running via the CLI. This includes
// configuring it, working with the schema, migrations, etc.
Local *Local `json:"local,omitempty" toml:"local"`
// Projects is a list of projects that you want to allow access to. This grants access to the
// GraphQL schema allowing it to inspect it and run allowed queries and mutations.
Projects []Project `json:"projects" toml:"projects"`
// Projects is a list of projects that you want to allow access to. This grants access
// to the GraphQL schema allowing it to inspect it and run allowed queries and mutations.
Projects ProjectList `json:"projects" toml:"projects"`
}
type Cloud struct {
@@ -41,17 +38,41 @@ type Cloud struct {
EnableMutations bool `json:"enable_mutations" toml:"enable_mutations"`
}
type Local struct {
// Admin secret to use when running against a local project.
AdminSecret string `json:"admin_secret" toml:"admin_secret"`
type ProjectList []Project
// GraphQL URL to use when running against a local project.
// Defaults to "https://local.dashboard.local.nhost.run/v1/configserver/graphql"
ConfigServerURL *string `json:"config_server_url,omitempty" toml:"config_server_url,omitempty"`
func (pl ProjectList) Get(subdomain string) (*Project, error) {
for _, p := range pl {
if p.Subdomain == subdomain {
return &p, nil
}
}
// GraphQL URL to use when running against a local project.
// Defaults to "https://local.graphql.local.nhost.run/v1"
GraphqlURL *string `json:"graphql_url,omitempty" toml:"graphql_url,omitempty"`
return nil, fmt.Errorf("%w: %s", ErrProjectNotConfigured, subdomain)
}
func (pl ProjectList) Subdomains() []string {
subdomains := make([]string, 0, len(pl))
for _, p := range pl {
subdomains = append(subdomains, p.Subdomain)
}
return subdomains
}
func (pl ProjectList) Instructions() string {
if len(pl) == 0 {
return "No projects configured. Please, run `nhost mcp config` to configure your projects."
}
var sb strings.Builder
sb.WriteString("Configured projects:\n")
for _, p := range pl {
sb.WriteString(fmt.Sprintf("- %s (%s): %s\n", p.Subdomain, p.Region, p.Description))
}
return sb.String()
}
type Project struct {
@@ -61,6 +82,9 @@ type Project struct {
// Project's region
Region string `json:"region" toml:"region"`
// Project's description
Description string `json:"description,omitempty" toml:"description,omitempty"`
// Admin secret to operate against the project.
// Either admin secret or PAT is required.
AdminSecret *string `json:"admin_secret,omitempty" toml:"admin_secret,omitempty"`
@@ -69,6 +93,10 @@ type Project struct {
// Either admin secret or PAT is required.
PAT *string `json:"pat,omitempty" toml:"pat,omitempty"`
// If enabled, allows managing the project's metadata (tables, relationships,
// permissions, etc).
ManageMetadata bool `json:"manage_metadata,omitempty" toml:"manage_metadata,omitempty"`
// List of queries that are allowed to be executed against the project.
// If empty, no queries are allowed. Use [*] to allow all queries.
AllowQueries []string `json:"allow_queries" toml:"allow_queries"`
@@ -77,6 +105,58 @@ type Project struct {
// If empty, no mutations are allowed. Use [*] to allow all mutations.
// Note that this is only used if the project is configured to allow mutations.
AllowMutations []string `json:"allow_mutations" toml:"allow_mutations"`
// GraphQL URL to use when running against the project. Defaults to constructed URL with
// the subdomain and region.
GraphqlURL string `json:"graphql_url,omitzero" toml:"graphql_url,omitzero"`
// Auth URL to use when running against the project. Defaults to constructed URL with
// the subdomain and region.
AuthURL string `json:"auth_url,omitzero" toml:"auth_url,omitzero"`
// Hasura's base URL. Defaults to constructed URL with the subdomain and region.
HasuraURL string `json:"hasura_url,omitzero" toml:"hasura_url,omitzero"`
}
func (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 {
@@ -117,15 +197,5 @@ func Load(path string) (*Config, error) {
return nil, fmt.Errorf("failed to unmarshal config file: %w", err)
}
if config.Local != nil {
if config.Local.GraphqlURL == nil {
config.Local.GraphqlURL = ptr(DefaultLocalGraphqlURL)
}
if config.Local.ConfigServerURL == nil {
config.Local.ConfigServerURL = ptr(DefaultLocalConfigServerURL)
}
}
return &config, nil
}

View File

@@ -3,6 +3,8 @@ package config //nolint:testpackage
import (
"os"
"testing"
"github.com/google/go-cmp/cmp"
)
func TestInterpolateEnv(t *testing.T) {
@@ -134,7 +136,9 @@ func TestInterpolateEnvRealWorld(t *testing.T) {
return envVars[key]
}
input := `[local]
input := `[[projects]]
subdomain = "local"
region = "local"
admin_secret = "$ADMIN_SECRET"
[[projects]]
@@ -143,7 +147,9 @@ admin_secret = "$ADMIN_SECRET"
# Price is $$100
`
expected := `[local]
expected := `[[projects]]
subdomain = "local"
region = "local"
admin_secret = "super-secret-key"
[[projects]]
@@ -191,9 +197,13 @@ func TestIsAlphaNumUnderscore(t *testing.T) {
}
}
func ptr[T any](v T) *T {
return &v
}
func TestLoadWithInterpolation(t *testing.T) {
// Create a temporary config file
content := `[local]
content := `[[projects]]
admin_secret = "$TEST_ADMIN_SECRET"
[[projects]]
@@ -222,33 +232,25 @@ allow_queries = ["*"]
t.Setenv("TEST_PROJECT_SECRET", "project-secret")
// Load config
config, err := Load(tmpfile.Name())
cfg, err := Load(tmpfile.Name())
if err != nil {
t.Fatalf("Load() error = %v", err)
}
// Verify interpolation worked
if config.Local == nil {
t.Fatal("config.Local is nil")
}
if config.Local.AdminSecret != "local-secret" {
t.Errorf("config.Local.AdminSecret = %q, want %q", config.Local.AdminSecret, "local-secret")
}
if len(config.Projects) != 1 {
t.Fatalf("len(config.Projects) = %d, want 1", len(config.Projects))
}
if config.Projects[0].AdminSecret == nil {
t.Fatal("config.Projects[0].AdminSecret is nil")
}
if *config.Projects[0].AdminSecret != "project-secret" {
t.Errorf(
"config.Projects[0].AdminSecret = %q, want %q",
*config.Projects[0].AdminSecret,
"project-secret",
)
if diff := cmp.Diff(cfg, &Config{
Cloud: nil,
Projects: ProjectList{
{ //nolint:exhaustruct
AdminSecret: ptr("local-secret"),
},
{ //nolint:exhaustruct
Subdomain: "myapp",
Region: "us-east-1",
AdminSecret: ptr("project-secret"),
AllowQueries: []string{"*"},
},
},
}); diff != "" {
t.Errorf("diff = %s", diff)
}
}

View File

@@ -25,11 +25,14 @@ func RunWizard() (*Config, error) {
projects := wizardProject(reader)
if localConfig != nil {
projects = append(projects, *localConfig)
}
fmt.Println()
return &Config{
Cloud: cloudConfig,
Local: localConfig,
Projects: projects,
}, nil
}
@@ -52,7 +55,7 @@ func wizardCloud(reader *bufio.Reader) *Cloud {
}
//nolint:forbidigo
func wizardLocal(reader *bufio.Reader) *Local {
func wizardLocal(reader *bufio.Reader) *Project {
fmt.Println("2. Local Development Access")
fmt.Println(" This allows LLMs to interact with your local Nhost environment,")
fmt.Println(" including project configuration and GraphQL API access.")
@@ -64,10 +67,18 @@ func wizardLocal(reader *bufio.Reader) *Local {
adminSecret = "nhost-admin-secret" //nolint:gosec
}
return &Local{
AdminSecret: adminSecret,
ConfigServerURL: nil,
GraphqlURL: nil,
return &Project{
Subdomain: "local",
Region: "local",
Description: "Local development project running via the Nhost CLI",
AdminSecret: &adminSecret,
PAT: nil,
ManageMetadata: true,
AllowQueries: []string{"*"},
AllowMutations: []string{"*"},
AuthURL: "",
GraphqlURL: "",
HasuraURL: "",
}
}
@@ -91,16 +102,29 @@ func wizardProject(reader *bufio.Reader) []Project {
if promptYesNo(reader, "Configure project access?") {
for {
project := Project{
Description: "",
Subdomain: "",
Region: "",
AdminSecret: nil,
PAT: nil,
ManageMetadata: false,
AllowQueries: []string{"*"},
AllowMutations: []string{"*"},
GraphqlURL: "",
AuthURL: "",
HasuraURL: "",
}
project.Subdomain = promptString(reader, "Project subdomain:")
project.Region = promptString(reader, "Project region:")
project.Description = promptString(
reader,
"Project description to provide additional information to LLMs:",
)
project.ManageMetadata = promptYesNo(
reader,
"Allow managing metadata (tables, relationships, permissions, etc)?",
)
authType := promptChoice(
reader,

View File

@@ -30,7 +30,7 @@ func getTypeName(t Type) string {
}
// ParseSchema converts an introspection query result into a GraphQL SDL string.
func ParseSchema(response ResponseIntrospection, filter Filter) string {
func ParseSchema(response ResponseIntrospection, filter Filter) string { //nolint:cyclop
availableTypes := make(map[string]Type)
// Process all types in the schema
@@ -58,6 +58,9 @@ func ParseSchema(response ResponseIntrospection, filter Filter) string {
}
neededMutations := make(map[string]Field)
if response.Data.Schema.MutationType == nil {
return render(neededQueries, neededMutations, neededTypes)
}
for _, mutation := range response.Data.Schema.MutationType.Fields {
if filter.AllowMutations == nil {

View File

@@ -45,8 +45,8 @@ func CheckAllowedGraphqlQuery( //nolint:cyclop
queryString string,
) error {
if allowedQueries == nil && allowedMutations == nil {
// nil means unrestricted
return nil
// nil means nothing allowed
return fmt.Errorf("%w: %s", ErrQueryNotAllowed, queryString)
}
if len(allowedQueries) == 0 && len(allowedMutations) == 0 {

View File

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

View File

@@ -0,0 +1,5 @@
package resources
import "errors"
var ErrParameterRequired = errors.New("parameter required")

View File

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

View File

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

View File

@@ -25,7 +25,6 @@ type GraphqlQueryRequest struct {
}
func (t *Tool) registerGraphqlQuery(mcpServer *server.MCPServer) {
t.registerGetGraphqlSchema(mcpServer)
queryTool := mcp.NewTool(
ToolGraphqlQueryName,
mcp.WithDescription(ToolGraphqlQueryInstructions),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
package schemas
import (
_ "embed"
"errors"
)
//go:embed cloud_schema.graphql
var schemaGraphql string
//go:embed cloud_schema-with-mutations.graphql
var schemaGraphqlWithMutations string
func (t *Tool) handleResourceCloud() (string, error) {
if t.cfg.Cloud == nil {
return "", errors.New("nhost cloud is not configured") //nolint:err113
}
schema := schemaGraphql
if t.cfg.Cloud.EnableMutations {
schema = schemaGraphqlWithMutations
}
return schema, nil
}

View File

@@ -0,0 +1,9 @@
package schemas
import (
"github.com/nhost/nhost/cli/mcp/nhost/graphql"
)
func (t *Tool) handleGraphqlManagementSchema() string {
return graphql.Schema
}

View File

@@ -0,0 +1,13 @@
package schemas
import (
_ "embed"
)
//go:embed nhost_toml_schema.cue
var schemaNhostToml string
//go:generate cp ../../../../vendor/github.com/nhost/be/services/mimir/schema/schema.cue nhost_toml_schema.cue
func (t *Tool) handleSchemaNhostToml() string {
return schemaNhostToml
}

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,69 @@
package schemas
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/nhost/nhost/cli/mcp/graphql"
"github.com/nhost/nhost/cli/mcp/nhost/auth"
)
const (
ToolGetGraphqlSchemaName = "project-get-graphql-schema"
ToolGetGraphqlSchemaInstructions = `Get GraphQL schema for an Nhost project running in the Nhost Cloud.`
)
var (
ErrNotFound = errors.New("not found")
ErrInvalidRequestBody = errors.New("invalid request body")
)
type GetGraphqlSchemaRequest struct {
Role string `json:"role"`
ProjectSubdomain string `json:"projectSubdomain"`
}
func (t *Tool) handleProjectGraphqlSchema(
ctx context.Context, role string, subdomain string,
) (string, error) {
project, err := t.cfg.Projects.Get(subdomain)
if err != nil {
return "", fmt.Errorf("failed to get project by subdomain: %w", err)
}
authInterceptor, err := project.GetAuthInterceptor()
if err != nil {
return "", fmt.Errorf("failed to get auth interceptor: %w", err)
}
interceptors := []func(ctx context.Context, req *http.Request) error{
authInterceptor,
auth.WithRole(role),
}
var introspection graphql.ResponseIntrospection
if err := graphql.Query(
ctx,
project.GetGraphqlURL(),
graphql.IntrospectionQuery,
nil,
&introspection,
nil,
nil,
interceptors...,
); err != nil {
return "", fmt.Errorf("failed to query GraphQL schema: %w", err)
}
schema := graphql.ParseSchema(
introspection,
graphql.Filter{
AllowQueries: nil,
AllowMutations: nil,
},
)
return schema, nil
}

View File

@@ -0,0 +1,111 @@
package schemas
import (
"context"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/nhost/nhost/cli/mcp/config"
)
const (
ToolGetSchemaName = "get-schema"
ToolGetSchemaInstructions = `
Get GraphQL/API schemas to interact with various services. Use the "service" parameter to
specify which schema you want. Supported services are:
- nhost: This is the schema to interact with the Nhost Cloud. Projects are equivalent
to apps in the schema. IDs are typically uuids.
- config-schema: Get 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.
- graphql-management: GraphQL's management schema for an Nhost development project
running locally via the Nhost CLI. This tool is useful to properly understand how
manage hasura metadata, migrations, permissions, remote schemas, etc.
- project: Get GraphQL schema for an Nhost project. The "subdomain"
parameter is required to specify which project to get the schema for. The "role"
parameter can be passed to specify the role to use when fetching the schema (defaults
to admin).
`
)
func ptr[T any](v T) *T {
return &v
}
type Tool struct {
cfg *config.Config
}
func NewTool(cfg *config.Config) *Tool {
return &Tool{cfg: cfg}
}
func (t *Tool) Register(mcpServer *server.MCPServer) {
queryTool := mcp.NewTool(
ToolGetSchemaName,
mcp.WithDescription(ToolGetGraphqlSchemaInstructions),
mcp.WithToolAnnotation(
mcp.ToolAnnotation{
Title: "Get GraphQL/API schema for various services",
ReadOnlyHint: ptr(true),
DestructiveHint: ptr(false),
IdempotentHint: ptr(true),
OpenWorldHint: ptr(true),
},
),
mcp.WithString(
"service",
mcp.Enum("nhost", "config-schema", "graphql-management", "project"),
mcp.Required(),
),
mcp.WithString(
"role",
mcp.Description(
"Role to use when fetching the schema. Useful only services `local` and `project`",
),
mcp.DefaultString("user"),
),
mcp.WithString(
"subdomain",
mcp.Description(
"Project to get the GraphQL schema for. Required when service is `project`",
),
mcp.Enum(t.cfg.Projects.Subdomains()...),
),
)
mcpServer.AddTool(queryTool, mcp.NewStructuredToolHandler(t.handle))
}
type HandleRequest struct {
Service string `json:"service"`
Role string `json:"role,omitempty"`
Subdomain string `json:"subdomain,omitempty"`
}
func (t *Tool) handle(
ctx context.Context, _ mcp.CallToolRequest, args HandleRequest,
) (*mcp.CallToolResult, error) {
var (
schema string
err error
)
switch args.Service {
case "nhost":
schema, err = t.handleResourceCloud()
case "local-config-server":
schema = t.handleSchemaNhostToml()
case "graphql-management":
schema = t.handleGraphqlManagementSchema()
case "project":
schema, err = t.handleProjectGraphqlSchema(ctx, args.Role, args.Subdomain)
default:
return mcp.NewToolResultError("unknown service: " + args.Service), nil
}
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
return mcp.NewToolResultText(schema), nil
}

View File

@@ -27,8 +27,9 @@ let
"${submodule}/mcp/nhost/auth/openapi.yaml"
"${submodule}/mcp/nhost/graphql/openapi.yaml"
"${submodule}/mcp/tools/cloud/schema.graphql"
"${submodule}/mcp/tools/cloud/schema-with-mutations.graphql"
"${submodule}/mcp/tools/schemas/cloud_schema.graphql"
"${submodule}/mcp/tools/schemas/cloud_schema-with-mutations.graphql"
"${submodule}/mcp/tools/schemas/nhost_toml_schema.cue"
(inDirectory "${submodule}/cmd/mcp/testdata")
(inDirectory "${submodule}/mcp/graphql/testdata")
];