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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-custom-methods.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-hive/plugin-mcp': minor
---

Add `customMethods` and `customCapabilities` to the MCP plugin configuration. Custom JSON-RPC methods are dispatched on the MCP endpoint alongside the built-ins and receive a context with `executeGraphQL` (full server pipeline, request headers forwarded), `getSchema`, and transport details. Throw the new `MCPMethodError` from a handler to produce a JSON-RPC error response with a specific code. Custom capability entries are merged into the `initialize` response. Unknown `notifications/*` methods are now silently dropped instead of receiving a "Method not found" error response, per the JSON-RPC 2.0 specification.
38 changes: 38 additions & 0 deletions packages/plugins/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,44 @@ The `load()` method is called once at startup. If `onUpdate` is provided, it is

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.

## Custom methods

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.

```typescript
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.

## Langfuse integration

The plugin has built-in support for [Langfuse](https://langfuse.com/) as a description provider. Tool and field descriptions are fetched from Langfuse prompts at startup and can be refreshed at runtime.
Expand Down
7 changes: 7 additions & 0 deletions packages/plugins/mcp/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
export { useMCP } from './plugin.js';
export { MCPMethodError } from './method-handler.js';
export type {
MCPGraphQLOperation,
MCPMethodContext,
MCPMethodHandler,
MCPMethodTransport,
} from './method-handler.js';
export type {
MCPConfig,
MCPOperationsLoader,
Expand Down
67 changes: 67 additions & 0 deletions packages/plugins/mcp/src/method-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { Logger } from '@graphql-hive/gateway-runtime';
import type { ExecutionResult, GraphQLSchema } from 'graphql';

/** A GraphQL operation submitted through {@link MCPMethodContext.executeGraphQL}. */
export interface MCPGraphQLOperation {
query: string;
variables?: Record<string, unknown>;
operationName?: string;
}

/** Transport details for the request that carried a custom method call. */
export type MCPMethodTransport = {
type: 'http';
/** The incoming HTTP request. */
request: Request;
/** Lower-cased HTTP request headers. */
headers: Record<string, string>;
};

/** Request metadata and server capabilities available to a custom MCP method handler. */
export interface MCPMethodContext {
/** Plugin logger, scoped with the MCP prefix. */
logger: Logger;
/** The JSON-RPC method name that was dispatched. */
method: string;
/** The JSON-RPC request id, or null for notifications. */
requestId: string | number | null;
/**
* Execute a GraphQL operation through the full server pipeline.
* Request headers are forwarded, so authentication and other
* header-driven plugins behave as if the operation arrived over HTTP.
* The operation shares the incoming request's server context, so
* plugins that key state on context identity see it as part of the
* surrounding MCP request.
*/
executeGraphQL(operation: MCPGraphQLOperation): Promise<ExecutionResult>;
/** The current GraphQL schema. */
getSchema(): GraphQLSchema;
/** Transport details for the current request, when available. */
transport?: MCPMethodTransport;
}

/**
* Handler for a custom JSON-RPC method on the MCP endpoint. `params`
* arrives exactly as sent by the client and may be undefined. The return
* value must be JSON-serializable and becomes the JSON-RPC `result`.
* Throw {@link MCPMethodError} to produce a JSON-RPC error response
* with a specific code.
*/
export type MCPMethodHandler = (
params: unknown,
context: MCPMethodContext,
) => Promise<unknown> | unknown;

/** Thrown by a custom method handler to produce a JSON-RPC error response. */
export class MCPMethodError extends Error {
constructor(
/** JSON-RPC error code (e.g. -32602 for invalid params). */
readonly code: number,
message: string,
/** Optional structured details serialized into the error `data` field. */
readonly data?: unknown,
) {
super(message);
this.name = 'MCPMethodError';
}
}
160 changes: 158 additions & 2 deletions packages/plugins/mcp/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
concatAST,
parse,
type DocumentNode,
type ExecutionResult,
type GraphQLSchema,
} from 'graphql';
import { isAsyncIterable, type FetchAPI } from 'graphql-yoga';
Expand All @@ -20,17 +21,23 @@ import {
type DescriptionProviderContext,
type ProviderRegistry,
} from './description-provider.js';
import type {
MCPGraphQLOperation,
MCPMethodHandler,
} from './method-handler.js';
import {
loadOperationsFromDocument,
parseInlineHeaderDirectives,
resolveOperation,
type ParsedOperation,
} from './operation-loader.js';
import {
builtInMethodNames,
dealiasArgs,
formatToolCallResult,
handleMCPRequest,
processExecutionResult,
type CustomMethodDispatchContext,
type JsonRpcRequest,
type MCPHandlerOptions,
} from './protocol.js';
Expand All @@ -41,7 +48,7 @@ import type { PluginContext } from './types.js';
type Prettify<T> = { [K in keyof T]: T[K] } & {};

interface MCPToolCallContext {
jsonrpcId: number | string;
jsonrpcId: number | string | null;
toolName: string;
args: Record<string, unknown>;
tool: RegisteredTool;
Expand Down Expand Up @@ -494,6 +501,17 @@ export interface MCPConfig {
suppressOutputSchema?: boolean;
/** Dynamic operations source. Loaded at startup; if `onUpdate` is provided, the plugin subscribes to live changes and rebuilds tools automatically. */
loader?: MCPOperationsLoader;
/**
* Custom JSON-RPC methods served from the MCP endpoint alongside the
* built-ins. Keys are method names (e.g. "graphql/query"); names that
* collide with built-in methods cause startup to fail.
*/
customMethods?: Record<string, MCPMethodHandler>;
/**
* Additional capability entries merged into the `initialize` response.
* Keys merge shallowly over the built-in advertisement; custom entries win.
*/
customCapabilities?: Record<string, unknown>;
}

/** Internal resolved form of a tool config after merging directive and explicit config sources. */
Expand Down Expand Up @@ -897,9 +915,48 @@ export function useMCP(ctx: PluginContext, config: MCPConfig): GatewayPlugin {
'[MCP] config.tools must be an array of tool configurations',
);
}
if (config.customMethods != null) {
if (
typeof config.customMethods !== 'object' ||
Array.isArray(config.customMethods)
) {
throw new Error(
'[MCP] config.customMethods must be an object mapping method names to handler functions',
);
}
const conflicts = Object.keys(config.customMethods).filter((name) =>
builtInMethodNames.has(name),
);
if (conflicts.length > 0) {
throw new Error(
`[MCP] customMethods cannot override built-in methods: ${conflicts.join(', ')}. ` +
`Built-in methods are: ${[...builtInMethodNames].join(', ')}`,
);
}
for (const [name, handler] of Object.entries(config.customMethods)) {
if (typeof handler !== 'function') {
throw new Error(`[MCP] customMethods["${name}"] must be a function`);
}
}
}
if (config.customCapabilities != null) {
if (
typeof config.customCapabilities !== 'object' ||
Array.isArray(config.customCapabilities)
) {
throw new Error('[MCP] config.customCapabilities must be an object');
}
}
const mcpPath = config.path || '/mcp';
let registry: ToolRegistry | null = null;
let schema: GraphQLSchema | null = null;
let executeViaYoga:
| ((
operation: MCPGraphQLOperation,
headers: Record<string, string>,
serverContext: unknown,
) => Promise<ExecutionResult>)
| null = null;

ctx = { ...ctx, log: (config.log ?? ctx.log).child('[MCP] ') };
const logger = ctx.log;
Expand Down Expand Up @@ -1132,6 +1189,14 @@ export function useMCP(ctx: PluginContext, config: MCPConfig): GatewayPlugin {
instructions: config.instructions,
protocolVersion: config.protocolVersion,
suppressOutputSchema: config.suppressOutputSchema,
customMethods: config.customMethods,
customCapabilities: config.customCapabilities,
getSchema: () => {
if (!schema) {
throw new Error('[MCP] schema is not yet available');
}
return schema;
},
registry: reg,
resolveToolDescriptions:
providerToolConfigs.length > 0
Expand Down Expand Up @@ -1280,7 +1345,7 @@ export function useMCP(ctx: PluginContext, config: MCPConfig): GatewayPlugin {
}

function mcpErrorResponse(
id: number | string,
id: number | string | null,
message: string,
fetchAPI: FetchAPI,
) {
Expand Down Expand Up @@ -1327,6 +1392,76 @@ export function useMCP(ctx: PluginContext, config: MCPConfig): GatewayPlugin {
// Extend Yoga's graphqlEndpoint to also accept the MCP path so
// all requests flow through the full pipeline.
onYogaInit({ yoga }) {
// Capture the GraphQL endpoint before extending it with the MCP
// path: internal execution must target the GraphQL route, not the
// MCP route, or requests would re-enter this plugin.
const endpoint = yoga.graphqlEndpoint;
const endpointGroup = endpoint.match(/^\/\((.+)\)$/);
const executionPath = endpointGroup
? `/${endpointGroup[1]!.split('|')[0]}`
: endpoint;
if (executionPath === mcpPath) {
throw new Error(
`[MCP] path "${mcpPath}" conflicts with the GraphQL endpoint. ` +
`Configure a distinct MCP path.`,
);
}
executeViaYoga = async (operation, headers, serverContext) => {
// Strip hop-by-hop and transport-mechanics headers (RFC 7230
// section 6.1); they describe the original request's transport,
// which is false for the synthetic internal request. Semantic
// headers (authorization, cookies, x-forwarded-*) pass through.
const requestHeaders: Record<string, string> = { ...headers };
for (const name of [
'accept-encoding',
'connection',
'content-length',
'host',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailer',
'transfer-encoding',
'upgrade',
]) {
delete requestHeaders[name];
}
requestHeaders['content-type'] = 'application/json';
requestHeaders['accept'] = 'application/json';
Comment thread
zensucht marked this conversation as resolved.
const response = await yoga.handle(
new yoga.fetchAPI.Request(`http://mcp.internal${executionPath}`, {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify(operation),
}),
serverContext as Parameters<(typeof yoga)['handle']>[1],
);
const responseText = await response.text();
let result: ExecutionResult;
try {
result = JSON.parse(responseText) as ExecutionResult;
} catch {
throw new Error(
`[MCP] GraphQL execution returned a non-JSON response (status ${response.status})`,
);
}
// A non-OK response without GraphQL result shape comes from a
// plugin short-circuiting the pipeline (e.g. auth), not from
// GraphQL execution; GraphQL-shaped error responses pass
// through as data.
if (
!response.ok &&
result.data === undefined &&
result.errors === undefined
) {
throw new Error(
`[MCP] GraphQL execution failed with status ${response.status}`,
);
}
return result;
};

const mcp = mcpPath
.replace(/^\//, '')
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
Expand All @@ -1345,6 +1480,7 @@ export function useMCP(ctx: PluginContext, config: MCPConfig): GatewayPlugin {
setRequestParser,
endResponse,
fetchAPI,
serverContext,
}) {
if (url.pathname !== mcpPath) {
return;
Expand Down Expand Up @@ -1630,13 +1766,33 @@ export function useMCP(ctx: PluginContext, config: MCPConfig): GatewayPlugin {
const providerContext: DescriptionProviderContext | undefined =
promptLabel ? { label: promptLabel } : undefined;

let dispatchContext: CustomMethodDispatchContext | undefined;
if (mcpHandlerOptions.customMethods) {
const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});
dispatchContext = {
transport: { type: 'http', request, headers },
executeGraphQL: (operation) => {
if (!executeViaYoga) {
throw new Error(
'[MCP] GraphQL execution is not available until the server has initialized',
);
}
return executeViaYoga(operation, headers, serverContext);
},
};
}

let result;
try {
result = await handleMCPRequest(
ctx,
body,
mcpHandlerOptions,
providerContext,
dispatchContext,
);
} catch (err) {
logger.error(
Expand Down
Loading