Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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';
}
}
112 changes: 110 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,32 @@ export function useMCP(ctx: PluginContext, config: MCPConfig): GatewayPlugin {
'[MCP] config.tools must be an array of tool configurations',
);
}
if (config.customMethods) {
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`);
}
}
}
Comment thread
zensucht marked this conversation as resolved.
Outdated
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 +1173,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 +1329,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 +1376,44 @@ 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) => {
const requestHeaders: Record<string, string> = { ...headers };
delete requestHeaders['content-length'];
delete requestHeaders['accept-encoding'];
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();
try {
return JSON.parse(responseText) as ExecutionResult;
} catch {
throw new Error(
`[MCP] GraphQL execution returned a non-JSON response (status ${response.status})`,
);
}
};

const mcp = mcpPath
.replace(/^\//, '')
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
Expand All @@ -1345,6 +1432,7 @@ export function useMCP(ctx: PluginContext, config: MCPConfig): GatewayPlugin {
setRequestParser,
endResponse,
fetchAPI,
serverContext,
}) {
if (url.pathname !== mcpPath) {
return;
Expand Down Expand Up @@ -1630,13 +1718,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