duden-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.
- duden_mcp-0.1.0/.github/workflows/publish.yml +20 -0
- duden_mcp-0.1.0/.github/workflows/registry.yml +61 -0
- duden_mcp-0.1.0/.github/workflows/smoke.yml +21 -0
- duden_mcp-0.1.0/LICENSE +21 -0
- duden_mcp-0.1.0/PKG-INFO +75 -0
- duden_mcp-0.1.0/README-de.md +52 -0
- duden_mcp-0.1.0/README-fa.md +52 -0
- duden_mcp-0.1.0/README.md +63 -0
- duden_mcp-0.1.0/pyproject.toml +25 -0
- duden_mcp-0.1.0/server.json +1 -0
- duden_mcp-0.1.0/src/duden_mcp/__init__.py +7 -0
- duden_mcp-0.1.0/src/duden_mcp/domain.py +232 -0
- duden_mcp-0.1.0/src/duden_mcp/http.py +102 -0
- duden_mcp-0.1.0/src/duden_mcp/server.py +211 -0
- duden_mcp-0.1.0/tests/__init__.py +0 -0
- duden_mcp-0.1.0/tests/fixtures/conjugation-laufen.html +772 -0
- duden_mcp-0.1.0/tests/fixtures/conjugation-sein.html +773 -0
- duden_mcp-0.1.0/tests/fixtures/search-haus.html +807 -0
- duden_mcp-0.1.0/tests/fixtures/synonyme-haus.html +867 -0
- duden_mcp-0.1.0/tests/fixtures/word-haus.html +1688 -0
- duden_mcp-0.1.0/tests/fixtures/word-laufen.html +1966 -0
- duden_mcp-0.1.0/tests/fixtures/word-sein.html +1194 -0
- duden_mcp-0.1.0/tests/smoke_live.py +17 -0
- duden_mcp-0.1.0/tests/test_domain.py +232 -0
- duden_mcp-0.1.0/tests/test_live.py +78 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
permissions:
|
|
11
|
+
id-token: write # required for PyPI trusted publishing (OIDC)
|
|
12
|
+
contents: read
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.12"
|
|
18
|
+
- run: pip install build
|
|
19
|
+
- run: python -m build
|
|
20
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
name: Publish to MCP Registry
|
|
2
|
+
|
|
3
|
+
# Official MCP Registry (registry.modelcontextprotocol.io) via GitHub OIDC.
|
|
4
|
+
# No secret, no interactive login: the runner's OIDC token proves ownership
|
|
5
|
+
# of the io.github.mohammad-abbasi-sh/* namespace.
|
|
6
|
+
# Refs: https://github.com/modelcontextprotocol/registry
|
|
7
|
+
|
|
8
|
+
on:
|
|
9
|
+
push:
|
|
10
|
+
tags: ["v*"]
|
|
11
|
+
workflow_dispatch:
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
publish:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
permissions:
|
|
17
|
+
id-token: write # required for OIDC -> MCP Registry
|
|
18
|
+
contents: read
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
|
|
22
|
+
- name: Sync server.json version to tag
|
|
23
|
+
# Only sync when running on a version tag (v*). On workflow_dispatch
|
|
24
|
+
# (ref = refs/heads/main) keep the version committed in server.json.
|
|
25
|
+
run: |
|
|
26
|
+
VER=""
|
|
27
|
+
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
|
28
|
+
TAG="${GITHUB_REF#refs/tags/}"
|
|
29
|
+
VER="${TAG#v}"
|
|
30
|
+
fi
|
|
31
|
+
if [ -n "$VER" ]; then
|
|
32
|
+
python3 - "$VER" << 'EOF'
|
|
33
|
+
import json, sys
|
|
34
|
+
ver = sys.argv[1]
|
|
35
|
+
d = json.load(open('server.json'))
|
|
36
|
+
d['version'] = ver
|
|
37
|
+
for pkg in d.get('packages', []):
|
|
38
|
+
pkg['version'] = ver
|
|
39
|
+
json.dump(d, open('server.json', 'w'), indent=2, ensure_ascii=False)
|
|
40
|
+
print('server.json version ->', ver)
|
|
41
|
+
EOF
|
|
42
|
+
else
|
|
43
|
+
echo "No version tag ($GITHUB_REF); keeping server.json version as-is"
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
- name: Install mcp-publisher
|
|
47
|
+
run: |
|
|
48
|
+
curl -sL "https://github.com/modelcontextprotocol/registry/releases/download/v1.8.7/mcp-publisher_linux_amd64.tar.gz" -o /tmp/mcp-publisher.tar.gz
|
|
49
|
+
tar -xzf /tmp/mcp-publisher.tar.gz -C /tmp
|
|
50
|
+
sudo mv /tmp/mcp-publisher /usr/local/bin/mcp-publisher
|
|
51
|
+
mcp-publisher --version
|
|
52
|
+
|
|
53
|
+
- name: Validate server.json
|
|
54
|
+
run: mcp-publisher validate server.json
|
|
55
|
+
|
|
56
|
+
- name: Publish to MCP Registry (OIDC)
|
|
57
|
+
run: |
|
|
58
|
+
mcp-publisher login github-oidc
|
|
59
|
+
mcp-publisher publish server.json
|
|
60
|
+
# A registry outage must never break the release; re-run from Actions if needed.
|
|
61
|
+
continue-on-error: true
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: Daily live smoke
|
|
2
|
+
|
|
3
|
+
# One fixed probe per day: catches fingerprint rot / bot-wall changes
|
|
4
|
+
# before users hit them. Fails visibly on 403/timeout.
|
|
5
|
+
on:
|
|
6
|
+
schedule:
|
|
7
|
+
- cron: "0 6 * * *"
|
|
8
|
+
workflow_dispatch:
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
smoke:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.12"
|
|
18
|
+
- name: Install
|
|
19
|
+
run: pip install -e .
|
|
20
|
+
- name: Smoke probe
|
|
21
|
+
run: python tests/smoke_live.py
|
duden_mcp-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mohammad Abbasi
|
|
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.
|
duden_mcp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: duden-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unofficial read-only MCP server for Duden (duden.de) — dictionary lookup, synonyms, spelling & verb conjugation
|
|
5
|
+
Author: Mohammad Abbasi
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
Requires-Dist: mcp<2
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# duden-mcp 📖
|
|
14
|
+
|
|
15
|
+
mcp-name: io.github.mohammad-abbasi-sh/duden-mcp
|
|
16
|
+
|
|
17
|
+
Unofficial **read-only** MCP server for **[duden.de](https://www.duden.de)** — Duden dictionary lookup from duden.de (free, no key, no login): word meanings, synonyms, spelling with hyphenation, grammar notes and full verb conjugation. No key, no login.
|
|
18
|
+
|
|
19
|
+
> Built by Mohammad Abbasi as part of the "Germany MCP factory". Not affiliated with duden.de.
|
|
20
|
+
|
|
21
|
+
## For AI agents
|
|
22
|
+
|
|
23
|
+
Add to your MCP client config (stdio):
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"mcpServers": {
|
|
28
|
+
"duden": {
|
|
29
|
+
"command": "uvx",
|
|
30
|
+
"args": ["duden-mcp==0.1.0"]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or run locally: `python -m duden_mcp.server` (needs `mcp<2`, `httpx`).
|
|
37
|
+
|
|
38
|
+
## Tools
|
|
39
|
+
|
|
40
|
+
| Tool | Description |
|
|
41
|
+
|------|-------------|
|
|
42
|
+
| `duden_lookup_word` | Look up a German word in Duden: meanings, word type, frequency, hyphenation & grammar — `word` (e.g. Haus, laufen) |
|
|
43
|
+
| `duden_synonyms` | Synonyms of a German word from Duden — `word` |
|
|
44
|
+
| `duden_spelling` | Spelling of a German word: hyphenation and spelling notes — `word` |
|
|
45
|
+
| `duden_grammar` | Grammar notes for a German word: declension/conjugation summary — `word` |
|
|
46
|
+
| `duden_conjugate` | Full conjugation table of a German verb: present, past, perfect, participles, imperative — `verb` |
|
|
47
|
+
| `duden_search` | Search Duden headwords matching a query — `query`, `limit` 1..20 |
|
|
48
|
+
| `duden_health` | Reachability probe (ok / blocked / error) — one polite request |
|
|
49
|
+
|
|
50
|
+
## Notes
|
|
51
|
+
|
|
52
|
+
- All data comes from **Duden's public word pages** (free, no key, no login).
|
|
53
|
+
- Meanings are the numbered definitions from the word entry; synonyms are the ones listed on the entry (the dedicated `/synonyme/<word>` overview page only lists alphabetical navigation, so it is linked, not parsed).
|
|
54
|
+
- Word URLs use Duden's transliterated slug (ä→ae, ö→oe, ü→ue, ß→ss); lookup tries the transliterated form first, then the raw spelling.
|
|
55
|
+
- Ambiguous headwords redirect: `sein` → `sein_Hilfsverb`, `haben` → `haben_Hilfsverb` / `haben_Vollverb`. The tools follow Duden's own redirects, and `duden_conjugate` resolves the suffixed conjugation page through the word entry when the bare `/konjugation/<verb>` URL 404s.
|
|
56
|
+
- Hyphenation marks syllable breaks with `·` (Duden itself writes a soft hyphen or pipe).
|
|
57
|
+
- Conjugation tables include the reflexive pronouns Duden lists (e.g. `laufe (mich/mir)` for `laufen`).
|
|
58
|
+
|
|
59
|
+
Tests: 37/37 green (28 offline + 9 live, 2026-11-10).
|
|
60
|
+
|
|
61
|
+
## Development
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
python3 -m venv .venv && .venv/bin/pip install "mcp<2" httpx pytest
|
|
65
|
+
.venv/bin/pip install -e .
|
|
66
|
+
.venv/bin/python -m pytest tests/ -q
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
MIT
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
Built by [Mohammad Abbasi](https://github.com/mohammad-abbasi-sh) — [Almanibama](https://almanibama.ir) · [mabbasi.ir](https://mabbasi.ir)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# duden-mcp 📖
|
|
2
|
+
|
|
3
|
+
mcp-name: io.github.mohammad-abbasi-sh/duden-mcp
|
|
4
|
+
|
|
5
|
+
Inoffizieller **nur lesender** MCP-Server für **[duden.de](https://www.duden.de)** — Duden-Nachschlagen auf duden.de (kostenlos, kein Schlüssel, kein Login): Bedeutungen, Synonyme, Rechtschreibung mit Worttrennung, Grammatik und vollständige Verbkonjugation. Kein Schlüssel, keine Anmeldung.
|
|
6
|
+
|
|
7
|
+
> Gebaut von Mohammad Abbasi als Teil der "Germany MCP factory". Nicht mit duden.de verbunden.
|
|
8
|
+
|
|
9
|
+
## Für KI-Agenten
|
|
10
|
+
|
|
11
|
+
Zur MCP-Client-Konfiguration hinzufügen (stdio):
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"mcpServers": {
|
|
16
|
+
"duden": {
|
|
17
|
+
"command": "uvx",
|
|
18
|
+
"args": ["duden-mcp==0.1.0"]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Oder lokal starten: `python -m duden_mcp.server` (benötigt `mcp<2`, `httpx`).
|
|
25
|
+
|
|
26
|
+
## Tools
|
|
27
|
+
|
|
28
|
+
| Tool | Beschreibung |
|
|
29
|
+
|------|-------------|
|
|
30
|
+
| `duden_lookup_word` | Ein deutsches Wort im Duden nachschlagen: Bedeutungen, Wortart & Grammatik |
|
|
31
|
+
| `duden_synonyms` | Synonyme eines deutschen Wortes |
|
|
32
|
+
| `duden_spelling` | Rechtschreibung & Worttrennung eines deutschen Wortes |
|
|
33
|
+
| `duden_grammar` | Grammatiknotizen zu einem deutschen Wort |
|
|
34
|
+
| `duden_conjugate` | Vollständige Konjugationstabelle eines deutschen Verbs |
|
|
35
|
+
| `duden_search` | Duden-Stichwörter suchen |
|
|
36
|
+
| `duden_health` | Erreichbarkeitsprüfung (ok / blocked / error) — eine höfliche Anfrage |
|
|
37
|
+
|
|
38
|
+
## Entwicklung
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
python3 -m venv .venv && .venv/bin/pip install "mcp<2" httpx pytest
|
|
42
|
+
.venv/bin/pip install -e .
|
|
43
|
+
.venv/bin/python -m pytest tests/ -q
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Lizenz
|
|
47
|
+
|
|
48
|
+
MIT
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
Built by [Mohammad Abbasi](https://github.com/mohammad-abbasi-sh) — [Almanibama](https://almanibama.ir) · [mabbasi.ir](https://mabbasi.ir)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# duden-mcp 📖
|
|
2
|
+
|
|
3
|
+
mcp-name: io.github.mohammad-abbasi-sh/duden-mcp
|
|
4
|
+
|
|
5
|
+
سرور MCP غیررسمی و **فقطخواندنی** برای **[duden.de](https://www.duden.de)** — جستوجو در فرهنگ لغت دودن از duden.de (رایگان، بدون کلید): معانی واژه، مترادفها، املا با جداسازی هجاها، نکات دستوری و صرف کامل فعل. بدون کلید، بدون ورود.
|
|
6
|
+
|
|
7
|
+
> ساختهٔ محمد عباسی، بخشی از «کارخانهٔ MCP آلمان». وابسته به duden.de نیست.
|
|
8
|
+
|
|
9
|
+
## برای ایجنتهای هوش مصنوعی
|
|
10
|
+
|
|
11
|
+
به پیکربندی MCP کلاینت اضافه کنید (stdio):
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"mcpServers": {
|
|
16
|
+
"duden": {
|
|
17
|
+
"command": "uvx",
|
|
18
|
+
"args": ["duden-mcp==0.1.0"]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
یا بهصورت محلی اجرا کنید: `python -m duden_mcp.server` (نیازمند `mcp<2` و `httpx`).
|
|
25
|
+
|
|
26
|
+
## ابزارها
|
|
27
|
+
|
|
28
|
+
| ابزار | توضیح |
|
|
29
|
+
|------|-------|
|
|
30
|
+
| `duden_lookup_word` | جستوجوی واژهٔ آلمانی در دودن: معانی، نوع کلمه و دستور زبان |
|
|
31
|
+
| `duden_synonyms` | مترادفهای یک واژهٔ آلمانی |
|
|
32
|
+
| `duden_spelling` | املا و جداسازی هجاهای یک واژهٔ آلمانی |
|
|
33
|
+
| `duden_grammar` | نکات دستوری یک واژهٔ آلمانی |
|
|
34
|
+
| `duden_conjugate` | جدول کامل صرف یک فعل آلمانی |
|
|
35
|
+
| `duden_search` | جستوجوی سرخطهای دودن |
|
|
36
|
+
| `duden_health` | بررسی دسترسپذیری (ok / blocked / error) — یک درخواست مؤدبانه |
|
|
37
|
+
|
|
38
|
+
## توسعه
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
python3 -m venv .venv && .venv/bin/pip install "mcp<2" httpx pytest
|
|
42
|
+
.venv/bin/pip install -e .
|
|
43
|
+
.venv/bin/python -m pytest tests/ -q
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## لایسنس
|
|
47
|
+
|
|
48
|
+
MIT
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
Built by [Mohammad Abbasi](https://github.com/mohammad-abbasi-sh) — [Almanibama](https://almanibama.ir) · [mabbasi.ir](https://mabbasi.ir)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# duden-mcp 📖
|
|
2
|
+
|
|
3
|
+
mcp-name: io.github.mohammad-abbasi-sh/duden-mcp
|
|
4
|
+
|
|
5
|
+
Unofficial **read-only** MCP server for **[duden.de](https://www.duden.de)** — Duden dictionary lookup from duden.de (free, no key, no login): word meanings, synonyms, spelling with hyphenation, grammar notes and full verb conjugation. No key, no login.
|
|
6
|
+
|
|
7
|
+
> Built by Mohammad Abbasi as part of the "Germany MCP factory". Not affiliated with duden.de.
|
|
8
|
+
|
|
9
|
+
## For AI agents
|
|
10
|
+
|
|
11
|
+
Add to your MCP client config (stdio):
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"mcpServers": {
|
|
16
|
+
"duden": {
|
|
17
|
+
"command": "uvx",
|
|
18
|
+
"args": ["duden-mcp==0.1.0"]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or run locally: `python -m duden_mcp.server` (needs `mcp<2`, `httpx`).
|
|
25
|
+
|
|
26
|
+
## Tools
|
|
27
|
+
|
|
28
|
+
| Tool | Description |
|
|
29
|
+
|------|-------------|
|
|
30
|
+
| `duden_lookup_word` | Look up a German word in Duden: meanings, word type, frequency, hyphenation & grammar — `word` (e.g. Haus, laufen) |
|
|
31
|
+
| `duden_synonyms` | Synonyms of a German word from Duden — `word` |
|
|
32
|
+
| `duden_spelling` | Spelling of a German word: hyphenation and spelling notes — `word` |
|
|
33
|
+
| `duden_grammar` | Grammar notes for a German word: declension/conjugation summary — `word` |
|
|
34
|
+
| `duden_conjugate` | Full conjugation table of a German verb: present, past, perfect, participles, imperative — `verb` |
|
|
35
|
+
| `duden_search` | Search Duden headwords matching a query — `query`, `limit` 1..20 |
|
|
36
|
+
| `duden_health` | Reachability probe (ok / blocked / error) — one polite request |
|
|
37
|
+
|
|
38
|
+
## Notes
|
|
39
|
+
|
|
40
|
+
- All data comes from **Duden's public word pages** (free, no key, no login).
|
|
41
|
+
- Meanings are the numbered definitions from the word entry; synonyms are the ones listed on the entry (the dedicated `/synonyme/<word>` overview page only lists alphabetical navigation, so it is linked, not parsed).
|
|
42
|
+
- Word URLs use Duden's transliterated slug (ä→ae, ö→oe, ü→ue, ß→ss); lookup tries the transliterated form first, then the raw spelling.
|
|
43
|
+
- Ambiguous headwords redirect: `sein` → `sein_Hilfsverb`, `haben` → `haben_Hilfsverb` / `haben_Vollverb`. The tools follow Duden's own redirects, and `duden_conjugate` resolves the suffixed conjugation page through the word entry when the bare `/konjugation/<verb>` URL 404s.
|
|
44
|
+
- Hyphenation marks syllable breaks with `·` (Duden itself writes a soft hyphen or pipe).
|
|
45
|
+
- Conjugation tables include the reflexive pronouns Duden lists (e.g. `laufe (mich/mir)` for `laufen`).
|
|
46
|
+
|
|
47
|
+
Tests: 37/37 green (28 offline + 9 live, 2026-11-10).
|
|
48
|
+
|
|
49
|
+
## Development
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
python3 -m venv .venv && .venv/bin/pip install "mcp<2" httpx pytest
|
|
53
|
+
.venv/bin/pip install -e .
|
|
54
|
+
.venv/bin/python -m pytest tests/ -q
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
MIT
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
Built by [Mohammad Abbasi](https://github.com/mohammad-abbasi-sh) — [Almanibama](https://almanibama.ir) · [mabbasi.ir](https://mabbasi.ir)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "duden-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Unofficial read-only MCP server for Duden (duden.de) — dictionary lookup, synonyms, spelling & verb conjugation"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"mcp<2",
|
|
14
|
+
"httpx>=0.27",
|
|
15
|
+
]
|
|
16
|
+
authors = [{ name = "Mohammad Abbasi" }]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
duden-mcp = "duden_mcp.server:main"
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/duden_mcp"]
|
|
23
|
+
|
|
24
|
+
[tool.pytest.ini_options]
|
|
25
|
+
markers = ["live: tests that hit the real https://www.duden.de site"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "description": "Unofficial read-only MCP server for Duden — meanings, synonyms, spelling & conjugation.", "name": "io.github.mohammad-abbasi-sh/duden-mcp", "packages": [{"identifier": "duden-mcp", "registryBaseUrl": "https://pypi.org", "registryType": "pypi", "transport": {"type": "stdio"}, "version": "0.1.0"}], "repository": {"source": "github", "url": "https://github.com/mohammad-abbasi-sh/duden-mcp"}, "title": "Duden — Germany's standard dictionary: word meanings, synonyms, spelling & grammar", "version": "0.1.0", "websiteUrl": "https://github.com/mohammad-abbasi-sh/duden-mcp"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""duden_mcp — unofficial read-only MCP server for duden.de.
|
|
2
|
+
|
|
3
|
+
Duden dictionary lookup from duden.de (free, no key, no login): word meanings, synonyms, spelling with hyphenation, grammar notes and full verb conjugation.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Domain logic: shape Duden word / conjugation / search pages into
|
|
2
|
+
compact dicts (stdlib only).
|
|
3
|
+
|
|
4
|
+
Data source: duden.de public word pages
|
|
5
|
+
(https://www.duden.de/rechtschreibung/<word>) — free, no key, no login.
|
|
6
|
+
|
|
7
|
+
Page structure (server-rendered HTML, verified 2026-11-10):
|
|
8
|
+
- lemma in <h1>; word type / frequency in <dl class="tuple"> blocks
|
|
9
|
+
(Wortart, Häufigkeit with a shaft aria-label).
|
|
10
|
+
- meanings: <div class="enumeration__text">…</div> inside the
|
|
11
|
+
#bedeutungen division (one per numbered meaning).
|
|
12
|
+
- spelling: #rechtschreibung division (Worttrennung line + notes).
|
|
13
|
+
- synonyms: links with data-duden-ref-type="lexeme" inside the
|
|
14
|
+
#synonyme division; the dedicated /synonyme/<word> overview page
|
|
15
|
+
only lists alphabetical navigation, so the word page's list is used.
|
|
16
|
+
- grammar: first <p> of the #grammatik division
|
|
17
|
+
("das Haus; Genitiv: des Hauses, Häuser"); verbs link to
|
|
18
|
+
/konjugation/<verb>.
|
|
19
|
+
- conjugation page: <ul class="accordion__list content-column"> lists
|
|
20
|
+
in document order — first 6 are Indikativ (Präsens, Präteritum,
|
|
21
|
+
Perfekt, Plusquamperfekt, Futur I, Futur II), then Konjunktiv I/II,
|
|
22
|
+
then the Imperativ list (2 items, no label) and the infinite forms
|
|
23
|
+
(Infinitiv mit zu / Partizip I / Partizip II, one form each).
|
|
24
|
+
Footnote <sup> markers are stripped before parsing.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import html as _html
|
|
29
|
+
import re
|
|
30
|
+
from urllib.parse import quote
|
|
31
|
+
|
|
32
|
+
BASE = "https://www.duden.de"
|
|
33
|
+
|
|
34
|
+
PERSONS = ["ich", "du", "er/sie/es", "wir", "ihr", "sie"]
|
|
35
|
+
|
|
36
|
+
TENSES = ["Präsens", "Präteritum", "Perfekt", "Plusquamperfekt",
|
|
37
|
+
"Futur I", "Futur II"]
|
|
38
|
+
TENSE_KEYS = ["praesens", "praeteritum", "perfekt", "plusquamperfekt",
|
|
39
|
+
"futur_i", "futur_ii"]
|
|
40
|
+
|
|
41
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
42
|
+
_SUP_RE = re.compile(r"<sup[^>]*>.*?</sup>", re.S)
|
|
43
|
+
_WS_RE = re.compile(r"\s+")
|
|
44
|
+
|
|
45
|
+
_TRANSLIT = str.maketrans({
|
|
46
|
+
"ä": "ae", "ö": "oe", "ü": "ue",
|
|
47
|
+
"Ä": "Ae", "Ö": "Oe", "Ü": "Ue", "ß": "ss",
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def validate_word(word: str) -> str:
|
|
52
|
+
w = (word or "").strip()
|
|
53
|
+
if not w:
|
|
54
|
+
raise ValueError("word must not be empty")
|
|
55
|
+
if len(w) > 80:
|
|
56
|
+
raise ValueError("word too long (max 80 characters)")
|
|
57
|
+
return w
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def validate_limit(limit: int) -> int:
|
|
61
|
+
if not 1 <= limit <= 20:
|
|
62
|
+
raise ValueError(f"limit out of range: {limit} (expected 1..20)")
|
|
63
|
+
return limit
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def word_slug(word: str) -> str:
|
|
67
|
+
"""Duden URL slug: umlauts transliterated (ä→ae …), spaces → _."""
|
|
68
|
+
return word.translate(_TRANSLIT).replace(" ", "_")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def word_url(word: str) -> str:
|
|
72
|
+
return f"{BASE}/rechtschreibung/{word_slug(word)}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def conjugation_url(verb: str) -> str:
|
|
76
|
+
return f"{BASE}/konjugation/{word_slug(verb)}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def search_url(query: str) -> str:
|
|
80
|
+
return f"{BASE}/suchen/dudenonline/{quote(query.strip())}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _clean(text: str) -> str:
|
|
84
|
+
return _WS_RE.sub(" ", _html.unescape(_TAG_RE.sub(" ", text or ""))).strip()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _division(html: str, div_id: str) -> str:
|
|
88
|
+
"""Raw HTML of the <div class="division" id="<div_id>"> block."""
|
|
89
|
+
m = re.search(rf'id="{re.escape(div_id)}"', html)
|
|
90
|
+
if not m:
|
|
91
|
+
return ""
|
|
92
|
+
start = html.rfind("<div", 0, m.start())
|
|
93
|
+
if start < 0:
|
|
94
|
+
start = m.start()
|
|
95
|
+
nxt = html.find('<div class="division ', start + 10)
|
|
96
|
+
return html[start:] if nxt < 0 else html[start:nxt]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _first_paragraph(division_html: str) -> str:
|
|
100
|
+
m = re.search(r"<p[^>]*>(.*?)</p>", division_html, re.S)
|
|
101
|
+
return _clean(m.group(1)) if m else ""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_word_page(html: str) -> dict:
|
|
105
|
+
"""{lemma, word_type, frequency, meanings, hyphenation, grammar,
|
|
106
|
+
origin, synonyms, conjugation_url, url} — empty strings/lists
|
|
107
|
+
when a section is absent (not every word has every section)."""
|
|
108
|
+
m = re.search(r"<h1[^>]*>(.*?)</h1>", html, re.S)
|
|
109
|
+
lemma = _clean(m.group(1)) if m else ""
|
|
110
|
+
lemma = lemma.replace("", "").replace(" ,", ",")
|
|
111
|
+
m = re.search(
|
|
112
|
+
r'<dt class="tuple__key">Wortart:.*?</dt>\s*'
|
|
113
|
+
r'<dd class="tuple__val">(.*?)</dd>', html, re.S)
|
|
114
|
+
word_type = _clean(m.group(1)) if m else ""
|
|
115
|
+
m = re.search(
|
|
116
|
+
r'<dt class="tuple__key">Häufigkeit:.*?'
|
|
117
|
+
r'<div class="shaft" aria-label="([^"]+)"', html, re.S)
|
|
118
|
+
frequency = _html.unescape(m.group(1)) if m else ""
|
|
119
|
+
|
|
120
|
+
meanings = []
|
|
121
|
+
for raw in re.findall(r'enumeration__text">(.*?)</div>', html, re.S):
|
|
122
|
+
t = _clean(raw)
|
|
123
|
+
if t and t not in meanings:
|
|
124
|
+
meanings.append(t)
|
|
125
|
+
|
|
126
|
+
rechtschreibung = _division(html, "rechtschreibung")
|
|
127
|
+
hyphenation = ""
|
|
128
|
+
if rechtschreibung:
|
|
129
|
+
lines = [_clean(ln) for ln in _TAG_RE.sub("\n", rechtschreibung).split("\n")]
|
|
130
|
+
lines = [ln for ln in lines if ln]
|
|
131
|
+
for i, ln in enumerate(lines):
|
|
132
|
+
if ln == "Worttrennung" and i + 1 < len(lines):
|
|
133
|
+
# Duden marks syllable breaks with a soft hyphen or
|
|
134
|
+
# a pipe; normalise both to · for display
|
|
135
|
+
hyphenation = (lines[i + 1].replace("", "·")
|
|
136
|
+
.replace("|", "·"))
|
|
137
|
+
break
|
|
138
|
+
|
|
139
|
+
grammar = _first_paragraph(_division(html, "grammatik"))
|
|
140
|
+
origin = _first_paragraph(_division(html, "herkunft"))
|
|
141
|
+
syn_div = _division(html, "synonyme")
|
|
142
|
+
synonyms = []
|
|
143
|
+
for raw in re.findall(
|
|
144
|
+
r'data-duden-ref-type="lexeme"[^>]*>([^<]+)</a>', syn_div):
|
|
145
|
+
w = _clean(raw)
|
|
146
|
+
if w and w not in synonyms:
|
|
147
|
+
synonyms.append(w)
|
|
148
|
+
m = re.search(r'href="(/konjugation/[^"]+)"', html)
|
|
149
|
+
conj_url = f"{BASE}{m.group(1)}" if m else ""
|
|
150
|
+
return {
|
|
151
|
+
"lemma": lemma,
|
|
152
|
+
"word_type": word_type,
|
|
153
|
+
"frequency": frequency,
|
|
154
|
+
"meanings": meanings,
|
|
155
|
+
"hyphenation": hyphenation,
|
|
156
|
+
"grammar": grammar,
|
|
157
|
+
"origin": origin,
|
|
158
|
+
"synonyms": synonyms,
|
|
159
|
+
"conjugation_url": conj_url,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def parse_spelling(html: str) -> dict:
|
|
164
|
+
"""{hyphenation, notes} from the #rechtschreibung division."""
|
|
165
|
+
word = parse_word_page(html)
|
|
166
|
+
div = _division(html, "rechtschreibung")
|
|
167
|
+
notes = _clean(div)[:1500]
|
|
168
|
+
# drop the leading "Rechtschreibung" heading + info icon
|
|
169
|
+
notes = re.sub(r"^Rechtschreibung\s*ⓘ\s*", "", notes)
|
|
170
|
+
return {"hyphenation": word["hyphenation"], "notes": notes}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def parse_conjugation(html: str) -> dict:
|
|
174
|
+
"""{indikativ: {tense_key: {person: form}}, imperative:
|
|
175
|
+
{singular, plural}, infinitiv_mit_zu, partizip_i, partizip_ii}."""
|
|
176
|
+
clean_html = _SUP_RE.sub("", html)
|
|
177
|
+
uls = re.findall(
|
|
178
|
+
r'<ul class="accordion__list content-column">(.*?)</ul>',
|
|
179
|
+
clean_html, re.S)
|
|
180
|
+
tables = []
|
|
181
|
+
for u in uls:
|
|
182
|
+
items = [_clean(li) for li in re.findall(
|
|
183
|
+
r'<li class="accordion__item">(.*?)</li>', u, re.S)]
|
|
184
|
+
items = [it for it in items if it]
|
|
185
|
+
if items:
|
|
186
|
+
tables.append(items)
|
|
187
|
+
|
|
188
|
+
indikativ: dict[str, dict[str, str]] = {}
|
|
189
|
+
imperative = {"singular": "", "plural": ""}
|
|
190
|
+
infinitiv_mit_zu = ""
|
|
191
|
+
partizip_i = ""
|
|
192
|
+
partizip_ii = ""
|
|
193
|
+
for items in tables:
|
|
194
|
+
label, forms = items[0], items[1:]
|
|
195
|
+
if label in TENSES and len(forms) == 6:
|
|
196
|
+
key = TENSE_KEYS[TENSES.index(label)]
|
|
197
|
+
if key not in indikativ:
|
|
198
|
+
indikativ[key] = dict(zip(PERSONS, forms))
|
|
199
|
+
elif label == "Infinitiv mit zu" and forms:
|
|
200
|
+
infinitiv_mit_zu = forms[0]
|
|
201
|
+
elif label == "Partizip I" and forms:
|
|
202
|
+
partizip_i = forms[0]
|
|
203
|
+
elif label == "Partizip II" and forms:
|
|
204
|
+
partizip_ii = forms[0]
|
|
205
|
+
elif len(items) == 2 and not imperative["singular"]:
|
|
206
|
+
# the Imperativ list carries no label — its two items are
|
|
207
|
+
# the singular (du) and plural (ihr) forms directly
|
|
208
|
+
imperative = {"singular": items[0], "plural": items[1]}
|
|
209
|
+
if not indikativ and not partizip_ii:
|
|
210
|
+
raise ValueError("no conjugation tables found on this page "
|
|
211
|
+
"(is it a verb?)")
|
|
212
|
+
return {
|
|
213
|
+
"indikativ": indikativ,
|
|
214
|
+
"imperative": imperative,
|
|
215
|
+
"infinitiv_mit_zu": infinitiv_mit_zu,
|
|
216
|
+
"partizip_i": partizip_i,
|
|
217
|
+
"partizip_ii": partizip_ii,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def parse_search(html: str) -> list[dict]:
|
|
222
|
+
"""[{word, url}] — Duden headwords from the search results page."""
|
|
223
|
+
out: list[dict] = []
|
|
224
|
+
seen = set()
|
|
225
|
+
for slug in re.findall(r'href="/rechtschreibung/([^"]+)"', html):
|
|
226
|
+
if slug in seen:
|
|
227
|
+
continue
|
|
228
|
+
seen.add(slug)
|
|
229
|
+
display = slug.replace("_", " ")
|
|
230
|
+
out.append({"word": display,
|
|
231
|
+
"url": f"{BASE}/rechtschreibung/{slug}"})
|
|
232
|
+
return out
|