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);
+ }
+}