morphit/apps/mcp-server/scripts/mcp-server-smoke.ts

440 lines
13 KiB
TypeScript

/**
* Smoke test for morphit-mcp.
*
* Boots the MCP server as a child process, speaks the stdio
* protocol directly (no SDK client — we want to verify the wire
* surface), and checks the five core invariants:
*
* 1. ListTools advertises exactly 5 tools with stable names
* 2. Each tool has a valid JSON Schema input
* 3. CallTool with a bogus name returns isError=true
* 4. CallTool with valid input but unreachable instance returns
* isError=true (with a useful error message)
* 5. CallTool with describe + a working instance URL (mocked
* via an HTTP stub) returns content with the expected fields
*
* The MCP-SDK Server uses JSON-RPC 2.0 framed by Content-Length
* headers when on stdio. The SDK takes care of framing, so we
* just send/receive JSON messages line by line.
*/
import { spawn, spawnSync } from 'node:child_process';
import { createServer } from 'node:http';
import { once } from 'node:events';
import { existsSync } from 'node:fs';
import { resolve as resolvePath } from 'node:path';
import process from 'node:process';
const ANSI_GREEN = '\x1b[32m';
const ANSI_RED = '\x1b[31m';
const ANSI_RESET = '\x1b[0m';
/**
* mcp-server is the only workspace in the repo whose `bin` field
* points into `dist/` (apps/mcp-server/package.json declares
* `bin.morphit-mcp = "dist/main.js"`). `dist/` is gitignored,
* so on a fresh checkout — including every CI run that does
* `actions/checkout` then `npm ci` — `dist/main.js` does NOT
* exist until something runs `npm run build`.
*
* Pre-cp142 this smoke spawned `node dist/main.js` directly. On
* a fresh checkout that child exits immediately with
* ERR_MODULE_NOT_FOUND, the smoke's stdin.write fires EPIPE
* (swallowed), the 5-second response-deadline loop times out
* with `responses = []`, and the smoke either:
* - emits 8 ✗ scenarios and exits 1 (in environments with
* plenty of memory / no concurrent smoke pressure), OR
* - hangs past the runner's per-smoke wall-clock and gets
* OOM-killed / signal-killed (in CI with concurrent jobs
* or in constrained sandboxes).
*
* Either way the smoke was a CI-time bomb whose only saving
* grace was that dev machines tend to keep dist/ on disk after
* the first `npm run build` and never blow it away.
*
* Fix: build dist/ lazily inside the smoke if it's missing.
* This makes the smoke self-healing on any fresh checkout AND
* keeps it production-faithful by still spawning the built
* artifact (the same `dist/main.js` that ships as the `bin`).
*/
function ensureBuilt(cwdPath: string): void {
const distMain = resolvePath(cwdPath, 'dist', 'main.js');
if (existsSync(distMain)) return;
console.log(' · dist/main.js missing — running `npm run build` …');
const build = spawnSync('npm', ['run', 'build'], {
cwd: cwdPath,
stdio: 'inherit'
});
if (build.status !== 0) {
console.error(` ${ANSI_RED}${ANSI_RESET} build failed (exit ${build.status})`);
console.error(' run `npm run build` in apps/mcp-server manually to debug');
process.exit(1);
}
if (!existsSync(distMain)) {
console.error(` ${ANSI_RED}${ANSI_RESET} build succeeded but dist/main.js still missing`);
process.exit(1);
}
}
interface JsonRpcRequest {
jsonrpc: '2.0';
id: number;
method: string;
params?: unknown;
}
interface JsonRpcResponse {
jsonrpc: '2.0';
id: number;
result?: unknown;
error?: { code: number; message: string };
}
interface ScenarioResult {
name: string;
passed: boolean;
detail?: string;
}
const results: ScenarioResult[] = [];
function pass(name: string) {
results.push({ name, passed: true });
}
function fail(name: string, detail: string) {
results.push({ name, passed: false, detail });
}
/** Run an MCP server child process, send the request list, and
* collect the responses. */
async function runMcpDialog(
requests: JsonRpcRequest[],
instanceUrl: string
): Promise<JsonRpcResponse[]> {
const child = spawn('node', ['dist/main.js'], {
cwd: new URL('..', import.meta.url).pathname,
env: {
...process.env,
MORPHIT_MCP_INSTANCE_URL: instanceUrl,
// cp154 F-mcp-1 — opt into the private-address denylist
// override because this smoke runs a local HTTP server
// on 127.0.0.1 to stub the indexer. Real users running
// against morphit.io don't need this env var; it's
// dev/test infrastructure.
MORPHIT_MCP_ALLOW_PRIVATE_INSTANCE: '1'
},
stdio: ['pipe', 'pipe', 'pipe']
});
const responses: JsonRpcResponse[] = [];
let buf = '';
child.stdout.on('data', (chunk: Buffer) => {
buf += chunk.toString('utf-8');
// MCP stdio uses newline-delimited JSON (NDJSON), one
// message per line.
while (true) {
const nl = buf.indexOf('\n');
if (nl === -1) return;
const line = buf.slice(0, nl).replace(/\r$/, '');
buf = buf.slice(nl + 1);
if (!line.trim()) continue;
try {
responses.push(JSON.parse(line));
} catch {
// ignore non-JSON lines (debug output, etc.)
}
}
});
// Initialize handshake (required by the MCP protocol).
const initReq: JsonRpcRequest = {
jsonrpc: '2.0',
id: 0,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'smoke-test', version: '0.0.0' }
}
};
for (const req of [initReq, ...requests]) {
child.stdin.write(JSON.stringify(req) + '\n');
}
// Wait up to 5s for all requests + initialize to respond.
const deadline = Date.now() + 5_000;
while (responses.length < requests.length + 1 && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 50));
}
child.kill('SIGTERM');
try {
await once(child, 'exit');
} catch {
// ignore
}
// Drop the initialize response, return only the test ones.
return responses.filter((r) => r.id !== 0);
}
async function main() {
console.log('── morphit-mcp smoke ───────────────────────────────────');
// Ensure dist/main.js exists before any runMcpDialog() call
// tries to spawn it. See the ensureBuilt() docblock above
// for the failure-mode this guards against (cp142 LL #1).
const serverCwd = new URL('..', import.meta.url).pathname;
ensureBuilt(serverCwd);
// Stand up a stub indexer for tests that need a working instance.
const stub = createServer((req, res) => {
if (req.url === '/v1/instance') {
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({
display_name: 'smoke-test instance',
contact_url: 'mailto:smoke@example.org',
declared_region: 'us-east'
})
);
} else if (req.url?.startsWith('/v1/orderbook')) {
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({
rows: [
{
account: 'alice',
permlink: 'sell-xmr-cash-001',
asset: 'XMR',
side: 'sell',
fiat_currency: 'USD',
price: '152.40',
amount_min: '0.5',
amount_max: '5.0',
location_region: 'US-CA',
payment_methods: 'cash,bank_transfer',
feedback_count: 12,
weighted_rating: 4.8,
is_new_trader: false,
updated_at: '2026-05-26T00:00:00Z',
// fields that should be trimmed:
internal_indexer_seq: 999,
row_hash: 'deadbeef'
}
],
next_cursor: null
})
);
} else {
res.statusCode = 404;
res.end('not found');
}
});
stub.listen(0, '127.0.0.1');
await once(stub, 'listening');
const addr = stub.address();
if (!addr || typeof addr === 'string') throw new Error('stub no addr');
const stubUrl = `http://127.0.0.1:${addr.port}`;
// Scenario 1: ListTools advertises exactly 5 tools.
const listResp = await runMcpDialog(
[{ jsonrpc: '2.0', id: 1, method: 'tools/list' }],
stubUrl
);
const tools = (listResp[0]?.result as { tools?: Array<{ name: string }> })?.tools || [];
const expected = new Set([
'morphit_search_orders',
'morphit_list_instances',
'morphit_list_payment_methods',
'morphit_get_listing',
'morphit_describe'
]);
if (tools.length === 5 && tools.every((t) => expected.has(t.name))) {
pass('ListTools advertises exactly the 5 expected tools');
} else {
fail(
'ListTools advertises exactly the 5 expected tools',
`got ${tools.length}: ${tools.map((t) => t.name).join(',')}`
);
}
// Scenario 2: each tool input schema declares "type":"object".
const allObject = (listResp[0]?.result as { tools?: Array<{ inputSchema?: { type?: string } }> })?.tools?.every(
(t) => t.inputSchema?.type === 'object'
);
if (allObject) {
pass('every tool inputSchema is type=object');
} else {
fail('every tool inputSchema is type=object', 'one or more missing');
}
// Scenario 3: bogus tool name → isError=true.
const bogus = await runMcpDialog(
[
{
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'morphit_does_not_exist', arguments: {} }
}
],
stubUrl
);
const bogusResult = bogus[0]?.result as { isError?: boolean } | undefined;
if (bogusResult?.isError === true) {
pass('bogus tool name returns isError=true');
} else {
fail('bogus tool name returns isError=true', JSON.stringify(bogus[0]));
}
// Scenario 4: unreachable instance → isError=true with message.
const unreachable = await runMcpDialog(
[
{
jsonrpc: '2.0',
id: 3,
method: 'tools/call',
params: { name: 'morphit_search_orders', arguments: { asset: 'XMR' } }
}
],
'http://127.0.0.1:1' // port 1 is reserved; should fail fast
);
const unreachResult = unreachable[0]?.result as
| { isError?: boolean; content?: Array<{ text?: string }> }
| undefined;
if (unreachResult?.isError === true && unreachResult.content?.[0]?.text) {
pass('unreachable instance returns isError with diagnostic');
} else {
fail(
'unreachable instance returns isError with diagnostic',
JSON.stringify(unreachable[0])
);
}
// Scenario 5: working stub + describe returns expected shape.
const describe = await runMcpDialog(
[
{
jsonrpc: '2.0',
id: 4,
method: 'tools/call',
params: { name: 'morphit_describe', arguments: {} }
}
],
stubUrl
);
const descResult = describe[0]?.result as
| { content?: Array<{ text?: string }> }
| undefined;
const descText = descResult?.content?.[0]?.text;
if (
descText &&
descText.includes('"non_custodial": true') &&
descText.includes('"kyc_required": false') &&
descText.includes('"federated": true') &&
descText.includes('"on_chain": "Blurt"') &&
descText.includes('"supported_assets"')
) {
pass('describe returns expected fields');
} else {
fail('describe returns expected fields', descText?.slice(0, 200) || 'no text');
}
// Scenario 6: searchOrders trims internal fields.
const search = await runMcpDialog(
[
{
jsonrpc: '2.0',
id: 5,
method: 'tools/call',
params: { name: 'morphit_search_orders', arguments: { asset: 'XMR' } }
}
],
stubUrl
);
const searchResult = search[0]?.result as
| { content?: Array<{ text?: string }> }
| undefined;
const searchText = searchResult?.content?.[0]?.text;
if (
searchText &&
searchText.includes('"account": "alice"') &&
searchText.includes('"asset": "XMR"') &&
!searchText.includes('internal_indexer_seq') &&
!searchText.includes('row_hash') &&
searchText.includes('"deeplink":')
) {
pass('search trims internal fields and emits deeplink');
} else {
fail(
'search trims internal fields and emits deeplink',
searchText?.slice(0, 300) || 'no text'
);
}
// Scenario 7: invalid input (bad enum) → isError.
const badInput = await runMcpDialog(
[
{
jsonrpc: '2.0',
id: 6,
method: 'tools/call',
params: {
name: 'morphit_search_orders',
arguments: { asset: 'DOGE_FAKE' }
}
}
],
stubUrl
);
const badResult = badInput[0]?.result as { isError?: boolean } | undefined;
if (badResult?.isError === true) {
pass('invalid asset enum rejected with isError');
} else {
fail('invalid asset enum rejected with isError', JSON.stringify(badInput[0]));
}
// Scenario 8: deeplink is well-formed URL routed through the
// cp156 `?then=` locale-detection shell with filter preserved.
// Inner path-with-query gets URI-encoded inside the `then`
// value: `?then=%2Forderbook%3Fasset%3DXMR`.
if (
searchText &&
/"deeplink":\s*"http:\/\/127\.0\.0\.1:\d+\/\?then=%2Forderbook%3Fasset%3DXMR"/.test(searchText)
) {
pass('deeplink is well-formed ?then= URL with filter preserved');
} else {
fail(
'deeplink is well-formed ?then= URL with filter preserved',
searchText?.match(/"deeplink":\s*"[^"]+"/)?.[0] || 'no deeplink'
);
}
stub.close();
// Report.
console.log('');
for (const r of results) {
const mark = r.passed ? `${ANSI_GREEN}${ANSI_RESET}` : `${ANSI_RED}${ANSI_RESET}`;
console.log(` ${mark} ${r.name}`);
if (!r.passed && r.detail) {
console.log(` ${r.detail}`);
}
}
const failed = results.filter((r) => !r.passed).length;
console.log('');
console.log('──────────────────────────────────────────────────────');
if (failed === 0) {
console.log(`✓ all ${results.length} scenarios passed`);
} else {
console.log(`${failed}/${results.length} scenarios failed`);
process.exit(1);
}
}
main().catch((err) => {
console.error('smoke fatal:', err);
process.exit(1);
});