diff --git a/src/mcp-server.js b/src/mcp-server.js index 5c0444b..24cbfa5 100644 --- a/src/mcp-server.js +++ b/src/mcp-server.js @@ -1,4 +1,5 @@ import { compile, analyze } from "./compile.js"; +import { pathToFileURL } from "node:url"; const API_VERSION = "0.3.0"; const SCHEMA_VERSION = "1.0.0"; @@ -38,7 +39,7 @@ function decodePayload(value) { throw new Error("payload must be a JSON object or JSON string"); } -function toolListResult() { +export function toolListResult() { return { tools: [ { @@ -89,7 +90,7 @@ function toolListResult() { }; } -function withEnvelopeMeta(payload) { +export function withEnvelopeMeta(payload) { return { api_version: API_VERSION, schema_version: SCHEMA_VERSION, @@ -97,7 +98,7 @@ function withEnvelopeMeta(payload) { }; } -function uiBundleDescriptor() { +export function uiBundleDescriptor() { return { name: "schemeta-workspace", version: "0.2.0", @@ -109,7 +110,7 @@ function uiBundleDescriptor() { }; } -function handleToolCall(name, args) { +export function handleToolCall(name, args) { if (!args || typeof args !== "object") { args = {}; } @@ -146,7 +147,7 @@ function handleToolCall(name, args) { throw new Error(`Unknown tool '${name}'`); } -function handleRequest(message) { +export function handleRequest(message) { const { id, method, params } = message; if (method === "initialize") { @@ -226,13 +227,16 @@ function tryParseBuffer() { } } -process.stdin.on("data", (chunk) => { - stdinBuffer = Buffer.concat([stdinBuffer, Buffer.from(chunk)]); - tryParseBuffer(); -}); +const isMain = process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false; +if (isMain) { + process.stdin.on("data", (chunk) => { + stdinBuffer = Buffer.concat([stdinBuffer, Buffer.from(chunk)]); + tryParseBuffer(); + }); -process.stdin.on("error", (err) => { - process.stderr.write(`stdin error: ${String(err)}\n`); -}); + process.stdin.on("error", (err) => { + process.stderr.write(`stdin error: ${String(err)}\n`); + }); -process.stdin.resume(); + process.stdin.resume(); +} diff --git a/src/server.js b/src/server.js index 1bb7d78..f6151e5 100644 --- a/src/server.js +++ b/src/server.js @@ -1,6 +1,7 @@ import { createServer } from "node:http"; import { readFile } from "node:fs/promises"; import { extname, join, normalize } from "node:path"; +import { pathToFileURL } from "node:url"; import { analyze, compile } from "./compile.js"; import { applyLayoutToModel } from "./layout.js"; import { validateModel } from "./validate.js"; @@ -10,8 +11,8 @@ const MAX_BODY_BYTES = Number(process.env.MAX_BODY_BYTES ?? 2 * 1024 * 1024); const CORS_ORIGIN = process.env.CORS_ORIGIN ?? "*"; const MAX_REQUESTS_PER_MINUTE = Number(process.env.MAX_REQUESTS_PER_MINUTE ?? 120); const FRONTEND_ROOT = join(process.cwd(), "frontend"); -const API_VERSION = "0.3.0"; -const SCHEMA_VERSION = "1.0.0"; +export const API_VERSION = "0.3.0"; +export const SCHEMA_VERSION = "1.0.0"; const RATE_WINDOW_MS = 60_000; const rateByClient = new Map(); @@ -36,7 +37,7 @@ function json(res, status, payload) { res.end(JSON.stringify(payload, null, 2)); } -function errorEnvelope(code, message) { +export function errorEnvelope(code, message) { return { ok: false, error: { @@ -114,7 +115,7 @@ async function serveStatic(urlPath, res) { } } -function parsePayloadOptions(body) { +export function parsePayloadOptions(body) { if (body && typeof body === "object" && Object.prototype.hasOwnProperty.call(body, "payload")) { return { payload: body.payload, @@ -128,7 +129,7 @@ function parsePayloadOptions(body) { }; } -function withEnvelopeMeta(payload) { +export function withEnvelopeMeta(payload) { return { api_version: API_VERSION, schema_version: SCHEMA_VERSION, @@ -172,7 +173,8 @@ function withinRateLimit(req) { return true; } -const server = createServer(async (req, res) => { +export function createRequestHandler() { + return async (req, res) => { if (!req.url || !req.method) { return json(res, 400, errorEnvelope("invalid_request", "Invalid request.")); } @@ -312,8 +314,18 @@ const server = createServer(async (req, res) => { } return json(res, 404, errorEnvelope("not_found", "Not found.")); -}); + }; +} -server.listen(PORT, () => { - console.log(`Schemeta server listening on http://localhost:${PORT}`); -}); +export const server = createServer(createRequestHandler()); + +export function startServer(port = PORT) { + server.listen(port, () => { + console.log(`Schemeta server listening on http://localhost:${port}`); + }); +} + +const isMain = process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false; +if (isMain) { + startServer(PORT); +} diff --git a/tests/api-contract.test.js b/tests/api-contract.test.js new file mode 100644 index 0000000..fef6009 --- /dev/null +++ b/tests/api-contract.test.js @@ -0,0 +1,85 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fixture from "../examples/esp32-audio.json" with { type: "json" }; +import { compile, analyze } from "../src/compile.js"; +import { + API_VERSION as REST_API_VERSION, + SCHEMA_VERSION as REST_SCHEMA_VERSION, + withEnvelopeMeta as withRestEnvelopeMeta, + parsePayloadOptions, + errorEnvelope +} from "../src/server.js"; +import { + handleToolCall, + uiBundleDescriptor, + toolListResult +} from "../src/mcp-server.js"; + +test("REST compile contract shape is stable with version metadata", () => { + const parsed = parsePayloadOptions({ payload: fixture, options: { render_mode: "schematic_stub" } }); + const body = withRestEnvelopeMeta(compile(parsed.payload, parsed.options)); + + assert.equal(body.ok, true); + assert.equal(body.api_version, REST_API_VERSION); + assert.equal(body.schema_version, REST_SCHEMA_VERSION); + assert.ok(Array.isArray(body.errors)); + assert.ok(Array.isArray(body.warnings)); + assert.ok(Array.isArray(body.bus_groups)); + assert.equal(typeof body.render_mode_used, "string"); + assert.equal(typeof body.svg, "string"); +}); + +test("REST analyze contract shape is stable with version metadata", () => { + const parsed = parsePayloadOptions({ payload: fixture }); + const body = withRestEnvelopeMeta(analyze(parsed.payload, parsed.options)); + + assert.equal(body.ok, true); + assert.equal(body.api_version, REST_API_VERSION); + assert.equal(body.schema_version, REST_SCHEMA_VERSION); + assert.ok(body.topology); + assert.ok(Array.isArray(body.topology.power_domains)); + assert.ok(Array.isArray(body.topology.signal_paths)); +}); + +test("REST error envelope exposes stable code/message fields", () => { + const body = errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly."); + assert.equal(body.ok, false); + assert.equal(body.error.code, "rate_limited"); + assert.equal(typeof body.error.message, "string"); +}); + +test("MCP schemeta_compile returns structured content with version metadata", () => { + const result = handleToolCall("schemeta_compile", { payload: fixture }); + const body = result.structuredContent; + + assert.equal(result.isError, false); + assert.equal(body.ok, true); + assert.equal(typeof body.api_version, "string"); + assert.equal(typeof body.schema_version, "string"); + assert.equal(typeof body.svg, "string"); +}); + +test("MCP schemeta_analyze returns structured topology fields", () => { + const result = handleToolCall("schemeta_analyze", { payload: fixture }); + const body = result.structuredContent; + + assert.equal(result.isError, false); + assert.equal(body.ok, true); + assert.equal(typeof body.api_version, "string"); + assert.equal(typeof body.schema_version, "string"); + assert.ok(body.topology); + assert.ok(Array.isArray(body.topology.power_domains)); +}); + +test("MCP UI bundle and tool list contracts remain stable", () => { + const ui = uiBundleDescriptor(); + assert.equal(ui.transport, "iframe"); + assert.equal(typeof ui.api_version, "string"); + assert.equal(typeof ui.schema_version, "string"); + + const tools = toolListResult(); + assert.ok(Array.isArray(tools.tools)); + assert.ok(tools.tools.some((t) => t.name === "schemeta_compile")); + assert.ok(tools.tools.some((t) => t.name === "schemeta_analyze")); + assert.ok(tools.tools.some((t) => t.name === "schemeta_ui_bundle")); +});