From 2ca039bae28d4a6df3beb4d83792cb101628679c Mon Sep 17 00:00:00 2001 From: kevzzsk <31565174+kevzzsk@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:44:30 +0800 Subject: [PATCH 1/7] feat: OASF plugin --- packages/aixyz-cli/build/AixyzServerPlugin.ts | 12 +- packages/aixyz-config/index.ts | 16 ++ packages/aixyz/app/plugins/erc-8004.test.ts | 137 ++++++++-- packages/aixyz/app/plugins/erc-8004.ts | 31 ++- packages/aixyz/app/plugins/oasf.test.ts | 246 ++++++++++++++++++ packages/aixyz/app/plugins/oasf.ts | 65 +++++ 6 files changed, 471 insertions(+), 36 deletions(-) create mode 100644 packages/aixyz/app/plugins/oasf.test.ts create mode 100644 packages/aixyz/app/plugins/oasf.ts 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..08cbbfc 100644 --- a/packages/aixyz-config/index.ts +++ b/packages/aixyz-config/index.ts @@ -76,6 +76,11 @@ export type AixyzConfig = { maxDuration?: number; }; skills?: InferredAixyzConfig["skills"]; + /** + * External services to advertise (e.g. OpenAPI, GraphQL). + * Each entry becomes an OASF locator and optionally an ERC-8004 service. + */ + services?: Array<{ type: string; url: string; [key: string]: any }>; }; const NetworkSchema = z.custom((val) => { @@ -91,6 +96,7 @@ const defaultConfig = { }, vercel: { maxDuration: 60 }, skills: [], + services: [], }; const AixyzConfigSchema = z.object({ @@ -158,6 +164,14 @@ const AixyzConfigSchema = z.object({ }), ) .default(defaultConfig.skills), + services: z + .array( + z.object({ + type: z.string().nonempty(), + url: z.string().url(), + }), + ) + .default(defaultConfig.services), }); type InferredAixyzConfig = z.infer; @@ -175,6 +189,7 @@ export type AixyzConfigRuntime = { version: AixyzConfig["version"]; url: AixyzConfig["url"]; skills: NonNullable; + services: NonNullable; }; /** @@ -225,5 +240,6 @@ export function getAixyzConfigRuntime(): AixyzConfigRuntime { version: config.version, url: config.url, skills: config.skills, + 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..d6fbf08 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,22 @@ describe("ERC8004Plugin", () => { expect(res.headers.get("content-type")).toContain("application/json"); }); + test("both routes return identical JSON", async () => { + const app = await createApp({ name: "Test", description: "Test agent" }); + + const { json: json1 } = await fetchJson(app, "/.well-known/erc-8004.json"); + const { json: json2 } = await fetchJson(app, "/_aixyz/erc-8004.json"); + + expect(json1).toEqual(json2); + }); + // --------------------------------------------------------------------------- // 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 +124,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 +133,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 +142,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 +151,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 +161,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 +169,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 +180,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 +196,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 +208,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 +221,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 +234,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 +244,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 +256,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 +274,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, "/.well-known/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, "/.well-known/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, "/.well-known/erc-8004.json"); + + expect(json.services).toHaveLength(1); + expect(json.services[0]).toEqual({ + 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 +332,7 @@ describe("ERC8004Plugin", () => { did: "did:example:123", supportedTrust: ["reputation"], }, - { mcp: true, a2a: [] }, + { mcp: true, a2a: [], oasf: false }, ); const result = AgentRegistrationFileSchema.safeParse(file); @@ -262,8 +347,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..ec06bd9 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,14 @@ export function getAgentRegistrationFile( }); } + if (options.oasf) { + services.push({ + name: "OASF", + endpoint: new URL("/_aixyz/oasf.json", config.url).toString(), + version: "1.0.0", + }); + } + const withDefault = AgentRegistrationFileSchema.extend({ type: z.literal(ERC8004_REGISTRATION_TYPE).default(ERC8004_REGISTRATION_TYPE), name: z.string().default(config.name), @@ -53,13 +61,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..20a1108 --- /dev/null +++ b/packages/aixyz/app/plugins/oasf.test.ts @@ -0,0 +1,246 @@ +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: [], + services: [], +}; + +const DEFAULT_RUNTIME_CONFIG = { + name: "Test Agent", + description: "A test agent", + version: "1.0.0", + url: "http://localhost:3000", + skills: [], + services: [], +}; + +function setMockConfig(overrides?: { skills?: unknown[]; services?: unknown[] }) { + 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(() => new Date(json.created_at)).not.toThrow(); + 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("maps config skills to OASF skill format", () => { + 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([{ name: "Trading" }, { name: "Market Analysis" }]); + }); + }); + + // --------------------------------------------------------------------------- + // 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..b626422 --- /dev/null +++ b/packages/aixyz/app/plugins/oasf.ts @@ -0,0 +1,65 @@ +import { getAixyzConfigRuntime } from "@aixyz/config"; +import { BasePlugin } from "../plugin"; +import type { AixyzApp } from "../index"; + +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 to OASF skill format + const skills = config.skills.map((s) => ({ name: s.name })); + + 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: [], + skills, + modules: [], + locators, + }; +} + +/** 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); + } +} From 6c6f815d89f44a5ab01ea33bf15795799fd87271 Mon Sep 17 00:00:00 2001 From: kevzzsk <31565174+kevzzsk@users.noreply.github.com> Date: Mon, 23 Mar 2026 12:10:44 +0800 Subject: [PATCH 2/7] config oasf specific domain + skills --- packages/aixyz-config/index.ts | 25 +++++++++- packages/aixyz/app/plugins/oasf.test.ts | 62 +++++++++++++++++++++++-- packages/aixyz/app/plugins/oasf.ts | 6 +-- 3 files changed, 86 insertions(+), 7 deletions(-) diff --git a/packages/aixyz-config/index.ts b/packages/aixyz-config/index.ts index 08cbbfc..6ed1e47 100644 --- a/packages/aixyz-config/index.ts +++ b/packages/aixyz-config/index.ts @@ -76,11 +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?: Array<{ type: string; url: string; [key: string]: any }>; + services?: InferredAixyzConfig["services"]; }; const NetworkSchema = z.custom((val) => { @@ -96,6 +102,7 @@ const defaultConfig = { }, vercel: { maxDuration: 60 }, skills: [], + domains: [], services: [], }; @@ -161,9 +168,23 @@ 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({ @@ -189,6 +210,7 @@ export type AixyzConfigRuntime = { version: AixyzConfig["version"]; url: AixyzConfig["url"]; skills: NonNullable; + domains: NonNullable; services: NonNullable; }; @@ -240,6 +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/oasf.test.ts b/packages/aixyz/app/plugins/oasf.test.ts index 20a1108..4fe555d 100644 --- a/packages/aixyz/app/plugins/oasf.test.ts +++ b/packages/aixyz/app/plugins/oasf.test.ts @@ -9,6 +9,7 @@ const DEFAULT_CONFIG = { build: { tools: [], agents: [], excludes: [], poweredByHeader: true }, vercel: { maxDuration: 30 }, skills: [], + domains: [], services: [], }; @@ -18,10 +19,15 @@ const DEFAULT_RUNTIME_CONFIG = { version: "1.0.0", url: "http://localhost:3000", skills: [], + domains: [], services: [], }; -function setMockConfig(overrides?: { skills?: unknown[]; services?: unknown[] }) { +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 }), @@ -218,7 +224,7 @@ describe("OASFPlugin", () => { // --------------------------------------------------------------------------- describe("skills", () => { - test("maps config skills to OASF skill format", () => { + test("excludes skills without OASF catalog info", () => { setMockConfig({ skills: [ { id: "trading", name: "Trading", description: "Execute trades", tags: ["finance"] }, @@ -229,7 +235,57 @@ describe("OASFPlugin", () => { const app = new AixyzApp(); const record = getOasfRecord(app); - expect(record.skills).toEqual([{ name: "Trading" }, { name: "Market Analysis" }]); + 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([]); }); }); diff --git a/packages/aixyz/app/plugins/oasf.ts b/packages/aixyz/app/plugins/oasf.ts index b626422..403b247 100644 --- a/packages/aixyz/app/plugins/oasf.ts +++ b/packages/aixyz/app/plugins/oasf.ts @@ -31,8 +31,8 @@ export function getOasfRecord(app: AixyzApp) { locators.push({ type: service.type, urls: [service.url] }); } - // Map config skills to OASF skill format - const skills = config.skills.map((s) => ({ name: s.name })); + // 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, @@ -42,7 +42,7 @@ export function getOasfRecord(app: AixyzApp) { 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: [], + domains: config.domains, skills, modules: [], locators, From 65a3c19600abf59d2a841f61f405a49172d3ceed Mon Sep 17 00:00:00 2001 From: kevzzsk <31565174+kevzzsk@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:40:46 +0800 Subject: [PATCH 3/7] add modules --- packages/aixyz/app/plugins/oasf.test.ts | 50 ++++++++++++++++++++++++ packages/aixyz/app/plugins/oasf.ts | 52 ++++++++++++++++++++++++- 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/packages/aixyz/app/plugins/oasf.test.ts b/packages/aixyz/app/plugins/oasf.test.ts index 4fe555d..1374095 100644 --- a/packages/aixyz/app/plugins/oasf.test.ts +++ b/packages/aixyz/app/plugins/oasf.test.ts @@ -289,6 +289,56 @@ describe("OASFPlugin", () => { }); }); + // --------------------------------------------------------------------------- + // 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 // --------------------------------------------------------------------------- diff --git a/packages/aixyz/app/plugins/oasf.ts b/packages/aixyz/app/plugins/oasf.ts index 403b247..65e865a 100644 --- a/packages/aixyz/app/plugins/oasf.ts +++ b/packages/aixyz/app/plugins/oasf.ts @@ -1,6 +1,8 @@ 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[] }; @@ -44,11 +46,59 @@ export function getOasfRecord(app: AixyzApp) { created_at: new Date().toISOString(), domains: config.domains, skills, - modules: [], + 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"; From ba699ca453ca9034b860e2f8fbef01ffb2647f4f Mon Sep 17 00:00:00 2001 From: kevzzsk <31565174+kevzzsk@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:44:24 +0800 Subject: [PATCH 4/7] fix erc8004 route test --- packages/aixyz/app/plugins/erc-8004.test.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/aixyz/app/plugins/erc-8004.test.ts b/packages/aixyz/app/plugins/erc-8004.test.ts index d6fbf08..64ac052 100644 --- a/packages/aixyz/app/plugins/erc-8004.test.ts +++ b/packages/aixyz/app/plugins/erc-8004.test.ts @@ -100,13 +100,12 @@ describe("ERC8004Plugin", () => { expect(res.headers.get("content-type")).toContain("application/json"); }); - test("both routes return identical JSON", async () => { + test("route returns valid ERC-8004 JSON", async () => { const app = await createApp({ name: "Test", description: "Test agent" }); - const { json: json1 } = await fetchJson(app, "/.well-known/erc-8004.json"); - const { json: json2 } = await fetchJson(app, "/_aixyz/erc-8004.json"); + const { json } = await fetchJson(app, "/_aixyz/erc-8004.json"); - expect(json1).toEqual(json2); + expect(json.type).toBe("https://eips.ethereum.org/EIPS/eip-8004#registration-v1"); }); // --------------------------------------------------------------------------- @@ -282,7 +281,7 @@ describe("ERC8004Plugin", () => { test("detects A2A, MCP, and OASF plugins", async () => { const app = await createApp({}, { a2a: true, mcp: true, oasf: true }); - const { json } = await fetchJson(app, "/.well-known/erc-8004.json"); + const { json } = await fetchJson(app, "/_aixyz/erc-8004.json"); expect(json.services).toHaveLength(3); expect(json.services[0].name).toBe("A2A"); @@ -293,7 +292,7 @@ describe("ERC8004Plugin", () => { test("no services when no plugins registered", async () => { const app = await createApp({}); - const { json } = await fetchJson(app, "/.well-known/erc-8004.json"); + const { json } = await fetchJson(app, "/_aixyz/erc-8004.json"); expect(json.services).toHaveLength(0); }); @@ -301,7 +300,7 @@ describe("ERC8004Plugin", () => { test("OASF service has correct fields", async () => { const app = await createApp({}, { oasf: true }); - const { json } = await fetchJson(app, "/.well-known/erc-8004.json"); + const { json } = await fetchJson(app, "/_aixyz/erc-8004.json"); expect(json.services).toHaveLength(1); expect(json.services[0]).toEqual({ From 7c9db10905ab1ca282bac7fd0d84eb270a38b7f9 Mon Sep 17 00:00:00 2001 From: kevzzsk <31565174+kevzzsk@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:03:54 +0800 Subject: [PATCH 5/7] resolve comments --- packages/aixyz/app/plugins/erc-8004.ts | 3 +++ packages/aixyz/app/plugins/oasf.test.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/aixyz/app/plugins/erc-8004.ts b/packages/aixyz/app/plugins/erc-8004.ts index ec06bd9..46d2c97 100644 --- a/packages/aixyz/app/plugins/erc-8004.ts +++ b/packages/aixyz/app/plugins/erc-8004.ts @@ -41,6 +41,9 @@ export function getAgentRegistrationFile( 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: [], }); } diff --git a/packages/aixyz/app/plugins/oasf.test.ts b/packages/aixyz/app/plugins/oasf.test.ts index 1374095..4107391 100644 --- a/packages/aixyz/app/plugins/oasf.test.ts +++ b/packages/aixyz/app/plugins/oasf.test.ts @@ -122,7 +122,7 @@ describe("OASFPlugin", () => { expect(json.schema_version).toBe("1.0.0"); expect(json.authors).toEqual([]); expect(typeof json.created_at).toBe("string"); - expect(() => new Date(json.created_at)).not.toThrow(); + expect(Number.isNaN(Date.parse(json.created_at))).toBe(false); expect(json.domains).toEqual([]); expect(json.skills).toEqual([]); expect(json.modules).toEqual([]); From d1ed4a106a05395e2153771a0f31351ff824f6d0 Mon Sep 17 00:00:00 2001 From: kevzzsk <31565174+kevzzsk@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:06:59 +0800 Subject: [PATCH 6/7] fix test --- packages/aixyz/app/plugins/erc-8004.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/aixyz/app/plugins/erc-8004.test.ts b/packages/aixyz/app/plugins/erc-8004.test.ts index 64ac052..05cb707 100644 --- a/packages/aixyz/app/plugins/erc-8004.test.ts +++ b/packages/aixyz/app/plugins/erc-8004.test.ts @@ -303,7 +303,7 @@ describe("ERC8004Plugin", () => { const { json } = await fetchJson(app, "/_aixyz/erc-8004.json"); expect(json.services).toHaveLength(1); - expect(json.services[0]).toEqual({ + expect(json.services[0]).toMatchObject({ name: "OASF", endpoint: "http://localhost:3000/_aixyz/oasf.json", version: "1.0.0", From 329d7cca47e46aa2ff9c5103d76e428a6e593892 Mon Sep 17 00:00:00 2001 From: kevzzsk <31565174+kevzzsk@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:10:42 +0800 Subject: [PATCH 7/7] update docs --- docs/config/aixyz-config.mdx | 58 ++++++++----- docs/docs.json | 2 +- docs/protocols/erc-8004.mdx | 4 + docs/protocols/oasf.mdx | 160 +++++++++++++++++++++++++++++++++++ 4 files changed, 204 insertions(+), 20 deletions(-) create mode 100644 docs/protocols/oasf.mdx 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. + +