Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58eda5bc34 | ||
|
|
6ad1cd1900 | ||
|
|
5c5223d871 | ||
|
|
00ef639455 | ||
|
|
a54da9c072 | ||
|
|
63edfa2600 | ||
|
|
381baf2e51 | ||
|
|
951ce168e8 | ||
|
|
be8f4e5b1b | ||
|
|
010573cc31 | ||
|
|
629bbe7a78 | ||
|
|
166889be1b | ||
|
|
c80f6292c6 |
1
.github/workflows/dashboard_wf_release.yaml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/gen_codeql-analysis.yml
vendored
@@ -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
|
||||
|
||||
@@ -7,6 +7,8 @@ linters:
|
||||
settings:
|
||||
funlen:
|
||||
lines: 65
|
||||
wsl_v5:
|
||||
allow-whole-block: true
|
||||
disable:
|
||||
- canonicalheader
|
||||
- depguard
|
||||
|
||||
@@ -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
|
||||
|
||||
199
cli/MCP.md
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
12
cli/cmd/mcp/testdata/sample.toml
vendored
@@ -1,12 +1,20 @@
|
||||
[cloud]
|
||||
enable_mutations = true
|
||||
|
||||
[local]
|
||||
[[projects]]
|
||||
subdomain = 'local'
|
||||
region = 'local'
|
||||
description = 'Local development project running via the Nhost CLI'
|
||||
admin_secret = 'nhost-admin-secret'
|
||||
manage_metadata = true
|
||||
allow_queries = ['*']
|
||||
allow_mutations = ['*']
|
||||
|
||||
[[projects]]
|
||||
subdomain = 'asdasdasdasdasd'
|
||||
region = 'eu-central-1'
|
||||
description = 'Staging project for my awesome app'
|
||||
manage_metadata = false
|
||||
admin_secret = 'your-admin-secret-1'
|
||||
allow_queries = ['*']
|
||||
allow_mutations = ['*']
|
||||
@@ -14,6 +22,8 @@ allow_mutations = ['*']
|
||||
[[projects]]
|
||||
subdomain = 'qweqweqweqweqwe'
|
||||
region = 'us-east-1'
|
||||
description = 'Production project for my awesome app'
|
||||
manage_metadata = false
|
||||
pat = 'pat-for-qweqweqweqweqwe'
|
||||
allow_queries = ['getComments']
|
||||
allow_mutations = ['insertComment', 'updateComment', 'deleteComment']
|
||||
|
||||
@@ -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 = []
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 819 KiB |
|
Before Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 181 KiB |
@@ -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 }}'
|
||||
|
||||
@@ -1,38 +1,35 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/nhost/nhost/cli/mcp/nhost/auth"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func ptr[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultLocalConfigServerURL = "https://local.dashboard.local.nhost.run/v1/configserver/graphql"
|
||||
DefaultLocalGraphqlURL = "https://local.graphql.local.nhost.run/v1"
|
||||
)
|
||||
|
||||
var ErrProjectNotConfigured = errors.New("project not configured")
|
||||
|
||||
type Config struct {
|
||||
// If configured allows managing the cloud. For instance, this allows you to configure
|
||||
// projects, list projects, organizations, and so on.
|
||||
Cloud *Cloud `json:"cloud,omitempty" toml:"cloud"`
|
||||
|
||||
// If configured allows working with a local project running via the CLI. This includes
|
||||
// configuring it, working with the schema, migrations, etc.
|
||||
Local *Local `json:"local,omitempty" toml:"local"`
|
||||
|
||||
// Projects is a list of projects that you want to allow access to. This grants access to the
|
||||
// GraphQL schema allowing it to inspect it and run allowed queries and mutations.
|
||||
Projects []Project `json:"projects" toml:"projects"`
|
||||
// Projects is a list of projects that you want to allow access to. This grants access
|
||||
// to the GraphQL schema allowing it to inspect it and run allowed queries and mutations.
|
||||
Projects ProjectList `json:"projects" toml:"projects"`
|
||||
}
|
||||
|
||||
type Cloud struct {
|
||||
@@ -41,17 +38,41 @@ type Cloud struct {
|
||||
EnableMutations bool `json:"enable_mutations" toml:"enable_mutations"`
|
||||
}
|
||||
|
||||
type Local struct {
|
||||
// Admin secret to use when running against a local project.
|
||||
AdminSecret string `json:"admin_secret" toml:"admin_secret"`
|
||||
type ProjectList []Project
|
||||
|
||||
// GraphQL URL to use when running against a local project.
|
||||
// Defaults to "https://local.dashboard.local.nhost.run/v1/configserver/graphql"
|
||||
ConfigServerURL *string `json:"config_server_url,omitempty" toml:"config_server_url,omitempty"`
|
||||
func (pl ProjectList) Get(subdomain string) (*Project, error) {
|
||||
for _, p := range pl {
|
||||
if p.Subdomain == subdomain {
|
||||
return &p, nil
|
||||
}
|
||||
}
|
||||
|
||||
// GraphQL URL to use when running against a local project.
|
||||
// Defaults to "https://local.graphql.local.nhost.run/v1"
|
||||
GraphqlURL *string `json:"graphql_url,omitempty" toml:"graphql_url,omitempty"`
|
||||
return nil, fmt.Errorf("%w: %s", ErrProjectNotConfigured, subdomain)
|
||||
}
|
||||
|
||||
func (pl ProjectList) Subdomains() []string {
|
||||
subdomains := make([]string, 0, len(pl))
|
||||
|
||||
for _, p := range pl {
|
||||
subdomains = append(subdomains, p.Subdomain)
|
||||
}
|
||||
|
||||
return subdomains
|
||||
}
|
||||
|
||||
func (pl ProjectList) Instructions() string {
|
||||
if len(pl) == 0 {
|
||||
return "No projects configured. Please, run `nhost mcp config` to configure your projects."
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Configured projects:\n")
|
||||
|
||||
for _, p := range pl {
|
||||
sb.WriteString(fmt.Sprintf("- %s (%s): %s\n", p.Subdomain, p.Region, p.Description))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
@@ -61,6 +82,9 @@ type Project struct {
|
||||
// Project's region
|
||||
Region string `json:"region" toml:"region"`
|
||||
|
||||
// Project's description
|
||||
Description string `json:"description,omitempty" toml:"description,omitempty"`
|
||||
|
||||
// Admin secret to operate against the project.
|
||||
// Either admin secret or PAT is required.
|
||||
AdminSecret *string `json:"admin_secret,omitempty" toml:"admin_secret,omitempty"`
|
||||
@@ -69,6 +93,10 @@ type Project struct {
|
||||
// Either admin secret or PAT is required.
|
||||
PAT *string `json:"pat,omitempty" toml:"pat,omitempty"`
|
||||
|
||||
// If enabled, allows managing the project's metadata (tables, relationships,
|
||||
// permissions, etc).
|
||||
ManageMetadata bool `json:"manage_metadata,omitempty" toml:"manage_metadata,omitempty"`
|
||||
|
||||
// List of queries that are allowed to be executed against the project.
|
||||
// If empty, no queries are allowed. Use [*] to allow all queries.
|
||||
AllowQueries []string `json:"allow_queries" toml:"allow_queries"`
|
||||
@@ -77,6 +105,58 @@ type Project struct {
|
||||
// If empty, no mutations are allowed. Use [*] to allow all mutations.
|
||||
// Note that this is only used if the project is configured to allow mutations.
|
||||
AllowMutations []string `json:"allow_mutations" toml:"allow_mutations"`
|
||||
|
||||
// GraphQL URL to use when running against the project. Defaults to constructed URL with
|
||||
// the subdomain and region.
|
||||
GraphqlURL string `json:"graphql_url,omitzero" toml:"graphql_url,omitzero"`
|
||||
|
||||
// Auth URL to use when running against the project. Defaults to constructed URL with
|
||||
// the subdomain and region.
|
||||
AuthURL string `json:"auth_url,omitzero" toml:"auth_url,omitzero"`
|
||||
|
||||
// Hasura's base URL. Defaults to constructed URL with the subdomain and region.
|
||||
HasuraURL string `json:"hasura_url,omitzero" toml:"hasura_url,omitzero"`
|
||||
}
|
||||
|
||||
func (p *Project) GetAuthURL() string {
|
||||
if p.AuthURL != "" {
|
||||
return p.AuthURL
|
||||
}
|
||||
|
||||
return fmt.Sprintf("https://%s.auth.%s.nhost.run/v1", p.Subdomain, p.Region)
|
||||
}
|
||||
|
||||
func (p *Project) GetGraphqlURL() string {
|
||||
if p.GraphqlURL != "" {
|
||||
return p.GraphqlURL
|
||||
}
|
||||
|
||||
return fmt.Sprintf("https://%s.graphql.%s.nhost.run/v1", p.Subdomain, p.Region)
|
||||
}
|
||||
|
||||
func (p *Project) GetHasuraURL() string {
|
||||
if p.HasuraURL != "" {
|
||||
return p.HasuraURL
|
||||
}
|
||||
|
||||
return fmt.Sprintf("https://%s.hasura.%s.nhost.run", p.Subdomain, p.Region)
|
||||
}
|
||||
|
||||
func (p *Project) GetAuthInterceptor() (func(ctx context.Context, req *http.Request) error, error) {
|
||||
if p.AdminSecret != nil {
|
||||
return auth.WithAdminSecret(*p.AdminSecret), nil
|
||||
} else if p.PAT != nil {
|
||||
interceptor, err := auth.WithPAT(p.GetAuthURL(), *p.PAT)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create PAT interceptor: %w", err)
|
||||
}
|
||||
|
||||
return interceptor, nil
|
||||
}
|
||||
|
||||
return func(_ context.Context, _ *http.Request) error {
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
func GetConfigPath(cmd *cli.Command) string {
|
||||
@@ -117,15 +197,5 @@ func Load(path string) (*Config, error) {
|
||||
return nil, fmt.Errorf("failed to unmarshal config file: %w", err)
|
||||
}
|
||||
|
||||
if config.Local != nil {
|
||||
if config.Local.GraphqlURL == nil {
|
||||
config.Local.GraphqlURL = ptr(DefaultLocalGraphqlURL)
|
||||
}
|
||||
|
||||
if config.Local.ConfigServerURL == nil {
|
||||
config.Local.ConfigServerURL = ptr(DefaultLocalConfigServerURL)
|
||||
}
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package config //nolint:testpackage
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
func TestInterpolateEnv(t *testing.T) {
|
||||
@@ -134,7 +136,9 @@ func TestInterpolateEnvRealWorld(t *testing.T) {
|
||||
return envVars[key]
|
||||
}
|
||||
|
||||
input := `[local]
|
||||
input := `[[projects]]
|
||||
subdomain = "local"
|
||||
region = "local"
|
||||
admin_secret = "$ADMIN_SECRET"
|
||||
|
||||
[[projects]]
|
||||
@@ -143,7 +147,9 @@ admin_secret = "$ADMIN_SECRET"
|
||||
# Price is $$100
|
||||
`
|
||||
|
||||
expected := `[local]
|
||||
expected := `[[projects]]
|
||||
subdomain = "local"
|
||||
region = "local"
|
||||
admin_secret = "super-secret-key"
|
||||
|
||||
[[projects]]
|
||||
@@ -191,9 +197,13 @@ func TestIsAlphaNumUnderscore(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
|
||||
func TestLoadWithInterpolation(t *testing.T) {
|
||||
// Create a temporary config file
|
||||
content := `[local]
|
||||
content := `[[projects]]
|
||||
admin_secret = "$TEST_ADMIN_SECRET"
|
||||
|
||||
[[projects]]
|
||||
@@ -222,33 +232,25 @@ allow_queries = ["*"]
|
||||
t.Setenv("TEST_PROJECT_SECRET", "project-secret")
|
||||
|
||||
// Load config
|
||||
config, err := Load(tmpfile.Name())
|
||||
cfg, err := Load(tmpfile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify interpolation worked
|
||||
if config.Local == nil {
|
||||
t.Fatal("config.Local is nil")
|
||||
}
|
||||
|
||||
if config.Local.AdminSecret != "local-secret" {
|
||||
t.Errorf("config.Local.AdminSecret = %q, want %q", config.Local.AdminSecret, "local-secret")
|
||||
}
|
||||
|
||||
if len(config.Projects) != 1 {
|
||||
t.Fatalf("len(config.Projects) = %d, want 1", len(config.Projects))
|
||||
}
|
||||
|
||||
if config.Projects[0].AdminSecret == nil {
|
||||
t.Fatal("config.Projects[0].AdminSecret is nil")
|
||||
}
|
||||
|
||||
if *config.Projects[0].AdminSecret != "project-secret" {
|
||||
t.Errorf(
|
||||
"config.Projects[0].AdminSecret = %q, want %q",
|
||||
*config.Projects[0].AdminSecret,
|
||||
"project-secret",
|
||||
)
|
||||
if diff := cmp.Diff(cfg, &Config{
|
||||
Cloud: nil,
|
||||
Projects: ProjectList{
|
||||
{ //nolint:exhaustruct
|
||||
AdminSecret: ptr("local-secret"),
|
||||
},
|
||||
{ //nolint:exhaustruct
|
||||
Subdomain: "myapp",
|
||||
Region: "us-east-1",
|
||||
AdminSecret: ptr("project-secret"),
|
||||
AllowQueries: []string{"*"},
|
||||
},
|
||||
},
|
||||
}); diff != "" {
|
||||
t.Errorf("diff = %s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,11 +25,14 @@ func RunWizard() (*Config, error) {
|
||||
|
||||
projects := wizardProject(reader)
|
||||
|
||||
if localConfig != nil {
|
||||
projects = append(projects, *localConfig)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
return &Config{
|
||||
Cloud: cloudConfig,
|
||||
Local: localConfig,
|
||||
Projects: projects,
|
||||
}, nil
|
||||
}
|
||||
@@ -52,7 +55,7 @@ func wizardCloud(reader *bufio.Reader) *Cloud {
|
||||
}
|
||||
|
||||
//nolint:forbidigo
|
||||
func wizardLocal(reader *bufio.Reader) *Local {
|
||||
func wizardLocal(reader *bufio.Reader) *Project {
|
||||
fmt.Println("2. Local Development Access")
|
||||
fmt.Println(" This allows LLMs to interact with your local Nhost environment,")
|
||||
fmt.Println(" including project configuration and GraphQL API access.")
|
||||
@@ -64,10 +67,18 @@ func wizardLocal(reader *bufio.Reader) *Local {
|
||||
adminSecret = "nhost-admin-secret" //nolint:gosec
|
||||
}
|
||||
|
||||
return &Local{
|
||||
AdminSecret: adminSecret,
|
||||
ConfigServerURL: nil,
|
||||
GraphqlURL: nil,
|
||||
return &Project{
|
||||
Subdomain: "local",
|
||||
Region: "local",
|
||||
Description: "Local development project running via the Nhost CLI",
|
||||
AdminSecret: &adminSecret,
|
||||
PAT: nil,
|
||||
ManageMetadata: true,
|
||||
AllowQueries: []string{"*"},
|
||||
AllowMutations: []string{"*"},
|
||||
AuthURL: "",
|
||||
GraphqlURL: "",
|
||||
HasuraURL: "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,16 +102,29 @@ func wizardProject(reader *bufio.Reader) []Project {
|
||||
if promptYesNo(reader, "Configure project access?") {
|
||||
for {
|
||||
project := Project{
|
||||
Description: "",
|
||||
Subdomain: "",
|
||||
Region: "",
|
||||
AdminSecret: nil,
|
||||
PAT: nil,
|
||||
ManageMetadata: false,
|
||||
AllowQueries: []string{"*"},
|
||||
AllowMutations: []string{"*"},
|
||||
GraphqlURL: "",
|
||||
AuthURL: "",
|
||||
HasuraURL: "",
|
||||
}
|
||||
|
||||
project.Subdomain = promptString(reader, "Project subdomain:")
|
||||
project.Region = promptString(reader, "Project region:")
|
||||
project.Description = promptString(
|
||||
reader,
|
||||
"Project description to provide additional information to LLMs:",
|
||||
)
|
||||
project.ManageMetadata = promptYesNo(
|
||||
reader,
|
||||
"Allow managing metadata (tables, relationships, permissions, etc)?",
|
||||
)
|
||||
|
||||
authType := promptChoice(
|
||||
reader,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
69
cli/mcp/resources/cloud.go
Normal 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
|
||||
}
|
||||
54
cli/mcp/resources/graphql_management_schema.go
Normal 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
|
||||
}
|
||||
57
cli/mcp/resources/nhost_toml.go
Normal 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
|
||||
}
|
||||
809
cli/mcp/resources/nhost_toml_schema.cue
Normal file
@@ -0,0 +1,809 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"list"
|
||||
"math"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// main entrypoint to the configuration
|
||||
#Config: {
|
||||
// Global configuration that applies to all services
|
||||
global: #Global
|
||||
|
||||
// Configuration for hasura
|
||||
hasura: #Hasura
|
||||
|
||||
// Advanced configuration for GraphQL
|
||||
graphql?: #Graphql
|
||||
|
||||
// Configuration for functions service
|
||||
functions: #Functions
|
||||
|
||||
// Configuration for auth service
|
||||
auth: #Auth
|
||||
|
||||
// Configuration for postgres service
|
||||
postgres: #Postgres
|
||||
|
||||
// Configuration for third party providers like SMTP, SMS, etc.
|
||||
provider: #Provider
|
||||
|
||||
// Configuration for storage service
|
||||
storage: #Storage
|
||||
|
||||
// Configuration for graphite service
|
||||
ai?: #AI
|
||||
|
||||
// Configuration for observability service
|
||||
observability: #Observability
|
||||
|
||||
_totalResourcesCPU: (
|
||||
hasura.resources.replicas*hasura.resources.compute.cpu +
|
||||
auth.resources.replicas*auth.resources.compute.cpu +
|
||||
storage.resources.replicas*storage.resources.compute.cpu +
|
||||
postgres.resources.compute.cpu) @cuegraph(skip)
|
||||
|
||||
_totalResourcesMemory: (
|
||||
hasura.resources.replicas*hasura.resources.compute.memory +
|
||||
auth.resources.replicas*auth.resources.compute.memory +
|
||||
storage.resources.replicas*storage.resources.compute.memory +
|
||||
postgres.resources.compute.memory) @cuegraph(skip)
|
||||
|
||||
_validateResourcesTotalCpuMemoryRatioMustBe1For2: (
|
||||
_totalResourcesCPU*2.048 & _totalResourcesMemory*1.0) @cuegraph(skip)
|
||||
|
||||
_validateResourcesTotalCpuMin1000: (
|
||||
hasura.resources.compute.cpu+
|
||||
auth.resources.compute.cpu+
|
||||
storage.resources.compute.cpu+
|
||||
postgres.resources.compute.cpu) >= 1000 & true @cuegraph(skip)
|
||||
|
||||
_validateAllResourcesAreSetOrNot: (
|
||||
((hasura.resources.compute != _|_) == (auth.resources.compute != _|_)) &&
|
||||
((auth.resources.compute != _|_) == (storage.resources.compute != _|_)) &&
|
||||
((storage.resources.compute != _|_) == (postgres.resources.compute != _|_))) & true @cuegraph(skip)
|
||||
|
||||
_validateNetworkingMustBeNullOrNotSet: !storage.resources.networking | storage.resources.networking == null @cuegraph(skip)
|
||||
|
||||
_isProviderSMTPSet: provider.smtp != _|_ @cuegraph(skip)
|
||||
_isAuthRateLimitEmailsDefault: auth.rateLimit.emails.limit == 10 && auth.rateLimit.emails.interval == "1h" @cuegraph(skip)
|
||||
_validateAuthRateLimitEmailsIsDefaultOrSMTPSettingsSet: (_isProviderSMTPSet | _isAuthRateLimitEmailsDefault) & true @cuegraph(skip)
|
||||
}
|
||||
|
||||
// Global configuration that applies to all services
|
||||
#Global: {
|
||||
// User-defined environment variables that are spread over all services
|
||||
environment: [...#GlobalEnvironmentVariable] | *[]
|
||||
}
|
||||
|
||||
#GlobalEnvironmentVariable: {
|
||||
// Name of the environment variable
|
||||
name: =~"(?i)^[a-z_]{1,}[a-z0-9_]*" & !~"(?i)^NHOST_" & !~"(?i)^HASURA_"
|
||||
// Value of the environment variable
|
||||
value: string
|
||||
}
|
||||
|
||||
#Graphql: {
|
||||
security: #GraphqlSecurity
|
||||
}
|
||||
|
||||
#GraphqlSecurity: {
|
||||
forbidAminSecret: bool | *false
|
||||
maxDepthQueries: uint | *0 // 0 disables the check
|
||||
}
|
||||
|
||||
#Networking: {
|
||||
ingresses: [#Ingress] | *[]
|
||||
}
|
||||
|
||||
#Ingress: {
|
||||
fqdn: [string & net.FQDN & strings.MinRunes(1) & strings.MaxRunes(63)]
|
||||
|
||||
tls?: {
|
||||
clientCA?: string
|
||||
}
|
||||
}
|
||||
|
||||
#Autoscaler: {
|
||||
maxReplicas: uint8 & >=2 & <=100
|
||||
}
|
||||
|
||||
// Resource configuration for a service
|
||||
#Resources: {
|
||||
compute?: #ResourcesCompute
|
||||
|
||||
// Number of replicas for a service
|
||||
replicas: uint8 & >=1 & <=10 | *1
|
||||
autoscaler?: #Autoscaler
|
||||
|
||||
_validateReplicasMustBeSmallerThanMaxReplicas: (replicas <= autoscaler.maxReplicas) & true @cuegraph(skip)
|
||||
|
||||
_validateMultipleReplicasNeedsCompute: (
|
||||
replicas == 1 && autoscaler == _|_ |
|
||||
compute != _|_) & true @cuegraph(skip)
|
||||
_validateMultipleReplicasRatioMustBe1For2: (
|
||||
replicas == 1 && autoscaler == _|_ |
|
||||
(compute.cpu*2.048 == compute.memory)) & true @cuegraph(skip)
|
||||
|
||||
networking?: #Networking | null
|
||||
}
|
||||
|
||||
#ResourcesCompute: {
|
||||
// milicpus, 1000 milicpus = 1 cpu
|
||||
cpu: uint32 & >=250 & <=30000
|
||||
// MiB: 128MiB to 30GiB
|
||||
memory: uint32 & >=128 & <=62464
|
||||
|
||||
// validate CPU steps of 250 milicpus
|
||||
_validateCPUSteps250: (mod(cpu, 250) == 0) & true @cuegraph(skip)
|
||||
|
||||
// validate memory steps of 128 MiB
|
||||
_validateMemorySteps128: (mod(memory, 128) == 0) & true @cuegraph(skip)
|
||||
}
|
||||
|
||||
// Configuration for hasura service
|
||||
#Hasura: {
|
||||
// Version of hasura, you can see available versions in the URL below:
|
||||
// https://hub.docker.com/r/hasura/graphql-engine/tags
|
||||
version: string | *"v2.46.0-ce"
|
||||
|
||||
// JWT Secrets configuration
|
||||
jwtSecrets: [#JWTSecret]
|
||||
|
||||
// Admin secret
|
||||
adminSecret: string
|
||||
|
||||
// Webhook secret
|
||||
webhookSecret: string
|
||||
|
||||
// Configuration for hasura services
|
||||
// Reference: https://hasura.io/docs/latest/deployment/graphql-engine-flags/reference/
|
||||
settings: {
|
||||
// HASURA_GRAPHQL_CORS_DOMAIN
|
||||
corsDomain: [...#Url] | *["*"]
|
||||
// HASURA_GRAPHQL_DEV_MODE
|
||||
devMode: bool | *true
|
||||
// HASURA_GRAPHQL_ENABLE_ALLOWLIST
|
||||
enableAllowList: bool | *false
|
||||
// HASURA_GRAPHQL_ENABLE_CONSOLE
|
||||
enableConsole: bool | *true
|
||||
// HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS
|
||||
enableRemoteSchemaPermissions: bool | *false
|
||||
// HASURA_GRAPHQL_ENABLED_APIS
|
||||
enabledAPIs: [...#HasuraAPIs] | *["metadata", "graphql", "pgdump", "config"]
|
||||
|
||||
// HASURA_GRAPHQL_INFER_FUNCTION_PERMISSIONS
|
||||
inferFunctionPermissions: bool | *true
|
||||
|
||||
// HASURA_GRAPHQL_LIVE_QUERIES_MULTIPLEXED_REFETCH_INTERVAL
|
||||
liveQueriesMultiplexedRefetchInterval: uint32 | *1000
|
||||
|
||||
// HASURA_GRAPHQL_STRINGIFY_NUMERIC_TYPES
|
||||
stringifyNumericTypes: bool | *false
|
||||
}
|
||||
|
||||
authHook?: {
|
||||
// HASURA_GRAPHQL_AUTH_HOOK
|
||||
url: string
|
||||
|
||||
// HASURA_GRAPHQL_AUTH_HOOK_MODE
|
||||
mode: "GET" | *"POST"
|
||||
|
||||
// HASURA_GRAPHQL_AUTH_HOOK_SEND_REQUEST_BODY
|
||||
sendRequestBody: bool | *true
|
||||
}
|
||||
|
||||
logs: {
|
||||
// HASURA_GRAPHQL_LOG_LEVEL
|
||||
level: "debug" | "info" | "error" | *"warn"
|
||||
}
|
||||
|
||||
events: {
|
||||
// HASURA_GRAPHQL_EVENTS_HTTP_POOL_SIZE
|
||||
httpPoolSize: uint32 & >=1 & <=100 | *100
|
||||
}
|
||||
|
||||
// Resources for the service
|
||||
resources?: #Resources
|
||||
|
||||
rateLimit?: #RateLimit
|
||||
}
|
||||
|
||||
// APIs for hasura
|
||||
#HasuraAPIs: "metadata" | "graphql" | "pgdump" | "config"
|
||||
|
||||
// Configuration for storage service
|
||||
#Storage: {
|
||||
// Version of storage service, you can see available versions in the URL below:
|
||||
// https://hub.docker.com/r/nhost/hasura-storage/tags
|
||||
//
|
||||
// Releases:
|
||||
//
|
||||
// https://github.com/nhost/hasura-storage/releases
|
||||
version: string | *"0.7.2"
|
||||
|
||||
// Networking (custom domains at the moment) are not allowed as we need to do further
|
||||
// configurations in the CDN. We will enable it again in the future.
|
||||
resources?: #Resources & {networking?: null}
|
||||
|
||||
antivirus?: {
|
||||
server: "tcp://run-clamav:3310"
|
||||
}
|
||||
|
||||
rateLimit?: #RateLimit
|
||||
}
|
||||
|
||||
// Configuration for functions service
|
||||
#Functions: {
|
||||
node: {
|
||||
version: 20 | *22
|
||||
}
|
||||
|
||||
resources?: {
|
||||
networking?: #Networking
|
||||
}
|
||||
|
||||
rateLimit?: #RateLimit
|
||||
}
|
||||
|
||||
// Configuration for postgres service
|
||||
#Postgres: {
|
||||
// Version of postgres, you can see available versions in the URL below:
|
||||
// https://hub.docker.com/r/nhost/postgres/tags
|
||||
version: string | *"14.18-20250728-1"
|
||||
|
||||
// Resources for the service
|
||||
resources: {
|
||||
compute?: #ResourcesCompute
|
||||
storage: {
|
||||
capacity: uint32 & >=1 & <=1000 // GiB
|
||||
}
|
||||
|
||||
replicas?: 1
|
||||
|
||||
enablePublicAccess?: bool | *false
|
||||
}
|
||||
|
||||
settings?: {
|
||||
jit: "off" | "on" | *"on"
|
||||
maxConnections: int32 | *100
|
||||
sharedBuffers: string | *"128MB"
|
||||
effectiveCacheSize: string | *"4GB"
|
||||
maintenanceWorkMem: string | *"64MB"
|
||||
checkpointCompletionTarget: number | *0.9
|
||||
walBuffers: string | *"-1"
|
||||
defaultStatisticsTarget: int32 | *100
|
||||
randomPageCost: number | *4.0
|
||||
effectiveIOConcurrency: int32 | *1
|
||||
workMem: string | *"4MB"
|
||||
hugePages: string | *"try"
|
||||
minWalSize: string | *"80MB"
|
||||
maxWalSize: string | *"1GB"
|
||||
maxWorkerProcesses: int32 | *8
|
||||
maxParallelWorkersPerGather: int32 | *2
|
||||
maxParallelWorkers: int32 | *8
|
||||
maxParallelMaintenanceWorkers: int32 | *2
|
||||
walLevel: string | *"replica"
|
||||
maxWalSenders: int32 | *10
|
||||
maxReplicationSlots: int32 | *10
|
||||
archiveTimeout: int32 & >=300 & <=1073741823 | *300
|
||||
trackIoTiming: "on" | *"off"
|
||||
|
||||
// if pitr is on we need walLevel to set to replica or logical
|
||||
_validateWalLevelIsLogicalOrReplicaIfPitrIsEnabled: ( pitr == _|_ | walLevel == "replica" | walLevel == "logical") & true @cuegraph(skip)
|
||||
}
|
||||
|
||||
pitr?: {
|
||||
retention: uint8 & 7
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration for auth service
|
||||
// You can find more information about the configuration here:
|
||||
// https://github.com/nhost/hasura-auth/blob/main/docs/environment-variables.md
|
||||
#Auth: {
|
||||
// Version of auth, you can see available versions in the URL below:
|
||||
// https://hub.docker.com/r/nhost/hasura-auth/tags
|
||||
//
|
||||
// Releases:
|
||||
//
|
||||
// https://github.com/nhost/hasura-auth/releases
|
||||
version: string | *"0.38.1"
|
||||
|
||||
// Resources for the service
|
||||
resources?: #Resources
|
||||
|
||||
elevatedPrivileges: {
|
||||
mode: "recommended" | "required" | *"disabled"
|
||||
}
|
||||
|
||||
redirections: {
|
||||
// AUTH_CLIENT_URL
|
||||
clientUrl: #Url | *"http://localhost:3000"
|
||||
// AUTH_ACCESS_CONTROL_ALLOWED_REDIRECT_URLS
|
||||
allowedUrls: [...string]
|
||||
}
|
||||
|
||||
signUp: {
|
||||
// Inverse of AUTH_DISABLE_SIGNUP
|
||||
enabled: bool | *true
|
||||
|
||||
// AUTH_DISABLE_NEW_USERS
|
||||
disableNewUsers: bool | *false
|
||||
|
||||
turnstile?: {
|
||||
secretKey: string
|
||||
}
|
||||
}
|
||||
|
||||
user: {
|
||||
roles: {
|
||||
// AUTH_USER_DEFAULT_ROLE
|
||||
default: #UserRole | *"user"
|
||||
// AUTH_USER_DEFAULT_ALLOWED_ROLES
|
||||
allowed: [...#UserRole] | *[default, "me"]
|
||||
}
|
||||
locale: {
|
||||
// AUTH_LOCALE_DEFAULT
|
||||
default: #Locale | *"en"
|
||||
// AUTH_LOCALE_ALLOWED_LOCALES
|
||||
allowed: [...#Locale] | *[default]
|
||||
}
|
||||
|
||||
gravatar: {
|
||||
// AUTH_GRAVATAR_ENABLED
|
||||
enabled: bool | *true
|
||||
// AUTH_GRAVATAR_DEFAULT
|
||||
default: "404" | "mp" | "identicon" | "monsterid" | "wavatar" | "retro" | "robohash" | *"blank"
|
||||
// AUTH_GRAVATAR_RATING
|
||||
rating: "pg" | "r" | "x" | *"g"
|
||||
}
|
||||
email: {
|
||||
// AUTH_ACCESS_CONTROL_ALLOWED_EMAILS
|
||||
allowed: [...#Email]
|
||||
// AUTH_ACCESS_CONTROL_BLOCKED_EMAILS
|
||||
blocked: [...#Email]
|
||||
|
||||
}
|
||||
emailDomains: {
|
||||
// AUTH_ACCESS_CONTROL_ALLOWED_EMAIL_DOMAINS
|
||||
allowed: [...string & net.FQDN]
|
||||
// AUTH_ACCESS_CONTROL_BLOCKED_EMAIL_DOMAINS
|
||||
blocked: [...string & net.FQDN]
|
||||
}
|
||||
}
|
||||
|
||||
session: {
|
||||
accessToken: {
|
||||
// AUTH_ACCESS_TOKEN_EXPIRES_IN
|
||||
expiresIn: uint32 | *900
|
||||
// AUTH_JWT_CUSTOM_CLAIMS
|
||||
customClaims: [...{
|
||||
key: =~"[a-zA-Z_]{1,}[a-zA-Z0-9_]*"
|
||||
value: string
|
||||
default?: string
|
||||
}] | *[]
|
||||
}
|
||||
|
||||
refreshToken: {
|
||||
// AUTH_REFRESH_TOKEN_EXPIRES_IN
|
||||
expiresIn: uint32 | *2592000
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
method: {
|
||||
anonymous: {
|
||||
enabled: bool | *false
|
||||
}
|
||||
|
||||
emailPasswordless: {
|
||||
enabled: bool | *false
|
||||
}
|
||||
|
||||
otp: {
|
||||
email: {
|
||||
enabled: bool | *false
|
||||
}
|
||||
}
|
||||
|
||||
emailPassword: {
|
||||
// Disabling email+password sign in is not implmented yet
|
||||
// enabled: bool | *true
|
||||
hibpEnabled: bool | *false
|
||||
emailVerificationRequired: bool | *true
|
||||
passwordMinLength: uint8 & >=3 | *9
|
||||
}
|
||||
|
||||
smsPasswordless: {
|
||||
enabled: bool | *false
|
||||
}
|
||||
|
||||
oauth: {
|
||||
apple: {
|
||||
enabled: bool | *false
|
||||
if enabled {
|
||||
clientId: string
|
||||
keyId: string
|
||||
teamId: string
|
||||
privateKey: string
|
||||
}
|
||||
if !enabled {
|
||||
clientId?: string
|
||||
keyId?: string
|
||||
teamId?: string
|
||||
privateKey?: string
|
||||
}
|
||||
audience?: string
|
||||
scope?: [...string]
|
||||
}
|
||||
azuread: {
|
||||
#StandardOauthProvider
|
||||
tenant: string | *"common"
|
||||
}
|
||||
bitbucket: #StandardOauthProvider
|
||||
discord: #StandardOauthProviderWithScope
|
||||
entraid: {
|
||||
#StandardOauthProvider
|
||||
tenant: string | *"common"
|
||||
}
|
||||
facebook: #StandardOauthProviderWithScope
|
||||
github: #StandardOauthProviderWithScope
|
||||
gitlab: #StandardOauthProviderWithScope
|
||||
google: #StandardOauthProviderWithScope
|
||||
linkedin: #StandardOauthProviderWithScope
|
||||
spotify: #StandardOauthProviderWithScope
|
||||
strava: #StandardOauthProviderWithScope
|
||||
twitch: #StandardOauthProviderWithScope
|
||||
twitter: {
|
||||
enabled: bool | *false
|
||||
if enabled {
|
||||
consumerKey: string
|
||||
consumerSecret: string
|
||||
}
|
||||
if !enabled {
|
||||
consumerKey?: string
|
||||
consumerSecret?: string
|
||||
}
|
||||
}
|
||||
windowslive: #StandardOauthProviderWithScope
|
||||
workos: {
|
||||
#StandardOauthProvider
|
||||
connection?: string
|
||||
organization?: string
|
||||
}
|
||||
}
|
||||
|
||||
webauthn: {
|
||||
enabled: bool | *false
|
||||
relyingParty?: {
|
||||
id: string | *""
|
||||
name?: string
|
||||
origins?: [...#Url] | *[redirections.clientUrl]
|
||||
}
|
||||
attestation: {
|
||||
timeout: uint32 | *60000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
totp: {
|
||||
enabled: bool | *false
|
||||
if enabled {
|
||||
issuer: string
|
||||
}
|
||||
if !enabled {
|
||||
issuer?: string
|
||||
}
|
||||
}
|
||||
|
||||
misc: {
|
||||
concealErrors: bool | *false
|
||||
}
|
||||
|
||||
rateLimit: #AuthRateLimit
|
||||
}
|
||||
|
||||
#RateLimit: {
|
||||
limit: uint32
|
||||
interval: string & time.Duration
|
||||
}
|
||||
|
||||
#AuthRateLimit: {
|
||||
emails: #RateLimit | *{limit: 10, interval: "1h"}
|
||||
sms: #RateLimit | *{limit: 10, interval: "1h"}
|
||||
bruteForce: #RateLimit | *{limit: 10, interval: "5m"}
|
||||
signups: #RateLimit | *{limit: 10, interval: "5m"}
|
||||
global: #RateLimit | *{limit: 100, interval: "1m"}
|
||||
}
|
||||
|
||||
#StandardOauthProvider: {
|
||||
enabled: bool | *false
|
||||
if enabled {
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
}
|
||||
if !enabled {
|
||||
clientId?: string
|
||||
clientSecret?: string
|
||||
}
|
||||
}
|
||||
|
||||
#StandardOauthProviderWithScope: {
|
||||
enabled: bool | *false
|
||||
if enabled {
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
}
|
||||
if !enabled {
|
||||
clientId?: string
|
||||
clientSecret?: string
|
||||
}
|
||||
audience?: string
|
||||
scope?: [...string]
|
||||
}
|
||||
|
||||
#Provider: {
|
||||
smtp?: #Smtp
|
||||
sms?: #Sms
|
||||
}
|
||||
|
||||
#Smtp: {
|
||||
user: string
|
||||
password: string
|
||||
sender: string
|
||||
host: string & net.FQDN | net.IP
|
||||
port: #Port
|
||||
secure: bool
|
||||
method: "LOGIN" | "CRAM-MD5" | "PLAIN"
|
||||
}
|
||||
|
||||
#Sms: {
|
||||
provider: "twilio"
|
||||
accountSid: string
|
||||
authToken: string
|
||||
messagingServiceId: string
|
||||
}
|
||||
|
||||
#UserRole: string
|
||||
#Url: string
|
||||
#Port: uint16
|
||||
#Email: =~"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
|
||||
#Locale: string & strings.MinRunes(2) & strings.MaxRunes(2)
|
||||
|
||||
// See https://hasura.io/docs/latest/auth/authentication/jwt/
|
||||
#JWTSecret:
|
||||
({
|
||||
type: "HS384" | "HS512" | *"HS256"
|
||||
key: string
|
||||
} |
|
||||
{
|
||||
type: "RS256" | "RS384" | "RS512"
|
||||
key: string
|
||||
signingKey?: string
|
||||
kid?: string
|
||||
} |
|
||||
{
|
||||
jwk_url: #Url | *null
|
||||
}) &
|
||||
{
|
||||
claims_format?: "stringified_json" | *"json"
|
||||
audience?: string
|
||||
issuer?: string
|
||||
allowed_skew?: uint32
|
||||
header?: string
|
||||
} & {
|
||||
claims_map?: [...#ClaimMap]
|
||||
|
||||
} &
|
||||
({
|
||||
claims_namespace: string | *"https://hasura.io/jwt/claims"
|
||||
} |
|
||||
{
|
||||
claims_namespace_path: string
|
||||
} | *{})
|
||||
|
||||
#ClaimMap: {
|
||||
claim: string
|
||||
{
|
||||
value: string
|
||||
} | {
|
||||
path: string
|
||||
default?: string
|
||||
}
|
||||
} & {}
|
||||
|
||||
#SystemConfig: {
|
||||
auth: {
|
||||
email: {
|
||||
templates: {
|
||||
s3Key?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
graphql: {
|
||||
// manually enable graphi on a per-service basis
|
||||
// by default it follows the plan
|
||||
featureAdvancedGraphql: bool | *false
|
||||
}
|
||||
|
||||
postgres: {
|
||||
enabled: bool | *true
|
||||
majorVersion: "14" | "15" | "16" | "17" | *"14"
|
||||
if enabled {
|
||||
database: string
|
||||
}
|
||||
if !enabled {
|
||||
database?: string
|
||||
}
|
||||
connectionString: {
|
||||
backup: string
|
||||
hasura: string
|
||||
auth: string
|
||||
storage: string
|
||||
}
|
||||
|
||||
disk?: {
|
||||
iops: uint32 | *3000
|
||||
tput: uint32 | *125
|
||||
}
|
||||
}
|
||||
|
||||
persistentVolumesEncrypted: bool | *false
|
||||
}
|
||||
|
||||
#AI: {
|
||||
version: string | *"0.8.0"
|
||||
resources: {
|
||||
compute: #ComputeResources
|
||||
}
|
||||
|
||||
openai: {
|
||||
organization?: string
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
autoEmbeddings: {
|
||||
synchPeriodMinutes: uint32 | *5
|
||||
}
|
||||
|
||||
webhookSecret: string
|
||||
}
|
||||
|
||||
#Observability: {
|
||||
grafana: #Grafana
|
||||
}
|
||||
|
||||
#Grafana: {
|
||||
adminPassword: string
|
||||
|
||||
smtp?: {
|
||||
host: string & net.FQDN | net.IP
|
||||
port: #Port
|
||||
sender: string
|
||||
user: string
|
||||
password: string
|
||||
}
|
||||
|
||||
alerting: {
|
||||
enabled: bool | *false
|
||||
}
|
||||
|
||||
contacts: {
|
||||
emails?: [...string]
|
||||
pagerduty?: [...{
|
||||
integrationKey: string
|
||||
severity: string
|
||||
class: string
|
||||
component: string
|
||||
group: string
|
||||
}]
|
||||
discord?: [...{
|
||||
url: string
|
||||
avatarUrl: string
|
||||
}]
|
||||
slack?: [...{
|
||||
recipient: string
|
||||
token: string
|
||||
username: string
|
||||
iconEmoji: string
|
||||
iconURL: string
|
||||
mentionUsers: [...string]
|
||||
mentionGroups: [...string]
|
||||
mentionChannel: string
|
||||
url: string
|
||||
endpointURL: string
|
||||
}]
|
||||
webhook?: [...{
|
||||
url: string
|
||||
httpMethod: string
|
||||
username: string
|
||||
password: string
|
||||
authorizationScheme: string
|
||||
authorizationCredentials: string
|
||||
maxAlerts: int
|
||||
}]
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
#RunServicePort: {
|
||||
port: #Port
|
||||
type: "http" | "grpc" | "tcp" | "udp"
|
||||
publish: bool | *false
|
||||
ingresses: [#Ingress] | *[]
|
||||
_publish_supported_only_over_http: (
|
||||
publish == false || type == "http" || type == "grpc" ) & true @cuegraph(skip)
|
||||
|
||||
rateLimit?: #RateLimit
|
||||
}
|
||||
|
||||
#RunServiceName: =~"^[a-z]([-a-z0-9]*[a-z0-9])?$" & strings.MinRunes(1) & strings.MaxRunes(30)
|
||||
|
||||
// Resource configuration for a service
|
||||
#ComputeResources: {
|
||||
// milicpus, 1000 milicpus = 1 cpu
|
||||
cpu: uint32 & >=62 & <=14000
|
||||
// MiB: 128MiB to 30GiB
|
||||
memory: uint32 & >=128 & <=28720
|
||||
|
||||
// validate memory steps of 128 MiB
|
||||
_validateMemorySteps128: (mod(memory, 128) == 0) & true @cuegraph(skip)
|
||||
}
|
||||
|
||||
// Resource configuration for a service
|
||||
#RunServiceResources: {
|
||||
compute: #ComputeResources
|
||||
|
||||
storage: [...{
|
||||
name: #RunServiceName // name of the volume, changing it will cause data loss
|
||||
capacity: uint32 & >=1 & <=1000 // GiB
|
||||
path: string
|
||||
}] | *[]
|
||||
_storage_name_must_be_unique: list.UniqueItems([for s in storage {s.name}]) & true @cuegraph(skip)
|
||||
_storage_path_must_be_unique: list.UniqueItems([for s in storage {s.path}]) & true @cuegraph(skip)
|
||||
|
||||
// Number of replicas for a service
|
||||
replicas: uint8 & <=10
|
||||
|
||||
autoscaler?: #Autoscaler
|
||||
|
||||
_validateReplicasMustBeSmallerThanMaxReplicas: (replicas <= autoscaler.maxReplicas) & true @cuegraph(skip)
|
||||
|
||||
_replcas_cant_be_greater_than_1_when_using_storage: (len(storage) == 0 | (len(storage) > 0 & replicas <= 1 && autoscaler == _|_)) & true @cuegraph(skip)
|
||||
|
||||
_validate_cpu_memory_ratio_must_be_1_for_2: (math.Abs(compute.memory-compute.cpu*2.048) <= 1.024) & true @cuegraph(skip)
|
||||
}
|
||||
|
||||
#RunServiceImage: {
|
||||
image: string
|
||||
// content of "auths", i.e., { "auths": $THIS }
|
||||
pullCredentials?: string
|
||||
}
|
||||
|
||||
#HealthCheck: {
|
||||
port: #Port
|
||||
initialDelaySeconds: int | *30
|
||||
probePeriodSeconds: int | *60
|
||||
}
|
||||
|
||||
#EnvironmentVariable: {
|
||||
// Name of the environment variable
|
||||
name: =~"(?i)^[a-z_]{1,}[a-z0-9_]*"
|
||||
// Value of the environment variable
|
||||
value: string
|
||||
}
|
||||
|
||||
#RunServiceConfig: {
|
||||
name: #RunServiceName
|
||||
image: #RunServiceImage
|
||||
command: [...string]
|
||||
environment: [...#EnvironmentVariable] | *[]
|
||||
ports?: [...#RunServicePort] | *[]
|
||||
resources: #RunServiceResources
|
||||
healthCheck?: #HealthCheck
|
||||
}
|
||||
40
cli/mcp/resources/resources.go
Normal 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
|
||||
}
|
||||
@@ -2,18 +2,11 @@ package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"net/http"
|
||||
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
//go:embed schema.graphql
|
||||
var schemaGraphql string
|
||||
|
||||
//go:embed schema-with-mutations.graphql
|
||||
var schemaGraphqlWithMutations string
|
||||
|
||||
type Tool struct {
|
||||
graphqlURL string
|
||||
withMutations bool
|
||||
@@ -33,7 +26,6 @@ func NewTool(
|
||||
}
|
||||
|
||||
func (t *Tool) Register(mcpServer *server.MCPServer) error {
|
||||
t.registerGetGraphqlSchema(mcpServer)
|
||||
t.registerGraphqlQuery(mcpServer)
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
const (
|
||||
ToolGetGraphqlSchemaName = "cloud-get-graphql-schema"
|
||||
//nolint:lll
|
||||
ToolGetGraphqlSchemaInstructions = `Get GraphQL schema for the Nhost Cloud allowing operations on projects and organizations. Retrieve the schema before using the tool to understand the available queries and mutations. Projects are equivalent to apps in the schema. IDs are typically uuids`
|
||||
)
|
||||
|
||||
func (t *Tool) registerGetGraphqlSchema(mcpServer *server.MCPServer) {
|
||||
schemaTool := mcp.NewTool(
|
||||
ToolGetGraphqlSchemaName,
|
||||
mcp.WithDescription(ToolGetGraphqlSchemaInstructions),
|
||||
mcp.WithToolAnnotation(
|
||||
mcp.ToolAnnotation{
|
||||
Title: "Get GraphQL Schema for Nhost Cloud Platform",
|
||||
ReadOnlyHint: ptr(true),
|
||||
DestructiveHint: ptr(false),
|
||||
IdempotentHint: ptr(true),
|
||||
OpenWorldHint: ptr(true),
|
||||
},
|
||||
),
|
||||
)
|
||||
mcpServer.AddTool(schemaTool, t.handleGetGraphqlSchema)
|
||||
}
|
||||
|
||||
func (t *Tool) handleGetGraphqlSchema(
|
||||
_ context.Context, _ mcp.CallToolRequest,
|
||||
) (*mcp.CallToolResult, error) {
|
||||
schema := schemaGraphql
|
||||
if t.withMutations {
|
||||
schema = schemaGraphqlWithMutations
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText(schema), nil
|
||||
}
|
||||
@@ -25,7 +25,6 @@ type GraphqlQueryRequest struct {
|
||||
}
|
||||
|
||||
func (t *Tool) registerGraphqlQuery(mcpServer *server.MCPServer) {
|
||||
t.registerGetGraphqlSchema(mcpServer)
|
||||
queryTool := mcp.NewTool(
|
||||
ToolGraphqlQueryName,
|
||||
mcp.WithDescription(ToolGraphqlQueryInstructions),
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"github.com/nhost/nhost/cli/mcp/graphql"
|
||||
)
|
||||
|
||||
const (
|
||||
ToolConfigServerSchemaName = "local-config-server-get-schema"
|
||||
//nolint:lll
|
||||
ToolConfigServerSchemaInstructions = `Get GraphQL schema for the local config server. This tool is useful when the user is developing a project and wants help changing the project's settings.`
|
||||
)
|
||||
|
||||
func (t *Tool) registerGetConfigServerSchema(mcpServer *server.MCPServer) {
|
||||
configServerSchemaTool := mcp.NewTool(
|
||||
ToolConfigServerSchemaName,
|
||||
mcp.WithDescription(ToolConfigServerSchemaInstructions),
|
||||
mcp.WithToolAnnotation(
|
||||
mcp.ToolAnnotation{
|
||||
Title: "Get GraphQL Schema for Nhost Config Server",
|
||||
ReadOnlyHint: ptr(true),
|
||||
DestructiveHint: ptr(false),
|
||||
IdempotentHint: ptr(true),
|
||||
OpenWorldHint: ptr(true),
|
||||
},
|
||||
),
|
||||
mcp.WithBoolean(
|
||||
"includeQueries",
|
||||
mcp.Description("include queries in the schema"),
|
||||
mcp.Required(),
|
||||
),
|
||||
mcp.WithBoolean(
|
||||
"includeMutations",
|
||||
mcp.Description("include mutations in the schema"),
|
||||
mcp.Required(),
|
||||
),
|
||||
)
|
||||
mcpServer.AddTool(
|
||||
configServerSchemaTool,
|
||||
mcp.NewStructuredToolHandler(t.handleConfigGetServerSchema),
|
||||
)
|
||||
}
|
||||
|
||||
type ConfigServerGetSchemaRequest struct {
|
||||
IncludeQueries bool `json:"includeQueries"`
|
||||
IncludeMutations bool `json:"includeMutations"`
|
||||
}
|
||||
|
||||
func (t *Tool) handleConfigGetServerSchema(
|
||||
ctx context.Context, _ mcp.CallToolRequest, args ConfigServerGetSchemaRequest,
|
||||
) (*mcp.CallToolResult, error) {
|
||||
var introspection graphql.ResponseIntrospection
|
||||
if err := graphql.Query(
|
||||
ctx,
|
||||
t.configServerURL,
|
||||
graphql.IntrospectionQuery,
|
||||
nil,
|
||||
&introspection,
|
||||
nil,
|
||||
nil,
|
||||
t.interceptors...,
|
||||
); err != nil {
|
||||
return mcp.NewToolResultErrorFromErr("failed to query GraphQL schema", err), nil
|
||||
}
|
||||
|
||||
var allowQueries, allowMutations []graphql.Queries
|
||||
if !args.IncludeQueries {
|
||||
allowQueries = []graphql.Queries{}
|
||||
}
|
||||
|
||||
if !args.IncludeMutations {
|
||||
allowMutations = []graphql.Queries{}
|
||||
}
|
||||
|
||||
schema := graphql.ParseSchema(
|
||||
introspection,
|
||||
graphql.Filter{
|
||||
AllowQueries: allowQueries,
|
||||
AllowMutations: allowMutations,
|
||||
},
|
||||
)
|
||||
|
||||
return mcp.NewToolResultText(schema), nil
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"github.com/nhost/nhost/cli/mcp/graphql"
|
||||
)
|
||||
|
||||
const (
|
||||
ToolConfigServerQueryName = "local-config-server-query"
|
||||
//nolint:lll
|
||||
ToolConfigServerQueryInstructions = `Execute a GraphQL query against the local config server. This tool is useful to query and perform configuration changes on the local development project. Before using this tool, make sure to get the schema using the local-config-server-schema tool. To perform configuration changes this endpoint is all you need but to apply them you need to run 'nhost up' again. Ask the user for input when you need information about settings, for instance if the user asks to enable some oauth2 method and you need the client id or secret.`
|
||||
)
|
||||
|
||||
type ConfigServerQueryRequest struct {
|
||||
Query string `json:"query"`
|
||||
Variables map[string]any `json:"variables,omitempty"`
|
||||
}
|
||||
|
||||
func (t *Tool) registerConfigServerQuery(mcpServer *server.MCPServer) {
|
||||
configServerQueryTool := mcp.NewTool(
|
||||
ToolConfigServerQueryName,
|
||||
mcp.WithDescription(ToolConfigServerQueryInstructions),
|
||||
mcp.WithToolAnnotation(
|
||||
mcp.ToolAnnotation{
|
||||
Title: "Perform GraphQL Query on Nhost Config Server",
|
||||
ReadOnlyHint: ptr(false),
|
||||
DestructiveHint: ptr(true),
|
||||
IdempotentHint: ptr(false),
|
||||
OpenWorldHint: ptr(true),
|
||||
},
|
||||
),
|
||||
mcp.WithString(
|
||||
"query",
|
||||
mcp.Description("graphql query to perform"),
|
||||
mcp.Required(),
|
||||
),
|
||||
mcp.WithString(
|
||||
"variables",
|
||||
mcp.Description("variables to use in the query"),
|
||||
),
|
||||
)
|
||||
mcpServer.AddTool(
|
||||
configServerQueryTool,
|
||||
mcp.NewStructuredToolHandler(t.handleConfigServerQuery),
|
||||
)
|
||||
}
|
||||
|
||||
func (t *Tool) handleConfigServerQuery(
|
||||
ctx context.Context, _ mcp.CallToolRequest, args ConfigServerQueryRequest,
|
||||
) (*mcp.CallToolResult, error) {
|
||||
if args.Query == "" {
|
||||
return mcp.NewToolResultError("query is required"), nil
|
||||
}
|
||||
|
||||
var resp graphql.Response[any]
|
||||
if err := graphql.Query(
|
||||
ctx,
|
||||
t.configServerURL,
|
||||
args.Query,
|
||||
args.Variables,
|
||||
&resp,
|
||||
nil,
|
||||
nil,
|
||||
t.interceptors...,
|
||||
); err != nil {
|
||||
return mcp.NewToolResultErrorFromErr("failed to query graphql endpoint", err), nil
|
||||
}
|
||||
|
||||
b, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultErrorFromErr("error marshalling response", err), nil
|
||||
}
|
||||
|
||||
return mcp.NewToolResultStructured(resp, string(b)), nil
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"github.com/nhost/nhost/cli/mcp/nhost/graphql"
|
||||
)
|
||||
|
||||
const (
|
||||
ToolGetGraphqlManagementSchemaName = "local-get-management-graphql-schema"
|
||||
ToolGetGraphqlManagementSchemaInstructions = `
|
||||
Get GraphQL's management schema for an Nhost development project running locally via the Nhost
|
||||
CLI. This tool is useful to properly understand how manage hasura metadata, migrations,
|
||||
permissions, remote schemas, etc.
|
||||
|
||||
Use it before attempting to use ` + ToolManageGraphqlName
|
||||
)
|
||||
|
||||
func (t *Tool) registerGetGraphqlManagementSchema(mcpServer *server.MCPServer) {
|
||||
schemaTool := mcp.NewTool(
|
||||
ToolGetGraphqlManagementSchemaName,
|
||||
mcp.WithDescription(ToolGetGraphqlManagementSchemaInstructions),
|
||||
mcp.WithToolAnnotation(
|
||||
mcp.ToolAnnotation{
|
||||
Title: "Get GraphQL's Management Schema for Nhost Development Project",
|
||||
ReadOnlyHint: ptr(true),
|
||||
DestructiveHint: ptr(false),
|
||||
IdempotentHint: ptr(true),
|
||||
OpenWorldHint: ptr(true),
|
||||
},
|
||||
),
|
||||
)
|
||||
mcpServer.AddTool(schemaTool, t.handleGetGraphqlManagementSchema)
|
||||
}
|
||||
|
||||
func (t *Tool) handleGetGraphqlManagementSchema(
|
||||
_ context.Context, _ mcp.CallToolRequest,
|
||||
) (*mcp.CallToolResult, error) {
|
||||
return mcp.NewToolResultText(graphql.Schema), nil
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"github.com/nhost/nhost/cli/mcp/graphql"
|
||||
"github.com/nhost/nhost/cli/mcp/nhost/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
ToolGetGraphqlSchemaName = "local-get-graphql-schema"
|
||||
//nolint:lll
|
||||
ToolGetGraphqlSchemaInstructions = `Get GraphQL schema for an Nhost development project running locally via the Nhost CLI. This tool is useful when the user is developing a project and wants help generating code to interact with their project's Graphql schema.`
|
||||
)
|
||||
|
||||
type GetGraphqlSchemaRequest struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
func (t *Tool) registerGetGraphqlSchema(mcpServer *server.MCPServer) {
|
||||
schemaTool := mcp.NewTool(
|
||||
ToolGetGraphqlSchemaName,
|
||||
mcp.WithDescription(ToolGetGraphqlSchemaInstructions),
|
||||
mcp.WithToolAnnotation(
|
||||
mcp.ToolAnnotation{
|
||||
Title: "Get GraphQL Schema for Nhost Development Project",
|
||||
ReadOnlyHint: ptr(true),
|
||||
DestructiveHint: ptr(false),
|
||||
IdempotentHint: ptr(true),
|
||||
OpenWorldHint: ptr(true),
|
||||
},
|
||||
),
|
||||
mcp.WithString(
|
||||
"role",
|
||||
mcp.Description(
|
||||
"role to use when executing queries. Default to user but make sure the user is aware",
|
||||
),
|
||||
mcp.Required(),
|
||||
),
|
||||
)
|
||||
mcpServer.AddTool(schemaTool, mcp.NewStructuredToolHandler(t.handleGetGraphqlSchema))
|
||||
}
|
||||
|
||||
func (t *Tool) handleGetGraphqlSchema(
|
||||
ctx context.Context, _ mcp.CallToolRequest, args GetGraphqlSchemaRequest,
|
||||
) (*mcp.CallToolResult, error) {
|
||||
if args.Role == "" {
|
||||
return mcp.NewToolResultError("role is required"), nil
|
||||
}
|
||||
|
||||
interceptors := append( //nolint:gocritic
|
||||
t.interceptors,
|
||||
auth.WithRole(args.Role),
|
||||
)
|
||||
|
||||
var introspection graphql.ResponseIntrospection
|
||||
if err := graphql.Query(
|
||||
ctx,
|
||||
t.graphqlURL,
|
||||
graphql.IntrospectionQuery,
|
||||
nil,
|
||||
&introspection,
|
||||
nil,
|
||||
nil,
|
||||
interceptors...,
|
||||
); err != nil {
|
||||
return mcp.NewToolResultErrorFromErr("failed to query GraphQL schema", err), nil
|
||||
}
|
||||
|
||||
schema := graphql.ParseSchema(
|
||||
introspection,
|
||||
graphql.Filter{
|
||||
AllowQueries: nil,
|
||||
AllowMutations: nil,
|
||||
},
|
||||
)
|
||||
|
||||
return mcp.NewToolResultText(schema), nil
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
type Tool struct {
|
||||
graphqlURL string
|
||||
configServerURL string
|
||||
interceptors []func(ctx context.Context, req *http.Request) error
|
||||
}
|
||||
|
||||
func NewTool(
|
||||
graphqlURL string,
|
||||
configServerURL string,
|
||||
interceptors ...func(ctx context.Context, req *http.Request) error,
|
||||
) *Tool {
|
||||
return &Tool{
|
||||
graphqlURL: graphqlURL,
|
||||
configServerURL: configServerURL,
|
||||
interceptors: interceptors,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tool) Register(mcpServer *server.MCPServer) error {
|
||||
t.registerGetGraphqlSchema(mcpServer)
|
||||
t.registerGraphqlQuery(mcpServer)
|
||||
t.registerGetConfigServerSchema(mcpServer)
|
||||
t.registerConfigServerQuery(mcpServer)
|
||||
t.registerGetGraphqlManagementSchema(mcpServer)
|
||||
t.registerManageGraphql(mcpServer)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"github.com/nhost/nhost/cli/mcp/graphql"
|
||||
"github.com/nhost/nhost/cli/mcp/nhost/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
ToolGraphqlQueryName = "local-graphql-query"
|
||||
//nolint:lll
|
||||
ToolGraphqlQueryInstructions = `Execute a GraphQL query against an Nhost development project running locally via the Nhost CLI. This tool is useful to test queries and mutations during development. If you run into issues executing queries, retrieve the schema using the local-get-graphql-schema tool in case the schema has changed.`
|
||||
)
|
||||
|
||||
func ptr[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
|
||||
type GraphqlQueryRequest struct {
|
||||
Query string `json:"query"`
|
||||
Variables map[string]any `json:"variables,omitempty"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
func (t *Tool) registerGraphqlQuery(mcpServer *server.MCPServer) {
|
||||
queryTool := mcp.NewTool(
|
||||
ToolGraphqlQueryName,
|
||||
mcp.WithDescription(ToolGraphqlQueryInstructions),
|
||||
mcp.WithToolAnnotation(
|
||||
mcp.ToolAnnotation{
|
||||
Title: "Perform GraphQL Query on Nhost Development Project",
|
||||
ReadOnlyHint: ptr(false),
|
||||
DestructiveHint: ptr(true),
|
||||
IdempotentHint: ptr(false),
|
||||
OpenWorldHint: ptr(true),
|
||||
},
|
||||
),
|
||||
mcp.WithString(
|
||||
"query",
|
||||
mcp.Description("graphql query to perform"),
|
||||
mcp.Required(),
|
||||
),
|
||||
mcp.WithObject(
|
||||
"variables",
|
||||
mcp.Description("variables to use in the query"),
|
||||
mcp.AdditionalProperties(true),
|
||||
),
|
||||
mcp.WithString(
|
||||
"role",
|
||||
mcp.Description(
|
||||
"role to use when executing queries. Default to user but make sure the user is aware. Keep in mind the schema depends on the role so if you retrieved the schema for a different role previously retrieve it for this role beforehand as it might differ", //nolint:lll
|
||||
),
|
||||
mcp.Required(),
|
||||
),
|
||||
)
|
||||
mcpServer.AddTool(queryTool, mcp.NewStructuredToolHandler(t.handleGraphqlQuery))
|
||||
}
|
||||
|
||||
func (t *Tool) handleGraphqlQuery(
|
||||
ctx context.Context, _ mcp.CallToolRequest, args GraphqlQueryRequest,
|
||||
) (*mcp.CallToolResult, error) {
|
||||
if args.Query == "" {
|
||||
return mcp.NewToolResultError("query is required"), nil
|
||||
}
|
||||
|
||||
if args.Role == "" {
|
||||
return mcp.NewToolResultError("role is required"), nil
|
||||
}
|
||||
|
||||
interceptors := append( //nolint:gocritic
|
||||
t.interceptors,
|
||||
auth.WithRole(args.Role),
|
||||
)
|
||||
|
||||
var resp graphql.Response[any]
|
||||
if err := graphql.Query(
|
||||
ctx,
|
||||
t.graphqlURL,
|
||||
args.Query,
|
||||
args.Variables,
|
||||
&resp,
|
||||
nil,
|
||||
nil,
|
||||
interceptors...,
|
||||
); err != nil {
|
||||
return mcp.NewToolResultErrorFromErr("failed to query graphql endpoint", err), nil
|
||||
}
|
||||
|
||||
b, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultErrorFromErr("error marshalling response", err), nil
|
||||
}
|
||||
|
||||
return mcp.NewToolResultStructured(resp, string(b)), nil
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"github.com/nhost/nhost/cli/mcp/graphql"
|
||||
"github.com/nhost/nhost/cli/mcp/nhost/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
ToolGetGraphqlSchemaName = "project-get-graphql-schema"
|
||||
ToolGetGraphqlSchemaInstructions = `Get GraphQL schema for an Nhost project running in the Nhost Cloud.`
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrInvalidRequestBody = errors.New("invalid request body")
|
||||
)
|
||||
|
||||
type GetGraphqlSchemaRequest struct {
|
||||
Role string `json:"role"`
|
||||
ProjectSubdomain string `json:"projectSubdomain"`
|
||||
}
|
||||
|
||||
func (t *Tool) registerGetGraphqlSchemaTool(mcpServer *server.MCPServer, projects string) {
|
||||
schemaTool := mcp.NewTool(
|
||||
ToolGetGraphqlSchemaName,
|
||||
mcp.WithDescription(ToolGetGraphqlSchemaInstructions),
|
||||
mcp.WithToolAnnotation(
|
||||
mcp.ToolAnnotation{
|
||||
Title: "Get GraphQL Schema for Nhost Project running on Nhost Cloud",
|
||||
ReadOnlyHint: ptr(true),
|
||||
DestructiveHint: ptr(false),
|
||||
IdempotentHint: ptr(true),
|
||||
OpenWorldHint: ptr(true),
|
||||
},
|
||||
),
|
||||
mcp.WithString(
|
||||
"role",
|
||||
mcp.Description(
|
||||
"role to use when executing queries. Default to user but make sure the user is aware",
|
||||
),
|
||||
mcp.Required(),
|
||||
),
|
||||
mcp.WithString(
|
||||
"projectSubdomain",
|
||||
mcp.Description(
|
||||
fmt.Sprintf(
|
||||
"Project to get the GraphQL schema for. Must be one of %s, otherwise you don't have access to it. You can use cloud-* tools to resolve subdomains and map them to names", //nolint:lll
|
||||
projects,
|
||||
),
|
||||
),
|
||||
mcp.Required(),
|
||||
),
|
||||
)
|
||||
mcpServer.AddTool(schemaTool, mcp.NewStructuredToolHandler(t.handleGetGraphqlSchema))
|
||||
}
|
||||
|
||||
func (t *Tool) handleGetGraphqlSchema(
|
||||
ctx context.Context, _ mcp.CallToolRequest, args GetGraphqlSchemaRequest,
|
||||
) (*mcp.CallToolResult, error) {
|
||||
if args.Role == "" {
|
||||
return mcp.NewToolResultError("role is required"), nil
|
||||
}
|
||||
|
||||
if args.ProjectSubdomain == "" {
|
||||
return mcp.NewToolResultError("projectSubdomain is required"), nil
|
||||
}
|
||||
|
||||
project, ok := t.projects[args.ProjectSubdomain]
|
||||
if !ok {
|
||||
return mcp.NewToolResultError("project not configured to be accessed by an LLM"), nil
|
||||
}
|
||||
|
||||
interceptors := []func(ctx context.Context, req *http.Request) error{
|
||||
project.authInterceptor,
|
||||
auth.WithRole(args.Role),
|
||||
}
|
||||
|
||||
var introspection graphql.ResponseIntrospection
|
||||
if err := graphql.Query(
|
||||
ctx,
|
||||
project.graphqlURL,
|
||||
graphql.IntrospectionQuery,
|
||||
nil,
|
||||
&introspection,
|
||||
nil,
|
||||
nil,
|
||||
interceptors...,
|
||||
); err != nil {
|
||||
return mcp.NewToolResultErrorFromErr("failed to query GraphQL schema", err), nil
|
||||
}
|
||||
|
||||
schema := graphql.ParseSchema(
|
||||
introspection,
|
||||
graphql.Filter{
|
||||
AllowQueries: nil,
|
||||
AllowMutations: nil,
|
||||
},
|
||||
)
|
||||
|
||||
return mcp.NewToolResultText(schema), nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package local
|
||||
package project
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -10,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
|
||||
@@ -1,90 +1,25 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"github.com/nhost/nhost/cli/mcp/config"
|
||||
"github.com/nhost/nhost/cli/mcp/nhost/auth"
|
||||
)
|
||||
|
||||
type Project struct {
|
||||
subdomain string
|
||||
graphqlURL string
|
||||
authInterceptor func(ctx context.Context, req *http.Request) error
|
||||
allowQueries []string
|
||||
allowMutations []string
|
||||
}
|
||||
|
||||
type Tool struct {
|
||||
projects map[string]Project
|
||||
}
|
||||
|
||||
func allowedQueries(allowQueries []string) []string {
|
||||
if len(allowQueries) == 1 && allowQueries[0] == "*" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return allowQueries
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewTool(
|
||||
projList []config.Project,
|
||||
) (*Tool, error) {
|
||||
projects := make(map[string]Project)
|
||||
|
||||
for _, proj := range projList {
|
||||
authURL := fmt.Sprintf("https://%s.auth.%s.nhost.run/v1", proj.Subdomain, proj.Region)
|
||||
graphqlURL := fmt.Sprintf("https://%s.graphql.%s.nhost.run/v1", proj.Subdomain, proj.Region)
|
||||
|
||||
var interceptor func(ctx context.Context, req *http.Request) error
|
||||
|
||||
switch {
|
||||
case proj.AdminSecret != nil && *proj.AdminSecret != "":
|
||||
interceptor = auth.WithAdminSecret(*proj.AdminSecret)
|
||||
case proj.PAT != nil && *proj.PAT != "":
|
||||
var err error
|
||||
|
||||
interceptor, err = auth.WithPAT(authURL, *proj.PAT)
|
||||
if err != nil {
|
||||
return nil,
|
||||
fmt.Errorf("failed to create PAT interceptor for %s: %w", proj.Subdomain, err)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf( //nolint:err113
|
||||
"project %s does not have a valid auth mechanism", proj.Subdomain)
|
||||
}
|
||||
|
||||
projects[proj.Subdomain] = Project{
|
||||
subdomain: proj.Subdomain,
|
||||
graphqlURL: graphqlURL,
|
||||
authInterceptor: interceptor,
|
||||
allowQueries: allowedQueries(proj.AllowQueries),
|
||||
allowMutations: allowedQueries(proj.AllowMutations),
|
||||
}
|
||||
}
|
||||
|
||||
cfg *config.Config,
|
||||
) *Tool {
|
||||
return &Tool{
|
||||
projects: projects,
|
||||
}, nil
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tool) Register(mcpServer *server.MCPServer) error {
|
||||
projectNames := make([]string, 0, len(t.projects))
|
||||
for _, proj := range t.projects {
|
||||
projectNames = append(projectNames, proj.subdomain)
|
||||
}
|
||||
|
||||
slices.Sort(projectNames)
|
||||
|
||||
projectNamesStr := strings.Join(projectNames, ", ")
|
||||
|
||||
t.registerGetGraphqlSchemaTool(mcpServer, projectNamesStr)
|
||||
t.registerGraphqlQuery(mcpServer, projectNamesStr)
|
||||
t.registerGraphqlQuery(mcpServer)
|
||||
t.registerManageGraphql(mcpServer)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package project
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
@@ -13,9 +12,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
ToolGraphqlQueryName = "project-graphql-query"
|
||||
ToolGraphqlQueryName = "graphql-query"
|
||||
//nolint:lll
|
||||
ToolGraphqlQueryInstructions = `Execute a GraphQL query against a Nhost project running in the Nhost Cloud. This tool is useful to query and mutate live data running on an online projec. If you run into issues executing queries, retrieve the schema using the project-get-graphql-schema tool in case the schema has changed. If you get an error indicating the query or mutation is not allowed the user may have disabled them in the server, don't retry and tell the user they need to enable them when starting mcp-nhost`
|
||||
ToolGraphqlQueryInstructions = `Execute a GraphQL query against a Nhost project. This tool is useful to query and mutate data. If you run into issues executing queries, retrieve the schema again in case the schema has changed. If you get an error indicating the query or mutation is not allowed the user may have disabled them in the server, don't retry and tell the user they need to enable them when starting mcp-nhost`
|
||||
)
|
||||
|
||||
func ptr[T any](v T) *T {
|
||||
@@ -23,18 +22,18 @@ func ptr[T any](v T) *T {
|
||||
}
|
||||
|
||||
type GraphqlQueryRequest struct {
|
||||
Query string `json:"query"`
|
||||
Variables map[string]any `json:"variables,omitempty"`
|
||||
ProjectSubdomain string `json:"projectSubdomain"`
|
||||
Role string `json:"role"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
Query string `json:"query"`
|
||||
Variables map[string]any `json:"variables,omitempty"`
|
||||
Subdomain string `json:"subdomain"`
|
||||
Role string `json:"role"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
}
|
||||
|
||||
func (t *Tool) registerGraphqlQuery(mcpServer *server.MCPServer, projects string) {
|
||||
func (t *Tool) registerGraphqlQuery(mcpServer *server.MCPServer) {
|
||||
allowedMutations := false
|
||||
|
||||
for _, proj := range t.projects {
|
||||
if proj.allowMutations == nil || len(proj.allowMutations) > 0 {
|
||||
for _, proj := range t.cfg.Projects {
|
||||
if proj.AllowMutations == nil || len(proj.AllowMutations) > 0 {
|
||||
allowedMutations = true
|
||||
break
|
||||
}
|
||||
@@ -62,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
|
||||
|
||||
94
cli/mcp/tools/schemas/project_schema.go
Normal 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
|
||||
}
|
||||
101
cli/mcp/tools/schemas/schemas.go
Normal 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
|
||||
}
|
||||
@@ -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")
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -50,7 +50,6 @@ export const baseEnvironmentVariableFormValidationSchema = Yup.object({
|
||||
'TERM',
|
||||
'NODE_VERSION',
|
||||
'YARN_VERSION',
|
||||
'NODE_ENV',
|
||||
'HOME',
|
||||
].includes(value),
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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_"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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!');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as highlightMatch } from './highlightMatch';
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
31
docs/platform/cli/mcp/clients.mdx
Normal 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
|
||||
```
|
||||
189
docs/platform/cli/mcp/configuration.mdx
Normal 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.
|
||||
98
docs/platform/cli/mcp/overview.mdx
Normal 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.
|
||||
28
docs/platform/cli/mcp/troubleshooting.mdx
Normal 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
|
||||
```
|
||||
|
||||
9
docs/products/graphql/guides/react-urql.mdx
Normal 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).
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
|
||||
453
examples/guides/react-urql/README.md
Normal 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.
|
||||
7
examples/guides/react-urql/biome.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"root": false,
|
||||
"extends": "//",
|
||||
"linter": {
|
||||
"includes": ["**", "!src/lib/graphql/__generated__/graphql.ts"]
|
||||
}
|
||||
}
|
||||
28
examples/guides/react-urql/codegen-wrapper.sh
Executable 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."
|
||||
41
examples/guides/react-urql/codegen.ts
Normal 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;
|
||||
13
examples/guides/react-urql/index.html
Normal 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>
|
||||
37
examples/guides/react-urql/package.json
Normal 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
1
examples/guides/react-urql/public/vite.svg
Normal 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 |
10143
examples/guides/react-urql/schema.graphql
Normal file
56
examples/guides/react-urql/src/App.tsx
Normal 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;
|
||||
85
examples/guides/react-urql/src/components/Navigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
examples/guides/react-urql/src/components/ProtectedRoute.tsx
Normal 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 />;
|
||||
}
|
||||
552
examples/guides/react-urql/src/index.css
Normal 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;
|
||||
}
|
||||
77
examples/guides/react-urql/src/lib/graphql/UrqlProvider.tsx
Normal 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>;
|
||||
};
|
||||
7003
examples/guides/react-urql/src/lib/graphql/__generated__/graphql.ts
generated
Normal file
28
examples/guides/react-urql/src/lib/graphql/queries.graphql
Normal 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
|
||||
}
|
||||
}
|
||||
174
examples/guides/react-urql/src/lib/nhost/AuthProvider.tsx
Normal 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;
|
||||
};
|
||||
22
examples/guides/react-urql/src/main.tsx
Normal 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 />);
|
||||
217
examples/guides/react-urql/src/pages/Home.css
Normal 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;
|
||||
}
|
||||
}
|
||||
202
examples/guides/react-urql/src/pages/Home.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||