From 455e0c649e70f399d6ca3194b08a91354b7aea01 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Wed, 12 Aug 2026 00:49:32 +0100 Subject: [PATCH 1/5] feat(router): add MCP schema discovery for search and query generation --- Makefile | 2 +- buf.lock | 6 + buf.router.go.gen.yaml | 4 + buf.yaml | 2 + docs-website/docs.json | 11 + docs-website/router/mcp.mdx | 3 + docs-website/router/mcp/configuration.mdx | 4 + .../mcp/schema-discovery/configuration.mdx | 112 ++ .../router/mcp/schema-discovery/guides.mdx | 157 ++ .../router/mcp/schema-discovery/overview.mdx | 135 ++ .../mcp/schema-discovery/quickstart.mdx | 212 +++ .../router/mcp/schema-discovery/tools.mdx | 164 +++ docs-website/router/mcp/tools.mdx | 6 + proto/yoko/v1/yoko.proto | 144 ++ router/core/router.go | 4 + router/gen/proto/yoko/v1/yoko.pb.go | 1281 +++++++++++++++++ .../yoko/v1/yokov1connect/yoko.connect.go | 298 ++++ router/go.mod | 3 +- router/go.sum | 6 +- router/mcp.schema-discovery.config.yaml | 63 + router/pkg/config/config.go | 23 + router/pkg/config/config.schema.json | 45 + .../pkg/config/testdata/config_defaults.json | 10 +- router/pkg/config/testdata/config_full.json | 10 +- .../pkg/mcpserver/schema_discovery_tools.go | 281 ++++ .../mcpserver/schema_discovery_tools_test.go | 158 ++ router/pkg/mcpserver/server.go | 135 +- router/pkg/querygen/client.go | 41 + router/pkg/querygen/config.go | 65 + router/pkg/querygen/indexer.go | 249 ++++ router/pkg/querygen/querygen_test.go | 418 ++++++ router/pkg/querygen/service.go | 260 ++++ 32 files changed, 4286 insertions(+), 26 deletions(-) create mode 100644 buf.lock create mode 100644 docs-website/router/mcp/schema-discovery/configuration.mdx create mode 100644 docs-website/router/mcp/schema-discovery/guides.mdx create mode 100644 docs-website/router/mcp/schema-discovery/overview.mdx create mode 100644 docs-website/router/mcp/schema-discovery/quickstart.mdx create mode 100644 docs-website/router/mcp/schema-discovery/tools.mdx create mode 100644 proto/yoko/v1/yoko.proto create mode 100644 router/gen/proto/yoko/v1/yoko.pb.go create mode 100644 router/gen/proto/yoko/v1/yokov1connect/yoko.connect.go create mode 100644 router/mcp.schema-discovery.config.yaml create mode 100644 router/pkg/mcpserver/schema_discovery_tools.go create mode 100644 router/pkg/mcpserver/schema_discovery_tools_test.go create mode 100644 router/pkg/querygen/client.go create mode 100644 router/pkg/querygen/config.go create mode 100644 router/pkg/querygen/indexer.go create mode 100644 router/pkg/querygen/querygen_test.go create mode 100644 router/pkg/querygen/service.go diff --git a/Makefile b/Makefile index e5c0829e33..e8be9cb1e3 100644 --- a/Makefile +++ b/Makefile @@ -116,7 +116,7 @@ generate: make generate-go generate-go: - rm -rf router/gen && buf generate --path proto/wg/cosmo/node --path proto/wg/cosmo/common --path proto/wg/cosmo/graphqlmetrics --template buf.router.go.gen.yaml + rm -rf router/gen && buf generate --path proto/wg/cosmo/node --path proto/wg/cosmo/common --path proto/wg/cosmo/graphqlmetrics --path proto/yoko/v1 --template buf.router.go.gen.yaml rm -rf graphqlmetrics/gen && buf generate --path proto/wg/cosmo/graphqlmetrics --path proto/wg/cosmo/common --template buf.graphqlmetrics.go.gen.yaml rm -rf connect-go/wg && buf generate --path proto/wg/cosmo/platform --path proto/wg/cosmo/notifications --path proto/wg/cosmo/common --path proto/wg/cosmo/node --template buf.connect-go.go.gen.yaml diff --git a/buf.lock b/buf.lock new file mode 100644 index 0000000000..7b474667a4 --- /dev/null +++ b/buf.lock @@ -0,0 +1,6 @@ +# Generated by buf. DO NOT EDIT. +version: v2 +deps: + - name: buf.build/bufbuild/protovalidate + commit: 435963d1631043e694e56e6bcc3c79c3 + digest: b5:f4ea07ad2dd94bd7243562f9908b9fb104feef8076040c89d9f7c1dedc074de4d4ce2b997686ef4400f3eccb765a7cfc20ed4acdd70b9a3699351245c61dba97 diff --git a/buf.router.go.gen.yaml b/buf.router.go.gen.yaml index b080cbf3d5..e00c1f26e4 100644 --- a/buf.router.go.gen.yaml +++ b/buf.router.go.gen.yaml @@ -4,6 +4,10 @@ managed: disable: - file_option: go_package module: buf.build/googleapis/googleapis + # Keep protovalidate on its published Go module. Without this the build + # fails on a missing gen/buf/validate package. + - file_option: go_package + module: buf.build/bufbuild/protovalidate override: - file_option: go_package_prefix value: github.com/wundergraph/cosmo/router/gen/proto diff --git a/buf.yaml b/buf.yaml index ee5c8b279b..753ea0e277 100644 --- a/buf.yaml +++ b/buf.yaml @@ -1,4 +1,6 @@ version: v2 +deps: + - buf.build/bufbuild/protovalidate modules: - path: proto lint: diff --git a/docs-website/docs.json b/docs-website/docs.json index 1f73eed1ab..c7997bd954 100644 --- a/docs-website/docs.json +++ b/docs-website/docs.json @@ -100,6 +100,17 @@ "router/mcp/quickstart", "router/mcp/tools", "router/mcp/configuration", + { + "group": "Schema Discovery", + "icon": "compass", + "pages": [ + "router/mcp/schema-discovery/overview", + "router/mcp/schema-discovery/quickstart", + "router/mcp/schema-discovery/guides", + "router/mcp/schema-discovery/tools", + "router/mcp/schema-discovery/configuration" + ] + }, { "group": "OAuth 2.1", "icon": "shield-check", diff --git a/docs-website/router/mcp.mdx b/docs-website/router/mcp.mdx index 39fccdc1b8..6bd6f2f9a8 100644 --- a/docs-website/router/mcp.mdx +++ b/docs-website/router/mcp.mdx @@ -45,6 +45,9 @@ The Cosmo MCP Server builds on top of the concept of persisted operations (also Empower AI assistants to work with your application's data through a standardized interface + + Let a model search a large schema and generate a valid operation from a prompt, without the schema in its context + ## Get Started diff --git a/docs-website/router/mcp/configuration.mdx b/docs-website/router/mcp/configuration.mdx index d257a4e5fa..318e5ad9a7 100644 --- a/docs-website/router/mcp/configuration.mdx +++ b/docs-website/router/mcp/configuration.mdx @@ -44,8 +44,12 @@ storage_providers: | `expose_schema` | Enables the `get_schema` built-in tool, exposing the full GraphQL schema to MCP clients. | `false` | | `omit_tool_name_prefix` | When enabled, MCP tool names omit the `execute_operation_` prefix. For example, `GetUser` becomes `get_user` instead of `execute_operation_get_user`. See [Tools - Omitting the Tool Name Prefix](/router/mcp/tools#omitting-the-tool-name-prefix). | `false` | +| `schema_discovery.enabled` | Enables the `search_schema`, `get_symbols` and `generate_query` built-in tools. They let a model search a large schema and generate a valid operation, without the schema in its context. See [Schema Discovery](/router/mcp/schema-discovery/configuration). | `false` | + For OAuth-specific configuration, see [OAuth 2.1 Authorization](/router/mcp/oauth/overview). +For schema discovery configuration, see [Schema Discovery - Configuration](/router/mcp/schema-discovery/configuration). + ## Environment Variables All MCP options can also be set via environment variables: diff --git a/docs-website/router/mcp/schema-discovery/configuration.mdx b/docs-website/router/mcp/schema-discovery/configuration.mdx new file mode 100644 index 0000000000..3f5d4a723a --- /dev/null +++ b/docs-website/router/mcp/schema-discovery/configuration.mdx @@ -0,0 +1,112 @@ +--- +title: 'Configuration' +description: 'Every configuration key and environment variable for MCP schema discovery.' +icon: 'sliders' +--- + +Schema discovery is configured under `mcp.schema_discovery`. + +```yaml router.config.yaml +mcp: + enabled: true + expose_schema: false + schema_discovery: + enabled: true + url: 'https://discovery.example.com' + token: 'your-token' + request_timeout: 90s + index_poll_interval: 2s + index_timeout: 10m +``` + +## Keys + +| Key | Type | Default | Environment variable | +| --- | --- | --- | --- | +| `enabled` | boolean | `false` | `MCP_SCHEMA_DISCOVERY_ENABLED` | +| `url` | string | | `MCP_SCHEMA_DISCOVERY_URL` | +| `token` | string | | `MCP_SCHEMA_DISCOVERY_TOKEN` | +| `request_timeout` | duration | `90s` | `MCP_SCHEMA_DISCOVERY_REQUEST_TIMEOUT` | +| `index_poll_interval` | duration | `2s` | `MCP_SCHEMA_DISCOVERY_INDEX_POLL_INTERVAL` | +| `index_timeout` | duration | `10m` | `MCP_SCHEMA_DISCOVERY_INDEX_TIMEOUT` | + +### enabled + +Turns schema discovery on. The router then indexes the client schema and registers the `search_schema`, `get_symbols` and `generate_query` tools. + +### url + +The base URL of the schema discovery service. Include the scheme. + +The service speaks Connect over HTTP/1.1. + + +The router does not start when `enabled` is `true` and `url` is empty. This stops a server whose tools always fail. + + +### token + +The bearer token for the service. The router sends it as `Authorization: Bearer `. + +An empty token sends no `Authorization` header. Use an empty token when your service runs with authentication off. + +The router never writes the token to a log or to an error message. + +### request_timeout + +The timeout for one call to the service. + +Query generation takes 10 to 30 seconds. A value below `60s` is too low. + +This value also raises the write timeout of the MCP HTTP server, so a slow generation still reaches the caller. + +### index_poll_interval + +The wait between two index status reads while the router waits for a build. + +### index_timeout + +The router stops waiting for an index that does not become ready within this time. + +A schema of 16,000 lines indexes in about 24 seconds. The default of `10m` only trips on a real fault. + +## Interaction with other keys + +### expose_schema + +Set `expose_schema` to `false`. + +`get_schema` returns the full schema. That is the context cost that schema discovery removes. The two settings do not fail together, but they work against each other. + +### enable_arbitrary_operations + +Set this to `true` when you want the agent to run the operation that `generate_query` returns. + +Set it to `false` in production. Use the curated path instead: generate the operation in a development router, review it, publish it as a persisted operation, then deploy that. See [Guides](/router/mcp/schema-discovery/guides). + +## Startup behaviour + +The router does not wait for the index. It serves GraphQL from the first moment. + +Read the log to follow the build. + +``` +INFO MCP schema discovery enabled url=https://discovery.example.com authenticated=true +INFO schema index is building index_id=sha256:6926769e... +INFO schema index is ready index_id=sha256:6926769e... symbol_count=318 +``` + +The router reindexes after every schema change. An unchanged schema costs nothing, because the router compares the hash locally and makes no network call. + +During a rebuild the previous index keeps serving. The router adopts the new index only when it is ready. + +## Failure behaviour + +| Condition | Result | +| --- | --- | +| The service is unreachable at startup | The router starts. The tools report that the index is not ready. | +| The build fails | The router logs the reason. The tools report that the index is not ready. | +| The build exceeds `index_timeout` | The router stops waiting and logs the reason. | +| The service drops an unused index | The router rebuilds it at the same address on the next tool call. | + +A fault in the discovery service never stops the router from serving GraphQL. diff --git a/docs-website/router/mcp/schema-discovery/guides.mdx b/docs-website/router/mcp/schema-discovery/guides.mdx new file mode 100644 index 0000000000..6994c9a187 --- /dev/null +++ b/docs-website/router/mcp/schema-discovery/guides.mdx @@ -0,0 +1,157 @@ +--- +title: 'Guides' +description: 'Solve one task at a time: find duplicate work, curate an operation into a tool, or use an operation in a BFF.' +icon: 'list-check' +--- + +Each guide solves one problem. Read only the guide that you need. + +## Find out if a capability already exists + +Use this guide before you build a new field, a new resolver, or a new subgraph. + +A large organisation runs many teams. Two teams add the same capability under different names. Schema discovery finds the first one before you build the second. + +### Step 1 - Search by intent + +Describe the capability in your own words. Do not guess field names. + +```json +{ "query": "customer billing address", "kinds": ["field"], "limit": 5 } +``` + +The search matches meaning, not text. It finds `Customer.invoiceAddress` and `Account.billingAddr` even though neither name contains your words. A text search over the schema finds neither. + +### Step 2 - Ask for the operation you were about to build + +```json +{ "prompt": "get the billing address and payment status for a customer" } +``` + +Read the result. It gives you a decision. + +| Result | Meaning | What you do | +| --- | --- | --- | +| One or more `queries` | The capability exists today. | Use the operation. Do not build it. | +| Empty `queries` and one `unsatisfied` reason | The schema cannot answer this. | Build the capability. | + +### Step 3 - Read the reason + +The `unsatisfied` reason names what is missing. + +```json +{ + "unsatisfied": [ + "The indexed schema exposes products, employees, and locations, but no invoice entity, billing address, or payment status." + ] +} +``` + +Collect these reasons across your teams. They tell you what consumers want and your graph does not have. + +### What this guide does not tell you + +The index holds the composed schema. It cannot show a subgraph that nobody published yet. Another team can be halfway through the same work. + +Check your schema registry as well, before you commit to a build. + +## Turn a generated operation into a tool + +Use this guide to give an agent a curated tool instead of an open prompt. + +The router generates an operation. The router never publishes it. You review the operation first, then publish it yourself. Your production router then exposes it as its own MCP tool. + +### Step 1 - Generate in a development router + +Run schema discovery in a development router. Send the prompt. + +```json +{ "prompt": "list active employees with their department and current mood" } +``` + +### Step 2 - Review the document + +Read the `document` field. Check three things: + +- The operation reads only the fields that you intend to expose. +- The operation is a `query` when you expect no side effect. +- The variables carry the filters that you want the caller to control. + +Give the operation a clear name. The name becomes the tool name. + +### Step 3 - Save the operation + +Write the document to your MCP operations directory. + +```graphql operations/ListActiveEmployees.graphql +query ListActiveEmployees($limit: Int) { + employees(limit: $limit) { + id + details { + forename + surname + } + currentMood + } +} +``` + +Add a description above the operation. The description becomes the tool description, so write it for the agent. + +### Step 4 - Deploy to production + +Deploy the operation to your production router. Turn schema discovery off there, and turn arbitrary operations off. + +```yaml production.config.yaml +mcp: + enabled: true + enable_arbitrary_operations: false + expose_schema: false + schema_discovery: + enabled: false +``` + +Your production router now exposes one typed tool. It runs no arbitrary GraphQL, and it sends no schema to an external service. + +This is the curated path. Discovery happens in development. Production runs only what you reviewed. + +## Use a generated operation in a BFF + +Use this guide to put an operation into an application. + +### Step 1 - Take both fields + +A generated operation gives you two things: + +- `document` is the operation text. +- `variablesSchema` is a JSON Schema for the variables. + +### Step 2 - Send the document and the variables + +```javascript +const response = await fetch('https://router.example.com/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: document, + variables: { limit: 10 }, + }), +}); +``` + +The operation is parameterized. Your prompt selected the shape. You supply the values at run time, so one operation serves many requests. + +### Step 3 - Use the variables schema to type the inputs + +The variables schema carries the descriptions and the allowed values from your GraphQL schema. Use it in two ways: + +- Generate types for your application. +- Register the operation as a tool for a language model. The tool name comes from `operationName`, the description from `description`, and the input schema from `variablesSchema`. + +A model then fills the variables correctly. It cannot invent a value for an enum, because the schema lists only the allowed names. + +### Step 4 - Generate one time + +Generation takes 10 to 30 seconds and uses a language model. Do not call it in your request path. + +Generate the operation one time. Store the document. Ship it with your application. diff --git a/docs-website/router/mcp/schema-discovery/overview.mdx b/docs-website/router/mcp/schema-discovery/overview.mdx new file mode 100644 index 0000000000..d5780fd615 --- /dev/null +++ b/docs-website/router/mcp/schema-discovery/overview.mdx @@ -0,0 +1,135 @@ +--- +title: 'Overview' +description: 'Schema discovery lets an agent search a large GraphQL schema and generate valid operations, without the schema in its context.' +icon: 'compass' +--- + +Schema discovery indexes your client schema in an external service. The MCP server then gives an agent three tools: search the schema, read schema records, and make a GraphQL operation from a prompt. + +## The problem + +The MCP server has two ways to show your API to an agent: + +- `get_schema` returns the full schema. A large schema fills the context of the agent. +- Persisted operations become tools. Somebody must write each operation first. + +Neither helps an agent that knows what it wants but does not know your schema. Neither helps a developer who wants to find out if a capability already exists. + +## What schema discovery does + +The router sends the client schema to the discovery service one time. The service builds an index. An agent then searches that index instead of reading the schema. + +```mermaid +sequenceDiagram + participant Agent + participant Router as Router MCP server + participant Service as Discovery service + + Note over Router,Service: At startup and after each schema change + Router->>Service: Index this schema + Service-->>Router: Address, status + + Agent->>Router: search_schema("billing address") + Router->>Service: Search + Service-->>Router: Ranked coordinates + Router-->>Agent: Coordinates and records + + Agent->>Router: generate_query("list unpaid invoices") + Router->>Service: Generate + Service-->>Router: Validated operation + Router-->>Agent: Document and variables schema + + Agent->>Router: Run the operation + Router-->>Agent: Your data +``` + +The service validates every operation against your schema before it returns it. An invalid operation never reaches the agent. + +## What schema discovery does not do + +- The discovery service never runs an operation. The router runs it. +- The router never writes a file. It returns the operation text. +- The router never publishes a persisted operation. You do that. +- The index holds the composed schema. It cannot show work in a subgraph that you did not publish yet. + +## Use cases + +### Subgraph builders + +- Find out if another subgraph already has a field, before you add it. +- Search by intent, not by name. The search finds a field even when your words differ from the field name. +- After you publish, confirm that the composed schema shows what you intended. + +### Platform teams + +- Stop duplicate work across many teams. One search answers "does the graph do this?". +- Lower the support load. Developers find the correct field without an internal request. +- Read the `unsatisfied` results. They tell you what consumers want and the schema does not have. This is a roadmap signal. + +### Enterprise governance + +- The router sends the schema to the discovery service. The router sends no data and no credentials. +- The router indexes the client schema only. Federation internals stay in the router. +- The service never runs an operation. Authentication, rate limits, and audit logs stay in the router. +- Use the curated path for production. Generate the operation in a development router. Review the operation. Publish it as a persisted operation. Then deploy it to a production router that has arbitrary operations off. + +### API and subgraph consumers + +- Get a valid operation without you reading the schema. +- Put the operation and its variables schema directly into a BFF. +- Use the variables schema to type the inputs. + +### Agents + +- Work against a large schema without the schema in the context. +- Follow three steps: search, inspect, generate. +- Read `unsatisfied`. It is a definite answer, so the agent stops guessing. + +## Design + +### The address is a hash + +The address of an index is the SHA-256 hash of the exact schema bytes. There is no index name and no version number. + +This has one purpose. You keep no state in the discovery service. You already hold your schema, so the router computes the address at any time. If the service loses an index, the router sends the schema again and gets the same address. Deletion is never data loss. + +One changed byte makes a different address. A new schema version is therefore a new index. + +### Indexing is asynchronous + +The router sends the schema and returns at once. The build runs in the background. + +Indexing reads the whole schema and computes a vector for each element. This work takes seconds. A synchronous call would delay every router config reload by that time. + +The router adopts a new address only when that address is ready. The previous index keeps serving until then. A reload never breaks a working tool. + +An unchanged schema costs nothing. The router compares the hash locally and makes no network call. + +### Operations are parameterized + +You ask for "the first 10 employees". The service returns a `$limit` variable. It does not write `limit: 10` into the document. + +Your prompt selects the shape of the operation. You supply the values at run time. One operation then serves many requests. + +This matters for cost. Generation takes 10 to 30 seconds and uses a language model. A parameterized operation removes that cost from your request path. + + +Do not set `expose_schema` to `true` with schema discovery. `get_schema` returns the full schema, and that is the context cost that schema discovery removes. + + +## Next steps + + + + Index a schema and generate your first operation. + + + Solve one task at a time. + + + Every input and every response field. + + + Every configuration key and environment variable. + + diff --git a/docs-website/router/mcp/schema-discovery/quickstart.mdx b/docs-website/router/mcp/schema-discovery/quickstart.mdx new file mode 100644 index 0000000000..02ea34e259 --- /dev/null +++ b/docs-website/router/mcp/schema-discovery/quickstart.mdx @@ -0,0 +1,212 @@ +--- +title: 'Quickstart' +description: 'Index your schema and generate your first GraphQL operation from a prompt.' +icon: 'rocket' +--- + +This tutorial takes about 10 minutes. Follow every step in order. Each step shows what you must see before you continue. + +## Prerequisites + +- A running Cosmo Router with a composed schema. See [Router Introduction](/router/intro). +- The base URL of a schema discovery service. +- A bearer token for that service, if it needs one. +- An MCP client, such as Claude Code, Claude Desktop, or Cursor. + +## Step 1 - Turn on schema discovery + +Add this block to your router config file. + +```yaml router.config.yaml +mcp: + enabled: true + server: + listen_addr: 'localhost:5025' + + # Keep this false. get_schema returns the full schema, and that is the + # context cost that schema discovery removes. + expose_schema: false + + # Let the agent run the operation that generate_query returns. + enable_arbitrary_operations: true + + schema_discovery: + enabled: true + url: 'https://discovery.example.com' + token: 'your-token' +``` + +Leave `token` empty if your service runs with authentication off. + + +The router does not start when `schema_discovery.enabled` is `true` and `url` is empty. This is deliberate. It stops a server whose tools always fail. + + +## Step 2 - Start the router + +```bash +./router --config router.config.yaml +``` + +Read the log. You must see two lines. + +``` +INFO MCP schema discovery enabled url=https://discovery.example.com authenticated=true +INFO schema index is building index_id=sha256:6926769e... +``` + +Then, within about a minute: + +``` +INFO schema index is ready index_id=sha256:6926769e... symbol_count=318 +``` + +The index now serves requests. A schema of 16,000 lines takes about 24 seconds. + +The router does not wait for the index. It serves GraphQL from the first moment. + + +The `index_id` is the SHA-256 hash of your schema. Compute it yourself at any time: + +```bash +printf 'sha256:%s\n' "$(shasum -a 256 ./schema.graphql | cut -d' ' -f1)" +``` + + +## Step 3 - Connect an MCP client + +Point your client at the MCP endpoint. + +```json +{ + "mcpServers": { + "cosmo": { + "type": "http", + "url": "http://localhost:5025/mcp" + } + } +} +``` + +List the tools. You must see three new names: + +- `search_schema` +- `get_symbols` +- `generate_query` + +## Step 4 - Find out what the API does + +Ask the client to search your schema. Use your own words, not field names. + +```json +{ + "query": "employee details", + "kinds": ["field"], + "limit": 3 +} +``` + +You get ranked coordinates and their records. + +```json +{ + "hits": [ + { + "coordinate": "field:Employee.details", + "score": 0.049, + "record": { "Coordinate": "field:Employee.details", "TypeRef": "Details" } + } + ] +} +``` + +Keep `limit` low. The records are large. + +## Step 5 - Generate an operation + +Now ask for the data that you want. Do not write GraphQL. + +```json +{ + "prompt": "list all employees with their id, first name, last name and current mood" +} +``` + +This call takes 10 to 30 seconds. Wait for it. + +```json +{ + "queries": [ + { + "description": "Lists employees with their ids, forenames, surnames, and current moods.", + "document": "query Query {\n employees {\n id\n details {\n forename\n surname\n }\n currentMood\n }\n}", + "operationName": "Query", + "operationType": "query", + "variablesSchema": { "type": "object", "additionalProperties": false } + } + ], + "guidance": { + "endpoint": "http://localhost:3002/graphql", + "nextSteps": ["Run the operation against the endpoint. ..."] + } +} +``` + +The document is valid against your schema. The service checked it before it answered. + +## Step 6 - Run the operation + +Send the document to your router. + +```bash +curl -sX POST http://localhost:3002/graphql \ + -H 'Content-Type: application/json' \ + -d '{ + "query": "query Query { employees { id details { forename surname } currentMood } }" + }' +``` + +You now have data. + +```json +{ + "data": { + "employees": [ + { "id": 1, "details": { "forename": "Jens", "surname": "Neuse" }, "currentMood": "HAPPY" } + ] + } +} +``` + +The tutorial is complete. + +## Step 7 - Ask for something that does not exist + +Send a prompt that your schema cannot answer. + +```json +{ "prompt": "list the invoices for a customer with their billing address" } +``` + +You get no queries and one reason. + +```json +{ + "unsatisfied": [ + "The indexed schema exposes products, employees, locations, and work reviews, but no invoice entity, billing address, or payment status." + ] +} +``` + +This result is correct. It is not an error. It tells you that the capability does not exist, so you build it instead of searching for it. + +## Next steps + + + + Find duplicate work, or turn an operation into a tool. + + + Every input and every response field. + + diff --git a/docs-website/router/mcp/schema-discovery/tools.mdx b/docs-website/router/mcp/schema-discovery/tools.mdx new file mode 100644 index 0000000000..fce0290072 --- /dev/null +++ b/docs-website/router/mcp/schema-discovery/tools.mdx @@ -0,0 +1,164 @@ +--- +title: 'Tools' +description: 'Reference for the search_schema, get_symbols and generate_query tools.' +icon: 'wrench' +--- + +The MCP server registers these three tools when `mcp.schema_discovery.enabled` is `true`. + +All three tools are read only. None of them changes your data or your schema. + +## search_schema + +Ranks schema elements against a topic. + +### Input + +| Field | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `query` | string | yes | | The topic, in your own words. | +| `kinds` | string[] | no | all | Restrict to these kinds. See [Kinds](#kinds). | +| `limit` | integer | no | 10 | The number of hits to return. | +| `parent` | string | no | | Restrict to the members of one coordinate, for example `object:Query`. | +| `paginated` | boolean | no | | `true` keeps only Relay connection fields. `false` removes them. | + +### Kinds + +`object`, `interface`, `union`, `enum`, `scalar`, `input`, `field`, `input_field`, `path`. + +An unknown kind matches nothing. The tool schema constrains the input to this list. + +### Response + +```json +{ + "hits": [ + { + "coordinate": "field:Employee.details", + "score": 0.049, + "record": { "Coordinate": "field:Employee.details", "TypeRef": "Details" } + } + ] +} +``` + +| Field | Type | Description | +| --- | --- | --- | +| `hits[].coordinate` | string | The canonical `kind:Type.field` path. | +| `hits[].score` | number | The relevance rank. | +| `hits[].record` | object | The full record of the element. | + +The `hits` list is absent when nothing matches. + + +The records are large. Use a low `limit`. + + +## get_symbols + +Reads the full record for each coordinate. + +### Input + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `coordinates` | string[] | yes | The coordinates to read. At least one. | + +Copy each coordinate from a `search_schema` result. A coordinate has one of these forms: + +- `field:Type.fieldName` +- `object:TypeName` +- `input:TypeName` +- `enum:TypeName` + +### Response + +```json +{ + "symbols": [ + { "coordinate": "enum:Mood", "record": { "Values": [{ "Name": "HAPPY" }, { "Name": "SAD" }] } } + ] +} +``` + +The response keeps the order of the request. It omits a coordinate that the index does not hold. A short response is not an error. + +## generate_query + +Makes a GraphQL operation from a prompt. + +This tool takes 10 to 30 seconds. + +### Input + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `prompt` | string | yes | A description of the data that you want, in your own words. | + +Write the entities and the fields that you want. Give the filter conditions and the sort order. Do not write GraphQL syntax. Do not guess type names. + +### Response + +```json +{ + "queries": [ + { + "description": "Lists employees with their ids and current moods.", + "document": "query Query { employees { id currentMood } }", + "operationName": "Query", + "operationType": "query", + "variablesSchema": { "type": "object", "additionalProperties": false } + } + ], + "unsatisfied": [], + "truncated": false, + "guidance": { + "endpoint": "http://localhost:3002/graphql", + "nextSteps": ["Run the operation against the endpoint. ..."] + } +} +``` + +| Field | Type | Description | +| --- | --- | --- | +| `queries[].document` | string | The operation text. It is valid against your schema. | +| `queries[].operationName` | string | The name to send with the request. | +| `queries[].operationType` | string | `query`, `mutation`, or `subscription`. | +| `queries[].variablesSchema` | object | A JSON Schema for the variables. | +| `queries[].description` | string | One line that says what the operation does. | +| `unsatisfied[]` | string[] | What the schema cannot answer, and why. | +| `truncated` | boolean | `true` if the service stopped early. | +| `guidance` | object | What to do with the operation. Absent when there is no operation. | + + +A response with no queries and one `unsatisfied` reason is a normal result. It is not an error. It tells you that your schema cannot answer the request. + + +### Parameterized operations + +A value in your prompt becomes a GraphQL variable. It does not become a literal. + +A prompt that asks for "the first 10 employees" returns a variable for the count, with the default from your schema. Your prompt selects the shape of the operation. You supply the values at run time. + +One operation thus serves many different inputs. Generate one time, then call many times. + +## Errors + +Each tool returns a readable message. The tools return no protocol error, because an agent can read a tool result and act on it. + +| Message | Cause | What the caller does | +| --- | --- | --- | +| The schema index is still building. Retry in a few seconds. | The first build is in flight. | Retry. | +| The schema index expired. It is being rebuilt. Retry in a few seconds. | The service dropped an unused index. The router rebuilds it. | Retry. | +| The schema index is not ready. Retry in a few seconds. | The service reports that the index cannot serve yet. | Retry. | +| Schema discovery is not configured correctly. Contact the router operator. | The token is missing, wrong, or expired. | Do not retry. Tell the operator. | +| The request is not valid. | The input is wrong, or the prompt asks for no data. | Do not retry. Fix the input. | +| The schema discovery service is unreachable. | The router cannot reach the service. | Tell the operator. | + +The router never puts the token into a message or a log. + +## Security + +`search_schema` and `generate_query` show the shape of your schema to any caller that reaches the MCP server. This is the same exposure that `get_schema` gives. + +These tools carry no `@requiresScopes` directive, so the [scope middleware](/router/mcp/oauth/scopes) derives no per-tool scope for them. Use the `tools_call` scope to gate them. diff --git a/docs-website/router/mcp/tools.mdx b/docs-website/router/mcp/tools.mdx index 6523d97cf2..e1ec4426fc 100644 --- a/docs-website/router/mcp/tools.mdx +++ b/docs-website/router/mcp/tools.mdx @@ -24,6 +24,12 @@ The MCP server gives AI models a set of tools they can discover and execute. It intended. Prefer creating focused tools. + + [Schema discovery](/router/mcp/schema-discovery/overview) adds three more built-in tools: `search_schema`, + `get_symbols` and `generate_query`. They let a model search a large schema and generate a valid operation, without + the full schema in its context. Use them instead of `get_schema` when your schema is large. + + ## 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. diff --git a/proto/yoko/v1/yoko.proto b/proto/yoko/v1/yoko.proto new file mode 100644 index 0000000000..cbf111c614 --- /dev/null +++ b/proto/yoko/v1/yoko.proto @@ -0,0 +1,144 @@ +syntax = "proto3"; + +package yoko.v1; + +import "buf/validate/validate.proto"; +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/wundergraph/yoko/gen/yoko/v1;yokov1"; + +service YokoService { + // EnsureIndex is the idempotent write path: it creates the index for this + // SDL on first sight (building asynchronously), retries a failed build, + // schedules an in-place rebuild when the index predates the current + // indexer version, and otherwise no-ops. It returns immediately with the + // index state; poll GetIndex for readiness. + rpc EnsureIndex(EnsureIndexRequest) returns (EnsureIndexResponse); + rpc GetIndex(GetIndexRequest) returns (GetIndexResponse); + rpc ListIndexes(ListIndexesRequest) returns (ListIndexesResponse); + rpc DeleteIndex(DeleteIndexRequest) returns (DeleteIndexResponse); + + rpc SearchSchema(SearchSchemaRequest) returns (SearchSchemaResponse); + rpc GetSymbols(GetSymbolsRequest) returns (GetSymbolsResponse); + + rpc GenerateQuery(GenerateQueryRequest) returns (GenerateQueryResponse); +} + +enum IndexStatus { + INDEX_STATUS_UNSPECIFIED = 0; + // Initial build in flight; not yet servable. + INDEX_STATUS_INDEXING = 1; + // Servable. A READY index keeps serving through in-place rebuilds and + // even after a failed rebuild (see Index.stale / Index.error). + INDEX_STATUS_READY = 2; + // Initial build failed; no symbols exist. EnsureIndex retries it. + INDEX_STATUS_FAILED = 3; +} + +message Index { + // Content address: "sha256:" + hex(SHA-256(sdl bytes)). + string index_id = 1; + IndexStatus status = 2; + // Built by an older indexer version; an in-place rebuild is due. Still + // servable in the meantime. + bool stale = 3; + int64 symbol_count = 4; + google.protobuf.Timestamp created_at = 5; + // When the current symbols finished building. + google.protobuf.Timestamp indexed_at = 6; + // Failure detail: the initial build's when status is FAILED, or the most + // recent rebuild's on a still-READY index. + string error = 7; +} + +// SymbolHit is one symbol record. payload carries the full record as a +// JSON-encoded string; kind, name, and parent live inside it, and kind +// and name are exactly the two halves of coordinate. +message SymbolHit { + // Canonical ":" symbol path; the record key. + string coordinate = 1; + // Relevance rank. Set only by SearchSchema; GetSymbols leaves it 0. + double score = 2; + string payload = 3; +} + +message EnsureIndexRequest { + // Exact SDL bytes; these are what the address hashes. Must not be blank. + string sdl = 1 [(buf.validate.field).string.pattern = "\\S"]; +} +message EnsureIndexResponse { + Index index = 1; +} + +message GetIndexRequest { + string index_id = 1 [(buf.validate.field).string.pattern = "^sha256:[0-9a-f]{64}$"]; +} +message GetIndexResponse { + Index index = 1; +} + +message ListIndexesRequest {} +message ListIndexesResponse { + repeated Index indexes = 1; +} + +message DeleteIndexRequest { + string index_id = 1 [(buf.validate.field).string.pattern = "^sha256:[0-9a-f]{64}$"]; +} +message DeleteIndexResponse {} + +message SearchSchemaRequest { + string index_id = 1 [(buf.validate.field).string.pattern = "^sha256:[0-9a-f]{64}$"]; + string query = 2 [(buf.validate.field).string.pattern = "\\S"]; + // 0 = backend default. + int32 limit = 3 [(buf.validate.field).int32.gte = 0]; + // Restrict to symbol kinds — the ":" coordinate prefixes + // ("object", "interface", "union", "enum", "scalar", "input", "field", + // "input_field", "path"). Unknown kinds match nothing. + repeated string kinds = 4; + repeated string roots = 5; + string parent = 6; + // true restricts to Relay connection (paginated) fields, false to + // non-paginated fields; unset applies no filter. + optional bool paginated = 7; +} +message SearchSchemaResponse { + repeated SymbolHit hits = 1; +} + +message GetSymbolsRequest { + string index_id = 1 [(buf.validate.field).string.pattern = "^sha256:[0-9a-f]{64}$"]; + repeated string coordinates = 2 [(buf.validate.field).repeated.min_items = 1]; +} +message GetSymbolsResponse { + repeated SymbolHit symbols = 1; +} + +message GenerateQueryRequest { + string index_id = 1 [(buf.validate.field).string.pattern = "^sha256:[0-9a-f]{64}$"]; + string prompt = 2 [(buf.validate.field).string.pattern = "\\S"]; +} +message GenerateQueryResponse { + Resolution resolution = 1; +} + +message Resolution { + repeated ResolvedQuery queries = 1; + repeated Unsatisfied unsatisfied = 2; + bool truncated = 3; +} + +message ResolvedQuery { + string description = 1; + string document = 2; + string operation_name = 3; + // The operation keyword as it appears in document: "query", "mutation" + // or "subscription". + string operation_type = 4; + // JSON Schema for the operation's variables, as a JSON-encoded string. + string variables_schema = 5; +} + +message Unsatisfied { + string reason = 1; +} diff --git a/router/core/router.go b/router/core/router.go index 3491a7e516..c3a7f5ea65 100644 --- a/router/core/router.go +++ b/router/core/router.go @@ -1236,6 +1236,10 @@ func (r *Router) startMCPServer(ctx context.Context) error { mcpOpts = append(mcpOpts, mcpserver.WithResourceDocumentation(r.mcp.ResourceDocumentation)) } + if r.mcp.SchemaDiscovery.Enabled { + mcpOpts = append(mcpOpts, mcpserver.WithSchemaDiscovery(&r.mcp.SchemaDiscovery)) + } + mcpGraphQLEndpoint := r.graphqlEndpointURL if r.mcp.RouterURL != "" { mcpGraphQLEndpoint = r.mcp.RouterURL diff --git a/router/gen/proto/yoko/v1/yoko.pb.go b/router/gen/proto/yoko/v1/yoko.pb.go new file mode 100644 index 0000000000..8a2836d6fa --- /dev/null +++ b/router/gen/proto/yoko/v1/yoko.pb.go @@ -0,0 +1,1281 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: yoko/v1/yoko.proto + +package yokov1 + +import ( + _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type IndexStatus int32 + +const ( + IndexStatus_INDEX_STATUS_UNSPECIFIED IndexStatus = 0 + // Initial build in flight; not yet servable. + IndexStatus_INDEX_STATUS_INDEXING IndexStatus = 1 + // Servable. A READY index keeps serving through in-place rebuilds and + // even after a failed rebuild (see Index.stale / Index.error). + IndexStatus_INDEX_STATUS_READY IndexStatus = 2 + // Initial build failed; no symbols exist. EnsureIndex retries it. + IndexStatus_INDEX_STATUS_FAILED IndexStatus = 3 +) + +// Enum value maps for IndexStatus. +var ( + IndexStatus_name = map[int32]string{ + 0: "INDEX_STATUS_UNSPECIFIED", + 1: "INDEX_STATUS_INDEXING", + 2: "INDEX_STATUS_READY", + 3: "INDEX_STATUS_FAILED", + } + IndexStatus_value = map[string]int32{ + "INDEX_STATUS_UNSPECIFIED": 0, + "INDEX_STATUS_INDEXING": 1, + "INDEX_STATUS_READY": 2, + "INDEX_STATUS_FAILED": 3, + } +) + +func (x IndexStatus) Enum() *IndexStatus { + p := new(IndexStatus) + *p = x + return p +} + +func (x IndexStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (IndexStatus) Descriptor() protoreflect.EnumDescriptor { + return file_yoko_v1_yoko_proto_enumTypes[0].Descriptor() +} + +func (IndexStatus) Type() protoreflect.EnumType { + return &file_yoko_v1_yoko_proto_enumTypes[0] +} + +func (x IndexStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use IndexStatus.Descriptor instead. +func (IndexStatus) EnumDescriptor() ([]byte, []int) { + return file_yoko_v1_yoko_proto_rawDescGZIP(), []int{0} +} + +type Index struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Content address: "sha256:" + hex(SHA-256(sdl bytes)). + IndexId string `protobuf:"bytes,1,opt,name=index_id,json=indexId,proto3" json:"index_id,omitempty"` + Status IndexStatus `protobuf:"varint,2,opt,name=status,proto3,enum=yoko.v1.IndexStatus" json:"status,omitempty"` + // Built by an older indexer version; an in-place rebuild is due. Still + // servable in the meantime. + Stale bool `protobuf:"varint,3,opt,name=stale,proto3" json:"stale,omitempty"` + SymbolCount int64 `protobuf:"varint,4,opt,name=symbol_count,json=symbolCount,proto3" json:"symbol_count,omitempty"` + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + // When the current symbols finished building. + IndexedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=indexed_at,json=indexedAt,proto3" json:"indexed_at,omitempty"` + // Failure detail: the initial build's when status is FAILED, or the most + // recent rebuild's on a still-READY index. + Error string `protobuf:"bytes,7,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Index) Reset() { + *x = Index{} + mi := &file_yoko_v1_yoko_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Index) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Index) ProtoMessage() {} + +func (x *Index) ProtoReflect() protoreflect.Message { + mi := &file_yoko_v1_yoko_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Index.ProtoReflect.Descriptor instead. +func (*Index) Descriptor() ([]byte, []int) { + return file_yoko_v1_yoko_proto_rawDescGZIP(), []int{0} +} + +func (x *Index) GetIndexId() string { + if x != nil { + return x.IndexId + } + return "" +} + +func (x *Index) GetStatus() IndexStatus { + if x != nil { + return x.Status + } + return IndexStatus_INDEX_STATUS_UNSPECIFIED +} + +func (x *Index) GetStale() bool { + if x != nil { + return x.Stale + } + return false +} + +func (x *Index) GetSymbolCount() int64 { + if x != nil { + return x.SymbolCount + } + return 0 +} + +func (x *Index) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +func (x *Index) GetIndexedAt() *timestamppb.Timestamp { + if x != nil { + return x.IndexedAt + } + return nil +} + +func (x *Index) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// SymbolHit is one symbol record. payload carries the full record as a +// JSON-encoded string; kind, name, and parent live inside it, and kind +// and name are exactly the two halves of coordinate. +type SymbolHit struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Canonical ":" symbol path; the record key. + Coordinate string `protobuf:"bytes,1,opt,name=coordinate,proto3" json:"coordinate,omitempty"` + // Relevance rank. Set only by SearchSchema; GetSymbols leaves it 0. + Score float64 `protobuf:"fixed64,2,opt,name=score,proto3" json:"score,omitempty"` + Payload string `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SymbolHit) Reset() { + *x = SymbolHit{} + mi := &file_yoko_v1_yoko_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SymbolHit) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SymbolHit) ProtoMessage() {} + +func (x *SymbolHit) ProtoReflect() protoreflect.Message { + mi := &file_yoko_v1_yoko_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SymbolHit.ProtoReflect.Descriptor instead. +func (*SymbolHit) Descriptor() ([]byte, []int) { + return file_yoko_v1_yoko_proto_rawDescGZIP(), []int{1} +} + +func (x *SymbolHit) GetCoordinate() string { + if x != nil { + return x.Coordinate + } + return "" +} + +func (x *SymbolHit) GetScore() float64 { + if x != nil { + return x.Score + } + return 0 +} + +func (x *SymbolHit) GetPayload() string { + if x != nil { + return x.Payload + } + return "" +} + +type EnsureIndexRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Exact SDL bytes; these are what the address hashes. Must not be blank. + Sdl string `protobuf:"bytes,1,opt,name=sdl,proto3" json:"sdl,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EnsureIndexRequest) Reset() { + *x = EnsureIndexRequest{} + mi := &file_yoko_v1_yoko_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EnsureIndexRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EnsureIndexRequest) ProtoMessage() {} + +func (x *EnsureIndexRequest) ProtoReflect() protoreflect.Message { + mi := &file_yoko_v1_yoko_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EnsureIndexRequest.ProtoReflect.Descriptor instead. +func (*EnsureIndexRequest) Descriptor() ([]byte, []int) { + return file_yoko_v1_yoko_proto_rawDescGZIP(), []int{2} +} + +func (x *EnsureIndexRequest) GetSdl() string { + if x != nil { + return x.Sdl + } + return "" +} + +type EnsureIndexResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Index *Index `protobuf:"bytes,1,opt,name=index,proto3" json:"index,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EnsureIndexResponse) Reset() { + *x = EnsureIndexResponse{} + mi := &file_yoko_v1_yoko_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EnsureIndexResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EnsureIndexResponse) ProtoMessage() {} + +func (x *EnsureIndexResponse) ProtoReflect() protoreflect.Message { + mi := &file_yoko_v1_yoko_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EnsureIndexResponse.ProtoReflect.Descriptor instead. +func (*EnsureIndexResponse) Descriptor() ([]byte, []int) { + return file_yoko_v1_yoko_proto_rawDescGZIP(), []int{3} +} + +func (x *EnsureIndexResponse) GetIndex() *Index { + if x != nil { + return x.Index + } + return nil +} + +type GetIndexRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + IndexId string `protobuf:"bytes,1,opt,name=index_id,json=indexId,proto3" json:"index_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetIndexRequest) Reset() { + *x = GetIndexRequest{} + mi := &file_yoko_v1_yoko_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetIndexRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetIndexRequest) ProtoMessage() {} + +func (x *GetIndexRequest) ProtoReflect() protoreflect.Message { + mi := &file_yoko_v1_yoko_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetIndexRequest.ProtoReflect.Descriptor instead. +func (*GetIndexRequest) Descriptor() ([]byte, []int) { + return file_yoko_v1_yoko_proto_rawDescGZIP(), []int{4} +} + +func (x *GetIndexRequest) GetIndexId() string { + if x != nil { + return x.IndexId + } + return "" +} + +type GetIndexResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Index *Index `protobuf:"bytes,1,opt,name=index,proto3" json:"index,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetIndexResponse) Reset() { + *x = GetIndexResponse{} + mi := &file_yoko_v1_yoko_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetIndexResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetIndexResponse) ProtoMessage() {} + +func (x *GetIndexResponse) ProtoReflect() protoreflect.Message { + mi := &file_yoko_v1_yoko_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetIndexResponse.ProtoReflect.Descriptor instead. +func (*GetIndexResponse) Descriptor() ([]byte, []int) { + return file_yoko_v1_yoko_proto_rawDescGZIP(), []int{5} +} + +func (x *GetIndexResponse) GetIndex() *Index { + if x != nil { + return x.Index + } + return nil +} + +type ListIndexesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListIndexesRequest) Reset() { + *x = ListIndexesRequest{} + mi := &file_yoko_v1_yoko_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListIndexesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListIndexesRequest) ProtoMessage() {} + +func (x *ListIndexesRequest) ProtoReflect() protoreflect.Message { + mi := &file_yoko_v1_yoko_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListIndexesRequest.ProtoReflect.Descriptor instead. +func (*ListIndexesRequest) Descriptor() ([]byte, []int) { + return file_yoko_v1_yoko_proto_rawDescGZIP(), []int{6} +} + +type ListIndexesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Indexes []*Index `protobuf:"bytes,1,rep,name=indexes,proto3" json:"indexes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListIndexesResponse) Reset() { + *x = ListIndexesResponse{} + mi := &file_yoko_v1_yoko_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListIndexesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListIndexesResponse) ProtoMessage() {} + +func (x *ListIndexesResponse) ProtoReflect() protoreflect.Message { + mi := &file_yoko_v1_yoko_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListIndexesResponse.ProtoReflect.Descriptor instead. +func (*ListIndexesResponse) Descriptor() ([]byte, []int) { + return file_yoko_v1_yoko_proto_rawDescGZIP(), []int{7} +} + +func (x *ListIndexesResponse) GetIndexes() []*Index { + if x != nil { + return x.Indexes + } + return nil +} + +type DeleteIndexRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + IndexId string `protobuf:"bytes,1,opt,name=index_id,json=indexId,proto3" json:"index_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteIndexRequest) Reset() { + *x = DeleteIndexRequest{} + mi := &file_yoko_v1_yoko_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteIndexRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteIndexRequest) ProtoMessage() {} + +func (x *DeleteIndexRequest) ProtoReflect() protoreflect.Message { + mi := &file_yoko_v1_yoko_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteIndexRequest.ProtoReflect.Descriptor instead. +func (*DeleteIndexRequest) Descriptor() ([]byte, []int) { + return file_yoko_v1_yoko_proto_rawDescGZIP(), []int{8} +} + +func (x *DeleteIndexRequest) GetIndexId() string { + if x != nil { + return x.IndexId + } + return "" +} + +type DeleteIndexResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteIndexResponse) Reset() { + *x = DeleteIndexResponse{} + mi := &file_yoko_v1_yoko_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteIndexResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteIndexResponse) ProtoMessage() {} + +func (x *DeleteIndexResponse) ProtoReflect() protoreflect.Message { + mi := &file_yoko_v1_yoko_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteIndexResponse.ProtoReflect.Descriptor instead. +func (*DeleteIndexResponse) Descriptor() ([]byte, []int) { + return file_yoko_v1_yoko_proto_rawDescGZIP(), []int{9} +} + +type SearchSchemaRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + IndexId string `protobuf:"bytes,1,opt,name=index_id,json=indexId,proto3" json:"index_id,omitempty"` + Query string `protobuf:"bytes,2,opt,name=query,proto3" json:"query,omitempty"` + // 0 = backend default. + Limit int32 `protobuf:"varint,3,opt,name=limit,proto3" json:"limit,omitempty"` + // Restrict to symbol kinds — the ":" coordinate prefixes + // ("object", "interface", "union", "enum", "scalar", "input", "field", + // "input_field", "path"). Unknown kinds match nothing. + Kinds []string `protobuf:"bytes,4,rep,name=kinds,proto3" json:"kinds,omitempty"` + Roots []string `protobuf:"bytes,5,rep,name=roots,proto3" json:"roots,omitempty"` + Parent string `protobuf:"bytes,6,opt,name=parent,proto3" json:"parent,omitempty"` + // true restricts to Relay connection (paginated) fields, false to + // non-paginated fields; unset applies no filter. + Paginated *bool `protobuf:"varint,7,opt,name=paginated,proto3,oneof" json:"paginated,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SearchSchemaRequest) Reset() { + *x = SearchSchemaRequest{} + mi := &file_yoko_v1_yoko_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SearchSchemaRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SearchSchemaRequest) ProtoMessage() {} + +func (x *SearchSchemaRequest) ProtoReflect() protoreflect.Message { + mi := &file_yoko_v1_yoko_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SearchSchemaRequest.ProtoReflect.Descriptor instead. +func (*SearchSchemaRequest) Descriptor() ([]byte, []int) { + return file_yoko_v1_yoko_proto_rawDescGZIP(), []int{10} +} + +func (x *SearchSchemaRequest) GetIndexId() string { + if x != nil { + return x.IndexId + } + return "" +} + +func (x *SearchSchemaRequest) GetQuery() string { + if x != nil { + return x.Query + } + return "" +} + +func (x *SearchSchemaRequest) GetLimit() int32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *SearchSchemaRequest) GetKinds() []string { + if x != nil { + return x.Kinds + } + return nil +} + +func (x *SearchSchemaRequest) GetRoots() []string { + if x != nil { + return x.Roots + } + return nil +} + +func (x *SearchSchemaRequest) GetParent() string { + if x != nil { + return x.Parent + } + return "" +} + +func (x *SearchSchemaRequest) GetPaginated() bool { + if x != nil && x.Paginated != nil { + return *x.Paginated + } + return false +} + +type SearchSchemaResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Hits []*SymbolHit `protobuf:"bytes,1,rep,name=hits,proto3" json:"hits,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SearchSchemaResponse) Reset() { + *x = SearchSchemaResponse{} + mi := &file_yoko_v1_yoko_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SearchSchemaResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SearchSchemaResponse) ProtoMessage() {} + +func (x *SearchSchemaResponse) ProtoReflect() protoreflect.Message { + mi := &file_yoko_v1_yoko_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SearchSchemaResponse.ProtoReflect.Descriptor instead. +func (*SearchSchemaResponse) Descriptor() ([]byte, []int) { + return file_yoko_v1_yoko_proto_rawDescGZIP(), []int{11} +} + +func (x *SearchSchemaResponse) GetHits() []*SymbolHit { + if x != nil { + return x.Hits + } + return nil +} + +type GetSymbolsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + IndexId string `protobuf:"bytes,1,opt,name=index_id,json=indexId,proto3" json:"index_id,omitempty"` + Coordinates []string `protobuf:"bytes,2,rep,name=coordinates,proto3" json:"coordinates,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSymbolsRequest) Reset() { + *x = GetSymbolsRequest{} + mi := &file_yoko_v1_yoko_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSymbolsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSymbolsRequest) ProtoMessage() {} + +func (x *GetSymbolsRequest) ProtoReflect() protoreflect.Message { + mi := &file_yoko_v1_yoko_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSymbolsRequest.ProtoReflect.Descriptor instead. +func (*GetSymbolsRequest) Descriptor() ([]byte, []int) { + return file_yoko_v1_yoko_proto_rawDescGZIP(), []int{12} +} + +func (x *GetSymbolsRequest) GetIndexId() string { + if x != nil { + return x.IndexId + } + return "" +} + +func (x *GetSymbolsRequest) GetCoordinates() []string { + if x != nil { + return x.Coordinates + } + return nil +} + +type GetSymbolsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Symbols []*SymbolHit `protobuf:"bytes,1,rep,name=symbols,proto3" json:"symbols,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSymbolsResponse) Reset() { + *x = GetSymbolsResponse{} + mi := &file_yoko_v1_yoko_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSymbolsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSymbolsResponse) ProtoMessage() {} + +func (x *GetSymbolsResponse) ProtoReflect() protoreflect.Message { + mi := &file_yoko_v1_yoko_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSymbolsResponse.ProtoReflect.Descriptor instead. +func (*GetSymbolsResponse) Descriptor() ([]byte, []int) { + return file_yoko_v1_yoko_proto_rawDescGZIP(), []int{13} +} + +func (x *GetSymbolsResponse) GetSymbols() []*SymbolHit { + if x != nil { + return x.Symbols + } + return nil +} + +type GenerateQueryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + IndexId string `protobuf:"bytes,1,opt,name=index_id,json=indexId,proto3" json:"index_id,omitempty"` + Prompt string `protobuf:"bytes,2,opt,name=prompt,proto3" json:"prompt,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GenerateQueryRequest) Reset() { + *x = GenerateQueryRequest{} + mi := &file_yoko_v1_yoko_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GenerateQueryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GenerateQueryRequest) ProtoMessage() {} + +func (x *GenerateQueryRequest) ProtoReflect() protoreflect.Message { + mi := &file_yoko_v1_yoko_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GenerateQueryRequest.ProtoReflect.Descriptor instead. +func (*GenerateQueryRequest) Descriptor() ([]byte, []int) { + return file_yoko_v1_yoko_proto_rawDescGZIP(), []int{14} +} + +func (x *GenerateQueryRequest) GetIndexId() string { + if x != nil { + return x.IndexId + } + return "" +} + +func (x *GenerateQueryRequest) GetPrompt() string { + if x != nil { + return x.Prompt + } + return "" +} + +type GenerateQueryResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Resolution *Resolution `protobuf:"bytes,1,opt,name=resolution,proto3" json:"resolution,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GenerateQueryResponse) Reset() { + *x = GenerateQueryResponse{} + mi := &file_yoko_v1_yoko_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GenerateQueryResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GenerateQueryResponse) ProtoMessage() {} + +func (x *GenerateQueryResponse) ProtoReflect() protoreflect.Message { + mi := &file_yoko_v1_yoko_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GenerateQueryResponse.ProtoReflect.Descriptor instead. +func (*GenerateQueryResponse) Descriptor() ([]byte, []int) { + return file_yoko_v1_yoko_proto_rawDescGZIP(), []int{15} +} + +func (x *GenerateQueryResponse) GetResolution() *Resolution { + if x != nil { + return x.Resolution + } + return nil +} + +type Resolution struct { + state protoimpl.MessageState `protogen:"open.v1"` + Queries []*ResolvedQuery `protobuf:"bytes,1,rep,name=queries,proto3" json:"queries,omitempty"` + Unsatisfied []*Unsatisfied `protobuf:"bytes,2,rep,name=unsatisfied,proto3" json:"unsatisfied,omitempty"` + Truncated bool `protobuf:"varint,3,opt,name=truncated,proto3" json:"truncated,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Resolution) Reset() { + *x = Resolution{} + mi := &file_yoko_v1_yoko_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Resolution) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Resolution) ProtoMessage() {} + +func (x *Resolution) ProtoReflect() protoreflect.Message { + mi := &file_yoko_v1_yoko_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Resolution.ProtoReflect.Descriptor instead. +func (*Resolution) Descriptor() ([]byte, []int) { + return file_yoko_v1_yoko_proto_rawDescGZIP(), []int{16} +} + +func (x *Resolution) GetQueries() []*ResolvedQuery { + if x != nil { + return x.Queries + } + return nil +} + +func (x *Resolution) GetUnsatisfied() []*Unsatisfied { + if x != nil { + return x.Unsatisfied + } + return nil +} + +func (x *Resolution) GetTruncated() bool { + if x != nil { + return x.Truncated + } + return false +} + +type ResolvedQuery struct { + state protoimpl.MessageState `protogen:"open.v1"` + Description string `protobuf:"bytes,1,opt,name=description,proto3" json:"description,omitempty"` + Document string `protobuf:"bytes,2,opt,name=document,proto3" json:"document,omitempty"` + OperationName string `protobuf:"bytes,3,opt,name=operation_name,json=operationName,proto3" json:"operation_name,omitempty"` + // The operation keyword as it appears in document: "query", "mutation" + // or "subscription". + OperationType string `protobuf:"bytes,4,opt,name=operation_type,json=operationType,proto3" json:"operation_type,omitempty"` + // JSON Schema for the operation's variables, as a JSON-encoded string. + VariablesSchema string `protobuf:"bytes,5,opt,name=variables_schema,json=variablesSchema,proto3" json:"variables_schema,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResolvedQuery) Reset() { + *x = ResolvedQuery{} + mi := &file_yoko_v1_yoko_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResolvedQuery) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResolvedQuery) ProtoMessage() {} + +func (x *ResolvedQuery) ProtoReflect() protoreflect.Message { + mi := &file_yoko_v1_yoko_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResolvedQuery.ProtoReflect.Descriptor instead. +func (*ResolvedQuery) Descriptor() ([]byte, []int) { + return file_yoko_v1_yoko_proto_rawDescGZIP(), []int{17} +} + +func (x *ResolvedQuery) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *ResolvedQuery) GetDocument() string { + if x != nil { + return x.Document + } + return "" +} + +func (x *ResolvedQuery) GetOperationName() string { + if x != nil { + return x.OperationName + } + return "" +} + +func (x *ResolvedQuery) GetOperationType() string { + if x != nil { + return x.OperationType + } + return "" +} + +func (x *ResolvedQuery) GetVariablesSchema() string { + if x != nil { + return x.VariablesSchema + } + return "" +} + +type Unsatisfied struct { + state protoimpl.MessageState `protogen:"open.v1"` + Reason string `protobuf:"bytes,1,opt,name=reason,proto3" json:"reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Unsatisfied) Reset() { + *x = Unsatisfied{} + mi := &file_yoko_v1_yoko_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Unsatisfied) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Unsatisfied) ProtoMessage() {} + +func (x *Unsatisfied) ProtoReflect() protoreflect.Message { + mi := &file_yoko_v1_yoko_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Unsatisfied.ProtoReflect.Descriptor instead. +func (*Unsatisfied) Descriptor() ([]byte, []int) { + return file_yoko_v1_yoko_proto_rawDescGZIP(), []int{18} +} + +func (x *Unsatisfied) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +var File_yoko_v1_yoko_proto protoreflect.FileDescriptor + +const file_yoko_v1_yoko_proto_rawDesc = "" + + "\n" + + "\x12yoko/v1/yoko.proto\x12\ayoko.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x95\x02\n" + + "\x05Index\x12\x19\n" + + "\bindex_id\x18\x01 \x01(\tR\aindexId\x12,\n" + + "\x06status\x18\x02 \x01(\x0e2\x14.yoko.v1.IndexStatusR\x06status\x12\x14\n" + + "\x05stale\x18\x03 \x01(\bR\x05stale\x12!\n" + + "\fsymbol_count\x18\x04 \x01(\x03R\vsymbolCount\x129\n" + + "\n" + + "created_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" + + "\n" + + "indexed_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\tindexedAt\x12\x14\n" + + "\x05error\x18\a \x01(\tR\x05error\"[\n" + + "\tSymbolHit\x12\x1e\n" + + "\n" + + "coordinate\x18\x01 \x01(\tR\n" + + "coordinate\x12\x14\n" + + "\x05score\x18\x02 \x01(\x01R\x05score\x12\x18\n" + + "\apayload\x18\x03 \x01(\tR\apayload\"1\n" + + "\x12EnsureIndexRequest\x12\x1b\n" + + "\x03sdl\x18\x01 \x01(\tB\t\xbaH\x06r\x042\x02\\SR\x03sdl\";\n" + + "\x13EnsureIndexResponse\x12$\n" + + "\x05index\x18\x01 \x01(\v2\x0e.yoko.v1.IndexR\x05index\"J\n" + + "\x0fGetIndexRequest\x127\n" + + "\bindex_id\x18\x01 \x01(\tB\x1c\xbaH\x19r\x172\x15^sha256:[0-9a-f]{64}$R\aindexId\"8\n" + + "\x10GetIndexResponse\x12$\n" + + "\x05index\x18\x01 \x01(\v2\x0e.yoko.v1.IndexR\x05index\"\x14\n" + + "\x12ListIndexesRequest\"?\n" + + "\x13ListIndexesResponse\x12(\n" + + "\aindexes\x18\x01 \x03(\v2\x0e.yoko.v1.IndexR\aindexes\"M\n" + + "\x12DeleteIndexRequest\x127\n" + + "\bindex_id\x18\x01 \x01(\tB\x1c\xbaH\x19r\x172\x15^sha256:[0-9a-f]{64}$R\aindexId\"\x15\n" + + "\x13DeleteIndexResponse\"\x83\x02\n" + + "\x13SearchSchemaRequest\x127\n" + + "\bindex_id\x18\x01 \x01(\tB\x1c\xbaH\x19r\x172\x15^sha256:[0-9a-f]{64}$R\aindexId\x12\x1f\n" + + "\x05query\x18\x02 \x01(\tB\t\xbaH\x06r\x042\x02\\SR\x05query\x12\x1d\n" + + "\x05limit\x18\x03 \x01(\x05B\a\xbaH\x04\x1a\x02(\x00R\x05limit\x12\x14\n" + + "\x05kinds\x18\x04 \x03(\tR\x05kinds\x12\x14\n" + + "\x05roots\x18\x05 \x03(\tR\x05roots\x12\x16\n" + + "\x06parent\x18\x06 \x01(\tR\x06parent\x12!\n" + + "\tpaginated\x18\a \x01(\bH\x00R\tpaginated\x88\x01\x01B\f\n" + + "\n" + + "_paginated\">\n" + + "\x14SearchSchemaResponse\x12&\n" + + "\x04hits\x18\x01 \x03(\v2\x12.yoko.v1.SymbolHitR\x04hits\"x\n" + + "\x11GetSymbolsRequest\x127\n" + + "\bindex_id\x18\x01 \x01(\tB\x1c\xbaH\x19r\x172\x15^sha256:[0-9a-f]{64}$R\aindexId\x12*\n" + + "\vcoordinates\x18\x02 \x03(\tB\b\xbaH\x05\x92\x01\x02\b\x01R\vcoordinates\"B\n" + + "\x12GetSymbolsResponse\x12,\n" + + "\asymbols\x18\x01 \x03(\v2\x12.yoko.v1.SymbolHitR\asymbols\"r\n" + + "\x14GenerateQueryRequest\x127\n" + + "\bindex_id\x18\x01 \x01(\tB\x1c\xbaH\x19r\x172\x15^sha256:[0-9a-f]{64}$R\aindexId\x12!\n" + + "\x06prompt\x18\x02 \x01(\tB\t\xbaH\x06r\x042\x02\\SR\x06prompt\"L\n" + + "\x15GenerateQueryResponse\x123\n" + + "\n" + + "resolution\x18\x01 \x01(\v2\x13.yoko.v1.ResolutionR\n" + + "resolution\"\x94\x01\n" + + "\n" + + "Resolution\x120\n" + + "\aqueries\x18\x01 \x03(\v2\x16.yoko.v1.ResolvedQueryR\aqueries\x126\n" + + "\vunsatisfied\x18\x02 \x03(\v2\x14.yoko.v1.UnsatisfiedR\vunsatisfied\x12\x1c\n" + + "\ttruncated\x18\x03 \x01(\bR\ttruncated\"\xc6\x01\n" + + "\rResolvedQuery\x12 \n" + + "\vdescription\x18\x01 \x01(\tR\vdescription\x12\x1a\n" + + "\bdocument\x18\x02 \x01(\tR\bdocument\x12%\n" + + "\x0eoperation_name\x18\x03 \x01(\tR\roperationName\x12%\n" + + "\x0eoperation_type\x18\x04 \x01(\tR\roperationType\x12)\n" + + "\x10variables_schema\x18\x05 \x01(\tR\x0fvariablesSchema\"%\n" + + "\vUnsatisfied\x12\x16\n" + + "\x06reason\x18\x01 \x01(\tR\x06reason*w\n" + + "\vIndexStatus\x12\x1c\n" + + "\x18INDEX_STATUS_UNSPECIFIED\x10\x00\x12\x19\n" + + "\x15INDEX_STATUS_INDEXING\x10\x01\x12\x16\n" + + "\x12INDEX_STATUS_READY\x10\x02\x12\x17\n" + + "\x13INDEX_STATUS_FAILED\x10\x032\x90\x04\n" + + "\vYokoService\x12H\n" + + "\vEnsureIndex\x12\x1b.yoko.v1.EnsureIndexRequest\x1a\x1c.yoko.v1.EnsureIndexResponse\x12?\n" + + "\bGetIndex\x12\x18.yoko.v1.GetIndexRequest\x1a\x19.yoko.v1.GetIndexResponse\x12H\n" + + "\vListIndexes\x12\x1b.yoko.v1.ListIndexesRequest\x1a\x1c.yoko.v1.ListIndexesResponse\x12H\n" + + "\vDeleteIndex\x12\x1b.yoko.v1.DeleteIndexRequest\x1a\x1c.yoko.v1.DeleteIndexResponse\x12K\n" + + "\fSearchSchema\x12\x1c.yoko.v1.SearchSchemaRequest\x1a\x1d.yoko.v1.SearchSchemaResponse\x12E\n" + + "\n" + + "GetSymbols\x12\x1a.yoko.v1.GetSymbolsRequest\x1a\x1b.yoko.v1.GetSymbolsResponse\x12N\n" + + "\rGenerateQuery\x12\x1d.yoko.v1.GenerateQueryRequest\x1a\x1e.yoko.v1.GenerateQueryResponseB\x93\x01\n" + + "\vcom.yoko.v1B\tYokoProtoP\x01Z yoko.v1.IndexStatus + 20, // 1: yoko.v1.Index.created_at:type_name -> google.protobuf.Timestamp + 20, // 2: yoko.v1.Index.indexed_at:type_name -> google.protobuf.Timestamp + 1, // 3: yoko.v1.EnsureIndexResponse.index:type_name -> yoko.v1.Index + 1, // 4: yoko.v1.GetIndexResponse.index:type_name -> yoko.v1.Index + 1, // 5: yoko.v1.ListIndexesResponse.indexes:type_name -> yoko.v1.Index + 2, // 6: yoko.v1.SearchSchemaResponse.hits:type_name -> yoko.v1.SymbolHit + 2, // 7: yoko.v1.GetSymbolsResponse.symbols:type_name -> yoko.v1.SymbolHit + 17, // 8: yoko.v1.GenerateQueryResponse.resolution:type_name -> yoko.v1.Resolution + 18, // 9: yoko.v1.Resolution.queries:type_name -> yoko.v1.ResolvedQuery + 19, // 10: yoko.v1.Resolution.unsatisfied:type_name -> yoko.v1.Unsatisfied + 3, // 11: yoko.v1.YokoService.EnsureIndex:input_type -> yoko.v1.EnsureIndexRequest + 5, // 12: yoko.v1.YokoService.GetIndex:input_type -> yoko.v1.GetIndexRequest + 7, // 13: yoko.v1.YokoService.ListIndexes:input_type -> yoko.v1.ListIndexesRequest + 9, // 14: yoko.v1.YokoService.DeleteIndex:input_type -> yoko.v1.DeleteIndexRequest + 11, // 15: yoko.v1.YokoService.SearchSchema:input_type -> yoko.v1.SearchSchemaRequest + 13, // 16: yoko.v1.YokoService.GetSymbols:input_type -> yoko.v1.GetSymbolsRequest + 15, // 17: yoko.v1.YokoService.GenerateQuery:input_type -> yoko.v1.GenerateQueryRequest + 4, // 18: yoko.v1.YokoService.EnsureIndex:output_type -> yoko.v1.EnsureIndexResponse + 6, // 19: yoko.v1.YokoService.GetIndex:output_type -> yoko.v1.GetIndexResponse + 8, // 20: yoko.v1.YokoService.ListIndexes:output_type -> yoko.v1.ListIndexesResponse + 10, // 21: yoko.v1.YokoService.DeleteIndex:output_type -> yoko.v1.DeleteIndexResponse + 12, // 22: yoko.v1.YokoService.SearchSchema:output_type -> yoko.v1.SearchSchemaResponse + 14, // 23: yoko.v1.YokoService.GetSymbols:output_type -> yoko.v1.GetSymbolsResponse + 16, // 24: yoko.v1.YokoService.GenerateQuery:output_type -> yoko.v1.GenerateQueryResponse + 18, // [18:25] is the sub-list for method output_type + 11, // [11:18] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name +} + +func init() { file_yoko_v1_yoko_proto_init() } +func file_yoko_v1_yoko_proto_init() { + if File_yoko_v1_yoko_proto != nil { + return + } + file_yoko_v1_yoko_proto_msgTypes[10].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_yoko_v1_yoko_proto_rawDesc), len(file_yoko_v1_yoko_proto_rawDesc)), + NumEnums: 1, + NumMessages: 19, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_yoko_v1_yoko_proto_goTypes, + DependencyIndexes: file_yoko_v1_yoko_proto_depIdxs, + EnumInfos: file_yoko_v1_yoko_proto_enumTypes, + MessageInfos: file_yoko_v1_yoko_proto_msgTypes, + }.Build() + File_yoko_v1_yoko_proto = out.File + file_yoko_v1_yoko_proto_goTypes = nil + file_yoko_v1_yoko_proto_depIdxs = nil +} diff --git a/router/gen/proto/yoko/v1/yokov1connect/yoko.connect.go b/router/gen/proto/yoko/v1/yokov1connect/yoko.connect.go new file mode 100644 index 0000000000..d3ce860546 --- /dev/null +++ b/router/gen/proto/yoko/v1/yokov1connect/yoko.connect.go @@ -0,0 +1,298 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: yoko/v1/yoko.proto + +package yokov1connect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + v1 "github.com/wundergraph/cosmo/router/gen/proto/yoko/v1" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // YokoServiceName is the fully-qualified name of the YokoService service. + YokoServiceName = "yoko.v1.YokoService" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // YokoServiceEnsureIndexProcedure is the fully-qualified name of the YokoService's EnsureIndex RPC. + YokoServiceEnsureIndexProcedure = "/yoko.v1.YokoService/EnsureIndex" + // YokoServiceGetIndexProcedure is the fully-qualified name of the YokoService's GetIndex RPC. + YokoServiceGetIndexProcedure = "/yoko.v1.YokoService/GetIndex" + // YokoServiceListIndexesProcedure is the fully-qualified name of the YokoService's ListIndexes RPC. + YokoServiceListIndexesProcedure = "/yoko.v1.YokoService/ListIndexes" + // YokoServiceDeleteIndexProcedure is the fully-qualified name of the YokoService's DeleteIndex RPC. + YokoServiceDeleteIndexProcedure = "/yoko.v1.YokoService/DeleteIndex" + // YokoServiceSearchSchemaProcedure is the fully-qualified name of the YokoService's SearchSchema + // RPC. + YokoServiceSearchSchemaProcedure = "/yoko.v1.YokoService/SearchSchema" + // YokoServiceGetSymbolsProcedure is the fully-qualified name of the YokoService's GetSymbols RPC. + YokoServiceGetSymbolsProcedure = "/yoko.v1.YokoService/GetSymbols" + // YokoServiceGenerateQueryProcedure is the fully-qualified name of the YokoService's GenerateQuery + // RPC. + YokoServiceGenerateQueryProcedure = "/yoko.v1.YokoService/GenerateQuery" +) + +// These variables are the protoreflect.Descriptor objects for the RPCs defined in this package. +var ( + yokoServiceServiceDescriptor = v1.File_yoko_v1_yoko_proto.Services().ByName("YokoService") + yokoServiceEnsureIndexMethodDescriptor = yokoServiceServiceDescriptor.Methods().ByName("EnsureIndex") + yokoServiceGetIndexMethodDescriptor = yokoServiceServiceDescriptor.Methods().ByName("GetIndex") + yokoServiceListIndexesMethodDescriptor = yokoServiceServiceDescriptor.Methods().ByName("ListIndexes") + yokoServiceDeleteIndexMethodDescriptor = yokoServiceServiceDescriptor.Methods().ByName("DeleteIndex") + yokoServiceSearchSchemaMethodDescriptor = yokoServiceServiceDescriptor.Methods().ByName("SearchSchema") + yokoServiceGetSymbolsMethodDescriptor = yokoServiceServiceDescriptor.Methods().ByName("GetSymbols") + yokoServiceGenerateQueryMethodDescriptor = yokoServiceServiceDescriptor.Methods().ByName("GenerateQuery") +) + +// YokoServiceClient is a client for the yoko.v1.YokoService service. +type YokoServiceClient interface { + // EnsureIndex is the idempotent write path: it creates the index for this + // SDL on first sight (building asynchronously), retries a failed build, + // schedules an in-place rebuild when the index predates the current + // indexer version, and otherwise no-ops. It returns immediately with the + // index state; poll GetIndex for readiness. + EnsureIndex(context.Context, *connect.Request[v1.EnsureIndexRequest]) (*connect.Response[v1.EnsureIndexResponse], error) + GetIndex(context.Context, *connect.Request[v1.GetIndexRequest]) (*connect.Response[v1.GetIndexResponse], error) + ListIndexes(context.Context, *connect.Request[v1.ListIndexesRequest]) (*connect.Response[v1.ListIndexesResponse], error) + DeleteIndex(context.Context, *connect.Request[v1.DeleteIndexRequest]) (*connect.Response[v1.DeleteIndexResponse], error) + SearchSchema(context.Context, *connect.Request[v1.SearchSchemaRequest]) (*connect.Response[v1.SearchSchemaResponse], error) + GetSymbols(context.Context, *connect.Request[v1.GetSymbolsRequest]) (*connect.Response[v1.GetSymbolsResponse], error) + GenerateQuery(context.Context, *connect.Request[v1.GenerateQueryRequest]) (*connect.Response[v1.GenerateQueryResponse], error) +} + +// NewYokoServiceClient constructs a client for the yoko.v1.YokoService service. By default, it uses +// the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends +// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or +// connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewYokoServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) YokoServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + return &yokoServiceClient{ + ensureIndex: connect.NewClient[v1.EnsureIndexRequest, v1.EnsureIndexResponse]( + httpClient, + baseURL+YokoServiceEnsureIndexProcedure, + connect.WithSchema(yokoServiceEnsureIndexMethodDescriptor), + connect.WithClientOptions(opts...), + ), + getIndex: connect.NewClient[v1.GetIndexRequest, v1.GetIndexResponse]( + httpClient, + baseURL+YokoServiceGetIndexProcedure, + connect.WithSchema(yokoServiceGetIndexMethodDescriptor), + connect.WithClientOptions(opts...), + ), + listIndexes: connect.NewClient[v1.ListIndexesRequest, v1.ListIndexesResponse]( + httpClient, + baseURL+YokoServiceListIndexesProcedure, + connect.WithSchema(yokoServiceListIndexesMethodDescriptor), + connect.WithClientOptions(opts...), + ), + deleteIndex: connect.NewClient[v1.DeleteIndexRequest, v1.DeleteIndexResponse]( + httpClient, + baseURL+YokoServiceDeleteIndexProcedure, + connect.WithSchema(yokoServiceDeleteIndexMethodDescriptor), + connect.WithClientOptions(opts...), + ), + searchSchema: connect.NewClient[v1.SearchSchemaRequest, v1.SearchSchemaResponse]( + httpClient, + baseURL+YokoServiceSearchSchemaProcedure, + connect.WithSchema(yokoServiceSearchSchemaMethodDescriptor), + connect.WithClientOptions(opts...), + ), + getSymbols: connect.NewClient[v1.GetSymbolsRequest, v1.GetSymbolsResponse]( + httpClient, + baseURL+YokoServiceGetSymbolsProcedure, + connect.WithSchema(yokoServiceGetSymbolsMethodDescriptor), + connect.WithClientOptions(opts...), + ), + generateQuery: connect.NewClient[v1.GenerateQueryRequest, v1.GenerateQueryResponse]( + httpClient, + baseURL+YokoServiceGenerateQueryProcedure, + connect.WithSchema(yokoServiceGenerateQueryMethodDescriptor), + connect.WithClientOptions(opts...), + ), + } +} + +// yokoServiceClient implements YokoServiceClient. +type yokoServiceClient struct { + ensureIndex *connect.Client[v1.EnsureIndexRequest, v1.EnsureIndexResponse] + getIndex *connect.Client[v1.GetIndexRequest, v1.GetIndexResponse] + listIndexes *connect.Client[v1.ListIndexesRequest, v1.ListIndexesResponse] + deleteIndex *connect.Client[v1.DeleteIndexRequest, v1.DeleteIndexResponse] + searchSchema *connect.Client[v1.SearchSchemaRequest, v1.SearchSchemaResponse] + getSymbols *connect.Client[v1.GetSymbolsRequest, v1.GetSymbolsResponse] + generateQuery *connect.Client[v1.GenerateQueryRequest, v1.GenerateQueryResponse] +} + +// EnsureIndex calls yoko.v1.YokoService.EnsureIndex. +func (c *yokoServiceClient) EnsureIndex(ctx context.Context, req *connect.Request[v1.EnsureIndexRequest]) (*connect.Response[v1.EnsureIndexResponse], error) { + return c.ensureIndex.CallUnary(ctx, req) +} + +// GetIndex calls yoko.v1.YokoService.GetIndex. +func (c *yokoServiceClient) GetIndex(ctx context.Context, req *connect.Request[v1.GetIndexRequest]) (*connect.Response[v1.GetIndexResponse], error) { + return c.getIndex.CallUnary(ctx, req) +} + +// ListIndexes calls yoko.v1.YokoService.ListIndexes. +func (c *yokoServiceClient) ListIndexes(ctx context.Context, req *connect.Request[v1.ListIndexesRequest]) (*connect.Response[v1.ListIndexesResponse], error) { + return c.listIndexes.CallUnary(ctx, req) +} + +// DeleteIndex calls yoko.v1.YokoService.DeleteIndex. +func (c *yokoServiceClient) DeleteIndex(ctx context.Context, req *connect.Request[v1.DeleteIndexRequest]) (*connect.Response[v1.DeleteIndexResponse], error) { + return c.deleteIndex.CallUnary(ctx, req) +} + +// SearchSchema calls yoko.v1.YokoService.SearchSchema. +func (c *yokoServiceClient) SearchSchema(ctx context.Context, req *connect.Request[v1.SearchSchemaRequest]) (*connect.Response[v1.SearchSchemaResponse], error) { + return c.searchSchema.CallUnary(ctx, req) +} + +// GetSymbols calls yoko.v1.YokoService.GetSymbols. +func (c *yokoServiceClient) GetSymbols(ctx context.Context, req *connect.Request[v1.GetSymbolsRequest]) (*connect.Response[v1.GetSymbolsResponse], error) { + return c.getSymbols.CallUnary(ctx, req) +} + +// GenerateQuery calls yoko.v1.YokoService.GenerateQuery. +func (c *yokoServiceClient) GenerateQuery(ctx context.Context, req *connect.Request[v1.GenerateQueryRequest]) (*connect.Response[v1.GenerateQueryResponse], error) { + return c.generateQuery.CallUnary(ctx, req) +} + +// YokoServiceHandler is an implementation of the yoko.v1.YokoService service. +type YokoServiceHandler interface { + // EnsureIndex is the idempotent write path: it creates the index for this + // SDL on first sight (building asynchronously), retries a failed build, + // schedules an in-place rebuild when the index predates the current + // indexer version, and otherwise no-ops. It returns immediately with the + // index state; poll GetIndex for readiness. + EnsureIndex(context.Context, *connect.Request[v1.EnsureIndexRequest]) (*connect.Response[v1.EnsureIndexResponse], error) + GetIndex(context.Context, *connect.Request[v1.GetIndexRequest]) (*connect.Response[v1.GetIndexResponse], error) + ListIndexes(context.Context, *connect.Request[v1.ListIndexesRequest]) (*connect.Response[v1.ListIndexesResponse], error) + DeleteIndex(context.Context, *connect.Request[v1.DeleteIndexRequest]) (*connect.Response[v1.DeleteIndexResponse], error) + SearchSchema(context.Context, *connect.Request[v1.SearchSchemaRequest]) (*connect.Response[v1.SearchSchemaResponse], error) + GetSymbols(context.Context, *connect.Request[v1.GetSymbolsRequest]) (*connect.Response[v1.GetSymbolsResponse], error) + GenerateQuery(context.Context, *connect.Request[v1.GenerateQueryRequest]) (*connect.Response[v1.GenerateQueryResponse], error) +} + +// NewYokoServiceHandler builds an HTTP handler from the service implementation. It returns the path +// on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewYokoServiceHandler(svc YokoServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + yokoServiceEnsureIndexHandler := connect.NewUnaryHandler( + YokoServiceEnsureIndexProcedure, + svc.EnsureIndex, + connect.WithSchema(yokoServiceEnsureIndexMethodDescriptor), + connect.WithHandlerOptions(opts...), + ) + yokoServiceGetIndexHandler := connect.NewUnaryHandler( + YokoServiceGetIndexProcedure, + svc.GetIndex, + connect.WithSchema(yokoServiceGetIndexMethodDescriptor), + connect.WithHandlerOptions(opts...), + ) + yokoServiceListIndexesHandler := connect.NewUnaryHandler( + YokoServiceListIndexesProcedure, + svc.ListIndexes, + connect.WithSchema(yokoServiceListIndexesMethodDescriptor), + connect.WithHandlerOptions(opts...), + ) + yokoServiceDeleteIndexHandler := connect.NewUnaryHandler( + YokoServiceDeleteIndexProcedure, + svc.DeleteIndex, + connect.WithSchema(yokoServiceDeleteIndexMethodDescriptor), + connect.WithHandlerOptions(opts...), + ) + yokoServiceSearchSchemaHandler := connect.NewUnaryHandler( + YokoServiceSearchSchemaProcedure, + svc.SearchSchema, + connect.WithSchema(yokoServiceSearchSchemaMethodDescriptor), + connect.WithHandlerOptions(opts...), + ) + yokoServiceGetSymbolsHandler := connect.NewUnaryHandler( + YokoServiceGetSymbolsProcedure, + svc.GetSymbols, + connect.WithSchema(yokoServiceGetSymbolsMethodDescriptor), + connect.WithHandlerOptions(opts...), + ) + yokoServiceGenerateQueryHandler := connect.NewUnaryHandler( + YokoServiceGenerateQueryProcedure, + svc.GenerateQuery, + connect.WithSchema(yokoServiceGenerateQueryMethodDescriptor), + connect.WithHandlerOptions(opts...), + ) + return "/yoko.v1.YokoService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case YokoServiceEnsureIndexProcedure: + yokoServiceEnsureIndexHandler.ServeHTTP(w, r) + case YokoServiceGetIndexProcedure: + yokoServiceGetIndexHandler.ServeHTTP(w, r) + case YokoServiceListIndexesProcedure: + yokoServiceListIndexesHandler.ServeHTTP(w, r) + case YokoServiceDeleteIndexProcedure: + yokoServiceDeleteIndexHandler.ServeHTTP(w, r) + case YokoServiceSearchSchemaProcedure: + yokoServiceSearchSchemaHandler.ServeHTTP(w, r) + case YokoServiceGetSymbolsProcedure: + yokoServiceGetSymbolsHandler.ServeHTTP(w, r) + case YokoServiceGenerateQueryProcedure: + yokoServiceGenerateQueryHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedYokoServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedYokoServiceHandler struct{} + +func (UnimplementedYokoServiceHandler) EnsureIndex(context.Context, *connect.Request[v1.EnsureIndexRequest]) (*connect.Response[v1.EnsureIndexResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("yoko.v1.YokoService.EnsureIndex is not implemented")) +} + +func (UnimplementedYokoServiceHandler) GetIndex(context.Context, *connect.Request[v1.GetIndexRequest]) (*connect.Response[v1.GetIndexResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("yoko.v1.YokoService.GetIndex is not implemented")) +} + +func (UnimplementedYokoServiceHandler) ListIndexes(context.Context, *connect.Request[v1.ListIndexesRequest]) (*connect.Response[v1.ListIndexesResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("yoko.v1.YokoService.ListIndexes is not implemented")) +} + +func (UnimplementedYokoServiceHandler) DeleteIndex(context.Context, *connect.Request[v1.DeleteIndexRequest]) (*connect.Response[v1.DeleteIndexResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("yoko.v1.YokoService.DeleteIndex is not implemented")) +} + +func (UnimplementedYokoServiceHandler) SearchSchema(context.Context, *connect.Request[v1.SearchSchemaRequest]) (*connect.Response[v1.SearchSchemaResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("yoko.v1.YokoService.SearchSchema is not implemented")) +} + +func (UnimplementedYokoServiceHandler) GetSymbols(context.Context, *connect.Request[v1.GetSymbolsRequest]) (*connect.Response[v1.GetSymbolsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("yoko.v1.YokoService.GetSymbols is not implemented")) +} + +func (UnimplementedYokoServiceHandler) GenerateQuery(context.Context, *connect.Request[v1.GenerateQueryRequest]) (*connect.Response[v1.GenerateQueryResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("yoko.v1.YokoService.GenerateQuery is not implemented")) +} diff --git a/router/go.mod b/router/go.mod index 5ba70bfe60..25dae25935 100644 --- a/router/go.mod +++ b/router/go.mod @@ -53,10 +53,11 @@ require ( golang.org/x/sync v0.21.0 golang.org/x/sys v0.46.0 // indirect google.golang.org/grpc v1.82.1 - google.golang.org/protobuf v1.36.11 + google.golang.org/protobuf v1.36.12 ) require ( + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260709200747-435963d16310.1 connectrpc.com/vanguard v0.3.0 github.com/KimMachineGun/automemlimit v0.6.1 github.com/MicahParks/jwkset v0.11.0 diff --git a/router/go.sum b/router/go.sum index 98af7c6676..5b346bfab0 100644 --- a/router/go.sum +++ b/router/go.sum @@ -1,3 +1,5 @@ +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260709200747-435963d16310.1 h1:6nlcxMOui23ZRVAfJM451duu79P1npA5JRdZqMilrrQ= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260709200747-435963d16310.1/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo= connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= connectrpc.com/vanguard v0.3.0 h1:prUKFm8rYDwvpvnOSoqdUowPMK0tRA0pbSrQoMd6Zng= @@ -452,8 +454,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y= gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/router/mcp.schema-discovery.config.yaml b/router/mcp.schema-discovery.config.yaml new file mode 100644 index 0000000000..e4132fb5bc --- /dev/null +++ b/router/mcp.schema-discovery.config.yaml @@ -0,0 +1,63 @@ +# MCP Schema Discovery - Example Config +# yaml-language-server: $schema=./pkg/config/config.schema.json +# +# Example config for running the router MCP server with schema discovery. +# +# Schema discovery indexes the client schema in an external service. An agent +# then searches the schema and generates operations without the schema in its +# context. +# +# Usage (from the router/ directory): +# 1. Start the schema discovery service on port 3400. +# 2. Start the router with this config: +# go run ./cmd/router -config mcp.schema-discovery.config.yaml +# 3. Point any MCP client at http://localhost:5035/mcp +# +# The router logs "schema index is ready" when the index can serve requests. + +version: '1' + +dev_mode: true + +listen_addr: 'localhost:3012' + +# EDIT ME: point this at your own execution config. +execution_config: + file: + path: '../router-tests/testenv/testdata/config.json' + +telemetry: + metrics: + otlp: + enabled: false + prometheus: + enabled: false + tracing: + enabled: false + +mcp: + enabled: true + graph_name: 'my-graph' + omit_tool_name_prefix: true + + server: + listen_addr: 'localhost:5035' + + session: + stateless: true + + # Let the agent run the operation that generate_query returns. + enable_arbitrary_operations: true + + # Keep this false. get_schema returns the full SDL, and that is the context + # cost that schema discovery removes. + expose_schema: false + + schema_discovery: + enabled: true + url: 'http://localhost:3400' + # The service permits an empty token when it runs with authentication off. + token: '' + request_timeout: 90s + index_poll_interval: 2s + index_timeout: 10m diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index 781709a25d..8d79eaebfd 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -1358,6 +1358,29 @@ type MCPConfiguration struct { // ResourceDocumentation is a URL to a human-readable page describing this MCP resource, // its access policies, and how to get started. Included in RFC 9728 Protected Resource Metadata if set. ResourceDocumentation string `yaml:"resource_documentation,omitempty" env:"MCP_RESOURCE_DOCUMENTATION"` + // SchemaDiscovery indexes the client schema in an external discovery service and + // exposes schema search and query generation as MCP tools. It lets an agent work + // with a large schema without the schema in its context. + SchemaDiscovery MCPSchemaDiscoveryConfiguration `yaml:"schema_discovery,omitempty" envPrefix:"MCP_SCHEMA_DISCOVERY_"` +} + +// MCPSchemaDiscoveryConfiguration configures the connection to the schema discovery service. +// +// The service is content addressed. The address of an index is the SHA-256 hash of the +// exact schema bytes, so the router computes it locally and holds no state in the service. +type MCPSchemaDiscoveryConfiguration struct { + Enabled bool `yaml:"enabled" envDefault:"false" env:"ENABLED"` + // URL is the base URL of the discovery service. It is required when enabled is true. + URL string `yaml:"url,omitempty" env:"URL"` + // Token is the bearer token for the discovery service. An empty token sends no + // Authorization header, which the service permits when it runs with authentication off. + Token string `yaml:"token,omitempty" env:"TOKEN"` + // RequestTimeout bounds a single call. Query generation takes 10 to 30 seconds. + RequestTimeout time.Duration `yaml:"request_timeout,omitempty" envDefault:"90s" env:"REQUEST_TIMEOUT"` + // IndexPollInterval is the wait between two index status reads. + IndexPollInterval time.Duration `yaml:"index_poll_interval,omitempty" envDefault:"2s" env:"INDEX_POLL_INTERVAL"` + // IndexTimeout stops waiting for an index that never becomes ready. + IndexTimeout time.Duration `yaml:"index_timeout,omitempty" envDefault:"10m" env:"INDEX_TIMEOUT"` } type MCPOAuthConfiguration struct { diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index 8428a4a0e4..9e7b1775e9 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -2761,6 +2761,51 @@ "description": "A URL to a human-readable page describing this MCP resource, its access policies, and how to get started. Included in the RFC 9728 Protected Resource Metadata response if set.", "format": "http-url" }, + "schema_discovery": { + "type": "object", + "description": "Schema discovery indexes the client schema in an external service and exposes schema search and query generation as MCP tools. An agent then works with a large schema without the schema in its context. Do not set expose_schema to true at the same time, because get_schema returns the full SDL and that is the context cost this feature removes.", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable schema discovery. If the value is true, the router indexes the client schema and registers the search_schema, get_symbols and generate_query tools. The url property is required." + }, + "url": { + "type": "string", + "description": "The base URL of the schema discovery service. The service speaks Connect over HTTP/1.1. The router fails to start when schema discovery is enabled and this value is empty.", + "format": "uri" + }, + "token": { + "type": "string", + "description": "The bearer token for the schema discovery service. An empty token sends no Authorization header, which the service permits when it runs with authentication off." + }, + "request_timeout": { + "type": "string", + "default": "90s", + "duration": { + "minimum": "1s" + }, + "description": "The timeout for a single call to the schema discovery service. Query generation takes 10 to 30 seconds, so a value below 60s is too low. The period is specified as a string with a number and a unit, e.g. 10ms, 1s, 1m, 1h. The supported units are 'ms', 's', 'm', 'h'." + }, + "index_poll_interval": { + "type": "string", + "default": "2s", + "duration": { + "minimum": "1s" + }, + "description": "The wait between two index status reads while the router waits for a build to finish. The period is specified as a string with a number and a unit, e.g. 10ms, 1s, 1m, 1h. The supported units are 'ms', 's', 'm', 'h'." + }, + "index_timeout": { + "type": "string", + "default": "10m", + "duration": { + "minimum": "1s" + }, + "description": "The router stops waiting for an index that does not become ready within this time. A schema of 16,000 lines indexes in about 24 seconds. The period is specified as a string with a number and a unit, e.g. 10ms, 1s, 1m, 1h. The supported units are 'ms', 's', 'm', 'h'." + } + } + }, "oauth": { "type": "object", "description": "OAuth/JWKS authentication configuration for the MCP server. When enabled, MCP tool calls require valid JWT authentication and the server implements OAuth 2.0 discovery mechanisms (RFC 8414, RFC 9728).", diff --git a/router/pkg/config/testdata/config_defaults.json b/router/pkg/config/testdata/config_defaults.json index 080b9f7a30..f8258cb318 100644 --- a/router/pkg/config/testdata/config_defaults.json +++ b/router/pkg/config/testdata/config_defaults.json @@ -226,7 +226,15 @@ "ScopeChallengeIncludeTokenScopes": false, "MaxScopeCombinations": 2048 }, - "ResourceDocumentation": "" + "ResourceDocumentation": "", + "SchemaDiscovery": { + "Enabled": false, + "URL": "", + "Token": "", + "RequestTimeout": 90000000000, + "IndexPollInterval": 2000000000, + "IndexTimeout": 600000000000 + } }, "ConnectRPC": { "Enabled": false, diff --git a/router/pkg/config/testdata/config_full.json b/router/pkg/config/testdata/config_full.json index 446dcc3958..72ada1c53d 100644 --- a/router/pkg/config/testdata/config_full.json +++ b/router/pkg/config/testdata/config_full.json @@ -295,7 +295,15 @@ "ScopeChallengeIncludeTokenScopes": false, "MaxScopeCombinations": 2048 }, - "ResourceDocumentation": "" + "ResourceDocumentation": "", + "SchemaDiscovery": { + "Enabled": false, + "URL": "", + "Token": "", + "RequestTimeout": 90000000000, + "IndexPollInterval": 2000000000, + "IndexTimeout": 600000000000 + } }, "ConnectRPC": { "Enabled": false, diff --git a/router/pkg/mcpserver/schema_discovery_tools.go b/router/pkg/mcpserver/schema_discovery_tools.go new file mode 100644 index 0000000000..eb4a014746 --- /dev/null +++ b/router/pkg/mcpserver/schema_discovery_tools.go @@ -0,0 +1,281 @@ +package mcpserver + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "go.uber.org/zap" + + "github.com/wundergraph/cosmo/router/pkg/querygen" +) + +// symbolKinds are the kinds that the discovery service understands. The tool +// schema constrains the input to this list, so an agent cannot send a kind that +// silently matches nothing. +var symbolKinds = []string{ + "object", "interface", "union", "enum", "scalar", + "input", "field", "input_field", "path", +} + +// schemaDiscoveryToolNames are the tools that this file registers. +var schemaDiscoveryToolNames = []string{"search_schema", "get_symbols", "generate_query"} + +// schemaDiscoveryInstructions is the cross-tool workflow. The router serves it +// as the MCP server instructions only when the operator sets no value of their +// own. +const schemaDiscoveryInstructions = `This server exposes a GraphQL API. The schema is too large to read in full. + +Follow these steps: +1. Use search_schema to find the parts of the schema that relate to your task. +2. Use get_symbols to read the full record for a coordinate that looks correct. +3. Use generate_query to make a validated GraphQL operation from your intent. + +Use generate_query first when you already know the data that you want. Use +search_schema first when you must find out what the API can do. + +Read the unsatisfied field of a generate_query result. An empty queries list with +one unsatisfied reason means that the schema cannot answer the request. The +capability does not exist. Do not try other wordings.` + +// searchSchemaInput defines the input of the search_schema tool. +type searchSchemaInput struct { + Query string `json:"query"` + Kinds []string `json:"kinds,omitempty"` + Limit int32 `json:"limit,omitempty"` + Parent string `json:"parent,omitempty"` + Paginated *bool `json:"paginated,omitempty"` +} + +// getSymbolsInput defines the input of the get_symbols tool. +type getSymbolsInput struct { + Coordinates []string `json:"coordinates"` +} + +// generateQueryInput defines the input of the generate_query tool. +type generateQueryInput struct { + Prompt string `json:"prompt"` +} + +// registerSchemaDiscoveryTools adds the schema discovery tools. +// +// Call this before the operations loop. The collision check in registerTools +// then sees these names in registeredTools, so an operation with the same name +// is skipped instead of overwriting a tool. +func (s *GraphQLSchemaServer) registerSchemaDiscoveryTools() { + if s.schemaDiscovery == nil { + return + } + + readOnly := true + openWorld := true + + searchTool := &mcp.Tool{ + Name: "search_schema", + Description: "Search the schema for elements that relate to a topic. The search does not load the full schema.\n\n" + + "The search finds elements by meaning. It also finds elements when the names do not contain your words. The results have a rank.\n\n" + + "Use this tool to find out if the API already does something. Use it before you write an operation. Use it before you build a new feature.\n\n" + + "The tool returns coordinates in the form kind:Type.field. Each coordinate has a description. Then use get_symbols to read a full record. Or use generate_query if you know your intent.\n\n" + + "Rules for the input:\n" + + "- Write the topic in your own words.\n" + + "- Do not guess field names.\n" + + "- Set kinds to [\"field\"] to find operations and data.\n" + + "- Set kinds to [\"object\"] to find types.\n" + + "- Set parent to \"object:Query\" to find read entry points.\n" + + "- Set parent to \"object:Mutation\" to find write entry points.\n" + + "- Set parent to \"object:TypeName\" to find the fields of one type.\n" + + "- Use a low limit value. The payloads are large.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{ + "type": "string", + "description": "The topic to search for, in your own words.", + }, + "kinds": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string", "enum": symbolKinds}, + "description": "Restrict the search to these kinds of schema element.", + }, + "limit": map[string]any{ + "type": "integer", + "minimum": 1, + "description": "The number of hits to return. The default is 10.", + }, + "parent": map[string]any{ + "type": "string", + "description": "Restrict the search to the members of one coordinate, for example \"object:Query\".", + }, + "paginated": map[string]any{ + "type": "boolean", + "description": "Set true to keep only Relay connection fields. Set false to remove them.", + }, + }, + "required": []string{"query"}, + "additionalProperties": false, + }, + Annotations: &mcp.ToolAnnotations{ + Title: "Search GraphQL Schema", + ReadOnlyHint: readOnly, + OpenWorldHint: &openWorld, + }, + } + s.server.AddTool(searchTool, s.handleSearchSchema()) + + symbolsTool := &mcp.Tool{ + Name: "get_symbols", + Description: "Read the full record for each coordinate that you give. A record contains the type, the arguments, the description, and the parent.\n\n" + + "Use this tool after search_schema. Use it when a result looks correct and you need the exact signature.\n\n" + + "A coordinate has one of these forms:\n" + + "- field:Type.fieldName\n" + + "- object:TypeName\n" + + "- input:TypeName\n" + + "- enum:TypeName\n\n" + + "Rules for the input:\n" + + "- Copy each coordinate from a search_schema result.\n" + + "- Do not guess a coordinate.\n" + + "- Give all the coordinates that you need in one call.\n\n" + + "The response omits a coordinate if the index does not hold it. A short response is not an error.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "coordinates": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + "minItems": 1, + "description": "The coordinates to read. Copy each one from a search_schema result.", + }, + }, + "required": []string{"coordinates"}, + "additionalProperties": false, + }, + Annotations: &mcp.ToolAnnotations{ + Title: "Get Schema Symbols", + ReadOnlyHint: readOnly, + OpenWorldHint: &openWorld, + }, + } + s.server.AddTool(symbolsTool, s.handleGetSymbols()) + + generateTool := &mcp.Tool{ + Name: "generate_query", + Description: "This tool takes 10 to 30 seconds. Call it one time and keep the result.\n\n" + + "Make a GraphQL operation from a description of the data that you want. The tool checks the operation against the schema before it returns it. Every field in the operation exists.\n\n" + + "Use this tool when you know the data that you want, but not how the schema shows it. This tool does not put the schema into your context.\n\n" + + "Rules for the prompt:\n" + + "- Write the data that you want in your own words.\n" + + "- Name the entities and the fields that you want.\n" + + "- Give the filter conditions and the sort order.\n" + + "- Do not write GraphQL syntax.\n" + + "- Do not guess type names.\n\n" + + "The operation is parameterized. A value in your prompt becomes a GraphQL variable. It does not become a literal. One operation thus serves many different inputs.\n\n" + + "Always read the unsatisfied field. An empty queries list with one unsatisfied entry means that the schema cannot answer your request. This result is correct. It is not a failure. It tells you that the capability does not exist. Build the capability.\n\n" + + "Use the result in one of two ways:\n" + + "- Run the operation against the router with the variables.\n" + + "- Save the operation as a persisted operation. Deploy it to expose the operation as a tool.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "prompt": map[string]any{ + "type": "string", + "description": "A description of the data that you want, in your own words.", + }, + }, + "required": []string{"prompt"}, + "additionalProperties": false, + }, + Annotations: &mcp.ToolAnnotations{ + Title: "Generate GraphQL Operation", + ReadOnlyHint: readOnly, + OpenWorldHint: &openWorld, + }, + } + s.server.AddTool(generateTool, s.handleGenerateQuery()) + + s.registeredTools = append(s.registeredTools, schemaDiscoveryToolNames...) +} + +// handleSearchSchema returns the handler of the search_schema tool. +func (s *GraphQLSchemaServer) handleSearchSchema() mcp.ToolHandler { + return func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var input searchSchemaInput + if err := json.Unmarshal(request.Params.Arguments, &input); err != nil { + return nil, fmt.Errorf("failed to read the tool input: %w", err) + } + + res, err := s.schemaDiscovery.SearchSchema(ctx, querygen.SearchInput{ + Query: input.Query, + Kinds: input.Kinds, + Limit: input.Limit, + Parent: input.Parent, + Paginated: input.Paginated, + }) + if err != nil { + return s.schemaDiscoveryError("search_schema", err), nil + } + + return jsonToolResult(res) + } +} + +// handleGetSymbols returns the handler of the get_symbols tool. +func (s *GraphQLSchemaServer) handleGetSymbols() mcp.ToolHandler { + return func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var input getSymbolsInput + if err := json.Unmarshal(request.Params.Arguments, &input); err != nil { + return nil, fmt.Errorf("failed to read the tool input: %w", err) + } + + res, err := s.schemaDiscovery.GetSymbols(ctx, input.Coordinates) + if err != nil { + return s.schemaDiscoveryError("get_symbols", err), nil + } + + return jsonToolResult(res) + } +} + +// handleGenerateQuery returns the handler of the generate_query tool. +func (s *GraphQLSchemaServer) handleGenerateQuery() mcp.ToolHandler { + return func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var input generateQueryInput + if err := json.Unmarshal(request.Params.Arguments, &input); err != nil { + return nil, fmt.Errorf("failed to read the tool input: %w", err) + } + + res, err := s.schemaDiscovery.GenerateQuery(ctx, input.Prompt) + if err != nil { + return s.schemaDiscoveryError("generate_query", err), nil + } + + return jsonToolResult(res) + } +} + +// schemaDiscoveryError turns an error into a tool result that a caller can act +// on. +// +// The tools return no protocol error. An agent reads a tool result and acts on +// it. An agent cannot act on a transport failure. +func (s *GraphQLSchemaServer) schemaDiscoveryError(tool string, err error) *mcp.CallToolResult { + s.logger.Debug("schema discovery tool failed", + zap.String("tool", tool), + zap.Error(err)) + + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: querygen.UserMessage(err)}}, + } +} + +// jsonToolResult marshals a value into a tool result. +func jsonToolResult(v any) (*mcp.CallToolResult, error) { + data, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("failed to write the tool result: %w", err) + } + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: string(data)}}, + }, nil +} diff --git a/router/pkg/mcpserver/schema_discovery_tools_test.go b/router/pkg/mcpserver/schema_discovery_tools_test.go new file mode 100644 index 0000000000..88b7afb753 --- /dev/null +++ b/router/pkg/mcpserver/schema_discovery_tools_test.go @@ -0,0 +1,158 @@ +package mcpserver + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" + "github.com/wundergraph/graphql-go-tools/v2/pkg/asttransform" + + yokov1 "github.com/wundergraph/cosmo/router/gen/proto/yoko/v1" + "github.com/wundergraph/cosmo/router/gen/proto/yoko/v1/yokov1connect" + "github.com/wundergraph/cosmo/router/pkg/config" +) + +// testSchemaDoc parses the shared test schema. +func testSchemaDoc(t *testing.T) *ast.Document { + t.Helper() + doc, report := astparser.ParseGraphqlDocumentString(testSchema) + require.False(t, report.HasErrors()) + require.NoError(t, asttransform.MergeDefinitionWithBaseSchema(&doc)) + return &doc +} + +// callToolRequest builds a tool call with the given JSON arguments. +func callToolRequest(t *testing.T, args string) *mcp.CallToolRequest { + t.Helper() + return &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{Arguments: []byte(args)}, + } +} + +// discoveryStub is a minimal stand-in for the discovery service. +type discoveryStub struct { + yokov1connect.UnimplementedYokoServiceHandler + ready bool +} + +func (d *discoveryStub) EnsureIndex(_ context.Context, req *connect.Request[yokov1.EnsureIndexRequest]) (*connect.Response[yokov1.EnsureIndexResponse], error) { + status := yokov1.IndexStatus_INDEX_STATUS_INDEXING + if d.ready { + status = yokov1.IndexStatus_INDEX_STATUS_READY + } + return connect.NewResponse(&yokov1.EnsureIndexResponse{ + Index: &yokov1.Index{IndexId: "sha256:" + testHash, Status: status}, + }), nil +} + +func (d *discoveryStub) GetIndex(_ context.Context, req *connect.Request[yokov1.GetIndexRequest]) (*connect.Response[yokov1.GetIndexResponse], error) { + return connect.NewResponse(&yokov1.GetIndexResponse{ + Index: &yokov1.Index{IndexId: req.Msg.GetIndexId(), Status: yokov1.IndexStatus_INDEX_STATUS_INDEXING}, + }), nil +} + +const testHash = "0000000000000000000000000000000000000000000000000000000000000000" + +// startDiscoveryStub starts a stub service and returns its URL. +func startDiscoveryStub(t *testing.T, ready bool) string { + t.Helper() + mux := http.NewServeMux() + mux.Handle(yokov1connect.NewYokoServiceHandler(&discoveryStub{ready: ready})) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv.URL +} + +func TestSchemaDiscovery_ToolsAppearOnlyWhenEnabled(t *testing.T) { + t.Run("disabled registers no discovery tool", func(t *testing.T) { + s, err := NewGraphQLSchemaServer(context.Background(), "http://localhost:3002/graphql", + WithOperationsDir(t.TempDir())) + require.NoError(t, err) + t.Cleanup(func() { s.cancel() }) + + require.NoError(t, s.Reload(testSchemaDoc(t), nil)) + + for _, name := range schemaDiscoveryToolNames { + assert.NotContains(t, s.registeredTools, name) + } + }) + + t.Run("enabled registers all three tools", func(t *testing.T) { + s, err := NewGraphQLSchemaServer(context.Background(), "http://localhost:3002/graphql", + WithOperationsDir(t.TempDir()), + WithSchemaDiscovery(&config.MCPSchemaDiscoveryConfiguration{ + Enabled: true, + URL: startDiscoveryStub(t, true), + }), + ) + require.NoError(t, err) + t.Cleanup(func() { s.cancel() }) + + require.NoError(t, s.Reload(testSchemaDoc(t), nil)) + + for _, name := range schemaDiscoveryToolNames { + assert.Contains(t, s.registeredTools, name) + } + }) +} + +func TestSchemaDiscovery_MissingURLFailsAtStartup(t *testing.T) { + // A server whose tools always fail is worse than a server that does not + // start. + _, err := NewGraphQLSchemaServer(context.Background(), "http://localhost:3002/graphql", + WithSchemaDiscovery(&config.MCPSchemaDiscoveryConfiguration{Enabled: true}), + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "no url is configured") +} + +func TestSchemaDiscovery_NotReadyReturnsRetryText(t *testing.T) { + s, err := NewGraphQLSchemaServer(context.Background(), "http://localhost:3002/graphql", + WithOperationsDir(t.TempDir()), + WithSchemaDiscovery(&config.MCPSchemaDiscoveryConfiguration{ + Enabled: true, + URL: startDiscoveryStub(t, false), + IndexPollInterval: time.Hour, // never finishes during the test + }), + ) + require.NoError(t, err) + t.Cleanup(func() { s.cancel() }) + + require.NoError(t, s.Reload(testSchemaDoc(t), nil)) + + res, err := s.handleGenerateQuery()(context.Background(), callToolRequest(t, `{"prompt":"anything"}`)) + require.NoError(t, err, "a not ready index is a tool result, not a protocol error") + require.True(t, res.IsError) + + text, ok := res.Content[0].(*mcp.TextContent) + require.True(t, ok) + assert.Contains(t, text.Text, "Retry in a few seconds") +} + +func TestSchemaDiscovery_WriteTimeoutCoversGeneration(t *testing.T) { + // Query generation takes 10 to 30 seconds. The HTTP write timeout must + // exceed the request timeout, or the router cuts off its own response. + t.Run("disabled keeps the default", func(t *testing.T) { + s := &GraphQLSchemaServer{} + assert.Equal(t, defaultWriteTimeout, s.writeTimeout()) + }) + + t.Run("enabled raises above the request timeout", func(t *testing.T) { + s := &GraphQLSchemaServer{schemaDiscoveryRequestTimeout: 90 * time.Second} + assert.Greater(t, s.writeTimeout(), 90*time.Second) + }) + + t.Run("a short request timeout keeps the default floor", func(t *testing.T) { + s := &GraphQLSchemaServer{schemaDiscoveryRequestTimeout: 5 * time.Second} + assert.Equal(t, defaultWriteTimeout, s.writeTimeout()) + }) +} diff --git a/router/pkg/mcpserver/server.go b/router/pkg/mcpserver/server.go index 1a118a6858..004af21a89 100644 --- a/router/pkg/mcpserver/server.go +++ b/router/pkg/mcpserver/server.go @@ -24,6 +24,7 @@ import ( "github.com/wundergraph/cosmo/router/pkg/authentication" "github.com/wundergraph/cosmo/router/pkg/config" "github.com/wundergraph/cosmo/router/pkg/cors" + "github.com/wundergraph/cosmo/router/pkg/querygen" "github.com/wundergraph/cosmo/router/pkg/schemaloader" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" @@ -110,6 +111,10 @@ type Options struct { // ServerDescription is a human-readable description reported in the MCP // serverInfo. ServerDescription string + // SchemaDiscoveryConfig configures the external schema discovery service. + // When it is enabled, the server indexes the client schema and registers the + // search_schema, get_symbols and generate_query tools. + SchemaDiscoveryConfig *config.MCPSchemaDiscoveryConfiguration } // GraphQLSchemaServer represents an MCP server that works with GraphQL schemas and operations @@ -137,6 +142,14 @@ type GraphQLSchemaServer struct { serverBaseURL string resourceDocumentation string authMiddleware *MCPAuthMiddleware + // schemaDiscovery is nil when the feature is disabled. + schemaDiscovery *querygen.Service + // schemaDiscoveryRequestTimeout is zero when the feature is disabled. It + // raises the write timeout of the HTTP server, because query generation + // takes longer than a normal tool call. + schemaDiscoveryRequestTimeout time.Duration + // ctx ends at Stop. It bounds the background index builds. + ctx context.Context } type graphqlRequest struct { @@ -296,6 +309,15 @@ func NewGraphQLSchemaServer(ctx context.Context, routerGraphQLEndpoint string, o zap.String("authorization_server", options.OAuthConfig.AuthorizationServerURL)) } + // Schema discovery works as a three step workflow across three tools, so the + // guidance belongs at server level rather than in each tool. The + // instructions belong to the operator, so supply this text only when the + // operator sets no value of their own. + if options.Instructions == "" && + options.SchemaDiscoveryConfig != nil && options.SchemaDiscoveryConfig.Enabled { + options.Instructions = schemaDiscoveryInstructions + } + // Create the MCP server with all options mcpServer := mcp.NewServer( &mcp.Implementation{ @@ -324,26 +346,54 @@ func NewGraphQLSchemaServer(ctx context.Context, routerGraphQLEndpoint string, o httpClient := retryClient.StandardClient() httpClient.Timeout = 60 * time.Second + // Build the schema discovery client before the server, so a misconfiguration + // fails at startup instead of at the first tool call. + var schemaDiscovery *querygen.Service + var schemaDiscoveryRequestTimeout time.Duration + if options.SchemaDiscoveryConfig != nil && options.SchemaDiscoveryConfig.Enabled { + sdCfg := querygen.Config{ + URL: options.SchemaDiscoveryConfig.URL, + Token: options.SchemaDiscoveryConfig.Token, + RequestTimeout: options.SchemaDiscoveryConfig.RequestTimeout, + IndexPollInterval: options.SchemaDiscoveryConfig.IndexPollInterval, + IndexTimeout: options.SchemaDiscoveryConfig.IndexTimeout, + Logger: options.Logger, + } + if err := sdCfg.Validate(); err != nil { + cancel() + return nil, fmt.Errorf("failed to configure MCP schema discovery: %w", err) + } + schemaDiscovery = querygen.NewService(sdCfg, routerGraphQLEndpoint) + schemaDiscoveryRequestTimeout = sdCfg.RequestTimeout + + options.Logger.Info("MCP schema discovery enabled", + zap.String("url", options.SchemaDiscoveryConfig.URL), + zap.Bool("authenticated", options.SchemaDiscoveryConfig.Token != "")) + } + gs := &GraphQLSchemaServer{ - server: mcpServer, - graphName: options.GraphName, - operationsDir: options.OperationsDir, - listenAddr: options.ListenAddr, - logger: options.Logger, - httpClient: httpClient, - requestTimeout: options.RequestTimeout, - routerGraphQLEndpoint: routerGraphQLEndpoint, - excludeMutations: options.ExcludeMutations, - enableArbitraryOperations: options.EnableArbitraryOperations, - exposeSchema: options.ExposeSchema, - omitToolNamePrefix: options.OmitToolNamePrefix, - stateless: options.Stateless, - corsConfig: options.CorsConfig, - cancel: cancel, - oauthConfig: options.OAuthConfig, - serverBaseURL: options.ServerBaseURL, - resourceDocumentation: options.ResourceDocumentation, - authMiddleware: authMiddleware, + server: mcpServer, + schemaDiscovery: schemaDiscovery, + schemaDiscoveryRequestTimeout: schemaDiscoveryRequestTimeout, + ctx: ctx, + graphName: options.GraphName, + operationsDir: options.OperationsDir, + listenAddr: options.ListenAddr, + logger: options.Logger, + httpClient: httpClient, + requestTimeout: options.RequestTimeout, + routerGraphQLEndpoint: routerGraphQLEndpoint, + excludeMutations: options.ExcludeMutations, + enableArbitraryOperations: options.EnableArbitraryOperations, + exposeSchema: options.ExposeSchema, + omitToolNamePrefix: options.OmitToolNamePrefix, + stateless: options.Stateless, + corsConfig: options.CorsConfig, + cancel: cancel, + oauthConfig: options.OAuthConfig, + serverBaseURL: options.ServerBaseURL, + resourceDocumentation: options.ResourceDocumentation, + authMiddleware: authMiddleware, } return gs, nil @@ -480,13 +530,41 @@ func WithResourceDocumentation(url string) func(*Options) { } } +// WithSchemaDiscovery sets the schema discovery configuration. +func WithSchemaDiscovery(cfg *config.MCPSchemaDiscoveryConfiguration) func(*Options) { + return func(o *Options) { + o.SchemaDiscoveryConfig = cfg + } +} + +// defaultWriteTimeout bounds how long a tool has to write its response. +const defaultWriteTimeout = 30 * time.Second + +// writeTimeout returns the write timeout of the MCP HTTP server. +// +// A tool call must finish inside this window. Query generation takes 10 to 30 +// seconds, so the default of 30 seconds cuts off the response before the tool +// answers. Raise the window above the schema discovery request timeout, and add +// headroom for the router to write the result. +func (s *GraphQLSchemaServer) writeTimeout() time.Duration { + timeout := defaultWriteTimeout + + if s.schemaDiscoveryRequestTimeout > 0 { + if needed := s.schemaDiscoveryRequestTimeout + 10*time.Second; needed > timeout { + timeout = needed + } + } + + return timeout +} + // Serve starts the server with the configured options and returns the HTTP server. func (s *GraphQLSchemaServer) Serve() (*http.Server, error) { // Create custom HTTP server httpServer := &http.Server{ Addr: s.listenAddr, ReadTimeout: 30 * time.Second, - WriteTimeout: 30 * time.Second, + WriteTimeout: s.writeTimeout(), IdleTimeout: 60 * time.Second, } @@ -576,6 +654,19 @@ func (s *GraphQLSchemaServer) Reload(schema *ast.Document, fieldConfigs []*nodev s.schemaCompiler = NewSchemaCompiler(s.logger) s.operationsManager = NewOperationsManager(schema, s.logger, s.excludeMutations) + // Index the client schema. Sync never blocks on the network, so a slow or + // unreachable discovery service cannot delay a router config reload. + if s.schemaDiscovery != nil { + sdl, err := astprinter.PrintString(schema) + if err != nil { + // Do not fail the reload. The router still serves GraphQL, and the + // discovery tools report that the index is not ready. + s.logger.Error("failed to print the client schema for schema discovery", zap.Error(err)) + } else { + s.schemaDiscovery.Sync(s.ctx, sdl) + } + } + if s.operationsDir != "" { if err := s.operationsManager.LoadOperationsFromDirectory(s.operationsDir); err != nil { return fmt.Errorf("failed to load operations: %w", err) @@ -690,6 +781,10 @@ func (s *GraphQLSchemaServer) registerTools() error { s.registeredTools = append(s.registeredTools, "execute_graphql") } + // Register before the operations loop. The collision check below then sees + // these names in registeredTools. + s.registerSchemaDiscoveryTools() + // Get operations filtered by the excludeMutations setting operations := s.operationsManager.GetFilteredOperations() diff --git a/router/pkg/querygen/client.go b/router/pkg/querygen/client.go new file mode 100644 index 0000000000..0982865879 --- /dev/null +++ b/router/pkg/querygen/client.go @@ -0,0 +1,41 @@ +package querygen + +import ( + "context" + "net/http" + + "connectrpc.com/connect" + + "github.com/wundergraph/cosmo/router/gen/proto/yoko/v1/yokov1connect" +) + +// newClient builds a Connect client for the discovery service. +// +// The service speaks Connect over HTTP/1.1. Plain gRPC over cleartext does not +// work, because the server runs no h2c handler. +// +// The transport carries no retry. A GenerateQuery call costs 10 to 30 seconds, +// so a silent retry doubles the wait of the caller. The index poll loop is the +// only retry in this package. +func newClient(cfg Config) yokov1connect.YokoServiceClient { + httpClient := &http.Client{Timeout: cfg.RequestTimeout} + + opts := []connect.ClientOption{} + if cfg.Token != "" { + opts = append(opts, connect.WithInterceptors(bearerInterceptor(cfg.Token))) + } + + return yokov1connect.NewYokoServiceClient(httpClient, cfg.URL, opts...) +} + +// bearerInterceptor sets the Authorization header on every outgoing call. +func bearerInterceptor(token string) connect.UnaryInterceptorFunc { + return func(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + if req.Spec().IsClient { + req.Header().Set("Authorization", "Bearer "+token) + } + return next(ctx, req) + } + } +} diff --git a/router/pkg/querygen/config.go b/router/pkg/querygen/config.go new file mode 100644 index 0000000000..e293679d9e --- /dev/null +++ b/router/pkg/querygen/config.go @@ -0,0 +1,65 @@ +// Package querygen indexes the router client schema in an external schema +// discovery service and exposes search and query generation on top of it. +// +// The service is content addressed. The address of an index is +// "sha256:" + hex(SHA-256(sdl bytes)), so the router computes it locally and +// holds no state in the service. +package querygen + +import ( + "errors" + "time" + + "go.uber.org/zap" +) + +// Default timings. They mirror the documented behaviour of the discovery +// service: GenerateQuery takes 10 to 30 seconds, and a schema of 16,000 lines +// indexes in about 24 seconds. +const ( + DefaultRequestTimeout = 90 * time.Second + DefaultIndexPollInterval = 2 * time.Second + DefaultIndexTimeout = 10 * time.Minute +) + +// Config holds the connection details for the schema discovery service. +type Config struct { + // URL is the base URL of the service. It must contain a scheme. + URL string + // Token is the bearer token. An empty token sends no Authorization header, + // which the service permits when it runs with authentication off. + Token string + // RequestTimeout bounds a single call to the service. + RequestTimeout time.Duration + // IndexPollInterval is the wait between two GetIndex calls. + IndexPollInterval time.Duration + // IndexTimeout stops a poll loop that never reaches a final status. + IndexTimeout time.Duration + + Logger *zap.Logger +} + +// Validate reports whether the config can build a working client. +func (c *Config) Validate() error { + if c.URL == "" { + return errors.New("schema discovery is enabled but no url is configured") + } + return nil +} + +// withDefaults returns a copy with every zero timing replaced by its default. +func (c Config) withDefaults() Config { + if c.RequestTimeout <= 0 { + c.RequestTimeout = DefaultRequestTimeout + } + if c.IndexPollInterval <= 0 { + c.IndexPollInterval = DefaultIndexPollInterval + } + if c.IndexTimeout <= 0 { + c.IndexTimeout = DefaultIndexTimeout + } + if c.Logger == nil { + c.Logger = zap.NewNop() + } + return c +} diff --git a/router/pkg/querygen/indexer.go b/router/pkg/querygen/indexer.go new file mode 100644 index 0000000000..ad9214f6e4 --- /dev/null +++ b/router/pkg/querygen/indexer.go @@ -0,0 +1,249 @@ +package querygen + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "sync" + "time" + + "connectrpc.com/connect" + "go.uber.org/zap" + + yokov1 "github.com/wundergraph/cosmo/router/gen/proto/yoko/v1" + "github.com/wundergraph/cosmo/router/gen/proto/yoko/v1/yokov1connect" +) + +var ( + // ErrIndexNotReady means that no address is adopted yet. The first build is + // still in flight, or the first build failed. + ErrIndexNotReady = errors.New("schema index is not ready") +) + +// Address computes the content address of an SDL. The service uses the same +// rule, so the router never has to ask for it. +func Address(sdl string) string { + sum := sha256.Sum256([]byte(sdl)) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// Indexer keeps one schema indexed in the discovery service. +// +// The indexer adopts a new address only when that address is READY. The +// previous address keeps serving until then, so a router config reload never +// breaks a working tool. +type Indexer struct { + client yokov1connect.YokoServiceClient + cfg Config + logger *zap.Logger + + mu sync.RWMutex + // address is the adopted READY address. It is empty until the first + // successful build. + address string + // pending is the address of the build in flight, if any. + pending string + // lastErr records why the most recent build did not finish. + lastErr error + // cancelPoll stops the poll goroutine of the previous Sync. + cancelPoll context.CancelFunc + // sdl and baseCtx let Resync rebuild an index that the service dropped. + sdl string + baseCtx context.Context +} + +// NewIndexer builds an indexer. The caller must call Validate on the config +// first. +func NewIndexer(cfg Config) *Indexer { + cfg = cfg.withDefaults() + return &Indexer{ + client: newClient(cfg), + cfg: cfg, + logger: cfg.Logger, + } +} + +// Sync makes the discovery service hold an index for this SDL. +// +// Sync never blocks on the network. It compares the local hash and returns. All +// calls to the service happen in a goroutine, so a slow or unreachable service +// cannot delay a router config reload. +func (i *Indexer) Sync(ctx context.Context, sdl string) { + want := Address(sdl) + + i.mu.Lock() + if i.address == want && i.pending == "" { + // The schema did not change and the index already serves. Make no + // network call at all. + i.mu.Unlock() + return + } + if i.pending == want { + // A build for this exact schema is already in flight. + i.mu.Unlock() + return + } + // A different schema arrived. Stop the previous poll. + if i.cancelPoll != nil { + i.cancelPoll() + } + pollCtx, cancel := context.WithCancel(ctx) + i.cancelPoll = cancel + i.pending = want + i.sdl = sdl + i.baseCtx = ctx + i.mu.Unlock() + + go i.build(pollCtx, sdl, want) +} + +// Resync rebuilds the index after the service reports that it is gone. +// +// The service deletes an index that nothing uses for 30 days. The schema then +// produces the same address again, so this recovers without any operator +// action. Resync does nothing while another build is in flight. +func (i *Indexer) Resync() { + i.mu.Lock() + if i.sdl == "" || i.pending != "" { + i.mu.Unlock() + return + } + sdl, baseCtx := i.sdl, i.baseCtx + // Drop the adopted address. It no longer exists in the service. + i.address = "" + want := Address(sdl) + if i.cancelPoll != nil { + i.cancelPoll() + } + pollCtx, cancel := context.WithCancel(baseCtx) + i.cancelPoll = cancel + i.pending = want + i.mu.Unlock() + + go i.build(pollCtx, sdl, want) +} + +// build sends the schema and then waits for the index to become servable. +func (i *Indexer) build(ctx context.Context, sdl, want string) { + log := i.logger.With(zap.String("index_id", want)) + + res, err := i.client.EnsureIndex(ctx, connect.NewRequest(&yokov1.EnsureIndexRequest{Sdl: sdl})) + if err != nil { + if ctx.Err() != nil { + return // a newer Sync replaced this build + } + i.fail(want, fmt.Errorf("failed to send the schema: %w", err)) + log.Error("failed to send the schema to the discovery service", zap.Error(err)) + return + } + + idx := res.Msg.GetIndex() + if got := idx.GetIndexId(); got != want { + // The service disagrees about the address. Trust the service, because + // it holds the index, but record the difference. + log.Warn("the discovery service returned a different address", + zap.String("returned_index_id", got)) + want = got + } + + if idx.GetStatus() == yokov1.IndexStatus_INDEX_STATUS_READY { + i.adopt(want, idx) + log.Info("schema index is ready", + zap.Int64("symbol_count", idx.GetSymbolCount())) + return + } + + log.Info("schema index is building") + i.poll(ctx, want, log) +} + +// poll waits for a final status, or gives up at IndexTimeout. +func (i *Indexer) poll(ctx context.Context, want string, log *zap.Logger) { + deadline := time.NewTimer(i.cfg.IndexTimeout) + defer deadline.Stop() + + ticker := time.NewTicker(i.cfg.IndexPollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return // shutdown, or a newer Sync replaced this build + case <-deadline.C: + i.fail(want, fmt.Errorf("the index did not become ready within %s", i.cfg.IndexTimeout)) + log.Error("gave up waiting for the schema index", + zap.Duration("index_timeout", i.cfg.IndexTimeout)) + return + case <-ticker.C: + res, err := i.client.GetIndex(ctx, connect.NewRequest(&yokov1.GetIndexRequest{IndexId: want})) + if err != nil { + if ctx.Err() != nil { + return + } + // A single read failure is not final. Keep polling until the + // deadline. + log.Debug("failed to read the index status", zap.Error(err)) + continue + } + + idx := res.Msg.GetIndex() + switch idx.GetStatus() { + case yokov1.IndexStatus_INDEX_STATUS_READY: + i.adopt(want, idx) + log.Info("schema index is ready", + zap.Int64("symbol_count", idx.GetSymbolCount())) + return + case yokov1.IndexStatus_INDEX_STATUS_FAILED: + i.fail(want, fmt.Errorf("the index build failed: %s", idx.GetError())) + log.Error("the schema index build failed", + zap.String("service_error", idx.GetError())) + return + } + } + } +} + +// adopt makes an address serve requests. +func (i *Indexer) adopt(address string, idx *yokov1.Index) { + i.mu.Lock() + defer i.mu.Unlock() + i.address = address + i.pending = "" + i.lastErr = nil + + if idx.GetStale() { + // The service schedules the rebuild itself. The index still serves. + i.logger.Info("the schema index is stale and the service will rebuild it", + zap.String("index_id", address)) + } +} + +// fail records why a build did not finish. It leaves any adopted address in +// place, so an older index keeps serving. +func (i *Indexer) fail(address string, err error) { + i.mu.Lock() + defer i.mu.Unlock() + if i.pending == address { + i.pending = "" + } + i.lastErr = err +} + +// CurrentAddress returns the address that serves requests. +// +// It returns ErrIndexNotReady when no address is adopted yet. The error names +// the reason when a build failed. +func (i *Indexer) CurrentAddress() (string, error) { + i.mu.RLock() + defer i.mu.RUnlock() + + if i.address != "" { + return i.address, nil + } + if i.lastErr != nil { + return "", fmt.Errorf("%w: %w", ErrIndexNotReady, i.lastErr) + } + return "", ErrIndexNotReady +} diff --git a/router/pkg/querygen/querygen_test.go b/router/pkg/querygen/querygen_test.go new file mode 100644 index 0000000000..4f679080e0 --- /dev/null +++ b/router/pkg/querygen/querygen_test.go @@ -0,0 +1,418 @@ +package querygen + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + yokov1 "github.com/wundergraph/cosmo/router/gen/proto/yoko/v1" + "github.com/wundergraph/cosmo/router/gen/proto/yoko/v1/yokov1connect" +) + +// fakeService is a programmable stand-in for the discovery service. +type fakeService struct { + yokov1connect.UnimplementedYokoServiceHandler + + mu sync.Mutex + + ensureCalls atomic.Int32 + getCalls atomic.Int32 + + // ensureStatus is the status that EnsureIndex reports. + ensureStatus yokov1.IndexStatus + // getStatuses are returned by GetIndex in order. The last one repeats. + getStatuses []yokov1.IndexStatus + // buildError is reported alongside a FAILED status. + buildError string + // ensureErr makes EnsureIndex fail. + ensureErr error + // stale marks the index as built by an older indexer version. + stale bool + + searchHits []*yokov1.SymbolHit + generateFn func(prompt string) *yokov1.Resolution + searchErr error +} + +func (f *fakeService) EnsureIndex(_ context.Context, req *connect.Request[yokov1.EnsureIndexRequest]) (*connect.Response[yokov1.EnsureIndexResponse], error) { + f.ensureCalls.Add(1) + if f.ensureErr != nil { + return nil, f.ensureErr + } + return connect.NewResponse(&yokov1.EnsureIndexResponse{ + Index: &yokov1.Index{ + IndexId: Address(req.Msg.GetSdl()), + Status: f.ensureStatus, + Stale: f.stale, + }, + }), nil +} + +func (f *fakeService) GetIndex(_ context.Context, req *connect.Request[yokov1.GetIndexRequest]) (*connect.Response[yokov1.GetIndexResponse], error) { + n := int(f.getCalls.Add(1)) + + f.mu.Lock() + defer f.mu.Unlock() + + status := yokov1.IndexStatus_INDEX_STATUS_INDEXING + if len(f.getStatuses) > 0 { + if n-1 < len(f.getStatuses) { + status = f.getStatuses[n-1] + } else { + status = f.getStatuses[len(f.getStatuses)-1] + } + } + + return connect.NewResponse(&yokov1.GetIndexResponse{ + Index: &yokov1.Index{ + IndexId: req.Msg.GetIndexId(), + Status: status, + Error: f.buildError, + SymbolCount: 42, + }, + }), nil +} + +func (f *fakeService) SearchSchema(_ context.Context, _ *connect.Request[yokov1.SearchSchemaRequest]) (*connect.Response[yokov1.SearchSchemaResponse], error) { + if f.searchErr != nil { + return nil, f.searchErr + } + return connect.NewResponse(&yokov1.SearchSchemaResponse{Hits: f.searchHits}), nil +} + +func (f *fakeService) GenerateQuery(_ context.Context, req *connect.Request[yokov1.GenerateQueryRequest]) (*connect.Response[yokov1.GenerateQueryResponse], error) { + return connect.NewResponse(&yokov1.GenerateQueryResponse{ + Resolution: f.generateFn(req.Msg.GetPrompt()), + }), nil +} + +// newTestService starts a fake discovery service and returns a Service wired to +// it. +func newTestService(t *testing.T, fake *fakeService) (*Service, *httptest.Server) { + t.Helper() + + mux := http.NewServeMux() + mux.Handle(yokov1connect.NewYokoServiceHandler(fake)) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + svc := NewService(Config{ + URL: srv.URL, + RequestTimeout: 5 * time.Second, + IndexPollInterval: 10 * time.Millisecond, + IndexTimeout: 2 * time.Second, + }, "http://router.local/graphql") + + return svc, srv +} + +// waitForAddress waits until an address is adopted. +func waitForAddress(t *testing.T, svc *Service) string { + t.Helper() + var address string + require.Eventually(t, func() bool { + a, err := svc.indexer.CurrentAddress() + if err != nil { + return false + } + address = a + return true + }, 2*time.Second, 5*time.Millisecond, "the index never became ready") + return address +} + +func TestAddress_MatchesServiceRule(t *testing.T) { + // "sha256:" plus 64 hex characters is 71 characters. + got := Address("type Query { a: Int }") + assert.Len(t, got, 71) + assert.Equal(t, "sha256:", got[:7]) + + // One changed byte makes a different address. + assert.NotEqual(t, got, Address("type Query { b: Int }")) +} + +func TestSync_NewSchemaBecomesReady(t *testing.T) { + fake := &fakeService{ + ensureStatus: yokov1.IndexStatus_INDEX_STATUS_INDEXING, + getStatuses: []yokov1.IndexStatus{ + yokov1.IndexStatus_INDEX_STATUS_INDEXING, + yokov1.IndexStatus_INDEX_STATUS_READY, + }, + } + svc, _ := newTestService(t, fake) + + const sdl = "type Query { hello: String }" + svc.Sync(context.Background(), sdl) + + // The tool must report not ready while the build runs. + _, err := svc.indexer.CurrentAddress() + require.ErrorIs(t, err, ErrIndexNotReady) + + assert.Equal(t, Address(sdl), waitForAddress(t, svc)) + assert.GreaterOrEqual(t, fake.getCalls.Load(), int32(2)) +} + +func TestSync_AlreadyBuiltAdoptsWithoutPolling(t *testing.T) { + fake := &fakeService{ensureStatus: yokov1.IndexStatus_INDEX_STATUS_READY} + svc, _ := newTestService(t, fake) + + svc.Sync(context.Background(), "type Query { hello: String }") + waitForAddress(t, svc) + + assert.Equal(t, int32(1), fake.ensureCalls.Load()) + assert.Equal(t, int32(0), fake.getCalls.Load(), "an already built index must not be polled") +} + +func TestSync_UnchangedSchemaMakesNoNetworkCall(t *testing.T) { + fake := &fakeService{ensureStatus: yokov1.IndexStatus_INDEX_STATUS_READY} + svc, _ := newTestService(t, fake) + + const sdl = "type Query { hello: String }" + svc.Sync(context.Background(), sdl) + waitForAddress(t, svc) + require.Equal(t, int32(1), fake.ensureCalls.Load()) + + // A reload with the same schema must not call the service at all. + for range 5 { + svc.Sync(context.Background(), sdl) + } + + assert.Equal(t, int32(1), fake.ensureCalls.Load()) + assert.Equal(t, int32(0), fake.getCalls.Load()) +} + +func TestSync_FailedBuildIsReported(t *testing.T) { + fake := &fakeService{ + ensureStatus: yokov1.IndexStatus_INDEX_STATUS_INDEXING, + getStatuses: []yokov1.IndexStatus{yokov1.IndexStatus_INDEX_STATUS_FAILED}, + buildError: "the SDL does not parse", + } + svc, _ := newTestService(t, fake) + + svc.Sync(context.Background(), "not a schema") + + require.Eventually(t, func() bool { + _, err := svc.indexer.CurrentAddress() + return err != nil && errors.Is(err, ErrIndexNotReady) && + strings.Contains(err.Error(), "the SDL does not parse") + }, 2*time.Second, 5*time.Millisecond) +} + +func TestSync_GivesUpAtIndexTimeout(t *testing.T) { + fake := &fakeService{ + ensureStatus: yokov1.IndexStatus_INDEX_STATUS_INDEXING, + getStatuses: []yokov1.IndexStatus{yokov1.IndexStatus_INDEX_STATUS_INDEXING}, + } + + mux := http.NewServeMux() + mux.Handle(yokov1connect.NewYokoServiceHandler(fake)) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + svc := NewService(Config{ + URL: srv.URL, + IndexPollInterval: 5 * time.Millisecond, + IndexTimeout: 50 * time.Millisecond, + }, "http://router.local/graphql") + + svc.Sync(context.Background(), "type Query { hello: String }") + + require.Eventually(t, func() bool { + _, err := svc.indexer.CurrentAddress() + return err != nil && strings.Contains(err.Error(), "did not become ready") + }, 2*time.Second, 5*time.Millisecond) +} + +func TestSync_SchemaChangeKeepsServingTheOldIndex(t *testing.T) { + fake := &fakeService{ensureStatus: yokov1.IndexStatus_INDEX_STATUS_READY} + svc, _ := newTestService(t, fake) + + const oldSDL = "type Query { hello: String }" + svc.Sync(context.Background(), oldSDL) + oldAddress := waitForAddress(t, svc) + + // The next build never finishes. + fake.mu.Lock() + fake.ensureStatus = yokov1.IndexStatus_INDEX_STATUS_INDEXING + fake.getStatuses = []yokov1.IndexStatus{yokov1.IndexStatus_INDEX_STATUS_INDEXING} + fake.mu.Unlock() + + svc.Sync(context.Background(), "type Query { hello: String world: Int }") + + // The old address must keep serving while the new one builds. + time.Sleep(50 * time.Millisecond) + current, err := svc.indexer.CurrentAddress() + require.NoError(t, err) + assert.Equal(t, oldAddress, current, "a reload must not break a working tool") +} + +func TestSync_StaleIndexStillServes(t *testing.T) { + fake := &fakeService{ + ensureStatus: yokov1.IndexStatus_INDEX_STATUS_READY, + stale: true, + } + svc, _ := newTestService(t, fake) + + svc.Sync(context.Background(), "type Query { hello: String }") + assert.NotEmpty(t, waitForAddress(t, svc)) +} + +func TestSearchSchema_DecodesTheRecord(t *testing.T) { + fake := &fakeService{ + ensureStatus: yokov1.IndexStatus_INDEX_STATUS_READY, + searchHits: []*yokov1.SymbolHit{ + { + Coordinate: "field:Query.hello", + Score: 0.9, + // The service sends the record as a JSON encoded string. + Payload: `{"Kind":"field","Name":"hello"}`, + }, + }, + } + svc, _ := newTestService(t, fake) + svc.Sync(context.Background(), "type Query { hello: String }") + waitForAddress(t, svc) + + res, err := svc.SearchSchema(context.Background(), SearchInput{Query: "greeting"}) + require.NoError(t, err) + require.Len(t, res.Hits, 1) + + assert.Equal(t, "field:Query.hello", res.Hits[0].Coordinate) + // The record must be raw JSON, not a JSON string. + assert.JSONEq(t, `{"Kind":"field","Name":"hello"}`, string(res.Hits[0].Record)) +} + +func TestSearchSchema_NotReadyReturnsRetryMessage(t *testing.T) { + fake := &fakeService{ + ensureStatus: yokov1.IndexStatus_INDEX_STATUS_INDEXING, + getStatuses: []yokov1.IndexStatus{yokov1.IndexStatus_INDEX_STATUS_INDEXING}, + } + svc, _ := newTestService(t, fake) + svc.Sync(context.Background(), "type Query { hello: String }") + + _, err := svc.SearchSchema(context.Background(), SearchInput{Query: "anything"}) + require.ErrorIs(t, err, ErrIndexNotReady) + assert.Equal(t, "The schema index is still building. Retry in a few seconds.", UserMessage(err)) +} + +func TestGenerateQuery_DecodesVariablesSchemaAndAddsGuidance(t *testing.T) { + fake := &fakeService{ + ensureStatus: yokov1.IndexStatus_INDEX_STATUS_READY, + generateFn: func(string) *yokov1.Resolution { + return &yokov1.Resolution{ + Queries: []*yokov1.ResolvedQuery{{ + Description: "Reads the greeting.", + Document: "query Q { hello }", + OperationName: "Q", + OperationType: "query", + VariablesSchema: `{"type":"object","properties":{}}`, + }}, + } + }, + } + svc, _ := newTestService(t, fake) + svc.Sync(context.Background(), "type Query { hello: String }") + waitForAddress(t, svc) + + res, err := svc.GenerateQuery(context.Background(), "get the greeting") + require.NoError(t, err) + require.Len(t, res.Queries, 1) + + assert.Equal(t, "query Q { hello }", res.Queries[0].Document) + assert.JSONEq(t, `{"type":"object","properties":{}}`, string(res.Queries[0].VariablesSchema)) + + require.NotNil(t, res.Guidance) + assert.Equal(t, "http://router.local/graphql", res.Guidance.Endpoint) + assert.NotEmpty(t, res.Guidance.NextSteps) +} + +func TestGenerateQuery_UnsatisfiedIsNotAnError(t *testing.T) { + fake := &fakeService{ + ensureStatus: yokov1.IndexStatus_INDEX_STATUS_READY, + generateFn: func(string) *yokov1.Resolution { + return &yokov1.Resolution{ + Unsatisfied: []*yokov1.Unsatisfied{{Reason: "the schema has no billing data"}}, + } + }, + } + svc, _ := newTestService(t, fake) + svc.Sync(context.Background(), "type Query { hello: String }") + waitForAddress(t, svc) + + res, err := svc.GenerateQuery(context.Background(), "list invoices") + require.NoError(t, err, "an unsatisfied result is a normal answer") + assert.Empty(t, res.Queries) + assert.Equal(t, []string{"the schema has no billing data"}, res.Unsatisfied) + assert.Nil(t, res.Guidance, "guidance is pointless without an operation") +} + +func TestUserMessage_MapsEveryServiceCode(t *testing.T) { + cases := []struct { + code connect.Code + want string + }{ + {connect.CodeNotFound, "The schema index expired. It is being rebuilt. Retry in a few seconds."}, + {connect.CodeFailedPrecondition, "The schema index is not ready. Retry in a few seconds."}, + {connect.CodeUnauthenticated, "Schema discovery is not configured correctly. Contact the router operator."}, + } + + for _, tc := range cases { + t.Run(tc.code.String(), func(t *testing.T) { + err := connect.NewError(tc.code, errors.New("boom")) + assert.Equal(t, tc.want, UserMessage(err)) + }) + } +} + +func TestUserMessage_NeverLeaksTheToken(t *testing.T) { + err := connect.NewError(connect.CodeUnauthenticated, errors.New("bad token s3cr3t-value")) + assert.NotContains(t, UserMessage(err), "s3cr3t-value") +} + +func TestBearerToken_SetsTheHeaderOnlyWhenConfigured(t *testing.T) { + var got string + var mu sync.Mutex + + fake := &fakeService{ensureStatus: yokov1.IndexStatus_INDEX_STATUS_READY} + mux := http.NewServeMux() + mux.Handle(yokov1connect.NewYokoServiceHandler(fake)) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + got = r.Header.Get("Authorization") + mu.Unlock() + mux.ServeHTTP(w, r) + })) + t.Cleanup(srv.Close) + + withToken := NewService(Config{URL: srv.URL, Token: "abc123"}, "") + withToken.Sync(context.Background(), "type Query { a: Int }") + waitForAddress(t, withToken) + + mu.Lock() + assert.Equal(t, "Bearer abc123", got) + mu.Unlock() + + noToken := NewService(Config{URL: srv.URL}, "") + noToken.Sync(context.Background(), "type Query { b: Int }") + waitForAddress(t, noToken) + + mu.Lock() + assert.Empty(t, got, "an empty token must send no Authorization header") + mu.Unlock() +} + +func TestConfigValidate_RequiresURL(t *testing.T) { + require.Error(t, (&Config{}).Validate()) + require.NoError(t, (&Config{URL: "http://localhost:3400"}).Validate()) +} diff --git a/router/pkg/querygen/service.go b/router/pkg/querygen/service.go new file mode 100644 index 0000000000..5f3cb605e1 --- /dev/null +++ b/router/pkg/querygen/service.go @@ -0,0 +1,260 @@ +package querygen + +import ( + "context" + "encoding/json" + "errors" + + "connectrpc.com/connect" + + yokov1 "github.com/wundergraph/cosmo/router/gen/proto/yoko/v1" +) + +// Symbol is one schema element. +// +// The service returns the full record as a JSON encoded string. This package +// decodes it, so a caller never parses a string inside a string. +type Symbol struct { + Coordinate string `json:"coordinate"` + Score float64 `json:"score,omitempty"` + Record json.RawMessage `json:"record,omitempty"` +} + +// SearchResult holds the ranked hits of a schema search. +type SearchResult struct { + Hits []Symbol `json:"hits"` +} + +// SymbolsResult holds the full records for the requested coordinates. +type SymbolsResult struct { + Symbols []Symbol `json:"symbols"` +} + +// GeneratedQuery is one operation that the service built and validated against +// the schema. +type GeneratedQuery struct { + Description string `json:"description,omitempty"` + Document string `json:"document"` + OperationName string `json:"operationName"` + OperationType string `json:"operationType"` + // VariablesSchema is a JSON Schema for the variables of the operation. + VariablesSchema json.RawMessage `json:"variablesSchema,omitempty"` +} + +// GenerateResult holds the outcome of a prompt. +// +// A result with no queries and one unsatisfied reason is correct. It means that +// the schema cannot answer the request. +type GenerateResult struct { + Queries []GeneratedQuery `json:"queries"` + Unsatisfied []string `json:"unsatisfied,omitempty"` + Truncated bool `json:"truncated,omitempty"` + Guidance *Guidance `json:"guidance,omitempty"` +} + +// Guidance tells the caller what to do with a generated operation. +type Guidance struct { + Endpoint string `json:"endpoint"` + NextSteps []string `json:"nextSteps"` +} + +// SearchInput holds the arguments of a schema search. +type SearchInput struct { + Query string `json:"query"` + Kinds []string `json:"kinds,omitempty"` + Limit int32 `json:"limit,omitempty"` + Parent string `json:"parent,omitempty"` + Paginated *bool `json:"paginated,omitempty"` +} + +// Service is the tool facing API of this package. +type Service struct { + indexer *Indexer + // graphqlEndpoint is the router endpoint that runs a generated operation. + graphqlEndpoint string +} + +// NewService builds the tool facing API. The caller must call Validate on the +// config first. +func NewService(cfg Config, graphqlEndpoint string) *Service { + return &Service{ + indexer: NewIndexer(cfg), + graphqlEndpoint: graphqlEndpoint, + } +} + +// Sync makes the discovery service hold an index for this schema. It never +// blocks on the network. +func (s *Service) Sync(ctx context.Context, sdl string) { + s.indexer.Sync(ctx, sdl) +} + +// SearchSchema ranks schema elements against a query. +func (s *Service) SearchSchema(ctx context.Context, in SearchInput) (*SearchResult, error) { + address, err := s.indexer.CurrentAddress() + if err != nil { + return nil, err + } + + req := &yokov1.SearchSchemaRequest{ + IndexId: address, + Query: in.Query, + Limit: in.Limit, + Kinds: in.Kinds, + Parent: in.Parent, + Paginated: in.Paginated, + } + + res, err := s.indexer.client.SearchSchema(ctx, connect.NewRequest(req)) + if err != nil { + return nil, s.handle(err) + } + + return &SearchResult{Hits: toSymbols(res.Msg.GetHits())}, nil +} + +// GetSymbols reads the full record for each coordinate. +// +// The response omits a coordinate that the index does not hold. A short +// response is not an error. +func (s *Service) GetSymbols(ctx context.Context, coordinates []string) (*SymbolsResult, error) { + address, err := s.indexer.CurrentAddress() + if err != nil { + return nil, err + } + + req := &yokov1.GetSymbolsRequest{IndexId: address, Coordinates: coordinates} + + res, err := s.indexer.client.GetSymbols(ctx, connect.NewRequest(req)) + if err != nil { + return nil, s.handle(err) + } + + return &SymbolsResult{Symbols: toSymbols(res.Msg.GetSymbols())}, nil +} + +// GenerateQuery turns a prompt into one or more validated operations. +func (s *Service) GenerateQuery(ctx context.Context, prompt string) (*GenerateResult, error) { + address, err := s.indexer.CurrentAddress() + if err != nil { + return nil, err + } + + req := &yokov1.GenerateQueryRequest{IndexId: address, Prompt: prompt} + + res, err := s.indexer.client.GenerateQuery(ctx, connect.NewRequest(req)) + if err != nil { + return nil, s.handle(err) + } + + resolution := res.Msg.GetResolution() + + out := &GenerateResult{ + Queries: make([]GeneratedQuery, 0, len(resolution.GetQueries())), + Truncated: resolution.GetTruncated(), + } + + for _, q := range resolution.GetQueries() { + out.Queries = append(out.Queries, GeneratedQuery{ + Description: q.GetDescription(), + Document: q.GetDocument(), + OperationName: q.GetOperationName(), + OperationType: q.GetOperationType(), + VariablesSchema: decodeJSON(q.GetVariablesSchema()), + }) + } + + for _, u := range resolution.GetUnsatisfied() { + out.Unsatisfied = append(out.Unsatisfied, u.GetReason()) + } + + if len(out.Queries) > 0 { + out.Guidance = &Guidance{ + Endpoint: s.graphqlEndpoint, + NextSteps: []string{ + "Run the operation against the endpoint. Send the document as \"query\" and the values as \"variables\".", + "Read variablesSchema to find the name, the type, and the allowed values of each variable.", + "Or save the document as a persisted operation. Deploy it to expose the operation as its own tool.", + }, + } + } + + return out, nil +} + +// toSymbols converts service hits and decodes the JSON encoded record of each +// one. +func toSymbols(hits []*yokov1.SymbolHit) []Symbol { + out := make([]Symbol, 0, len(hits)) + for _, h := range hits { + out = append(out, Symbol{ + Coordinate: h.GetCoordinate(), + Score: h.GetScore(), + Record: decodeJSON(h.GetPayload()), + }) + } + return out +} + +// decodeJSON turns a JSON encoded string into raw JSON. +// +// It returns nil when the string is empty. It returns the string as a JSON +// string when the content does not parse, so a caller still sees the value. +func decodeJSON(s string) json.RawMessage { + if s == "" { + return nil + } + if json.Valid([]byte(s)) { + return json.RawMessage(s) + } + quoted, err := json.Marshal(s) + if err != nil { + return nil + } + return quoted +} + +// handle maps a service error to an error that a caller can act on. +// +// A not_found means that the index expired. The service deletes an index that +// nothing uses for 30 days. The indexer rebuilds it at the same address. +func (s *Service) handle(err error) error { + var connectErr *connect.Error + if !errors.As(err, &connectErr) { + return err + } + + if connectErr.Code() == connect.CodeNotFound { + s.indexer.Resync() + } + + return err +} + +// UserMessage turns an error into text for a tool caller. +// +// The text tells the caller whether to retry, and never names the token or any +// other secret. +func UserMessage(err error) string { + if errors.Is(err, ErrIndexNotReady) { + return "The schema index is still building. Retry in a few seconds." + } + + var connectErr *connect.Error + if !errors.As(err, &connectErr) { + return "The schema discovery service is unreachable. " + err.Error() + } + + switch connectErr.Code() { + case connect.CodeNotFound: + return "The schema index expired. It is being rebuilt. Retry in a few seconds." + case connect.CodeFailedPrecondition: + return "The schema index is not ready. Retry in a few seconds." + case connect.CodeUnauthenticated, connect.CodePermissionDenied: + return "Schema discovery is not configured correctly. Contact the router operator." + case connect.CodeInvalidArgument: + return "The request is not valid. " + connectErr.Message() + default: + return "The schema discovery service returned an error. " + connectErr.Message() + } +} From 3e24417351ebac4ea240dba327832ef2d348d12d Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Wed, 12 Aug 2026 01:00:21 +0100 Subject: [PATCH 2/5] docs(router): remove filler prose from schema discovery guides --- .../mcp/schema-discovery/configuration.mdx | 30 +++---- .../router/mcp/schema-discovery/guides.mdx | 22 +++-- .../router/mcp/schema-discovery/overview.mdx | 5 +- .../mcp/schema-discovery/quickstart.mdx | 10 +-- .../router/mcp/schema-discovery/tools.mdx | 81 +++++++++---------- 5 files changed, 72 insertions(+), 76 deletions(-) diff --git a/docs-website/router/mcp/schema-discovery/configuration.mdx b/docs-website/router/mcp/schema-discovery/configuration.mdx index 3f5d4a723a..3f99c207f8 100644 --- a/docs-website/router/mcp/schema-discovery/configuration.mdx +++ b/docs-website/router/mcp/schema-discovery/configuration.mdx @@ -21,14 +21,14 @@ mcp: ## Keys -| Key | Type | Default | Environment variable | -| --- | --- | --- | --- | -| `enabled` | boolean | `false` | `MCP_SCHEMA_DISCOVERY_ENABLED` | -| `url` | string | | `MCP_SCHEMA_DISCOVERY_URL` | -| `token` | string | | `MCP_SCHEMA_DISCOVERY_TOKEN` | -| `request_timeout` | duration | `90s` | `MCP_SCHEMA_DISCOVERY_REQUEST_TIMEOUT` | -| `index_poll_interval` | duration | `2s` | `MCP_SCHEMA_DISCOVERY_INDEX_POLL_INTERVAL` | -| `index_timeout` | duration | `10m` | `MCP_SCHEMA_DISCOVERY_INDEX_TIMEOUT` | +| Key | Type | Default | Environment variable | +| --------------------- | -------- | ------- | ------------------------------------------ | +| `enabled` | boolean | `false` | `MCP_SCHEMA_DISCOVERY_ENABLED` | +| `url` | string | | `MCP_SCHEMA_DISCOVERY_URL` | +| `token` | string | | `MCP_SCHEMA_DISCOVERY_TOKEN` | +| `request_timeout` | duration | `90s` | `MCP_SCHEMA_DISCOVERY_REQUEST_TIMEOUT` | +| `index_poll_interval` | duration | `2s` | `MCP_SCHEMA_DISCOVERY_INDEX_POLL_INTERVAL` | +| `index_timeout` | duration | `10m` | `MCP_SCHEMA_DISCOVERY_INDEX_TIMEOUT` | ### enabled @@ -41,7 +41,7 @@ The base URL of the schema discovery service. Include the scheme. The service speaks Connect over HTTP/1.1. -The router does not start when `enabled` is `true` and `url` is empty. This stops a server whose tools always fail. + The router does not start when `enabled` is `true` and `url` is empty. This stops a server whose tools always fail. ### token @@ -102,11 +102,11 @@ During a rebuild the previous index keeps serving. The router adopts the new ind ## Failure behaviour -| Condition | Result | -| --- | --- | -| The service is unreachable at startup | The router starts. The tools report that the index is not ready. | -| The build fails | The router logs the reason. The tools report that the index is not ready. | -| The build exceeds `index_timeout` | The router stops waiting and logs the reason. | -| The service drops an unused index | The router rebuilds it at the same address on the next tool call. | +| Condition | Result | +| ------------------------------------- | ------------------------------------------------------------------------- | +| The service is unreachable at startup | The router starts. The tools report that the index is not ready. | +| The build fails | The router logs the reason. The tools report that the index is not ready. | +| The build exceeds `index_timeout` | The router stops waiting and logs the reason. | +| The service drops an unused index | The router rebuilds it at the same address on the next tool call. | A fault in the discovery service never stops the router from serving GraphQL. diff --git a/docs-website/router/mcp/schema-discovery/guides.mdx b/docs-website/router/mcp/schema-discovery/guides.mdx index 6994c9a187..a3d4bd41a6 100644 --- a/docs-website/router/mcp/schema-discovery/guides.mdx +++ b/docs-website/router/mcp/schema-discovery/guides.mdx @@ -1,16 +1,14 @@ --- title: 'Guides' -description: 'Solve one task at a time: find duplicate work, curate an operation into a tool, or use an operation in a BFF.' +description: 'Find duplicate work before you build it, curate a generated operation into a tool, or ship one in a BFF.' icon: 'list-check' --- -Each guide solves one problem. Read only the guide that you need. +These guides assume a router that runs with schema discovery enabled. To set one up, see the [Quickstart](/router/mcp/schema-discovery/quickstart). ## Find out if a capability already exists -Use this guide before you build a new field, a new resolver, or a new subgraph. - -A large organisation runs many teams. Two teams add the same capability under different names. Schema discovery finds the first one before you build the second. +Two teams in a large organisation often add the same capability under different names. Search before you build a new field, a new resolver, or a new subgraph. Schema discovery finds the first one before you build the second. ### Step 1 - Search by intent @@ -30,10 +28,10 @@ The search matches meaning, not text. It finds `Customer.invoiceAddress` and `Ac Read the result. It gives you a decision. -| Result | Meaning | What you do | -| --- | --- | --- | -| One or more `queries` | The capability exists today. | Use the operation. Do not build it. | -| Empty `queries` and one `unsatisfied` reason | The schema cannot answer this. | Build the capability. | +| Result | Meaning | What you do | +| -------------------------------------------- | ------------------------------ | ----------------------------------- | +| One or more `queries` | The capability exists today. | Use the operation. Do not build it. | +| Empty `queries` and one `unsatisfied` reason | The schema cannot answer this. | Build the capability. | ### Step 3 - Read the reason @@ -49,7 +47,7 @@ The `unsatisfied` reason names what is missing. Collect these reasons across your teams. They tell you what consumers want and your graph does not have. -### What this guide does not tell you +### Limits The index holds the composed schema. It cannot show a subgraph that nobody published yet. Another team can be halfway through the same work. @@ -57,7 +55,7 @@ Check your schema registry as well, before you commit to a build. ## Turn a generated operation into a tool -Use this guide to give an agent a curated tool instead of an open prompt. +Give an agent a curated tool instead of an open prompt. The router generates an operation. The router never publishes it. You review the operation first, then publish it yourself. Your production router then exposes it as its own MCP tool. @@ -117,7 +115,7 @@ This is the curated path. Discovery happens in development. Production runs only ## Use a generated operation in a BFF -Use this guide to put an operation into an application. +A generated operation drops straight into an application. The document is the request, and the variables schema types the inputs. ### Step 1 - Take both fields diff --git a/docs-website/router/mcp/schema-discovery/overview.mdx b/docs-website/router/mcp/schema-discovery/overview.mdx index d5780fd615..4edaf0a6d4 100644 --- a/docs-website/router/mcp/schema-discovery/overview.mdx +++ b/docs-website/router/mcp/schema-discovery/overview.mdx @@ -114,7 +114,8 @@ Your prompt selects the shape of the operation. You supply the values at run tim This matters for cost. Generation takes 10 to 30 seconds and uses a language model. A parameterized operation removes that cost from your request path. -Do not set `expose_schema` to `true` with schema discovery. `get_schema` returns the full schema, and that is the context cost that schema discovery removes. + Do not set `expose_schema` to `true` with schema discovery. `get_schema` returns the full schema, and that is the + context cost that schema discovery removes. ## Next steps @@ -124,7 +125,7 @@ Do not set `expose_schema` to `true` with schema discovery. `get_schema` returns Index a schema and generate your first operation. - Solve one task at a time. + Find duplicate work, curate an operation into a tool, or ship one in a BFF. Every input and every response field. diff --git a/docs-website/router/mcp/schema-discovery/quickstart.mdx b/docs-website/router/mcp/schema-discovery/quickstart.mdx index 02ea34e259..fda1034f75 100644 --- a/docs-website/router/mcp/schema-discovery/quickstart.mdx +++ b/docs-website/router/mcp/schema-discovery/quickstart.mdx @@ -4,7 +4,7 @@ description: 'Index your schema and generate your first GraphQL operation from a icon: 'rocket' --- -This tutorial takes about 10 minutes. Follow every step in order. Each step shows what you must see before you continue. +This tutorial takes about 10 minutes. At the end you have a GraphQL operation that you wrote as a sentence, and the data it returns from your own graph. ## Prerequisites @@ -39,7 +39,8 @@ mcp: Leave `token` empty if your service runs with authentication off. -The router does not start when `schema_discovery.enabled` is `true` and `url` is empty. This is deliberate. It stops a server whose tools always fail. + The router does not start when `schema_discovery.enabled` is `true` and `url` is empty. This is deliberate. It stops a + server whose tools always fail. ## Step 2 - Start the router @@ -71,6 +72,7 @@ The `index_id` is the SHA-256 hash of your schema. Compute it yourself at any ti ```bash printf 'sha256:%s\n' "$(shasum -a 256 ./schema.graphql | cut -d' ' -f1)" ``` + ## Step 3 - Connect an MCP client @@ -171,9 +173,7 @@ You now have data. ```json { "data": { - "employees": [ - { "id": 1, "details": { "forename": "Jens", "surname": "Neuse" }, "currentMood": "HAPPY" } - ] + "employees": [{ "id": 1, "details": { "forename": "Jens", "surname": "Neuse" }, "currentMood": "HAPPY" }] } } ``` diff --git a/docs-website/router/mcp/schema-discovery/tools.mdx b/docs-website/router/mcp/schema-discovery/tools.mdx index fce0290072..fd0605721a 100644 --- a/docs-website/router/mcp/schema-discovery/tools.mdx +++ b/docs-website/router/mcp/schema-discovery/tools.mdx @@ -14,13 +14,13 @@ Ranks schema elements against a topic. ### Input -| Field | Type | Required | Default | Description | -| --- | --- | --- | --- | --- | -| `query` | string | yes | | The topic, in your own words. | -| `kinds` | string[] | no | all | Restrict to these kinds. See [Kinds](#kinds). | -| `limit` | integer | no | 10 | The number of hits to return. | -| `parent` | string | no | | Restrict to the members of one coordinate, for example `object:Query`. | -| `paginated` | boolean | no | | `true` keeps only Relay connection fields. `false` removes them. | +| Field | Type | Required | Default | Description | +| ----------- | -------- | -------- | ------- | ---------------------------------------------------------------------- | +| `query` | string | yes | | The topic, in your own words. | +| `kinds` | string[] | no | all | Restrict to these kinds. See [Kinds](#kinds). | +| `limit` | integer | no | 10 | The number of hits to return. | +| `parent` | string | no | | Restrict to the members of one coordinate, for example `object:Query`. | +| `paginated` | boolean | no | | `true` keeps only Relay connection fields. `false` removes them. | ### Kinds @@ -42,17 +42,15 @@ An unknown kind matches nothing. The tool schema constrains the input to this li } ``` -| Field | Type | Description | -| --- | --- | --- | +| Field | Type | Description | +| ------------------- | ------ | ------------------------------------- | | `hits[].coordinate` | string | The canonical `kind:Type.field` path. | -| `hits[].score` | number | The relevance rank. | -| `hits[].record` | object | The full record of the element. | +| `hits[].score` | number | The relevance rank. | +| `hits[].record` | object | The full record of the element. | The `hits` list is absent when nothing matches. - -The records are large. Use a low `limit`. - +The records are large. Use a low `limit`. ## get_symbols @@ -60,9 +58,9 @@ Reads the full record for each coordinate. ### Input -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `coordinates` | string[] | yes | The coordinates to read. At least one. | +| Field | Type | Required | Description | +| ------------- | -------- | -------- | -------------------------------------- | +| `coordinates` | string[] | yes | The coordinates to read. At least one. | Copy each coordinate from a `search_schema` result. A coordinate has one of these forms: @@ -75,9 +73,7 @@ Copy each coordinate from a `search_schema` result. A coordinate has one of thes ```json { - "symbols": [ - { "coordinate": "enum:Mood", "record": { "Values": [{ "Name": "HAPPY" }, { "Name": "SAD" }] } } - ] + "symbols": [{ "coordinate": "enum:Mood", "record": { "Values": [{ "Name": "HAPPY" }, { "Name": "SAD" }] } }] } ``` @@ -91,9 +87,9 @@ This tool takes 10 to 30 seconds. ### Input -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `prompt` | string | yes | A description of the data that you want, in your own words. | +| Field | Type | Required | Description | +| -------- | ------ | -------- | ----------------------------------------------------------- | +| `prompt` | string | yes | A description of the data that you want, in your own words. | Write the entities and the fields that you want. Give the filter conditions and the sort order. Do not write GraphQL syntax. Do not guess type names. @@ -119,19 +115,20 @@ Write the entities and the fields that you want. Give the filter conditions and } ``` -| Field | Type | Description | -| --- | --- | --- | -| `queries[].document` | string | The operation text. It is valid against your schema. | -| `queries[].operationName` | string | The name to send with the request. | -| `queries[].operationType` | string | `query`, `mutation`, or `subscription`. | -| `queries[].variablesSchema` | object | A JSON Schema for the variables. | -| `queries[].description` | string | One line that says what the operation does. | -| `unsatisfied[]` | string[] | What the schema cannot answer, and why. | -| `truncated` | boolean | `true` if the service stopped early. | -| `guidance` | object | What to do with the operation. Absent when there is no operation. | +| Field | Type | Description | +| --------------------------- | -------- | ----------------------------------------------------------------- | +| `queries[].document` | string | The operation text. It is valid against your schema. | +| `queries[].operationName` | string | The name to send with the request. | +| `queries[].operationType` | string | `query`, `mutation`, or `subscription`. | +| `queries[].variablesSchema` | object | A JSON Schema for the variables. | +| `queries[].description` | string | One line that says what the operation does. | +| `unsatisfied[]` | string[] | What the schema cannot answer, and why. | +| `truncated` | boolean | `true` if the service stopped early. | +| `guidance` | object | What to do with the operation. Absent when there is no operation. | -A response with no queries and one `unsatisfied` reason is a normal result. It is not an error. It tells you that your schema cannot answer the request. + A response with no queries and one `unsatisfied` reason is a normal result. It is not an error. It tells you that your + schema cannot answer the request. ### Parameterized operations @@ -146,14 +143,14 @@ One operation thus serves many different inputs. Generate one time, then call ma Each tool returns a readable message. The tools return no protocol error, because an agent can read a tool result and act on it. -| Message | Cause | What the caller does | -| --- | --- | --- | -| The schema index is still building. Retry in a few seconds. | The first build is in flight. | Retry. | -| The schema index expired. It is being rebuilt. Retry in a few seconds. | The service dropped an unused index. The router rebuilds it. | Retry. | -| The schema index is not ready. Retry in a few seconds. | The service reports that the index cannot serve yet. | Retry. | -| Schema discovery is not configured correctly. Contact the router operator. | The token is missing, wrong, or expired. | Do not retry. Tell the operator. | -| The request is not valid. | The input is wrong, or the prompt asks for no data. | Do not retry. Fix the input. | -| The schema discovery service is unreachable. | The router cannot reach the service. | Tell the operator. | +| Message | Cause | What the caller does | +| -------------------------------------------------------------------------- | ------------------------------------------------------------ | -------------------------------- | +| The schema index is still building. Retry in a few seconds. | The first build is in flight. | Retry. | +| The schema index expired. It is being rebuilt. Retry in a few seconds. | The service dropped an unused index. The router rebuilds it. | Retry. | +| The schema index is not ready. Retry in a few seconds. | The service reports that the index cannot serve yet. | Retry. | +| Schema discovery is not configured correctly. Contact the router operator. | The token is missing, wrong, or expired. | Do not retry. Tell the operator. | +| The request is not valid. | The input is wrong, or the prompt asks for no data. | Do not retry. Fix the input. | +| The schema discovery service is unreachable. | The router cannot reach the service. | Tell the operator. | The router never puts the token into a message or a log. From 5fb42ae6fdfc0b70dde05830590bfcae568677e8 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Wed, 12 Aug 2026 01:10:10 +0100 Subject: [PATCH 3/5] docs(router): correct response shape and tool naming in schema discovery docs --- docs-website/router/mcp/schema-discovery/guides.mdx | 13 ++++++++----- docs-website/router/mcp/schema-discovery/tools.mdx | 8 ++++---- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/docs-website/router/mcp/schema-discovery/guides.mdx b/docs-website/router/mcp/schema-discovery/guides.mdx index a3d4bd41a6..37f2c75225 100644 --- a/docs-website/router/mcp/schema-discovery/guides.mdx +++ b/docs-website/router/mcp/schema-discovery/guides.mdx @@ -64,7 +64,7 @@ The router generates an operation. The router never publishes it. You review the Run schema discovery in a development router. Send the prompt. ```json -{ "prompt": "list active employees with their department and current mood" } +{ "prompt": "list employees with their id, first name, last name and current mood" } ``` ### Step 2 - Review the document @@ -81,9 +81,12 @@ Give the operation a clear name. The name becomes the tool name. Write the document to your MCP operations directory. -```graphql operations/ListActiveEmployees.graphql -query ListActiveEmployees($limit: Int) { - employees(limit: $limit) { +```graphql operations/ListEmployees.graphql +""" +Lists every employee with their id, name, and current mood. +""" +query ListEmployees { + employees { id details { forename @@ -94,7 +97,7 @@ query ListActiveEmployees($limit: Int) { } ``` -Add a description above the operation. The description becomes the tool description, so write it for the agent. +The operation name sets the tool name. A file name is used only when the operation has no name. The `"""` docstring above the operation sets the tool description, so write it for the agent. A `#` comment does not work. ### Step 4 - Deploy to production diff --git a/docs-website/router/mcp/schema-discovery/tools.mdx b/docs-website/router/mcp/schema-discovery/tools.mdx index fd0605721a..2d224b26c1 100644 --- a/docs-website/router/mcp/schema-discovery/tools.mdx +++ b/docs-website/router/mcp/schema-discovery/tools.mdx @@ -48,7 +48,7 @@ An unknown kind matches nothing. The tool schema constrains the input to this li | `hits[].score` | number | The relevance rank. | | `hits[].record` | object | The full record of the element. | -The `hits` list is absent when nothing matches. +The `hits` list is always present. It is empty when nothing matches. The records are large. Use a low `limit`. @@ -106,8 +106,6 @@ Write the entities and the fields that you want. Give the filter conditions and "variablesSchema": { "type": "object", "additionalProperties": false } } ], - "unsatisfied": [], - "truncated": false, "guidance": { "endpoint": "http://localhost:3002/graphql", "nextSteps": ["Run the operation against the endpoint. ..."] @@ -126,6 +124,8 @@ Write the entities and the fields that you want. Give the filter conditions and | `truncated` | boolean | `true` if the service stopped early. | | `guidance` | object | What to do with the operation. Absent when there is no operation. | +`queries` is always present. `unsatisfied`, `truncated` and `guidance` are absent when they are empty. + A response with no queries and one `unsatisfied` reason is a normal result. It is not an error. It tells you that your schema cannot answer the request. @@ -158,4 +158,4 @@ The router never puts the token into a message or a log. `search_schema` and `generate_query` show the shape of your schema to any caller that reaches the MCP server. This is the same exposure that `get_schema` gives. -These tools carry no `@requiresScopes` directive, so the [scope middleware](/router/mcp/oauth/scopes) derives no per-tool scope for them. Use the `tools_call` scope to gate them. +These tools carry no `@requiresScopes` directive, so the [scope middleware](/router/mcp/oauth/scopes) derives no per-tool scope for them. There is also no `search_schema`, `get_symbols` or `generate_query` key under `mcp.oauth.scopes`, unlike `get_schema`. Use the `tools_call` scope, which applies to every tool call. From a9b0b5d82c88c1575fb148c708401a2d5762626f Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Wed, 12 Aug 2026 09:03:53 +0100 Subject: [PATCH 4/5] fix(router): keep schema discovery protos out of the shared buf module --- Makefile | 3 ++- buf.yaml | 2 -- buf.lock => router/proto/buf.lock | 0 router/proto/buf.yaml | 11 +++++++++++ {proto => router/proto}/yoko/v1/yoko.proto | 0 5 files changed, 13 insertions(+), 3 deletions(-) rename buf.lock => router/proto/buf.lock (100%) create mode 100644 router/proto/buf.yaml rename {proto => router/proto}/yoko/v1/yoko.proto (100%) diff --git a/Makefile b/Makefile index e8be9cb1e3..f00d8bcaad 100644 --- a/Makefile +++ b/Makefile @@ -116,7 +116,8 @@ generate: make generate-go generate-go: - rm -rf router/gen && buf generate --path proto/wg/cosmo/node --path proto/wg/cosmo/common --path proto/wg/cosmo/graphqlmetrics --path proto/yoko/v1 --template buf.router.go.gen.yaml + rm -rf router/gen && buf generate --path proto/wg/cosmo/node --path proto/wg/cosmo/common --path proto/wg/cosmo/graphqlmetrics --template buf.router.go.gen.yaml + buf generate router/proto --template buf.router.go.gen.yaml rm -rf graphqlmetrics/gen && buf generate --path proto/wg/cosmo/graphqlmetrics --path proto/wg/cosmo/common --template buf.graphqlmetrics.go.gen.yaml rm -rf connect-go/wg && buf generate --path proto/wg/cosmo/platform --path proto/wg/cosmo/notifications --path proto/wg/cosmo/common --path proto/wg/cosmo/node --template buf.connect-go.go.gen.yaml diff --git a/buf.yaml b/buf.yaml index 753ea0e277..ee5c8b279b 100644 --- a/buf.yaml +++ b/buf.yaml @@ -1,6 +1,4 @@ version: v2 -deps: - - buf.build/bufbuild/protovalidate modules: - path: proto lint: diff --git a/buf.lock b/router/proto/buf.lock similarity index 100% rename from buf.lock rename to router/proto/buf.lock diff --git a/router/proto/buf.yaml b/router/proto/buf.yaml new file mode 100644 index 0000000000..71402d986b --- /dev/null +++ b/router/proto/buf.yaml @@ -0,0 +1,11 @@ +# The schema discovery protos live outside the shared `proto` module on purpose. +# +# Workspace-wide commands such as `buf generate --template buf.ts.gen.yaml` run +# with no path filter. A proto inside the shared module therefore also generates +# TypeScript into the `connect` package, which does not need a client for this +# service and cannot resolve the protovalidate import. +version: v2 +deps: + - buf.build/bufbuild/protovalidate +modules: + - path: . diff --git a/proto/yoko/v1/yoko.proto b/router/proto/yoko/v1/yoko.proto similarity index 100% rename from proto/yoko/v1/yoko.proto rename to router/proto/yoko/v1/yoko.proto From a60a3139cc941ef879141b2a2b7fd8ca2fdbe0fe Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Wed, 12 Aug 2026 09:14:11 +0100 Subject: [PATCH 5/5] fix(router): pin connect-go codegen and sync router-tests go.sum --- router-tests/go.mod | 3 +- router-tests/go.sum | 6 ++- .../yoko/v1/yokov1connect/yoko.connect.go | 42 +++++++------------ 3 files changed, 22 insertions(+), 29 deletions(-) diff --git a/router-tests/go.mod b/router-tests/go.mod index cc9f3d6b00..2fbddd8ef0 100644 --- a/router-tests/go.mod +++ b/router-tests/go.mod @@ -42,11 +42,12 @@ require ( golang.org/x/net v0.56.0 golang.org/x/sys v0.46.0 google.golang.org/grpc v1.82.1 - google.golang.org/protobuf v1.36.11 + google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 ) require ( + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260709200747-435963d16310.1 // indirect connectrpc.com/vanguard v0.3.0 // indirect github.com/99designs/gqlgen v0.17.76 // indirect github.com/KimMachineGun/automemlimit v0.6.1 // indirect diff --git a/router-tests/go.sum b/router-tests/go.sum index daf0bc5963..8716e900a3 100644 --- a/router-tests/go.sum +++ b/router-tests/go.sum @@ -1,3 +1,5 @@ +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260709200747-435963d16310.1 h1:6nlcxMOui23ZRVAfJM451duu79P1npA5JRdZqMilrrQ= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260709200747-435963d16310.1/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo= connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= connectrpc.com/vanguard v0.3.0 h1:prUKFm8rYDwvpvnOSoqdUowPMK0tRA0pbSrQoMd6Zng= @@ -511,8 +513,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y= gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/router/gen/proto/yoko/v1/yokov1connect/yoko.connect.go b/router/gen/proto/yoko/v1/yokov1connect/yoko.connect.go index d3ce860546..c9baf07709 100644 --- a/router/gen/proto/yoko/v1/yokov1connect/yoko.connect.go +++ b/router/gen/proto/yoko/v1/yokov1connect/yoko.connect.go @@ -51,18 +51,6 @@ const ( YokoServiceGenerateQueryProcedure = "/yoko.v1.YokoService/GenerateQuery" ) -// These variables are the protoreflect.Descriptor objects for the RPCs defined in this package. -var ( - yokoServiceServiceDescriptor = v1.File_yoko_v1_yoko_proto.Services().ByName("YokoService") - yokoServiceEnsureIndexMethodDescriptor = yokoServiceServiceDescriptor.Methods().ByName("EnsureIndex") - yokoServiceGetIndexMethodDescriptor = yokoServiceServiceDescriptor.Methods().ByName("GetIndex") - yokoServiceListIndexesMethodDescriptor = yokoServiceServiceDescriptor.Methods().ByName("ListIndexes") - yokoServiceDeleteIndexMethodDescriptor = yokoServiceServiceDescriptor.Methods().ByName("DeleteIndex") - yokoServiceSearchSchemaMethodDescriptor = yokoServiceServiceDescriptor.Methods().ByName("SearchSchema") - yokoServiceGetSymbolsMethodDescriptor = yokoServiceServiceDescriptor.Methods().ByName("GetSymbols") - yokoServiceGenerateQueryMethodDescriptor = yokoServiceServiceDescriptor.Methods().ByName("GenerateQuery") -) - // YokoServiceClient is a client for the yoko.v1.YokoService service. type YokoServiceClient interface { // EnsureIndex is the idempotent write path: it creates the index for this @@ -88,47 +76,48 @@ type YokoServiceClient interface { // http://api.acme.com or https://acme.com/grpc). func NewYokoServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) YokoServiceClient { baseURL = strings.TrimRight(baseURL, "/") + yokoServiceMethods := v1.File_yoko_v1_yoko_proto.Services().ByName("YokoService").Methods() return &yokoServiceClient{ ensureIndex: connect.NewClient[v1.EnsureIndexRequest, v1.EnsureIndexResponse]( httpClient, baseURL+YokoServiceEnsureIndexProcedure, - connect.WithSchema(yokoServiceEnsureIndexMethodDescriptor), + connect.WithSchema(yokoServiceMethods.ByName("EnsureIndex")), connect.WithClientOptions(opts...), ), getIndex: connect.NewClient[v1.GetIndexRequest, v1.GetIndexResponse]( httpClient, baseURL+YokoServiceGetIndexProcedure, - connect.WithSchema(yokoServiceGetIndexMethodDescriptor), + connect.WithSchema(yokoServiceMethods.ByName("GetIndex")), connect.WithClientOptions(opts...), ), listIndexes: connect.NewClient[v1.ListIndexesRequest, v1.ListIndexesResponse]( httpClient, baseURL+YokoServiceListIndexesProcedure, - connect.WithSchema(yokoServiceListIndexesMethodDescriptor), + connect.WithSchema(yokoServiceMethods.ByName("ListIndexes")), connect.WithClientOptions(opts...), ), deleteIndex: connect.NewClient[v1.DeleteIndexRequest, v1.DeleteIndexResponse]( httpClient, baseURL+YokoServiceDeleteIndexProcedure, - connect.WithSchema(yokoServiceDeleteIndexMethodDescriptor), + connect.WithSchema(yokoServiceMethods.ByName("DeleteIndex")), connect.WithClientOptions(opts...), ), searchSchema: connect.NewClient[v1.SearchSchemaRequest, v1.SearchSchemaResponse]( httpClient, baseURL+YokoServiceSearchSchemaProcedure, - connect.WithSchema(yokoServiceSearchSchemaMethodDescriptor), + connect.WithSchema(yokoServiceMethods.ByName("SearchSchema")), connect.WithClientOptions(opts...), ), getSymbols: connect.NewClient[v1.GetSymbolsRequest, v1.GetSymbolsResponse]( httpClient, baseURL+YokoServiceGetSymbolsProcedure, - connect.WithSchema(yokoServiceGetSymbolsMethodDescriptor), + connect.WithSchema(yokoServiceMethods.ByName("GetSymbols")), connect.WithClientOptions(opts...), ), generateQuery: connect.NewClient[v1.GenerateQueryRequest, v1.GenerateQueryResponse]( httpClient, baseURL+YokoServiceGenerateQueryProcedure, - connect.WithSchema(yokoServiceGenerateQueryMethodDescriptor), + connect.WithSchema(yokoServiceMethods.ByName("GenerateQuery")), connect.WithClientOptions(opts...), ), } @@ -202,46 +191,47 @@ type YokoServiceHandler interface { // By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf // and JSON codecs. They also support gzip compression. func NewYokoServiceHandler(svc YokoServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + yokoServiceMethods := v1.File_yoko_v1_yoko_proto.Services().ByName("YokoService").Methods() yokoServiceEnsureIndexHandler := connect.NewUnaryHandler( YokoServiceEnsureIndexProcedure, svc.EnsureIndex, - connect.WithSchema(yokoServiceEnsureIndexMethodDescriptor), + connect.WithSchema(yokoServiceMethods.ByName("EnsureIndex")), connect.WithHandlerOptions(opts...), ) yokoServiceGetIndexHandler := connect.NewUnaryHandler( YokoServiceGetIndexProcedure, svc.GetIndex, - connect.WithSchema(yokoServiceGetIndexMethodDescriptor), + connect.WithSchema(yokoServiceMethods.ByName("GetIndex")), connect.WithHandlerOptions(opts...), ) yokoServiceListIndexesHandler := connect.NewUnaryHandler( YokoServiceListIndexesProcedure, svc.ListIndexes, - connect.WithSchema(yokoServiceListIndexesMethodDescriptor), + connect.WithSchema(yokoServiceMethods.ByName("ListIndexes")), connect.WithHandlerOptions(opts...), ) yokoServiceDeleteIndexHandler := connect.NewUnaryHandler( YokoServiceDeleteIndexProcedure, svc.DeleteIndex, - connect.WithSchema(yokoServiceDeleteIndexMethodDescriptor), + connect.WithSchema(yokoServiceMethods.ByName("DeleteIndex")), connect.WithHandlerOptions(opts...), ) yokoServiceSearchSchemaHandler := connect.NewUnaryHandler( YokoServiceSearchSchemaProcedure, svc.SearchSchema, - connect.WithSchema(yokoServiceSearchSchemaMethodDescriptor), + connect.WithSchema(yokoServiceMethods.ByName("SearchSchema")), connect.WithHandlerOptions(opts...), ) yokoServiceGetSymbolsHandler := connect.NewUnaryHandler( YokoServiceGetSymbolsProcedure, svc.GetSymbols, - connect.WithSchema(yokoServiceGetSymbolsMethodDescriptor), + connect.WithSchema(yokoServiceMethods.ByName("GetSymbols")), connect.WithHandlerOptions(opts...), ) yokoServiceGenerateQueryHandler := connect.NewUnaryHandler( YokoServiceGenerateQueryProcedure, svc.GenerateQuery, - connect.WithSchema(yokoServiceGenerateQueryMethodDescriptor), + connect.WithSchema(yokoServiceMethods.ByName("GenerateQuery")), connect.WithHandlerOptions(opts...), ) return "/yoko.v1.YokoService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {