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,
sentry
} 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 type { GatewayHonoEnv } from './lib/types'
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 { getRequestCacheKey } from './lib/get-request-cache-key'
import { resolveOriginRequest } from './lib/resolve-origin-request'
@ -72,8 +75,38 @@ app.all(async (ctx) => {
break
}
case 'mcp':
throw new Error('MCP not yet supported')
case 'mcp': {
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')

Wyświetl plik

@ -1,3 +1,4 @@
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import { Validator } from '@agentic/json-schema'
import { HttpError } from '@agentic/platform-core'
import plur from 'plur'
@ -18,15 +19,17 @@ export function cfValidateJsonSchemaObject<
>({
schema,
data,
coerce = false,
strictAdditionalProperties = false,
errorMessage,
coerce = true,
strictAdditionalProperties = true
errorStatusCode = 400
}: {
schema: any
data: Record<string, unknown>
errorMessage?: string
coerce?: boolean
strictAdditionalProperties?: boolean
errorMessage?: string
errorStatusCode?: ContentfulStatusCode
}): T {
// Special-case check for required fields to give better error messages.
if (schema.required && Array.isArray(schema.required)) {
@ -36,7 +39,7 @@ export function cfValidateJsonSchemaObject<
if (missingRequiredFields.length > 0) {
throw new HttpError({
statusCode: 400,
statusCode: errorStatusCode,
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) {
throw new HttpError({
statusCode: 400,
statusCode: errorStatusCode,
message: `${errorMessage ? errorMessage + ': ' : ''}Unexpected additional ${plur('parameter', extraProperties.length)}: ${extraProperties.map((property) => `"${property}"`).join(', ')}`
})
}
@ -85,7 +88,7 @@ export function cfValidateJsonSchemaObject<
.join(' ')}`
throw new HttpError({
statusCode: 400,
statusCode: errorStatusCode,
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 {
AdminDeployment,
OpenAPIToolOperation,
Tool
OpenAPIToolOperation
} from '@agentic/platform-types'
import { assert } from '@agentic/platform-core'
import type { GatewayHonoContext } from './types'
import { cfValidateJsonSchemaObject } from './cf-validate-json-schema-object'
import type { GatewayHonoContext, ToolArgs } from './types'
export async function createRequestForOpenAPIOperation(
ctx: GatewayHonoContext,
{
tool,
toolArgs,
operation,
deployment
}: {
tool: Tool
toolArgs: ToolArgs
operation: OpenAPIToolOperation
deployment: AdminDeployment
}
): Promise<Request> {
const request = ctx.req.raw
assert(toolArgs, 500, 'Tool args are required')
assert(
deployment.originAdapter.type === 'openapi',
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
const params = Object.entries(operation.parameterSources)
const bodyParams = params.filter(([_key, source]) => source === 'body')
@ -84,13 +49,12 @@ export async function createRequestForOpenAPIOperation(
if (headerParams.length > 0) {
for (const [key] of headerParams) {
headers[key] =
(request.headers.get(key) as string) ?? incomingRequestParams[key]
headers[key] = (request.headers.get(key) as string) ?? toolArgs[key]
}
}
for (const [key] of cookieParams) {
headers[key] = String(incomingRequestParams[key])
headers[key] = String(toolArgs[key])
}
let body: string | undefined
@ -98,7 +62,7 @@ export async function createRequestForOpenAPIOperation(
body = JSON.stringify(
Object.fromEntries(
bodyParams
.map(([key]) => [key, incomingRequestParams[key]])
.map(([key]) => [key, toolArgs[key]])
// Prune undefined values. We know these aren't required fields,
// because the incoming request params have already been validated
// against the tool's input schema.
@ -111,7 +75,7 @@ export async function createRequestForOpenAPIOperation(
// TODO: Double-check FormData usage.
const formData = new FormData()
for (const [key] of formDataParams) {
const value = incomingRequestParams[key]
const value = toolArgs[key]
if (value !== undefined) {
formData.append(key, value)
}
@ -124,7 +88,7 @@ export async function createRequestForOpenAPIOperation(
let path = operation.path
if (pathParams.length > 0) {
for (const [key] of pathParams) {
const value: string = incomingRequestParams[key]
const value: string = toolArgs[key]
assert(value, 400, `Missing required parameter "${key}"`)
const pathParamPlaceholder = `{${key}}`
@ -145,7 +109,7 @@ export async function createRequestForOpenAPIOperation(
const query = new URLSearchParams()
for (const [key] of queryParams) {
query.set(key, incomingRequestParams[key] as string)
query.set(key, toolArgs[key] as string)
}
const queryString = query.toString()
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 {
AdminConsumer,
GatewayHonoContext,
ResolvedOriginRequest
ResolvedOriginRequest,
ToolArgs
} from './types'
import { createRequestForOpenAPIOperation } from './create-request-for-openapi-operation'
import { enforceRateLimit } from './enforce-rate-limit'
import { getAdminConsumer } from './get-admin-consumer'
import { getAdminDeployment } from './get-admin-deployment'
import { getTool } from './get-tool'
import { getToolArgsFromRequest } from './get-tool-args-from-request'
import { updateOriginRequest } from './update-origin-request'
/**
@ -178,28 +180,37 @@ export async function resolveOriginRequest(
const { originAdapter } = deployment
let originRequest: Request | undefined
let toolArgs: ToolArgs | undefined
if (originAdapter.type === 'openapi' || originAdapter.type === 'raw') {
if (originAdapter.type === 'openapi') {
const operation = originAdapter.toolToOperationMap[tool.name]
assert(operation, 404, `Tool "${tool.name}" not found in OpenAPI spec`)
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
})
}
originRequest = await createRequestForOpenAPIOperation(ctx, {
tool,
operation,
deployment
})
} else {
const originRequestUrl = `${deployment.originUrl}/${toolName}${requestUrl.search}`
originRequest = new Request(originRequestUrl, ctx.req.raw)
}
if (originAdapter.type === 'openapi') {
const operation = originAdapter.toolToOperationMap[tool.name]
assert(operation, 404, `Tool "${tool.name}" not found in OpenAPI spec`)
originRequest = await createRequestForOpenAPIOperation(ctx, {
toolArgs: toolArgs!,
operation,
deployment
})
}
if (originRequest) {
logger.info('originRequestUrl', originRequest.url)
updateOriginRequest(originRequest, { consumer, deployment })
}
return {
originRequest,
toolArgs,
deployment,
consumer,
tool,

Wyświetl plik

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

Wyświetl plik

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