feat: refactor validators to throw on errors; gateway work wip

pull/715/head
Travis Fischer 2025-06-09 18:17:34 +07:00
rodzic ce2f3afc41
commit 8ae5dec653
26 zmienionych plików z 124 dodań i 143 usunięć

Wyświetl plik

@ -53,11 +53,6 @@ export async function tryGetDeploymentByIdentifier(
deploymentIdentifier,
{ strict }
)
assert(
parsedDeploymentIdentifier,
400,
`Invalid deployment identifier "${deploymentIdentifier}"`
)
const { projectIdentifier, deploymentHash, deploymentVersion } =
parsedDeploymentIdentifier

Wyświetl plik

@ -43,11 +43,6 @@ export async function tryGetProjectByIdentifier(
const parsedProjectIdentifier = parseProjectIdentifier(projectIdentifier, {
strict
})
assert(
parsedProjectIdentifier?.projectIdentifier,
400,
`Invalid project identifier "${projectIdentifier}"`
)
projectIdentifier = parsedProjectIdentifier.projectIdentifier
const project = await db.query.projects.findFirst({

Wyświetl plik

@ -1,4 +1,3 @@
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import { Validator } from '@agentic/json-schema'
import { assert, HttpError } from '@agentic/platform-core'
import plur from 'plur'
@ -27,7 +26,7 @@ export function cfValidateJsonSchema<T = unknown>({
coerce?: boolean
strictAdditionalProperties?: boolean
errorMessage?: string
errorStatusCode?: ContentfulStatusCode
errorStatusCode?: number
}): T {
assert(schema, 400, '`schema` is required')
const isSchemaObject =

Wyświetl plik

@ -3,7 +3,6 @@ import { Client as McpClient } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { DurableObject } from 'cloudflare:workers'
import type { RawEnv } from './env'
import type { AgenticMcpRequestMetadata } from './types'
export type DurableMcpClientInfo = {
@ -17,7 +16,7 @@ export type DurableMcpClientInfo = {
// customer<>DurableMcpClientInfo connection?
// Currently using `sessionId`
export class DurableMcpClient extends DurableObject<RawEnv> {
export class DurableMcpClient extends DurableObject {
protected client?: McpClient
protected clientConnectionP?: Promise<void>

Wyświetl plik

@ -7,7 +7,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import { DurableObject } from 'cloudflare:workers'
import type { RawEnv } from './env'
import type { AdminConsumer } from './types'
export type DurableMcpServerInfo = {
@ -16,7 +15,7 @@ export type DurableMcpServerInfo = {
pricingPlan?: PricingPlan
}
export class DurableMcpServer extends DurableObject<RawEnv> {
export class DurableMcpServer extends DurableObject {
protected server?: McpServer
protected serverTransport?: StreamableHTTPServerTransport
protected serverConnectionP?: Promise<void>
@ -50,15 +49,9 @@ export class DurableMcpServer extends DurableObject<RawEnv> {
assert(mcpServerInfo, 500, 'DurableMcpServer has not been initialized')
const { deployment } = mcpServerInfo
const parsedDeploymentIdentifier = parseDeploymentIdentifier(
const { projectIdentifier } = parseDeploymentIdentifier(
deployment.identifier
)
assert(
parsedDeploymentIdentifier,
500,
`Invalid deployment identifier "${deployment.identifier}"`
)
const { projectIdentifier } = parsedDeploymentIdentifier
this.server = new McpServer({
name: projectIdentifier,

Wyświetl plik

@ -1,25 +1,8 @@
import { DurableObject } from 'cloudflare:workers'
import type { RawEnv } from './env'
// TODO: implement
/** A Durable Object's behavior is defined in an exported Javascript class */
export class DurableRateLimiter extends DurableObject<RawEnv> {
/**
* The constructor is invoked once upon creation of the Durable Object, i.e. the first call to
* `DurableObjectStub::get` for a given identifier (no-op constructors can be omitted)
*
* @param ctx - The interface for interacting with Durable Object state
* @param env - The interface to reference bindings declared in wrangler.jsonc
*/
constructor(ctx: DurableObjectState, env: RawEnv) {
super(ctx, env)
}
/**
* The Durable Object exposes an RPC method sayHello which will be invoked
* when when a Durable Object instance receives a request from a Worker via
* the same method invocation on the stub.
*/
export class DurableRateLimiter extends DurableObject {
async sayHello(name: string): Promise<string> {
return `Hello, ${name}!`
}

Wyświetl plik

@ -9,13 +9,9 @@ export async function getAdminDeployment(
identifier: string
): Promise<AdminDeployment> {
const parsedDeploymentIdentifier = parseDeploymentIdentifier(identifier, {
strict: false
strict: true,
errorStatusCode: 404
})
assert(
parsedDeploymentIdentifier,
404,
`Invalid deployment identifier "${identifier}"`
)
const client = ctx.get('client')
const deployment = await client.adminGetDeploymentByIdentifier({

Wyświetl plik

@ -16,19 +16,11 @@ export async function resolveMcpEdgeRequest(ctx: GatewayHonoContext): Promise<{
const requestedDeploymentIdentifier = pathname
.replace(/^\//, '')
.replace(/\/$/, '')
const parsedDeploymentIdentifier = parseDeploymentIdentifier(
const { deploymentIdentifier } = parseDeploymentIdentifier(
requestedDeploymentIdentifier
)
assert(
parsedDeploymentIdentifier,
404,
`Invalid deployment identifier "${requestedDeploymentIdentifier}"`
)
const deployment = await getAdminDeployment(
ctx,
parsedDeploymentIdentifier.deploymentIdentifier
)
const deployment = await getAdminDeployment(ctx, deploymentIdentifier)
const apiKey = ctx.req.query('apiKey')?.trim()
let consumer: AdminConsumer | undefined

Wyświetl plik

@ -38,18 +38,11 @@ export async function resolveOriginRequest(
const requestUrl = new URL(ctx.req.url)
const { pathname } = requestUrl
const requestedToolIdentifier = pathname.replace(/^\//, '').replace(/\/$/, '')
const parsedToolIdentifier = parseToolIdentifier(requestedToolIdentifier)
assert(
parsedToolIdentifier,
404,
`Invalid tool identifier "${requestedToolIdentifier}"`
const { toolName, deploymentIdentifier } = parseToolIdentifier(
requestedToolIdentifier
)
const { toolName } = parsedToolIdentifier
const deployment = await getAdminDeployment(
ctx,
parsedToolIdentifier.deploymentIdentifier
)
const deployment = await getAdminDeployment(ctx, deploymentIdentifier)
const tool = getTool({
method,
@ -227,15 +220,10 @@ export async function resolveOriginRequest(
version: originAdapter.serverInfo.version
})
const parsedDeploymentIdentifier = parseDeploymentIdentifier(
deployment.identifier
const { projectIdentifier } = parseDeploymentIdentifier(
deployment.identifier,
{ errorStatusCode: 500 }
)
assert(
parsedDeploymentIdentifier,
500,
`Internal error: deployment identifier "${deployment.identifier}" is invalid`
)
const { projectIdentifier } = parsedDeploymentIdentifier
originMcpRequestMetadata = {
agenticProxySecret: deployment._secret,

Wyświetl plik

@ -1,9 +1,11 @@
// import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
// import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { assert, JsonRpcError } from '@agentic/platform-core'
import { parseDeploymentIdentifier } from '@agentic/platform-validators'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import {
InitializeRequestSchema,
isJSONRPCError,
// isJSONRPCError,
isJSONRPCNotification,
isJSONRPCRequest,
isJSONRPCResponse,
@ -13,6 +15,7 @@ import {
import type { GatewayHonoContext } from './lib/types'
import { resolveMcpEdgeRequest } from './lib/resolve-mcp-edge-request'
// import { DurableMcpServer } from './lib/durable-mcp-server'
// TODO: https://github.com/modelcontextprotocol/servers/blob/8fb7bbdab73eddb42aba72e8eab81102efe1d544/src/everything/sse.ts
// TODO: https://github.com/cloudflare/agents
@ -168,10 +171,11 @@ export async function handleMcpRequest(ctx: GatewayHonoContext) {
)
ctx.set('sessionId', sessionId)
// TODO: first version using the McpServer locally instead of a DurableMcpServer
// Fetch the durable mcp server for this session
const id = ctx.env.DO_MCP_SERVER.idFromName(`streamable-http:${sessionId}`)
const durableMcpServer = ctx.env.DO_MCP_SERVER.get(id)
const isInitialized = await durableMcpServer.isInitialized()
// const id = ctx.env.DO_MCP_SERVER.idFromName(`streamable-http:${sessionId}`)
// const durableMcpServer = ctx.env.DO_MCP_SERVER.get(id)
// const isInitialized = await durableMcpServer.isInitialized()
if (!isInitializationRequest && !isInitialized) {
// A session id that was never initialized was provided
@ -183,16 +187,21 @@ export async function handleMcpRequest(ctx: GatewayHonoContext) {
})
}
if (isInitializationRequest) {
const { deployment, consumer, pricingPlan } =
await resolveMcpEdgeRequest(ctx)
const { deployment, consumer, pricingPlan } = await resolveMcpEdgeRequest(ctx)
const { projectIdentifier } = parseDeploymentIdentifier(deployment.identifier)
await durableMcpServer.init({
deployment,
consumer,
pricingPlan
})
}
const server = new McpServer({
name: projectIdentifier,
version: deployment.version ?? '0.0.0'
})
// if (isInitializationRequest) {
// await durableMcpServer.init({
// deployment,
// consumer,
// pricingPlan
// })
// }
// We've validated and initialized the request! Now it's time to actually
// handle the JSON RPC messages in the request and respond with an SSE

Wyświetl plik

@ -1,5 +1,4 @@
/**
* For more details on how to configure Wrangler, refer to:
* https://developers.cloudflare.com/workers/wrangler/configuration/
*/
{

Wyświetl plik

@ -34,12 +34,6 @@ export function registerListDeploymentsCommand({
}
)
if (!parsedDeploymentIdentifier) {
throw new Error(
`Invalid project or deployment identifier "${identifier}"`
)
}
query.projectIdentifier = parsedDeploymentIdentifier.projectIdentifier
label = `Fetching deployments for project "${query.projectIdentifier}"`

Wyświetl plik

@ -28,9 +28,6 @@
"zod": "catalog:",
"zod-validation-error": "catalog:"
},
"devDependencies": {
"hono": "catalog:"
},
"publishConfig": {
"access": "public"
}

Wyświetl plik

@ -1,4 +1,3 @@
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import { fromError } from 'zod-validation-error'
export class BaseError extends Error {
@ -16,7 +15,7 @@ export class BaseError extends Error {
}
export class HttpError extends BaseError {
readonly statusCode: ContentfulStatusCode
readonly statusCode: number
constructor({
message,
@ -24,7 +23,7 @@ export class HttpError extends BaseError {
cause
}: {
message: string
statusCode?: ContentfulStatusCode
statusCode?: number
cause?: unknown
}) {
super({ message, cause })
@ -47,7 +46,7 @@ export class JsonRpcError extends HttpError {
message: string
jsonRpcErrorCode: number
jsonRpcId?: string | number | null
statusCode?: ContentfulStatusCode
statusCode?: number
cause?: unknown
}) {
super({ message, cause, statusCode })
@ -63,7 +62,7 @@ export class ZodValidationError extends HttpError {
prefix,
cause
}: {
statusCode?: ContentfulStatusCode
statusCode?: number
prefix?: string
cause: unknown
}) {

Wyświetl plik

@ -1,4 +1,3 @@
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import type { z, ZodType } from 'zod'
import hashObjectImpl, { type Options as HashObjectOptions } from 'hash-object'
@ -51,12 +50,12 @@ export const pick = <
export function assert(expr: unknown, message?: string): asserts expr
export function assert(
expr: unknown,
statusCode?: ContentfulStatusCode,
statusCode?: number,
message?: string
): asserts expr
export function assert(
expr: unknown,
statusCodeOrMessage?: ContentfulStatusCode | string,
statusCodeOrMessage?: number | string,
message = 'Internal assertion failed'
): asserts expr {
if (expr) {
@ -86,7 +85,7 @@ export function parseZodSchema<TSchema extends ZodType<any, any, any>>(
statusCode = 500
}: {
error?: string
statusCode?: ContentfulStatusCode
statusCode?: number
} = {}
): z.infer<TSchema> {
try {

Wyświetl plik

@ -25,13 +25,13 @@ export function errorHandler(
status = err.status
} else if (err instanceof HttpError) {
message = err.message
status = err.statusCode
status = err.statusCode as ContentfulStatusCode
} else if (err instanceof HTTPError) {
message = err.message
status = err.response.status as ContentfulStatusCode
} else if (err instanceof JsonRpcError) {
message = err.message
status = err.statusCode
status = err.statusCode as ContentfulStatusCode
jsonRpcId = err.jsonRpcId
jsonRpcErrorCode = err.jsonRpcErrorCode
isJsonRpcRequest = true

Wyświetl plik

@ -23,6 +23,7 @@
"test:unit": "vitest run"
},
"dependencies": {
"@agentic/platform-core": "workspace:*",
"@paralleldrive/cuid2": "catalog:",
"email-validator": "catalog:",
"type-fest": "catalog:"

Wyświetl plik

@ -30,8 +30,7 @@ function success(...args: Parameters<typeof parseDeploymentIdentifier>) {
}
function error(...args: Parameters<typeof parseDeploymentIdentifier>) {
const result = parseDeploymentIdentifier(...args)
expect(result).toBeUndefined()
expect(() => parseDeploymentIdentifier(...args)).throws()
}
describe('parseDeploymentIdentifier', () => {

Wyświetl plik

@ -1,3 +1,5 @@
import { HttpError } from '@agentic/platform-core'
import type {
ParsedDeploymentIdentifier,
ParseIdentifierOptions
@ -16,15 +18,18 @@ const deploymentIdentifierVersionRe =
export function parseDeploymentIdentifier(
identifier?: string,
{ strict = true }: ParseIdentifierOptions = {}
): ParsedDeploymentIdentifier | undefined {
if (!strict) {
const parsedToolIdentifier = parseToolIdentifier(identifier, {
strict
})
{ strict = true, errorStatusCode = 400 }: ParseIdentifierOptions = {}
): ParsedDeploymentIdentifier {
const inputIdentifier = identifier
if (parsedToolIdentifier) {
return parsedToolIdentifier
if (!strict) {
try {
return parseToolIdentifier(identifier, {
strict,
errorStatusCode
})
} catch {
// ignore
}
}
@ -33,7 +38,10 @@ export function parseDeploymentIdentifier(
}
if (!identifier?.length) {
return
throw new HttpError({
statusCode: errorStatusCode,
message: `Invalid deployment identifier "${inputIdentifier}"`
})
}
const iMatch = identifier.match(deploymentIdentifierImplicitRe)
@ -71,4 +79,9 @@ export function parseDeploymentIdentifier(
deploymentVersion: 'latest'
}
}
throw new HttpError({
statusCode: errorStatusCode,
message: `Invalid deployment identifier "${inputIdentifier}"`
})
}

Wyświetl plik

@ -20,8 +20,7 @@ function success(...args: Parameters<typeof parseProjectIdentifier>) {
}
function error(...args: Parameters<typeof parseProjectIdentifier>) {
const result = parseProjectIdentifier(...args)
expect(result).toBeUndefined()
expect(() => parseProjectIdentifier(...args)).throws()
}
describe('parseProjectIdentifier', () => {

Wyświetl plik

@ -1,3 +1,5 @@
import { HttpError } from '@agentic/platform-core'
import type { ParsedProjectIdentifier, ParseIdentifierOptions } from './types'
import { parseDeploymentIdentifier } from './parse-deployment-identifier'
import { coerceIdentifier } from './utils'
@ -6,15 +8,18 @@ const projectIdentifierRe = /^@([a-z0-9-]{1,256})\/([a-z0-9-]{1,256})$/
export function parseProjectIdentifier(
identifier?: string,
{ strict = true }: ParseIdentifierOptions = {}
): ParsedProjectIdentifier | undefined {
if (!strict) {
const parsedToolIdentifier = parseDeploymentIdentifier(identifier, {
strict
})
{ strict = true, errorStatusCode = 400 }: ParseIdentifierOptions = {}
): ParsedProjectIdentifier {
const inputIdentifier = identifier
if (parsedToolIdentifier) {
return parsedToolIdentifier
if (!strict) {
try {
return parseDeploymentIdentifier(identifier, {
strict,
errorStatusCode
})
} catch {
// ignore
}
}
@ -23,7 +28,10 @@ export function parseProjectIdentifier(
}
if (!identifier?.length) {
return
throw new HttpError({
statusCode: errorStatusCode,
message: `Invalid project identifier "${inputIdentifier}"`
})
}
const match = identifier.match(projectIdentifierRe)
@ -35,4 +43,9 @@ export function parseProjectIdentifier(
projectName: match[2]!
}
}
throw new HttpError({
statusCode: errorStatusCode,
message: `Invalid project identifier "${inputIdentifier}"`
})
}

Wyświetl plik

@ -32,8 +32,7 @@ function success(...args: Parameters<typeof parseToolIdentifier>) {
}
function error(...args: Parameters<typeof parseToolIdentifier>) {
const result = parseToolIdentifier(...args)
expect(result).toBeUndefined()
expect(() => parseToolIdentifier(...args)).throws()
}
describe('parseToolIdentifier', () => {

Wyświetl plik

@ -1,3 +1,5 @@
import { HttpError } from '@agentic/platform-core'
import type { ParsedToolIdentifier, ParseIdentifierOptions } from './types'
import { coerceIdentifier } from './utils'
@ -12,14 +14,19 @@ const toolIdentifierVersionRe =
export function parseToolIdentifier(
identifier?: string,
{ strict = true }: ParseIdentifierOptions = {}
): ParsedToolIdentifier | undefined {
{ strict = true, errorStatusCode = 400 }: ParseIdentifierOptions = {}
): ParsedToolIdentifier {
const inputIdentifier = identifier
if (!strict) {
identifier = coerceIdentifier(identifier)
}
if (!identifier?.length) {
return
throw new HttpError({
statusCode: errorStatusCode,
message: `Invalid tool identifier "${inputIdentifier}"`
})
}
const iMatch = identifier.match(toolIdentifierImplicitRe)
@ -60,4 +67,9 @@ export function parseToolIdentifier(
toolName: vMatch[4]!
}
}
throw new HttpError({
statusCode: errorStatusCode,
message: `Invalid tool identifier "${inputIdentifier}"`
})
}

Wyświetl plik

@ -2,6 +2,7 @@ import type { Simplify } from 'type-fest'
export type ParseIdentifierOptions = {
strict?: boolean
errorStatusCode?: number
}
export type ParsedProjectIdentifier = {

Wyświetl plik

@ -50,14 +50,22 @@ export function isValidProjectIdentifier(
value?: string,
opts?: ParseIdentifierOptions
): boolean {
return !!parseProjectIdentifier(value, opts)
try {
return !!parseProjectIdentifier(value, opts)
} catch {
return false
}
}
export function isValidDeploymentIdentifier(
value?: string,
opts?: ParseIdentifierOptions
): boolean {
return !!parseDeploymentIdentifier(value, opts)
try {
return !!parseDeploymentIdentifier(value, opts)
} catch {
return false
}
}
export function isValidToolName(value?: string): boolean {

Wyświetl plik

@ -546,10 +546,6 @@ importers:
zod-validation-error:
specifier: 'catalog:'
version: 3.4.1(zod@3.25.51)
devDependencies:
hono:
specifier: 'catalog:'
version: 4.7.11
packages/emails:
dependencies:
@ -757,6 +753,9 @@ importers:
packages/validators:
dependencies:
'@agentic/platform-core':
specifier: workspace:*
version: link:../core
'@paralleldrive/cuid2':
specifier: 'catalog:'
version: 2.2.2