mirror of
https://github.com/hanzoai/mcp.git
synced 2026-10-15 12:30:12 +00:00
Add code_search / code_context / code_ask / code_index — one MCP tool per api.hanzo.ai/v1/code endpoint — so @hanzo/dev and any MCP agent gain per-org code search, budget-packed context, and cited RAG answers. Auth mirrors hanzo-cloud.ts exactly: the caller's Hanzo bearer from HANZO_API_KEY (or API_KEY/API_TOKEN/HANZO_TOKEN). The org is minted server-side from that identity (principal gate) — a client never passes an org. Output is requested as markdown (?format=md) so the LLM reads a token-thrifty table; a non-2xx surfaces as a clear isError string. The tools join the canonical HIP-0300 surface (allUnifiedTools), so tools/list exposes all four over stdio. test: hermetic local-server suite (10 tests) asserts method/path/format=md/ Bearer, no-org-leak, and arg + auth failures. Update the two tool-list registry tests for gimp + the four code tools, and fix a pre-existing ts-jest rootDir error that blocked every suite importing test/setup.ts. Co-authored-by: Hanzo Dev <[email protected]>
184 lines
6.8 KiB
TypeScript
184 lines
6.8 KiB
TypeScript
/**
|
|
* Tests for the code-intelligence tools (Hanzo /v1/code/* surface).
|
|
*
|
|
* A local HTTP server stands in for api.hanzo.ai so the tests are deterministic
|
|
* and hermetic. We assert each tool: (1) hits the right method + path, (2) carries
|
|
* the caller's Bearer token, (3) requests markdown (?format=md), (4) never leaks an
|
|
* org param, (5) returns the markdown body, and (6) surfaces non-2xx + arg/auth
|
|
* failures as isError.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll } from '@jest/globals';
|
|
import * as http from 'http';
|
|
import { AddressInfo } from 'net';
|
|
import {
|
|
codeSearchTool,
|
|
codeContextTool,
|
|
codeAskTool,
|
|
codeIndexTool,
|
|
codeIntelTools,
|
|
} from '../../src/tools/code-intel.js';
|
|
|
|
interface RecordedRequest {
|
|
method: string;
|
|
url: string;
|
|
pathname: string;
|
|
query: URLSearchParams;
|
|
headers: http.IncomingHttpHeaders;
|
|
body: string;
|
|
}
|
|
|
|
let server: http.Server;
|
|
const recorded: RecordedRequest[] = [];
|
|
let prevApiUrl: string | undefined;
|
|
let prevKey: string | undefined;
|
|
|
|
beforeAll(async () => {
|
|
server = http.createServer((req, res) => {
|
|
let body = '';
|
|
req.on('data', (chunk) => { body += chunk; });
|
|
req.on('end', () => {
|
|
const parsed = new URL(req.url || '/', 'http://127.0.0.1');
|
|
recorded.push({
|
|
method: req.method || '',
|
|
url: req.url || '',
|
|
pathname: parsed.pathname,
|
|
query: parsed.searchParams,
|
|
headers: req.headers,
|
|
body,
|
|
});
|
|
|
|
// A magic query lets us force a 403 (principal gate) for the error path.
|
|
const q = parsed.searchParams.get('q');
|
|
const bodyQuery = body ? (() => { try { return JSON.parse(body).query; } catch { return undefined; } })() : undefined;
|
|
if (q === 'DENY' || bodyQuery === 'DENY') {
|
|
res.writeHead(403, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'valid principal required' }));
|
|
return;
|
|
}
|
|
|
|
// Echo a markdown table (what the code surface returns for ?format=md).
|
|
res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8' });
|
|
res.end(`| endpoint | ${parsed.pathname} |\n| --- | --- |\n`);
|
|
});
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const addr = server.address() as AddressInfo;
|
|
|
|
prevApiUrl = process.env.API_URL;
|
|
prevKey = process.env.HANZO_API_KEY;
|
|
process.env.API_URL = `http://127.0.0.1:${addr.port}`;
|
|
process.env.HANZO_API_KEY = 'test-key';
|
|
});
|
|
|
|
afterAll(async () => {
|
|
process.env.API_URL = prevApiUrl;
|
|
process.env.HANZO_API_KEY = prevKey;
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
});
|
|
|
|
const last = () => recorded[recorded.length - 1];
|
|
|
|
describe('code-intelligence tools', () => {
|
|
test('exports exactly the four endpoints', () => {
|
|
expect(codeIntelTools.map((t) => t.name)).toEqual([
|
|
'code_search', 'code_context', 'code_ask', 'code_index',
|
|
]);
|
|
});
|
|
|
|
test('metadata: names and required inputs', () => {
|
|
expect(codeSearchTool.inputSchema.required).toEqual(['q']);
|
|
expect(codeContextTool.inputSchema.required).toEqual(['query']);
|
|
expect(codeAskTool.inputSchema.required).toEqual(['q']);
|
|
expect(codeIndexTool.inputSchema.required).toEqual(['repo', 'files']);
|
|
});
|
|
|
|
test('code_search: GET /v1/code/search with query, markdown, Bearer', async () => {
|
|
const result = await codeSearchTool.handler({ q: 'round signer', type: 'hybrid', repo: 'lux/consensus', limit: 5 });
|
|
expect(result.isError).toBeFalsy();
|
|
expect(result.content[0].text).toContain('/v1/code/search');
|
|
|
|
const req = last();
|
|
expect(req.method).toBe('GET');
|
|
expect(req.pathname).toBe('/v1/code/search');
|
|
expect(req.query.get('q')).toBe('round signer');
|
|
expect(req.query.get('type')).toBe('hybrid');
|
|
expect(req.query.get('repo')).toBe('lux/consensus');
|
|
expect(req.query.get('limit')).toBe('5');
|
|
expect(req.query.get('format')).toBe('md');
|
|
expect(req.headers['authorization']).toBe('Bearer test-key');
|
|
expect(req.headers['accept']).toBe('text/markdown');
|
|
});
|
|
|
|
test('code_search: never sends an org param', async () => {
|
|
await codeSearchTool.handler({ q: 'anything', owner: 'evil-org', org: 'evil-org', organization: 'evil-org' });
|
|
const req = last();
|
|
expect(req.query.get('owner')).toBeNull();
|
|
expect(req.query.get('org')).toBeNull();
|
|
expect(req.query.get('organization')).toBeNull();
|
|
});
|
|
|
|
test('code_context: POST /v1/code/context with JSON body', async () => {
|
|
const result = await codeContextTool.handler({ query: 'how does auth work', repo: 'hanzo/iam', budgetTokens: 8000 });
|
|
expect(result.isError).toBeFalsy();
|
|
|
|
const req = last();
|
|
expect(req.method).toBe('POST');
|
|
expect(req.pathname).toBe('/v1/code/context');
|
|
expect(req.query.get('format')).toBe('md');
|
|
expect(JSON.parse(req.body)).toEqual({ query: 'how does auth work', repo: 'hanzo/iam', budgetTokens: 8000 });
|
|
expect(req.headers['authorization']).toBe('Bearer test-key');
|
|
});
|
|
|
|
test('code_ask: GET /v1/code/ask', async () => {
|
|
const result = await codeAskTool.handler({ q: 'where is the principal gate', repo: 'hanzo/cloud' });
|
|
expect(result.isError).toBeFalsy();
|
|
|
|
const req = last();
|
|
expect(req.method).toBe('GET');
|
|
expect(req.pathname).toBe('/v1/code/ask');
|
|
expect(req.query.get('q')).toBe('where is the principal gate');
|
|
expect(req.query.get('repo')).toBe('hanzo/cloud');
|
|
expect(req.query.get('format')).toBe('md');
|
|
});
|
|
|
|
test('code_index: POST /v1/code/index with files', async () => {
|
|
const files = [{ path: 'main.go', content: 'package main' }];
|
|
const result = await codeIndexTool.handler({ repo: 'hanzo/x', files, prune: true });
|
|
expect(result.isError).toBeFalsy();
|
|
|
|
const req = last();
|
|
expect(req.method).toBe('POST');
|
|
expect(req.pathname).toBe('/v1/code/index');
|
|
expect(JSON.parse(req.body)).toEqual({ repo: 'hanzo/x', files, prune: true });
|
|
});
|
|
|
|
test('non-2xx surfaces as isError with the status', async () => {
|
|
const result = await codeSearchTool.handler({ q: 'DENY' });
|
|
expect(result.isError).toBe(true);
|
|
expect(result.content[0].text).toContain('403');
|
|
});
|
|
|
|
test('missing required arg fails before any network call', async () => {
|
|
const before = recorded.length;
|
|
const result = await codeSearchTool.handler({});
|
|
expect(result.isError).toBe(true);
|
|
expect(result.content[0].text).toContain('q required');
|
|
expect(recorded.length).toBe(before);
|
|
});
|
|
|
|
test('missing token fails with a clear error', async () => {
|
|
const saved = process.env.HANZO_API_KEY;
|
|
delete process.env.HANZO_API_KEY;
|
|
try {
|
|
const before = recorded.length;
|
|
const result = await codeAskTool.handler({ q: 'anything' });
|
|
expect(result.isError).toBe(true);
|
|
expect(result.content[0].text).toContain('HANZO_API_KEY required');
|
|
expect(recorded.length).toBe(before);
|
|
} finally {
|
|
process.env.HANZO_API_KEY = saved;
|
|
}
|
|
});
|
|
});
|