GraphQL offers unique capabilities for agents to access APIs through Model Context Protocol (MCP). With its strict specification, it's easy to discover and execute arbitrary requests to an API. The clear distinction between queries and mutations also makes it less risky for users to let the agent try queries without worrying about potential side-effects. There are two common strategies used by GraphQL MCP servers today that we know of:
- Provide the full schema with a tool and enable agents to execute arbitrary queries.
- Provide a list of predefined GraphQL operations, often associated with a dedicated tool.
Both suffer from the same fundamental problem: the more capabilities you want to provide to the agent, the bigger its context needs to be. Providing the schema makes it proportional to your API size and specifying possible operations explicitly obviously scale with their number in some form. To avoid context explosion one ends up filtering the schema and/or limiting the number of possible GraphQL operations. Furthermore the second option becomes quickly tedious to maintain or drastically limits the agent's abilities.
We came up with a different approach to enable agents to explore an API without prohibitive context sizes. We believe a GraphQL MCP should consist of three tools: search
, introspect
and execute
. We did just that last month with our GraphQL MCP server Each tool provides as much as context as possible within a reasonable size, to avoid back and forth between the agent and the MCP server but also agent's hallucinations.
On start-up, we build an in-memory index with Tantivy of the schema keeping track of each field with the following information:
- name & description of the parent type, field and output type.
- field depth from root operation types (
Query
,Mutation
andSubscription
): the further away, the deeper they are.
The agent provides a list of keywords for which we search the most relevant fields with lowest depth. Now we touch our core problem, provide only the useful parts of the GraphQL schema to use those fields. The agent needs to know how to access them from Query
or Mutation
and what sub-selection to retrieve. To illustrate our filtering mechanism, we will take the following schema as an example:
type User {
id: ID!
name: String!
email: String
posts: [Post!]
}
type Post {
id: ID!
title: String!
content: String
author: User!
comments: [Comment!]!
}
type Comment {
id: ID!
content: String
author: User!
}
type Query {
"Get a specific post by its ID"
post(id: ID!): Post
"Get latest posts"
posts(offset: Int = 0, limit: Int = 10): [Post!]
"Get a specific user by their ID"
user(id: ID!): User
}
Suppose the user asks for "Latest posts with their comments"
, the agent would typically search with the keywords ["post", "comment"]
. With the help of the search index we would find Post.comments
field but also Query.post
and Query.posts
with a lower score. From there, we start by identifying all parent fields up to Query
or Mutation
. They're computed on start-up with a shortest path algorithm, which also defines the field depth used in the search index. For Post.comments
we would have either Query.post
or Query.posts
. We generate an initial schema with the first top matches and their parents, only including the relevant fields:
# incomplete fields
type Query {
"Get a specific post by its ID"
post(id: ID!): Post
"Get latest posts"
posts(offset: Int = 0, limit: Int = 10): [Post!]
}
# incomplete fields
type Post {
comments: [Comment!]!
}
Next, we recursively add nested fields. Each child field re-uses its parent field score with an exponential decay, until we reach a depth-limit. Said differently, we divide the score by a number strictly higher to 1 each time the depth increases. When we encounter the same field multiple times, we reinforce its score and also keep track of the lowest depth. After this score attribution, we recursively include the child fields with the highest score and lowest depth until we reach a hard-coded context size limit. In our example this would render something like the following:
# incomplete fields
type Query {
"Get a specific post by its ID"
post(id: ID!): Post
"Get latest posts"
posts(offset: Int = 0, limit: Int = 10): [Post!]
}
type Post {
id: ID!
title: String!
content: String
author: User!
comments: [Comment!]!
}
type Comment {
id: ID!
content: String
author: User!
}
And with a higher limit it would include User
:
# incomplete fields
type Query {
"Get a specific post by its ID"
post(id: ID!): Post
"Get latest posts"
posts(offset: Int = 0, limit: Int = 10): [Post!]
}
type User {
id: ID!
name: String!
email: String
posts: [Post!]
}
type Post {
id: ID!
title: String!
content: String
author: User!
comments: [Comment!]!
}
type Comment {
id: ID!
content: String
author: User!
}
In practice User
would always be included given how small the schema is. Input types are processed similarly to output types except for scalars which are always included to avoid having agents guessing the format of scalars like dates.
Overall this initial search
tool provide a starting point of the schema which is most often enough for the agent to create the query it needs.
You surely noticed that search
may not always provide all the necessary information, hence the introspect
tool enabling the agent to explicitly Introspect specific types. We also follow a similar logic to search, trying to provide as much context as possible for the nested fields and their types within a certain context size limit, avoiding back and forth between the agent and the tool.
Finally, the agent can execute arbitrary queries, but we're not done yet! Agents will often tend to guess the fields rather than introspect types which often leads to cases like the following:
query {
posts {
comments {
content
author {
# does not exist
username
}
}
}
}
This would return an error such as "username" field does not exist
, and the agent would continue to guess fields. To prevent this and help agents as much as possible we also provide all the relevant schema parts for errors, such as unknown type conditions, union members, fields, etc. So in this case we would also return the following to the agent:
type User {
id: ID!
name: String!
email: String
posts: [Post!]
}
And similarly to introspect
and search
, additional information on nested fields are provided until a certain threshold.
Our current solution is a first step towards exposing complex and rich APIs to agents without sacrificing their context window. We intend to fine-tune this design with real-time data among other things, but also provide better control over which part of the schema the agent can access with schema contracts.
With this approach, we’re enabling agents to interact with large GraphQL schemas without exceeding context limits or relying on brittle hardcoding. Our search + introspect + execute triad delivers the best of both flexibility and performance.
Get started with MCP and try it out with Cursor, Windsurf, Zed or VS Code. We can't wait to see what you build with it!