Skip to content

feat: service discovery tooling (OpenAPI + x-payment-info + x-service-info)#178

Closed
brendanjryan wants to merge 7 commits intomainfrom
feat/discovery-tooling
Closed

feat: service discovery tooling (OpenAPI + x-payment-info + x-service-info)#178
brendanjryan wants to merge 7 commits intomainfrom
feat/discovery-tooling

Conversation

@brendanjryan
Copy link
Collaborator

@brendanjryan brendanjryan commented Mar 12, 2026

Summary

Implements mppx discovery per draft-payment-discovery-00 (mpp-specs #166).

Services publish an OpenAPI 3.x doc at /openapi.json annotated with two extensions:

  • x-service-info (top-level): service categories and documentation links
  • x-payment-info (per-operation): payment intent, method, amount, and currency

Example interfaces

Hono (auto-introspection):

import { Hono } from "hono"
import { Mppx, discovery } from "mppx/hono"

const app = new Hono()
const mppx = Mppx.create({ methods: [tempo()] })

app.get("/premium", mppx.charge({ amount: "1" }), (c) => c.json({ data: "paid" }))

discovery(app, mppx, { auto: true, serviceInfo: { categories: ["ai"] } })

Express:

import express from "express"
import { Mppx, discovery } from "mppx/express"

const app = express()
const mppx = Mppx.create({ methods: [tempo()] })

discovery(app, mppx, {
  routes: [{ path: "/premium", method: "get", intent: "charge", options: { amount: "1" } }],
})

Next.js:

// app/openapi.json/route.ts
import { Mppx, discovery } from "mppx/nextjs"

const mppx = Mppx.create({ methods: [tempo()] })

export const GET = discovery(mppx, {
  routes: [{ path: "/api/premium", method: "get", intent: "charge", options: { amount: "1" } }],
})

CLI:

mppx discover validate https://example.com/openapi.json
mppx discover validate ./local-openapi.json

Programmatic:

import { generate, validate } from "mppx/discovery"

// Generate
const doc = generate(mppx, { serviceInfo: { categories: ["ai"] }, routes: [...] })

// Validate
const errors = validate(doc)
errors.forEach(e => console.log(`[${e.severity}] ${e.path}: ${e.message}`))

Allow compose() to accept [mppx.tempo.charge, { amount: '1' }] tuples
in addition to Method.AnyServer objects and string keys.

Each MethodFn is tagged with a _method property pointing back to its
source Method.AnyServer during create(). compose() checks for _method
to resolve the handler lookup key.

- Tag MethodFn with _method metadata in nested handler setup
- Widen ComposeEntry type with third union branch for MethodFn refs
- Add runtime branch in composeFn for _method resolution
- Add tests for handler ref syntax and mixed usage
…-info)

Implements mppx discovery per draft-payment-discovery-00 (mpp-specs #166).

Phase 1 — Schema + Validator + CLI:
- Zod schemas for PaymentInfo, ServiceInfo, DiscoveryDocument
- validate() with structural parsing + semantic checks
- 'discover validate <url-or-file>' CLI subcommand
- 26 tests (Discovery + Validate)

Phase 2 — OpenAPI Generator:
- generate(mppx, config) builds OpenAPI 3.1.0 doc from mppx.methods
- Annotates routes with x-service-info and x-payment-info extensions
- 6 tests

Phase 3 — Framework Plugins:
- Hono: discovery(app, mppx, config) mounts GET /openapi.json
- Express: discovery(app, mppx, config) mounts GET /openapi.json
- Next.js: discovery(mppx, config) returns a route handler

Phase 4 — Auto-introspection (Hono):
- discovery(app, mppx, { auto: true }) walks app.routes and matches
  handlers with _internal metadata to build RouteConfig[] automatically

Infrastructure:
- ./discovery export in package.json
- mppx/discovery alias in vitest.config.ts
- src/discovery/index.ts re-exports
@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 12, 2026

Open in StackBlitz

npm i https://pkg.pr.new/wevm/mppx@178

commit: c1ad287

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Auto-introspection never finds _internal on middleware handlers
    • Eagerly call intent(options) at registration time and forward _internal metadata to the MiddlewareHandler via Object.assign, so introspectRoutes() can find it on route.handler.
  • ✅ Fixed: Path-item schema rejects valid OpenAPI non-operation keys
    • Wrapped the operation object schema in z.union with z.unknown() fallback so non-operation path item values (like parameters arrays) pass validation, and added a type guard in Validate.ts to skip non-object entries.

Create PR

Or push these changes by commenting:

@cursor push 77278ca20a
Preview (77278ca20a)
diff --git a/src/discovery/Discovery.ts b/src/discovery/Discovery.ts
--- a/src/discovery/Discovery.ts
+++ b/src/discovery/Discovery.ts
@@ -51,12 +51,15 @@
       z.string(),
       z.record(
         z.string(),
-        z.object({
-          'x-payment-info': z.optional(PaymentInfo),
-          responses: z.optional(z.record(z.string(), z.unknown())),
-          requestBody: z.optional(z.unknown()),
-          summary: z.optional(z.string()),
-        }),
+        z.union([
+          z.object({
+            'x-payment-info': z.optional(PaymentInfo),
+            responses: z.optional(z.record(z.string(), z.unknown())),
+            requestBody: z.optional(z.unknown()),
+            summary: z.optional(z.string()),
+          }),
+          z.unknown(),
+        ]),
       ),
     ),
   ),

diff --git a/src/discovery/OpenApi.test.ts b/src/discovery/OpenApi.test.ts
--- a/src/discovery/OpenApi.test.ts
+++ b/src/discovery/OpenApi.test.ts
@@ -1,7 +1,7 @@
 import { describe, expect, test } from 'vitest'
-import * as z from '../zod.js'
 import * as Method from '../Method.js'
 import * as Mppx from '../server/Mppx.js'
+import * as z from '../zod.js'
 import { generate } from './OpenApi.js'
 
 const charge = Method.toServer(
@@ -17,7 +17,14 @@
       }),
     },
   }),
-  { verify: async () => ({ status: 'success' as const, method: 'tempo', timestamp: '', reference: '' }) },
+  {
+    verify: async () => ({
+      status: 'success' as const,
+      method: 'tempo',
+      timestamp: '',
+      reference: '',
+    }),
+  },
 )
 
 const session = Method.toServer(
@@ -32,7 +39,14 @@
       }),
     },
   }),
-  { verify: async () => ({ status: 'success' as const, method: 'tempo', timestamp: '', reference: '' }) },
+  {
+    verify: async () => ({
+      status: 'success' as const,
+      method: 'tempo',
+      timestamp: '',
+      reference: '',
+    }),
+  },
 )
 
 function createMppx(methods: Mppx.Methods) {

diff --git a/src/discovery/Validate.ts b/src/discovery/Validate.ts
--- a/src/discovery/Validate.ts
+++ b/src/discovery/Validate.ts
@@ -36,9 +36,12 @@
 
   for (const [pathKey, methods] of Object.entries(paths)) {
     for (const [method, operation] of Object.entries(methods)) {
+      if (typeof operation !== 'object' || operation === null || Array.isArray(operation)) continue
+
       const opPath = `paths.${pathKey}.${method}`
+      const op = operation as Record<string, unknown>
 
-      const rawPaymentInfo = (operation as Record<string, unknown>)['x-payment-info']
+      const rawPaymentInfo = op['x-payment-info']
       if (!rawPaymentInfo) continue
 
       const paymentResult = PaymentInfo.safeParse(rawPaymentInfo)
@@ -53,7 +56,7 @@
         continue
       }
 
-      const responses = operation.responses as Record<string, unknown> | undefined
+      const responses = op.responses as Record<string, unknown> | undefined
       if (!responses || !('402' in responses)) {
         errors.push({
           path: `${opPath}.responses`,
@@ -62,7 +65,7 @@
         })
       }
 
-      if (!operation.requestBody) {
+      if (!op.requestBody) {
         errors.push({
           path: opPath,
           message: 'Operation with x-payment-info has no requestBody',

diff --git a/src/discovery/index.ts b/src/discovery/index.ts
--- a/src/discovery/index.ts
+++ b/src/discovery/index.ts
@@ -1,10 +1,10 @@
 export {
   DiscoveryDocument,
+  type DiscoveryDocument as DiscoveryDocumentType,
   PaymentInfo,
+  type PaymentInfo as PaymentInfoType,
   ServiceInfo,
-  type DiscoveryDocument as DiscoveryDocumentType,
-  type PaymentInfo as PaymentInfoType,
   type ServiceInfo as ServiceInfoType,
 } from './Discovery.js'
+export { type GenerateConfig, generate, type RouteConfig } from './OpenApi.js'
 export { type ValidationError, validate } from './Validate.js'
-export { type GenerateConfig, type RouteConfig, generate } from './OpenApi.js'

diff --git a/src/middlewares/hono.ts b/src/middlewares/hono.ts
--- a/src/middlewares/hono.ts
+++ b/src/middlewares/hono.ts
@@ -54,12 +54,14 @@
   intent: intent,
   options: intent extends (options: infer options) => any ? options : never,
 ): MiddlewareHandler {
-  return async (c, next) => {
-    const result = await intent(options)(c.req.raw)
+  const configured = intent(options)
+  const handler: MiddlewareHandler = async (c, next) => {
+    const result = await configured(c.req.raw)
     if (result.status === 402) return result.challenge
     await next()
     c.res = result.withReceipt(c.res)
   }
+  return Object.assign(handler, { _internal: (configured as any)._internal })
 }
 
 export type DiscoveryConfig = Omit<GenerateConfig, 'routes'> & {
@@ -117,7 +119,10 @@
  * Walks Hono's `app.routes` and matches them to mppx handlers,
  * building `RouteConfig[]` automatically.
  */
-function introspectRoutes(app: Hono<any>, _mppx: { methods: readonly Mppx_internal.AnyServer[]; realm: string }): RouteConfig[] {
+function introspectRoutes(
+  app: Hono<any>,
+  _mppx: { methods: readonly Mppx_internal.AnyServer[]; realm: string },
+): RouteConfig[] {
   const routes: RouteConfig[] = []
   const appRoutes = (app as any).routes as
     | { method: string; path: string; handler: any }[]

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

}

return routes
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto-introspection never finds _internal on middleware handlers

High Severity

introspectRoutes() looks for route.handler._internal on Hono route handlers, but the payment() wrapper returns a plain MiddlewareHandler (async (c, next) => { ... }) that never carries _internal. The _internal metadata is only attached to the configured handler created inside createMethodFn (via Object.assign), which is called lazily at request time inside the middleware — not at route registration time. This means auto: true discovery will always produce an empty routes array and an empty OpenAPI document.

Additional Locations (1)
Fix in Cursor Fix in Web

}),
),
),
),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Path-item schema rejects valid OpenAPI non-operation keys

Medium Severity

The DiscoveryDocument schema models each path item as z.record(z.string(), z.object({...})), requiring every value to match the operation object shape. Real OpenAPI path items can contain non-operation keys like parameters (an array), summary, description, or servers. These values would fail the operation object schema, causing validate() to reject valid OpenAPI documents — particularly problematic for the discover validate CLI command that targets external documents.

Fix in Cursor Fix in Web

const result = DiscoveryDocument.safeParse(doc)
if (!result.success) {
for (const issue of result.error.issues) {
errors.push({
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this check the validity of the openapi spec in general too?

- PaymentInfo.intent now z.string() to match core Method.intent
- generate() throws on unknown intent instead of silent skip
- Validate requestBody warning only for POST/PUT/PATCH (not GET)
- Hono auto-introspection: propagate _internal metadata through wrap()
- RouteConfig.method widened to string
- GenerateConfig.info allows overriding title/version
- Updated tests for new behavior
type ServiceInfo as ServiceInfoType,
} from './Discovery.js'
export { type GenerateConfig, generate, type RouteConfig } from './OpenApi.js'
export { type ValidationError, validate } from './Validate.js'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be good to follow module-first convention to keep consistent with rest of library:

export * as Discovery from './Discovery.js'
export * as OpenApi from './OpenApi.js'

and consume as:

import { OpenApi } from 'mppx/discovery'

const doc = OpenApi.generate(...)
const valid = OpenApi.validate(doc)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants