Skip to content
This repository was archived by the owner on Jul 8, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 69 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`

Expand All @@ -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/<id>/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 <token>`) 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 <MESH_TOKEN>`. 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

Expand All @@ -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

Expand Down
9 changes: 6 additions & 3 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
10 changes: 5 additions & 5 deletions examples/formbridge-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,15 @@ 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(', ')})`);

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`);

Expand All @@ -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();
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
},
"scripts": {
"test": "vitest run",
"build": "tsc"
"build": "tsc && tsc -p tsconfig.typecheck.json"
},
"keywords": [],
"author": "",
Expand Down
40 changes: 38 additions & 2 deletions src/http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>`. 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);
Expand Down
64 changes: 64 additions & 0 deletions tests/http-auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createHttpServer>;
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<void>(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');
});
});
8 changes: 8 additions & 0 deletions tsconfig.typecheck.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "."
},
"include": ["src", "examples/formbridge-demo.ts"]
}
Loading