Merge remote-tracking branch 'origin/main' into ochafik/readme-mcp-config

This commit is contained in:
Olivier Chafik 2026-01-20 10:17:45 +00:00
commit 5ef3120e0c
No known key found for this signature in database
34 changed files with 2711 additions and 217 deletions

View file

@ -1,5 +1,6 @@
import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp, type McpUiResourcePermissions, buildAllowAttribute, type McpUiUpdateModelContextRequest, type McpUiMessageRequest } from "@modelcontextprotocol/ext-apps/app-bridge";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
@ -24,11 +25,8 @@ export interface ServerInfo {
export async function connectToServer(serverUrl: URL): Promise<ServerInfo> {
const client = new Client(IMPLEMENTATION);
log.info("Connecting to server:", serverUrl.href);
await client.connect(new StreamableHTTPClientTransport(serverUrl));
log.info("Connection successful");
const client = await connectWithFallback(serverUrl);
const name = client.getServerVersion()?.name ?? serverUrl.href;
@ -39,6 +37,28 @@ export async function connectToServer(serverUrl: URL): Promise<ServerInfo> {
return { name, client, tools, appHtmlCache: new Map() };
}
async function connectWithFallback(serverUrl: URL): Promise<Client> {
// Try Streamable HTTP first (modern transport)
try {
const client = new Client(IMPLEMENTATION);
await client.connect(new StreamableHTTPClientTransport(serverUrl));
log.info("Connected via Streamable HTTP transport");
return client;
} catch (streamableError) {
log.info("Streamable HTTP failed:", streamableError);
}
// Fall back to SSE (deprecated but needed for older servers)
try {
const client = new Client(IMPLEMENTATION);
await client.connect(new SSEClientTransport(serverUrl));
log.info("Connected via SSE transport");
return client;
} catch (sseError) {
throw new Error(`Could not connect with any transport. SSE error: ${sseError}`);
}
}
interface UiResourceData {
html: string;

View file

@ -10,3 +10,9 @@ html, body {
code {
font-size: 1em;
}
/* Server time fills remaining width for consistent E2E screenshot masking */
#server-time {
flex: 1;
min-width: 0;
}

View file

@ -28,6 +28,13 @@
margin-top: 0.5rem;
}
/* Server time row: flex layout for consistent mask width in E2E tests */
> p {
display: flex;
align-items: baseline;
gap: 0.25em;
}
/* Consistent font for form inputs (inherits from global.css) */
textarea,
input {

View file

@ -10,3 +10,9 @@ html, body {
code {
font-size: 1em;
}
/* Server time fills remaining width for consistent E2E screenshot masking */
#server-time {
flex: 1;
min-width: 0;
}

View file

@ -28,6 +28,13 @@
margin-top: 0.5rem;
}
/* Server time row: flex layout for consistent mask width in E2E tests */
> p {
display: flex;
align-items: baseline;
gap: 0.25em;
}
/* Consistent font for form inputs (inherits from global.css) */
textarea,
input {

View file

@ -10,3 +10,9 @@ html, body {
code {
font-size: 1em;
}
/* Server time fills remaining width for consistent E2E screenshot masking */
#server-time {
flex: 1;
min-width: 0;
}

View file

@ -28,6 +28,13 @@
margin-top: 0.5rem;
}
/* Server time row: flex layout for consistent mask width in E2E tests */
> p {
display: flex;
align-items: baseline;
gap: 0.25em;
}
/* Consistent font for form inputs (inherits from global.css) */
textarea,
input {

View file

@ -162,6 +162,13 @@ async function handleOpenLink() {
margin-top: 0.5rem;
}
/* Server time row: flex layout for consistent mask width in E2E tests */
> p {
display: flex;
align-items: baseline;
gap: 0.25em;
}
textarea,
input {
font-family: inherit;
@ -196,4 +203,10 @@ async function handleOpenLink() {
font-style: normal;
}
}
/* Server time fills remaining width for consistent E2E screenshot masking */
:global(#server-time) {
flex: 1;
min-width: 0;
}
</style>

View file

@ -10,3 +10,9 @@ html, body {
code {
font-size: 1em;
}
/* Server time fills remaining width for consistent E2E screenshot masking */
#server-time {
flex: 1;
min-width: 0;
}

View file

@ -28,6 +28,13 @@
margin-top: 0.5rem;
}
/* Server time row: flex layout for consistent mask width in E2E tests */
> p {
display: flex;
align-items: baseline;
gap: 0.25em;
}
/* Consistent font for form inputs (inherits from global.css) */
textarea,
input {

View file

@ -170,6 +170,13 @@ async function handleOpenLink() {
margin-top: 0.5rem;
}
/* Server time row: flex layout for consistent mask width in E2E tests */
> p {
display: flex;
align-items: baseline;
gap: 0.25em;
}
textarea,
input {
font-family: inherit;
@ -204,4 +211,10 @@ async function handleOpenLink() {
font-style: normal;
}
}
/* Server time fills remaining width for consistent E2E screenshot masking */
:deep(#server-time) {
flex: 1;
min-width: 0;
}
</style>

View file

@ -10,3 +10,9 @@ html, body {
code {
font-size: 1em;
}
/* Server time fills remaining width for consistent E2E screenshot masking */
#server-time {
flex: 1;
min-width: 0;
}

View file

@ -28,6 +28,13 @@
margin-top: 0.5rem;
}
/* Server time row: flex layout for consistent mask width in E2E tests */
> p {
display: flex;
align-items: baseline;
gap: 0.25em;
}
/* Consistent font for form inputs (inherits from global.css) */
textarea,
input {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Before After
Before After

View file

@ -2,7 +2,7 @@
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "mcp>=1.9.0",
# "mcp @ git+https://github.com/modelcontextprotocol/python-sdk@main",
# "qrcode[pil]>=8.0",
# "uvicorn>=0.34.0",
# "starlette>=0.46.0",
@ -30,7 +30,10 @@ PORT = int(os.environ.get("PORT", "3108"))
mcp = FastMCP("QR Code Server", port=PORT, stateless_http=True)
@mcp.tool(meta={"ui/resourceUri": WIDGET_URI})
@mcp.tool(meta={
"ui":{"resourceUri": WIDGET_URI},
"ui/resourceUri": WIDGET_URI, # legacy support
})
def generate_qr(
text: str = "https://modelcontextprotocol.io",
box_size: int = 10,
@ -72,53 +75,17 @@ def generate_qr(
return [types.ImageContent(type="image", data=b64, mimeType="image/png")]
# Register widget resource using FastMCP decorator (returns HTML string)
@mcp.resource(WIDGET_URI, mime_type="text/html;profile=mcp-app")
# IMPORTANT: all the external domains used by app must be listed
# in the meta.ui.csp.resourceDomains - otherwise they will be blocked by CSP policy
@mcp.resource(
WIDGET_URI,
mime_type="text/html;profile=mcp-app",
meta={"ui": {"csp": {"resourceDomains": ["https://unpkg.com"]}}},
)
def widget() -> str:
"""Widget HTML resource with CSP metadata for external dependencies."""
return Path(__file__).parent.joinpath("widget.html").read_text()
# Override the read_resource handler to inject _meta into the response
# This is needed because FastMCP doesn't support custom _meta on resources
_low_level_server = mcp._mcp_server
async def _read_resource_with_meta(req: types.ReadResourceRequest):
"""Custom handler that injects CSP metadata for the widget resource."""
uri = str(req.params.uri)
html = Path(__file__).parent.joinpath("widget.html").read_text()
if uri == WIDGET_URI:
# NOTE: Must use model_validate with '_meta' key (not 'meta') due to Pydantic alias behavior
content = types.TextResourceContents.model_validate({
"uri": WIDGET_URI,
"mimeType": "text/html;profile=mcp-app",
"text": html,
# IMPORTANT: all the external domains used by app must be listed
# in the _meta.ui.csp.resourceDomains - otherwise they will be blocked by CSP policy
"_meta": {"ui": {"csp": {"resourceDomains": ["https://unpkg.com"]}}}
})
return types.ServerResult(
types.ReadResourceResult(contents=[content])
)
# Fallback for other resources (shouldn't happen for this server)
return types.ServerResult(
types.ReadResourceResult(
contents=[
types.TextResourceContents(
uri=uri,
mimeType="text/plain",
text="Resource not found"
)
]
)
)
# Replace the handler after FastMCP has registered its own
_low_level_server.request_handlers[types.ReadResourceRequest] = _read_resource_with_meta
if __name__ == "__main__":
if "--stdio" in sys.argv:
# Claude Desktop mode

View file

@ -6,6 +6,9 @@
* bun examples/run-all.ts start - Build and start all examples
* bun examples/run-all.ts dev - Run all examples in dev/watch mode
* bun examples/run-all.ts build - Build all examples
*
* Environment:
* EXAMPLE=<folder> - Run only a single example (e.g., EXAMPLE=say-server)
*/
import { readdirSync, statSync, existsSync } from "fs";
@ -14,21 +17,36 @@ import concurrently from "concurrently";
const BASE_PORT = 3101;
const BASIC_HOST = "basic-host";
// Optional: filter to a single example via EXAMPLE env var (folder name)
const EXAMPLE_FILTER = process.env.EXAMPLE;
// Find all example directories except basic-host that have a package.json,
// assign ports, and build URL list
const servers = readdirSync("examples")
const allServers = readdirSync("examples")
.filter(
(d) =>
d !== BASIC_HOST &&
statSync(`examples/${d}`).isDirectory() &&
existsSync(`examples/${d}/package.json`),
)
.sort() // Sort for consistent port assignment
.map((dir, i) => ({
dir,
port: BASE_PORT + i,
url: `http://localhost:${BASE_PORT + i}/mcp`,
}));
.sort(); // Sort for consistent port assignment
// Filter servers if EXAMPLE is specified
const filteredDirs = EXAMPLE_FILTER
? allServers.filter((d) => d === EXAMPLE_FILTER)
: allServers;
if (EXAMPLE_FILTER && filteredDirs.length === 0) {
console.error(`Error: No example found matching EXAMPLE=${EXAMPLE_FILTER}`);
console.error(`Available examples: ${allServers.join(", ")}`);
process.exit(1);
}
const servers = filteredDirs.map((dir, i) => ({
dir,
port: BASE_PORT + i,
url: `http://localhost:${BASE_PORT + i}/mcp`,
}));
const COMMANDS = ["start", "dev", "build"];
@ -43,6 +61,9 @@ if (!command || !COMMANDS.includes(command)) {
const serversEnv = JSON.stringify(servers.map((s) => s.url));
console.log(`Running command: ${command}`);
if (EXAMPLE_FILTER) {
console.log(`Filtering to single example: ${EXAMPLE_FILTER}`);
}
console.log(
`Server examples: ${servers.map((s) => `${s.dir}:${s.port}`).join(", ")}`,
);

2
examples/say-server/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
dist/

View file

@ -0,0 +1,175 @@
# Say Server - Streaming TTS MCP App
A real-time text-to-speech MCP App with karaoke-style text highlighting, powered by [Kyutai's Pocket TTS](https://github.com/kyutai-labs/pocket-tts).
## MCP App Features Demonstrated
This example showcases several MCP App capabilities:
- **Single-file executable**: Python server with embedded React UI - no build step required
- **Partial tool inputs** (`ontoolinputpartial`): Widget receives streaming text as it's being generated
- **Queue-based streaming**: Demonstrates how to stream text out and audio in via a polling tool (adds text to an input queue, retrieves audio chunks from an output queue)
- **Model context updates**: Widget updates the LLM with playback progress ("Playing: ...snippet...")
- **Native theming**: Uses CSS variables for automatic dark/light mode adaptation
- **Fullscreen mode**: Toggle fullscreen via `requestDisplayMode()` API, press Escape to exit
- **Multi-widget speak lock**: Coordinates multiple TTS widgets via localStorage so only one plays at a time
- **Hidden tools** (`visibility: ["app"]`): Private tools only accessible to the widget, not the model
- **External links** (`openLink`): Attribution popup uses `app.openLink()` to open external URLs
- **CSP metadata**: Resource declares required domains (`esm.sh`) for in-browser transpilation
## Features
- **Streaming TTS**: Audio starts playing as text is being generated
- **Karaoke highlighting**: Words are highlighted in sync with speech
- **Interactive controls**: Click to pause/resume, double-click to restart
- **Low latency**: Uses a polling-based queue for minimal delay
## Prerequisites
- [uv](https://docs.astral.sh/uv/getting-started/installation/) - fast Python package manager
- A CUDA GPU (recommended) or CPU with sufficient RAM (~2GB for model)
## Quick Start
The server is a single self-contained Python file that can be run directly with `uv`:
```bash
# Run directly (uv auto-installs dependencies)
uv run examples/say-server/server.py
```
The server will be available at `http://localhost:3109/mcp`.
## Running with Docker
Run directly from GitHub using the official `uv` Docker image. Mount your HuggingFace cache to avoid re-downloading the model:
```bash
docker run --rm -it \
-p 3109:3109 \
-v ~/.cache/huggingface:/root/.cache/huggingface \
-e HF_HOME=/root/.cache/huggingface \
ghcr.io/astral-sh/uv:debian \
uv run https://raw.githubusercontent.com/modelcontextprotocol/ext-apps/main/examples/say-server/server.py
```
For GPU support, add `--gpus all` (requires [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html)).
## Usage
### With Claude Desktop
Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json`):
```json
{
"mcpServers": {
"say": {
"command": "uv",
"args": ["run", "server.py", "--stdio"],
"cwd": "/path/to/examples/say-server"
}
}
}
```
### With MCP Clients
Connect to `http://localhost:3109/mcp` and call the `say` tool:
```json
{
"name": "say",
"arguments": {
"text": "Hello, world! This is a streaming TTS demo."
}
}
```
## Available Voices
The default voice is `cosette`. Use the `list_voices` tool or pass a `voice` parameter to `say`:
### Predefined Voices
- `alba`, `marius`, `javert`, `jean` - from [alba-mackenna](https://huggingface.co/kyutai/tts-voices/tree/main/alba-mackenna) (CC BY 4.0)
- `cosette`, `eponine`, `azelma`, `fantine` - from [VCTK dataset](https://huggingface.co/kyutai/tts-voices/tree/main/vctk) (CC BY 4.0)
### Custom Voices
You can also use HuggingFace URLs or local file paths:
```json
{"text": "Hello!", "voice": "hf://kyutai/tts-voices/voice-donations/alice.wav"}
{"text": "Hello!", "voice": "/path/to/my-voice.wav"}
```
See the [kyutai/tts-voices](https://huggingface.co/kyutai/tts-voices) repository for more voice collections
## Architecture
The entire server is contained in a single `server.py` file:
1. **`say` tool**: Public tool that triggers the widget with text to speak
2. **Private tools** (`create_tts_queue`, `add_tts_text`, `poll_tts_audio`, etc.): Hidden from the model, only callable by the widget
3. **Embedded React widget**: Uses [Babel standalone](https://babeljs.io/docs/babel-standalone) for in-browser JSX transpilation - no build step needed
4. **TTS backend**: Manages per-request audio queues using Pocket TTS
The widget communicates with the server via MCP tool calls:
- Receives streaming text via `ontoolinputpartial` callback
- Incrementally sends new text to the server as it arrives (via `add_tts_text`)
- Polls for generated audio chunks while TTS runs in parallel
- Plays audio via Web Audio API with synchronized text highlighting
## Multi-Widget Speak Lock
When multiple TTS widgets exist in the same browser (e.g., multiple chat messages each with their own say widget), they coordinate via localStorage to ensure only one plays at a time:
1. **Unique Widget IDs**: Each widget receives a UUID via `toolResult._meta.widgetUUID`
2. **Announce on Play**: When starting, a widget writes `{uuid, timestamp}` to `localStorage["mcp-tts-playing"]`
3. **Poll for Conflicts**: Every 200ms, playing widgets check if another widget took the lock
4. **Yield Gracefully**: If another widget started playing, pause and yield
5. **Clean Up**: On pause/finish, clear the lock (only if owned)
This "last writer wins" protocol ensures a seamless experience: clicking play on any widget immediately pauses others, without requiring cross-iframe postMessage coordination.
## TODO
- Persist caret position in localStorage (resume from where you left off)
- Click anywhere in text to move the cursor/playback position
## Credits
This project uses [Pocket TTS](https://github.com/kyutai-labs/pocket-tts) by [Kyutai](https://kyutai.org/) - a fantastic open-source text-to-speech model. Thank you to the Kyutai team for making this technology available!
The server includes modified Pocket TTS code to support streaming text input (text can be fed incrementally while audio generation runs in parallel). A PR contributing this functionality back to the original repo is planned.
## License
This example is MIT licensed.
### Third-Party Licenses
This project uses the following open-source components:
| Component | License | Link |
| --------------------------------------------------------------------- | ----------------- | ---------------------------- |
| [pocket-tts](https://github.com/kyutai-labs/pocket-tts) | MIT | Python TTS library |
| [Kyutai TTS model](https://huggingface.co/kyutai/tts-0.75b-en-public) | CC-BY 4.0 | Text-to-speech model weights |
| [kyutai/tts-voices](https://huggingface.co/kyutai/tts-voices) | Mixed (see below) | Voice prompt files |
### Voice Collection Licenses
The predefined voices in this example use **CC-BY 4.0** licensed collections:
| Collection | License | Commercial Use |
| --------------- | ------------------- | ------------------------- |
| alba-mackenna | CC-BY 4.0 | ✅ Yes (with attribution) |
| vctk | CC-BY 4.0 | ✅ Yes (with attribution) |
| cml-tts/fr | CC-BY 4.0 | ✅ Yes (with attribution) |
| voice-donations | CC0 (Public Domain) | ✅ Yes |
| **expresso** | CC-BY-NC 4.0 | ❌ Non-commercial only |
| **ears** | CC-BY-NC 4.0 | ❌ Non-commercial only |
⚠️ **Note**: If you use voices from the `expresso/` or `ears/` collections, your use is restricted to non-commercial purposes.

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View file

@ -0,0 +1,14 @@
<!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>Say Widget</title>
<link rel="stylesheet" href="/src/global.css">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/mcp-app.tsx"></script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show more