Compare commits

...

13 Commits

Author SHA1 Message Date
David Barroso
58eda5bc34 feat(nixops): added postgres18 and remove pg14 and pg15 2025-10-09 12:21:42 +02:00
github-actions[bot]
6ad1cd1900 release(cli): 1.34.0 (#3553)
Co-authored-by: dbarrosop <dbarrosop@users.noreply.github.com>
2025-10-09 11:48:54 +02:00
David Barroso
5c5223d871 chore(cli): bump nhost/dashboard to 2.38.4 (#3539)
Co-authored-by: dbarrosop <dbarrosop@users.noreply.github.com>
2025-10-09 11:39:23 +02:00
github-actions[bot]
00ef639455 release(dashboard): 2.38.4 (#3574)
Co-authored-by: dbarrosop <dbarrosop@users.noreply.github.com>
2025-10-09 10:38:57 +02:00
David Barroso
a54da9c072 fix(dashboard): remove NODE_ENV from restricted env vars (#3573) 2025-10-09 10:36:25 +02:00
David Barroso
63edfa2600 feat(cli): MCP refactor and documentation prior to official release (#3571) 2025-10-09 08:55:10 +02:00
David Barroso
381baf2e51 feat(docs): added react urql guide (#3570) 2025-10-08 11:02:50 +02:00
dependabot[bot]
951ce168e8 chore(ci): bump github/codeql-action from 3 to 4 (#3569)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-08 08:34:39 +02:00
github-actions[bot]
be8f4e5b1b release(dashboard): 2.38.3 (#3563)
Co-authored-by: dbm03 <dbm03@users.noreply.github.com>
2025-10-07 15:03:40 +02:00
David BM
010573cc31 fix(dashboard): improve remote schema preview search (#3558) 2025-10-07 14:37:21 +02:00
David BM
629bbe7a78 fix(dashboard): remote schema edit graphql customizations, default value for root field namespace is empty (#3565) 2025-10-06 14:56:46 +02:00
David BM
166889be1b fix(dashboard): show paused banner in Run page (#3564) 2025-10-06 10:04:30 +02:00
David BM
c80f6292c6 fix(dashboard): show paused banner in remote schemas/database page if project is paused (#3557) 2025-10-06 08:59:49 +02:00
110 changed files with 25802 additions and 1693 deletions

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,26 @@
All notable changes to this project will be documented in this file.
## [cli@1.34.0] - 2025-10-09
### 🚀 Features
- *(cli)* Added mcp server functionality from mcp-nhost (#3550)
- *(cli)* Mcp: move configuration to .nhost folder and integrate cloud credentials (#3555)
- *(cli)* Mcp: added support for environment variables in the configuration (#3556)
- *(cli)* MCP refactor and documentation prior to official release (#3571)
### 🐛 Bug Fixes
- *(dashboard)* Remove NODE_ENV from restricted env vars (#3573)
### ⚙️ Miscellaneous Tasks
- *(nixops)* Update nhost-cli (#3554)
- *(cli)* Bump nhost/dashboard to 2.38.4 (#3539)
## [cli@1.33.0] - 2025-10-02
### 🚀 Features

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,10 +14,11 @@ import (
nhostmcp "github.com/nhost/nhost/cli/cmd/mcp"
"github.com/nhost/nhost/cli/cmd/mcp/start"
"github.com/nhost/nhost/cli/cmd/user"
"github.com/nhost/nhost/cli/mcp/resources"
"github.com/nhost/nhost/cli/mcp/tools/cloud"
"github.com/nhost/nhost/cli/mcp/tools/docs"
"github.com/nhost/nhost/cli/mcp/tools/local"
"github.com/nhost/nhost/cli/mcp/tools/project"
"github.com/nhost/nhost/cli/mcp/tools/schemas"
)
func ptr[T any](v T) *T {
@@ -96,8 +97,14 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx,paralleltest
Experimental: nil,
Logging: nil,
Prompts: nil,
Resources: nil,
Sampling: nil,
Resources: &struct {
Subscribe bool "json:\"subscribe,omitempty\""
ListChanged bool "json:\"listChanged,omitempty\""
}{
Subscribe: false,
ListChanged: false,
},
Sampling: nil,
Tools: &struct {
ListChanged bool "json:\"listChanged,omitempty\""
}{
@@ -108,7 +115,23 @@ 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
The following resources are available:
- schema://nhost-cloud: Schema to interact with the Nhost Cloud. Projects are equivalent
to apps in the schema. IDs are typically uuids.
- schema://graphql-management: GraphQL's management schema for an Nhost project.
This tool is useful to properly understand how manage hasura metadata, migrations,
permissions, remote schemas, etc.
- schema://nhost.toml: Cuelang schema for the nhost.toml configuration file. Run nhost
config validate after making changes to your nhost.toml file to ensure it is valid.
`,
Result: mcp.Result{
Meta: nil,
},
@@ -130,22 +153,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 +179,38 @@ 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{
"description": string("role to use when executing queries. Keep in mind the schema depends on the role so if you retrieved the schema for a different role previously retrieve it for this role beforehand as it might differ"),
"type": string("string"),
},
"includeQueries": map[string]any{
"description": "include queries in the schema",
"type": "boolean",
"subdomain": map[string]any{
"description": string("Project to get the GraphQL schema for. Required when service is `project`"),
"enum": []any{string("local"), string("asdasdasdasdasd"), string("qweqweqweqweqwe")},
"type": string("string"),
},
"mutations": map[string]any{
"description": string("list of mutations to fetch"),
"type": string("array"),
},
"queries": map[string]any{
"description": string("list of queries to fetch"),
"type": string("array"),
},
"summary": map[string]any{
"default": bool(true),
"description": string("only return a summary of the schema"),
"type": string("boolean"),
},
},
Required: []string{"includeQueries", "includeMutations"},
Required: []string{"role", "subdomain"},
},
Annotations: mcp.ToolAnnotation{
Title: "Get GraphQL Schema for Nhost Config Server",
Title: "Get GraphQL/API schema for various services",
ReadOnlyHint: ptr(true),
DestructiveHint: ptr(false),
IdempotentHint: ptr(true),
@@ -197,109 +218,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,12 +227,17 @@ 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",
"description": "role to use when executing queries. Keep in mind the schema depends on the role so if you retrieved the schema for a different role previously retrieve it for this role beforehand as it might differ",
"type": "string",
},
"userId": map[string]any{
@@ -325,7 +249,7 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx,paralleltest
"type": "string",
},
},
Required: []string{"query", "projectSubdomain", "role"},
Required: []string{"query", "subdomain", "role"},
},
Annotations: mcp.ToolAnnotation{
Title: "Perform GraphQL Query on Nhost Project running on Nhost Cloud",
@@ -336,36 +260,30 @@ 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"),
"path": map[string]any{
"description": "The path for the HTTP request",
"type": "string",
},
"subdomain": map[string]any{
"description": "Project to perform the GraphQL management operation against",
"type": "string",
"enum": []any{
string("local"),
string("asdasdasdasdasd"),
string("qweqweqweqweqwe"),
},
},
},
Required: []string{"endpoint", "body"},
Required: []string{"subdomain", "path", "body"},
},
Annotations: mcp.ToolAnnotation{
Title: "Manage GraphQL's Metadata on an Nhost Development Project",
@@ -405,24 +323,60 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx,paralleltest
t.Errorf("ListToolsResult mismatch (-want +got):\n%s", diff)
}
if res.Capabilities.Resources != nil {
resources, err := mcpClient.ListResources(
context.Background(),
mcp.ListResourcesRequest{}, //nolint:exhaustruct
)
if err != nil {
t.Fatalf("failed to list resources: %v", err)
}
resourceList, err := mcpClient.ListResources(
context.Background(),
mcp.ListResourcesRequest{}, //nolint:exhaustruct
)
if err != nil {
t.Fatalf("failed to list resources: %v", err)
}
if diff := cmp.Diff(
resources,
//nolint:exhaustruct
&mcp.ListResourcesResult{
Resources: []mcp.Resource{},
if diff := cmp.Diff(
resourceList,
//nolint:exhaustruct
&mcp.ListResourcesResult{
Resources: []mcp.Resource{
{
Annotated: mcp.Annotated{
Annotations: &mcp.Annotations{
Audience: []mcp.Role{"agent"},
Priority: 9,
},
},
URI: "schema://graphql-management",
Name: "graphql-management",
Description: resources.GraphqlManagementDescription,
MIMEType: "text/plain",
},
{
Annotated: mcp.Annotated{
Annotations: &mcp.Annotations{
Audience: []mcp.Role{"agent"},
Priority: 9,
},
},
URI: "schema://nhost-cloud",
Name: "nhost-cloud",
Description: resources.CloudDescription,
MIMEType: "text/plain",
},
{
Annotated: mcp.Annotated{
Annotations: &mcp.Annotations{
Audience: []mcp.Role{"agent"},
Priority: 9,
},
},
URI: "schema://nhost.toml",
Name: "nhost.toml",
Description: resources.NhostTomlResourceDescription,
MIMEType: "text/plain",
},
},
); diff != "" {
t.Errorf("ListResourcesResult mismatch (-want +got):\n%s", diff)
}
},
); diff != "" {
t.Errorf("ListResourcesResult mismatch (-want +got):\n%s", diff)
}
if res.Capabilities.Prompts != nil {

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 819 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,7 +25,6 @@ type GraphqlQueryRequest struct {
}
func (t *Tool) registerGraphqlQuery(mcpServer *server.MCPServer) {
t.registerGetGraphqlSchema(mcpServer)
queryTool := mcp.NewTool(
ToolGraphqlQueryName,
mcp.WithDescription(ToolGraphqlQueryInstructions),
@@ -60,7 +59,7 @@ func (t *Tool) handleGraphqlQuery(
allowedMutations := []string{}
if t.withMutations {
allowedMutations = nil
allowedMutations = []string{"*"}
}
var resp graphql.Response[any]
@@ -70,7 +69,7 @@ func (t *Tool) handleGraphqlQuery(
args.Query,
args.Variables,
&resp,
nil,
[]string{"*"},
allowedMutations,
t.interceptors...,
); err != nil {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,21 @@
All notable changes to this project will be documented in this file.
## [@nhost/dashboard@2.38.4] - 2025-10-09
### 🐛 Bug Fixes
- *(dashboard)* Remove NODE_ENV from restricted env vars (#3573)
## [@nhost/dashboard@2.38.3] - 2025-10-07
### 🐛 Bug Fixes
- *(dashboard)* Show paused banner in remote schemas/database page if project is paused (#3557)
- *(dashboard)* Show paused banner in Run page (#3564)
- *(dashboard)* Remote schema edit graphql customizations, default value for root field namespace is empty (#3565)
- *(dashboard)* Improve remote schema preview search (#3558)
## [@nhost/dashboard@2.38.2] - 2025-09-30
### 🐛 Bug Fixes

View File

@@ -78,10 +78,14 @@ function ProjectLayoutContent({
const blockedPausedProjectPages = [
'database',
'database/browser/[dataSourceSlug]',
'database/browser/[dataSourceSlug]/[schemaSlug]/[tableSlug]',
'graphql',
'graphql/remote-schemas',
'graphql/remote-schemas/[remoteSchemaSlug]',
'hasura',
'users',
'storage',
'run',
'ai/auto-embeddings',
'ai/assistants',
'metrics',

View File

@@ -50,7 +50,6 @@ export const baseEnvironmentVariableFormValidationSchema = Yup.object({
'TERM',
'NODE_VERSION',
'YARN_VERSION',
'NODE_ENV',
'HOME',
].includes(value),
)

View File

@@ -186,6 +186,8 @@ export default function BaseRemoteSchemaForm({
</Text>
<ForwardClientHeadersToggle />
<AdditionalHeadersEditor />
</Box>
<Box className="border-t-1 px-6 py-6">
{graphQLCustomizationsSlot ?? <GraphQLCustomizations />}
</Box>
</div>

View File

@@ -49,9 +49,9 @@ export default function GraphQLCustomizations() {
size="small"
startIcon={<PlusIcon />}
onClick={() => setIsOpen(true)}
className="mt-2"
className="mt-2 px-2"
>
Add GQL Customization
Add GraphQL Customization
</Button>
</Box>
);
@@ -95,7 +95,10 @@ export default function GraphQLCustomizations() {
</Tooltip>
</Box>
<Input
{...register('definition.customization.root_fields_namespace')}
{...register('definition.customization.root_fields_namespace', {
setValueAs: (v) =>
typeof v === 'string' && v.trim() === '' ? undefined : v,
})}
id="definition.customization.root_fields_namespace"
name="definition.customization.root_fields_namespace"
placeholder="namespace_"

View File

@@ -4,6 +4,7 @@ import { Input } from '@/components/ui/v2/Input';
import { Text } from '@/components/ui/v2/Text';
import { Tooltip } from '@/components/ui/v2/Tooltip';
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
import { PencilIcon } from '@/components/ui/v2/icons/PencilIcon';
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
import { Button as ButtonV3 } from '@/components/ui/v3/button';
@@ -29,7 +30,7 @@ import type {
RemoteSchemaCustomizationFieldNamesItem,
} from '@/utils/hasura-api/generated/schemas';
import { Check, ChevronsUpDown } from 'lucide-react';
import { useEffect, useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
Controller,
useFieldArray,
@@ -45,6 +46,7 @@ export interface EditGraphQLCustomizationsProps {
export default function EditGraphQLCustomizations({
remoteSchemaName,
}: EditGraphQLCustomizationsProps) {
const [isOpen, setIsOpen] = useState(false);
const { data, isLoading, error } =
useIntrospectRemoteSchemaQuery(remoteSchemaName);
@@ -189,12 +191,40 @@ export default function EditGraphQLCustomizations({
);
}
return (
<Box className="space-y-4">
<Box className="flex flex-row items-center space-x-2">
if (!isOpen) {
return (
<Box className="space-y-4">
<Text variant="h4" className="text-lg font-semibold">
GraphQL Customizations
</Text>
<Button
variant="outlined"
color="primary"
size="small"
startIcon={<PencilIcon />}
onClick={() => setIsOpen(true)}
className="mt-2 px-2"
>
Edit GraphQL Customization
</Button>
</Box>
);
}
return (
<Box className="space-y-4">
<Box className="flex flex-row items-center justify-between">
<Text variant="h4" className="text-lg font-semibold">
GraphQL Customizations
</Text>
<Button
variant="outlined"
color="secondary"
size="small"
onClick={() => setIsOpen(false)}
>
Close
</Button>
</Box>
<Box className="space-y-4 rounded border-1 p-4">
@@ -206,7 +236,10 @@ export default function EditGraphQLCustomizations({
</Tooltip>
</Box>
<Input
{...register('definition.customization.root_fields_namespace')}
{...register('definition.customization.root_fields_namespace', {
setValueAs: (v) =>
typeof v === 'string' && v.trim() === '' ? undefined : v,
})}
id="definition.customization.root_fields_namespace"
name="definition.customization.root_fields_namespace"
placeholder="namespace_"
@@ -310,10 +343,10 @@ export default function EditGraphQLCustomizations({
)}
/>
</Box>
<Box className="flex items-end justify-end md:justify-start">
<Box className="flex">
<Button
variant="borderless"
className="col-span-1"
className="h-10 self-end"
color="error"
onClick={() => removeTypeRemap(fromType)}
>
@@ -480,10 +513,10 @@ export default function EditGraphQLCustomizations({
fullWidth
/>
</Box>
<Box className="flex items-end justify-end md:justify-start">
<Box className="flex">
<Button
variant="borderless"
className="col-span-1"
className="h-10 self-end"
color="error"
onClick={() => removeFieldName(index)}
>
@@ -492,42 +525,46 @@ export default function EditGraphQLCustomizations({
</Box>
</Box>
<Box className="mt-3 space-y-2">
<Text className="font-medium">Field remaps</Text>
<Box className="space-y-2">
{fields.map((f) => {
const key = f.name;
return (
<Box
key={key}
className="grid grid-cols-1 gap-2 md:grid-cols-2"
>
<Input
disabled
value={key}
variant="inline"
fullWidth
/>
<Controller
control={control}
name={`definition.customization.field_names.${index}.mapping.${key}`}
render={({ field }) => (
<Input
value={field.value ?? ''}
onChange={(e) => field.onChange(e.target.value)}
placeholder="new_field_name"
hideEmptyHelperText
autoComplete="off"
variant="inline"
fullWidth
/>
)}
/>
</Box>
);
})}
{fields.length > 0 && (
<Box className="mt-3 space-y-2">
<Text className="font-medium">Field remaps</Text>
<Box className="space-y-2">
{fields.map((f) => {
const key = f.name;
return (
<Box
key={key}
className="grid grid-cols-1 gap-2 md:grid-cols-2"
>
<Input
disabled
value={key}
variant="inline"
fullWidth
/>
<Controller
control={control}
name={`definition.customization.field_names.${index}.mapping.${key}`}
render={({ field }) => (
<Input
value={field.value ?? ''}
onChange={(e) =>
field.onChange(e.target.value)
}
placeholder="new_field_name"
hideEmptyHelperText
autoComplete="off"
variant="inline"
fullWidth
/>
)}
/>
</Box>
);
})}
</Box>
</Box>
</Box>
)}
</Box>
);
})}

View File

@@ -1,9 +1,11 @@
import { Input } from '@/components/ui/v2/Input';
import { ButtonWithLoading as Button } from '@/components/ui/v3/button';
import { Input } from '@/components/ui/v3/input';
import useIntrospectRemoteSchemaQuery from '@/features/orgs/projects/remote-schemas/hooks/useIntrospectRemoteSchemaQuery/useIntrospectRemoteSchemaQuery';
import convertIntrospectionToSchema from '@/features/orgs/projects/remote-schemas/utils/convertIntrospectionToSchema';
import { Search, X } from 'lucide-react';
import React, { useMemo, useRef, useState } from 'react';
import { getToastStyleProps } from '@/utils/constants/settings';
import { SearchIcon, XIcon } from 'lucide-react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'react-hot-toast';
import type { RemoteSchemaTreeRef } from './RemoteSchemaTree';
import { RemoteSchemaTree } from './RemoteSchemaTree';
@@ -23,6 +25,11 @@ export default function RemoteSchemaPreview({
const [searchTerm, setSearchTerm] = useState('');
const [isSearching, setIsSearching] = useState(false);
const [matches, setMatches] = useState<string[][]>([]);
const [matchIndex, setMatchIndex] = useState(0);
const [hasSearched, setHasSearched] = useState(false);
const treeContainerRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const schema = useMemo(() => {
if (introspectionData) {
@@ -31,6 +38,18 @@ export default function RemoteSchemaPreview({
return null;
}, [introspectionData]);
const navigateToMatch = async (paths: string[][], index: number) => {
if (!treeRef.current || paths.length === 0) {
return;
}
const path = paths[index];
await treeRef.current.expandToItem(path);
const foundItemId = path[path.length - 1];
treeRef.current.selectItems([foundItemId]);
treeRef.current.focusItem(foundItemId);
};
const handleSearch = async (e?: React.FormEvent) => {
e?.preventDefault();
@@ -41,35 +60,60 @@ export default function RemoteSchemaPreview({
setIsSearching(true);
try {
const path = treeRef.current.findItemPath(searchTerm);
if (path && path.length > 0) {
await treeRef.current.expandToItem(path);
const foundItemId = path[path.length - 1];
treeRef.current.selectItems([foundItemId]);
treeRef.current.focusItem(foundItemId);
const allPaths = treeRef.current.findAllItemPaths(searchTerm);
setMatches(allPaths);
setHasSearched(true);
if (allPaths.length > 0) {
setMatchIndex(0);
await navigateToMatch(allPaths, 0);
}
} catch (searchError) {
console.error('Search error:', searchError);
toast.error(
searchError?.message || 'An error occurred. Please try again.',
getToastStyleProps(),
);
} finally {
setIsSearching(false);
}
};
const handleClearSearch = () => {
setSearchTerm('');
treeRef.current?.focusTree();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSearch();
} else if (e.key === 'Escape') {
handleClearSearch();
if (e.key === 'Enter' && matches.length > 0) {
e.preventDefault();
const step = e.shiftKey ? -1 : 1;
const nextIndex = (matchIndex + step + matches.length) % matches.length;
setMatchIndex(nextIndex);
navigateToMatch(matches, nextIndex);
}
};
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Enter') {
return;
}
const { target } = e;
const inTree =
treeContainerRef.current?.contains(target as Node) ?? false;
const isNotSearchInput = target !== searchInputRef.current;
if (!inTree || !isNotSearchInput) {
return;
}
if (matches.length === 0) {
return;
}
e.preventDefault();
e.stopPropagation();
const step = e.shiftKey ? -1 : 1;
const nextIndex = (matchIndex + step + matches.length) % matches.length;
setMatchIndex(nextIndex);
navigateToMatch(matches, nextIndex);
};
window.addEventListener('keydown', onKeyDown, { capture: true });
return () => window.removeEventListener('keydown', onKeyDown, true);
}, [matches, matchIndex]);
if (isLoading) {
return (
<div className="flex items-center justify-center p-4">
@@ -100,8 +144,8 @@ export default function RemoteSchemaPreview({
}
return (
<div className="rounded-lg border">
<div className="border-b bg-muted/50 p-4">
<div className="rounded-lg border bg-card">
<div className="border-b p-4">
<div className="mb-3 flex items-center justify-between">
<div>
<h3 className="text-sm font-medium">Schema Preview</h3>
@@ -112,45 +156,66 @@ export default function RemoteSchemaPreview({
</div>
<form onSubmit={handleSearch} className="flex w-full gap-2">
<div className="relative flex w-full flex-1 flex-row items-center">
<Search className="h-4 w-4 text-muted-foreground" />
<div className="relative max-w-xs flex-1 text-foreground">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center justify-center pl-3 text-muted-foreground peer-disabled:opacity-50">
<SearchIcon className="z-10 h-4 w-4" />
<span className="sr-only">Search</span>
</div>
<Input
type="search"
value={searchTerm}
fullWidth
onChange={(e) => setSearchTerm(e.target.value)}
onChange={(e) => {
setSearchTerm(e.target.value);
setMatches([]);
setMatchIndex(0);
setHasSearched(false);
}}
onKeyDown={handleKeyDown}
placeholder="Search fields, types, or operations..."
className="max-w-72 pl-4"
ref={searchInputRef}
disabled={isSearching}
placeholder="Search fields, types, or operations..."
className="peer px-9 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none"
/>
{searchTerm && (
<button
<Button
variant="ghost"
size="icon"
type="button"
onClick={handleClearSearch}
className="absolute right-3 top-1/2 -translate-y-1/2 transform text-muted-foreground hover:text-foreground"
aria-label="Clear search"
onClick={() => {
setSearchTerm('');
setMatches([]);
setMatchIndex(0);
}}
className="absolute inset-y-0 right-0 rounded-l-none text-muted-foreground hover:bg-transparent focus-visible:ring-ring/50"
>
<X className="h-4 w-4" />
</button>
<XIcon className="h-4 w-4" />
<span className="sr-only">Clear input</span>
</Button>
)}
</div>
<Button
type="submit"
variant="secondary"
size="sm"
disabled={!searchTerm.trim() || isSearching}
loading={isSearching}
>
{isSearching ? 'Searching...' : 'Find'}
</Button>
{hasSearched && (
<span className="flex items-center text-xs text-muted-foreground">
{matches.length}{' '}
{matches.length === 1 ? 'occurrence' : 'occurrences'} found
</span>
)}
</form>
</div>
<div className="p-4">
<div className="p-4" ref={treeContainerRef}>
<RemoteSchemaTree
ref={treeRef}
className="min-h-[400px]"
schema={schema}
highlightTerm={searchTerm}
/>
</div>
</div>

View File

@@ -205,43 +205,45 @@ describe('RemoteSchemaTree', () => {
).toBeInTheDocument();
});
it('findItemPath returns the path to the first matching item', () => {
it('findAllItemPaths returns paths to matching items', () => {
const ref = React.createRef<RemoteSchemaTreeRef>();
render(<RemoteSchemaTree ref={ref} schema={schema} />);
expect(ref.current).toBeTruthy();
const helloPath = ref.current!.findItemPath('hello');
expect(helloPath?.[helloPath.length - 1]).toEqual('__query.field.hello');
const helloPaths = ref.current!.findAllItemPaths('hello');
expect(helloPaths[0]?.[helloPaths[0].length - 1]).toEqual(
'__query.field.hello',
);
const postByIdPath = ref.current!.findItemPath('postById');
expect(postByIdPath?.[postByIdPath.length - 1]).toEqual(
const postByIdPaths = ref.current!.findAllItemPaths('postById');
expect(postByIdPaths[0]?.[postByIdPaths[0].length - 1]).toEqual(
'__subscription.field.postById',
);
});
it('findItemPath is case-insensitive and returns full path including parents', () => {
it('findAllItemPaths is case-insensitive and returns full path including parents', () => {
const ref = React.createRef<RemoteSchemaTreeRef>();
render(<RemoteSchemaTree ref={ref} schema={schema} />);
const helloPath = ref.current!.findItemPath('HELLO');
expect(helloPath).toEqual(['root', '__query', '__query.field.hello']);
const helloPaths = ref.current!.findAllItemPaths('HELLO');
expect(helloPaths[0]).toEqual(['root', '__query', '__query.field.hello']);
});
it('findItemPath can match a parent item (e.g., Mutation)', () => {
it('findAllItemPaths can match a parent item (e.g., Mutation)', () => {
const ref = React.createRef<RemoteSchemaTreeRef>();
render(<RemoteSchemaTree ref={ref} schema={schema} />);
const mutationPath = ref.current!.findItemPath('Mutation');
expect(mutationPath).toEqual(['root', '__mutation']);
const mutationPaths = ref.current!.findAllItemPaths('Mutation');
expect(mutationPaths[0]).toEqual(['root', '__mutation']);
});
it('findItemPath returns null when no item matches', () => {
it('findAllItemPaths returns empty array when no item matches', () => {
const ref = React.createRef<RemoteSchemaTreeRef>();
render(<RemoteSchemaTree ref={ref} schema={schema} />);
const noMatch = ref.current!.findItemPath('THIS_DOES_NOT_EXIST');
expect(noMatch).toBeNull();
const noMatches = ref.current!.findAllItemPaths('THIS_DOES_NOT_EXIST');
expect(noMatches).toEqual([]);
});
it('renders correctly when schema uses custom root type names', async () => {

View File

@@ -15,8 +15,9 @@ import {
type TreeRef,
} from 'react-complex-tree';
import 'react-complex-tree/lib/style-modern.css';
import getText from './getText';
import type { ComplexTreeData } from './types';
import { buildComplexTreeData } from './utils';
import { buildComplexTreeData, highlightNode } from './utils';
export interface RemoteSchemaTreeProps {
/**
@@ -24,10 +25,11 @@ export interface RemoteSchemaTreeProps {
*/
schema: GraphQLSchema;
className?: string;
highlightTerm?: string;
}
export interface RemoteSchemaTreeRef {
findItemPath: (searchTerm: string) => string[] | null;
findAllItemPaths: (searchTerm: string) => string[][];
expandToItem: (path: string[]) => Promise<void>;
focusItem: (itemId: string) => void;
selectItems: (itemIds: string[]) => void;
@@ -37,7 +39,7 @@ export interface RemoteSchemaTreeRef {
export const RemoteSchemaTree = forwardRef<
RemoteSchemaTreeRef,
RemoteSchemaTreeProps
>(({ schema, className }, ref) => {
>(({ schema, className, highlightTerm }, ref) => {
const treeRef = useRef<TreeRef<string | React.ReactNode>>(null);
const theme = useTheme();
@@ -54,50 +56,34 @@ export const RemoteSchemaTree = forwardRef<
const [expandedItems, setExpandedItems] = useState<string[]>(['root']);
const [selectedItems, setSelectedItems] = useState<string[]>([]);
const findItemPath = useCallback(
(searchTerm: string, searchRoot = 'root'): string[] | null => {
const findAllItemPaths = useCallback(
(searchTerm: string, searchRoot = 'root'): string[][] => {
const lowerSearchTerm = searchTerm.toLowerCase();
const getText = (data: any): string => {
if (React.isValidElement<{ children?: React.ReactNode }>(data)) {
const { children } = data.props;
if (Array.isArray(children)) {
return children.map(getText).join('');
}
return getText(children);
}
if (typeof data === 'string' || typeof data === 'number') {
return String(data);
}
return String(data);
};
const searchInItem = (
const collectInItem = (
itemId: string,
currentPath: string[],
): string[] | null => {
acc: string[][],
): void => {
const item = treeData[itemId];
if (!item) {
return null;
return;
}
const searchableText = getText(item.data);
if (searchableText.toLowerCase().includes(lowerSearchTerm)) {
return [...currentPath, itemId];
acc.push([...currentPath, itemId]);
}
let foundPath: string[] | null = null;
// eslint-disable-next-line no-restricted-syntax
for (const childId of item.children ?? []) {
foundPath = searchInItem(childId, [...currentPath, itemId]);
if (foundPath) {
break;
}
collectInItem(childId, [...currentPath, itemId], acc);
}
return foundPath;
};
return searchInItem(searchRoot, []);
const results: string[][] = [];
collectInItem(searchRoot, [], results);
return results;
},
[treeData],
);
@@ -114,7 +100,7 @@ export const RemoteSchemaTree = forwardRef<
useImperativeHandle(
ref,
() => ({
findItemPath,
findAllItemPaths,
expandToItem,
focusItem: (itemId: string) => {
setFocusedItem(itemId);
@@ -128,7 +114,7 @@ export const RemoteSchemaTree = forwardRef<
treeRef.current?.focusTree();
},
}),
[findItemPath],
[findAllItemPaths],
);
const getItemTitle = (item: any) => {
@@ -157,6 +143,9 @@ export const RemoteSchemaTree = forwardRef<
<ControlledTreeEnvironment
items={treeData}
getItemTitle={getItemTitle}
renderItemTitle={({ title }) => (
<span>{highlightNode(title, highlightTerm)}</span>
)}
viewState={{
'schema-tree': {
focusedItem,
@@ -174,6 +163,7 @@ export const RemoteSchemaTree = forwardRef<
)
}
onSelectItems={(items) => setSelectedItems(items.map(String))}
canSearch={false}
>
<Tree
ref={treeRef}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { describe, expect, it } from 'vitest';
import getText from './getText';
function Icon() {
return React.createElement('svg');
}
describe('getText', () => {
it('returns the same string for plain string inputs (e.g., root labels)', () => {
expect(getText('Schema')).toBe('Schema');
expect(getText('Query')).toBe('Query');
});
it('converts numbers to strings', () => {
expect(getText(123)).toBe('123');
});
it('handles argument-like input with type annotation', () => {
expect(getText('amount: Int!')).toBe('amount: Int!');
});
it('returns empty string for a React element with no children', () => {
const element = React.createElement('span', null);
expect(getText(element)).toBe('');
});
it('flattens array children like the field item structure in the tree (icon + span text)', () => {
const fieldElement = React.createElement(
'div',
{ className: 'flex items-center gap-1', style: { color: '#6b7280' } },
[
React.createElement(Icon, { key: 'icon', className: 'text-gray-400' }),
React.createElement('span', { key: 'text' }, 'Slug: String!'),
],
);
expect(getText(fieldElement)).toBe('Slug: String!');
});
});

View File

@@ -0,0 +1,20 @@
import { isEmptyValue } from '@/lib/utils';
import React from 'react';
export default function getText(data: any): string {
if (React.isValidElement<{ children?: React.ReactNode }>(data)) {
const { children } = data.props;
if (Array.isArray(children)) {
return children.map(getText).join('');
}
if (isEmptyValue(children)) {
return '';
}
return getText(children);
}
if (typeof data === 'string' || typeof data === 'number') {
return String(data);
}
return String(data);
}

View File

@@ -1,3 +1,4 @@
import highlightMatch from '@/features/orgs/utils/highlightMatch/highlightMatch';
import type { GraphQLSchema } from 'graphql';
import { HelpCircle } from 'lucide-react';
import React from 'react';
@@ -221,3 +222,34 @@ export const buildComplexTreeData = ({
return treeData;
};
export const highlightNode = (
node: React.ReactNode,
term?: string,
): React.ReactNode => {
const search = term?.trim();
if (!search) {
return node;
}
if (typeof node === 'string' || typeof node === 'number') {
const text = String(node);
return highlightMatch(text, search);
}
if (Array.isArray(node)) {
return React.Children.map(node as React.ReactNode[], (child) =>
highlightNode(child, search),
);
}
if (React.isValidElement(node)) {
const childProps: any = {};
if (node.props && 'children' in node.props) {
childProps.children = highlightNode(node.props.children, search);
}
return React.cloneElement(node, childProps);
}
return node;
};

View File

@@ -0,0 +1,43 @@
function escapeRegExp(input: string) {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export default function highlightMatch(text: string, query: string) {
if (!query) {
return text;
}
const safe = escapeRegExp(query);
const regex = new RegExp(safe, 'gi');
const nodes: Array<JSX.Element> = [];
let lastIndex = 0;
let match: RegExpExecArray | null = regex.exec(text);
while (match) {
const start = match.index;
const end = start + match[0].length;
if (start > lastIndex) {
const chunk = text.slice(lastIndex, start);
nodes.push(<span key={`seg-${lastIndex}`}>{chunk}</span>);
}
nodes.push(
<mark
key={`hit-${start}`}
className="rounded bg-yellow-300/60 px-0.5 dark:bg-yellow-200/60"
>
{match[0]}
</mark>,
);
lastIndex = end;
match = regex.exec(text);
}
if (lastIndex < text.length) {
nodes.push(<span key={`seg-${lastIndex}`}>{text.slice(lastIndex)}</span>);
}
return nodes;
}

View File

@@ -0,0 +1 @@
export { default as highlightMatch } from './highlightMatch';

View File

@@ -195,7 +195,8 @@
"icon": "book",
"pages": [
"/products/graphql/guides/react-apollo",
"/products/graphql/guides/react-query"
"/products/graphql/guides/react-query",
"/products/graphql/guides/react-urql"
]
}
]
@@ -278,7 +279,17 @@
"/platform/cli/migrate-config",
"/platform/cli/multiple-projects",
"/platform/cli/configuration-overlays",
"/platform/cli/seeds"
"/platform/cli/seeds",
{
"group": "MCP Server",
"icon": "brain",
"pages": [
"/platform/cli/mcp/overview",
"/platform/cli/mcp/configuration",
"/platform/cli/mcp/clients",
"/platform/cli/mcp/troubleshooting"
]
}
]
},
{

View File

@@ -0,0 +1,31 @@
---
title: Clients
description: Configuration examples for various MCP clients
icon: rocket
---
The Nhost MCP server can be accessed through any client that supports the Model Context Protocol (MCP). Below are examples of how to configure and use various MCP clients.
#### Cursor
1. Go to "Cursor Settings"
2. Click on "MCP"
3. Click on "+ Add new global MCP server"
4. Add the following object inside `"mcpServers"`:
```json
"mcp-nhost": {
"command": "nhost",
"args": [
"mcp", "start",
],
}
```
#### Claude Code
Run the command:
```
claude mcp add nhost nhost mcp start
```

View File

@@ -0,0 +1,189 @@
---
title: Configuration
description: Configuration options for the Nhost MCP tool
icon: wrench
---
This document describes all available configuration options for the Nhost MCP tool. The configuration file uses TOML format and supports environment variable interpolation.
## Configuration File Location
The configuration file is located at `.nhost/mcp-nhost.toml` in your project directory. You can specify a custom location using the `--config-file` flag.
## TOML
```toml
# Cloud configuration for managing Nhost Cloud projects and organizations
# Remove this section to disable Nhost Cloud access
[cloud]
# Enable mutations on Nhost Cloud (project configuration, organization management, etc.)
# When false, only queries are allowed
# Requires your personal access token from CLI credentials
enable_mutations = true
# Project-specific configurations
# You can configure multiple projects (local or cloud)
[[projects]]
# Project subdomain (required)
# For local projects, use "local"
subdomain = "your-project-subdomain"
# Project region (required)
# For local projects, use "local"
region = "your-project-region"
# Optional: Project description for better identification
description = "Production project for my app"
# Authentication: Use either admin_secret or pat
# Admin secret for project access
admin_secret = "your-project-admin-secret"
# OR
# Project-specific PAT (Personal Access Token)
pat = "your-project-pat"
# Enable metadata management (migrations, permissions, etc.)
# Requires admin_secret to be set
# When enabled, the manage-graphql tool can create migrations and manage Hasura metadata
manage_metadata = false
# List of allowed GraphQL queries
# Use ["*"] to allow all queries
# Use [] to disable all queries
# Use specific query names to allow only those queries
allow_queries = ["*"]
# List of allowed GraphQL mutations
# Use ["*"] to allow all mutations
# Use [] to disable all mutations
# Use specific mutation names to allow only those mutations
allow_mutations = ["*"]
# Optional: Custom GraphQL URL
# Default: https://{subdomain}.graphql.{region}.nhost.run/v1
# For local: https://local.graphql.local.nhost.run/v1
graphql_url = "your-custom-url"
# Optional: Custom Auth URL
# Default: https://{subdomain}.auth.{region}.nhost.run/v1
# For local: https://local.auth.local.nhost.run/v1
auth_url = "your-custom-url"
# Optional: Custom Hasura URL (for metadata management)
# Default: https://{subdomain}.hasura.{region}.nhost.run
# For local: https://local.hasura.local.nhost.run
hasura_url = "your-custom-url"
```
## Example Configurations
### Basic Configuration with Cloud Access
```toml
# Enable Nhost Cloud management with mutations
[cloud]
enable_mutations = true
# Configure a production project
[[projects]]
subdomain = "my-app"
region = "eu-central-1"
description = "Production project"
pat = "nhp_production_pat"
allow_queries = ["*"]
allow_mutations = ["insertPost", "updatePost", "deletePost"]
```
### Local Development Configuration
```toml
# Configure local development environment
[[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 = ["*"]
```
### Multiple Projects with Different Access Levels
```toml
# Enable cloud management
[cloud]
enable_mutations = true
# Local development with full access
[[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 = ["*"]
# Production project with restricted access
[[projects]]
subdomain = "my-app-prod"
region = "eu-central-1"
description = "Production project"
pat = "nhp_prod_pat"
allow_queries = ["*"]
allow_mutations = ["insertPost", "updatePost"]
# Staging project with read-only access
[[projects]]
subdomain = "my-app-staging"
region = "eu-central-1"
description = "Staging project"
pat = "nhp_staging_pat"
allow_queries = ["*"]
allow_mutations = []
```
### Environment Variable Interpolation
You can use environment variables in your configuration:
```toml
[cloud]
enable_mutations = true
[[projects]]
subdomain = "my-app"
region = "eu-central-1"
description = "Production project"
# Use ${VAR_NAME} syntax to interpolate environment variables
admin_secret = "${NHOST_ADMIN_SECRET}"
allow_queries = ["*"]
allow_mutations = ["*"]
```
## Configuration Options Reference
### Cloud Section
| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `enable_mutations` | boolean | No | Enable mutations on Nhost Cloud. Defaults to `false`. Requires CLI credentials with PAT. |
### Projects Section
| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `subdomain` | string | Yes | Project subdomain. Use `"local"` for local development projects. |
| `region` | string | Yes | Project region. Use `"local"` for local development projects. |
| `description` | string | No | Human-readable description of the project. |
| `admin_secret` | string | No* | Admin secret for authentication. Either `admin_secret` or `pat` is required. |
| `pat` | string | No* | Project-specific Personal Access Token. Either `admin_secret` or `pat` is required. |
| `manage_metadata` | boolean | No | Enable metadata management (migrations, permissions, etc.). Requires `admin_secret`. Defaults to `false`. |
| `allow_queries` | array | No | List of allowed query names. Use `["*"]` for all, `[]` for none. Defaults to `[]`. |
| `allow_mutations` | array | No | List of allowed mutation names. Use `["*"]` for all, `[]` for none. Defaults to `[]`. |
| `graphql_url` | string | No | Custom GraphQL endpoint URL. Auto-generated if not provided. |
| `auth_url` | string | No | Custom Auth endpoint URL. Auto-generated if not provided. |
| `hasura_url` | string | No | Custom Hasura endpoint URL. Auto-generated if not provided. Required for `manage_metadata`. |
\* Either `admin_secret` or `pat` must be provided for authentication.

View File

@@ -0,0 +1,98 @@
---
title: Overview
description: A Model Context Protocol (MCP) server implementation for interacting with Nhost projects and services.
icon: house
---
The Nhost cli ships with an MCP server that lets you interact with your Nhost projects through AI assistants using the Model Context Protocol. It provides secure, controlled access to your GraphQL data, project configuration, and documentation—with granular permissions that let you specify exactly which queries and mutations an LLM can execute. For development, it streamlines your workflow by enabling AI-assisted schema management, metadata changes, and migrations, while providing direct access to your GraphQL schema for intelligent query building.
## Overview
The MCP server is designed to provide a unified interface for managing Nhost projects through the Model Context Protocol. It enables seamless interaction with Nhost Cloud services, offering a robust set of tools for project management and configuration.
## Available Tools
The following tools are currently exposed through the MCP interface:
1. **cloud-graphql-query**
- Executes GraphQL queries and mutations against the Nhost Cloud platform
- Allows querying and updating project configurations running on Nhost Cloud
- Schema available via `schema://nhost-cloud` resource
2. **graphql-query**
- Executes GraphQL queries against Nhost projects
- Enables interaction with project data
- Supports both queries and mutations (must be allowed in configuration)
- Requires specifying subdomain, role, and optionally userId
- Role parameter is required and affects the schema
- Schema available via `get-schema` tool
3. **manage-graphql**
- Interacts with GraphQL's management endpoints for Nhost projects
- Manages Hasura metadata, migrations, permissions, and remote schemas
- Requires admin secret and `manage_metadata` enabled in configuration
- Schema available via `schema://graphql-management` resource
4. **get-schema**
- Retrieves the GraphQL schema for Nhost projects
- Provides access to project-specific queries and mutations
5. **search**
- Searches Nhost's official documentation
- Provides information about Nhost features, APIs, and guides
- Helps find relevant documentation for implementing features or solving issues
- Returns links to detailed documentation pages
## Available Resources
The MCP server exposes the following resources:
1. **schema://nhost-cloud**
- GraphQL schema for the Nhost Cloud platform
- Schema includes mutations only if `enable_mutations` is enabled (see CONFIG section)
2. **schema://graphql-management**
- GraphQL management schema for Nhost projects
- Useful for understanding how to manage Hasura metadata, migrations, permissions, and remote schemas
- Available only when `manage_metadata` is enabled for at least one project
3. **schema://nhost.toml**
- Cuelang schema for the nhost.toml configuration file
## Security and Privacy
### Enhanced Protection Layer
The MCP server is designed with security at its core, providing an additional protection layer beyond your existing GraphQL permissions. Key security features include:
- **Authentication enforcement** for all requests
- **Permission and role respect** based on your existing authorization system and the credentials provided
- **Query/mutation filtering** to further restrict allowed operations
### Granular Access Control
One of the MCP server's key security advantages is the ability to specify exactly which operations can pass through, even for authenticated users:
```toml
[[projects]]
subdomain = "my-blog"
region = "eu-central-1"
pat = "nhp_project_specific_pat"
allow_queries = ["getBlogs", "getComments"]
allow_mutations = ["insertBlog", "insertComment"]
```
With the configuration above, an LLM will be able to only execute the queries and mutations above on behalf of a user even if the user has broader permissions in the Nhost project.
Additionally, for local development projects, you can enable metadata management:
```toml
[[projects]]
subdomain = "local"
admin_secret = "nhost-admin-secret"
manage_metadata = true
```
This allows the LLM to create migrations, manage permissions, and handle schema changes through the `manage-graphql` tool.
For more details, see the [configuration](./configuration) page.

View File

@@ -0,0 +1,28 @@
---
title: Troubleshooting
description: Tips for troubleshooting issues with the Nhost MCP server
icon: arrow-progress
---
If you run into issues using the MCP server on your favorite AI service you can try running the tools yourself. For example:
```bash
# cloud-graphql-query
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"cloud-graphql-query","arguments":{"query":"{ apps { id subdomain name } }"}},"id":1}' | nhost mcp start
# graphql-query
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"graphql-query","arguments":{"query":"{ users { id } }","subdomain":"local","role":"admin"}},"id":1}' | nhost mcp start
# manage-graphql
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"manage-graphql","arguments":{"body":"{\"type\":\"export_metadata\",\"args\":{}}","subdomain":"local","path":"/v1/metadata"}},"id":1}' | nhost mcp start
# get-schema
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get-schema","arguments":{"subdomain":"local","role":"admin","summary":true}},"id":1}' | nhost mcp start
# search
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"search","arguments":{"query":"how to enable magic links"}},"id":1}' | nhost mcp start
# Read resource
echo '{"jsonrpc":"2.0","method":"resources/read","params":{"uri":"schema://nhost-cloud"},"id":1}' | nhost mcp start
```

View File

@@ -0,0 +1,9 @@
---
title: "React urql"
description: "How to use Nhost with React and url"
icon: U
---
You can use Nhost with [urql](https://nearform.com/open-source/urql/docs/) in your React applications. Urql is a highly customizable and versatile GraphQL client that makes it easy to fetch, cache, and manage GraphQL data in your React applications.
You can find a working example with instructions in our [GitHub repository](https://github.com/nhost/nhost/blob/main/examples/guides/react-urql/README.md).

View File

@@ -254,7 +254,7 @@ Start local development environment
**--ca-certificates**="": Mounts and everrides path to CA certificates in the containers
**--dashboard-version**="": Dashboard version to use (default: nhost/dashboard:2.38.0)
**--dashboard-version**="": Dashboard version to use (default: nhost/dashboard:2.38.4)
**--disable-tls**: Disable TLS
@@ -284,7 +284,7 @@ Start local development environment connected to an Nhost Cloud project (BETA)
**--ca-certificates**="": Mounts and everrides path to CA certificates in the containers
**--dashboard-version**="": Dashboard version to use (default: nhost/dashboard:2.38.0)
**--dashboard-version**="": Dashboard version to use (default: nhost/dashboard:2.38.4)
**--disable-tls**: Disable TLS

View File

@@ -28,7 +28,7 @@ httpPoolSize = 100
[functions]
[functions.node]
version = 20
version = 22
[auth]
version = '0.41.1'
@@ -74,7 +74,7 @@ expiresIn = 2592000
enabled = false
[auth.method.emailPasswordless]
enabled = true
enabled = false
[auth.method.otp]
[auth.method.otp.email]
@@ -90,12 +90,7 @@ enabled = false
[auth.method.oauth]
[auth.method.oauth.apple]
enabled = true
teamId = '{{ secrets.APPLE_TEAM_ID }}'
clientId = '{{ secrets.APPLE_CLIENT_ID }}'
audience = '{{ secrets.APPLE_AUDIENCE }}'
keyId = '{{ secrets.APPLE_KEY_ID }}'
privateKey = '{{ secrets.APPLE_PRIVATE_KEY }}'
enabled = false
[auth.method.oauth.azuread]
tenant = 'common'
@@ -107,13 +102,15 @@ enabled = false
[auth.method.oauth.discord]
enabled = false
[auth.method.oauth.entraid]
tenant = 'common'
enabled = false
[auth.method.oauth.facebook]
enabled = false
[auth.method.oauth.github]
enabled = true
clientId = '{{ secrets.GITHUB_CLIENT_ID }}'
clientSecret = '{{ secrets.GITHUB_CLIENT_SECRET }}'
enabled = false
[auth.method.oauth.gitlab]
enabled = false
@@ -143,19 +140,13 @@ enabled = false
enabled = false
[auth.method.webauthn]
enabled = true
[auth.method.webauthn.relyingParty]
id = 'localhost'
name = 'nhost-sdk-experiment'
origins = ['http://localhost:3000', 'http://localhost:5173']
enabled = false
[auth.method.webauthn.attestation]
timeout = 60000
[auth.totp]
enabled = true
issuer = 'new-sdk'
enabled = false
[auth.misc]
concealErrors = false
@@ -166,23 +157,23 @@ limit = 10
interval = '1h'
[auth.rateLimit.sms]
limit = 100
limit = 10
interval = '1h'
[auth.rateLimit.bruteForce]
limit = 100
limit = 10
interval = '5m'
[auth.rateLimit.signups]
limit = 100
limit = 10
interval = '5m'
[auth.rateLimit.global]
limit = 1000
limit = 100
interval = '1m'
[postgres]
version = '17.5-20250728-1'
version = '14.18-20250728-1'
[postgres.resources]
[postgres.resources.storage]
@@ -191,7 +182,7 @@ capacity = 1
[provider]
[storage]
version = '0.8.0-beta3'
version = '0.7.2'
[observability]
[observability.grafana]

View File

@@ -21,6 +21,8 @@ let
"${submodule}/react-apollo/pnpm-lock.yaml"
"${submodule}/react-query/package.json"
"${submodule}/react-query/pnpm-lock.yaml"
"${submodule}/react-urql/package.json"
"${submodule}/react-urql/pnpm-lock.yaml"
];
};

25
examples/guides/react-urql/.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.vite

View File

@@ -0,0 +1,453 @@
# React with urql and Nhost SDK
This guide demonstrates how to integrate GraphQL queries and mutations with React using urql and the Nhost SDK.
## Setup
### 1. Install Dependencies
```bash
npm install urql @urql/exchange-auth @nhost/nhost-js graphql @graphql-typed-document-node/core
# or
yarn add urql @urql/exchange-auth @nhost/nhost-js graphql @graphql-typed-document-node/core
# or
pnpm add urql @urql/exchange-auth @nhost/nhost-js graphql @graphql-typed-document-node/core
```
### 2. Install GraphQL CodeGen for Development
```bash
npm install -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typed-document-node @graphql-codegen/schema-ast
# or
yarn add -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typed-document-node @graphql-codegen/schema-ast
# or
pnpm add -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typed-document-node @graphql-codegen/schema-ast
```
### 3. Configure GraphQL CodeGen
Create a `codegen.ts` file in your project root:
```typescript
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
schema: [
{
"https://local.graphql.local.nhost.run/v1": {
headers: {
"x-hasura-admin-secret": "nhost-admin-secret",
},
},
},
],
documents: ["src/**/*.ts"],
ignoreNoDocuments: true,
generates: {
"./src/lib/graphql/__generated__/graphql.ts": {
documents: ["src/lib/graphql/**/*.graphql"],
plugins: ["typescript", "typescript-operations", "typed-document-node"],
config: {
scalars: {
UUID: "string",
uuid: "string",
timestamptz: "string",
jsonb: "Record<string, any>",
bigint: "number",
bytea: "Buffer",
citext: "string",
},
useTypeImports: true,
},
},
"./schema.graphql": {
plugins: ["schema-ast"],
config: {
includeDirectives: true,
},
},
},
};
export default config;
```
Add a script to your `package.json`:
```json
{
"scripts": {
"generate": "graphql-codegen --config codegen.ts"
}
}
```
## Integration Guide
### 1. Create an Auth Provider
Create an authentication context to manage the user session:
```typescript
// src/lib/nhost/AuthProvider.tsx (see file for full code)
import { createClient, type NhostClient } from "@nhost/nhost-js";
import type { Session } from "@nhost/nhost-js/auth";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
interface AuthContextType {
user: Session["user"] | null;
session: Session | null;
isAuthenticated: boolean;
isLoading: boolean;
nhost: NhostClient;
}
const AuthContext = createContext<AuthContextType | null>(null);
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<Session["user"] | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
// Create the nhost client
const nhost = useMemo(
() =>
createClient({
region: import.meta.env.VITE_NHOST_REGION || "local",
subdomain: import.meta.env.VITE_NHOST_SUBDOMAIN || "local",
}),
[],
);
useEffect(() => {
setIsLoading(true);
const currentSession = nhost.getUserSession();
setUser(currentSession?.user || null);
setSession(currentSession);
setIsAuthenticated(!!currentSession);
setIsLoading(false);
const unsubscribe = nhost.sessionStorage.onChange((currentSession) => {
setUser(currentSession?.user || null);
setSession(currentSession);
setIsAuthenticated(!!currentSession);
});
return () => {
unsubscribe();
};
}, [nhost]);
const value: AuthContextType = {
user,
session,
isAuthenticated,
isLoading,
nhost,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};
```
### 2. Create urql Provider
Set up the urql client with authentication:
```typescript
// src/lib/graphql/UrqlProvider.tsx
import { authExchange } from "@urql/exchange-auth";
import type { ReactNode } from "react";
import {
type Client,
cacheExchange,
createClient,
fetchExchange,
Provider,
} from "urql";
import { useAuth } from "../nhost/AuthProvider";
export const UrqlProvider = ({ children }: { children: ReactNode }) => {
const { nhost } = useAuth();
const client: Client = createClient({
url:
import.meta.env.VITE_NHOST_GRAPHQL_URL ||
"https://local.graphql.local.nhost.run/v1",
// Force POST requests (Hasura interprets GET requests as persisted queries)
preferGetMethod: false,
exchanges: [
cacheExchange,
authExchange(async (utils) => {
return {
addAuthToOperation(operation) {
const session = nhost.getUserSession();
if (!session?.accessToken) {
return operation;
}
return utils.appendHeaders(operation, {
Authorization: `Bearer ${session.accessToken}`,
});
},
didAuthError(error) {
return error.graphQLErrors.some((e) =>
e.message.includes("JWTExpired"),
);
},
async refreshAuth() {
const currentSession = nhost.getUserSession();
if (!currentSession?.refreshToken) {
return;
}
try {
await nhost.refreshSession(60);
} catch (e: unknown) {
console.error(
"Error refreshing session:",
e instanceof Error ? e : "Unknown error",
);
await nhost.auth.signOut({
refreshToken: currentSession.refreshToken,
});
}
},
};
}),
fetchExchange,
],
});
return <Provider value={client}>{children}</Provider>;
};
```
### 3. Set Up Your App Providers
Wrap your application with the Auth and urql providers:
```tsx
// src/main.tsx
import React from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
import { UrqlProvider } from "./lib/graphql/UrqlProvider";
import { AuthProvider } from "./lib/nhost/AuthProvider";
const Root = () => (
<React.StrictMode>
<AuthProvider>
<UrqlProvider>
<App />
</UrqlProvider>
</AuthProvider>
</React.StrictMode>
);
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("Root element not found");
createRoot(rootElement).render(<Root />);
```
### 4. Define GraphQL Operations
Create a GraphQL file with your queries and mutations:
```graphql
# src/lib/graphql/queries.graphql
query GetNinjaTurtlesWithComments {
ninjaTurtles {
id
name
description
createdAt
updatedAt
comments {
id
comment
createdAt
user {
id
displayName
email
}
}
}
}
mutation AddComment($ninjaTurtleId: uuid!, $comment: String!) {
insertComment(object: { ninjaTurtleId: $ninjaTurtleId, comment: $comment }) {
id
comment
createdAt
ninjaTurtleId
}
}
```
### 5. Generate TypeScript Types
Run the code generator:
```bash
npm run generate
# or
yarn generate
# or
pnpm generate
```
This will generate typed document nodes in `src/lib/graphql/__generated__/graphql.ts`.
### 6. Use in Components
Use the generated document nodes with urql hooks in your components:
```tsx
// src/pages/Home.tsx
import { type JSX, useState } from "react";
import { useMutation, useQuery } from "urql";
import {
AddCommentDocument,
GetNinjaTurtlesWithCommentsDocument,
} from "../lib/graphql/__generated__/graphql";
import { useAuth } from "../lib/nhost/AuthProvider";
export default function Home(): JSX.Element {
const { isLoading } = useAuth();
const [activeCommentId, setActiveCommentId] = useState<string | null>(null);
const [commentText, setCommentText] = useState("");
// Query for data
const [{ data, fetching: loading, error }] = useQuery({
query: GetNinjaTurtlesWithCommentsDocument,
});
// Mutation hook
const [, addComment] = useMutation(AddCommentDocument);
if (isLoading) {
return <div>Loading...</div>;
}
const handleAddComment = async (turtleId: string) => {
if (!commentText.trim()) return;
const result = await addComment({
ninjaTurtleId: turtleId,
comment: commentText,
});
if (!result.error) {
setCommentText("");
setActiveCommentId(null);
}
};
if (loading) return <div>Loading ninja turtles...</div>;
if (error) return <div>Error: {error.message}</div>;
const ninjaTurtles = data?.ninjaTurtles || [];
return (
<div>
<h1>Ninja Turtles</h1>
{ninjaTurtles.map((turtle) => (
<div key={turtle.id}>
<h2>{turtle.name}</h2>
<p>{turtle.description}</p>
{/* Comments section */}
<div>
<h3>Comments ({turtle.comments.length})</h3>
{turtle.comments.map((comment) => (
<div key={comment.id}>
<p>{comment.comment}</p>
<small>
By{" "}
{comment.user?.displayName ||
comment.user?.email ||
"Anonymous"}
</small>
</div>
))}
{activeCommentId === turtle.id ? (
<div>
<textarea
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="Add your comment..."
/>
<div>
<button onClick={() => setActiveCommentId(null)}>
Cancel
</button>
<button onClick={() => handleAddComment(turtle.id)}>
Submit
</button>
</div>
</div>
) : (
<button onClick={() => setActiveCommentId(turtle.id)}>
Add a comment
</button>
)}
</div>
</div>
))}
</div>
);
}
```
## Key Features
- **Type Safety**: Full TypeScript support with generated types from your GraphQL schema
- **Authentication**: Automatic token management with Nhost's auth exchange
- **Token Refresh**: Automatic JWT token refresh when expired
- **Caching**: Built-in document caching with urql's cache exchange
- **POST Requests**: Configured to use POST requests (Hasura compatibility)
## Important Configuration Notes
### Hasura Compatibility
When using urql with Hasura, it's important to set `preferGetMethod: false` in the client configuration. This is because:
- urql v5 defaults to using GET requests for queries (for better HTTP caching)
- Hasura interprets GET requests as persisted query attempts
- Setting `preferGetMethod: false` forces POST requests for all operations
### Authentication Exchange
The `authExchange` from `@urql/exchange-auth` handles:
- Adding the JWT access token to every request
- Detecting authentication errors (expired tokens)
- Automatically refreshing tokens when needed
- Signing out users when token refresh fails
This ensures seamless authenticated GraphQL requests throughout your application.

View File

@@ -0,0 +1,7 @@
{
"root": false,
"extends": "//",
"linter": {
"includes": ["**", "!src/lib/graphql/__generated__/graphql.ts"]
}
}

View File

@@ -0,0 +1,28 @@
#!/bin/bash
set -e
echo "Running GraphQL code generator..."
pnpm graphql-codegen --config codegen.ts
GENERATED_TS_FILE="src/lib/graphql/__generated__/graphql.ts"
GENERATED_SCHEMA_FILE="schema.graphql"
if [ -f "$GENERATED_TS_FILE" ]; then
echo "Fixing import in $GENERATED_TS_FILE..."
echo "Formatting $GENERATED_TS_FILE..."
biome check --write "$GENERATED_TS_FILE"
else
echo "Error: Generated TypeScript file not found at $GENERATED_TS_FILE"
exit 1
fi
if [ -f "$GENERATED_SCHEMA_FILE" ]; then
echo "Formatting $GENERATED_SCHEMA_FILE..."
biome check --write "$GENERATED_SCHEMA_FILE"
echo "Successfully formatted $GENERATED_SCHEMA_FILE"
else
echo "Warning: Generated schema file not found at $GENERATED_SCHEMA_FILE"
fi
echo "All tasks completed successfully."

View File

@@ -0,0 +1,41 @@
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
schema: [
{
"https://local.graphql.local.nhost.run/v1": {
headers: {
"x-hasura-admin-secret": "nhost-admin-secret",
},
},
},
],
documents: ["src/**/*.ts"],
ignoreNoDocuments: true,
generates: {
"./src/lib/graphql/__generated__/graphql.ts": {
documents: ["src/lib/graphql/**/*.graphql"],
plugins: ["typescript", "typescript-operations", "typed-document-node"],
config: {
scalars: {
UUID: "string",
uuid: "string",
timestamptz: "string",
jsonb: "Record<string, any>",
bigint: "number",
bytea: "Buffer",
citext: "string",
},
useTypeImports: true,
},
},
"./schema.graphql": {
plugins: ["schema-ast"],
config: {
includeDirectives: true,
},
},
},
};
export default config;

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,37 @@
{
"name": "demos/react-urql",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"generate": "bash codegen-wrapper.sh",
"test": "pnpm test:typecheck && pnpm test:lint",
"test:typecheck": "tsc --noEmit",
"test:lint": "biome check",
"format": "biome format --write",
"preview": "vite preview"
},
"dependencies": {
"@graphql-typed-document-node/core": "^3.2.0",
"@nhost/nhost-js": "workspace:*",
"@urql/exchange-auth": "^3.0.0",
"graphql": "^16.11.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.0",
"urql": "^5.0.1"
},
"devDependencies": {
"@graphql-codegen/cli": "^5.0.6",
"@graphql-codegen/schema-ast": "^4.1.0",
"@graphql-codegen/typed-document-node": "^6.0.2",
"@graphql-codegen/typescript": "^4.1.6",
"@graphql-codegen/typescript-operations": "^4.6.1",
"@types/node": "^22.15.17",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1"
}
}

3741
examples/guides/react-urql/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
import type { JSX } from "react";
import {
createBrowserRouter,
createRoutesFromElements,
Navigate,
Outlet,
Route,
RouterProvider,
} from "react-router-dom";
import Navigation from "./components/Navigation";
import ProtectedRoute from "./components/ProtectedRoute";
import Home from "./pages/Home";
import Profile from "./pages/Profile";
import SignIn from "./pages/SignIn";
import SignUp from "./pages/SignUp";
// Root layout component to wrap all routes
const RootLayout = (): JSX.Element => {
return (
<div className="flex-col min-h-screen">
<Navigation />
<main className="max-w-2xl mx-auto p-6 w-full">
<Outlet />
</main>
<footer>
<p
className="text-sm text-center"
style={{ color: "var(--text-muted)" }}
>
© {new Date().getFullYear()} Nhost Demo
</p>
</footer>
</div>
);
};
// Create router with routes
const router = createBrowserRouter(
createRoutesFromElements(
<Route element={<RootLayout />}>
<Route path="signin" element={<SignIn />} />
<Route path="signup" element={<SignUp />} />
<Route element={<ProtectedRoute />}>
<Route path="home" element={<Home />} />
<Route path="profile" element={<Profile />} />
</Route>
<Route path="*" element={<Navigate to="/" />} />
</Route>,
),
);
const App = (): JSX.Element => {
return <RouterProvider router={router} />;
};
export default App;

View File

@@ -0,0 +1,85 @@
import type { JSX } from "react";
import { Link, useLocation } from "react-router-dom";
import { useAuth } from "../lib/nhost/AuthProvider";
export default function Navigation(): JSX.Element {
const { isAuthenticated, nhost, session } = useAuth();
const location = useLocation();
// Helper function to determine if a link is active
const isActive = (path: string): string => {
return location.pathname === path ? "active" : "";
};
return (
<nav className="navbar">
<div className="navbar-container">
<div className="flex items-center">
<span className="navbar-brand">Nhost Demo</span>
<div className="navbar-links">
{isAuthenticated ? (
<>
<Link to="/home" className={`nav-link ${isActive("/home")}`}>
Home
</Link>
<Link
to="/profile"
className={`nav-link ${isActive("/profile")}`}
>
Profile
</Link>
</>
) : (
<>
<Link
to="/signin"
className={`nav-link ${isActive("/signin")}`}
>
Sign In
</Link>
<Link
to="/signup"
className={`nav-link ${isActive("/signup")}`}
>
Sign Up
</Link>
</>
)}
</div>
</div>
{isAuthenticated && (
<div>
<button
type="button"
onClick={async () => {
if (session) {
await nhost.auth.signOut({
refreshToken: session.refreshToken,
});
}
}}
className="icon-button"
title="Sign Out"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-label="Sign Out"
role="img"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
</div>
)}
</div>
</nav>
);
}

View File

@@ -0,0 +1,26 @@
import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "../lib/nhost/AuthProvider";
interface ProtectedRouteProps {
redirectTo?: string;
}
export default function ProtectedRoute({
redirectTo = "/signin",
}: ProtectedRouteProps) {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return (
<div className="loading-container">
<p>Loading...</p>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to={redirectTo} />;
}
return <Outlet />;
}

View File

@@ -0,0 +1,552 @@
/* Base styles */
:root {
--background: #030712;
--foreground: #ffffff;
--card-bg: #111827;
--card-border: #1f2937;
--primary: #6366f1;
--primary-hover: #4f46e5;
--secondary: #10b981;
--secondary-hover: #059669;
--accent: #8b5cf6;
--accent-hover: #7c3aed;
--success: #22c55e;
--error: #ef4444;
--text-primary: #f9fafb;
--text-secondary: #d1d5db;
--text-muted: #9ca3af;
--border-color: rgba(31, 41, 55, 0.7);
--font-geist-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background: var(--background);
color: var(--foreground);
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.6;
min-height: 100vh;
}
/* Layout */
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.min-h-screen {
min-height: 100vh;
}
.w-full {
width: 100%;
}
.max-w-2xl {
max-width: 42rem;
}
.mx-auto {
margin-left: auto;
margin-right: auto;
}
.p-6 {
padding: 1.5rem;
}
.p-8 {
padding: 2rem;
}
.py-5 {
padding-top: 1.25rem;
padding-bottom: 1.25rem;
}
.mb-6 {
margin-bottom: 1.5rem;
}
.mb-4 {
margin-bottom: 1rem;
}
.mt-4 {
margin-top: 1rem;
}
.mr-8 {
margin-right: 2rem;
}
.ml-2 {
margin-left: 0.5rem;
}
.space-y-5 > * + * {
margin-top: 1.25rem;
}
.space-x-4 > * + * {
margin-left: 1rem;
}
/* Typography */
h1,
h2,
h3 {
font-weight: bold;
line-height: 1.2;
}
.text-3xl {
font-size: 1.875rem;
}
.text-2xl {
font-size: 1.5rem;
}
.text-xl {
font-size: 1.25rem;
}
.text-lg {
font-size: 1.125rem;
}
.text-sm {
font-size: 0.875rem;
}
.text-xs {
font-size: 0.75rem;
}
.font-bold {
font-weight: 700;
}
.font-semibold {
font-weight: 600;
}
.font-medium {
font-weight: 500;
}
.text-center {
text-align: center;
}
.gradient-text {
background: linear-gradient(to right, var(--primary), var(--accent));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* Components */
.glass-card {
background: rgba(17, 24, 39, 0.7);
backdrop-filter: blur(8px);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.2);
}
.btn {
display: inline-block;
padding: 0.625rem 1rem;
font-weight: 500;
border-radius: 0.375rem;
transition: all 0.2s ease;
cursor: pointer;
text-align: center;
border: none;
}
.btn-primary {
background-color: var(--primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--primary-hover);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background-color: var(--secondary);
color: white;
}
.btn-secondary:hover:not(:disabled) {
background-color: var(--secondary-hover);
}
.nav-link {
display: inline-block;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 600;
font-size: 0.875rem;
transition: all 0.2s ease;
color: var(--text-secondary);
text-decoration: none;
}
.nav-link:hover {
color: white;
background-color: rgba(31, 41, 55, 0.7);
}
.nav-link.active {
background-color: var(--primary);
color: white;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3);
}
input,
textarea,
select {
width: 100%;
padding: 0.625rem 0.75rem;
background-color: rgba(31, 41, 55, 0.8);
border: 1px solid var(--border-color);
color: white;
border-radius: 0.375rem;
transition: all 0.2s;
}
input:focus,
textarea:focus,
select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
}
.alert {
padding: 0.75rem;
border-radius: 0.375rem;
margin-bottom: 1rem;
}
.alert-error {
background-color: rgba(239, 68, 68, 0.2);
border: 1px solid rgba(239, 68, 68, 0.5);
color: white;
}
.alert-success {
background-color: rgba(34, 197, 94, 0.2);
border: 1px solid rgba(34, 197, 94, 0.5);
color: white;
}
/* Navigation */
.navbar {
position: sticky;
top: 0;
z-index: 10;
background-color: rgba(17, 24, 39, 0.8);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border-color);
padding: 1rem 0;
margin-bottom: 2rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.navbar-container {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 42rem;
margin: 0 auto;
padding: 0 1.5rem;
}
.navbar-brand {
color: var(--primary);
font-weight: bold;
font-size: 1.125rem;
margin-right: 2rem;
}
.navbar-links {
display: flex;
gap: 1rem;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
}
th {
text-align: left;
padding: 0.75rem 1rem;
font-size: 0.75rem;
text-transform: uppercase;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
}
td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
font-size: 0.875rem;
}
tr:hover {
background-color: rgba(31, 41, 55, 0.3);
}
/* File upload styles */
.file-upload {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem;
border: 2px dashed rgba(99, 102, 241, 0.3);
border-radius: 0.5rem;
background-color: rgba(31, 41, 55, 0.3);
cursor: pointer;
transition: all 0.2s;
}
.file-upload:hover {
border-color: var(--primary);
}
/* Footer */
footer {
padding: 1.25rem 0;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: center;
align-items: center;
}
/* Link styles */
a {
color: var(--primary);
text-decoration: none;
transition: color 0.2s;
}
a:hover {
color: var(--primary-hover);
text-decoration: underline;
}
/* Loading state */
.loading-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 50vh;
}
/* Code blocks */
pre {
background-color: rgba(31, 41, 55, 0.8);
padding: 1rem;
border-radius: 0.375rem;
overflow: auto;
font-family: monospace;
border: 1px solid var(--border-color);
font-size: 0.875rem;
color: var(--text-secondary);
}
/* Profile data */
.profile-item {
padding-bottom: 0.75rem;
margin-bottom: 0.75rem;
border-bottom: 1px solid var(--border-color);
}
.profile-item strong {
color: var(--text-secondary);
}
.action-link {
color: var(--primary);
font-weight: 500;
margin-right: 0.75rem;
cursor: pointer;
}
.action-link:hover {
color: var(--primary-hover);
text-decoration: underline;
}
.action-link-danger {
color: var(--error);
}
.action-link-danger:hover {
color: #f05252;
}
/* Icon button */
.icon-button {
background-color: transparent;
color: var(--primary);
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border: none;
transition: all 0.2s;
}
.icon-button:hover {
background-color: rgba(99, 102, 241, 0.1);
color: var(--primary-hover);
}
.icon-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.icon-button svg {
width: 20px;
height: 20px;
}
/* Table action icons */
.table-actions {
display: flex;
gap: 8px;
}
.action-icon {
background-color: transparent;
border: none;
width: 32px;
height: 32px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
padding: 0;
}
.action-icon svg {
width: 18px;
height: 18px;
}
.action-icon:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-icon-view {
color: var(--primary);
}
.action-icon-view:hover:not(:disabled) {
background-color: rgba(99, 102, 241, 0.1);
color: var(--primary-hover);
}
.action-icon-delete {
color: var(--error);
}
.action-icon-delete:hover:not(:disabled) {
background-color: rgba(239, 68, 68, 0.1);
color: #f05252;
}
/* Tab styles */
.tabs-container {
display: flex;
border-radius: 0.5rem;
overflow: hidden;
margin-bottom: 1.5rem;
border: 1px solid var(--border-color);
}
.tab-button {
flex: 1;
padding: 0.75rem 1rem;
font-weight: 500;
transition: all 0.2s ease;
background-color: rgba(31, 41, 55, 0.5);
color: var(--text-secondary);
}
.tab-button:hover:not(.tab-active) {
background-color: rgba(31, 41, 55, 0.8);
color: var(--text-primary);
}
.tab-button.tab-active {
background-color: var(--primary);
color: white;
box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.3);
}
.tab-button:first-child {
border-top-left-radius: 0.5rem;
border-bottom-left-radius: 0.5rem;
}
.tab-button:last-child {
border-top-right-radius: 0.5rem;
border-bottom-right-radius: 0.5rem;
}
.tab-content {
margin-top: 1.5rem;
}

View File

@@ -0,0 +1,77 @@
import { authExchange } from "@urql/exchange-auth";
import type { ReactNode } from "react";
import {
type Client,
cacheExchange,
createClient,
fetchExchange,
Provider,
} from "urql";
import { useAuth } from "../nhost/AuthProvider";
interface UrqlProviderProps {
children: ReactNode;
}
/**
* UrqlProvider component that provides the urql client to the React application.
*
* This component handles:
* - Creating the urql client with authentication support
* - Managing GraphQL requests with proper authentication headers
* - Token refresh handling through Nhost
*/
export const UrqlProvider = ({ children }: UrqlProviderProps) => {
const { nhost } = useAuth();
const client: Client = createClient({
url:
import.meta.env["VITE_NHOST_GRAPHQL_URL"] ||
"https://local.graphql.local.nhost.run/v1",
// Force POST requests (Hasura interprets GET requests as persisted queries)
preferGetMethod: false,
exchanges: [
cacheExchange,
authExchange(async (utils) => {
return {
addAuthToOperation(operation) {
const session = nhost.getUserSession();
if (!session?.accessToken) {
return operation;
}
return utils.appendHeaders(operation, {
Authorization: `Bearer ${session.accessToken}`,
});
},
didAuthError(error) {
return error.graphQLErrors.some((e) =>
e.message.includes("JWTExpired"),
);
},
async refreshAuth() {
const currentSession = nhost.getUserSession();
if (!currentSession?.refreshToken) {
return;
}
try {
await nhost.refreshSession(60);
} catch (e: unknown) {
console.error(
"Error refreshing session:",
e instanceof Error ? e : "Unknown error",
);
await nhost.auth.signOut({
refreshToken: currentSession.refreshToken,
});
}
},
};
}),
fetchExchange,
],
});
return <Provider value={client}>{children}</Provider>;
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
query GetNinjaTurtlesWithComments {
ninjaTurtles {
id
name
description
createdAt
updatedAt
comments {
id
comment
createdAt
user {
id
displayName
email
}
}
}
}
mutation AddComment($ninjaTurtleId: uuid!, $comment: String!) {
insertComment(object: { ninjaTurtleId: $ninjaTurtleId, comment: $comment }) {
id
comment
createdAt
ninjaTurtleId
}
}

View File

@@ -0,0 +1,174 @@
import { createClient, type NhostClient } from "@nhost/nhost-js";
import type { Session } from "@nhost/nhost-js/auth";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
/**
* Authentication context interface providing access to user session state and Nhost client.
* Used throughout the React application to access authentication-related data and operations.
*/
interface AuthContextType {
/** Current authenticated user object, null if not authenticated */
user: Session["user"] | null;
/** Current session object containing tokens and user data, null if no active session */
session: Session | null;
/** Boolean indicating if user is currently authenticated */
isAuthenticated: boolean;
/** Boolean indicating if authentication state is still loading */
isLoading: boolean;
/** Nhost client instance for making authenticated requests */
nhost: NhostClient;
}
// Create React context for authentication state and nhost client
const AuthContext = createContext<AuthContextType | null>(null);
interface AuthProviderProps {
children: ReactNode;
}
/**
* AuthProvider component that provides authentication context to the React application.
*
* This component handles:
* - Initializing the Nhost client with default EventEmitterStorage
* - Managing authentication state (user, session, loading, authenticated status)
* - Cross-tab session synchronization using sessionStorage.onChange events
* - Page visibility and focus event handling to maintain session consistency
* - Client-side only session management (no server-side rendering)
*/
export const AuthProvider = ({ children }: AuthProviderProps) => {
const [user, setUser] = useState<Session["user"] | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const lastRefreshTokenIdRef = useRef<string | null>(null);
// Initialize Nhost client with default SessionStorage (local storage)
const nhost = useMemo(
() =>
createClient({
region: import.meta.env.VITE_NHOST_REGION || "local",
subdomain: import.meta.env.VITE_NHOST_SUBDOMAIN || "local",
}),
[],
);
/**
* Handles session reload when refresh token changes.
* This detects when the session has been updated from other tabs.
*
* @param currentRefreshTokenId - The current refresh token ID to compare against stored value
*/
const reloadSession = useCallback(
(currentRefreshTokenId: string | null) => {
if (currentRefreshTokenId !== lastRefreshTokenIdRef.current) {
lastRefreshTokenIdRef.current = currentRefreshTokenId;
// Update local authentication state to match current session
const currentSession = nhost.getUserSession();
setUser(currentSession?.user || null);
setSession(currentSession);
setIsAuthenticated(!!currentSession);
}
},
[nhost],
);
// Initialize authentication state and set up cross-tab session synchronization
useEffect(() => {
setIsLoading(true);
// Load initial session state from Nhost client
const currentSession = nhost.getUserSession();
setUser(currentSession?.user || null);
setSession(currentSession);
setIsAuthenticated(!!currentSession);
lastRefreshTokenIdRef.current = currentSession?.refreshTokenId ?? null;
setIsLoading(false);
// Subscribe to session changes from other browser tabs
// This enables real-time synchronization when user signs in/out in another tab
const unsubscribe = nhost.sessionStorage.onChange((session) => {
reloadSession(session?.refreshTokenId ?? null);
});
return unsubscribe;
}, [nhost, reloadSession]);
// Handle session changes from page focus events (for additional session consistency)
useEffect(() => {
/**
* Checks for session changes when page becomes visible or focused.
* In the React SPA context, this provides additional consistency checks
* though it's less critical than in the Next.js SSR version.
*/
const checkSessionOnFocus = () => {
reloadSession(nhost.getUserSession()?.refreshTokenId ?? null);
};
// Monitor page visibility changes (tab switching, window minimizing)
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
checkSessionOnFocus();
}
});
// Monitor window focus events (clicking back into the browser window)
window.addEventListener("focus", checkSessionOnFocus);
// Cleanup event listeners on component unmount
return () => {
document.removeEventListener("visibilitychange", checkSessionOnFocus);
window.removeEventListener("focus", checkSessionOnFocus);
};
}, [nhost, reloadSession]);
const value: AuthContextType = {
user,
session,
isAuthenticated,
isLoading,
nhost,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
/**
* Custom hook to access the authentication context.
*
* Must be used within a component wrapped by AuthProvider.
* Provides access to current user session, authentication state, and Nhost client.
*
* @throws {Error} When used outside of AuthProvider
* @returns {AuthContextType} Authentication context containing user, session, and client
*
* @example
* ```tsx
* function MyComponent() {
* const { user, isAuthenticated, nhost } = useAuth();
*
* if (!isAuthenticated) {
* return <div>Please sign in</div>;
* }
*
* return <div>Welcome, {user?.displayName}!</div>;
* }
* ```
*/
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};

View File

@@ -0,0 +1,22 @@
import React from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
import { UrqlProvider } from "./lib/graphql/UrqlProvider";
import { AuthProvider } from "./lib/nhost/AuthProvider";
// Root component that sets up providers
const Root = () => (
<React.StrictMode>
<AuthProvider>
<UrqlProvider>
<App />
</UrqlProvider>
</AuthProvider>
</React.StrictMode>
);
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("Root element not found");
createRoot(rootElement).render(<Root />);

View File

@@ -0,0 +1,217 @@
/* Custom styles for Ninja Turtles tabs interface */
.ninja-turtles-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.ninja-turtles-title {
text-align: center;
margin-bottom: 25px;
color: #1a9c44;
text-transform: uppercase;
letter-spacing: 1px;
}
/* Tab navigation */
.turtle-tabs {
display: flex;
border-bottom: 2px solid #1a9c44;
margin-bottom: 20px;
overflow-x: auto;
}
.turtle-tab {
padding: 10px 20px;
margin-right: 5px;
background: none;
border: none;
cursor: pointer;
font-weight: 600;
color: var(--text-secondary);
transition: all 0.3s ease;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
position: relative;
outline: none;
}
.turtle-tab:hover {
color: var(--text-primary);
background: rgba(26, 156, 68, 0.1);
}
.turtle-tab.active {
color: white;
background: #1a9c44;
}
/* Turtle Card Styles */
.turtle-card {
border-radius: 8px;
overflow: hidden;
transition: all 0.3s ease;
animation: fadeIn 0.5s;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.turtle-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.turtle-name {
color: #1a9c44;
font-weight: 700;
}
.turtle-description {
margin-bottom: 20px;
line-height: 1.6;
}
.turtle-date {
font-size: 0.85rem;
margin-bottom: 20px;
color: var(--text-muted);
}
/* Comments section */
.comments-section {
margin-top: 25px;
border-top: 1px solid var(--border-color);
padding-top: 15px;
}
.comments-title {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 15px;
color: #1a9c44;
}
.comment-card {
margin-bottom: 15px;
padding: 12px;
border-radius: 6px;
background-color: rgba(26, 156, 68, 0.05);
border: 1px solid rgba(26, 156, 68, 0.1);
}
.comment-text {
margin-bottom: 8px;
}
.comment-meta {
display: flex;
align-items: center;
font-size: 0.8rem;
color: var(--text-muted);
}
.comment-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
background-color: #1a9c44;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
margin-right: 8px;
}
/* Comment form */
.comment-form {
margin-top: 20px;
}
.comment-textarea {
width: 100%;
background-color: rgba(31, 41, 55, 0.8);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 10px;
color: white;
transition: all 0.2s;
margin-bottom: 10px;
}
.comment-textarea:focus {
border-color: #1a9c44;
box-shadow: 0 0 0 2px rgba(26, 156, 68, 0.2);
outline: none;
}
.comment-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.cancel-button {
background-color: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.cancel-button:hover {
background-color: rgba(31, 41, 55, 0.8);
}
.submit-button {
background-color: #1a9c44;
color: white;
}
.submit-button:hover {
background-color: #148035;
}
.add-comment-button {
display: inline-flex;
align-items: center;
color: #1a9c44;
background: none;
border: none;
padding: 5px 0;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.add-comment-button:hover {
color: #148035;
text-decoration: underline;
}
.add-comment-button svg {
width: 14px;
height: 14px;
margin-right: 5px;
}
/* Responsive adjustments */
@media (max-width: 640px) {
.turtle-tabs {
flex-wrap: nowrap;
overflow-x: auto;
}
.turtle-tab {
flex: 0 0 auto;
}
}

View File

@@ -0,0 +1,202 @@
import { type JSX, useState } from "react";
import { useMutation, useQuery } from "urql";
import {
AddCommentDocument,
GetNinjaTurtlesWithCommentsDocument,
} from "../lib/graphql/__generated__/graphql";
import { useAuth } from "../lib/nhost/AuthProvider";
import "./Home.css";
export default function Home(): JSX.Element {
const { isLoading } = useAuth();
const [activeCommentId, setActiveCommentId] = useState<string | null>(null);
const [commentText, setCommentText] = useState("");
const [activeTabId, setActiveTabId] = useState<string | null>(null);
const [{ data, fetching: loading, error }] = useQuery({
query: GetNinjaTurtlesWithCommentsDocument,
});
// Log the full error for debugging
if (error) {
console.error("GraphQL Error:", error);
console.error("GraphQL Error details:", JSON.stringify(error, null, 2));
}
const [, addComment] = useMutation(AddCommentDocument);
// If authentication is still loading, show a loading state
if (isLoading) {
return (
<div className="loading-container">
<p>Loading...</p>
</div>
);
}
const handleAddComment = async (turtleId: string) => {
if (!commentText.trim()) return;
const result = await addComment({
ninjaTurtleId: turtleId,
comment: commentText,
});
if (!result.error) {
setCommentText("");
setActiveCommentId(null);
}
};
if (loading)
return (
<div className="loading-container">
<p>Loading ninja turtles...</p>
</div>
);
if (error)
return (
<div className="alert alert-error">
Error loading ninja turtles: {(error as Error).message}
</div>
);
// Access the data using the correct field name from the GraphQL response
const ninjaTurtles = data?.ninjaTurtles || [];
if (!ninjaTurtles || ninjaTurtles.length === 0) {
return (
<div className="no-turtles-container">
<p>No ninja turtles found. Please add some!</p>
</div>
);
}
// Set the active tab to the first turtle if there's no active tab and there are turtles
if (activeTabId === null) {
setActiveTabId(ninjaTurtles[0] ? ninjaTurtles[0].id : null);
}
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`;
};
return (
<div className="ninja-turtles-container">
<h1 className="ninja-turtles-title text-3xl font-bold mb-6">
Teenage Mutant Ninja Turtles
</h1>
{/* Tabs navigation */}
<div className="turtle-tabs">
{ninjaTurtles.map((turtle) => (
<button
key={turtle.id}
type="button"
className={`turtle-tab ${activeTabId === turtle.id ? "active" : ""}`}
onClick={() => setActiveTabId(turtle.id)}
>
{turtle.name}
</button>
))}
</div>
{/* Display active turtle */}
{ninjaTurtles
.filter((turtle) => turtle.id === activeTabId)
.map((turtle) => (
<div key={turtle.id} className="turtle-card glass-card p-6">
<div className="turtle-header">
<h2 className="turtle-name text-2xl font-semibold">
{turtle.name}
</h2>
</div>
<p className="turtle-description">{turtle.description}</p>
<div className="turtle-date">
Added on {formatDate(turtle.createdAt || turtle.createdAt)}
</div>
<div className="comments-section">
<h3 className="comments-title">
Comments ({turtle.comments.length})
</h3>
{turtle.comments.map((comment) => (
<div key={comment.id} className="comment-card">
<p className="comment-text">{comment.comment}</p>
<div className="comment-meta">
<div className="comment-avatar">
{(comment.user?.displayName || comment.user?.email || "?")
.charAt(0)
.toUpperCase()}
</div>
<p>
{comment.user?.displayName ||
comment.user?.email ||
"Anonymous"}{" "}
- {formatDate(comment.createdAt || comment.createdAt)}
</p>
</div>
</div>
))}
{activeCommentId === turtle.id ? (
<div className="comment-form">
<textarea
className="comment-textarea"
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="Add your comment..."
rows={3}
/>
<div className="comment-actions">
<button
type="button"
onClick={() => {
setActiveCommentId(null);
setCommentText("");
}}
className="btn cancel-button"
>
Cancel
</button>
<button
type="button"
onClick={() => handleAddComment(turtle.id)}
className="btn submit-button"
>
Submit
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setActiveCommentId(turtle.id)}
className="add-comment-button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-label="Add Comment"
role="img"
>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Add a comment
</button>
)}
</div>
</div>
))}
</div>
);
}

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