UNPKG

@ansvar/singapore-law-mcp

Version:

Complete Singapore law database — 523 Acts, 28K+ provisions from Singapore Statutes Online (sso.agc.gov.sg) with full-text search, definitions, and citation support

215 lines 9.65 kB
import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { createHash } from 'node:crypto'; import { readFileSync, rmdirSync, rmSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import Database from '@ansvar/mcp-sqlite'; import { registerTools } from '../../src/tools/registry.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); function normalizeText(text) { return text.replace(/\s+/g, ' ').replace(/[\r\n]+/g, ' ').trim().toLowerCase(); } function sha256(text) { return createHash('sha256').update(normalizeText(text)).digest('hex'); } function extractCitationUrls(data) { const urls = []; const text = JSON.stringify(data); const urlRegex = /https?:\/\/[^\s"'<>]+/g; let match; while ((match = urlRegex.exec(text)) !== null) { urls.push(match[0]); } return urls; } async function fetchWithTimeout(url, timeoutMs = 10_000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { signal: controller.signal }); } finally { clearTimeout(timer); } } function stringifyData(data) { if (typeof data === 'string') return data; return JSON.stringify(data, null, 0) ?? ''; } async function callTool(mcpClient, name, args) { try { const result = await mcpClient.callTool({ name, arguments: args }); const content = result.content; const text = content?.[0]?.text ?? ''; if (result.isError) { return { tool: name, ok: false, error: { code: 'TOOL_ERROR', message: text } }; } try { const data = JSON.parse(text); return { tool: name, ok: true, data }; } catch { return { tool: name, ok: true, data: text }; } } catch (err) { return { tool: name, ok: false, error: { code: 'CALL_ERROR', message: err.message }, }; } } // --------------------------------------------------------------------------- // Load fixtures & set up MCP client/server // --------------------------------------------------------------------------- const fixturesPath = join(__dirname, '..', '..', 'fixtures', 'golden-tests.json'); const fixtureContent = readFileSync(fixturesPath, 'utf-8'); const fixture = JSON.parse(fixtureContent); const isNightly = process.env['CONTRACT_MODE'] === 'nightly'; let mcpClient; let db; // --------------------------------------------------------------------------- // Contract test runner // --------------------------------------------------------------------------- describe(`Contract tests: ${fixture.mcp_name}`, () => { beforeAll(async () => { const dbPath = process.env['SG_LAW_DB_PATH'] ?? join(__dirname, '..', '..', 'data', 'database.db'); // Clean up stale lock dir and WAL files try { rmdirSync(dbPath + '.lock'); } catch { /* ignore */ } try { rmSync(dbPath + '-wal', { force: true }); } catch { /* ignore */ } try { rmSync(dbPath + '-shm', { force: true }); } catch { /* ignore */ } db = new Database(dbPath, { readonly: true }); db.pragma('foreign_keys = ON'); const server = new Server({ name: 'singapore-law-test', version: '0.0.0' }, { capabilities: { tools: {} } }); registerTools(server, db); mcpClient = new Client({ name: 'test-client', version: '0.0.0' }, { capabilities: {} }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); await mcpClient.connect(clientTransport); }, 30_000); afterAll(() => { db?.close(); }); for (const test of fixture.tests) { describe(`[${test.id}] ${test.description}`, () => { let result; it('runs without throwing', async () => { result = await callTool(mcpClient, test.tool, test.input); expect(result).toBeDefined(); expect(result.tool).toBe(test.tool); }); if (test.assertions.result_not_empty) { it('result is not empty', async () => { result ??= await callTool(mcpClient, test.tool, test.input); if (result.ok) { expect(result.data).toBeDefined(); } else { expect(result.error).toBeDefined(); } }); } if (test.assertions.text_contains) { for (const needle of test.assertions.text_contains) { it(`result contains text "${needle}"`, async () => { result ??= await callTool(mcpClient, test.tool, test.input); const haystack = stringifyData(result.data).toLowerCase(); expect(haystack).toContain(needle.toLowerCase()); }); } } if (test.assertions.any_result_contains) { for (const needle of test.assertions.any_result_contains) { it(`any result item contains "${needle}"`, async () => { result ??= await callTool(mcpClient, test.tool, test.input); const haystack = stringifyData(result.data).toLowerCase(); expect(haystack).toContain(needle.toLowerCase()); }); } } if (test.assertions.fields_present) { it(`result has fields: ${test.assertions.fields_present.join(', ')}`, async () => { result ??= await callTool(mcpClient, test.tool, test.input); expect(result.ok).toBe(true); const data = result.data; expect(data).toBeDefined(); for (const field of test.assertions.fields_present) { expect(data).toHaveProperty(field); } }); } if (test.assertions.text_not_empty) { it('result text is not empty', async () => { result ??= await callTool(mcpClient, test.tool, test.input); const text = stringifyData(result.data); expect(text.trim().length).toBeGreaterThan(0); }); } if (test.assertions.min_results !== undefined) { it(`returns at least ${test.assertions.min_results} results`, async () => { result ??= await callTool(mcpClient, test.tool, test.input); const data = result.data; const items = Array.isArray(data) ? data : Array.isArray(data?.results) ? data.results : []; expect(items.length).toBeGreaterThanOrEqual(test.assertions.min_results); }); } if (test.assertions.citation_url_pattern) { it(`citation URLs match pattern: ${test.assertions.citation_url_pattern}`, async () => { result ??= await callTool(mcpClient, test.tool, test.input); const urls = extractCitationUrls(result.data); const pattern = new RegExp(test.assertions.citation_url_pattern); expect(urls.length).toBeGreaterThan(0); for (const url of urls) { expect(url).toMatch(pattern); } }); } if (test.assertions.upstream_text_hash) { const hashAssertion = test.assertions.upstream_text_hash; it.skipIf(!isNightly)(`upstream text hash matches for ${hashAssertion.url}`, async () => { const response = await fetchWithTimeout(hashAssertion.url); expect(response.ok).toBe(true); const body = await response.text(); const hash = sha256(body); expect(hash).toBe(hashAssertion.expected_sha256); }, 30_000); } if (test.assertions.citation_resolves) { it.skipIf(!isNightly)('citation URLs resolve (HTTP 200)', async () => { result ??= await callTool(mcpClient, test.tool, test.input); const urls = extractCitationUrls(result.data); expect(urls.length).toBeGreaterThan(0); for (const url of urls) { const response = await fetchWithTimeout(url); expect(response.ok, `Expected HTTP 200 for ${url}, got ${response.status}`).toBe(true); } }, 60_000); } if (test.assertions.handles_gracefully) { it('handles gracefully (no unhandled exception)', async () => { result ??= await callTool(mcpClient, test.tool, test.input); expect(result.tool).toBe(test.tool); }); } }); } }); //# sourceMappingURL=golden.test.js.map