feat: improve mcp origin support for api-gateway

pull/715/head
Travis Fischer 2025-06-06 00:15:53 +07:00
rodzic ee658f8d81
commit 2897b26c0b
8 zmienionych plików z 213 dodań i 74 usunięć

Wyświetl plik

@ -6,10 +6,13 @@ import {
responseTime, responseTime,
sentry sentry
} from '@agentic/platform-hono' } from '@agentic/platform-hono'
import { Client as McpClient } from '@modelcontextprotocol/sdk/client/index.js'
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { Hono } from 'hono' import { Hono } from 'hono'
import type { GatewayHonoEnv } from './lib/types' import type { GatewayHonoEnv } from './lib/types'
import { createAgenticClient } from './lib/agentic-client' import { createAgenticClient } from './lib/agentic-client'
import { createHttpResponseFromMcpToolCallResponse } from './lib/create-http-response-from-mcp-tool-call-response'
import { fetchCache } from './lib/fetch-cache' import { fetchCache } from './lib/fetch-cache'
import { getRequestCacheKey } from './lib/get-request-cache-key' import { getRequestCacheKey } from './lib/get-request-cache-key'
import { resolveOriginRequest } from './lib/resolve-origin-request' import { resolveOriginRequest } from './lib/resolve-origin-request'
@ -72,8 +75,38 @@ app.all(async (ctx) => {
break break
} }
case 'mcp': case 'mcp': {
throw new Error('MCP not yet supported') assert(
resolvedOriginRequest.toolArgs,
500,
'Tool args are required for MCP origin requests'
)
const transport = new SSEClientTransport(
new URL(resolvedOriginRequest.deployment.originUrl)
)
const client = new McpClient({
name: resolvedOriginRequest.deployment.originAdapter.serverInfo.name,
version:
resolvedOriginRequest.deployment.originAdapter.serverInfo.version
})
// TODO: re-use client connection across requests
await client.connect(transport)
// TODO: add timeout support to the origin tool call?
// TODO: add response caching for MCP tool calls
const toolCallResponse = await client.callTool({
name: resolvedOriginRequest.tool.name,
arguments: resolvedOriginRequest.toolArgs
})
originResponse = await createHttpResponseFromMcpToolCallResponse(ctx, {
tool: resolvedOriginRequest.tool,
deployment: resolvedOriginRequest.deployment,
toolCallResponse
})
}
} }
assert(originResponse, 500, 'Origin response is required') assert(originResponse, 500, 'Origin response is required')

Wyświetl plik

@ -1,3 +1,4 @@
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import { Validator } from '@agentic/json-schema' import { Validator } from '@agentic/json-schema'
import { HttpError } from '@agentic/platform-core' import { HttpError } from '@agentic/platform-core'
import plur from 'plur' import plur from 'plur'
@ -18,15 +19,17 @@ export function cfValidateJsonSchemaObject<
>({ >({
schema, schema,
data, data,
coerce = false,
strictAdditionalProperties = false,
errorMessage, errorMessage,
coerce = true, errorStatusCode = 400
strictAdditionalProperties = true
}: { }: {
schema: any schema: any
data: Record<string, unknown> data: Record<string, unknown>
errorMessage?: string
coerce?: boolean coerce?: boolean
strictAdditionalProperties?: boolean strictAdditionalProperties?: boolean
errorMessage?: string
errorStatusCode?: ContentfulStatusCode
}): T { }): T {
// Special-case check for required fields to give better error messages. // Special-case check for required fields to give better error messages.
if (schema.required && Array.isArray(schema.required)) { if (schema.required && Array.isArray(schema.required)) {
@ -36,7 +39,7 @@ export function cfValidateJsonSchemaObject<
if (missingRequiredFields.length > 0) { if (missingRequiredFields.length > 0) {
throw new HttpError({ throw new HttpError({
statusCode: 400, statusCode: errorStatusCode,
message: `${errorMessage ? errorMessage + ': ' : ''}Missing required ${plur('parameter', missingRequiredFields.length)}: ${missingRequiredFields.map((field) => `"${field}"`).join(', ')}` message: `${errorMessage ? errorMessage + ': ' : ''}Missing required ${plur('parameter', missingRequiredFields.length)}: ${missingRequiredFields.map((field) => `"${field}"`).join(', ')}`
}) })
} }
@ -55,7 +58,7 @@ export function cfValidateJsonSchemaObject<
if (extraProperties.length > 0) { if (extraProperties.length > 0) {
throw new HttpError({ throw new HttpError({
statusCode: 400, statusCode: errorStatusCode,
message: `${errorMessage ? errorMessage + ': ' : ''}Unexpected additional ${plur('parameter', extraProperties.length)}: ${extraProperties.map((property) => `"${property}"`).join(', ')}` message: `${errorMessage ? errorMessage + ': ' : ''}Unexpected additional ${plur('parameter', extraProperties.length)}: ${extraProperties.map((property) => `"${property}"`).join(', ')}`
}) })
} }
@ -85,7 +88,7 @@ export function cfValidateJsonSchemaObject<
.join(' ')}` .join(' ')}`
throw new HttpError({ throw new HttpError({
statusCode: 400, statusCode: errorStatusCode,
message: finalErrorMessage message: finalErrorMessage
}) })
} }

Wyświetl plik

@ -0,0 +1,55 @@
import type { AdminDeployment, Tool } from '@agentic/platform-types'
import { assert } from '@agentic/platform-core'
import type { GatewayHonoContext, McpToolCallResponse } from './types'
import { cfValidateJsonSchemaObject } from './cf-validate-json-schema-object'
export async function createHttpResponseFromMcpToolCallResponse(
ctx: GatewayHonoContext,
{
tool,
deployment,
toolCallResponse
}: {
tool: Tool
deployment: AdminDeployment
toolCallResponse: McpToolCallResponse
}
): Promise<Response> {
assert(
deployment.originAdapter.type === 'mcp',
500,
`Internal logic error for origin adapter type "${deployment.originAdapter.type}"`
)
if (tool.outputSchema) {
assert(
toolCallResponse.structuredContent,
502,
`Structured content is required for MCP origin requests to tool "${tool.name}" because it has an output schema.`
)
// Validate tool response against the tool's output schema.
const toolCallResponseContent = cfValidateJsonSchemaObject({
schema: tool.outputSchema,
data: toolCallResponse.structuredContent as Record<string, unknown>,
coerce: false,
// TODO: double-check MCP schema on whether additional properties are allowed
strictAdditionalProperties: true,
errorMessage: `Invalid tool response for tool "${tool.name}"`,
errorStatusCode: 502
})
return new Response(JSON.stringify(toolCallResponseContent), {
headers: {
'content-type': 'application/json'
}
})
}
return new Response(JSON.stringify(toolCallResponse.content), {
headers: {
'content-type': 'application/json'
}
})
}

Wyświetl plik

@ -1,66 +1,31 @@
import type { import type {
AdminDeployment, AdminDeployment,
OpenAPIToolOperation, OpenAPIToolOperation
Tool
} from '@agentic/platform-types' } from '@agentic/platform-types'
import { assert } from '@agentic/platform-core' import { assert } from '@agentic/platform-core'
import type { GatewayHonoContext } from './types' import type { GatewayHonoContext, ToolArgs } from './types'
import { cfValidateJsonSchemaObject } from './cf-validate-json-schema-object'
export async function createRequestForOpenAPIOperation( export async function createRequestForOpenAPIOperation(
ctx: GatewayHonoContext, ctx: GatewayHonoContext,
{ {
tool, toolArgs,
operation, operation,
deployment deployment
}: { }: {
tool: Tool toolArgs: ToolArgs
operation: OpenAPIToolOperation operation: OpenAPIToolOperation
deployment: AdminDeployment deployment: AdminDeployment
} }
): Promise<Request> { ): Promise<Request> {
const request = ctx.req.raw const request = ctx.req.raw
assert(toolArgs, 500, 'Tool args are required')
assert( assert(
deployment.originAdapter.type === 'openapi', deployment.originAdapter.type === 'openapi',
500, 500,
`Unexpected origin adapter type: "${deployment.originAdapter.type}"` `Internal logic error for origin adapter type "${deployment.originAdapter.type}"`
) )
let incomingRequestParamsRaw: Record<string, any> = {}
if (request.method === 'GET') {
// Params will be coerced to match their expected types via
// `cfValidateJsonSchemaObject` since all values will be strings.
incomingRequestParamsRaw = Object.fromEntries(
new URL(request.url).searchParams.entries()
)
} else if (request.method === 'POST') {
incomingRequestParamsRaw = (await request.clone().json()) as Record<
string,
any
>
// TODO: Support empty params for POST requests
assert(incomingRequestParamsRaw, 400, 'Invalid empty request body')
assert(
typeof incomingRequestParamsRaw === 'object',
400,
'Invalid request body'
)
assert(
!Array.isArray(incomingRequestParamsRaw),
400,
'Invalid request body'
)
}
// Validate incoming request params against the tool's input JSON schema.
const incomingRequestParams = cfValidateJsonSchemaObject({
schema: tool.inputSchema,
data: incomingRequestParamsRaw,
errorMessage: `Invalid request parameters for tool "${tool.name}"`
})
// TODO: Make this more efficient by changing the `parameterSources` data structure // TODO: Make this more efficient by changing the `parameterSources` data structure
const params = Object.entries(operation.parameterSources) const params = Object.entries(operation.parameterSources)
const bodyParams = params.filter(([_key, source]) => source === 'body') const bodyParams = params.filter(([_key, source]) => source === 'body')
@ -84,13 +49,12 @@ export async function createRequestForOpenAPIOperation(
if (headerParams.length > 0) { if (headerParams.length > 0) {
for (const [key] of headerParams) { for (const [key] of headerParams) {
headers[key] = headers[key] = (request.headers.get(key) as string) ?? toolArgs[key]
(request.headers.get(key) as string) ?? incomingRequestParams[key]
} }
} }
for (const [key] of cookieParams) { for (const [key] of cookieParams) {
headers[key] = String(incomingRequestParams[key]) headers[key] = String(toolArgs[key])
} }
let body: string | undefined let body: string | undefined
@ -98,7 +62,7 @@ export async function createRequestForOpenAPIOperation(
body = JSON.stringify( body = JSON.stringify(
Object.fromEntries( Object.fromEntries(
bodyParams bodyParams
.map(([key]) => [key, incomingRequestParams[key]]) .map(([key]) => [key, toolArgs[key]])
// Prune undefined values. We know these aren't required fields, // Prune undefined values. We know these aren't required fields,
// because the incoming request params have already been validated // because the incoming request params have already been validated
// against the tool's input schema. // against the tool's input schema.
@ -111,7 +75,7 @@ export async function createRequestForOpenAPIOperation(
// TODO: Double-check FormData usage. // TODO: Double-check FormData usage.
const formData = new FormData() const formData = new FormData()
for (const [key] of formDataParams) { for (const [key] of formDataParams) {
const value = incomingRequestParams[key] const value = toolArgs[key]
if (value !== undefined) { if (value !== undefined) {
formData.append(key, value) formData.append(key, value)
} }
@ -124,7 +88,7 @@ export async function createRequestForOpenAPIOperation(
let path = operation.path let path = operation.path
if (pathParams.length > 0) { if (pathParams.length > 0) {
for (const [key] of pathParams) { for (const [key] of pathParams) {
const value: string = incomingRequestParams[key] const value: string = toolArgs[key]
assert(value, 400, `Missing required parameter "${key}"`) assert(value, 400, `Missing required parameter "${key}"`)
const pathParamPlaceholder = `{${key}}` const pathParamPlaceholder = `{${key}}`
@ -145,7 +109,7 @@ export async function createRequestForOpenAPIOperation(
const query = new URLSearchParams() const query = new URLSearchParams()
for (const [key] of queryParams) { for (const [key] of queryParams) {
query.set(key, incomingRequestParams[key] as string) query.set(key, toolArgs[key] as string)
} }
const queryString = query.toString() const queryString = query.toString()
const originRequestUrl = `${deployment.originUrl}${path}${ const originRequestUrl = `${deployment.originUrl}${path}${

Wyświetl plik

@ -0,0 +1,60 @@
import type { AdminDeployment, Tool } from '@agentic/platform-types'
import { assert } from '@agentic/platform-core'
import type { GatewayHonoContext } from './types'
import { cfValidateJsonSchemaObject } from './cf-validate-json-schema-object'
export async function getToolArgsFromRequest(
ctx: GatewayHonoContext,
{
tool,
deployment
}: {
tool: Tool
deployment: AdminDeployment
}
): Promise<Record<string, any>> {
const request = ctx.req.raw
assert(
deployment.originAdapter.type !== 'raw',
500,
`Internal logic error for origin adapter type "${deployment.originAdapter.type}"`
)
let incomingRequestArgsRaw: Record<string, any> = {}
let coerceRequestArgs = false
if (request.method === 'GET') {
// Args will be coerced to match their expected types via
// `cfValidateJsonSchemaObject` since all values will be strings.
incomingRequestArgsRaw = Object.fromEntries(
new URL(request.url).searchParams.entries()
)
coerceRequestArgs = true
} else if (request.method === 'POST') {
incomingRequestArgsRaw = (await request.clone().json()) as Record<
string,
any
>
// TODO: Support empty params for POST requests
assert(incomingRequestArgsRaw, 400, 'Invalid empty request body')
assert(
typeof incomingRequestArgsRaw === 'object',
400,
'Invalid request body'
)
assert(!Array.isArray(incomingRequestArgsRaw), 400, 'Invalid request body')
}
// Validate incoming request params against the tool's input schema.
const incomingRequestArgs = cfValidateJsonSchemaObject({
schema: tool.inputSchema,
data: incomingRequestArgsRaw,
errorMessage: `Invalid request parameters for tool "${tool.name}"`,
coerce: coerceRequestArgs,
strictAdditionalProperties: true
})
return incomingRequestArgs
}

Wyświetl plik

@ -5,13 +5,15 @@ import { parseToolIdentifier } from '@agentic/platform-validators'
import type { import type {
AdminConsumer, AdminConsumer,
GatewayHonoContext, GatewayHonoContext,
ResolvedOriginRequest ResolvedOriginRequest,
ToolArgs
} from './types' } from './types'
import { createRequestForOpenAPIOperation } from './create-request-for-openapi-operation' import { createRequestForOpenAPIOperation } from './create-request-for-openapi-operation'
import { enforceRateLimit } from './enforce-rate-limit' import { enforceRateLimit } from './enforce-rate-limit'
import { getAdminConsumer } from './get-admin-consumer' import { getAdminConsumer } from './get-admin-consumer'
import { getAdminDeployment } from './get-admin-deployment' import { getAdminDeployment } from './get-admin-deployment'
import { getTool } from './get-tool' import { getTool } from './get-tool'
import { getToolArgsFromRequest } from './get-tool-args-from-request'
import { updateOriginRequest } from './update-origin-request' import { updateOriginRequest } from './update-origin-request'
/** /**
@ -178,28 +180,37 @@ export async function resolveOriginRequest(
const { originAdapter } = deployment const { originAdapter } = deployment
let originRequest: Request | undefined let originRequest: Request | undefined
let toolArgs: ToolArgs | undefined
if (originAdapter.type === 'raw') {
const originRequestUrl = `${deployment.originUrl}/${toolName}${requestUrl.search}`
originRequest = new Request(originRequestUrl, ctx.req.raw)
} else {
toolArgs = await getToolArgsFromRequest(ctx, {
tool,
deployment
})
}
if (originAdapter.type === 'openapi' || originAdapter.type === 'raw') {
if (originAdapter.type === 'openapi') { if (originAdapter.type === 'openapi') {
const operation = originAdapter.toolToOperationMap[tool.name] const operation = originAdapter.toolToOperationMap[tool.name]
assert(operation, 404, `Tool "${tool.name}" not found in OpenAPI spec`) assert(operation, 404, `Tool "${tool.name}" not found in OpenAPI spec`)
originRequest = await createRequestForOpenAPIOperation(ctx, { originRequest = await createRequestForOpenAPIOperation(ctx, {
tool, toolArgs: toolArgs!,
operation, operation,
deployment deployment
}) })
} else {
const originRequestUrl = `${deployment.originUrl}/${toolName}${requestUrl.search}`
originRequest = new Request(originRequestUrl, ctx.req.raw)
} }
if (originRequest) {
logger.info('originRequestUrl', originRequest.url) logger.info('originRequestUrl', originRequest.url)
updateOriginRequest(originRequest, { consumer, deployment }) updateOriginRequest(originRequest, { consumer, deployment })
} }
return { return {
originRequest, originRequest,
toolArgs,
deployment, deployment,
consumer, consumer,
tool, tool,

Wyświetl plik

@ -9,11 +9,16 @@ import type {
Tool, Tool,
User User
} from '@agentic/platform-types' } from '@agentic/platform-types'
import type { Client as McpClient } from '@modelcontextprotocol/sdk/client/index.js'
import type { Context } from 'hono' import type { Context } from 'hono'
import type { Simplify } from 'type-fest' import type { Simplify } from 'type-fest'
import type { Env } from './env' import type { Env } from './env'
export type McpToolCallResponse = Simplify<
Awaited<ReturnType<McpClient['callTool']>>
>
export type AdminConsumer = Simplify< export type AdminConsumer = Simplify<
Consumer & { Consumer & {
user: User user: User
@ -36,13 +41,19 @@ export type GatewayHonoEnv = {
export type GatewayHonoContext = Context<GatewayHonoEnv> export type GatewayHonoContext = Context<GatewayHonoEnv>
export interface ResolvedOriginRequest { // TODO: better type here
originRequest?: Request export type ToolArgs = Record<string, any>
export type ResolvedOriginRequest = {
deployment: AdminDeployment deployment: AdminDeployment
consumer?: AdminConsumer
tool: Tool tool: Tool
method: string method: string
reportUsage: boolean reportUsage: boolean
consumer?: AdminConsumer
ip?: string ip?: string
pricingPlanSlug?: string pricingPlanSlug?: string
originRequest?: Request
toolArgs?: ToolArgs
} }

Wyświetl plik

@ -28,10 +28,11 @@
- raw - raw
- auth - auth
- custom auth pages for `openauth` - custom auth pages for `openauth`
- consider `projectName` and `projectSlug` or `projectIdentifier`?
- add username / team name blacklist - add username / team name blacklist
- admin, internal, mcp, sse, etc - admin, internal, mcp, sse, etc
- API gateway - API gateway
- public MCP interface
- MCP origin server support
- add support for custom headers on responses - add support for custom headers on responses
- how to handle binary bodies and responses? - how to handle binary bodies and responses?
@ -60,6 +61,7 @@
- https://github.com/honojs/middleware/issues/943 - https://github.com/honojs/middleware/issues/943
- https://github.com/getsentry/sentry-javascript/tree/master/packages/cloudflare - https://github.com/getsentry/sentry-javascript/tree/master/packages/cloudflare
- additional transactional emails - additional transactional emails
- consider `projectName` and `projectSlug` or `projectIdentifier`?
## License ## License