diff --git a/apps/api/src/api-v1/deployments/create-deployment.ts b/apps/api/src/api-v1/deployments/create-deployment.ts index 9d92c080..4dcb0acc 100644 --- a/apps/api/src/api-v1/deployments/create-deployment.ts +++ b/apps/api/src/api-v1/deployments/create-deployment.ts @@ -1,4 +1,4 @@ -import { validateAgenticProjectConfig } from '@agentic/platform' +import { resolveAgenticProjectConfig } from '@agentic/platform' import { assert, parseZodSchema, sha256 } from '@agentic/platform-core' import { validators } from '@agentic/platform-validators' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' @@ -111,9 +111,8 @@ export function registerV1DeploymentsCreateDeployment( // - origin API base URL // - origin adapter OpenAPI or MCP specs // - tool definitions - const agenticProjectConfig = await validateAgenticProjectConfig(body, { + const agenticProjectConfig = await resolveAgenticProjectConfig(body, { label: `deployment "${deploymentIdentifier}"`, - strip: true, logger }) diff --git a/apps/api/src/db/schema/deployment.ts b/apps/api/src/db/schema/deployment.ts index 80dab8cf..a46136aa 100644 --- a/apps/api/src/db/schema/deployment.ts +++ b/apps/api/src/db/schema/deployment.ts @@ -2,6 +2,7 @@ import { agenticProjectConfigSchema, type OriginAdapter, type PricingPlanList, + resolvedAgenticProjectConfigSchema, type Tool, type ToolConfig } from '@agentic/platform-schemas' @@ -149,16 +150,16 @@ export const deploymentSelectSchema = createSelectSchema(deployments, { message: 'Invalid deployment hash' }), - version: agenticProjectConfigSchema.shape.version, - description: agenticProjectConfigSchema.shape.description, - readme: agenticProjectConfigSchema.shape.readme, - iconUrl: agenticProjectConfigSchema.shape.iconUrl, - sourceUrl: agenticProjectConfigSchema.shape.sourceUrl, - originAdapter: agenticProjectConfigSchema.shape.originAdapter, - pricingPlans: agenticProjectConfigSchema.shape.pricingPlans, - pricingIntervals: agenticProjectConfigSchema.shape.pricingIntervals, - tools: agenticProjectConfigSchema.shape.toolConfigs, - toolConfigs: agenticProjectConfigSchema.shape.toolConfigs + version: resolvedAgenticProjectConfigSchema.shape.version, + description: resolvedAgenticProjectConfigSchema.shape.description, + readme: resolvedAgenticProjectConfigSchema.shape.readme, + iconUrl: resolvedAgenticProjectConfigSchema.shape.iconUrl, + sourceUrl: resolvedAgenticProjectConfigSchema.shape.sourceUrl, + originAdapter: resolvedAgenticProjectConfigSchema.shape.originAdapter, + pricingPlans: resolvedAgenticProjectConfigSchema.shape.pricingPlans, + pricingIntervals: resolvedAgenticProjectConfigSchema.shape.pricingIntervals, + tools: resolvedAgenticProjectConfigSchema.shape.tools, + toolConfigs: resolvedAgenticProjectConfigSchema.shape.toolConfigs }) .omit({ originUrl: true diff --git a/packages/cli/src/commands/debug.ts b/packages/cli/src/commands/debug.ts index 33f27697..32a10917 100644 --- a/packages/cli/src/commands/debug.ts +++ b/packages/cli/src/commands/debug.ts @@ -32,6 +32,9 @@ export function registerDebugCommand({ program, logger }: Context) { } ) + // TODO: we may want to resolve the resulting agentic config so we see + // the inferred `tools` (and `toolToOperationMap` for mcp servers) + logger.log(config) }) diff --git a/packages/platform/package.json b/packages/platform/package.json index 4243b808..5884c0ad 100644 --- a/packages/platform/package.json +++ b/packages/platform/package.json @@ -19,8 +19,7 @@ "scripts": { "test": "run-s test:*", "test:lint": "eslint .", - "test:typecheck": "tsc --noEmit", - "test:unit": "vitest run" + "test:typecheck": "tsc --noEmit" }, "dependencies": { "@agentic/platform-core": "workspace:*", diff --git a/packages/platform/src/index.ts b/packages/platform/src/index.ts index e60ab702..983a73a2 100644 --- a/packages/platform/src/index.ts +++ b/packages/platform/src/index.ts @@ -1,4 +1,3 @@ export * from './define-config' +export * from './resolve-agentic-project-config' export * from './validate-agentic-project-config' -export * from './validate-origin-adapter' -export * from './validate-tools' diff --git a/packages/platform/src/origin-adapters/mcp.ts b/packages/platform/src/origin-adapters/mcp.ts new file mode 100644 index 00000000..c3990c13 --- /dev/null +++ b/packages/platform/src/origin-adapters/mcp.ts @@ -0,0 +1,55 @@ +import type { + MCPOriginAdapter, + MCPOriginAdapterConfig, + Tool +} from '@agentic/platform-schemas' +import { assert } from '@agentic/platform-core' +import { Client as McpClient } from '@modelcontextprotocol/sdk/client/index.js' +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' + +export async function resolveMCPOriginAdapter({ + name, + version, + originUrl, + originAdapter, + label +}: { + name: string + originUrl: string + originAdapter: MCPOriginAdapterConfig + label: string + version: string +}): Promise<{ + originAdapter: MCPOriginAdapter + tools?: Tool[] +}> { + assert( + originAdapter.type === 'mcp', + 400, + `Invalid origin adapter type "${originAdapter.type}" for ${label}` + ) + const transport = new SSEClientTransport(new URL(originUrl)) + const client = new McpClient({ name, version }) + await client.connect(transport) + + const serverInfo = { + name, + version, + ...client.getServerVersion(), + capabilities: client.getServerCapabilities(), + instructions: client.getInstructions() + } + + const listToolsResponse = await client.listTools() + + // TODO: Validate MCP tools + const tools = listToolsResponse.tools + + return { + tools, + originAdapter: { + ...originAdapter, + serverInfo + } + } +} diff --git a/packages/platform/src/origin-adapters/openapi.ts b/packages/platform/src/origin-adapters/openapi.ts new file mode 100644 index 00000000..e85a9015 --- /dev/null +++ b/packages/platform/src/origin-adapters/openapi.ts @@ -0,0 +1,76 @@ +import type { + OpenAPIOriginAdapter, + OpenAPIOriginAdapterConfig, + Tool +} from '@agentic/platform-schemas' +import { assert, type Logger } from '@agentic/platform-core' +import { + getToolsFromOpenAPISpec, + validateOpenAPISpec +} from '@agentic/platform-openapi' + +export async function resolveOpenAPIOriginAdapter({ + originAdapter, + label, + cwd, + logger +}: { + originAdapter: OpenAPIOriginAdapterConfig + label: string + cwd?: URL + logger?: Logger +}): Promise<{ + originAdapter: OpenAPIOriginAdapter + tools?: Tool[] +}> { + assert( + originAdapter.type === 'openapi', + 400, + `Invalid origin adapter type "${originAdapter.type}" for ${label}` + ) + assert( + originAdapter.spec, + 400, + `OpenAPI spec is required for ${label} with origin adapter type set to "openapi"` + ) + + // Validate and normalize the OpenAPI spec + const openapiSpec = await validateOpenAPISpec(originAdapter.spec, { + cwd, + logger + }) + + // Remove origin servers from the OpenAPI spec. + // TODO: Ensure that `originUrl` matches any origin servers in the openapi spec? + delete openapiSpec.servers + + // TODO: Additional, agentic-specific validation of the OpenAPI spec's + // operations to ensure they are valid tools. + + // TODO: Simplify OpenAPI spec by removing any query params and headers + // specific to the Agentic API gateway. + + // TODO: Extract tool definitions from OpenAPI operationIds + + const dereferencedOpenAPISpec = await validateOpenAPISpec( + originAdapter.spec, + { + cwd, + dereference: true + } + ) + + const { tools, toolToOperationMap } = await getToolsFromOpenAPISpec( + dereferencedOpenAPISpec + ) + + return { + tools, + originAdapter: { + ...originAdapter, + // Update the openapi spec with the normalized version + spec: JSON.stringify(openapiSpec), + toolToOperationMap + } + } +} diff --git a/packages/platform/src/resolve-agentic-project-config.ts b/packages/platform/src/resolve-agentic-project-config.ts new file mode 100644 index 00000000..e149dcfa --- /dev/null +++ b/packages/platform/src/resolve-agentic-project-config.ts @@ -0,0 +1,49 @@ +import { type Logger, parseZodSchema } from '@agentic/platform-core' +import { + type AgenticProjectConfig, + agenticProjectConfigSchema, + type ResolvedAgenticProjectConfig, + resolvedAgenticProjectConfigSchema +} from '@agentic/platform-schemas' + +import { resolveMetadata } from './resolve-metadata' +import { resolveOriginAdapter } from './resolve-origin-adapter' +import { validatePricing } from './validate-pricing' +import { validateTools } from './validate-tools' + +export async function resolveAgenticProjectConfig( + inputConfig: AgenticProjectConfig, + opts: { logger?: Logger; cwd?: URL; label?: string } = {} +): Promise { + const config = parseZodSchema(agenticProjectConfigSchema.strip(), inputConfig) + + const { name, version } = resolveMetadata(config) + validatePricing(config) + + const { originAdapter, tools } = await resolveOriginAdapter({ + name, + version, + label: `project "${name}"`, + ...opts, + originUrl: config.originUrl, + originAdapter: config.originAdapter + }) + + const resolvedConfig = parseZodSchema(resolvedAgenticProjectConfigSchema, { + ...config, + name, + version, + originAdapter, + tools + }) + + validateTools({ + label: `project "${name}"`, + ...opts, + originAdapter: resolvedConfig.originAdapter, + tools: resolvedConfig.tools, + toolConfigs: resolvedConfig.toolConfigs || [] + }) + + return resolvedConfig +} diff --git a/packages/platform/src/resolve-metadata.ts b/packages/platform/src/resolve-metadata.ts new file mode 100644 index 00000000..dd7d2111 --- /dev/null +++ b/packages/platform/src/resolve-metadata.ts @@ -0,0 +1,35 @@ +import type { AgenticProjectConfig } from '@agentic/platform-schemas' +import { assert } from '@agentic/platform-core' +import { validators } from '@agentic/platform-validators' +import { clean as cleanSemver, valid as isValidSemver } from 'semver' + +export function resolveMetadata({ + name, + version +}: Pick): Pick< + AgenticProjectConfig, + 'name' | 'version' +> { + assert( + validators.projectName(name), + `Invalid project name "${name}". Must be lower kebab-case with no spaces between 2 and 64 characters. Example: "my-project" or "linkedin-resolver-23"` + ) + + if (version) { + const normalizedVersion = cleanSemver(version)! + assert(version, `Invalid semver version "${version}" for project "${name}"`) + + assert( + isValidSemver(version), + `Invalid semver version "${version}" for project "${name}"` + ) + + // Update the config with the normalized semver version + version = normalizedVersion + } + + return { + name, + version + } +} diff --git a/packages/platform/src/resolve-origin-adapter.ts b/packages/platform/src/resolve-origin-adapter.ts new file mode 100644 index 00000000..e4db55be --- /dev/null +++ b/packages/platform/src/resolve-origin-adapter.ts @@ -0,0 +1,63 @@ +import type { + OriginAdapter, + OriginAdapterConfig, + Tool +} from '@agentic/platform-schemas' +import { assert, type Logger } from '@agentic/platform-core' + +import { resolveMCPOriginAdapter } from './origin-adapters/mcp' +import { resolveOpenAPIOriginAdapter } from './origin-adapters/openapi' +import { validateOriginUrl } from './validate-origin-url' + +/** + * Validates, normalizes, and resolves the origin adapter config for a project. + */ +export async function resolveOriginAdapter({ + name, + version = '0.0.0', + originUrl, + originAdapter, + label, + cwd, + logger +}: { + name: string + originUrl: string + originAdapter: OriginAdapterConfig + label: string + version?: string + cwd?: URL + logger?: Logger +}): Promise<{ + originAdapter: OriginAdapter + tools?: Tool[] +}> { + validateOriginUrl({ originUrl, label }) + + if (originAdapter.type === 'openapi') { + return resolveOpenAPIOriginAdapter({ + originAdapter, + label, + cwd, + logger + }) + } else if (originAdapter.type === 'mcp') { + return resolveMCPOriginAdapter({ + name, + version, + originUrl, + originAdapter, + label + }) + } else { + assert( + originAdapter.type === 'raw', + 400, + `Invalid origin adapter type "${originAdapter.type}" for ${label}` + ) + + return { + originAdapter + } + } +} diff --git a/packages/platform/src/validate-agentic-project-config.ts b/packages/platform/src/validate-agentic-project-config.ts index ef073823..623e174b 100644 --- a/packages/platform/src/validate-agentic-project-config.ts +++ b/packages/platform/src/validate-agentic-project-config.ts @@ -1,18 +1,14 @@ import type { ZodTypeDef } from 'zod' -import { assert, type Logger, parseZodSchema } from '@agentic/platform-core' +import { type Logger, parseZodSchema } from '@agentic/platform-core' import { type AgenticProjectConfig, type AgenticProjectConfigInput, - agenticProjectConfigSchema, - getPricingPlansByInterval, - type PricingPlanLineItem, - type ResolvedAgenticProjectConfig, - resolvedAgenticProjectConfigSchema + agenticProjectConfigSchema } from '@agentic/platform-schemas' -import { validators } from '@agentic/platform-validators' -import { clean as cleanSemver, valid as isValidSemver } from 'semver' +import { resolveMetadata } from './resolve-metadata' import { validateOriginAdapter } from './validate-origin-adapter' +import { validatePricing } from './validate-pricing' export async function validateAgenticProjectConfig( inputConfig: unknown, @@ -20,7 +16,7 @@ export async function validateAgenticProjectConfig( strip = false, ...opts }: { logger?: Logger; cwd?: URL; strip?: boolean; label?: string } = {} -): Promise { +): Promise { const config = parseZodSchema< AgenticProjectConfig, ZodTypeDef, @@ -32,228 +28,31 @@ export async function validateAgenticProjectConfig( inputConfig ) - const { name, pricingIntervals, pricingPlans, originUrl } = config - assert( - validators.projectName(name), - `Invalid project name "${name}". Must be lower kebab-case with no spaces between 2 and 64 characters. Example: "my-project" or "linkedin-resolver-23"` - ) - assert( - pricingPlans?.length, - 'Invalid pricingPlans: must be a non-empty array' - ) - assert( - pricingIntervals?.length, - 'Invalid pricingIntervals: must be a non-empty array' - ) + const { name, version } = resolveMetadata(config) + validatePricing(config) - try { - const parsedOriginUrl = new URL(originUrl) - assert( - parsedOriginUrl.protocol === 'https:', - 'Invalid originUrl: must be a valid https URL' - ) - - assert(parsedOriginUrl.hostname, 'Invalid originUrl: must be a valid URL') - } catch (err) { - throw new Error('Invalid originUrl: must be a valid https URL', { - cause: err - }) - } - - if (config.version) { - const version = cleanSemver(config.version) - assert( - version, - `Invalid semver version "${config.version}" for project "${name}"` - ) - - assert( - isValidSemver(version), - `Invalid semver version "${version}" for project "${name}"` - ) - - // Update the config with the normalized semver version - config.version = version - } - - { - // Validate pricing interval - const pricingIntervalsSet = new Set(pricingIntervals) - assert( - pricingIntervalsSet.size === pricingIntervals.length, - 'Invalid pricingIntervals: duplicate pricing intervals' - ) - assert( - pricingIntervals.length >= 1, - 'Invalid pricingIntervals: must contain at least one pricing interval' - ) - - if (pricingIntervals.length > 1) { - for (const pricingPlan of pricingPlans) { - if (pricingPlan.interval) { - assert( - pricingIntervalsSet.has(pricingPlan.interval), - `Invalid pricingPlan "${pricingPlan.slug}": PricingPlan "${pricingPlan.slug}" has invalid interval "${pricingPlan.interval}" which is not included in the "pricingIntervals" array.` - ) - } - - if (pricingPlan.slug === 'free') continue - - assert( - pricingPlan.interval !== undefined, - `Invalid pricingPlan "${pricingPlan.slug}": non-free PricingPlan "${pricingPlan.slug}" must specify an "interval" because the project supports multiple pricing intervals.` - ) - } - } else { - // Only a single pricing interval is supported, so default all pricing - // plans to use the default pricing interval. - const defaultPricingInterval = pricingIntervals[0]! - assert( - defaultPricingInterval, - 'Invalid pricingIntervals: must contain at least one valid pricing interval' - ) - - for (const pricingPlan of pricingPlans) { - if (pricingPlan.interval) { - assert( - pricingIntervalsSet.has(pricingPlan.interval), - `Invalid pricingPlan "${pricingPlan.slug}": PricingPlan "${pricingPlan.slug}" has invalid interval "${pricingPlan.interval}" which is not included in the "pricingIntervals" array.` - ) - } - - if (pricingPlan.slug === 'free') continue - - pricingPlan.interval ??= defaultPricingInterval - } - } - } - - { - // Validate pricingPlans - const pricingPlanSlugsSet = new Set(pricingPlans.map((p) => p.slug)) - assert( - pricingPlanSlugsSet.size === pricingPlans.length, - 'Invalid pricingPlans: duplicate PricingPlan slugs. All PricingPlan slugs must be unique (e.g. "free", "starter-monthly", "pro-annual", etc).' - ) - - const pricingPlanLineItemSlugMap: Record = {} - - for (const pricingPlan of pricingPlans) { - const lineItemSlugsSet = new Set( - pricingPlan.lineItems.map((lineItem) => lineItem.slug) - ) - - assert( - lineItemSlugsSet.size === pricingPlan.lineItems.length, - `Invalid pricingPlan "${pricingPlan.slug}": duplicate line-item slugs` - ) - - for (const lineItem of pricingPlan.lineItems) { - if (!pricingPlanLineItemSlugMap[lineItem.slug]) { - pricingPlanLineItemSlugMap[lineItem.slug] = [] - } - - pricingPlanLineItemSlugMap[lineItem.slug]!.push(lineItem) - } - } - - for (const lineItems of Object.values(pricingPlanLineItemSlugMap)) { - if (lineItems.length <= 1) continue - - const lineItem0 = lineItems[0]! - - for (let i = 1; i < lineItems.length; ++i) { - const lineItem = lineItems[i]! - - assert( - lineItem.usageType === lineItem0.usageType, - `Invalid pricingPlans: all PricingPlans which contain the same LineItems (by slug "${lineItem.slug}") must have the same usage type ("licensed" or "metered").` - ) - } - } - } - - // Validate PricingPlanLineItems - for (const pricingPlan of pricingPlans) { - for (const lineItem of pricingPlan.lineItems) { - if (lineItem.usageType === 'metered') { - switch (lineItem.billingScheme) { - case 'per_unit': - assert( - lineItem.unitAmount !== undefined, - `Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must specify a non-negative "unitAmount" when using "per_unit" billing scheme.` - ) - - assert( - lineItem.tiersMode === undefined, - `Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must not specify "tiersMode" when using "per_unit" billing scheme.` - ) - - assert( - lineItem.tiers === undefined, - `Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must not specify "tiers" when using "per_unit" billing scheme.` - ) - break - - case 'tiered': - assert( - lineItem.unitAmount === undefined, - `Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must not specify "unitAmount" when using "tiered" billing scheme.` - ) - - assert( - lineItem.tiers?.length, - `Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must specify a non-empty "tiers" array when using "tiered" billing scheme.` - ) - - assert( - lineItem.tiersMode !== undefined, - `Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must specify a valid "tiersMode" when using "tiered" billing scheme.` - ) - - assert( - lineItem.transformQuantity === undefined, - `Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must not specify "transformQuantity" when using "tiered" billing scheme.` - ) - break - - default: - assert( - false, - `Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must specify a valid "billingScheme".` - ) - } - } - } - } - - // Validate deployment pricing plans to ensure they contain at least one valid - // plan per pricing interval configured on the project. - for (const pricingInterval of config.pricingIntervals) { - const pricingPlansForInterval = getPricingPlansByInterval({ - pricingInterval, - pricingPlans - }) - - assert( - pricingPlansForInterval.length > 0, - 400, - `Invalid pricing config: no pricing plans for pricing interval "${pricingInterval}"` - ) - } - - const { originAdapter, tools } = await validateOriginAdapter({ + const originAdapter = await validateOriginAdapter({ name, - version: config.version, + version, label: `project "${name}"`, ...opts, - originUrl, + originUrl: config.originUrl, originAdapter: config.originAdapter }) - return parseZodSchema(resolvedAgenticProjectConfigSchema, { - ...config, - originAdapter, - tools - }) + return parseZodSchema< + AgenticProjectConfig, + ZodTypeDef, + AgenticProjectConfigInput + >( + strip + ? agenticProjectConfigSchema.strip() + : agenticProjectConfigSchema.strict(), + { + ...config, + name, + version, + originAdapter + } + ) } diff --git a/packages/platform/src/validate-origin-adapter.ts b/packages/platform/src/validate-origin-adapter.ts index ffea1b86..9391b759 100644 --- a/packages/platform/src/validate-origin-adapter.ts +++ b/packages/platform/src/validate-origin-adapter.ts @@ -1,17 +1,12 @@ -import type { - OriginAdapter, - OriginAdapterConfig, - Tool -} from '@agentic/platform-schemas' +import type { OriginAdapterConfig } from '@agentic/platform-schemas' import { assert, type Logger } from '@agentic/platform-core' -import { - getToolsFromOpenAPISpec, - validateOpenAPISpec -} from '@agentic/platform-openapi' -import { Client as McpClient } from '@modelcontextprotocol/sdk/client/index.js' + +import { resolveMCPOriginAdapter } from './origin-adapters/mcp' +import { resolveOpenAPIOriginAdapter } from './origin-adapters/openapi' +import { validateOriginUrl } from './validate-origin-url' /** - * Validates and normalizes the origin adapter config for a project. + * Validates and normalizes the origin adapter for a project. */ export async function validateOriginAdapter({ name, @@ -24,93 +19,43 @@ export async function validateOriginAdapter({ }: { name: string originUrl: string - originAdapter: OriginAdapterConfig + originAdapter: Readonly label: string version?: string cwd?: URL logger?: Logger -}): Promise<{ - originAdapter: OriginAdapter - tools?: Tool[] -}> { - assert(originUrl, 400, `Origin URL is required for ${label}`) +}): Promise { + validateOriginUrl({ originUrl, label }) if (originAdapter.type === 'openapi') { - assert( - originAdapter.spec, - 400, - `OpenAPI spec is required for ${label} with origin adapter type set to "openapi"` - ) - - // Validate and normalize the OpenAPI spec - const openapiSpec = await validateOpenAPISpec(originAdapter.spec, { - cwd, - logger - }) - - // Remove origin servers from the OpenAPI spec. - // TODO: Ensure that `originUrl` matches any origin servers in the openapi spec? - delete openapiSpec.servers - - // TODO: Additional, agentic-specific validation of the OpenAPI spec's - // operations to ensure they are valid tools. - - // TODO: Simplify OpenAPI spec by removing any query params and headers - // specific to the Agentic API gateway. - - // TODO: Extract tool definitions from OpenAPI operationIds - - const dereferencedOpenAPISpec = await validateOpenAPISpec( - originAdapter.spec, - { + // We intentionally ignore the resolved tools here because the server will + // need to re-validate the OpenAPI spec and tools anyway. We do, however, + // override the `spec` field with the parsed, normalized version because + // that may have been pointing to a local file or remote URL. + const { originAdapter: resolvedOriginAdapter } = + await resolveOpenAPIOriginAdapter({ + originAdapter, + label, cwd, - dereference: true - } - ) - - const { tools, toolToOperationMap } = await getToolsFromOpenAPISpec( - dereferencedOpenAPISpec - ) + logger + }) return { - tools, - originAdapter: { - ...originAdapter, - // Update the openapi spec with the normalized version - spec: JSON.stringify(openapiSpec), - toolToOperationMap - } + ...originAdapter, + spec: resolvedOriginAdapter.spec } } else if (originAdapter.type === 'mcp') { - // TODO: Validate MCP server info and tools - - const { SSEClientTransport } = await import( - '@modelcontextprotocol/sdk/client/sse.js' - ) - const transport = new SSEClientTransport(new URL(originUrl)) - const client = new McpClient({ name, version }) - await client.connect(transport) - - const serverInfo = { + // We intentionally ignore the resolved version and tools here because the + // server will need to re-validate the MCP server info and tools anyway. + await resolveMCPOriginAdapter({ name, version, - ...client.getServerVersion(), - capabilities: client.getServerCapabilities(), - instructions: client.getInstructions() - } + originUrl, + originAdapter, + label + }) - const listToolsResponse = await client.listTools() - - // TODO: Validate MCP tools - const tools = listToolsResponse.tools - - return { - originAdapter: { - ...originAdapter, - serverInfo - }, - tools - } + return originAdapter } else { assert( originAdapter.type === 'raw', @@ -118,8 +63,6 @@ export async function validateOriginAdapter({ `Invalid origin adapter type "${originAdapter.type}" for ${label}` ) - return { - originAdapter - } + return originAdapter } } diff --git a/packages/platform/src/validate-origin-url.ts b/packages/platform/src/validate-origin-url.ts new file mode 100644 index 00000000..3fcbe708 --- /dev/null +++ b/packages/platform/src/validate-origin-url.ts @@ -0,0 +1,25 @@ +import { assert } from '@agentic/platform-core' + +export function validateOriginUrl({ + originUrl, + label +}: { + originUrl: string + label: string +}) { + assert(originUrl, 400, `Origin URL is required for ${label}`) + + try { + const parsedOriginUrl = new URL(originUrl) + assert( + parsedOriginUrl.protocol === 'https:', + 'Invalid originUrl: must be a valid https URL' + ) + + assert(parsedOriginUrl.hostname, 'Invalid originUrl: must be a valid URL') + } catch (err) { + throw new Error('Invalid originUrl: must be a valid https URL', { + cause: err + }) + } +} diff --git a/packages/platform/src/validate-pricing.ts b/packages/platform/src/validate-pricing.ts new file mode 100644 index 00000000..3449b3a3 --- /dev/null +++ b/packages/platform/src/validate-pricing.ts @@ -0,0 +1,186 @@ +import { assert } from '@agentic/platform-core' +import { + type AgenticProjectConfig, + getPricingPlansByInterval, + type PricingPlanLineItem +} from '@agentic/platform-schemas' + +export function validatePricing({ + pricingIntervals, + pricingPlans +}: Pick) { + assert( + pricingPlans?.length, + 'Invalid pricingPlans: must be a non-empty array' + ) + assert( + pricingIntervals?.length, + 'Invalid pricingIntervals: must be a non-empty array' + ) + + { + // Validate pricing interval + const pricingIntervalsSet = new Set(pricingIntervals) + assert( + pricingIntervalsSet.size === pricingIntervals.length, + 'Invalid pricingIntervals: duplicate pricing intervals' + ) + assert( + pricingIntervals.length >= 1, + 'Invalid pricingIntervals: must contain at least one pricing interval' + ) + + if (pricingIntervals.length > 1) { + for (const pricingPlan of pricingPlans) { + if (pricingPlan.interval) { + assert( + pricingIntervalsSet.has(pricingPlan.interval), + `Invalid pricingPlan "${pricingPlan.slug}": PricingPlan "${pricingPlan.slug}" has invalid interval "${pricingPlan.interval}" which is not included in the "pricingIntervals" array.` + ) + } + + if (pricingPlan.slug === 'free') continue + + assert( + pricingPlan.interval !== undefined, + `Invalid pricingPlan "${pricingPlan.slug}": non-free PricingPlan "${pricingPlan.slug}" must specify an "interval" because the project supports multiple pricing intervals.` + ) + } + } else { + // Only a single pricing interval is supported, so default all pricing + // plans to use the default pricing interval. + const defaultPricingInterval = pricingIntervals[0]! + assert( + defaultPricingInterval, + 'Invalid pricingIntervals: must contain at least one valid pricing interval' + ) + + for (const pricingPlan of pricingPlans) { + if (pricingPlan.interval) { + assert( + pricingIntervalsSet.has(pricingPlan.interval), + `Invalid pricingPlan "${pricingPlan.slug}": PricingPlan "${pricingPlan.slug}" has invalid interval "${pricingPlan.interval}" which is not included in the "pricingIntervals" array.` + ) + } + + if (pricingPlan.slug === 'free') continue + + pricingPlan.interval ??= defaultPricingInterval + } + } + } + + { + // Validate pricingPlans + const pricingPlanSlugsSet = new Set(pricingPlans.map((p) => p.slug)) + assert( + pricingPlanSlugsSet.size === pricingPlans.length, + 'Invalid pricingPlans: duplicate PricingPlan slugs. All PricingPlan slugs must be unique (e.g. "free", "starter-monthly", "pro-annual", etc).' + ) + + const pricingPlanLineItemSlugMap: Record = {} + + for (const pricingPlan of pricingPlans) { + const lineItemSlugsSet = new Set( + pricingPlan.lineItems.map((lineItem) => lineItem.slug) + ) + + assert( + lineItemSlugsSet.size === pricingPlan.lineItems.length, + `Invalid pricingPlan "${pricingPlan.slug}": duplicate line-item slugs` + ) + + for (const lineItem of pricingPlan.lineItems) { + if (!pricingPlanLineItemSlugMap[lineItem.slug]) { + pricingPlanLineItemSlugMap[lineItem.slug] = [] + } + + pricingPlanLineItemSlugMap[lineItem.slug]!.push(lineItem) + } + } + + for (const lineItems of Object.values(pricingPlanLineItemSlugMap)) { + if (lineItems.length <= 1) continue + + const lineItem0 = lineItems[0]! + + for (let i = 1; i < lineItems.length; ++i) { + const lineItem = lineItems[i]! + + assert( + lineItem.usageType === lineItem0.usageType, + `Invalid pricingPlans: all PricingPlans which contain the same LineItems (by slug "${lineItem.slug}") must have the same usage type ("licensed" or "metered").` + ) + } + } + } + + // Validate PricingPlanLineItems + for (const pricingPlan of pricingPlans) { + for (const lineItem of pricingPlan.lineItems) { + if (lineItem.usageType === 'metered') { + switch (lineItem.billingScheme) { + case 'per_unit': + assert( + lineItem.unitAmount !== undefined, + `Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must specify a non-negative "unitAmount" when using "per_unit" billing scheme.` + ) + + assert( + lineItem.tiersMode === undefined, + `Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must not specify "tiersMode" when using "per_unit" billing scheme.` + ) + + assert( + lineItem.tiers === undefined, + `Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must not specify "tiers" when using "per_unit" billing scheme.` + ) + break + + case 'tiered': + assert( + lineItem.unitAmount === undefined, + `Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must not specify "unitAmount" when using "tiered" billing scheme.` + ) + + assert( + lineItem.tiers?.length, + `Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must specify a non-empty "tiers" array when using "tiered" billing scheme.` + ) + + assert( + lineItem.tiersMode !== undefined, + `Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must specify a valid "tiersMode" when using "tiered" billing scheme.` + ) + + assert( + lineItem.transformQuantity === undefined, + `Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must not specify "transformQuantity" when using "tiered" billing scheme.` + ) + break + + default: + assert( + false, + `Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must specify a valid "billingScheme".` + ) + } + } + } + } + + // Validate deployment pricing plans to ensure they contain at least one valid + // plan per pricing interval configured on the project. + for (const pricingInterval of pricingIntervals) { + const pricingPlansForInterval = getPricingPlansByInterval({ + pricingInterval, + pricingPlans + }) + + assert( + pricingPlansForInterval.length > 0, + 400, + `Invalid pricing config: no pricing plans for pricing interval "${pricingInterval}"` + ) + } +} diff --git a/packages/platform/src/validate-tools.ts b/packages/platform/src/validate-tools.ts index a99e8987..3ed3c120 100644 --- a/packages/platform/src/validate-tools.ts +++ b/packages/platform/src/validate-tools.ts @@ -2,9 +2,9 @@ import type { OriginAdapter, Tool, ToolConfig } from '@agentic/platform-schemas' import { assert } from '@agentic/platform-core' /** - * Validates and normalizes the origin adapter config for a project. + * Validates the origin server's tools for a project. */ -export async function validateTools({ +export function validateTools({ originAdapter, tools, toolConfigs, @@ -14,8 +14,13 @@ export async function validateTools({ tools: Tool[] toolConfigs: ToolConfig[] label: string -}): Promise { - assert(tools.length > 0, 400, `No tools defined for ${label}`) +}) { + if (!tools.length) { + assert( + originAdapter.type === 'raw', + `No tools defined for ${label} with origin adapter type "${originAdapter.type}"` + ) + } const toolsMap: Record = {} for (const tool of tools) { @@ -35,8 +40,4 @@ export async function validateTools({ `Tool "${toolConfig.name}" from \`toolConfigs\` not found in \`tools\` for ${label}` ) } - - if (originAdapter.type === 'openapi') { - // TODO - } } diff --git a/packages/schemas/src/origin-adapter.ts b/packages/schemas/src/origin-adapter.ts index 83386dbc..81096d4f 100644 --- a/packages/schemas/src/origin-adapter.ts +++ b/packages/schemas/src/origin-adapter.ts @@ -54,6 +54,9 @@ export const openapiOriginAdapterConfigSchema = commonOriginAdapterSchema.merge( ) }) ) +export type OpenAPIOriginAdapterConfig = z.infer< + typeof openapiOriginAdapterConfigSchema +> export const mcpOriginAdapterConfigSchema = commonOriginAdapterSchema.merge( z.object({ @@ -63,6 +66,9 @@ export const mcpOriginAdapterConfigSchema = commonOriginAdapterSchema.merge( type: z.literal('mcp') }) ) +export type MCPOriginAdapterConfig = z.infer< + typeof mcpOriginAdapterConfigSchema +> export const rawOriginAdapterConfigSchema = commonOriginAdapterSchema.merge( z.object({ @@ -76,6 +82,9 @@ export const rawOriginAdapterConfigSchema = commonOriginAdapterSchema.merge( type: z.literal('raw') }) ) +export type RawOriginAdapterConfig = z.infer< + typeof rawOriginAdapterConfigSchema +> /** * Origin adapter is used to configure the origin API server downstream from @@ -176,6 +185,7 @@ export const openapiOriginAdapterSchema = commonOriginAdapterSchema.merge( ) }) ) +export type OpenAPIOriginAdapter = z.infer export const mcpOriginAdapterSchema = commonOriginAdapterSchema.merge( z.object({ @@ -190,6 +200,7 @@ export const mcpOriginAdapterSchema = commonOriginAdapterSchema.merge( serverInfo: mcpServerInfoSchema }) ) +export type MCPOriginAdapter = z.infer export const rawOriginAdapterSchema = commonOriginAdapterSchema.merge( z.object({ @@ -203,6 +214,7 @@ export const rawOriginAdapterSchema = commonOriginAdapterSchema.merge( type: z.literal('raw') }) ) +export type RawOriginAdapter = z.infer /** * Origin adapter is used to configure the origin API server downstream from diff --git a/readme.md b/readme.md index 670fcb55..b629592a 100644 --- a/readme.md +++ b/readme.md @@ -30,6 +30,7 @@ - consider switching to [consola](https://github.com/unjs/consola) for logging? - consider switching to `bun` (for `--hot` reloading!!) - consider `projectName` and `projectSlug` or `projectIdentifier`? +- not sure I like the duplication between client and server AgenticProjectConfig and ResolvedAgenticProjectConfig, especially for openapi and mcp processing ## License