Compare commits
1 Commits
feat/event
...
resources
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efb58f6565 |
@@ -7,6 +7,8 @@ linters:
|
||||
settings:
|
||||
funlen:
|
||||
lines: 65
|
||||
wsl_v5:
|
||||
allow-whole-block: true
|
||||
disable:
|
||||
- canonicalheader
|
||||
- depguard
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
12
cli/cmd/mcp/testdata/sample.toml
vendored
12
cli/cmd/mcp/testdata/sample.toml
vendored
@@ -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']
|
||||
|
||||
@@ -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 }}'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
5
cli/mcp/resources/errors.go
Normal file
5
cli/mcp/resources/errors.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package resources
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrParameterRequired = errors.New("parameter required")
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -25,7 +25,6 @@ type GraphqlQueryRequest struct {
|
||||
}
|
||||
|
||||
func (t *Tool) registerGraphqlQuery(mcpServer *server.MCPServer) {
|
||||
t.registerGetGraphqlSchema(mcpServer)
|
||||
queryTool := mcp.NewTool(
|
||||
ToolGraphqlQueryName,
|
||||
mcp.WithDescription(ToolGraphqlQueryInstructions),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
25
cli/mcp/tools/schemas/cloud.go
Normal file
25
cli/mcp/tools/schemas/cloud.go
Normal 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
|
||||
}
|
||||
9
cli/mcp/tools/schemas/graphql_management_schema.go
Normal file
9
cli/mcp/tools/schemas/graphql_management_schema.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package schemas
|
||||
|
||||
import (
|
||||
"github.com/nhost/nhost/cli/mcp/nhost/graphql"
|
||||
)
|
||||
|
||||
func (t *Tool) handleGraphqlManagementSchema() string {
|
||||
return graphql.Schema
|
||||
}
|
||||
13
cli/mcp/tools/schemas/nhost_toml.go
Normal file
13
cli/mcp/tools/schemas/nhost_toml.go
Normal 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
|
||||
}
|
||||
809
cli/mcp/tools/schemas/nhost_toml_schema.cue
Normal file
809
cli/mcp/tools/schemas/nhost_toml_schema.cue
Normal 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
|
||||
}
|
||||
69
cli/mcp/tools/schemas/project_schema.go
Normal file
69
cli/mcp/tools/schemas/project_schema.go
Normal 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
|
||||
}
|
||||
111
cli/mcp/tools/schemas/schemas.go
Normal file
111
cli/mcp/tools/schemas/schemas.go
Normal 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
|
||||
}
|
||||
@@ -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")
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user