From 032c7df3428bb8259b86f4ed85d386130956fb2d Mon Sep 17 00:00:00 2001 From: Amit Paz Date: Fri, 19 Jun 2026 15:27:36 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20P1=20=E2=80=94=20repair=20demo,=20honest?= =?UTF-8?q?=20delegation/discovery=20docs,=20control-plane=20auth=20+=20CO?= =?UTF-8?q?RS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix the broken "runnable" demo: examples/formbridge-demo.ts referenced a nonexistent r.matchedTerms (→ r.matchedCapabilities); fix the matching README line. Add tsconfig.typecheck.json (noEmit) covering examples/ and chain it into `build` so the example is type-checked going forward (the original tsc only covered src/). - Docs: delegation is HTTP POST /task, not the advertised MCP /mcp + handle_task — rewrite the README delegation section + example endpoints to the real contract. Remove the LoreDiscoveryEngine / "semantic search" claims that don't exist; describe discovery honestly as keyword/token-overlap matching. - Security: add Bearer-token auth on /v1/* (constant-time compare via crypto.timingSafeEqual; token from MESH_TOKEN). Fail-closed: unset token → all /v1/* return 401. Replace wildcard CORS with a configurable origin (MESH_CORS_ORIGIN, never '*'); /health stays open for liveness. build clean (incl. the now-typechecked example); pnpm test green (5 new auth tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 86 +++++++++++++++++++++++++++++-------- examples/README.md | 9 ++-- examples/formbridge-demo.ts | 10 ++--- package.json | 2 +- src/http-server.ts | 40 ++++++++++++++++- tests/http-auth.test.ts | 64 +++++++++++++++++++++++++++ tsconfig.typecheck.json | 8 ++++ 7 files changed, 191 insertions(+), 28 deletions(-) create mode 100644 tests/http-auth.test.ts create mode 100644 tsconfig.typecheck.json diff --git a/README.md b/README.md index a5ec9a8..98fcffa 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ --- -Agents register their capabilities, discover each other by semantic search, and delegate tasks — all through standard MCP tools. +Agents register their capabilities, discover each other by keyword / token-overlap matching, and delegate tasks. Registration and discovery are exposed as standard MCP tools; delegation is performed over HTTP (`POST /task`) to each agent's registered endpoint. ## Quick Start @@ -80,18 +80,21 @@ Register an agent with its capabilities. | `name` | string | Unique agent name | | `description` | string | What this agent does | | `capabilities` | string[] | List of capabilities | -| `endpoint` | string | Agent's MCP endpoint URL | +| `endpoint` | string | Agent's HTTP callback URL — receives `POST /task` (e.g. `http://host:port/task`) | ### `mesh_discover` -Discover agents matching a natural language query. +Discover agents whose description / capabilities overlap with the query tokens. +Matching is plain keyword / token-overlap (no embeddings or semantic search): +the query is lowercased and split into tokens, and each agent is scored by the +fraction of query tokens found in its description + capabilities. | Parameter | Type | Description | |-----------|------|-------------| | `query` | string | Search query (e.g. "budget management") | | `limit` | number? | Max results to return | -Returns agents ranked by relevance score with matched capability terms. +Returns agents ranked by token-overlap score with the matched capability tokens. ### `mesh_unregister` @@ -111,7 +114,59 @@ Delegate a task to another agent by name. | `task` | string | Task description to delegate | | `context` | string? | Optional JSON context | -Connects to the target agent's MCP endpoint and calls its `handle_task` tool. +Delegation does **not** go over MCP. The mesh sends an HTTP `POST` to the target +agent's registered `endpoint` (its `POST /task` URL). Any agent that exposes such +an HTTP endpoint can participate — no MCP server required on the target side. + +### Agent `POST /task` contract + +The target agent must accept a JSON request body of the form: + +```json +{ + "delegationId": "uuid", + "task": "Get budget and cost center for Engineering", + "context": { "depth": 1 }, + "callbackUrl": "http://mesh-host:8766/v1/delegations//result" +} +``` + +(`callbackUrl` is only present for async delegations.) The agent responds with one of: + +- **Synchronous:** HTTP `200` and a JSON body `{ "result": "..." }` (or any JSON; it + is returned to the caller as the delegation result). +- **Asynchronous:** HTTP `202` to accept the task, then later `POST` the result to + `callbackUrl` with `{ "status": "completed" | "failed", "result"?: ..., "error"?: ... }`. +- **Failure:** any non-2xx status; the body text is surfaced as the error. + +If the registered agent has `auth` configured, the mesh attaches it (e.g. +`Authorization: Bearer `) to the outgoing request. + +### Delegating over HTTP directly + +The mesh also exposes the delegation flow over its own HTTP control plane: + +```bash +agentkit-mesh serve --port 8766 # start the HTTP control plane + +curl -X POST http://localhost:8766/v1/delegate \ + -H "Authorization: Bearer $MESH_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{ "targetName": "finance-agent", "task": "Get Engineering budget" }' +``` + +#### Securing the control plane + +The `/v1/*` routes (register, discover, delegate, …) require a shared secret. +Configure it with environment variables before starting `serve`: + +| Env var | Required | Description | +|---------|----------|-------------| +| `MESH_TOKEN` | **yes** | Shared secret. Clients must send `Authorization: Bearer `. If unset, **all `/v1/*` requests return `401`** (fail-closed). | +| `MESH_CORS_ORIGIN` | no | Allowed browser origin for CORS. Defaults to `http://localhost:8766` (never `*`). | + +`/health` stays open (no auth) for liveness probes. This is a single shared +bearer secret — there are no per-agent keys, scopes, or rotation. ## Use Case: FormBridge @@ -127,28 +182,25 @@ registry.register({ name: 'finance-agent', description: 'Budget management and expense approval', capabilities: ['budget', 'cost_center', 'expense_approval'], - endpoint: 'http://localhost:4002/mcp', + endpoint: 'http://localhost:4002/task', }); // HR agent discovers who can help with budget fields const discovery = new DiscoveryEngine(); const results = discovery.discover('budget cost center', registry); -// → [{ agent: finance-agent, score: 0.67, matchedTerms: ['budget', 'cost', 'center'] }] +// → [{ agent: finance-agent, score: 0.67, matchedCapabilities: ['budget', 'cost', 'center'] }] ``` See [examples/](examples/) for a runnable demo. -## Optional: Lore Integration - -For semantic search beyond keyword matching, connect to a [Lore](https://github.com/agentkitai/lore) server: +## Discovery: keyword / token-overlap matching -```typescript -import { LoreDiscoveryEngine } from 'agentkit-mesh'; - -const engine = new LoreDiscoveryEngine('http://lore:8080', registry, 'api-key'); -const results = await engine.discover('financial planning'); -// Falls back to text matching if Lore is unavailable -``` +Discovery ships as plain keyword / token-overlap matching only — there is no +embedding model or semantic search. `DiscoveryEngine.discover()` tokenizes the +query, scores each agent by the fraction of query tokens that appear in its +description + capabilities, and returns the matches ranked by that score. +Resource-requirement filtering (scheme/host-aware URI matching) can further +narrow results. That is the full extent of the matching algorithm. ## Programmatic API diff --git a/examples/README.md b/examples/README.md index c8db45b..1adf02d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -10,7 +10,7 @@ An HR agent needs to fill an expense form but doesn't know budget details. It: 1. **Registers** itself and discovers that a Finance agent is also registered 2. **Discovers** the Finance agent by searching for "budget cost center" -3. **Delegates** the budget lookup to the Finance agent via MCP +3. **Delegates** the budget lookup to the Finance agent via an HTTP `POST /task` to its endpoint ### Run @@ -30,10 +30,13 @@ npx tsx examples/formbridge-demo.ts 2. HR agent discovers agents for "budget cost center"... → finance-agent (score: 0.60, matched: budget, cost, center) -3. HR agent would delegate to finance-agent at http://localhost:4002/mcp +3. HR agent would delegate to finance-agent at http://localhost:4002/task Task: "Get budget and cost center for Engineering department" ``` ### In Production -Replace the simulated delegation with real MCP servers. Each agent runs `agentkit-mesh` as its MCP tool provider, enabling automatic discovery and cross-agent task delegation. +Replace the simulated delegation with a real agent listening at the registered +endpoint. The mesh delegates by sending an HTTP `POST /task` to that endpoint +(see the delegation contract in the top-level README), so any agent that exposes +such an HTTP endpoint can participate. diff --git a/examples/formbridge-demo.ts b/examples/formbridge-demo.ts index 543dfd2..97854ac 100644 --- a/examples/formbridge-demo.ts +++ b/examples/formbridge-demo.ts @@ -24,7 +24,7 @@ const hr = registry.register({ name: 'hr-agent', description: 'Human resources agent for employee info, HR data, and department lookups', capabilities: ['employee_info', 'hr_data', 'department'], - endpoint: 'http://localhost:4001/mcp', + endpoint: 'http://localhost:4001/task', }); console.log(` ✓ ${hr.name} registered (capabilities: ${hr.capabilities.join(', ')})`); @@ -32,7 +32,7 @@ const finance = registry.register({ name: 'finance-agent', description: 'Finance agent for budget management, cost center lookups, and expense approval', capabilities: ['budget', 'cost_center', 'expense_approval'], - endpoint: 'http://localhost:4002/mcp', + endpoint: 'http://localhost:4002/task', }); console.log(` ✓ ${finance.name} registered (capabilities: ${finance.capabilities.join(', ')})\n`); @@ -42,14 +42,14 @@ const discovery = new DiscoveryEngine(); const results = discovery.discover('budget cost center for expense form', registry); for (const r of results) { - console.log(` → ${r.agent.name} (score: ${r.score.toFixed(2)}, matched: ${r.matchedTerms.join(', ')})`); + console.log(` → ${r.agent.name} (score: ${r.score.toFixed(2)}, matched: ${r.matchedCapabilities.join(', ')})`); } -// Step 3: Delegation (simulated — real delegation requires running MCP servers) +// Step 3: Delegation (simulated — real delegation requires a running agent at the endpoint) const bestMatch = results[0]; console.log(`\n3. HR agent would delegate to ${bestMatch.agent.name} at ${bestMatch.agent.endpoint}`); console.log(' Task: "Get budget and cost center for Engineering department"'); -console.log(' (In production, this calls mesh_delegate which connects via MCP)'); +console.log(' (In production, this sends an HTTP POST /task to the agent\'s endpoint)'); console.log('\n=== Demo complete ==='); registry.close(); diff --git a/package.json b/package.json index 8907276..24f62f9 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ }, "scripts": { "test": "vitest run", - "build": "tsc" + "build": "tsc && tsc -p tsconfig.typecheck.json" }, "keywords": [], "author": "", diff --git a/src/http-server.ts b/src/http-server.ts index 6e49d15..a6ae53e 100644 --- a/src/http-server.ts +++ b/src/http-server.ts @@ -12,12 +12,48 @@ function safeInt(val: string | undefined, fallback: number): number { return Number.isNaN(n) || n < 0 ? fallback : n; } -export function createHttpServer(registry: AgentRegistry, port = 8766) { +/** Constant-time string compare to avoid leaking the token via timing. */ +function tokensMatch(a: string, b: string): boolean { + const ab = Buffer.from(a); + const bb = Buffer.from(b); + // timingSafeEqual requires equal length; length mismatch is itself a non-match. + if (ab.length !== bb.length) return false; + return crypto.timingSafeEqual(ab, bb); +} + +export interface HttpServerOptions { + /** Shared secret required on /v1/* as `Authorization: Bearer `. Defaults to MESH_TOKEN. */ + token?: string; + /** Allowed CORS origin (exact origin, not `*`). Defaults to MESH_CORS_ORIGIN or http://localhost:8766. */ + corsOrigin?: string; +} + +export function createHttpServer(registry: AgentRegistry, port = 8766, opts?: HttpServerOptions) { const app = new Hono(); const discovery = new DiscoveryEngine(); const delegationClient = new DelegationClient(); - app.use('*', cors()); + const token = opts?.token ?? process.env['MESH_TOKEN']; + // Non-wildcard CORS: only the configured origin may call the control plane from a browser. + const corsOrigin = opts?.corsOrigin ?? process.env['MESH_CORS_ORIGIN'] ?? 'http://localhost:8766'; + app.use('*', cors({ origin: corsOrigin })); + + // Shared-secret auth on the control plane. Fail-closed: if no token is + // configured server-side, every /v1/* request is rejected (the server is not + // usable until an operator sets MESH_TOKEN), so a misconfigured deploy can + // never accidentally expose an open control plane. + // ponytail: single shared bearer secret, no per-agent keys / rotation / scopes. + app.use('/v1/*', async (c, next) => { + if (!token) { + return c.json({ error: 'Server misconfigured: MESH_TOKEN not set' }, 401); + } + const header = c.req.header('Authorization') ?? ''; + const presented = header.startsWith('Bearer ') ? header.slice(7) : ''; + if (!presented || !tokensMatch(presented, token)) { + return c.json({ error: 'Unauthorized' }, 401); + } + await next(); + }); app.onError((err, c) => { console.error('Unhandled error:', err); diff --git a/tests/http-auth.test.ts b/tests/http-auth.test.ts new file mode 100644 index 0000000..65ab694 --- /dev/null +++ b/tests/http-auth.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { AddressInfo } from 'net'; +import { AgentRegistry } from '../src/registry.js'; +import { createHttpServer } from '../src/http-server.js'; + +const TOKEN = 'test-secret-token'; + +describe('HTTP control-plane auth', () => { + let registry: AgentRegistry; + let server: ReturnType; + let base: string; + + beforeAll(async () => { + registry = new AgentRegistry(':memory:'); + // port 0 → OS assigns a free port; corsOrigin set to a concrete (non-wildcard) origin. + server = createHttpServer(registry, 0, { token: TOKEN, corsOrigin: 'http://localhost:5173' }); + await new Promise(r => { + const check = () => { + const addr = (server as any).address?.() as AddressInfo | null; + if (addr && typeof addr === 'object' && addr.port) { base = `http://127.0.0.1:${addr.port}`; r(); } + else setTimeout(check, 10); + }; + check(); + }); + }); + + afterAll(() => { + server.close(); + registry.close(); + }); + + it('rejects /v1/* without a token (401)', async () => { + const res = await fetch(`${base}/v1/agents`); + expect(res.status).toBe(401); + }); + + it('rejects /v1/* with a wrong token (401)', async () => { + const res = await fetch(`${base}/v1/agents`, { + headers: { Authorization: 'Bearer wrong-token' }, + }); + expect(res.status).toBe(401); + }); + + it('allows /v1/* with the correct token (200)', async () => { + const res = await fetch(`${base}/v1/agents`, { + headers: { Authorization: `Bearer ${TOKEN}` }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + it('leaves /health open (no auth required)', async () => { + const res = await fetch(`${base}/health`); + expect(res.status).toBe(200); + }); + + it('uses a non-wildcard CORS origin', async () => { + const res = await fetch(`${base}/health`, { + headers: { Origin: 'http://localhost:5173' }, + }); + expect(res.headers.get('access-control-allow-origin')).toBe('http://localhost:5173'); + }); +}); diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json new file mode 100644 index 0000000..5e50a9c --- /dev/null +++ b/tsconfig.typecheck.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src", "examples/formbridge-demo.ts"] +}