Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs-website/router/mcp/oauth/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ icon: 'sliders-up'
| `oauth.scopes.execute_graphql` | Scopes required to call the `execute_graphql` built-in tool. Additive to `tools_call`. Only relevant when `enable_arbitrary_operations` is `true`. | `[]` |
| `oauth.scopes.get_operation_info` | Scopes required to call the `get_operation_info` built-in tool. Additive to `tools_call`. | `[]` |
| `oauth.scopes.get_schema` | Scopes required to call the `get_schema` built-in tool. Additive to `tools_call`. Only relevant when `expose_schema` is `true`. | `[]` |
| `oauth.scopes.generate_query` | Scopes required to call the `generate_query` built-in tool. Additive to `tools_call`. Only relevant when Prompt to Query is enabled by the graph token. | `[]` |
| `oauth.jwks` | List of JWKS providers for JWT verification. Supports remote JWKS URLs or symmetric secrets. | `[]` |

## JWKS Configuration
Expand Down Expand Up @@ -90,6 +91,7 @@ The option `authorization_server_url` is deprecated. Use `authorization_server_u
| `MCP_OAUTH_AUTHORIZATION_SERVER_URL` | `mcp.oauth.authorization_server_url` (deprecated) |
| `MCP_OAUTH_SCOPE_CHALLENGE_INCLUDE_TOKEN_SCOPES` | `mcp.oauth.scope_challenge_include_token_scopes` |
| `MCP_OAUTH_MAX_SCOPE_COMBINATIONS` | `mcp.oauth.max_scope_combinations` |
| `MCP_OAUTH_SCOPES_GENERATE_QUERY` | `mcp.oauth.scopes.generate_query` |

## HTTP Error Responses

Expand Down
30 changes: 30 additions & 0 deletions docs-website/router/mcp/tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ The MCP server gives AI models a set of tools they can discover and execute. It
| `get_operation_info` | Returns instructions for executing the operation behind one of your tools directly via HTTP and integrating it into an application. |
| `get_schema` | Returns the full GraphQL schema of the API, helping AI models understand the entire API structure. |
| `execute_graphql` | Executes an arbitrary GraphQL query or mutation, letting AI models craft operations beyond the tools you have created. |
| `generate_query` | Uses Cosmo Cloud Prompt to Query to generate a GraphQL operation from a natural-language prompt. |

<Warning>
`get_schema` and `execute_graphql` are disabled by default because they expose your full API surface to AI models.
Expand All @@ -24,6 +25,35 @@ The MCP server gives AI models a set of tools they can discover and execute. It
intended. Prefer creating focused tools.
</Warning>

### Generate a GraphQL Operation with Cosmo Cloud

The `generate_query` tool sends a natural-language prompt and the router's active schema version to Cosmo Cloud Prompt
to Query. The generated operation is returned to the MCP client but is not executed.

The tool accepts one required field:

```json
{
"prompt": "List the names and email addresses of the five most recently created users"
}
```

A successful response contains the GraphQL document and the metadata needed to use it:

```json
{
"description": "Lists the five most recently created users",
"document": "query RecentUsers { users(first: 5, orderBy: CREATED_AT_DESC) { name email } }",
"operationName": "RecentUsers",
"operationType": "query",
"variablesSchema": "{\"type\":\"object\"}"
}
```

The tool is registered only when Prompt to Query is enabled for the Cosmo Cloud organization and the router's graph
token contains the `prompt-to-query` feature. Graph tokens issued before the feature was enabled must be rotated and the
router updated with the new token. Control Plane or entitlement errors are returned as MCP tool errors.

## Creating Tools

Create a directory for your tools (as specified in your [storage provider configuration](/router/mcp/configuration#storage-providers)) and add `.graphql` or `.gql` files containing GraphQL operations.
Expand Down
2 changes: 1 addition & 1 deletion router/core/graph_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -1611,7 +1611,7 @@ func (s *graphServer) buildGraphMux(

// We support the MCP only on the base graph. Feature flags are not supported yet.
if opts.IsBaseGraph() && s.mcpServer != nil {
if mErr := s.mcpServer.Reload(executor.ClientSchema, opts.EngineConfig.FieldConfigurations); mErr != nil {
if mErr := s.mcpServer.Reload(executor.ClientSchema, opts.EngineConfig.FieldConfigurations, opts.RouterConfigVersion); mErr != nil {
return nil, fmt.Errorf("failed to reload MCP server: %w", mErr)
}
}
Expand Down
10 changes: 10 additions & 0 deletions router/core/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -1327,6 +1327,9 @@ func (r *Router) startMCPServer(ctx context.Context) error {
mcpserver.WithServerTitle(r.mcp.Server.Title),
mcpserver.WithServerDescription(r.mcp.Server.Description),
}
if r.promptToQueryClient != nil {
mcpOpts = append(mcpOpts, mcpserver.WithPromptToQueryClient(r.promptToQueryClient))
}

if r.corsOptions != nil {
mcpOpts = append(mcpOpts, mcpserver.WithCORS(*r.corsOptions))
Expand Down Expand Up @@ -2206,6 +2209,13 @@ func WithSelfRegistration(sr selfregister.SelfRegister) Option {
}
}

// WithPromptToQueryClient sets the control-plane client used by the MCP generate_query tool.
func WithPromptToQueryClient(client mcpserver.PromptToQueryClient) Option {
return func(r *Router) {
r.promptToQueryClient = client
}
}

// WithGracePeriod sets the grace period for the router to shutdown.
func WithGracePeriod(timeout time.Duration) Option {
return func(r *Router) {
Expand Down
1 change: 1 addition & 0 deletions router/core/router_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ type Config struct {
// Poller
configPoller configpoller.ConfigPoller
selfRegister selfregister.SelfRegister
promptToQueryClient mcpserver.PromptToQueryClient
registrationInfo *nodev1.RegistrationInfo
securityConfiguration config.SecurityConfiguration
customModules []Module
Expand Down
23 changes: 23 additions & 0 deletions router/core/supervisor_instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import (

"github.com/KimMachineGun/automemlimit/memlimit"
"github.com/dustin/go-humanize"
"github.com/wundergraph/cosmo/router/internal/controlplane"
rjwt "github.com/wundergraph/cosmo/router/internal/jwt"
"github.com/wundergraph/cosmo/router/internal/prompttoquery"
"github.com/wundergraph/cosmo/router/pkg/authentication"
"github.com/wundergraph/cosmo/router/pkg/config"
"github.com/wundergraph/cosmo/router/pkg/controlplane/selfregister"
Expand Down Expand Up @@ -159,6 +162,26 @@ func newRouter(ctx context.Context, params RouterResources, additionalOptions ..
options = append(options, WithSelfRegistration(selfRegister))
}

if cfg.MCP.Enabled && cfg.Graph.Token != "" {
claims, err := rjwt.ExtractFederatedGraphTokenClaims(cfg.Graph.Token)
if err != nil {
return nil, fmt.Errorf("could not inspect graph token features: %w", err)
}

if claims.HasFeature(rjwt.FeaturePromptToQuery) {
controlplaneTransport, err := controlplane.NewTransport(cfg.Graph.Token, logger)
if err != nil {
return nil, fmt.Errorf("could not create controlplane transport: %w", err)
}

promptToQueryClient, err := prompttoquery.New(cfg.ControlplaneURL, controlplaneTransport)
if err != nil {
return nil, fmt.Errorf("could not create prompt-to-query client: %w", err)
}
options = append(options, WithPromptToQueryClient(promptToQueryClient))
}
}
Comment on lines +165 to +183

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsure about this, see my PM


if opt := optionFromExecutionConfig(&cfg.ExecutionConfig, cfg.RouterConfigPath); opt != nil {
options = append(options, opt)
} else {
Expand Down
58 changes: 58 additions & 0 deletions router/internal/controlplane/transport.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package controlplane

import (
"fmt"
"net/http"
"time"

"github.com/hashicorp/go-retryablehttp"
"go.uber.org/zap"
)

const (
retryWaitMax = 15 * time.Second
retryMax = 3
)

type bearerAuthTransport struct {
token string
next http.RoundTripper
}

// NewTransport creates an authenticated, retrying transport for control plane requests.
func NewTransport(token string, logger *zap.Logger) (http.RoundTripper, error) {
if token == "" {
return nil, fmt.Errorf("graph api token is required for controlplane requests")
}

if logger == nil {
logger = zap.NewNop()
}

retryClient := retryablehttp.NewClient()
retryClient.RetryWaitMax = retryWaitMax
retryClient.RetryMax = retryMax
retryClient.Backoff = retryablehttp.DefaultBackoff
retryClient.Logger = nil
retryClient.RequestLogHook = func(_ retryablehttp.Logger, _ *http.Request, retry int) {
if retry > 0 {
logger.Info("Retry controlplane request", zap.Int("retry", retry))
}
}

return newBearerAuthTransport(token, retryClient.StandardClient().Transport), nil
}

func newBearerAuthTransport(token string, next http.RoundTripper) http.RoundTripper {
return &bearerAuthTransport{
token: token,
next: next,
}
}

func (t *bearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set("Authorization", "Bearer "+t.token)

return t.next.RoundTrip(req)
}
1 change: 1 addition & 0 deletions router/internal/jwt/claims.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const (
OrganizationIDClaim = "organization_id"
FeaturesClaim = "features"
FeatureSplitConfigLoading = "split-config-loading"
FeaturePromptToQuery = "prompt-to-query"
)

type FederatedGraphTokenClaims struct {
Expand Down
56 changes: 56 additions & 0 deletions router/internal/prompttoquery/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package prompttoquery

import (
"context"
"fmt"
"net/http"
"time"

"connectrpc.com/connect"
aiv1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/ai/v1"
"github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/ai/v1/aiv1connect"
brotli "go.withmatt.com/connect-brotli"
)

const clientTimeout = 15 * time.Second

// Client generates GraphQL operations through the control plane.
type Client struct {
aiServiceClient aiv1connect.AIServiceClient
}

func New(endpoint string, transport http.RoundTripper) (*Client, error) {
if endpoint == "" {
return nil, fmt.Errorf("controlplane endpoint is required for prompt to query")
}

if transport == nil {
return nil, fmt.Errorf("controlplane transport is required for prompt to query")
}

httpClient := &http.Client{
Transport: transport,
Timeout: clientTimeout,
}

aiServiceClient := aiv1connect.NewAIServiceClient(httpClient, endpoint,
brotli.WithCompression(),
connect.WithSendCompression(brotli.Name),
)

return &Client{aiServiceClient: aiServiceClient}, nil
}

func (c *Client) GenerateQuery(ctx context.Context, schemaVersionID, prompt string) (*aiv1.GenerateQueryResponse, error) {
req := connect.NewRequest(&aiv1.GenerateQueryRequest{
Version: schemaVersionID,
Prompt: prompt,
})

resp, err := c.aiServiceClient.GenerateQuery(ctx, req)
if err != nil {
return nil, err
}

return resp.Msg, nil
}
3 changes: 3 additions & 0 deletions router/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -1487,6 +1487,9 @@ type MCPOAuthScopesConfiguration struct {
// GetSchema specifies scopes required to call the get_schema built-in tool.
// Additive to tools_call scopes. Only relevant when expose_schema is true.
GetSchema []string `yaml:"get_schema,omitempty" env:"GET_SCHEMA"`
// GenerateQuery specifies scopes required to call the generate_query built-in tool.
// Additive to tools_call scopes. Only relevant when query generation is enabled.
GenerateQuery []string `yaml:"generate_query,omitempty" env:"GENERATE_QUERY"`
}

type MCPSessionConfig struct {
Expand Down
5 changes: 5 additions & 0 deletions router/pkg/config/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2832,6 +2832,11 @@
"type": "array",
"description": "Scopes required to call the get_schema built-in tool. Additive to tools_call scopes. Only relevant when expose_schema is true.",
"items": { "type": "string" }
},
"generate_query": {
"type": "array",
"description": "Scopes required to call the generate_query built-in tool. Additive to tools_call scopes. Only relevant when query generation is enabled.",
"items": { "type": "string" }
}
}
},
Expand Down
3 changes: 2 additions & 1 deletion router/pkg/config/testdata/config_defaults.json
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,8 @@
"ToolsCall": null,
"ExecuteGraphQL": null,
"GetOperationInfo": null,
"GetSchema": null
"GetSchema": null,
"GenerateQuery": null
},
"ScopeChallengeIncludeTokenScopes": false,
"MaxScopeCombinations": 2048
Expand Down
3 changes: 2 additions & 1 deletion router/pkg/config/testdata/config_full.json
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,8 @@
"ToolsCall": null,
"ExecuteGraphQL": null,
"GetOperationInfo": null,
"GetSchema": null
"GetSchema": null,
"GenerateQuery": null
},
"ScopeChallengeIncludeTokenScopes": false,
"MaxScopeCombinations": 2048
Expand Down
2 changes: 2 additions & 0 deletions router/pkg/mcpserver/auth_middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ func (m *MCPAuthMiddleware) getBuiltinToolScopes(toolName string) []string {
return m.scopes.GetOperationInfo
case "get_schema":
return m.scopes.GetSchema
case "generate_query":
return m.scopes.GenerateQuery
default:
return nil
}
Expand Down
16 changes: 16 additions & 0 deletions router/pkg/mcpserver/auth_middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,8 @@ func TestMCPAuthMiddlewareBuiltinToolScopes(t *testing.T) {
return authentication.Claims{"sub": "user3", "scope": "mcp:connect mcp:tools:call mcp:graphql:execute"}, nil
case "has-ops-read":
return authentication.Claims{"sub": "user4", "scope": "mcp:connect mcp:tools:call mcp:ops:read"}, nil
case "has-query-generate":
return authentication.Claims{"sub": "user5", "scope": "mcp:connect mcp:tools:call mcp:query:generate"}, nil
default:
return nil, errors.New("invalid token")
}
Expand All @@ -619,6 +621,7 @@ func TestMCPAuthMiddlewareBuiltinToolScopes(t *testing.T) {
ExecuteGraphQL: []string{"mcp:graphql:execute"},
GetOperationInfo: []string{"mcp:ops:read"},
GetSchema: []string{"mcp:schema:read"},
GenerateQuery: []string{"mcp:query:generate"},
}

tests := []struct {
Expand Down Expand Up @@ -667,6 +670,19 @@ func TestMCPAuthMiddlewareBuiltinToolScopes(t *testing.T) {
body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_operation_info"}}`,
wantStatusCode: 200,
},
{
name: "returns 403 when generate_query lacks required builtin scope",
token: "base-only",
body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"generate_query"}}`,
wantStatusCode: 403,
wantScope: `scope="mcp:query:generate"`,
},
{
name: "allows generate_query when token has required builtin scope",
token: "has-query-generate",
body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"generate_query"}}`,
wantStatusCode: 200,
},
{
name: "allows non-builtin tool regardless of builtin scopes",
token: "base-only",
Expand Down
2 changes: 1 addition & 1 deletion router/pkg/mcpserver/discover_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func newTestSession(t *testing.T, opts ...func(*Options)) *mcp.ClientSession {
}, opts...)...,
)
require.NoError(t, err)
require.NoError(t, srv.Reload(&schemaDoc, nil))
require.NoError(t, srv.Reload(&schemaDoc, nil, "schema-version-test"))

serverTransport, clientTransport := mcp.NewInMemoryTransports()

Expand Down
Loading
Loading