diff --git a/docs/config/aixyz-config.mdx b/docs/config/aixyz-config.mdx index 7d0e227..f50d7a4 100644 --- a/docs/config/aixyz-config.mdx +++ b/docs/config/aixyz-config.mdx @@ -26,8 +26,11 @@ const config: AixyzConfig = { description: "Get current weather conditions for any city", tags: ["weather"], examples: ["What's the weather in Tokyo?"], + oasf: { name: "weather/forecast", id: 101 }, }, ], + domains: [{ name: "technology/ai", id: 1 }], + services: [{ type: "openapi", url: "https://my-agent.vercel.app/openapi.json" }], }; export default config; @@ -35,28 +38,45 @@ export default config; ## Config Fields -| Field | Type | Required | Description | -| -------------- | -------------- | -------- | ---------------------------------------- | -| `name` | `string` | Yes | Agent display name | -| `description` | `string` | Yes | What the agent does | -| `version` | `string` | Yes | Semver version | -| `url` | `string` | No | Agent base URL (auto-detected on Vercel) | -| `x402` | `object` | Yes | Payment configuration | -| `x402.payTo` | `string` | Yes | EVM address to receive payments | -| `x402.network` | `string` | Yes | CAIP-2 chain ID (e.g., `eip155:8453`) | -| `skills` | `AgentSkill[]` | Yes | Skills exposed in the A2A agent card | +| Field | Type | Required | Description | +| -------------- | -------------- | -------- | -------------------------------------------- | +| `name` | `string` | Yes | Agent display name | +| `description` | `string` | Yes | What the agent does | +| `version` | `string` | Yes | Semver version | +| `url` | `string` | No | Agent base URL (auto-detected on Vercel) | +| `x402` | `object` | Yes | Payment configuration | +| `x402.payTo` | `string` | Yes | EVM address to receive payments | +| `x402.network` | `string` | Yes | CAIP-2 chain ID (e.g., `eip155:8453`) | +| `skills` | `AgentSkill[]` | Yes | Skills exposed in the A2A agent card | +| `domains` | `Domain[]` | No | OASF domain categories (defaults to `[]`) | +| `services` | `Service[]` | No | External service locators (defaults to `[]`) | ### `AgentSkill` -| Field | Type | Required | Description | -| ------------- | ---------- | -------- | ----------------------- | -| `id` | `string` | Yes | Unique skill identifier | -| `name` | `string` | Yes | Skill display name | -| `description` | `string` | Yes | What the skill does | -| `tags` | `string[]` | Yes | Categorization tags | -| `examples` | `string[]` | No | Example prompts | -| `inputModes` | `string[]` | No | Input MIME types | -| `outputModes` | `string[]` | No | Output MIME types | +| Field | Type | Required | Description | +| ------------- | ---------- | -------- | -------------------------------------------------------------------- | +| `id` | `string` | Yes | Unique skill identifier | +| `name` | `string` | Yes | Skill display name | +| `description` | `string` | Yes | What the skill does | +| `tags` | `string[]` | Yes | Categorization tags | +| `examples` | `string[]` | No | Example prompts | +| `inputModes` | `string[]` | No | Input MIME types | +| `outputModes` | `string[]` | No | Output MIME types | +| `oasf` | `object` | No | OASF catalog metadata (`{ name, id }`) — see [OASF](/protocols/oasf) | + +### `Domain` + +| Field | Type | Required | Description | +| ------ | -------- | -------- | ---------------------------------------------------------------------------------------------------- | +| `name` | `string` | Yes | Domain name from the [OASF domain catalog](https://schema.oasf.outshift.com/1.0.0/domain_categories) | +| `id` | `number` | Yes | Domain ID from the OASF domain catalog | + +### `Service` + +| Field | Type | Required | Description | +| ------ | -------- | -------- | -------------------------------------------- | +| `type` | `string` | Yes | Service type (e.g. `"openapi"`, `"graphql"`) | +| `url` | `string` | Yes | Service endpoint URL | ## URL Resolution diff --git a/docs/docs.json b/docs/docs.json index a2eabac..b7cd20d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -51,7 +51,7 @@ }, { "group": "Protocols", - "pages": ["protocols/a2a", "protocols/mcp", "protocols/x402", "protocols/erc-8004"] + "pages": ["protocols/a2a", "protocols/mcp", "protocols/x402", "protocols/erc-8004", "protocols/oasf"] } ] }, diff --git a/docs/protocols/erc-8004.mdx b/docs/protocols/erc-8004.mdx index ba6cdc3..fa8040e 100644 --- a/docs/protocols/erc-8004.mdx +++ b/docs/protocols/erc-8004.mdx @@ -114,6 +114,7 @@ ERC-8004 identity complements the other protocols: - **A2A** — The agent card can reference an on-chain identity for verification - **x402** — Payment gating tied to verified on-chain agent identities - **MCP** — Tool discovery backed by verifiable identity +- **OASF** — When both plugins are active, the registration file automatically includes an OASF service entry @@ -122,4 +123,7 @@ ERC-8004 identity complements the other protocols: Payment gating for agent endpoints. + + Standardized agent discovery with OASF. + diff --git a/docs/protocols/oasf.mdx b/docs/protocols/oasf.mdx new file mode 100644 index 0000000..ddad34b --- /dev/null +++ b/docs/protocols/oasf.mdx @@ -0,0 +1,160 @@ +--- +title: "OASF" +description: "Open Agent Service Framework for standardized agent discovery" +--- + +## Overview + +[OASF (Open Agent Service Framework)](https://schema.oasf.outshift.com/1.0.0/) is a standardized format for describing AI agents, their capabilities, and integrations. It enables agent discovery across the Open Agent Service ecosystem. + +aixyz automatically generates an OASF record from your `aixyz.config.ts` and registered plugins. The `OASFPlugin` is always included in the build pipeline — no setup required. + +## Endpoint + +| Endpoint | Method | Description | +| ------------------- | ------ | ------------------------------------------------ | +| `/_aixyz/oasf.json` | GET | OASF record with metadata, modules, and locators | + +```bash +curl http://localhost:3000/_aixyz/oasf.json +``` + +Example response: + +```json +{ + "name": "Weather Agent", + "description": "Get current weather for any location worldwide.", + "version": "0.1.0", + "schema_version": "1.0.0", + "authors": [], + "created_at": "2026-03-23T12:00:00.000Z", + "domains": [{ "name": "technology/ai", "id": 1 }], + "skills": [{ "name": "weather/forecast", "id": 101 }], + "modules": [ + { + "name": "integration/a2a", + "id": 203, + "data": { + "card_data": { "name": "Weather Agent", "...": "..." }, + "card_schema_version": "0.3.0" + } + }, + { + "name": "integration/mcp", + "id": 202, + "data": { + "name": "Weather Agent", + "connections": [{ "type": "streamable-http", "url": "https://my-agent.vercel.app/mcp" }], + "tools": [{ "name": "getWeather", "description": "Get weather for a city" }] + } + } + ], + "locators": [ + { "type": "a2a", "urls": ["https://my-agent.vercel.app/.well-known/agent-card.json"] }, + { "type": "mcp", "urls": ["https://my-agent.vercel.app/mcp"] } + ] +} +``` + +## Auto-Detection + +The OASF record is assembled automatically from your registered plugins and config: + +**Modules** — detected from registered plugins: + +| Module | ID | Detected When | Data Included | +| ----------------- | --- | --------------------------- | -------------------------------------------- | +| `integration/a2a` | 203 | A2A agent card route exists | Full agent card data, schema version `0.3.0` | +| `integration/mcp` | 202 | MCP plugin registered | Connection info, registered tools | + +**Locators** — service endpoints for agent discovery: + +| Source | Type | URLs | +| --------------- | --------- | ----------------------------------------- | +| A2A plugin | `a2a` | All `/.well-known/agent-card.json` routes | +| MCP plugin | `mcp` | `/mcp` endpoint | +| Config services | Per entry | From `services` array in config | + +## Configuration + +OASF-specific fields in `aixyz.config.ts`: + +```typescript title="aixyz.config.ts" +import type { AixyzConfig } from "aixyz/config"; + +const config: AixyzConfig = { + name: "Weather Agent", + description: "Get current weather for any location worldwide.", + version: "0.1.0", + url: "https://my-agent.vercel.app", + x402: { payTo: "0x...", network: "eip155:8453" }, + skills: [ + { + id: "get-weather", + name: "Get Weather", + description: "Get current weather conditions for any city", + tags: ["weather"], + examples: ["What's the weather in Tokyo?"], + // OASF catalog metadata — only skills with this field appear in the OASF record + oasf: { name: "weather/forecast", id: 101 }, + }, + ], + // OASF domain categories + domains: [{ name: "technology/ai", id: 1 }], + // Additional service locators (e.g. OpenAPI, GraphQL) + services: [{ type: "openapi", url: "https://my-agent.vercel.app/openapi.json" }], +}; + +export default config; +``` + +### `domains` + +OASF domain categories describing the agent's area of expertise. Each entry has a `name` and `id` from the [OASF domain catalog](https://schema.oasf.outshift.com/1.0.0/domain_categories). Defaults to `[]`. + +### `services` + +External service endpoints to advertise. Each entry becomes an OASF locator (and optionally an ERC-8004 service). Defaults to `[]`. + +### `skills[].oasf` + +Optional OASF catalog metadata on each skill. Skills without this field are excluded from the OASF record. The `name` and `id` come from the [OASF skill catalog](https://schema.oasf.outshift.com/1.0.0/skill_categories). + +## Using the OASF Plugin + +The `OASFPlugin` is auto-registered in all generated servers. For custom servers: + +```typescript title="app/server.ts" +import { OASFPlugin } from "aixyz/app/plugins/oasf"; + +await server.withPlugin(new OASFPlugin()); +``` + +The plugin uses a two-phase lifecycle: + +1. **Register** — creates the `GET /_aixyz/oasf.json` route +2. **Initialize** — builds and caches the OASF record after all other plugins are registered, ensuring A2A and MCP integrations are detected + +## Integration with ERC-8004 + +When both OASF and ERC-8004 plugins are active, the ERC-8004 agent registration file automatically includes an OASF service entry: + +```json +{ + "name": "OASF", + "endpoint": "https://my-agent.vercel.app/_aixyz/oasf.json", + "version": "1.0.0" +} +``` + +This links on-chain agent identity to the OASF discovery endpoint. + + + + Agent discovery with A2A agent cards. + + + On-chain agent identity standard. + + diff --git a/packages/aixyz-cli/build/AixyzServerPlugin.ts b/packages/aixyz-cli/build/AixyzServerPlugin.ts index 381bb5c..84d2978 100644 --- a/packages/aixyz-cli/build/AixyzServerPlugin.ts +++ b/packages/aixyz-cli/build/AixyzServerPlugin.ts @@ -169,6 +169,9 @@ function generateServer(appDir: string, entrypointDir: string): string { imports.push(`import * as erc8004 from "${importPrefix}/erc-8004";`); } + // Always register OASF endpoint (derives from aixyz.config.ts) + imports.push('import { OASFPlugin } from "aixyz/app/plugins/oasf";'); + body.push("const app = new AixyzApp({ facilitators: facilitator });"); body.push("await app.withPlugin(new IndexPagePlugin());"); @@ -189,14 +192,11 @@ function generateServer(appDir: string, entrypointDir: string): string { } if (hasErc8004) { - const a2aPaths: string[] = []; - if (rootAgent) a2aPaths.push("/.well-known/agent-card.json"); - for (const subAgent of subAgents) a2aPaths.push(`/${subAgent.name}/.well-known/agent-card.json`); - body.push( - `await app.withPlugin(new ERC8004Plugin({ default: erc8004.default, options: { mcp: ${tools.length > 0}, a2a: ${JSON.stringify(a2aPaths)} } }));`, - ); + body.push(`await app.withPlugin(new ERC8004Plugin({ default: erc8004.default }));`); } + body.push(`await app.withPlugin(new OASFPlugin());`); + body.push("await app.initialize();"); body.push("export default app;"); diff --git a/packages/aixyz-config/index.ts b/packages/aixyz-config/index.ts index be55407..6ed1e47 100644 --- a/packages/aixyz-config/index.ts +++ b/packages/aixyz-config/index.ts @@ -76,6 +76,17 @@ export type AixyzConfig = { maxDuration?: number; }; skills?: InferredAixyzConfig["skills"]; + /** + * OASF domain categories for the agent. + * Uses the OASF domain catalog identifiers (e.g. `"finance_and_business"`, `"technology/blockchain"`). + * @see https://schema.oasf.outshift.com/1.0.0/domain_categories + */ + domains?: InferredAixyzConfig["domains"]; + /** + * External services to advertise (e.g. OpenAPI, GraphQL). + * Each entry becomes an OASF locator and optionally an ERC-8004 service. + */ + services?: InferredAixyzConfig["services"]; }; const NetworkSchema = z.custom((val) => { @@ -91,6 +102,8 @@ const defaultConfig = { }, vercel: { maxDuration: 60 }, skills: [], + domains: [], + services: [], }; const AixyzConfigSchema = z.object({ @@ -155,9 +168,31 @@ const AixyzConfigSchema = z.object({ inputModes: z.array(z.string()).optional(), outputModes: z.array(z.string()).optional(), security: z.array(z.record(z.string(), z.array(z.string()))).optional(), + oasf: z + .object({ + name: z.string().nonempty(), + id: z.number().int().positive(), + }) + .optional(), }), ) .default(defaultConfig.skills), + domains: z + .array( + z.object({ + name: z.string().nonempty(), + id: z.number().int().positive(), + }), + ) + .default(defaultConfig.domains), + services: z + .array( + z.object({ + type: z.string().nonempty(), + url: z.string().url(), + }), + ) + .default(defaultConfig.services), }); type InferredAixyzConfig = z.infer; @@ -175,6 +210,8 @@ export type AixyzConfigRuntime = { version: AixyzConfig["version"]; url: AixyzConfig["url"]; skills: NonNullable; + domains: NonNullable; + services: NonNullable; }; /** @@ -225,5 +262,7 @@ export function getAixyzConfigRuntime(): AixyzConfigRuntime { version: config.version, url: config.url, skills: config.skills, + domains: config.domains, + services: config.services, }; } diff --git a/packages/aixyz/app/plugins/erc-8004.test.ts b/packages/aixyz/app/plugins/erc-8004.test.ts index b15e701..05cb707 100644 --- a/packages/aixyz/app/plugins/erc-8004.test.ts +++ b/packages/aixyz/app/plugins/erc-8004.test.ts @@ -10,6 +10,7 @@ mock.module("@aixyz/config", () => ({ build: { tools: [], agents: [], excludes: [], poweredByHeader: true }, vercel: { maxDuration: 30 }, skills: [], + services: [], }), getAixyzConfigRuntime: () => ({ name: "Test Agent", @@ -17,10 +18,12 @@ mock.module("@aixyz/config", () => ({ version: "1.0.0", url: "http://localhost:3000", skills: [], + services: [], }), })); import { AixyzApp } from "../index"; +import { BasePlugin } from "../plugin"; import { ERC8004Plugin, getAgentRegistrationFile } from "./erc-8004"; import { @@ -38,9 +41,47 @@ async function fetchJson(app: AixyzApp, path: string) { return { res, json: await res.json() }; } -function createApp(data: unknown = {}, options = { mcp: true, a2a: ["/.well-known/agent-card.json"] }) { +/** Stub plugin that registers a route, simulating A2A, MCP, or OASF. */ +class StubPlugin extends BasePlugin { + readonly name: string; + constructor( + name: string, + private routes: Array<{ method: "GET" | "POST"; path: `/${string}` }>, + ) { + super(); + this.name = name; + } + register(app: AixyzApp): void { + for (const r of this.routes) { + app.route(r.method, r.path, () => Response.json({})); + } + } +} + +async function createApp( + data: unknown = {}, + opts?: { a2a?: boolean; mcp?: boolean; oasf?: boolean; multiA2A?: boolean }, +) { const app = new AixyzApp(); - app.withPlugin(new ERC8004Plugin({ default: data, options })); + if (opts?.a2a) { + await app.withPlugin(new StubPlugin("a2a", [{ method: "GET", path: "/.well-known/agent-card.json" }])); + } + if (opts?.multiA2A) { + await app.withPlugin( + new StubPlugin("a2a-multi", [ + { method: "GET", path: "/.well-known/agent-card.json" }, + { method: "GET", path: "/v2/.well-known/agent-card.json" }, + ]), + ); + } + if (opts?.mcp) { + await app.withPlugin(new StubPlugin("mcp", [{ method: "POST", path: "/mcp" }])); + } + if (opts?.oasf) { + await app.withPlugin(new StubPlugin("oasf", [{ method: "GET", path: "/_aixyz/oasf.json" }])); + } + await app.withPlugin(new ERC8004Plugin({ default: data })); + await app.initialize(); return app; } @@ -49,8 +90,8 @@ function createApp(data: unknown = {}, options = { mcp: true, a2a: ["/.well-know // --------------------------------------------------------------------------- describe("ERC8004Plugin", () => { - test("registers GET route returning the registration file as JSON", async () => { - const app = createApp({ name: "Test", description: "Test agent" }); + test("registers two GET routes returning the registration file as JSON", async () => { + const app = await createApp({ name: "Test", description: "Test agent" }); expect(app.routes.has("GET /_aixyz/erc-8004.json")).toBe(true); @@ -59,13 +100,21 @@ describe("ERC8004Plugin", () => { expect(res.headers.get("content-type")).toContain("application/json"); }); + test("route returns valid ERC-8004 JSON", async () => { + const app = await createApp({ name: "Test", description: "Test agent" }); + + const { json } = await fetchJson(app, "/_aixyz/erc-8004.json"); + + expect(json.type).toBe("https://eips.ethereum.org/EIPS/eip-8004#registration-v1"); + }); + // --------------------------------------------------------------------------- // Schema validation — responses must conform to AgentRegistrationFileSchema // --------------------------------------------------------------------------- describe("schema validation", () => { test("response validates against AgentRegistrationFileSchema", async () => { - const app = createApp({ name: "My Agent", description: "Does things" }); + const app = await createApp({ name: "My Agent", description: "Does things" }, { mcp: true, a2a: true }); const { json } = await fetchJson(app, "/_aixyz/erc-8004.json"); @@ -74,7 +123,7 @@ describe("ERC8004Plugin", () => { }); test("response with config defaults validates against schema", async () => { - const app = createApp({}); + const app = await createApp({}, { mcp: true }); const { json } = await fetchJson(app, "/_aixyz/erc-8004.json"); @@ -83,7 +132,7 @@ describe("ERC8004Plugin", () => { }); test("response with MCP only validates against schema", async () => { - const app = createApp({}, { mcp: true, a2a: [] }); + const app = await createApp({}, { mcp: true }); const { json } = await fetchJson(app, "/_aixyz/erc-8004.json"); @@ -92,7 +141,7 @@ describe("ERC8004Plugin", () => { }); test("response with A2A only validates against schema", async () => { - const app = createApp({}, { mcp: false, a2a: ["/agent"] }); + const app = await createApp({}, { a2a: true }); const { json } = await fetchJson(app, "/_aixyz/erc-8004.json"); @@ -101,10 +150,7 @@ describe("ERC8004Plugin", () => { }); test("response with multiple A2A endpoints validates against schema", async () => { - const app = createApp( - {}, - { mcp: true, a2a: ["/.well-known/agent-card.json", "/v2/.well-known/agent-card.json"] }, - ); + const app = await createApp({}, { mcp: true, multiA2A: true }); const { json } = await fetchJson(app, "/_aixyz/erc-8004.json"); @@ -114,7 +160,7 @@ describe("ERC8004Plugin", () => { }); test("type field is the ERC-8004 registration literal", async () => { - const app = createApp({}); + const app = await createApp({}); const { json } = await fetchJson(app, "/_aixyz/erc-8004.json"); @@ -122,7 +168,7 @@ describe("ERC8004Plugin", () => { }); test("each service validates against ServiceSchema", async () => { - const app = createApp({}, { mcp: true, a2a: ["/.well-known/agent-card.json"] }); + const app = await createApp({}, { mcp: true, a2a: true }); const { json } = await fetchJson(app, "/_aixyz/erc-8004.json"); @@ -133,7 +179,7 @@ describe("ERC8004Plugin", () => { }); test("service endpoints are valid URLs", async () => { - const app = createApp({}, { mcp: true, a2a: ["/.well-known/agent-card.json"] }); + const app = await createApp({}, { mcp: true, a2a: true }); const { json } = await fetchJson(app, "/_aixyz/erc-8004.json"); @@ -149,7 +195,7 @@ describe("ERC8004Plugin", () => { describe("response body", () => { test("custom registration data is reflected", async () => { - const app = createApp({ name: "My Agent", description: "Does things" }); + const app = await createApp({ name: "My Agent", description: "Does things" }); const { json } = await fetchJson(app, "/_aixyz/erc-8004.json"); @@ -161,7 +207,7 @@ describe("ERC8004Plugin", () => { }); test("A2A service has correct fields", async () => { - const app = createApp({}, { mcp: false, a2a: ["/.well-known/agent-card.json"] }); + const app = await createApp({}, { a2a: true }); const { json } = await fetchJson(app, "/_aixyz/erc-8004.json"); @@ -174,7 +220,7 @@ describe("ERC8004Plugin", () => { }); test("MCP service has correct fields", async () => { - const app = createApp({}, { mcp: true, a2a: [] }); + const app = await createApp({}, { mcp: true }); const { json } = await fetchJson(app, "/_aixyz/erc-8004.json"); @@ -187,7 +233,7 @@ describe("ERC8004Plugin", () => { }); test("combined A2A + MCP services in correct order", async () => { - const app = createApp({}, { mcp: true, a2a: ["/.well-known/agent-card.json"] }); + const app = await createApp({}, { mcp: true, a2a: true }); const { json } = await fetchJson(app, "/_aixyz/erc-8004.json"); @@ -197,7 +243,7 @@ describe("ERC8004Plugin", () => { }); test("config defaults applied for empty registration", async () => { - const app = createApp({}); + const app = await createApp({}); const { json } = await fetchJson(app, "/_aixyz/erc-8004.json"); @@ -209,7 +255,7 @@ describe("ERC8004Plugin", () => { }); test("user-provided fields override config defaults", async () => { - const app = createApp({ + const app = await createApp({ name: "Custom Name", description: "Custom desc", image: "https://example.com/logo.png", @@ -227,13 +273,51 @@ describe("ERC8004Plugin", () => { }); }); + // --------------------------------------------------------------------------- + // Dynamic service detection via plugins + // --------------------------------------------------------------------------- + + describe("dynamic service detection", () => { + test("detects A2A, MCP, and OASF plugins", async () => { + const app = await createApp({}, { a2a: true, mcp: true, oasf: true }); + + const { json } = await fetchJson(app, "/_aixyz/erc-8004.json"); + + expect(json.services).toHaveLength(3); + expect(json.services[0].name).toBe("A2A"); + expect(json.services[1].name).toBe("MCP"); + expect(json.services[2].name).toBe("OASF"); + }); + + test("no services when no plugins registered", async () => { + const app = await createApp({}); + + const { json } = await fetchJson(app, "/_aixyz/erc-8004.json"); + + expect(json.services).toHaveLength(0); + }); + + test("OASF service has correct fields", async () => { + const app = await createApp({}, { oasf: true }); + + const { json } = await fetchJson(app, "/_aixyz/erc-8004.json"); + + expect(json.services).toHaveLength(1); + expect(json.services[0]).toMatchObject({ + name: "OASF", + endpoint: "http://localhost:3000/_aixyz/oasf.json", + version: "1.0.0", + }); + }); + }); + // --------------------------------------------------------------------------- // getAgentRegistrationFile // --------------------------------------------------------------------------- describe("getAgentRegistrationFile", () => { - test("returns empty services when mcp: false and a2a: []", () => { - const file = getAgentRegistrationFile({}, { mcp: false, a2a: [] }); + test("returns empty services when mcp: false, a2a: [], oasf: false", () => { + const file = getAgentRegistrationFile({}, { mcp: false, a2a: [], oasf: false }); expect(file.services).toEqual([]); }); @@ -247,7 +331,7 @@ describe("ERC8004Plugin", () => { did: "did:example:123", supportedTrust: ["reputation"], }, - { mcp: true, a2a: [] }, + { mcp: true, a2a: [], oasf: false }, ); const result = AgentRegistrationFileSchema.safeParse(file); @@ -262,8 +346,8 @@ describe("ERC8004Plugin", () => { // Unregistered routes // --------------------------------------------------------------------------- - test("POST to erc-8004 endpoint returns 404", async () => { - const app = createApp({}); + test("POST to well-known returns 404", async () => { + const app = await createApp({}); const res = await app.fetch(new Request("http://localhost/_aixyz/erc-8004.json", { method: "POST", body: "{}" })); expect(res.status).toBe(404); diff --git a/packages/aixyz/app/plugins/erc-8004.ts b/packages/aixyz/app/plugins/erc-8004.ts index a43718f..46d2c97 100644 --- a/packages/aixyz/app/plugins/erc-8004.ts +++ b/packages/aixyz/app/plugins/erc-8004.ts @@ -15,7 +15,7 @@ import type { AixyzApp } from "../index"; */ export function getAgentRegistrationFile( data: unknown, - options: { mcp: boolean; a2a: string[] }, + options: { mcp: boolean; a2a: string[]; oasf: boolean }, ): AgentRegistrationFile { const config = getAixyzConfigRuntime(); const services: AgentRegistrationFile["services"] = []; @@ -36,6 +36,17 @@ export function getAgentRegistrationFile( }); } + if (options.oasf) { + services.push({ + name: "OASF", + endpoint: new URL("/_aixyz/oasf.json", config.url).toString(), + version: "1.0.0", + // TODO(kevin): support OASF skills and domains here. + domains: [], + skills: [], + }); + } + const withDefault = AgentRegistrationFileSchema.extend({ type: z.literal(ERC8004_REGISTRATION_TYPE).default(ERC8004_REGISTRATION_TYPE), name: z.string().default(config.name), @@ -53,13 +64,28 @@ export function getAgentRegistrationFile( export class ERC8004Plugin extends BasePlugin { readonly name = "erc-8004"; - constructor(private exports: { default: unknown; options: { mcp: boolean; a2a: string[] } }) { + private _file: AgentRegistrationFile | undefined; + + constructor(private exports: { default: unknown }) { super(); } register(app: AixyzApp): void { - const file = getAgentRegistrationFile(this.exports.default, this.exports.options); + app.route("GET", "/_aixyz/erc-8004.json", () => Response.json(this._file)); + } - app.route("GET", "/_aixyz/erc-8004.json", () => Response.json(file)); + initialize(app: AixyzApp): void { + // Detect A2A agent card routes + const a2a: string[] = []; + for (const key of app.routes.keys()) { + const match = key.match(/^GET (\/.*\.well-known\/agent-card\.json)$/); + if (match) a2a.push(match[1]); + } + + this._file = getAgentRegistrationFile(this.exports.default, { + a2a, + mcp: !!app.getPlugin("mcp"), + oasf: !!app.getPlugin("oasf"), + }); } } diff --git a/packages/aixyz/app/plugins/oasf.test.ts b/packages/aixyz/app/plugins/oasf.test.ts new file mode 100644 index 0000000..4107391 --- /dev/null +++ b/packages/aixyz/app/plugins/oasf.test.ts @@ -0,0 +1,352 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; + +const DEFAULT_CONFIG = { + name: "Test Agent", + description: "A test agent", + version: "1.0.0", + url: "http://localhost:3000", + x402: { payTo: "0x0000000000000000000000000000000000000000", network: "eip155:8453" }, + build: { tools: [], agents: [], excludes: [], poweredByHeader: true }, + vercel: { maxDuration: 30 }, + skills: [], + domains: [], + services: [], +}; + +const DEFAULT_RUNTIME_CONFIG = { + name: "Test Agent", + description: "A test agent", + version: "1.0.0", + url: "http://localhost:3000", + skills: [], + domains: [], + services: [], +}; + +function setMockConfig(overrides?: { + skills?: unknown[]; + services?: unknown[]; + domains?: Array<{ name: string; id: number }>; +}) { + mock.module("@aixyz/config", () => ({ + getAixyzConfig: () => ({ ...DEFAULT_CONFIG, ...overrides }), + getAixyzConfigRuntime: () => ({ ...DEFAULT_RUNTIME_CONFIG, ...overrides }), + })); +} + +setMockConfig(); + +import { AixyzApp } from "../index"; +import { BasePlugin } from "../plugin"; +import { OASFPlugin, getOasfRecord } from "./oasf"; + +afterEach(() => { + setMockConfig(); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function fetchJson(app: AixyzApp, path: string) { + const res = await app.fetch(new Request(`http://localhost${path}`)); + return { res, json: await res.json() }; +} + +/** Stub plugin that registers a route, simulating A2A or MCP. */ +class StubPlugin extends BasePlugin { + readonly name: string; + constructor( + name: string, + private routes: Array<{ method: "GET" | "POST"; path: `/${string}` }>, + ) { + super(); + this.name = name; + } + register(app: AixyzApp): void { + for (const r of this.routes) { + app.route(r.method, r.path, () => Response.json({})); + } + } +} + +async function createApp(opts?: { a2a?: boolean; mcp?: boolean; multiA2A?: boolean }) { + const app = new AixyzApp(); + if (opts?.a2a) { + await app.withPlugin(new StubPlugin("a2a", [{ method: "GET", path: "/.well-known/agent-card.json" }])); + } + if (opts?.multiA2A) { + await app.withPlugin( + new StubPlugin("a2a-multi", [ + { method: "GET", path: "/.well-known/agent-card.json" }, + { method: "GET", path: "/v2/.well-known/agent-card.json" }, + ]), + ); + } + if (opts?.mcp) { + await app.withPlugin(new StubPlugin("mcp", [{ method: "POST", path: "/mcp" }])); + } + await app.withPlugin(new OASFPlugin()); + await app.initialize(); + return app; +} + +// --------------------------------------------------------------------------- +// Route registration +// --------------------------------------------------------------------------- + +describe("OASFPlugin", () => { + test("registers GET /_aixyz/oasf.json returning JSON", async () => { + const app = await createApp(); + + expect(app.routes.has("GET /_aixyz/oasf.json")).toBe(true); + + const { res } = await fetchJson(app, "/_aixyz/oasf.json"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/json"); + }); + + // --------------------------------------------------------------------------- + // Required fields always present + // --------------------------------------------------------------------------- + + describe("required fields", () => { + test("all required OASF fields present", async () => { + const app = await createApp(); + + const { json } = await fetchJson(app, "/_aixyz/oasf.json"); + + expect(json.name).toBe("Test Agent"); + expect(json.description).toBe("A test agent"); + expect(json.version).toBe("1.0.0"); + expect(json.schema_version).toBe("1.0.0"); + expect(json.authors).toEqual([]); + expect(typeof json.created_at).toBe("string"); + expect(Number.isNaN(Date.parse(json.created_at))).toBe(false); + expect(json.domains).toEqual([]); + expect(json.skills).toEqual([]); + expect(json.modules).toEqual([]); + }); + + test("config defaults populate name, description, version", async () => { + const app = await createApp(); + + const { json } = await fetchJson(app, "/_aixyz/oasf.json"); + + expect(json.name).toBe("Test Agent"); + expect(json.description).toBe("A test agent"); + expect(json.version).toBe("1.0.0"); + }); + }); + + // --------------------------------------------------------------------------- + // Locators auto-detection + // --------------------------------------------------------------------------- + + describe("locators", () => { + test("A2A locator detected from registered agent-card route", async () => { + const app = await createApp({ a2a: true }); + + const { json } = await fetchJson(app, "/_aixyz/oasf.json"); + + expect(json.locators).toEqual([{ type: "a2a", urls: ["http://localhost:3000/.well-known/agent-card.json"] }]); + }); + + test("MCP locator detected from registered mcp plugin", async () => { + const app = await createApp({ mcp: true }); + + const { json } = await fetchJson(app, "/_aixyz/oasf.json"); + + expect(json.locators).toEqual([{ type: "mcp", urls: ["http://localhost:3000/mcp"] }]); + }); + + test("combined A2A + MCP locators in correct order", async () => { + const app = await createApp({ a2a: true, mcp: true }); + + const { json } = await fetchJson(app, "/_aixyz/oasf.json"); + + expect(json.locators).toHaveLength(2); + expect(json.locators[0].type).toBe("a2a"); + expect(json.locators[1].type).toBe("mcp"); + }); + + test("multiple A2A endpoints create single locator with multiple urls", async () => { + const app = await createApp({ multiA2A: true }); + + const { json } = await fetchJson(app, "/_aixyz/oasf.json"); + + expect(json.locators).toHaveLength(1); + expect(json.locators[0].type).toBe("a2a"); + expect(json.locators[0].urls).toHaveLength(2); + }); + + test("no locators when no A2A or MCP plugins registered", async () => { + const app = await createApp(); + + const { json } = await fetchJson(app, "/_aixyz/oasf.json"); + + expect(json.locators).toEqual([]); + }); + + test("config services mapped to locators", async () => { + setMockConfig({ services: [{ type: "openapi", url: "https://example.com/openapi.json" }] }); + + const app = new AixyzApp(); + const record = getOasfRecord(app); + + expect(record.locators).toEqual([{ type: "openapi", urls: ["https://example.com/openapi.json"] }]); + }); + }); + + // --------------------------------------------------------------------------- + // getOasfRecord unit tests + // --------------------------------------------------------------------------- + + describe("getOasfRecord", () => { + test("returns record with defaults for bare app", () => { + const app = new AixyzApp(); + const record = getOasfRecord(app); + + expect(record.name).toBe("Test Agent"); + expect(record.description).toBe("A test agent"); + expect(record.version).toBe("1.0.0"); + expect(record.schema_version).toBe("1.0.0"); + expect(record.authors).toEqual([]); + expect(record.domains).toEqual([]); + expect(record.skills).toEqual([]); + expect(record.modules).toEqual([]); + expect(record.locators).toEqual([]); + }); + }); + + // --------------------------------------------------------------------------- + // Skills mapping + // --------------------------------------------------------------------------- + + describe("skills", () => { + test("excludes skills without OASF catalog info", () => { + setMockConfig({ + skills: [ + { id: "trading", name: "Trading", description: "Execute trades", tags: ["finance"] }, + { id: "analysis", name: "Market Analysis", description: "Analyze markets", tags: ["finance"] }, + ], + }); + + const app = new AixyzApp(); + const record = getOasfRecord(app); + + expect(record.skills).toEqual([]); + }); + + test("maps skills with OASF catalog info", () => { + setMockConfig({ + skills: [ + { + id: "trading", + name: "Trading", + description: "Execute trades", + tags: ["finance"], + oasf: { name: "finance_and_business/trading", id: 201 }, + }, + { id: "analysis", name: "Market Analysis", description: "Analyze markets", tags: ["finance"] }, + ], + }); + + const app = new AixyzApp(); + const record = getOasfRecord(app); + + expect(record.skills).toEqual([{ name: "finance_and_business/trading", id: 201 }]); + }); + }); + + // --------------------------------------------------------------------------- + // Domains mapping + // --------------------------------------------------------------------------- + + describe("domains", () => { + test("maps config domains to OASF domains", () => { + setMockConfig({ + domains: [ + { name: "finance_and_business", id: 2 }, + { name: "technology/blockchain", id: 109 }, + ], + }); + + const app = new AixyzApp(); + const record = getOasfRecord(app); + + expect(record.domains).toEqual([ + { name: "finance_and_business", id: 2 }, + { name: "technology/blockchain", id: 109 }, + ]); + }); + + test("defaults to empty array when no domains configured", () => { + const app = new AixyzApp(); + const record = getOasfRecord(app); + + expect(record.domains).toEqual([]); + }); + }); + + // --------------------------------------------------------------------------- + // Modules auto-detection + // --------------------------------------------------------------------------- + + describe("modules", () => { + test("A2A plugin registered → modules includes integration/a2a with card data", async () => { + const app = await createApp({ a2a: true }); + + const { json } = await fetchJson(app, "/_aixyz/oasf.json"); + + const a2aModule = json.modules.find((m: any) => m.name === "integration/a2a"); + expect(a2aModule).toBeDefined(); + expect(a2aModule.id).toBe(203); + expect(a2aModule.data.card_schema_version).toBe("0.3.0"); + expect(a2aModule.data.card_data).toBeDefined(); + expect(a2aModule.data.card_data.name).toBe("Test Agent"); + expect(a2aModule.data.card_data.protocolVersion).toBe("0.3.0"); + }); + + test("MCP plugin registered → modules includes integration/mcp with connection", async () => { + const app = await createApp({ mcp: true }); + + const { json } = await fetchJson(app, "/_aixyz/oasf.json"); + + const mcpModule = json.modules.find((m: any) => m.name === "integration/mcp"); + expect(mcpModule).toBeDefined(); + expect(mcpModule.id).toBe(202); + expect(mcpModule.data.name).toBe("Test Agent"); + expect(mcpModule.data.connections).toEqual([{ type: "streamable-http", url: "http://localhost:3000/mcp" }]); + }); + + test("both plugins → both modules present", async () => { + const app = await createApp({ a2a: true, mcp: true }); + + const { json } = await fetchJson(app, "/_aixyz/oasf.json"); + + expect(json.modules).toHaveLength(2); + expect(json.modules.find((m: any) => m.name === "integration/a2a")).toBeDefined(); + expect(json.modules.find((m: any) => m.name === "integration/mcp")).toBeDefined(); + }); + + test("no plugins → modules is empty", async () => { + const app = await createApp(); + + const { json } = await fetchJson(app, "/_aixyz/oasf.json"); + + expect(json.modules).toEqual([]); + }); + }); + + // --------------------------------------------------------------------------- + // Unregistered routes + // --------------------------------------------------------------------------- + + test("POST to well-known returns 404", async () => { + const app = await createApp(); + + const res = await app.fetch(new Request("http://localhost/_aixyz/oasf.json", { method: "POST", body: "{}" })); + expect(res.status).toBe(404); + }); +}); diff --git a/packages/aixyz/app/plugins/oasf.ts b/packages/aixyz/app/plugins/oasf.ts new file mode 100644 index 0000000..65e865a --- /dev/null +++ b/packages/aixyz/app/plugins/oasf.ts @@ -0,0 +1,115 @@ +import { getAixyzConfigRuntime } from "@aixyz/config"; +import { BasePlugin } from "../plugin"; +import type { AixyzApp } from "../index"; +import { getAgentCard } from "./a2a"; +import type { MCPPlugin } from "./mcp"; + +type OASFLocator = { type: string; urls: string[] }; + +/** + * Build an OASF record from the runtime config and registered routes. + */ +export function getOasfRecord(app: AixyzApp) { + const config = getAixyzConfigRuntime(); + const locators: OASFLocator[] = []; + + // Detect A2A agent card routes + const a2aUrls: string[] = []; + for (const key of app.routes.keys()) { + const match = key.match(/^GET (\/.*\.well-known\/agent-card\.json)$/); + if (match) a2aUrls.push(new URL(match[1], config.url).toString()); + } + if (a2aUrls.length > 0) { + locators.push({ type: "a2a", urls: a2aUrls }); + } + + // Detect MCP plugin + if (app.getPlugin("mcp")) { + locators.push({ type: "mcp", urls: [new URL("/mcp", config.url).toString()] }); + } + + // Map config services to locators + for (const service of config.services ?? []) { + locators.push({ type: service.type, urls: [service.url] }); + } + + // Map config skills with OASF catalog info to OASF skill format + const skills = config.skills.filter((s) => s.oasf).map((s) => s.oasf!); + + return { + name: config.name, + description: config.description, + version: config.version, + schema_version: "1.0.0", + authors: [], + // NOTE: created_at is required by OASF schema, but not in aixyz config. We use transient value here since it doesn't make sense to persist it in the config file. + created_at: new Date().toISOString(), + domains: config.domains, + skills, + modules: buildModules(app, config, a2aUrls), + locators, + }; +} + +/** + * @see https://schema.oasf.outshift.com/1.0.0/module_categories for constants + */ +const OASF_A2A_MODULE = { name: "integration/a2a", id: 203 } as const; +const OASF_MCP_MODULE = { name: "integration/mcp", id: 202 } as const; + +type OASFModule = { name: string; id: number; data: Record }; + +function buildModules( + app: AixyzApp, + config: ReturnType, + a2aUrls: string[], +): OASFModule[] { + const modules: OASFModule[] = []; + + // A2A module + if (a2aUrls.length > 0) { + modules.push({ + ...OASF_A2A_MODULE, + data: { + // TODO(kevin): read dynamically from a2a plugin exports instead of duplicating the same info here + card_data: getAgentCard(), + card_schema_version: "0.3.0", + }, + }); + } + + // MCP module + const mcpPlugin = app.getPlugin("mcp"); + if (mcpPlugin) { + // @see https://schema.oasf.outshift.com/1.0.0/objects/mcp_data for more format + const data: Record = { + name: config.name, + connections: [{ type: "streamable-http", url: new URL("/mcp", config.url).toString() }], + }; + if (mcpPlugin.registeredTools?.length) { + // @see https://schema.oasf.outshift.com/1.0.0/objects/mcp_server_tool for more format + data.tools = mcpPlugin.registeredTools.map((t) => ({ + name: t.name, + description: t.tool.description, + })); + } + modules.push({ ...OASF_MCP_MODULE, data }); + } + + return modules; +} + +/** OASF identity plugin. Registers `GET /_aixyz/oasf.json`. */ +export class OASFPlugin extends BasePlugin { + readonly name = "oasf"; + private _record: ReturnType | undefined; + + register(app: AixyzApp): void { + // Route registered eagerly; record computed in initialize() after all plugins are registered. + app.route("GET", "/_aixyz/oasf.json", () => Response.json(this._record)); + } + + initialize(app: AixyzApp): void { + this._record = getOasfRecord(app); + } +}