server.ts•6.9 kB
import fs from "fs";
import path from "path";
import crypto from "crypto";
import express from "express";
import morgan from "morgan";
import cors from "cors";
import * as easymidi from "easymidi";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
const args = Object.fromEntries(
process.argv.slice(2).map((a) => {
const [k, v] = a.replace(/^--/, "").split("=");
return [k, v ?? "true"];
})
);
const MIDI_PORT = (args["midi-port-name"] as string) || "fm8";
const MIDI_CHANNEL = Math.max(1, Math.min(16, parseInt((args["channel"] as string) || "1", 10)));
const TRANSPORT = (args["transport"] as string) || "stdio";
const PORT = parseInt((args["port"] as string) || "3333", 10);
// Try to find mappings.json - first in cwd, then relative to this script
let mappingPath = path.resolve(process.cwd(), "mappings.json");
if (!fs.existsSync(mappingPath)) {
// If running from dist/server.js, go up to project root
mappingPath = path.resolve(path.dirname(process.argv[1]), "..", "mappings.json");
}
if (!fs.existsSync(mappingPath)) {
throw new Error(`Could not find mappings.json. Tried: ${process.cwd()}/mappings.json and ${path.dirname(process.argv[1])}/../mappings.json`);
}
const mapping = JSON.parse(fs.readFileSync(mappingPath, "utf-8")) as {
instrument: string;
section: string;
cc_map: Array<{
cc: number;
label: string;
source?: string;
dest?: string;
type?: string;
}>;
};
const byCC = new Map<number, (typeof mapping)["cc_map"][number]>();
const byRoute = new Map<string, (typeof mapping)["cc_map"][number]>();
for (const row of mapping.cc_map) {
byCC.set(row.cc, row);
if (row.source && row.dest) {
byRoute.set(`${row.source}->${row.dest}`.toUpperCase(), row);
}
}
let midi: easymidi.Output | null = null;
let midiError: string | null = null;
function openMidi() {
if (midi) return midi;
if (midiError) throw new Error(midiError);
try {
midi = new easymidi.Output(MIDI_PORT);
return midi;
} catch (error) {
midiError = `Failed to open MIDI port "${MIDI_PORT}". Make sure loopMIDI is running and the port exists. Error: ${error}`;
throw new Error(midiError);
}
}
function sendCC(cc: number, value: number, channel = MIDI_CHANNEL) {
const out = openMidi();
const clamped = Math.max(0, Math.min(127, Math.round(value)));
const midiChannel = (channel - 1) as easymidi.Channel;
// Log what we're about to send
console.error(`[MIDI SEND] CC=${cc} Value=${clamped} Channel=${midiChannel} (arg was ${channel})`);
(out as any).send("cc", { controller: cc, value: clamped, channel: midiChannel });
}
function panic(channel = MIDI_CHANNEL) {
const out = openMidi();
const ch = (channel - 1) as easymidi.Channel;
(out as any).send("cc", { controller: 121, value: 0, channel: ch });
(out as any).send("cc", { controller: 120, value: 0, channel: ch });
(out as any).send("cc", { controller: 123, value: 0, channel: ch });
}
const server = new McpServer({
name: "fm8-mcp-server",
version: "0.1.1",
description: "MCP server that maps ChatGPT tool calls to FM8 matrix CCs via loopMIDI"
});
server.registerTool(
"send_by_cc",
{
title: "Send raw CC",
description: "Send a raw MIDI CC (0-127) on the configured channel.",
inputSchema: {
cc: z.number().int().min(0).max(127),
value: z.number().int().min(0).max(127)
}
},
async ({ cc, value }) => {
const row = byCC.get(cc);
sendCC(cc, value);
const output = { ok: true, cc, value, mappedLabel: row?.label ?? null };
return { content: [{ type: "text", text: JSON.stringify(output) }], structuredContent: output };
}
);
server.registerTool(
"send_by_route",
{
title: "Send by route",
description: "Send a FM8 matrix route like A->B with a value (0-127).",
inputSchema: {
source: z.string(),
dest: z.string(),
value: z.number().int().min(0).max(127)
}
},
async ({ source, dest, value }) => {
console.error(`[TOOL CALL] send_by_route: source=${source}, dest=${dest}, value=${value}`);
const key = `${source}->${dest}`.toUpperCase();
const row = byRoute.get(key);
if (!row) throw new Error(`No mapping for route ${source}->${dest}`);
sendCC(row.cc, value);
const output = { ok: true, cc: row.cc, label: row.label };
return { content: [{ type: "text", text: JSON.stringify(output) }], structuredContent: output };
}
);
server.registerTool(
"list_mappings",
{
title: "List mappings",
description: "List all available FM8 mappings (route, label, cc).",
inputSchema: {}
},
async () => {
const output = mapping.cc_map.map((r) => ({ cc: r.cc, label: r.label, source: r.source, dest: r.dest, type: r.type }));
return { content: [{ type: "text", text: JSON.stringify(output) }], structuredContent: { mappings: output } };
}
);
server.registerTool(
"panic",
{
title: "Panic",
description: "Sends Reset All Controllers, All Sound Off, and All Notes Off.",
inputSchema: { channel: z.number().int().min(1).max(16).optional() }
},
async ({ channel }) => {
panic(channel ?? MIDI_CHANNEL);
const output = { ok: true };
return { content: [{ type: "text", text: JSON.stringify(output) }], structuredContent: output };
}
);
server.registerTool(
"status",
{
title: "Status",
description: "Report server status.",
inputSchema: {}
},
async () => {
const output = {
transport: TRANSPORT,
midiPort: MIDI_PORT,
channel: MIDI_CHANNEL,
instrument: mapping.instrument,
section: mapping.section,
routes: mapping.cc_map.length
};
return { content: [{ type: "text", text: JSON.stringify(output) }], structuredContent: output };
}
);
async function start() {
if (TRANSPORT === "stdio") {
const transport = new StdioServerTransport();
await server.connect(transport);
process.stdin.resume();
} else if (TRANSPORT === "http") {
const app = express();
app.use(cors());
app.use(morgan("dev"));
app.use(express.json());
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID()
});
res.on("close", () => transport.close());
await server.connect(transport);
await transport.handleRequest(req as any, res as any, req.body);
});
app.get("/health", (_req, res) => res.json({ ok: true }));
app.listen(PORT, () => console.log(`MCP HTTP on http://localhost:${PORT}/mcp`));
} else {
throw new Error(`Unknown transport: ${TRANSPORT}`);
}
}
start().catch((e) => {
console.error(e);
process.exit(1);
});