Add sheet-music-server example

Demo MCP App showcasing ABC notation rendering and audio playback with:
- Server-side ABC notation validation
- Built-in audio player with play/pause and loop controls
- Sheet music visualization using abcjs library

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jonathan Hefner 2026-01-01 10:47:06 -06:00
commit 012fb3260c
13 changed files with 709 additions and 36 deletions

View file

@ -0,0 +1,84 @@
# Example: Sheet Music Server
A demo MCP App that renders [ABC notation](https://en.wikipedia.org/wiki/ABC_notation) as sheet music with interactive audio playback using the [abcjs](https://www.abcjs.net/) library.
<table>
<tr>
<td><a href="https://modelcontextprotocol.github.io/ext-apps/screenshots/sheet-music-server/01-twinkle-twinkle-little-star.png"><img src="https://modelcontextprotocol.github.io/ext-apps/screenshots/sheet-music-server/01-twinkle-twinkle-little-star.png" alt="Twinkle, Twinkle Little Star" width="100%"></a></td>
<td><a href="https://modelcontextprotocol.github.io/ext-apps/screenshots/sheet-music-server/02-playing-on-repeat.png"><img src="https://modelcontextprotocol.github.io/ext-apps/screenshots/sheet-music-server/02-playing-on-repeat.png" alt="Playing on repeat" width="100%"></a></td>
</tr>
</table>
## Features
- **Audio Playback**: Built-in audio player with play/pause and loop controls
- **Sheet Music Rendering**: Displays ABC notation as properly formatted sheet music
## Running
1. Install dependencies:
```bash
npm install
```
2. Build and start the server:
```bash
npm run start:http # for Streamable HTTP transport
# OR
npm run start:stdio # for stdio transport
```
3. View using the [`basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) example or another MCP Apps-compatible host.
### Tool Input
When calling the `play-sheet-music` tool, provide ABC notation:
```json
{
"abcNotation": "X:1\nT:C Major Scale\nM:4/4\nL:1/4\nK:C\nC D E F | G A B c |"
}
```
#### ABC Notation Examples
**C Major Scale:**
```abc
X:1
T:C Major Scale
M:4/4
L:1/4
K:C
C D E F | G A B c |
```
**Twinkle, Twinkle Little Star:**
```abc
X:1
T:Twinkle, Twinkle Little Star
M:4/4
L:1/4
K:C
C C G G | A A G2 | F F E E | D D C2 |
G G F F | E E D2 | G G F F | E E D2 |
C C G G | A A G2 | F F E E | D D C2 |
```
## Architecture
### Server (`server.ts`)
Exposes a single `play-sheet-music` tool that accepts:
- `abcNotation`: ABC notation string to render
The tool validates the ABC notation server-side using the abcjs parser and returns any parse errors. The actual rendering happens client-side when the UI receives the tool input.
### App (`src/mcp-app.ts`)
- Receives ABC notation via `ontoolinput` handler
- Uses abcjs for audio playback controls and sheet music rendering (in `renderAbc()`)

View file

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<title>Sheet Music Viewer</title>
</head>
<body>
<main class="main">
<header class="header">
<div id="audio-controls" class="audio-controls abcjs-large">
<!-- SynthController will inject audio controls here -->
</div>
<span id="status" class="status">Waiting for notation...</span>
</header>
<section class="sheet-section">
<div id="sheet-music" class="sheet-music-container">
<!-- ABC notation will be rendered here by abcjs -->
</div>
</section>
</main>
<script type="module" src="./src/mcp-app.ts"></script>
</body>
</html>

View file

@ -0,0 +1,34 @@
{
"name": "sheet-music-server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build",
"watch": "cross-env INPUT=mcp-app.html vite build --watch",
"serve:http": "bun server.ts",
"serve:stdio": "bun server.ts --stdio",
"start": "npm run start:http",
"start:http": "cross-env NODE_ENV=development npm run build && npm run serve:http",
"start:stdio": "cross-env NODE_ENV=development npm run build && npm run serve:stdio",
"dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve:http'"
},
"dependencies": {
"@modelcontextprotocol/ext-apps": "../..",
"@modelcontextprotocol/sdk": "^1.24.0",
"abcjs": "^6.4.4",
"zod": "^4.1.13"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"concurrently": "^9.2.1",
"cors": "^2.8.5",
"cross-env": "^7.0.3",
"express": "^5.1.0",
"typescript": "^5.9.3",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.3.0"
}
}

View file

@ -0,0 +1,119 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type {
CallToolResult,
ReadResourceResult,
} from "@modelcontextprotocol/sdk/types.js";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import ABCJS from "abcjs";
import {
RESOURCE_MIME_TYPE,
RESOURCE_URI_META_KEY,
registerAppResource,
registerAppTool,
} from "@modelcontextprotocol/ext-apps/server";
import { startServer } from "./src/server-utils.js";
const DIST_DIR = path.join(import.meta.dirname, "dist");
const DEFAULT_ABC_NOTATION_INPUT = `X:1
T:Twinkle, Twinkle Little Star
M:4/4
L:1/4
K:C
C C G G | A A G2 | F F E E | D D C2 |
G G F F | E E D2 | G G F F | E E D2 |
C C G G | A A G2 | F F E E | D D C2 |`;
/**
* Creates a new MCP server instance with the sheet music tool and resource.
*/
function createServer(): McpServer {
const server = new McpServer({
name: "Sheet Music Server",
version: "1.0.0",
});
const resourceUri = "ui://sheet-music/mcp-app.html";
// Register the play-sheet-music tool.
// Validates ABC notation server-side, then the client renders via ontoolinput.
registerAppTool(
server,
"play-sheet-music",
{
title: "Play Sheet Music",
description:
"Plays music from ABC notation with audio playback and visual sheet music. " +
"Use this to compose original songs (for birthdays, holidays, or any occasion) " +
"or perform well-known tunes (folk songs, nursery rhymes, hymns, classical melodies). " +
"For accurate renditions of well-known tunes, look up the ABC notation from " +
"abcnotation.com or thesession.org rather than recalling from memory.",
inputSchema: z.object({
abcNotation: z
.string()
.default(DEFAULT_ABC_NOTATION_INPUT)
.describe(
"ABC notation string to render as sheet music with audio playback",
),
}),
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
},
async ({ abcNotation }): Promise<CallToolResult> => {
// Validate ABC notation using abcjs parser
const [{ warnings }] = ABCJS.parseOnly(abcNotation);
// Check for parse warnings (abcjs reports errors as warnings)
if (warnings && warnings.length > 0) {
// Strip HTML markup from warning messages
const messages = warnings.map((w) => w.replace(/<[^>]*>/g, ""));
const error = `Invalid ABC notation:\n${messages.join("\n")}`;
return {
isError: true,
content: [{ type: "text", text: error }],
};
}
return {
content: [{ type: "text", text: "Input parsed successfully." }],
};
},
);
// Register the UI resource that serves the bundled HTML/JS/CSS.
registerAppResource(
server,
resourceUri,
resourceUri,
{ mimeType: RESOURCE_MIME_TYPE, description: "Sheet Music Viewer UI" },
async (): Promise<ReadResourceResult> => {
const html = await fs.readFile(
path.join(DIST_DIR, "mcp-app.html"),
"utf-8",
);
return {
contents: [
{
uri: resourceUri,
mimeType: RESOURCE_MIME_TYPE,
text: html,
_meta: {
ui: {
csp: {
// Allow loading soundfonts for audio playback
connectDomains: ["https://paulrosen.github.io"],
},
},
},
},
],
};
},
);
return server;
}
startServer(createServer);

View file

@ -0,0 +1,12 @@
* {
box-sizing: border-box;
}
html, body {
font-family: system-ui, -apple-system, sans-serif;
font-size: 1rem;
}
code {
font-size: 1em;
}

View file

@ -0,0 +1,87 @@
:root {
--color-bg: #ffffff;
--color-text: #1f2937;
--color-text-muted: #6b7280;
--color-primary: #2563eb;
--color-success: #10b981;
--color-danger: #ef4444;
--color-card-bg: #f9fafb;
--color-border: #e5e7eb;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #111827;
--color-text: #f9fafb;
--color-text-muted: #9ca3af;
--color-primary: #3b82f6;
--color-success: #34d399;
--color-danger: #f87171;
--color-card-bg: #1f2937;
--color-border: #374151;
}
}
html,
body {
margin: 0;
padding: 0;
background: var(--color-bg);
color: var(--color-text);
}
.main {
width: 100%;
margin: 0 auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
/* Header */
#audio-controls:empty {
display: none;
}
#audio-controls:not(:empty) ~ #status {
display: none;
}
.audio-controls {
width: 100%;
.abcjs-inline-audio {
border-radius: 8px;
/* Make loop button active state more visible */
.abcjs-midi-loop.abcjs-pushed {
background-color: var(--color-success) !important;
border-color: var(--color-success) !important;
}
}
}
.status {
font-size: 0.875rem;
color: var(--color-text-muted);
}
.status.error {
color: var(--color-danger);
}
/* Sheet Music Section */
.sheet-section {
background: var(--color-card-bg);
border-radius: 8px;
padding: 16px;
border: 1px solid var(--color-border);
min-height: 500px;
.sheet-music-container {
width: 100%;
overflow-x: auto;
}
}

View file

@ -0,0 +1,120 @@
/**
* @file Sheet Music App - renders ABC notation with abcjs and provides audio playback
*/
import { App } from "@modelcontextprotocol/ext-apps";
import ABCJS from "abcjs";
import "abcjs/abcjs-audio.css";
import "./global.css";
import "./mcp-app.css";
const log = {
info: console.log.bind(console, "[APP]"),
error: console.error.bind(console, "[APP]"),
};
// State management
interface AppState {
visualObj: ABCJS.TuneObject[] | null;
synthControl: ABCJS.SynthObjectController | null;
}
const state: AppState = {
visualObj: null,
synthControl: null,
};
// DOM element references
const mainEl = document.querySelector(".main") as HTMLElement;
const statusEl = document.getElementById("status")!;
const sheetMusicEl = document.getElementById("sheet-music")!;
const audioControlsEl = document.getElementById("audio-controls")!;
/**
* Updates the status display.
*/
function setStatus(text: string, isError = false): void {
statusEl.textContent = text;
statusEl.classList.toggle("error", isError);
}
/**
* Renders ABC notation to sheet music and sets up audio playback
*/
async function renderAbc(abcNotation: string): Promise<void> {
try {
setStatus("Rendering...");
// Clear previous content
sheetMusicEl.innerHTML = "";
audioControlsEl.innerHTML = "";
// Render the sheet music visually
state.visualObj = ABCJS.renderAbc(sheetMusicEl, abcNotation, {
responsive: "resize",
add_classes: true,
});
if (!state.visualObj || state.visualObj.length === 0) {
throw new Error("Failed to parse music notation");
}
if (!ABCJS.synth.supportsAudio()) {
throw new Error("Audio not supported in this browser");
}
// Create synth controller with UI controls
state.synthControl = new ABCJS.synth.SynthController();
state.synthControl.load(audioControlsEl, null, {
displayLoop: true,
// displayRestart: true,
displayPlay: true,
displayProgress: true,
// displayWarp: true,
});
// Connect synth to the rendered tune
await state.synthControl.setTune(state.visualObj[0], false, {});
setStatus("Ready to play!");
} catch (error) {
log.error("Render error:", error);
setStatus(`Error: ${(error as Error).message}`, true);
audioControlsEl.innerHTML = "";
}
}
// Create app instance
const app = new App({ name: "Sheet Music App", version: "1.0.0" });
// Handle tool input - receives ABC notation from the host
app.ontoolinput = (params) => {
log.info("Received tool input:", params);
const abcNotation = params.arguments?.abcNotation as string | undefined;
if (abcNotation) {
renderAbc(abcNotation);
} else {
setStatus("No ABC notation provided", true);
}
};
// Error handler
app.onerror = log.error;
app.onhostcontextchanged = (params) => {
if (params.safeAreaInsets) {
mainEl.style.paddingTop = `${params.safeAreaInsets.top}px`;
mainEl.style.paddingRight = `${params.safeAreaInsets.right}px`;
mainEl.style.paddingBottom = `${params.safeAreaInsets.bottom}px`;
mainEl.style.paddingLeft = `${params.safeAreaInsets.left}px`;
}
};
// Connect to host
app.connect().then(() => {
const ctx = app.getHostContext();
if (ctx) {
app.onhostcontextchanged(ctx);
}
});

View file

@ -0,0 +1,110 @@
/**
* Shared utilities for running MCP servers with various transports.
*/
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import cors from "cors";
import type { Request, Response } from "express";
/**
* Starts an MCP server using the appropriate transport based on command-line arguments.
*
* If `--stdio` is passed, uses stdio transport. Otherwise, uses Streamable HTTP transport.
*
* @param createServer - Factory function that creates a new McpServer instance.
*/
export async function startServer(
createServer: () => McpServer,
): Promise<void> {
try {
if (process.argv.includes("--stdio")) {
await startStdioServer(createServer);
} else {
await startStreamableHttpServer(createServer);
}
} catch (e) {
console.error(e);
process.exit(1);
}
}
/**
* Starts an MCP server with stdio transport.
*
* @param createServer - Factory function that creates a new McpServer instance.
*/
export async function startStdioServer(
createServer: () => McpServer,
): Promise<void> {
await createServer().connect(new StdioServerTransport());
}
/**
* Starts an MCP server with Streamable HTTP transport in stateless mode.
*
* Each request creates a fresh server and transport instance, which are
* closed when the response ends (no session tracking).
*
* The server listens on the port specified by the PORT environment variable,
* defaulting to 3001 if not set.
*
* @param createServer - Factory function that creates a new McpServer instance per request.
*/
export async function startStreamableHttpServer(
createServer: () => McpServer,
): Promise<void> {
const port = parseInt(process.env.PORT ?? "3001", 10);
// Express app - bind to all interfaces for development/testing
const expressApp = createMcpExpressApp({ host: "0.0.0.0" });
expressApp.use(cors());
expressApp.all("/mcp", async (req: Request, res: Response) => {
// Create fresh server and transport for each request (stateless mode)
const server = createServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
// Clean up when response ends
res.on("close", () => {
transport.close().catch(() => {});
server.close().catch(() => {});
});
try {
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error("MCP error:", error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: { code: -32603, message: "Internal server error" },
id: null,
});
}
}
});
const { promise, resolve, reject } = Promise.withResolvers<void>();
const httpServer = expressApp.listen(port, (err?: Error) => {
if (err) return reject(err);
console.log(`Server listening on http://localhost:${port}/mcp`);
resolve();
});
const shutdown = () => {
console.log("\nShutting down...");
httpServer.close(() => process.exit(0));
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
return promise;
}

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "server.ts"]
}

View file

@ -0,0 +1,24 @@
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
const INPUT = process.env.INPUT;
if (!INPUT) {
throw new Error("INPUT environment variable is not set");
}
const isDevelopment = process.env.NODE_ENV === "development";
export default defineConfig({
plugins: [viteSingleFile()],
build: {
sourcemap: isDevelopment ? "inline" : undefined,
cssMinify: !isDevelopment,
minify: !isDevelopment,
rollupOptions: {
input: INPUT,
},
outDir: "dist",
emptyOutDir: false,
},
});

108
package-lock.json generated
View file

@ -306,7 +306,7 @@
"version": "1.0.0",
"dependencies": {
"@modelcontextprotocol/ext-apps": "../..",
"@modelcontextprotocol/sdk": "^1.22.0",
"@modelcontextprotocol/sdk": "^1.24.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"zod": "^4.1.13"
@ -318,7 +318,6 @@
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^4.3.4",
"bun": "^1.3.2",
"concurrently": "^9.2.1",
"cors": "^2.8.5",
"express": "^5.1.0",
@ -386,6 +385,63 @@
"dev": true,
"license": "MIT"
},
"examples/sheet-music-server": {
"version": "1.0.0",
"dependencies": {
"@modelcontextprotocol/ext-apps": "../..",
"@modelcontextprotocol/sdk": "^1.24.0",
"abcjs": "^6.4.4",
"zod": "^4.1.13"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"concurrently": "^9.2.1",
"cors": "^2.8.5",
"cross-env": "^7.0.3",
"express": "^5.1.0",
"typescript": "^5.9.3",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.3.0"
}
},
"examples/sheet-music-server/node_modules/@types/node": {
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"examples/sheet-music-server/node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.1"
},
"bin": {
"cross-env": "src/bin/cross-env.js",
"cross-env-shell": "src/bin/cross-env-shell.js"
},
"engines": {
"node": ">=10.14",
"npm": ">=6",
"yarn": ">=1"
}
},
"examples/sheet-music-server/node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"examples/system-monitor-server": {
"version": "1.0.0",
"dependencies": {
@ -2394,6 +2450,16 @@
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/abcjs": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/abcjs/-/abcjs-6.5.2.tgz",
"integrity": "sha512-XLDZPy/4TZbOqPsLwuu0Umsl79NTAcObEkboPxdYZXI8/fU6PNh59SAnkZOnEPVbyT8EXfBUjgNoe/uKd3T0xQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/paulrosen"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@ -2685,40 +2751,6 @@
"resolved": "examples/budget-allocator-server",
"link": true
},
"node_modules/bun": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/bun/-/bun-1.3.5.tgz",
"integrity": "sha512-c1YHIGUfgvYPJmLug5QiLzNWlX2Dg7X/67JWu1Va+AmMXNXzC/KQn2lgQ7rD+n1u1UqDpJMowVGGxTNpbPydNw==",
"cpu": [
"arm64",
"x64"
],
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"os": [
"darwin",
"linux",
"win32"
],
"bin": {
"bun": "bin/bun.exe",
"bunx": "bin/bunx.exe"
},
"optionalDependencies": {
"@oven/bun-darwin-aarch64": "1.3.5",
"@oven/bun-darwin-x64": "1.3.5",
"@oven/bun-darwin-x64-baseline": "1.3.5",
"@oven/bun-linux-aarch64": "1.3.5",
"@oven/bun-linux-aarch64-musl": "1.3.5",
"@oven/bun-linux-x64": "1.3.5",
"@oven/bun-linux-x64-baseline": "1.3.5",
"@oven/bun-linux-x64-musl": "1.3.5",
"@oven/bun-linux-x64-musl-baseline": "1.3.5",
"@oven/bun-windows-x64": "1.3.5",
"@oven/bun-windows-x64-baseline": "1.3.5"
}
},
"node_modules/bun-types": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.3.tgz",
@ -5656,6 +5688,10 @@
"node": ">=8"
}
},
"node_modules/sheet-music-server": {
"resolved": "examples/sheet-music-server",
"link": true
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",

View file

@ -30,6 +30,7 @@ const SERVERS = [
{ key: "cohort-heatmap", name: "Cohort Heatmap Server" },
{ key: "customer-segmentation", name: "Customer Segmentation Server" },
{ key: "scenario-modeler", name: "SaaS Scenario Modeler" },
{ key: "sheet-music", name: "Sheet Music Server" },
{ key: "system-monitor", name: "System Monitor Server" },
{ key: "threejs", name: "Three.js Server" },
{ key: "wiki-explorer", name: "Wiki Explorer" },

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB