#!/usr/bin/env node
import express from "express";
import cors from "cors";
import rateLimit from "express-rate-limit";
import { randomUUID } from "crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { createServer } from "./server.js";
const app = express();
app.set("trust proxy", true);
app.use(cors({
origin: "*",
methods: ["GET", "POST", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "mcp-session-id"],
exposedHeaders: ["mcp-session-id"]
}));
app.use(express.json());
// Logging middleware
app.use((req, res, next) => {
const start = Date.now();
res.on("finish", () => {
const duration = Date.now() - start;
console.log(`${new Date().toISOString()} ${req.method} ${req.path} ${res.statusCode} ${duration}ms`);
});
if (req.method === "POST" && req.body) {
console.log("Body:", JSON.stringify(req.body).substring(0, 200));
}
next();
});
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 1000,
message: { error: "Too many requests, try again later" },
standardHeaders: true,
legacyHeaders: false,
validate: { trustProxy: false }
});
app.use(limiter);
// Root handler
app.get("/", (req, res) => {
res.send(`
<h1>Apple Shortcuts MCP Server</h1>
<p>Status: Running</p>
<p>Endpoints:</p>
<ul>
<li>Streamable HTTP: <code>/mcp</code> (Recommended)</li>
<li>SSE: <code>/sse</code> (Backwards compatibility)</li>
<li>Health: <code>/health</code></li>
</ul>
`);
});
// Streamable HTTP sessions
const transports = new Map<string, { transport: StreamableHTTPServerTransport; lastUsed: number }>();
const sseTransports = new Map<string, { transport: SSEServerTransport; lastUsed: number }>();
const server = createServer();
const SESSION_TIMEOUT = 30 * 60 * 1000;
const CLEANUP_INTERVAL = 5 * 60 * 1000;
setInterval(() => {
const now = Date.now();
for (const [id, session] of transports.entries()) {
if (now - session.lastUsed > SESSION_TIMEOUT) {
session.transport.close().catch(() => {});
transports.delete(id);
}
}
for (const [id, session] of sseTransports.entries()) {
if (now - session.lastUsed > SESSION_TIMEOUT) {
session.transport.close().catch(() => {});
sseTransports.delete(id);
}
}
}, CLEANUP_INTERVAL);
// SSE endpoint
app.get("/sse", async (req, res) => {
const transport = new SSEServerTransport("/messages", res);
const sessionId = transport.sessionId;
sseTransports.set(sessionId, { transport, lastUsed: Date.now() });
transport.onclose = () => {
sseTransports.delete(sessionId);
};
await server.connect(transport);
await transport.start();
console.log(`New SSE session: ${sessionId}`);
});
app.post("/messages", async (req, res) => {
const sessionId = req.query.sessionId as string;
const session = sseTransports.get(sessionId);
if (session) {
session.lastUsed = Date.now();
await session.transport.handlePostMessage(req, res);
} else {
res.status(400).json({ error: "No SSE session found", sessionId: sessionId || "missing" });
}
});
// Streamable HTTP endpoint
app.post("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
if (sessionId && transports.has(sessionId)) {
const session = transports.get(sessionId)!;
session.lastUsed = Date.now();
await session.transport.handleRequest(req, res, req.body);
} else if (isInitializeRequest(req.body)) {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
enableJsonResponse: true, // Recommended for better compatibility with standard HTTP clients
onsessioninitialized: (id) => {
transports.set(id, { transport, lastUsed: Date.now() });
console.log(`New Streamable HTTP session: ${id}`);
},
onsessionclosed: (id) => {
transports.delete(id);
}
});
transport.onclose = () => {
if (transport.sessionId) transports.delete(transport.sessionId);
};
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} else {
// Check if this is a notification (no id)
if (req.body && typeof req.body === "object" && !("id" in req.body)) {
// Notifications should return 202 Accepted
res.status(202).end();
return;
}
res.status(400).json({
jsonrpc: "2.0",
error: { code: -32000, message: "Invalid session or missing initialization" },
id: req.body?.id || null
});
}
});
app.get("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string;
if (!sessionId) {
// Basic info for discovery/health checks
res.json({
mcp: "supported",
transport: "streamable-http",
message: "Send a POST request with an initialization message to start a session."
});
return;
}
const session = transports.get(sessionId);
if (!session) {
res.status(400).json({ error: "No session found", sessionId });
return;
}
session.lastUsed = Date.now();
await session.transport.handleRequest(req, res);
});
app.delete("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string;
const session = transports.get(sessionId);
if (session) {
await session.transport.close();
transports.delete(sessionId);
}
res.status(200).json({ message: "Session closed" });
});
app.get("/health", (_, res) => {
res.json({
status: "ok",
sessions: Array.from(transports.keys()),
sseSessions: Array.from(sseTransports.keys()),
time: new Date().toISOString()
});
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Apple Shortcuts MCP Server running on port ${PORT}`);
});