fix(app-deployments): auto-inject MCP directive definitions during operation validation (#7970)

This commit is contained in:
Adam Benhassen 2026-04-13 20:08:08 +03:00 committed by GitHub
parent c6905421e7
commit 0162def432
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 283 additions and 2 deletions

View file

@ -958,6 +958,221 @@ test('add documents to app deployment fails if document does not pass validation
});
});
test('app deployment validates documents with MCP directives when schema does not define them', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
await token.publishSchema({
sdl: /* GraphQL */ `
type Query {
weather(location: String!): Weather
}
type Weather {
temp: Float
conditions: String
}
`,
});
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'mcp-test',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
const { addDocumentsToAppDeployment } = await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'mcp-test',
appVersion: '1.0.0',
documents: [
{
hash: 'mcp-weather',
body: [
'query GetWeather(',
' $location: String! @mcpDescription(provider: "langfuse:loc") @mcpHeader(name: "X-Location")',
') @mcpTool(name: "get_weather", description: "Get weather") {',
' weather(location: $location) { temp conditions }',
'}',
].join('\n'),
},
{
hash: 'mcp-weather-title',
body: [
'query GetWeatherTitle(',
' $location: String! @mcpDescription(provider: "langfuse:loc")',
') @mcpTool(name: "get_weather_title", title: "Weather Tool", meta: "{}") {',
' weather(location: $location) { temp conditions }',
'}',
].join('\n'),
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(addDocumentsToAppDeployment.error).toBeNull();
});
test('app deployment validates documents with MCP directives when schema already defines them', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
await token.publishSchema({
sdl: /* GraphQL */ `
scalar JSON
directive @mcpTool(name: String!, description: String) on QUERY | MUTATION
directive @mcpDescription(provider: String!) on VARIABLE_DEFINITION | FIELD
directive @mcpHeader(name: String!) on VARIABLE_DEFINITION
type Query {
weather(location: String!): Weather
}
type Weather {
temp: Float
conditions: String
}
`,
});
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'mcp-existing',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
const { addDocumentsToAppDeployment: successResult } = await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'mcp-existing',
appVersion: '1.0.0',
documents: [
{
hash: 'mcp-weather-existing',
body: [
'query GetWeather(',
' $location: String! @mcpDescription(provider: "langfuse:loc")',
') @mcpTool(name: "get_weather", description: "Get weather") {',
' weather(location: $location) { temp conditions }',
'}',
].join('\n'),
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(successResult.error).toBeNull();
const { addDocumentsToAppDeployment: failResult } = await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'mcp-existing',
appVersion: '1.0.0',
documents: [
{
hash: 'mcp-weather-title',
body: [
'query GetWeather(',
' $location: String! @mcpDescription(provider: "langfuse:loc")',
') @mcpTool(name: "get_weather", title: "Weather Tool") {',
' weather(location: $location) { temp conditions }',
'}',
].join('\n'),
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(failResult.error).toEqual(
expect.objectContaining({
message: expect.stringContaining('not valid'),
details: expect.objectContaining({
message: expect.stringContaining('title'),
}),
}),
);
});
test('app deployment injects only missing MCP directives when schema partially defines them', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
await token.publishSchema({
sdl: /* GraphQL */ `
directive @mcpTool(name: String!, description: String) on QUERY | MUTATION
type Query {
weather(location: String!): Weather
}
type Weather {
temp: Float
conditions: String
}
`,
});
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'mcp-partial',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
const { addDocumentsToAppDeployment } = await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'mcp-partial',
appVersion: '1.0.0',
documents: [
{
hash: 'mcp-partial-weather',
body: [
'query GetWeather(',
' $location: String! @mcpDescription(provider: "langfuse:loc")',
') @mcpTool(name: "get_weather", description: "Get weather") {',
' weather(location: $location) { temp conditions }',
'}',
].join('\n'),
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(addDocumentsToAppDeployment.error).toBeNull();
});
test('add documents to app deployment fails if document contains multiple executable operation definitions', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();

View file

@ -1,4 +1,15 @@
import { buildSchema, DocumentNode, GraphQLError, Kind, parse, TypeInfo, validate } from 'graphql';
import {
buildSchema,
DirectiveDefinitionNode,
DocumentNode,
GraphQLError,
Kind,
parse,
print,
ScalarTypeDefinitionNode,
TypeInfo,
validate,
} from 'graphql';
import PromiseQueue from 'p-queue';
import { z } from 'zod';
import { collectSchemaCoordinates, preprocessOperation } from '@graphql-hive/core';
@ -30,6 +41,60 @@ const AppDeploymentOperationHashModel = z
const AppDeploymentOperationBodyModel = z.string().min(3, 'Body must be at least 3 character long');
const MCP_DIRECTIVES_DOC = parse(/* GraphQL */ `
directive @mcpTool(
name: String!
description: String
title: String
descriptionProvider: String
meta: _HiveMCPJSON
) on QUERY | MUTATION
directive @mcpDescription(provider: String!) on VARIABLE_DEFINITION | FIELD
directive @mcpHeader(name: String!) on VARIABLE_DEFINITION
scalar _HiveMCPJSON
`);
/**
* Persisted operation documents may use MCP directives (`@mcpTool`, `@mcpDescription`,
* `@mcpHeader`) and the `JSON` scalar that may not be part of the user's schema.
* We inject any missing definitions so that `validate()` does not reject the documents
* for unknown directives or types.
*
* Only definitions absent from the SDL are added. When the user already defines an MCP
* directive (e.g. with a narrower arg set), their definition takes precedence.
*/
function injectMcpDirectives(schemaSdl: string): string {
const schemaDoc = parse(schemaSdl);
const mcpNames = new Set(
MCP_DIRECTIVES_DOC.definitions
.filter(
(def): def is DirectiveDefinitionNode | ScalarTypeDefinitionNode =>
def.kind === Kind.DIRECTIVE_DEFINITION || def.kind === Kind.SCALAR_TYPE_DEFINITION,
)
.map(def => def.name.value),
);
for (const def of schemaDoc.definitions) {
if ('name' in def && def.name && mcpNames.has(def.name.value)) {
mcpNames.delete(def.name.value);
}
}
const missing = MCP_DIRECTIVES_DOC.definitions.filter(def => {
if (def.kind === Kind.DIRECTIVE_DEFINITION || def.kind === Kind.SCALAR_TYPE_DEFINITION) {
return mcpNames.has(def.name.value);
}
return false;
});
if (missing.length === 0) {
return schemaSdl;
}
return schemaSdl + '\n' + print({ kind: Kind.DOCUMENT, definitions: missing });
}
export type BatchProcessEvent = {
event: 'PROCESS';
id: string;
@ -107,7 +172,8 @@ export class PersistedDocumentIngester {
data.documents.length,
);
const schema = buildSchema(data.schemaSdl);
const schemaSdl = injectMcpDirectives(data.schemaSdl);
const schema = buildSchema(schemaSdl);
const typeInfo = new TypeInfo(schema);
const documents: Array<DocumentRecord> = [];