mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
fix(app-deployments): auto-inject MCP directive definitions during operation validation (#7970)
This commit is contained in:
parent
c6905421e7
commit
0162def432
2 changed files with 283 additions and 2 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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> = [];
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue