diff --git a/havre/Dockerfile b/havre/Dockerfile new file mode 100644 index 0000000..ca59313 --- /dev/null +++ b/havre/Dockerfile @@ -0,0 +1,7 @@ +FROM node:22-alpine +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev +COPY src ./src +USER node +CMD ["node", "src/http.js"] diff --git a/havre/fly.toml b/havre/fly.toml new file mode 100644 index 0000000..35110ef --- /dev/null +++ b/havre/fly.toml @@ -0,0 +1,25 @@ +# Runs on the existing green-tea-mcp app. Deploy: cd havre && fly deploy (needs FLY_API_TOKEN). +app = "green-tea-mcp" + +[build] + +[env] + PORT = "8080" + +[http_service] + internal_port = 8080 + force_https = true + auto_stop_machines = "stop" # stateless, so idle machines can stop: no cost while unused + auto_start_machines = true + min_machines_running = 0 + + [[http_service.checks]] + method = "GET" + path = "/health" + interval = "30s" + timeout = "5s" + grace_period = "10s" + +[[vm]] + size = "shared-cpu-1x" + memory = "256mb" diff --git a/havre/package.json b/havre/package.json index 8722fff..0875d02 100644 --- a/havre/package.json +++ b/havre/package.json @@ -6,6 +6,7 @@ "description": "Havre MCP server. Stateless: no disk, no db, no stored secrets.", "scripts": { "start": "node src/index.js", + "start:http": "node src/http.js", "test": "node --test" }, "dependencies": { diff --git a/havre/src/http.js b/havre/src/http.js new file mode 100644 index 0000000..41b1024 --- /dev/null +++ b/havre/src/http.js @@ -0,0 +1,26 @@ +import { createServer as createHttp } from "node:http"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { createServer } from "./server.js"; + +// Stateless streamable HTTP: a fresh MCP server + transport per request, nothing kept between requests. +export function createHttpServer() { + return createHttp(async (req, res) => { + const path = new URL(req.url, "http://x").pathname; + if (path === "/health") return res.writeHead(200, { "content-type": "text/plain" }).end("ok"); + if (path !== "/mcp") return res.writeHead(404).end(); + if (req.method !== "POST") return res.writeHead(405, { allow: "POST" }).end(); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); + const server = createServer(); + res.on("close", () => { transport.close(); server.close(); }); + try { + await server.connect(transport); + await transport.handleRequest(req, res); + } catch { + if (!res.headersSent) res.writeHead(500).end(); + } + }); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + createHttpServer().listen(Number(process.env.PORT ?? 8080), "0.0.0.0"); +} diff --git a/havre/test/http.test.js b/havre/test/http.test.js new file mode 100644 index 0000000..1100609 --- /dev/null +++ b/havre/test/http.test.js @@ -0,0 +1,22 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { createHttpServer } from "../src/http.js"; + +test("health check and MCP over streamable HTTP", async () => { + const http = createHttpServer().listen(0); + await new Promise((r) => http.once("listening", r)); + const base = `http://127.0.0.1:${http.address().port}`; + const client = new Client({ name: "test", version: "0.0.0" }); + try { + assert.equal((await fetch(`${base}/health`)).status, 200); + assert.equal((await fetch(`${base}/nope`)).status, 404); + await client.connect(new StreamableHTTPClientTransport(new URL(`${base}/mcp`))); + assert.equal(client.getServerVersion().name, "havre"); + assert.equal((await client.callTool({ name: "havre_ping", arguments: { message: "hi" } })).content[0].text, "hi"); + } finally { + await client.close(); + http.close(); + } +});