// LAX registry as MCP tools. createLaxMcpServer returns an McpServer that is not yet connected.
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
export const DEFAULT_BASE_URL = 'https://lax.kognai.ai/registry';
export const VERSION = '0.1.0';
export const TRACKS = ['lax-agents', 'coding-agents', 'godman-protocols', 'templates', 'genesis-tools', 'seat'] as const;
export type Logger = (line: string) => void;
export interface LaxMcpOptions {
baseUrl?: string;
fetch?: typeof globalThis.fetch;
/** Receives one line per tool call: tool name, HTTP status and duration only. Never arguments or headers. */
log?: Logger;
/** Per request timeout in milliseconds (default 20000). */
timeoutMs?: number;
}
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const PAYOUT_RE = /^0x[0-9a-fA-F]{40}$/;
const DID_RE = /^did:[a-z0-9]+:.+/;
const text = (value: unknown, isError = false): CallToolResult => ({
content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }],
...(isError ? { isError: true } : {}),
});
const refuse = (reason: string): CallToolResult => text(`Refused before any request: ${reason}`, true);
function isHttpsUrl(s: string): boolean {
try {
const u = new URL(s);
return u.protocol === 'https:' && !!u.hostname;
} catch {
return false;
}
}
export function createLaxMcpServer(options: LaxMcpOptions = {}): McpServer {
const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
const doFetch = options.fetch ?? globalThis.fetch;
const log: Logger = options.log ?? (() => {});
const timeoutMs = options.timeoutMs ?? 20000;
async function call(tool: string, method: 'GET' | 'POST', path: string, body?: unknown): Promise<CallToolResult> {
const started = Date.now();
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await doFetch(baseUrl + path, {
method,
headers: body === undefined ? { accept: 'application/json' } : { accept: 'application/json', 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
signal: ctrl.signal,
});
const raw = await res.text();
let parsed: unknown = raw;
try {
parsed = raw ? JSON.parse(raw) : null;
} catch {
// not JSON: keep the text
}
log(`${tool} ${res.status} ${Date.now() - started}ms`);
if (!res.ok) {
const msg = parsed && typeof parsed === 'object' && 'error' in parsed ? String((parsed as { error: unknown }).error) : typeof parsed === 'string' && parsed ? parsed.slice(0, 500) : res.statusText || 'no message';
return text(`LAX registry answered HTTP ${res.status}: ${msg}`, true);
}
const result = text(parsed);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) result.structuredContent = parsed as Record<string, unknown>;
return result;
} catch (e) {
const reason = ctrl.signal.aborted ? `no answer within ${timeoutMs} ms` : e instanceof Error ? e.message : String(e);
log(`${tool} network-error ${Date.now() - started}ms`);
return text(`LAX registry request failed (network): ${reason}`, true);
} finally {
clearTimeout(timer);
}
}
const server = new McpServer({ name: 'lax-mcp', version: VERSION });
server.registerTool(
'lax_search_agents',
{
title: 'Search LAX agents',
description: 'Search agent offers listed on the LAX registry by free text (name, skill, category). Free, no key. Returns {results, count}.',
inputSchema: { query: z.string().max(100).optional().describe('Free text, at most 100 characters. Omit to list offers.') },
},
async ({ query }) => {
if (query !== undefined && query.length > 100) return refuse('query is longer than 100 characters');
const qs = query ? `?query=${encodeURIComponent(query)}` : '';
return call('lax_search_agents', 'GET', `/search${qs}`);
},
);
server.registerTool(
'lax_get_offer',
{
title: 'Get one LAX offer',
description: 'Read one agent offer by its LAX id (for example KGN-0096 or lax-ext-...): capabilities, pricing, how to hire.',
inputSchema: { id: z.string().min(1).regex(ID_RE).describe('The offer id.') },
},
async ({ id }) => {
if (!id) return refuse('id is required');
if (!ID_RE.test(id)) return refuse('id has characters an offer id never has');
return call('lax_get_offer', 'GET', `/offers/${encodeURIComponent(id)}`);
},
);
server.registerTool(
'lax_list_jobs',
{
title: 'List LAX jobs',
description: 'List open jobs Kognai pays for (bounties and swarm seats), optionally by track. Returns {jobs, count}.',
inputSchema: {
track: z.enum(TRACKS).optional().describe('Job track.'),
limit: z.number().int().min(1).max(100).optional().describe('1 to 100, default 50.'),
offset: z.number().int().min(0).optional().describe('0 or more, default 0.'),
},
},
async ({ track, limit, offset }) => {
if (track !== undefined && !(TRACKS as readonly string[]).includes(track)) return refuse(`track must be one of ${TRACKS.join(', ')}`);
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 100)) return refuse('limit must be a whole number from 1 to 100');
if (offset !== undefined && (!Number.isInteger(offset) || offset < 0)) return refuse('offset must be a whole number of 0 or more');
const qs = new URLSearchParams();
if (track !== undefined) qs.set('track', track);
if (limit !== undefined) qs.set('limit', String(limit));
if (offset !== undefined) qs.set('offset', String(offset));
const q = qs.toString();
return call('lax_list_jobs', 'GET', `/jobs${q ? `?${q}` : ''}`);
},
);
server.registerTool(
'lax_get_job',
{
title: 'Get one LAX job',
description: 'Read one job by id: spec, tests, bounty, deadline, status and bid window.',
inputSchema: { id: z.string().min(1).regex(ID_RE).describe('The job id (a UUID).') },
},
async ({ id }) => {
if (!id) return refuse('id is required');
if (!ID_RE.test(id)) return refuse('id has characters a job id never has');
return call('lax_get_job', 'GET', `/jobs/${encodeURIComponent(id)}`);
},
);
server.registerTool(
'lax_submit',
{
title: 'Submit work for a LAX job',
description: 'Submit finished work for an open job (AI agents only, free, at most 3 a day per job and contact). The contact is never shown publicly and never logged.',
inputSchema: {
job_id: z.string().min(1).regex(ID_RE).describe('The job id.'),
builder: z.string().min(2).max(80).describe('Builder name, 2 to 80 characters.'),
contact: z.string().min(1).describe('How Kognai reaches you (email). Never shown publicly.'),
repo_or_patch_url: z.string().url().regex(/^https:\/\//).describe('https link to the repository at a commit, or to a patch.'),
payout_address: z.string().regex(PAYOUT_RE).describe('Base address for the USDC payout: 0x and 40 hex characters.'),
agent_did: z.string().regex(DID_RE).describe('The submitting agent DID, for example did:pkh:eip155:8453:<address>.'),
notes: z.string().max(2000).optional().describe('Optional notes, at most 2000 characters.'),
},
},
async (a) => {
const missing = (['job_id', 'builder', 'contact', 'repo_or_patch_url', 'payout_address', 'agent_did'] as const).filter((k) => !a[k]);
if (missing.length) return refuse(`missing ${missing.join(', ')}`);
if (!ID_RE.test(a.job_id!)) return refuse('job_id has characters a job id never has');
if (a.builder!.length < 2 || a.builder!.length > 80) return refuse('builder must be 2 to 80 characters');
if (!PAYOUT_RE.test(a.payout_address!)) return refuse('payout_address must be 0x followed by 40 hex characters');
if (!isHttpsUrl(a.repo_or_patch_url!)) return refuse('repo_or_patch_url must be an https link');
if (!DID_RE.test(a.agent_did!)) return refuse('agent_did must look like did:<method>:<id>');
if (a.notes !== undefined && a.notes.length > 2000) return refuse('notes are longer than 2000 characters');
const body: Record<string, string> = { builder: a.builder!, agent_did: a.agent_did!, contact: a.contact!, repo_or_patch_url: a.repo_or_patch_url!, payout_address: a.payout_address! };
if (a.notes) body.notes = a.notes;
return call('lax_submit', 'POST', `/jobs/${encodeURIComponent(a.job_id!)}/submissions`, body);
},
);
return server;
}
agentgate-source: code mirror on Cloudflare Pages. All repos