kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
feat: improve mcp origin support for api-gateway
rodzic
ee658f8d81
commit
2897b26c0b
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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}${
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
Ładowanie…
Reference in New Issue