audial-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- audial_mcp-0.1.0/.gitignore +8 -0
- audial_mcp-0.1.0/LICENSE +21 -0
- audial_mcp-0.1.0/PKG-INFO +102 -0
- audial_mcp-0.1.0/README.md +80 -0
- audial_mcp-0.1.0/pyproject.toml +57 -0
- audial_mcp-0.1.0/server.json +25 -0
- audial_mcp-0.1.0/src/audial_mcp/__init__.py +15 -0
- audial_mcp-0.1.0/src/audial_mcp/__main__.py +4 -0
- audial_mcp-0.1.0/src/audial_mcp/config.py +85 -0
- audial_mcp-0.1.0/src/audial_mcp/errors.py +34 -0
- audial_mcp-0.1.0/src/audial_mcp/jobs.py +72 -0
- audial_mcp-0.1.0/src/audial_mcp/results.py +88 -0
- audial_mcp-0.1.0/src/audial_mcp/server.py +701 -0
- audial_mcp-0.1.0/src/audial_mcp/validation.py +32 -0
- audial_mcp-0.1.0/tests/conftest.py +6 -0
- audial_mcp-0.1.0/tests/test_config.py +96 -0
- audial_mcp-0.1.0/tests/test_errors.py +36 -0
- audial_mcp-0.1.0/tests/test_jobs.py +85 -0
- audial_mcp-0.1.0/tests/test_results.py +43 -0
- audial_mcp-0.1.0/tests/test_server.py +366 -0
- audial_mcp-0.1.0/tests/test_validation.py +45 -0
- audial_mcp-0.1.0/uv.lock +1350 -0
audial_mcp-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Audial
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: audial-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for Audial: stem splitting, mastering, MIDI, music generation, resynthesis and vocal synthesis from any MCP client.
|
|
5
|
+
Project-URL: Homepage, https://audialmusic.ai
|
|
6
|
+
Project-URL: Documentation, https://audialmusic.ai/resources/api-reference
|
|
7
|
+
Project-URL: Repository, https://github.com/AudialAI/audial-mcp
|
|
8
|
+
Author-email: Audial <contact@audialmusic.ai>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: audial,audio,mcp,model-context-protocol,music,stems
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: anyio>=4
|
|
17
|
+
Requires-Dist: audial-sdk>=1.2.3
|
|
18
|
+
Requires-Dist: mcp<3,>=2.2
|
|
19
|
+
Requires-Dist: pydantic>=2
|
|
20
|
+
Requires-Dist: typing-extensions>=4
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# audial-mcp
|
|
24
|
+
|
|
25
|
+
Run Audial's hosted audio tools from any MCP client: split stems, analyze and segment audio,
|
|
26
|
+
master tracks, build sample packs, convert audio to MIDI, generate music, turn a one-shot into
|
|
27
|
+
an editable Audial Synth preset, and sing lyrics in a reference voice.
|
|
28
|
+
|
|
29
|
+
mcp-name: io.github.AudialAI/audial-mcp
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
You need an Audial account (user id + API key from https://audialmusic.ai) and
|
|
34
|
+
[`uv`](https://docs.astral.sh/uv/getting-started/installation/).
|
|
35
|
+
|
|
36
|
+
**Claude Code**
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
claude mcp add audial \
|
|
40
|
+
-e AUDIAL_USER_ID=your-user-id -e AUDIAL_API_KEY=your-api-key -e AUDIAL_RESULTS_DIR=~/Audial \
|
|
41
|
+
-- uvx audial-mcp
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**Claude Desktop / Cursor / any client with a JSON config**
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"mcpServers": {
|
|
49
|
+
"audial": {
|
|
50
|
+
"command": "uvx",
|
|
51
|
+
"args": ["audial-mcp"],
|
|
52
|
+
"env": {
|
|
53
|
+
"AUDIAL_USER_ID": "your-user-id",
|
|
54
|
+
"AUDIAL_API_KEY": "your-api-key",
|
|
55
|
+
"AUDIAL_RESULTS_DIR": "~/Audial"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Tools
|
|
63
|
+
|
|
64
|
+
| Tool | What it does |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `stem_split` | Split a track into vocals, drums, bass, other (optionally retime / rekey) |
|
|
67
|
+
| `analyze` | BPM, key and other characteristics |
|
|
68
|
+
| `segment` | Sections and component analysis |
|
|
69
|
+
| `master` | Mastering, optionally matched to a reference |
|
|
70
|
+
| `generate_samples` | Sample pack from a track |
|
|
71
|
+
| `generate_midi` | Audio to MIDI |
|
|
72
|
+
| `generate_music` | Text to music, covers, remixes, extraction, completion with the Audial music model |
|
|
73
|
+
| `sound2vital` | One-shot → editable Audial Synth (Vital) preset |
|
|
74
|
+
| `text2vox` | Lyrics + reference voice → sung vocal and MIDI |
|
|
75
|
+
| `list_results` | Browse previous results in your results folder |
|
|
76
|
+
|
|
77
|
+
## What leaves your machine
|
|
78
|
+
|
|
79
|
+
Audio and text you pass to a tool are uploaded to Audial's API (https://api.audialmusic.ai)
|
|
80
|
+
over HTTPS and processed on Audial's servers; results are downloaded into
|
|
81
|
+
`AUDIAL_RESULTS_DIR` (default `~/Audial`). Nothing else is sent.
|
|
82
|
+
|
|
83
|
+
Two other files are *read* if they exist, because the Audial SDK looks for them:
|
|
84
|
+
`~/.audial/.audial_config.json` and a `.env` in the directory the server was started from.
|
|
85
|
+
Credentials from your MCP client's config always win: `audial-mcp` snapshots its environment
|
|
86
|
+
before the SDK loads either file, so neither can redirect your credentials or the API host.
|
|
87
|
+
|
|
88
|
+
Some tools require an active Audial subscription; the tool tells you when that is the case.
|
|
89
|
+
|
|
90
|
+
## Configuration
|
|
91
|
+
|
|
92
|
+
| Variable | Required | Default |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| `AUDIAL_USER_ID` | yes | |
|
|
95
|
+
| `AUDIAL_API_KEY` | yes | |
|
|
96
|
+
| `AUDIAL_RESULTS_DIR` | no | `~/Audial` |
|
|
97
|
+
| `AUDIAL_JOB_TIMEOUT_S` | no | `900` |
|
|
98
|
+
| `AUDIAL_API_BASE_URL` | no | production API |
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT. Source: https://github.com/AudialAI/audial-mcp
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# audial-mcp
|
|
2
|
+
|
|
3
|
+
Run Audial's hosted audio tools from any MCP client: split stems, analyze and segment audio,
|
|
4
|
+
master tracks, build sample packs, convert audio to MIDI, generate music, turn a one-shot into
|
|
5
|
+
an editable Audial Synth preset, and sing lyrics in a reference voice.
|
|
6
|
+
|
|
7
|
+
mcp-name: io.github.AudialAI/audial-mcp
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
You need an Audial account (user id + API key from https://audialmusic.ai) and
|
|
12
|
+
[`uv`](https://docs.astral.sh/uv/getting-started/installation/).
|
|
13
|
+
|
|
14
|
+
**Claude Code**
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
claude mcp add audial \
|
|
18
|
+
-e AUDIAL_USER_ID=your-user-id -e AUDIAL_API_KEY=your-api-key -e AUDIAL_RESULTS_DIR=~/Audial \
|
|
19
|
+
-- uvx audial-mcp
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**Claude Desktop / Cursor / any client with a JSON config**
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"mcpServers": {
|
|
27
|
+
"audial": {
|
|
28
|
+
"command": "uvx",
|
|
29
|
+
"args": ["audial-mcp"],
|
|
30
|
+
"env": {
|
|
31
|
+
"AUDIAL_USER_ID": "your-user-id",
|
|
32
|
+
"AUDIAL_API_KEY": "your-api-key",
|
|
33
|
+
"AUDIAL_RESULTS_DIR": "~/Audial"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Tools
|
|
41
|
+
|
|
42
|
+
| Tool | What it does |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `stem_split` | Split a track into vocals, drums, bass, other (optionally retime / rekey) |
|
|
45
|
+
| `analyze` | BPM, key and other characteristics |
|
|
46
|
+
| `segment` | Sections and component analysis |
|
|
47
|
+
| `master` | Mastering, optionally matched to a reference |
|
|
48
|
+
| `generate_samples` | Sample pack from a track |
|
|
49
|
+
| `generate_midi` | Audio to MIDI |
|
|
50
|
+
| `generate_music` | Text to music, covers, remixes, extraction, completion with the Audial music model |
|
|
51
|
+
| `sound2vital` | One-shot → editable Audial Synth (Vital) preset |
|
|
52
|
+
| `text2vox` | Lyrics + reference voice → sung vocal and MIDI |
|
|
53
|
+
| `list_results` | Browse previous results in your results folder |
|
|
54
|
+
|
|
55
|
+
## What leaves your machine
|
|
56
|
+
|
|
57
|
+
Audio and text you pass to a tool are uploaded to Audial's API (https://api.audialmusic.ai)
|
|
58
|
+
over HTTPS and processed on Audial's servers; results are downloaded into
|
|
59
|
+
`AUDIAL_RESULTS_DIR` (default `~/Audial`). Nothing else is sent.
|
|
60
|
+
|
|
61
|
+
Two other files are *read* if they exist, because the Audial SDK looks for them:
|
|
62
|
+
`~/.audial/.audial_config.json` and a `.env` in the directory the server was started from.
|
|
63
|
+
Credentials from your MCP client's config always win: `audial-mcp` snapshots its environment
|
|
64
|
+
before the SDK loads either file, so neither can redirect your credentials or the API host.
|
|
65
|
+
|
|
66
|
+
Some tools require an active Audial subscription; the tool tells you when that is the case.
|
|
67
|
+
|
|
68
|
+
## Configuration
|
|
69
|
+
|
|
70
|
+
| Variable | Required | Default |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `AUDIAL_USER_ID` | yes | |
|
|
73
|
+
| `AUDIAL_API_KEY` | yes | |
|
|
74
|
+
| `AUDIAL_RESULTS_DIR` | no | `~/Audial` |
|
|
75
|
+
| `AUDIAL_JOB_TIMEOUT_S` | no | `900` |
|
|
76
|
+
| `AUDIAL_API_BASE_URL` | no | production API |
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT. Source: https://github.com/AudialAI/audial-mcp
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "audial-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server for Audial: stem splitting, mastering, MIDI, music generation, resynthesis and vocal synthesis from any MCP client."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Audial", email = "contact@audialmusic.ai" }]
|
|
13
|
+
keywords = ["mcp", "audio", "music", "stems", "audial", "model-context-protocol"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Topic :: Multimedia :: Sound/Audio",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"mcp>=2.2,<3",
|
|
21
|
+
"audial-sdk>=1.2.3",
|
|
22
|
+
"anyio>=4",
|
|
23
|
+
"pydantic>=2",
|
|
24
|
+
"typing_extensions>=4",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://audialmusic.ai"
|
|
29
|
+
Documentation = "https://audialmusic.ai/resources/api-reference"
|
|
30
|
+
Repository = "https://github.com/AudialAI/audial-mcp"
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
audial-mcp = "audial_mcp.server:main"
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = ["pytest>=8", "anyio>=4", "ruff>=0.6", "build>=1.2"]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/audial_mcp"]
|
|
40
|
+
|
|
41
|
+
# The sdist ships the package and its metadata, not the project's working notes: docs/ is
|
|
42
|
+
# design and checklist material, .github/ is CI, .superpowers/ is planning scratch.
|
|
43
|
+
[tool.hatch.build.targets.sdist]
|
|
44
|
+
exclude = ["docs", ".github", ".superpowers"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
line-length = 100
|
|
51
|
+
target-version = "py310"
|
|
52
|
+
# `ruff format` rewrites Python code blocks inside Markdown, which reflows the illustrative
|
|
53
|
+
# snippets in the design and plan documents. Those are prose, not code that runs.
|
|
54
|
+
extend-exclude = ["docs"]
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint]
|
|
57
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.AudialAI/audial-mcp",
|
|
4
|
+
"description": "Stem splitting, analysis, mastering, sample packs, audio-to-MIDI, music generation, resynthesis and vocal synthesis with Audial's hosted engines.",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"repository": { "url": "https://github.com/AudialAI/audial-mcp", "source": "github" },
|
|
7
|
+
"websiteUrl": "https://audialmusic.ai/resources/api-reference",
|
|
8
|
+
"packages": [
|
|
9
|
+
{
|
|
10
|
+
"registryType": "pypi",
|
|
11
|
+
"registryBaseUrl": "https://pypi.org",
|
|
12
|
+
"identifier": "audial-mcp",
|
|
13
|
+
"version": "0.1.0",
|
|
14
|
+
"runtimeHint": "uvx",
|
|
15
|
+
"transport": { "type": "stdio" },
|
|
16
|
+
"environmentVariables": [
|
|
17
|
+
{ "name": "AUDIAL_USER_ID", "description": "Audial user id", "isRequired": true, "isSecret": false },
|
|
18
|
+
{ "name": "AUDIAL_API_KEY", "description": "Audial API key", "isRequired": true, "isSecret": true },
|
|
19
|
+
{ "name": "AUDIAL_RESULTS_DIR", "description": "Folder results are written to", "default": "~/Audial" },
|
|
20
|
+
{ "name": "AUDIAL_JOB_TIMEOUT_S", "description": "Seconds to wait for a job before giving up", "default": "900" },
|
|
21
|
+
{ "name": "AUDIAL_API_BASE_URL", "description": "Override the Audial API base URL (advanced)", "isRequired": false, "isSecret": false }
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""audial-mcp: a local MCP server for the Audial audio tools."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
|
|
7
|
+
# The environment as the client handed it to us, snapshotted before anything else runs.
|
|
8
|
+
#
|
|
9
|
+
# `import audial` calls `dotenv.load_dotenv()`, which copies a `.env` in the *process's working
|
|
10
|
+
# directory* into `os.environ`. The working directory of an MCP server is the client's, not the
|
|
11
|
+
# user's project, and a `.env` that happens to sit there would silently redirect credentials and
|
|
12
|
+
# the API host. This package is imported before `audial_mcp.server` executes its `import audial`
|
|
13
|
+
# (importing a submodule initialises its package first), so this snapshot predates that call.
|
|
14
|
+
# `config.load_settings()` reads from here rather than from the live `os.environ`.
|
|
15
|
+
BOOT_ENV: dict[str, str] = dict(os.environ)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Environment-driven settings for the audial MCP server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from audial_mcp import BOOT_ENV
|
|
12
|
+
|
|
13
|
+
DEFAULT_RESULTS_DIR = "~/Audial"
|
|
14
|
+
DEFAULT_JOB_TIMEOUT_S = 900
|
|
15
|
+
|
|
16
|
+
CREDENTIALS_HELP = (
|
|
17
|
+
"AUDIAL_USER_ID and AUDIAL_API_KEY are not set. Add both to the `audial` server's env in "
|
|
18
|
+
"your MCP client config (for Claude Code: `claude mcp add audial -e AUDIAL_USER_ID=... "
|
|
19
|
+
"-e AUDIAL_API_KEY=... -- uvx audial-mcp`). Get them from your dashboard at "
|
|
20
|
+
"https://audialmusic.ai, then restart the client."
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConfigError(Exception):
|
|
25
|
+
"""Raised when the environment cannot be turned into usable settings."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Settings:
|
|
30
|
+
user_id: str | None
|
|
31
|
+
api_key: str | None
|
|
32
|
+
results_dir: Path
|
|
33
|
+
api_base_url: str | None
|
|
34
|
+
job_timeout_s: int
|
|
35
|
+
|
|
36
|
+
def require_credentials(self) -> None:
|
|
37
|
+
if not self.user_id or not self.api_key:
|
|
38
|
+
raise ConfigError(CREDENTIALS_HELP)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _always_parseable(env: Mapping[str, str]) -> dict[str, Any]:
|
|
42
|
+
"""The settings fields that cannot fail to parse: credentials, base URL, results dir."""
|
|
43
|
+
results_dir = Path(
|
|
44
|
+
os.path.expandvars(env.get("AUDIAL_RESULTS_DIR") or DEFAULT_RESULTS_DIR)
|
|
45
|
+
).expanduser()
|
|
46
|
+
return {
|
|
47
|
+
"user_id": (env.get("AUDIAL_USER_ID") or "").strip() or None,
|
|
48
|
+
"api_key": (env.get("AUDIAL_API_KEY") or "").strip() or None,
|
|
49
|
+
"results_dir": results_dir,
|
|
50
|
+
"api_base_url": (env.get("AUDIAL_API_BASE_URL") or "").strip() or None,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_settings(env: Mapping[str, str] | None = None) -> Settings:
|
|
55
|
+
"""Read settings from `env`, defaulting to the environment as it was at import time.
|
|
56
|
+
|
|
57
|
+
The default is `audial_mcp.BOOT_ENV`, not the live `os.environ`: the Audial SDK calls
|
|
58
|
+
`dotenv.load_dotenv()` when it is imported, so a `.env` file in whatever directory the MCP
|
|
59
|
+
client happened to launch the server from would otherwise be able to override the
|
|
60
|
+
credentials and API host the client configured.
|
|
61
|
+
"""
|
|
62
|
+
env = BOOT_ENV if env is None else env
|
|
63
|
+
raw_timeout = env.get("AUDIAL_JOB_TIMEOUT_S", str(DEFAULT_JOB_TIMEOUT_S))
|
|
64
|
+
try:
|
|
65
|
+
timeout = int(raw_timeout)
|
|
66
|
+
if timeout <= 0:
|
|
67
|
+
raise ValueError
|
|
68
|
+
except ValueError:
|
|
69
|
+
raise ConfigError(
|
|
70
|
+
"AUDIAL_JOB_TIMEOUT_S must be a positive integer number of seconds, "
|
|
71
|
+
f"got {raw_timeout!r}"
|
|
72
|
+
) from None
|
|
73
|
+
|
|
74
|
+
return Settings(**_always_parseable(env), job_timeout_s=timeout)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def fallback_settings(env: Mapping[str, str] | None = None) -> Settings:
|
|
78
|
+
"""Settings to run with when `load_settings` raised: what parsed, defaults for what did not.
|
|
79
|
+
|
|
80
|
+
The server keeps starting on a bad `AUDIAL_JOB_TIMEOUT_S` so that it can list its tools and
|
|
81
|
+
return the configuration error as a readable message. A server that dies at import shows up
|
|
82
|
+
in clients as an opaque "failed to start".
|
|
83
|
+
"""
|
|
84
|
+
env = BOOT_ENV if env is None else env
|
|
85
|
+
return Settings(**_always_parseable(env), job_timeout_s=DEFAULT_JOB_TIMEOUT_S)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Turn every failure into a ToolError the model can read and act on."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from audial.api.exceptions import AudialAuthError, AudialError, SubscriptionRequiredError
|
|
8
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
9
|
+
|
|
10
|
+
from audial_mcp.config import ConfigError
|
|
11
|
+
from audial_mcp.jobs import JobTimeout
|
|
12
|
+
from audial_mcp.validation import ValidationError
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger("audial_mcp")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def to_tool_error(exc: BaseException, *, tool: str, execution_id: str | None = None) -> ToolError:
|
|
18
|
+
if isinstance(exc, (ConfigError, ValidationError, SubscriptionRequiredError)):
|
|
19
|
+
return ToolError(str(exc))
|
|
20
|
+
if isinstance(exc, AudialAuthError):
|
|
21
|
+
return ToolError(
|
|
22
|
+
"Audial rejected the credentials. Check AUDIAL_USER_ID and AUDIAL_API_KEY in the "
|
|
23
|
+
"server's env and restart the client."
|
|
24
|
+
)
|
|
25
|
+
if isinstance(exc, JobTimeout):
|
|
26
|
+
ref = f" (execution {execution_id})" if execution_id else ""
|
|
27
|
+
return ToolError(
|
|
28
|
+
f"{exc.label} did not finish within {exc.elapsed_s:.0f} s{ref}. The job may still "
|
|
29
|
+
"complete on Audial's side; try again, or use a shorter file."
|
|
30
|
+
)
|
|
31
|
+
if isinstance(exc, AudialError):
|
|
32
|
+
return ToolError(str(exc))
|
|
33
|
+
log.exception("%s failed", tool)
|
|
34
|
+
return ToolError(f"{tool} failed unexpectedly. Details were logged to the server's stderr.")
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Run blocking SDK calls without freezing the MCP connection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
import anyio
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JobTimeout(Exception):
|
|
15
|
+
def __init__(self, label: str, elapsed_s: float):
|
|
16
|
+
super().__init__(f"{label} did not finish within {elapsed_s:.0f} s")
|
|
17
|
+
self.label = label
|
|
18
|
+
self.elapsed_s = elapsed_s
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def run_job(
|
|
22
|
+
ctx: Any,
|
|
23
|
+
label: str,
|
|
24
|
+
fn: Callable[[], T],
|
|
25
|
+
*,
|
|
26
|
+
timeout_s: float,
|
|
27
|
+
heartbeat_s: float = 5.0,
|
|
28
|
+
) -> T:
|
|
29
|
+
"""Run `fn` in a worker thread; report elapsed seconds as progress until it returns."""
|
|
30
|
+
started = time.monotonic()
|
|
31
|
+
|
|
32
|
+
async def heartbeat() -> None:
|
|
33
|
+
while True:
|
|
34
|
+
await anyio.sleep(heartbeat_s)
|
|
35
|
+
elapsed = int(time.monotonic() - started)
|
|
36
|
+
try:
|
|
37
|
+
# Progress is best-effort: a disconnected client must not
|
|
38
|
+
# decide the job's outcome, so swallow reporting failures here
|
|
39
|
+
# rather than let them cross the task group as an error.
|
|
40
|
+
await ctx.report_progress(elapsed, message=f"{label}: {elapsed} s elapsed")
|
|
41
|
+
except Exception:
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
# Exceptions (other than cancellation) are caught here rather than left to
|
|
45
|
+
# propagate out of the `async with` block: anyio's TaskGroup.__aexit__
|
|
46
|
+
# unconditionally wraps any non-CancelledError exception raised in the
|
|
47
|
+
# block's body in a BaseExceptionGroup, even when there is only one.
|
|
48
|
+
# Catching and re-raising after the block exits keeps the caller-facing
|
|
49
|
+
# exception a bare JobTimeout / the original error. CancelledError is left
|
|
50
|
+
# to propagate normally; anyio already excludes it from that wrapping.
|
|
51
|
+
#
|
|
52
|
+
# A bare `except TimeoutError` here would be wrong: fn itself may raise a
|
|
53
|
+
# TimeoutError (socket.timeout / concurrent.futures.TimeoutError are
|
|
54
|
+
# aliases of it), which must propagate as itself, not get relabeled as a
|
|
55
|
+
# JobTimeout. So the deadline is detected via move_on_after's
|
|
56
|
+
# cancelled_caught instead of by catching an exception type.
|
|
57
|
+
error: Exception | None = None
|
|
58
|
+
result: T | None = None
|
|
59
|
+
async with anyio.create_task_group() as tg:
|
|
60
|
+
tg.start_soon(heartbeat)
|
|
61
|
+
with anyio.move_on_after(timeout_s) as scope:
|
|
62
|
+
try:
|
|
63
|
+
# Cancelling to_thread does not stop the thread; it finishes quietly.
|
|
64
|
+
result = await anyio.to_thread.run_sync(fn, abandon_on_cancel=True)
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
error = exc
|
|
67
|
+
tg.cancel_scope.cancel()
|
|
68
|
+
if scope.cancelled_caught:
|
|
69
|
+
raise JobTimeout(label, time.monotonic() - started)
|
|
70
|
+
if error is not None:
|
|
71
|
+
raise error
|
|
72
|
+
return result
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Where results live and how they are reported back to the model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from typing_extensions import TypedDict
|
|
11
|
+
|
|
12
|
+
_SLUG_RE = re.compile(r"[^A-Za-z0-9_-]+")
|
|
13
|
+
SLUG_MAX = 40
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FileInfo(TypedDict):
|
|
17
|
+
name: str
|
|
18
|
+
path: str
|
|
19
|
+
size_bytes: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class JobResult(TypedDict):
|
|
23
|
+
tool: str
|
|
24
|
+
execution_id: str | None
|
|
25
|
+
output_dir: str
|
|
26
|
+
files: list[FileInfo]
|
|
27
|
+
metadata: dict[str, Any]
|
|
28
|
+
summary: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def slugify(text: str, fallback: str = "job") -> str:
|
|
32
|
+
slug = _SLUG_RE.sub("_", text.strip()).strip("_")[:SLUG_MAX]
|
|
33
|
+
return slug or fallback
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def new_job_dir(results_dir: Path, tool: str, slug: str, now: datetime | None = None) -> Path:
|
|
37
|
+
stamp = (now or datetime.now()).strftime("%Y-%m-%d_%H%M%S")
|
|
38
|
+
folder = results_dir / tool / f"{stamp}_{slug}"
|
|
39
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
return folder
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def inventory(folder: Path) -> list[FileInfo]:
|
|
44
|
+
if not folder.is_dir():
|
|
45
|
+
return []
|
|
46
|
+
files: list[FileInfo] = []
|
|
47
|
+
for path in sorted(folder.rglob("*")):
|
|
48
|
+
rel_path = path.relative_to(folder)
|
|
49
|
+
if not path.is_file() or any(part.startswith(".") for part in rel_path.parts):
|
|
50
|
+
continue
|
|
51
|
+
files.append(
|
|
52
|
+
FileInfo(
|
|
53
|
+
name=rel_path.as_posix(),
|
|
54
|
+
path=str(path.resolve()),
|
|
55
|
+
size_bytes=path.stat().st_size,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
return files
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def list_jobs(results_dir: Path, tool: str | None, limit: int) -> list[JobResult]:
|
|
62
|
+
if not results_dir.is_dir():
|
|
63
|
+
return []
|
|
64
|
+
tools = [tool] if tool else sorted(p.name for p in results_dir.iterdir() if p.is_dir())
|
|
65
|
+
jobs: list[tuple[str, JobResult]] = []
|
|
66
|
+
for name in tools:
|
|
67
|
+
tool_dir = results_dir / name
|
|
68
|
+
if not tool_dir.is_dir():
|
|
69
|
+
continue
|
|
70
|
+
for job_dir in tool_dir.iterdir():
|
|
71
|
+
if not job_dir.is_dir():
|
|
72
|
+
continue
|
|
73
|
+
files = inventory(job_dir)
|
|
74
|
+
jobs.append(
|
|
75
|
+
(
|
|
76
|
+
job_dir.name,
|
|
77
|
+
JobResult(
|
|
78
|
+
tool=name,
|
|
79
|
+
execution_id=None,
|
|
80
|
+
output_dir=str(job_dir),
|
|
81
|
+
files=files,
|
|
82
|
+
metadata={},
|
|
83
|
+
summary=f"{name}: {len(files)} file(s) in {job_dir}",
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
jobs.sort(key=lambda item: item[0], reverse=True)
|
|
88
|
+
return [job for _, job in jobs[: max(limit, 0)]]
|