MCP (Model Context Protocol) plugin for Hive Gateway. Exposes your GraphQL API as MCP tools, resources, and resource templates that AI agents can discover and execute.
The plugin turns your Hive Gateway into an MCP server. It maps GraphQL operations to MCP tools, so AI agents can discover and call your API through the standard MCP protocol.
AI Agent --MCP JSON-RPC--> /mcp endpoint --GraphQL--> your subgraphs
The gateway handles schema loading, input/output schema generation, argument validation, and response formatting. You define which operations to expose as tools, and the plugin does the rest.
Supported MCP methods: initialize, tools/list, tools/call, resources/list, resources/read, resources/templates/list.
Requires Node.js >= 20 and graphql as a peer dependency.
npm install @graphql-hive/gateway-runtime @graphql-hive/plugin-mcp graphqlBoth CommonJS and ESM are supported. Langfuse is an optional peer dependency, only needed if you use the built-in Langfuse description provider.
import { createGatewayRuntime } from '@graphql-hive/gateway-runtime';
import { useMCP } from '@graphql-hive/plugin-mcp';
const gateway = createGatewayRuntime({
supergraph: 'supergraph.graphql',
plugins: (ctx) => [
useMCP(ctx, {
name: 'my-api',
tools: [
{
name: 'get_users',
source: {
type: 'inline',
query: `query($limit: Int) { users(limit: $limit) { id name } }`,
},
},
],
}),
],
});Define tools directly in your .graphql operation files using @mcpTool, @mcpDescription, and @mcpHeader directives:
query GetWeather($location: String!)
@mcpTool(name: "get_weather", description: "Get current weather") {
weather(location: $location) {
temperature
conditions
}
}Point the plugin to your operations directory:
useMCP(ctx, {
name: 'my-api',
operationsPath: './operations',
});All operations with @mcpTool are auto-registered as tools. Operations without the directive are available as sources for explicit tool configs via source.type: 'graphql'. Note that all operations must be named (anonymous operations are not supported).
@mcpDescription can also be placed on selection fields to add descriptions to the output schema:
query GetForecast($location: String!, $days: Int)
@mcpTool(name: "get_forecast", description: "Weather forecast") {
forecast(location: $location, days: $days) {
date
high
low
conditions @mcpDescription(provider: "langfuse:conditions_desc")
}
}@mcpHeader injects a variable from an HTTP header instead of exposing it in the tool's input schema. This is useful for auth context like company or user IDs that come from request headers rather than the LLM:
query GetCompanyData($companyId: String! @mcpHeader(name: "x-company-id"))
@mcpTool(name: "get_company_data") {
company(companyId: $companyId) {
id
name
plan
}
}The companyId variable is hidden from tools/list and automatically populated from the x-company-id header on each tools/call request. If the header is missing, the tool returns an error.
For cases where you need more control (e.g. transforming the header value or falling back to a default), use hidden: true with a preprocess hook instead:
{
name: 'get_company_data',
source: { type: 'inline', query: `query($companyId: String!) { ... }` },
input: { schema: { properties: { companyId: { hidden: true } } } },
hooks: {
preprocess(args, { headers }) {
args.companyId = headers['x-company-id'] || 'default-company';
},
},
}To override which .graphql file a specific tool loads from (instead of using the global operationsPath), use source.file:
{
name: 'get_weather',
source: {
type: 'graphql',
operationName: 'GetWeather',
operationType: 'query',
file: './custom-operations/weather.graphql',
},
}You can also define tools in a YAML or JSON file:
# mcp.yaml
name: weather-api
version: 1.0.0
tools:
- name: get_weather
source:
type: inline
query: |
query GetWeather($location: String!) {
weather(location: $location) {
temperature
conditions
}
}
tool:
title: Current Weather# List tools
curl http://localhost:4000/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# Call a tool
curl http://localhost:4000/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_weather","arguments":{"location":"London"}}}'A complete tool definition with all available options:
{
name: 'cancel_order',
source: {
type: 'inline',
query: `mutation($orderId: String!, $confirmationId: String) {
cancelOrder(orderId: $orderId, confirmationId: $confirmationId) { success message }
}`,
},
tool: {
title: 'Cancel Order',
description: 'Cancel a pending order by ID',
annotations: { destructiveHint: true, idempotentHint: false },
execution: { taskSupport: 'optional' },
},
input: {
schema: {
properties: {
orderId: { description: 'The order ID to cancel', examples: ['ORD-123'] },
confirmationId: { alias: 'confirm', description: 'Confirmation code' },
},
},
},
output: {
path: 'cancelOrder',
contentAnnotations: { audience: ['user'], priority: 0.9 },
},
hooks: {
preprocess: (args, context) => {
if (!args['confirm']) return { error: 'Confirmation required' };
return undefined;
},
},
}All fields except name and source are optional.
Rename arguments with aliases and add per-field descriptions:
{
name: 'get_weather',
source: {
type: 'inline',
query: `query($location: String!) { weather(location: $location) { temperature } }`,
},
input: {
schema: {
properties: {
location: {
alias: 'city',
description: 'City name to check weather for',
examples: ['London', 'New York'],
default: 'London',
},
},
},
},
}Agents see city as the input parameter with the provided examples and default. The plugin maps it back to location when executing the query.
Use output.path to return a subset of the GraphQL response instead of the full object:
{
name: 'search_cities',
source: {
type: 'inline',
query: `query($query: String!) { cities(query: $query) { name country population } }`,
},
output: { path: 'cities' },
}Without output.path, the agent receives { cities: [{ name: "London", ... }] }. With path: 'cities', it receives [{ name: "London", ... }] directly. The output schema in tools/list is narrowed to match.
Additional output options:
output.schema: falsesuppresses the outputSchema for a specific tool (vssuppressOutputSchemawhich is global). Output schemas are also automatically suppressed when hooks are configured, since hooks may change the response shape.output.descriptionProvidersadds dynamic descriptions to output fields, keyed by dot-notation path:
output: {
descriptionProviders: {
'forecast.conditions': { type: 'langfuse', prompt: 'conditions_desc' },
},
}Transform arguments or short-circuit execution before the GraphQL query runs:
{
name: 'gated_action',
source: { type: 'inline', query: '...' },
hooks: {
preprocess: (args, context) => {
// context: { toolName, headers, query }
if (!args['_confirmed']) {
return { needsConfirmation: true };
}
return undefined; // proceed with execution
},
},
}Transform the GraphQL result before returning it to the agent:
{
name: 'formatted_weather',
source: { type: 'inline', query: '...' },
hooks: {
postprocess: (result, args, context) => {
// context: { toolName, headers, query }
const data = result as { weather: { temperature: number; conditions: string } };
return `${data.weather.temperature}F and ${data.weather.conditions}`;
},
},
}When postprocess returns a string, the result is sent as text content instead of structured content.
Both hooks receive a context object with toolName (the MCP tool name), headers (request headers from the agent), and query (the resolved GraphQL query string).
Serve static content or dynamic data as MCP resources alongside your tools:
useMCP(ctx, {
name: 'my-api',
resources: [
{
name: 'api-guide',
uri: 'docs://api-guide',
mimeType: 'text/markdown',
text: '# API Guide\n\nUse get_users to fetch user data.',
},
],
resourceTemplates: [
{
uriTemplate: 'users://{id}',
name: 'User Profile',
description: 'Get a user profile by ID',
mimeType: 'application/json',
handler: async (params) => ({
text: JSON.stringify({ id: params['id'], source: 'gateway' }),
}),
},
],
});Resources support three content sources: text (inline string), blob (inline base64), or file (read from disk at startup):
resources: [
{ name: 'guide', uri: 'docs://guide', text: '# Guide\n...' },
{
name: 'icon',
uri: 'files://icon.png',
mimeType: 'image/png',
blob: 'iVBOR...',
},
{ name: 'schema', uri: 'files://schema.graphql', file: './schema.graphql' },
];Agents discover resources via resources/list and fetch them with resources/read. Templates allow dynamic URIs with parameters.
Supply a custom loader to fetch operations from any external source at startup and optionally subscribe to live updates. The plugin handles parsing, tool registration, and registry rebuilds.
useMCP(ctx, {
name: 'my-api',
loader: {
async load() {
const res = await fetch('https://my-cdn.example.com/operations.graphql');
return res.text();
},
onUpdate(callback) {
const interval = setInterval(async () => {
const res = await fetch(
'https://my-cdn.example.com/operations.graphql',
);
callback(await res.text());
}, 60_000);
return () => clearInterval(interval);
},
},
});The load() method is called once at startup. If onUpdate is provided, it is called after load() succeeds and should invoke the callback whenever the source changes. Optionally return a cleanup function from onUpdate to unsubscribe on dispose.
If load() rejects, the error is logged and onUpdate is not called. The plugin continues without loader-sourced operations. Implement retry logic inside load() if you need automatic recovery.
Register additional JSON-RPC methods on the MCP endpoint with customMethods. Handlers receive the request params and a context with access to the schema and GraphQL execution. Use customCapabilities to advertise the extension in initialize responses.
import { MCPMethodError, useMCP } from '@graphql-hive/plugin-mcp';
useMCP(ctx, {
name: 'my-api',
customCapabilities: { echo: {} },
customMethods: {
'echo/uppercase': (params) => {
if (typeof params !== 'object' || params === null) {
throw new MCPMethodError(-32602, 'Invalid params: expected an object');
}
const { text } = params as { text?: string };
return { text: text?.toUpperCase() ?? '' };
},
'graphql/run': async (params, context) => {
const { query } = params as { query?: string };
if (typeof query !== 'string') {
throw new MCPMethodError(
-32602,
'Invalid params: "query" must be a string',
);
}
return context.executeGraphQL({ query });
},
},
});The handler's return value becomes the JSON-RPC result. Throw MCPMethodError to produce a JSON-RPC error response with a specific code; any other thrown error becomes a generic internal error.
context.executeGraphQL runs the operation through the full server pipeline with the original request headers forwarded, so header-driven plugins (authentication, tracing) treat the operation like any HTTP request. The operation shares the MCP request's server context, so plugins that key per-request state on context identity see it as part of the surrounding request. context.getSchema() returns the current schema, and context.transport exposes the incoming request and its headers.
Method names that collide with the built-in MCP methods (initialize, tools/list, tools/call, resources/list, resources/templates/list, resources/read, notifications/initialized) are rejected at startup. Methods named under notifications/ follow JSON-RPC notification semantics: they may run side effects but never produce a response.
The plugin has built-in support for Langfuse as a description provider. Tool and field descriptions are fetched from Langfuse prompts at startup and can be refreshed at runtime.
npm install @langfuse/clientSet your Langfuse credentials:
export LANGFUSE_SECRET_KEY=sk-lf-...
export LANGFUSE_PUBLIC_KEY=pk-lf-...
export LANGFUSE_BASE_URL=https://cloud.langfuse.comRegister the provider and reference it from tools:
useMCP(ctx, {
name: 'my-api',
providers: {
langfuse: {
defaults: { label: 'production' },
},
},
tools: [
{
name: 'get_weather',
source: { type: 'inline', query: '...' },
tool: {
descriptionProvider: {
type: 'langfuse',
prompt: 'get_weather_description',
},
},
},
],
});You can also use @mcpDescription directives in .graphql files to reference providers:
query GetWeather(
$location: String! @mcpDescription(provider: "langfuse:location_field_desc")
) @mcpTool(name: "get_weather") {
weather(location: $location) {
temperature
conditions
}
}You can override the Langfuse prompt label per request by adding a ?promptLabel= query parameter to the MCP endpoint:
curl http://localhost:4000/mcp?promptLabel=staging \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'Label precedence: per-request ?promptLabel= > per-tool options.label > provider defaults.label.
Version vs label: Langfuse treats
versionandlabelas mutually exclusive. When a tool specifies an explicitversion, the plugin strips any label before calling Langfuse. A pinned version is an intentional contract ("use exactly this prompt version"), while a label is environment-level routing ("use whatever is taggedproduction"). Specificity wins.
To use a custom provider instead of Langfuse, pass any object with a fetchDescription method:
providers: {
myProvider: {
fetchDescription: async (toolName, config) => {
return `Description for ${toolName}`;
},
},
},When multiple sources define a description for a tool, the highest priority wins:
descriptionProvider(resolved at runtime from Langfuse or custom provider)tool.description(explicit config)@mcpTool(description: "...")directive- GraphQL schema description
The same precedence applies to per-field descriptions via input.schema.properties.<field>.descriptionProvider.
| Option | Type | Default | Description |
|---|---|---|---|
name |
string |
(required) | Server name in initialize responses |
version |
string |
"1.0.0" |
Server version |
title |
string |
Human-readable server title | |
description |
string |
Human-readable server description | |
icons |
MCPIcon[] |
Server icons for client UIs | |
websiteUrl |
string |
Server website URL | |
path |
string |
"/mcp" |
HTTP path for the MCP endpoint |
operationsPath |
string |
Path to .graphql file(s) containing operations |
|
operationsStr |
string |
Raw GraphQL operations string (alternative to file) | |
tools |
MCPToolConfig[] |
[] |
Tool definitions |
resources |
MCPResourceConfig[] |
[] |
Static resource definitions |
resourceTemplates |
MCPResourceTemplateConfig[] |
[] |
Dynamic resource templates |
providers |
object |
Description provider instances (e.g. { langfuse: {} }) |
|
suppressOutputSchema |
boolean |
false |
Suppress outputSchema in tools/list |
loader |
MCPOperationsLoader |
Dynamic operations source with optional live updates | |
instructions |
string |
Free-text instructions included in initialize for LLM context |
|
protocolVersion |
string |
"2025-11-25" |
MCP protocol version to advertise |
- Tool sources: inline queries, file-based operations (
operationsPath), or auto-registered via@mcpTooldirectives - Input overrides: aliases, descriptions, description providers, and
hiddenper field - Header injection:
@mcpHeaderdirective to inject variables from HTTP headers (hidden from input schema) - Output extraction:
output.pathto return a subset of the GraphQL response - Hooks:
preprocess(short-circuit or transform args) andpostprocess(transform results) - Description providers: resolve tool/field descriptions dynamically (built-in Langfuse support, or bring your own)
- Resources: static text or binary content served via
resources/read - Resource templates: dynamic URI-based resources with custom handlers
- Annotations: tool hints (
readOnlyHint,destructiveHint,idempotentHint,openWorldHint) and content annotations (audience,priority) - Task support: per-tool
execution.taskSupport('forbidden','optional','required') for long-running operations - Dynamic loader: fetch operations from any external source with optional live updates
See examples/ for runnable demos covering all features.
# Unit/integration tests
npx vitest run packages/plugins/mcp/tests/
# E2E tests
npx vitest run e2e/mcp-plugin/MIT