Skip to content

Commit 4a30952

Browse files
committed
feat: add audio + audio-video analysis tools
Two new MCP tools (analyze_audio, analyze_audio_video) on qwen3.5-omni-plus, separate from qwen3.7-plus video/image. Reuse the non-streaming analyze path: live testing shows the doc's 'stream=True mandatory' claim is stale -- omni non-stream returns 200 for text/audio/video. Audio uses input_audio{data,format} where data must be data:;base64,<b64> (raw base64 rejected). Local files validated by ext + magic-byte before encoding; 25MB guardrail reused (8.8MB / 11.7MB base64 verified on omni). New QWEN_OMNI_MODEL env, default qwen3.5-omni-plus.
1 parent 6979d18 commit 4a30952

13 files changed

Lines changed: 533 additions & 36 deletions

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ DASHSCOPE_API_KEY=
55
# Model ID. qwen3.7-plus is multimodal (text+image+video) on Bailian.
66
QWEN_MODEL=qwen3.7-plus
77

8+
# Omni model ID for audio + audio-video analysis (native audio understanding).
9+
QWEN_OMNI_MODEL=qwen3.5-omni-plus
10+
811
# OpenAI-compatible endpoint (documented multimodal/video support).
912
# Anthropic-compatible /apps/anthropic does NOT support video input.
1013
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1

AGENTS.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,20 +33,21 @@ CI runs the same on Node 20 and 22. Local green ≠ CI green if you skip a step.
3333

3434
- **TypeScript strict.** No `any` in `src/` (allowed sparingly in `test/` for fixture typing). No `@ts-ignore`. No non-null assertions in `src/`.
3535
- Prefer narrow types and `unknown` over `any` when parsing external JSON (see `src/bailian.ts`).
36-
- The DashScope payload builder (`buildPayload`) is intentionally injectable — if the `video_url`/`image_url` content block shape changes, change it in one place.
36+
- The DashScope payload builder (`buildPayload`) is intentionally injectable — if the `video_url`/`image_url`/`input_audio` content block shape changes, change it in one place (`contentBlock` / `audioBlock` in `src/bailian.ts`).
3737
- Do not add a new runtime, language, or heavy dependency without explicit maintainer approval.
3838
- Match existing style; let `prettier` and `eslint --fix` handle formatting.
3939

4040
## Tool surface
4141

42-
The server exposes 3 MCP tools (see `src/server.ts`): `analyze_video`, `analyze_image`, `check_endpoint_status`. Do not silently change a tool's name or argument schema — that breaks MCP clients. Add new tools rather than renaming.
42+
The server exposes 5 MCP tools (see `src/server.ts`): `analyze_video`, `analyze_image`, `analyze_audio`, `analyze_audio_video`, `check_endpoint_status`. Do not silently change a tool's name or argument schema — that breaks MCP clients. Add new tools rather than renaming.
4343

4444
`check_endpoint_status` must redact the API key (`redactKey`). There is a test asserting no key leaks — keep it passing.
4545

4646
## Backend
4747

4848
- Endpoint: Bailian (DashScope) OpenAI-compatible mode, `${DASHSCOPE_BASE_URL}/chat/completions` (default `https://dashscope.aliyuncs.com/compatible-mode/v1`).
49-
- Model: `qwen3.7-plus` (multimodal, native video — **no client-side frame extraction**).
49+
- Model: `qwen3.7-plus` (multimodal, native video — **no client-side frame extraction**) for `analyze_video`/`analyze_image`.
50+
- Omni model: `qwen3.5-omni-plus` (native audio + audio-video understanding) for `analyze_audio`/`analyze_audio_video`, configured via `QWEN_OMNI_MODEL`. Omni calls send `modalities: ["text"]` to force text-only output (no voice blob).
5051
- The Anthropic-compatible `/apps/anthropic` endpoint does NOT support video input. Do not switch to it for multimodal tools.
5152
- Video frame sampling is server-side (fixed 0.5s/frame on OpenAI-compatible mode). Do not add frame extraction logic.
5253

@@ -64,5 +65,8 @@ The server exposes 3 MCP tools (see `src/server.ts`): `analyze_video`, `analyze_
6465
## Fragile assumptions (verify before relying on)
6566

6667
1. The OpenAI-compatible endpoint accepts a `video_url` content block for `qwen3.7-plus`. If a live call rejects it, the fallback is the native DashScope `video` content type or switching to `qwen-vl-max-latest`. Change `contentBlock()` in `src/bailian.ts`.
67-
2. The exact model id string `qwen3.7-plus`. Verify against the Bailian model list if a call returns a model-not-found error.
68-
3. Local files up to the 25MB guardrail in `src/media.ts` can be sent as base64 data URLs — verified live (14MB video / ~18MB body, HTTP 200 on `qwen3.7-plus` OpenAI-compatible mode). Larger files must be hosted at a public URL. Local input is validated by extension + magic-byte signature before encoding (see `toDataUrl`).
68+
2. The exact model id strings `qwen3.7-plus` and `qwen3.5-omni-plus`. Verify against the Bailian model list if a call returns a model-not-found error.
69+
3. Local files up to the 25MB guardrail in `src/media.ts` can be sent as base64 data URLs — verified live (14MB video / ~18MB body, HTTP 200 on `qwen3.7-plus` OpenAI-compatible mode; 8.8MB video / ~11.7MB base64 body, HTTP 200 on `qwen3.5-omni-plus`). Larger files must be hosted at a public URL. Local input is validated by extension + magic-byte signature before encoding (see `toDataUrl` / `toAudioData`).
70+
4. **Qwen-Omni `stream=True` is NOT mandatory.** The official doc claims all Qwen-Omni requests must set `stream=True`, but live testing shows non-streaming calls succeed (text/audio/video, HTTP 200 + JSON). The omni tools therefore reuse the same non-streaming `analyze` path as `qwen3.7-plus`. If a future endpoint revision starts rejecting non-stream omni calls, add a streaming variant in `src/bailian.ts` and route omni tools through it.
71+
5. **`input_audio.data` must be `data:;base64,<b64>` + `format`, not raw base64.** Raw base64 is rejected with `"The provided URL does not appear to be valid"`. Verified live for mp3/wav. If other formats (flac/ogg/m4a/aac) are rejected, change `toAudioData()` in `src/media.ts` (e.g. to a full `data:audio/<fmt>;base64,` data URL) — single point of change, no tool-schema impact.
72+
6. The default `dashscope.aliyuncs.com/compatible-mode/v1` endpoint serves `qwen3.5-omni-plus` (verified live). No workspace-specific MaaS URL is needed. If a future key/region rejects omni, add an optional `QWEN_OMNI_BASE_URL` env and route omni calls through it.

README.md

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
# qwen-omni-mcp
22

3-
An [MCP](https://modelcontextprotocol.io) server that gives Claude Code and other AI agents **video and image understanding** via [Bailian (DashScope)](https://bailian.console.aliyun.com/) using the multimodal **Qwen3.7-Plus** model.
3+
An [MCP](https://modelcontextprotocol.io) server that gives Claude Code and other AI agents **video, image, audio, and audio-video understanding** via [Bailian (DashScope)](https://bailian.console.aliyun.com/) using the multimodal **Qwen3.7-Plus** and **Qwen3.5-Omni** models.
44

5-
Qwen3.7-Plus reads video natively — **no client-side frame extraction**. Pass a public media URL **or a local file path**; the model does the rest.
5+
Qwen3.7-Plus reads video natively — **no client-side frame extraction**. Qwen3.5-Omni adds native **audio** understanding (and audio-track awareness for video). Pass a public media URL **or a local file path**; the model does the rest.
66

77
## Highlights
88

99
- **Native video understanding** — send a video URL or local file, get grounded analysis
1010
- **Image understanding** — describe, Q&A, OCR
11-
- **Local file support** — pass a local path; files are sent inline as base64 data URLs (25MB guardrail)
12-
- **Convenience tools** — summarize, text extraction, frame comparison, Q&A
11+
- **Audio understanding** — transcribe, summarize, analyze speech/sound (mp3/wav/flac/ogg/m4a/aac)
12+
- **Audio-video understanding** — analyze a video's visuals **and** its sound track together
13+
- **Local file support** — pass a local path; files are sent inline as base64 (25MB guardrail)
1314
- **npx-launchable** — one line in your MCP client config
1415

1516
## Install
@@ -34,12 +35,13 @@ npm run dev # run from source via tsx
3435

3536
All config is via environment variables (loaded from `.env` by `dotenv`):
3637

37-
| Variable | Required | Default | Description |
38-
| ---------------------- | -------- | --------------------------------------------------- | ------------------------------ |
39-
| `DASHSCOPE_API_KEY` | yes || Bailian API key |
40-
| `QWEN_MODEL` | no | `qwen3.7-plus` | Model id (multimodal) |
41-
| `DASHSCOPE_BASE_URL` | no | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI-compatible endpoint |
42-
| `QWEN_REQUEST_TIMEOUT` | no | `300` | Per-request timeout in seconds |
38+
| Variable | Required | Default | Description |
39+
| ---------------------- | -------- | --------------------------------------------------- | ----------------------------------- |
40+
| `DASHSCOPE_API_KEY` | yes || Bailian API key |
41+
| `QWEN_MODEL` | no | `qwen3.7-plus` | Model id for video/image analysis |
42+
| `QWEN_OMNI_MODEL` | no | `qwen3.5-omni-plus` | Omni model id for audio/audio-video |
43+
| `DASHSCOPE_BASE_URL` | no | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI-compatible endpoint |
44+
| `QWEN_REQUEST_TIMEOUT` | no | `300` | Per-request timeout in seconds |
4345

4446
Get a key at <https://bailian.console.aliyun.com/cn-beijing?tab=model#/api-key>.
4547

@@ -79,13 +81,17 @@ For local development without publishing:
7981

8082
## Tools
8183

82-
| Tool | Description |
83-
| ----------------------- | --------------------------------------------------------- |
84-
| `analyze_video` | Analyze a video (URL or local file) with a custom prompt |
85-
| `analyze_image` | Analyze an image (URL or local file) with a custom prompt |
86-
| `check_endpoint_status` | Show configured endpoint/model (key redacted) |
84+
| Tool | Description |
85+
| ----------------------- | --------------------------------------------------------------------- |
86+
| `analyze_video` | Analyze a video (URL or local file) with a custom prompt |
87+
| `analyze_image` | Analyze an image (URL or local file) with a custom prompt |
88+
| `analyze_audio` | Analyze an audio file (URL or local) with a custom prompt (Omni) |
89+
| `analyze_audio_video` | Analyze a video's visuals + sound (URL or local) with a prompt (Omni) |
90+
| `check_endpoint_status` | Show configured endpoint/model (key redacted) |
8791

88-
Each media tool accepts a public `http`/`https` URL **or a local file path**. Local files are read and sent inline as base64 data URLs, with a 25MB guardrail (verified up to a 14MB video / ~18MB body, HTTP 200). Files larger than 25MB must be hosted at a public URL instead. Local input is validated by extension + magic-byte signature before encoding, so non-media files are rejected.
92+
Each media tool accepts a public `http`/`https` URL **or a local file path**. Local files are read and sent inline as base64, with a 25MB guardrail (verified up to a 14MB video / ~18MB body on Qwen3.7-Plus, and an 8.8MB video / ~11.7MB base64 body on Qwen3.5-Omni, both HTTP 200). Files larger than 25MB must be hosted at a public URL instead. Local input is validated by extension + magic-byte signature before encoding, so non-media files are rejected.
93+
94+
`analyze_audio` / `analyze_audio_video` use the omni model (`QWEN_OMNI_MODEL`, default `qwen3.5-omni-plus`) and force text-only output. Audio is sent as an `input_audio` block in the `data:;base64,<b64>` form with a `format` field (mp3/wav/flac/ogg/m4a/aac).
8995

9096
## Development
9197

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "qwen-omni-mcp",
3-
"version": "0.2.2",
4-
"description": "MCP server for video/image understanding via Bailian (DashScope) Qwen3.7-Plus",
3+
"version": "0.3.0",
4+
"description": "MCP server for video/image/audio understanding via Bailian (DashScope) Qwen3.7-Plus + Qwen3.5-Omni",
55
"license": "MIT",
66
"repository": {
77
"type": "git",

src/bailian.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import type { AppConfig } from "./config.js";
22

3-
export type MediaKind = "video" | "image";
3+
export type MediaKind = "video" | "image" | "audio";
44

55
export interface AnalyzeParams {
66
kind: MediaKind;
77
url: string;
88
prompt: string;
99
maxTokens: number;
10+
/** Per-call model override. Falls back to `cfg.model` when omitted. */
11+
model?: string;
12+
/** Audio format for `kind: "audio"` (e.g. "mp3", "wav"). Required for audio. */
13+
audioFormat?: string;
14+
/** Output modalities. Omni calls send `["text"]` to force text-only output. */
15+
modalities?: string[];
1016
}
1117

1218
export interface AnalyzeResult {
@@ -41,20 +47,42 @@ export function contentBlock(kind: MediaKind, url: string): Record<string, unkno
4147
if (kind === "video") {
4248
return { type: "video_url", video_url: { url } };
4349
}
44-
return { type: "image_url", image_url: { url } };
50+
if (kind === "image") {
51+
return { type: "image_url", image_url: { url } };
52+
}
53+
// audio uses input_audio with {data, format}; handled in buildPayload via audioBlock.
54+
return audioBlock(url, "");
55+
}
56+
57+
/**
58+
* Audio content block for the OpenAI-compatible `input_audio` type. DashScope
59+
* requires `data` to be a URL or a `data:;base64,<b64>` data URL (NOT raw
60+
* base64 — raw base64 is rejected as "URL does not appear to be valid"),
61+
* plus a `format` field carrying the actual codec. Verified live for mp3/wav.
62+
*/
63+
export function audioBlock(data: string, format: string): Record<string, unknown> {
64+
return { type: "input_audio", input_audio: { data, format } };
4565
}
4666

4767
export function buildPayload(cfg: AppConfig, params: AnalyzeParams): Record<string, unknown> {
48-
return {
49-
model: cfg.model,
68+
const mediaBlock =
69+
params.kind === "audio"
70+
? audioBlock(params.url, params.audioFormat ?? "")
71+
: contentBlock(params.kind, params.url);
72+
const payload: Record<string, unknown> = {
73+
model: params.model ?? cfg.model,
5074
max_tokens: params.maxTokens,
5175
messages: [
5276
{
5377
role: "user",
54-
content: [{ type: "text", text: params.prompt }, contentBlock(params.kind, params.url)],
78+
content: [{ type: "text", text: params.prompt }, mediaBlock],
5579
},
5680
],
5781
};
82+
if (params.modalities) {
83+
payload.modalities = params.modalities;
84+
}
85+
return payload;
5886
}
5987

6088
function chatCompletionsUrl(cfg: AppConfig): string {

src/config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@ import "dotenv/config";
33
export interface AppConfig {
44
apiKey: string;
55
model: string;
6+
omniModel: string;
67
baseUrl: string;
78
timeoutMs: number;
89
}
910

11+
/** Multimodal model for video/image analysis (text+image+video, no audio). */
1012
export const DEFAULT_MODEL = "qwen3.7-plus";
13+
/** Omni model for audio and audio-video analysis (native audio understanding). */
14+
export const DEFAULT_OMNI_MODEL = "qwen3.5-omni-plus";
1115
export const DEFAULT_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";
1216
export const DEFAULT_TIMEOUT_SECONDS = 300;
1317

@@ -37,6 +41,7 @@ export function loadConfig(): AppConfig {
3741
return {
3842
apiKey: required("DASHSCOPE_API_KEY"),
3943
model: process.env.QWEN_MODEL?.trim() || DEFAULT_MODEL,
44+
omniModel: process.env.QWEN_OMNI_MODEL?.trim() || DEFAULT_OMNI_MODEL,
4045
baseUrl: process.env.DASHSCOPE_BASE_URL?.trim() || DEFAULT_BASE_URL,
4146
timeoutMs: positiveInt("QWEN_REQUEST_TIMEOUT", DEFAULT_TIMEOUT_SECONDS) * 1000,
4247
};

0 commit comments

Comments
 (0)