macwhisper-mcp-server 1.0.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.
- macwhisper_mcp_server-1.0.0/LICENSE +21 -0
- macwhisper_mcp_server-1.0.0/PKG-INFO +184 -0
- macwhisper_mcp_server-1.0.0/README.md +158 -0
- macwhisper_mcp_server-1.0.0/pyproject.toml +56 -0
- macwhisper_mcp_server-1.0.0/setup.cfg +4 -0
- macwhisper_mcp_server-1.0.0/src/macwhisper_mcp/__init__.py +3 -0
- macwhisper_mcp_server-1.0.0/src/macwhisper_mcp/config.py +61 -0
- macwhisper_mcp_server-1.0.0/src/macwhisper_mcp/server.py +139 -0
- macwhisper_mcp_server-1.0.0/src/macwhisper_mcp/transcribe.py +118 -0
- macwhisper_mcp_server-1.0.0/src/macwhisper_mcp/watcher.py +136 -0
- macwhisper_mcp_server-1.0.0/src/macwhisper_mcp_server.egg-info/PKG-INFO +184 -0
- macwhisper_mcp_server-1.0.0/src/macwhisper_mcp_server.egg-info/SOURCES.txt +18 -0
- macwhisper_mcp_server-1.0.0/src/macwhisper_mcp_server.egg-info/dependency_links.txt +1 -0
- macwhisper_mcp_server-1.0.0/src/macwhisper_mcp_server.egg-info/entry_points.txt +2 -0
- macwhisper_mcp_server-1.0.0/src/macwhisper_mcp_server.egg-info/requires.txt +6 -0
- macwhisper_mcp_server-1.0.0/src/macwhisper_mcp_server.egg-info/top_level.txt +1 -0
- macwhisper_mcp_server-1.0.0/tests/test_config.py +74 -0
- macwhisper_mcp_server-1.0.0/tests/test_integration.py +75 -0
- macwhisper_mcp_server-1.0.0/tests/test_transcribe.py +138 -0
- macwhisper_mcp_server-1.0.0/tests/test_watcher.py +133 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thomas
|
|
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,184 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: macwhisper-mcp-server
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Local MCP server exposing MacWhisper transcription to Claude Desktop.
|
|
5
|
+
Author: Thomas
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/docdyhr/macwhisper-mcp-server
|
|
8
|
+
Project-URL: Issues, https://github.com/docdyhr/macwhisper-mcp-server/issues
|
|
9
|
+
Keywords: mcp,macwhisper,transcription,claude,llm
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Environment :: MacOS X
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
17
|
+
Requires-Python: <3.14,>=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: fastmcp==3.2.4
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-mock>=3.12; extra == "dev"
|
|
24
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# macwhisper-mcp-server
|
|
28
|
+
|
|
29
|
+
Local MCP server that connects [MacWhisper](https://goodsnooze.gumroad.com/l/macwhisper) to [Claude Desktop](https://claude.ai/download).
|
|
30
|
+
|
|
31
|
+
**What it does:** Drop an audio file on your Desktop, then ask Claude to transcribe it, summarise it, or pull out action items — in one step. MacWhisper does the transcription on your Mac; Claude does the thinking. Nothing leaves your machine. No cloud APIs. No data ever leaves your Mac.
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
Audio file → MacWhisper CLI → MCP server → Claude Desktop
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
[](https://github.com/docdyhr/macwhisper-mcp-server/actions/workflows/ci.yml)
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+

|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Requirements
|
|
46
|
+
|
|
47
|
+
- macOS (MacWhisper is macOS-only)
|
|
48
|
+
- [MacWhisper](https://goodsnooze.gumroad.com/l/macwhisper) — installed, licensed, CLI enabled in Settings
|
|
49
|
+
- Python 3.13.x via [pyenv](https://github.com/pyenv/pyenv)
|
|
50
|
+
- [Claude Desktop](https://claude.ai/download)
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
git clone https://github.com/docdyhr/macwhisper-mcp-server.git
|
|
58
|
+
cd macwhisper-mcp-server
|
|
59
|
+
|
|
60
|
+
pyenv install 3.13.13 # skip if already installed
|
|
61
|
+
pyenv local 3.13.13
|
|
62
|
+
python -m venv .venv
|
|
63
|
+
source .venv/bin/activate
|
|
64
|
+
pip install -e .
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Verify the MacWhisper CLI is reachable:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
/Applications/MacWhisper.app/Contents/MacOS/mw --help
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
If you get "command not found": open MacWhisper → Settings → enable CLI.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Configure Claude Desktop
|
|
78
|
+
|
|
79
|
+
Edit `~/Library/Application Support/Claude/claude_desktop_config.json` and add:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"mcpServers": {
|
|
84
|
+
"macwhisper": {
|
|
85
|
+
"command": "/Users/<you>/macwhisper-mcp-server/.venv/bin/macwhisper-mcp",
|
|
86
|
+
"args": [],
|
|
87
|
+
"env": {
|
|
88
|
+
"MACWHISPER_ALLOWED_PATHS": "/Users/<you>/Desktop:/Users/<you>/Downloads"
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Replace `<you>` with your macOS username. Restart Claude Desktop.
|
|
96
|
+
|
|
97
|
+
### Verify it works
|
|
98
|
+
|
|
99
|
+
In Claude Desktop, ask:
|
|
100
|
+
|
|
101
|
+
> Transcribe ~/Desktop/memo.m4a
|
|
102
|
+
|
|
103
|
+
You should see a `transcribe_audio` tool call appear, followed by the transcript.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Available tools
|
|
108
|
+
|
|
109
|
+
| Tool | Description |
|
|
110
|
+
|------|-------------|
|
|
111
|
+
| `transcribe_audio(path, model?)` | Transcribe an audio file and return the transcript as plain text |
|
|
112
|
+
| `cancel_transcription()` | Cancel the currently running transcription |
|
|
113
|
+
| `list_allowed_paths()` | Return the directories the server is allowed to read from |
|
|
114
|
+
| `start_watch(folder)` | Watch a folder and auto-transcribe new audio files into `../done/` |
|
|
115
|
+
| `stop_watch()` | Stop the active folder watcher |
|
|
116
|
+
| `get_watch_results()` | Return completed watch-folder transcriptions and clear the queue |
|
|
117
|
+
|
|
118
|
+
Supported audio formats: `.m4a` `.mp3` `.mp4` `.mov` `.wav` `.aiff` `.flac`
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Configuration
|
|
123
|
+
|
|
124
|
+
All configuration is via environment variables. Pass them through the `env` dict in `claude_desktop_config.json` (for Claude Desktop) or set them in `.env` for local development.
|
|
125
|
+
|
|
126
|
+
| Env var | Default | Description |
|
|
127
|
+
|---------|---------|-------------|
|
|
128
|
+
| `MACWHISPER_ALLOWED_PATHS` | `~/Desktop` | Colon-separated list of directories the server may read from |
|
|
129
|
+
| `MACWHISPER_CLI` | auto-detected | Path to the `mw` binary. Defaults to `/Applications/MacWhisper.app/Contents/MacOS/mw` if that file exists, otherwise `mw` on `PATH` |
|
|
130
|
+
| `MACWHISPER_LOG_PATH` | `~/Library/Logs/macwhisper-mcp.log` | Log file path (never stdout — that's reserved for MCP) |
|
|
131
|
+
|
|
132
|
+
**Local development:** copy `.env.example` to `.env` and adjust. With [direnv](https://direnv.net/), `.envrc` exports `.env` automatically. Without direnv: `source .env`.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Development
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
source .venv/bin/activate
|
|
140
|
+
pip install -e ".[dev]"
|
|
141
|
+
|
|
142
|
+
# Tests
|
|
143
|
+
pytest -q
|
|
144
|
+
|
|
145
|
+
# Lint + format
|
|
146
|
+
ruff check .
|
|
147
|
+
ruff format .
|
|
148
|
+
|
|
149
|
+
# Pre-commit hooks (one-time setup)
|
|
150
|
+
pip install pre-commit
|
|
151
|
+
pre-commit install
|
|
152
|
+
|
|
153
|
+
# Smoke-test against a real audio file (server must not be running in Claude Desktop)
|
|
154
|
+
python scripts/smoke_test.py ~/Downloads/Test.m4a
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Logs
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
tail -f ~/Library/Logs/macwhisper-mcp.log
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Security
|
|
166
|
+
|
|
167
|
+
- All file paths are resolved (symlinks followed) and checked against the `MACWHISPER_ALLOWED_PATHS` allow-list before anything reaches the CLI.
|
|
168
|
+
- `subprocess.run` is always called with an argv list — never `shell=True`.
|
|
169
|
+
- No network calls. Ever.
|
|
170
|
+
|
|
171
|
+
See [PRD §7](./PRD.md) for the full threat model.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Known limitations
|
|
176
|
+
|
|
177
|
+
- **Danish letter names:** Whisper may phonetically approximate letter names (e.g. "Æ, Ø, Å" → "E, Y, U") when they are spoken in isolation. Letters *inside words* transcribe correctly. This is a Whisper engine limitation, not a bug in this wrapper. See [PRD §12](./PRD.md).
|
|
178
|
+
- **Cold-start latency:** First transcription after MacWhisper launches takes ~13s (model load). Subsequent calls are ~2s.
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
MIT — see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# macwhisper-mcp-server
|
|
2
|
+
|
|
3
|
+
Local MCP server that connects [MacWhisper](https://goodsnooze.gumroad.com/l/macwhisper) to [Claude Desktop](https://claude.ai/download).
|
|
4
|
+
|
|
5
|
+
**What it does:** Drop an audio file on your Desktop, then ask Claude to transcribe it, summarise it, or pull out action items — in one step. MacWhisper does the transcription on your Mac; Claude does the thinking. Nothing leaves your machine. No cloud APIs. No data ever leaves your Mac.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Audio file → MacWhisper CLI → MCP server → Claude Desktop
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
[](https://github.com/docdyhr/macwhisper-mcp-server/actions/workflows/ci.yml)
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Requirements
|
|
20
|
+
|
|
21
|
+
- macOS (MacWhisper is macOS-only)
|
|
22
|
+
- [MacWhisper](https://goodsnooze.gumroad.com/l/macwhisper) — installed, licensed, CLI enabled in Settings
|
|
23
|
+
- Python 3.13.x via [pyenv](https://github.com/pyenv/pyenv)
|
|
24
|
+
- [Claude Desktop](https://claude.ai/download)
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
git clone https://github.com/docdyhr/macwhisper-mcp-server.git
|
|
32
|
+
cd macwhisper-mcp-server
|
|
33
|
+
|
|
34
|
+
pyenv install 3.13.13 # skip if already installed
|
|
35
|
+
pyenv local 3.13.13
|
|
36
|
+
python -m venv .venv
|
|
37
|
+
source .venv/bin/activate
|
|
38
|
+
pip install -e .
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Verify the MacWhisper CLI is reachable:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
/Applications/MacWhisper.app/Contents/MacOS/mw --help
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
If you get "command not found": open MacWhisper → Settings → enable CLI.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Configure Claude Desktop
|
|
52
|
+
|
|
53
|
+
Edit `~/Library/Application Support/Claude/claude_desktop_config.json` and add:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"mcpServers": {
|
|
58
|
+
"macwhisper": {
|
|
59
|
+
"command": "/Users/<you>/macwhisper-mcp-server/.venv/bin/macwhisper-mcp",
|
|
60
|
+
"args": [],
|
|
61
|
+
"env": {
|
|
62
|
+
"MACWHISPER_ALLOWED_PATHS": "/Users/<you>/Desktop:/Users/<you>/Downloads"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Replace `<you>` with your macOS username. Restart Claude Desktop.
|
|
70
|
+
|
|
71
|
+
### Verify it works
|
|
72
|
+
|
|
73
|
+
In Claude Desktop, ask:
|
|
74
|
+
|
|
75
|
+
> Transcribe ~/Desktop/memo.m4a
|
|
76
|
+
|
|
77
|
+
You should see a `transcribe_audio` tool call appear, followed by the transcript.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Available tools
|
|
82
|
+
|
|
83
|
+
| Tool | Description |
|
|
84
|
+
|------|-------------|
|
|
85
|
+
| `transcribe_audio(path, model?)` | Transcribe an audio file and return the transcript as plain text |
|
|
86
|
+
| `cancel_transcription()` | Cancel the currently running transcription |
|
|
87
|
+
| `list_allowed_paths()` | Return the directories the server is allowed to read from |
|
|
88
|
+
| `start_watch(folder)` | Watch a folder and auto-transcribe new audio files into `../done/` |
|
|
89
|
+
| `stop_watch()` | Stop the active folder watcher |
|
|
90
|
+
| `get_watch_results()` | Return completed watch-folder transcriptions and clear the queue |
|
|
91
|
+
|
|
92
|
+
Supported audio formats: `.m4a` `.mp3` `.mp4` `.mov` `.wav` `.aiff` `.flac`
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Configuration
|
|
97
|
+
|
|
98
|
+
All configuration is via environment variables. Pass them through the `env` dict in `claude_desktop_config.json` (for Claude Desktop) or set them in `.env` for local development.
|
|
99
|
+
|
|
100
|
+
| Env var | Default | Description |
|
|
101
|
+
|---------|---------|-------------|
|
|
102
|
+
| `MACWHISPER_ALLOWED_PATHS` | `~/Desktop` | Colon-separated list of directories the server may read from |
|
|
103
|
+
| `MACWHISPER_CLI` | auto-detected | Path to the `mw` binary. Defaults to `/Applications/MacWhisper.app/Contents/MacOS/mw` if that file exists, otherwise `mw` on `PATH` |
|
|
104
|
+
| `MACWHISPER_LOG_PATH` | `~/Library/Logs/macwhisper-mcp.log` | Log file path (never stdout — that's reserved for MCP) |
|
|
105
|
+
|
|
106
|
+
**Local development:** copy `.env.example` to `.env` and adjust. With [direnv](https://direnv.net/), `.envrc` exports `.env` automatically. Without direnv: `source .env`.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Development
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
source .venv/bin/activate
|
|
114
|
+
pip install -e ".[dev]"
|
|
115
|
+
|
|
116
|
+
# Tests
|
|
117
|
+
pytest -q
|
|
118
|
+
|
|
119
|
+
# Lint + format
|
|
120
|
+
ruff check .
|
|
121
|
+
ruff format .
|
|
122
|
+
|
|
123
|
+
# Pre-commit hooks (one-time setup)
|
|
124
|
+
pip install pre-commit
|
|
125
|
+
pre-commit install
|
|
126
|
+
|
|
127
|
+
# Smoke-test against a real audio file (server must not be running in Claude Desktop)
|
|
128
|
+
python scripts/smoke_test.py ~/Downloads/Test.m4a
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Logs
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
tail -f ~/Library/Logs/macwhisper-mcp.log
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Security
|
|
140
|
+
|
|
141
|
+
- All file paths are resolved (symlinks followed) and checked against the `MACWHISPER_ALLOWED_PATHS` allow-list before anything reaches the CLI.
|
|
142
|
+
- `subprocess.run` is always called with an argv list — never `shell=True`.
|
|
143
|
+
- No network calls. Ever.
|
|
144
|
+
|
|
145
|
+
See [PRD §7](./PRD.md) for the full threat model.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Known limitations
|
|
150
|
+
|
|
151
|
+
- **Danish letter names:** Whisper may phonetically approximate letter names (e.g. "Æ, Ø, Å" → "E, Y, U") when they are spoken in isolation. Letters *inside words* transcribe correctly. This is a Whisper engine limitation, not a bug in this wrapper. See [PRD §12](./PRD.md).
|
|
152
|
+
- **Cold-start latency:** First transcription after MacWhisper launches takes ~13s (model load). Subsequent calls are ~2s.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT — see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "macwhisper-mcp-server"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Local MCP server exposing MacWhisper transcription to Claude Desktop."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10,<3.14"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Thomas" }]
|
|
13
|
+
keywords = ["mcp", "macwhisper", "transcription", "claude", "llm"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Environment :: MacOS X",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: MacOS",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Multimedia :: Sound/Audio :: Speech",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"fastmcp==3.2.4",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=8.0",
|
|
30
|
+
"pytest-mock>=3.12",
|
|
31
|
+
"ruff>=0.6",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
macwhisper-mcp = "macwhisper_mcp.server:main"
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/docdyhr/macwhisper-mcp-server"
|
|
39
|
+
Issues = "https://github.com/docdyhr/macwhisper-mcp-server/issues"
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.packages.find]
|
|
42
|
+
where = ["src"]
|
|
43
|
+
|
|
44
|
+
[tool.ruff]
|
|
45
|
+
line-length = 100
|
|
46
|
+
target-version = "py313"
|
|
47
|
+
src = ["src", "tests"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff.lint]
|
|
50
|
+
select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
|
|
51
|
+
ignore = []
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
testpaths = ["tests"]
|
|
55
|
+
pythonpath = ["src"]
|
|
56
|
+
addopts = "-ra --strict-markers"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Configuration loaded from environment variables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
DEFAULT_ALLOWED = str(Path.home() / "Desktop")
|
|
10
|
+
DEFAULT_LOG_PATH = str(Path.home() / "Library" / "Logs" / "macwhisper-mcp.log")
|
|
11
|
+
|
|
12
|
+
# MacWhisper ships its CLI binary inside the app bundle and does not put it on PATH
|
|
13
|
+
# by default. Prefer the app-bundle path if present, fall back to `mw` on PATH.
|
|
14
|
+
_BUNDLED_CLI = Path("/Applications/MacWhisper.app/Contents/MacOS/mw")
|
|
15
|
+
DEFAULT_CLI = str(_BUNDLED_CLI) if _BUNDLED_CLI.exists() else "mw"
|
|
16
|
+
|
|
17
|
+
# File extensions accepted by MacWhisper. Reject anything else before invoking the CLI.
|
|
18
|
+
ALLOWED_EXTENSIONS: frozenset[str] = frozenset(
|
|
19
|
+
{".m4a", ".mp3", ".mp4", ".mov", ".wav", ".aiff", ".flac"}
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Config:
|
|
25
|
+
allowed_paths: tuple[Path, ...]
|
|
26
|
+
mw_cli: str = DEFAULT_CLI
|
|
27
|
+
log_path: Path = field(default_factory=lambda: Path(DEFAULT_LOG_PATH))
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_env(cls) -> Config:
|
|
31
|
+
raw = os.environ.get("MACWHISPER_ALLOWED_PATHS", DEFAULT_ALLOWED)
|
|
32
|
+
allowed = tuple(Path(p).expanduser().resolve() for p in raw.split(":") if p.strip())
|
|
33
|
+
if not allowed:
|
|
34
|
+
raise ValueError("MACWHISPER_ALLOWED_PATHS must contain at least one directory.")
|
|
35
|
+
|
|
36
|
+
log_path_str = os.environ.get("MACWHISPER_LOG_PATH", DEFAULT_LOG_PATH)
|
|
37
|
+
log_path = Path(log_path_str).expanduser()
|
|
38
|
+
home = Path.home()
|
|
39
|
+
# resolve() without strict handles non-existent paths via lexical .. collapsing.
|
|
40
|
+
if home not in log_path.resolve().parents:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"MACWHISPER_LOG_PATH must be inside your home directory ({home}). Got: {log_path}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
return cls(
|
|
46
|
+
allowed_paths=allowed,
|
|
47
|
+
mw_cli=os.environ.get("MACWHISPER_CLI", DEFAULT_CLI),
|
|
48
|
+
log_path=log_path,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def is_path_allowed(self, path: Path) -> bool:
|
|
52
|
+
"""True if `path` resolves inside any of the allow-listed directories.
|
|
53
|
+
|
|
54
|
+
Uses `Path.resolve()` to follow symlinks before the prefix check, so a symlink
|
|
55
|
+
inside an allowed dir pointing outside is still rejected.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
resolved = path.resolve(strict=True)
|
|
59
|
+
except (FileNotFoundError, RuntimeError):
|
|
60
|
+
return False
|
|
61
|
+
return any(resolved == base or base in resolved.parents for base in self.allowed_paths)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""FastMCP server entry point.
|
|
2
|
+
|
|
3
|
+
Run via `python -m macwhisper_mcp.server` or the `macwhisper-mcp` console script.
|
|
4
|
+
Communicates over stdio — do NOT print to stdout anywhere. All logs go to a file.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import subprocess
|
|
11
|
+
import threading
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from fastmcp import FastMCP
|
|
15
|
+
|
|
16
|
+
from .config import Config
|
|
17
|
+
from .transcribe import TranscribeError, transcribe
|
|
18
|
+
from .watcher import FolderWatcher
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _setup_logging(config: Config) -> None:
|
|
22
|
+
config.log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
logging.basicConfig(
|
|
24
|
+
filename=str(config.log_path),
|
|
25
|
+
level=logging.INFO,
|
|
26
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_server(config: Config | None = None) -> FastMCP:
|
|
31
|
+
config = config or Config.from_env()
|
|
32
|
+
_setup_logging(config)
|
|
33
|
+
log = logging.getLogger(__name__)
|
|
34
|
+
log.info("Starting macwhisper-mcp-server, allow-list=%s", config.allowed_paths)
|
|
35
|
+
|
|
36
|
+
mcp = FastMCP("macwhisper")
|
|
37
|
+
|
|
38
|
+
# --- concurrency + cancel state ---
|
|
39
|
+
_transcribe_lock = threading.Lock()
|
|
40
|
+
_current_proc: list[subprocess.Popen] = [] # at most one element
|
|
41
|
+
|
|
42
|
+
# --- watch-folder state ---
|
|
43
|
+
_watcher: list[FolderWatcher] = [] # at most one element
|
|
44
|
+
|
|
45
|
+
@mcp.tool()
|
|
46
|
+
def transcribe_audio(path: str, model: str | None = None) -> str:
|
|
47
|
+
"""Transcribe a local audio file using MacWhisper and return the transcript.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
path: Absolute path to an audio file inside the configured allow-list.
|
|
51
|
+
Supported formats: m4a, mp3, mp4, mov, wav, aiff, flac.
|
|
52
|
+
model: Optional model override in MacWhisper engine:model-id format,
|
|
53
|
+
e.g. "whisperkit:openai_whisper-large-v3-v20240930" or
|
|
54
|
+
"parakeet-pro:nvidia_parakeet-v3_494MB". Defaults to the model
|
|
55
|
+
currently selected in MacWhisper.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
The full transcript as plain text.
|
|
59
|
+
"""
|
|
60
|
+
if not _transcribe_lock.acquire(blocking=False):
|
|
61
|
+
raise TranscribeError(
|
|
62
|
+
"Busy: another transcription is already running. Try again shortly."
|
|
63
|
+
)
|
|
64
|
+
_current_proc.clear()
|
|
65
|
+
try:
|
|
66
|
+
return transcribe(path, config, model=model, _proc_ref=_current_proc)
|
|
67
|
+
except TranscribeError:
|
|
68
|
+
log.warning("transcribe_audio failed: %s", path)
|
|
69
|
+
raise
|
|
70
|
+
finally:
|
|
71
|
+
_current_proc.clear()
|
|
72
|
+
_transcribe_lock.release()
|
|
73
|
+
|
|
74
|
+
@mcp.tool()
|
|
75
|
+
def cancel_transcription() -> str:
|
|
76
|
+
"""Cancel the currently running transcription, if any."""
|
|
77
|
+
if not _current_proc:
|
|
78
|
+
return "No transcription is currently running."
|
|
79
|
+
_current_proc[0].kill()
|
|
80
|
+
log.info("Transcription cancelled by user")
|
|
81
|
+
return "Transcription cancelled."
|
|
82
|
+
|
|
83
|
+
@mcp.tool()
|
|
84
|
+
def list_allowed_paths() -> list[str]:
|
|
85
|
+
"""Return the directories this server is allowed to read audio from."""
|
|
86
|
+
return [str(p) for p in config.allowed_paths]
|
|
87
|
+
|
|
88
|
+
@mcp.tool()
|
|
89
|
+
def start_watch(folder: str) -> str:
|
|
90
|
+
"""Start watching a folder for new audio files to auto-transcribe.
|
|
91
|
+
|
|
92
|
+
New audio files dropped into ``folder`` are transcribed automatically
|
|
93
|
+
and moved to ``<folder>/../done/``. Call ``get_watch_results()`` to
|
|
94
|
+
retrieve completed transcriptions.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
folder: Absolute or ``~``-prefixed path to the incoming directory.
|
|
98
|
+
"""
|
|
99
|
+
if _watcher and _watcher[0].is_running:
|
|
100
|
+
return f"Already watching {_watcher[0].incoming}. Call stop_watch() first."
|
|
101
|
+
incoming = Path(folder).expanduser().resolve()
|
|
102
|
+
if not any(incoming == base or base in incoming.parents for base in config.allowed_paths):
|
|
103
|
+
raise TranscribeError("Access denied: folder is outside the configured allow-list.")
|
|
104
|
+
w = FolderWatcher(incoming, config)
|
|
105
|
+
w.start()
|
|
106
|
+
if _watcher:
|
|
107
|
+
_watcher[0] = w
|
|
108
|
+
else:
|
|
109
|
+
_watcher.append(w)
|
|
110
|
+
return f"Watching {w.incoming} — completed files moved to {w.done_dir}"
|
|
111
|
+
|
|
112
|
+
@mcp.tool()
|
|
113
|
+
def stop_watch() -> str:
|
|
114
|
+
"""Stop the active folder watcher."""
|
|
115
|
+
if not _watcher or not _watcher[0].is_running:
|
|
116
|
+
return "No active watcher."
|
|
117
|
+
w = _watcher[0]
|
|
118
|
+
w.stop()
|
|
119
|
+
return f"Stopped watching {w.incoming}"
|
|
120
|
+
|
|
121
|
+
@mcp.tool()
|
|
122
|
+
def get_watch_results() -> list[dict]:
|
|
123
|
+
"""Return completed watch-folder transcriptions and clear the queue.
|
|
124
|
+
|
|
125
|
+
Each entry contains: ``file``, ``transcript``, ``destination``, ``error``.
|
|
126
|
+
"""
|
|
127
|
+
if not _watcher:
|
|
128
|
+
return []
|
|
129
|
+
return _watcher[0].drain_results()
|
|
130
|
+
|
|
131
|
+
return mcp
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def main() -> None:
|
|
135
|
+
build_server().run()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
main()
|