# Directory Structure
```
├── .gitignore
├── .python-version
├── Dockerfile
├── LICENSE
├── Makefile
├── pyproject.toml
├── README.md
├── src
│ └── mcp_server_zenn
│ ├── __init__.py
│ ├── __main__.py
│ └── server.py
└── uv.lock
```
# Files
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
```
1 | 3.11.8
2 |
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # UV
98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | #uv.lock
102 |
103 | # poetry
104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105 | # This is especially recommended for binary packages to ensure reproducibility, and is more
106 | # commonly ignored for libraries.
107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108 | #poetry.lock
109 |
110 | # pdm
111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
112 | #pdm.lock
113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
114 | # in version control.
115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
116 | .pdm.toml
117 | .pdm-python
118 | .pdm-build/
119 |
120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
121 | __pypackages__/
122 |
123 | # Celery stuff
124 | celerybeat-schedule
125 | celerybeat.pid
126 |
127 | # SageMath parsed files
128 | *.sage.py
129 |
130 | # Environments
131 | .env
132 | .venv
133 | env/
134 | venv/
135 | ENV/
136 | env.bak/
137 | venv.bak/
138 |
139 | # Spyder project settings
140 | .spyderproject
141 | .spyproject
142 |
143 | # Rope project settings
144 | .ropeproject
145 |
146 | # mkdocs documentation
147 | /site
148 |
149 | # mypy
150 | .mypy_cache/
151 | .dmypy.json
152 | dmypy.json
153 |
154 | # Pyre type checker
155 | .pyre/
156 |
157 | # pytype static type analyzer
158 | .pytype/
159 |
160 | # Cython debug symbols
161 | cython_debug/
162 |
163 | # PyCharm
164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
166 | # and can be added to the global gitignore or merged into this file. For a more nuclear
167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
168 | .idea/
169 |
170 | # PyPI configuration file
171 | .pypirc
172 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # mcp-server-zenn: Unofficial MCP server for Zenn (https://zenn.dev/)
2 |
3 | ## Overview
4 |
5 | This is an unofficial Model Context Protocol server for [Zenn](https://zenn.dev/). Build on top of [Zenn's dev API](https://zenn.dev/api/).
6 |
7 | ## Features
8 |
9 | - Fetch a list of articles
10 | - Fetch a list of books
11 |
12 | ## Run this project locally
13 |
14 | This project is not yet set up for ephemeral environments (e.g. `uvx` usage). Run this project locally by cloning this repo:
15 |
16 | ```shell
17 | git clone https://github.com/shibuiwilliam/mcp-server-zenn.git
18 | ```
19 |
20 | You can launch the [MCP inspector](https://github.com/modelcontextprotocol/inspector) via [npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm):
21 |
22 | ```shell
23 | npx @modelcontextprotocol/inspector uv --directory=src/mcp_server_zenn run mcp-server-zenn
24 | ```
25 |
26 | Upon launching, the Inspector will display a URL that you can access in your browser to begin debugging.
27 |
28 |
29 | OR
30 | Add this tool as a MCP server:
31 |
32 | ```json
33 | {
34 | "zenn": {
35 | "command": "uv",
36 | "args": [
37 | "--directory",
38 | "/path/to/mcp-server-zenn",
39 | "run",
40 | "mcp-server-zenn"
41 | ]
42 | }
43 | }
44 | ```
45 |
46 | ## Deployment
47 |
48 | (TODO)
49 |
50 | ## [License](./LICENSE)
51 |
52 |
53 |
```
--------------------------------------------------------------------------------
/src/mcp_server_zenn/__main__.py:
--------------------------------------------------------------------------------
```python
1 | from . import main
2 |
3 | if __name__ == "__main__":
4 | main()
5 |
```
--------------------------------------------------------------------------------
/src/mcp_server_zenn/__init__.py:
--------------------------------------------------------------------------------
```python
1 | import asyncio
2 |
3 | from . import server
4 |
5 |
6 | def main():
7 | asyncio.run(server.serve())
8 |
9 |
10 | __all__ = ["main", "server"]
11 |
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
1 | # Use a Python image with uv pre-installed
2 | FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS uv
3 |
4 | # Install the project into `/app`
5 | WORKDIR /app
6 |
7 | # Enable bytecode compilation
8 | ENV UV_COMPILE_BYTECODE=1
9 |
10 | # Copy from the cache instead of linking since it's a mounted volume
11 | ENV UV_LINK_MODE=copy
12 |
13 | # Install the project's dependencies using the lockfile and settings
14 | RUN --mount=type=cache,target=/root/.cache/uv \
15 | --mount=type=bind,source=uv.lock,target=uv.lock \
16 | --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
17 | uv sync --frozen --no-install-project --no-dev --no-editable
18 |
19 | # Then, add the rest of the project source code and install it
20 | # Installing separately from its dependencies allows optimal layer caching
21 | ADD . /app
22 | RUN --mount=type=cache,target=/root/.cache/uv \
23 | uv sync --frozen --no-dev --no-editable
24 |
25 | FROM python:3.12-slim-bookworm
26 |
27 | WORKDIR /app
28 |
29 | COPY --from=uv /root/.local /root/.local
30 | COPY --from=uv --chown=app:app /app/.venv /app/.venv
31 |
32 | # Place executables in the environment at the front of the path
33 | ENV PATH="/app/.venv/bin:$PATH"
34 |
35 | # when running the container, add --db-path and a bind mount to the host's db file
36 | ENTRYPOINT ["mcp-server-zenn"]
37 |
```
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
```toml
1 | [project]
2 | name = "mcp-server-zenn"
3 | version = "0.1.0"
4 | description = "Add your description here"
5 | readme = "README.md"
6 | requires-python = ">=3.11.8"
7 | dependencies = [
8 | "httpx>=0.28.1",
9 | "mcp[cli]>=1.3.0",
10 | "pydantic>=2.10.6",
11 | ]
12 |
13 | [dependency-groups]
14 | dev = [
15 | "hatchling>=1.27.0",
16 | "isort>=6.0.1",
17 | "mypy>=1.15.0",
18 | "ruff>=0.9.10",
19 | ]
20 |
21 | [project.scripts]
22 | mcp-server-zenn = "mcp_server_zenn:main"
23 |
24 | [tool.hatch.build.targets.wheel]
25 | packages = ["src/mcp_server_zenn"]
26 |
27 | [build-system]
28 | requires = ["hatchling"]
29 | build-backend = "hatchling.build"
30 |
31 | [tool.hatch.metadata]
32 | allow-direct-references = true
33 |
34 | [tool.mypy]
35 | show_error_context = true
36 | show_column_numbers = true
37 | ignore_missing_imports = false
38 | disallow_untyped_defs = true
39 | no_implicit_optional = true
40 | warn_return_any = true
41 | warn_unused_ignores = true
42 | warn_redundant_casts = true
43 |
44 | [tool.ruff]
45 | exclude = [
46 | ".bzr",
47 | ".direnv",
48 | ".eggs",
49 | ".git",
50 | ".git-rewrite",
51 | ".hg",
52 | ".ipynb_checkpoints",
53 | ".mypy_cache",
54 | ".nox",
55 | ".pants.d",
56 | ".pyenv",
57 | ".pytest_cache",
58 | ".pytype",
59 | ".ruff_cache",
60 | ".svn",
61 | ".tox",
62 | ".venv",
63 | ".vscode",
64 | "__pypackages__",
65 | "_build",
66 | "buck-out",
67 | "build",
68 | "dist",
69 | "node_modules",
70 | "site-packages",
71 | "venv",
72 | ]
73 |
74 | line-length = 120
75 | indent-width = 4
76 | target-version = "py312"
77 |
78 | [tool.ruff.lint]
79 | select = ["E4", "E7", "E9", "F"]
80 | ignore = ["E203"]
81 | fixable = ["ALL"]
82 | unfixable = []
83 |
84 | [tool.ruff.format]
85 | quote-style = "double"
86 | indent-style = "space"
87 | skip-magic-trailing-comma = false
88 | line-ending = "auto"
89 | docstring-code-format = false
90 | docstring-code-line-length = "dynamic"
91 |
```
--------------------------------------------------------------------------------
/src/mcp_server_zenn/server.py:
--------------------------------------------------------------------------------
```python
1 | import json
2 | import logging
3 | from enum import Enum
4 | from typing import Optional, Sequence
5 |
6 | import httpx
7 | from mcp.server import NotificationOptions, Server
8 | from mcp.server.models import InitializationOptions
9 | from mcp.server.stdio import stdio_server
10 | from mcp.types import EmbeddedResource, ImageContent, Prompt, Resource, TextContent, Tool
11 | from pydantic import BaseModel, ConfigDict, Field
12 |
13 | logging.basicConfig(level=logging.DEBUG)
14 | logger = logging.getLogger(__name__)
15 |
16 | APP_NAME = "mcp-server-zenn"
17 | APP_VERSION = "0.0.0"
18 |
19 | BASE_URL = "https://zenn.dev/api/"
20 | server = Server(APP_NAME)
21 | options = server.create_initialization_options()
22 |
23 |
24 | class ZennTool(Enum):
25 | ARTICLE = "article"
26 | BOOK = "book"
27 |
28 | @staticmethod
29 | def from_str(tool: str) -> "ZennTool":
30 | for tool_type in ZennTool:
31 | if tool_type.value == tool.lower():
32 | return tool_type
33 | raise ValueError(f"Invalid tool value: {tool}")
34 |
35 |
36 | class URLResource(Enum):
37 | ARTICLES = "articles"
38 | BOOKS = "books"
39 |
40 | @staticmethod
41 | def from_str(resource: str) -> "URLResource":
42 | for resource_type in URLResource:
43 | if resource_type.value == resource.lower():
44 | return resource_type
45 | raise ValueError(f"Invalid resource value: {resource}")
46 |
47 | @staticmethod
48 | def from_zenn_tool(tool: ZennTool) -> "URLResource":
49 | if tool == ZennTool.ARTICLE:
50 | return URLResource.ARTICLES
51 | elif tool == ZennTool.BOOK:
52 | return URLResource.BOOKS
53 | else:
54 | raise ValueError(f"Invalid tool value: {tool}")
55 |
56 | def to_zenn_tool(self) -> ZennTool:
57 | if self == URLResource.ARTICLES:
58 | return ZennTool.ARTICLE
59 | elif self == URLResource.BOOKS:
60 | return ZennTool.BOOK
61 | else:
62 | raise ValueError(f"Invalid resource value: {self}")
63 |
64 |
65 | class Order(Enum):
66 | LATEST = "latest"
67 | OLDEST = "oldest"
68 |
69 | @staticmethod
70 | def from_str(order: str) -> "Order":
71 | for order_type in Order:
72 | if order_type.value == order.lower():
73 | return order_type
74 | raise ValueError(f"Invalid order value: {order}")
75 |
76 |
77 | class Article(BaseModel):
78 | """Fetch articles from Zenn.dev"""
79 |
80 | model_config = ConfigDict(
81 | validate_assignment=True,
82 | frozen=True,
83 | extra="forbid",
84 | )
85 |
86 | username: Optional[str] = Field(default=None, description="Username of the article author")
87 | topicname: Optional[str] = Field(default=None, description="Topic name of the article")
88 | order: Optional[Order] = Field(
89 | default=Order.LATEST,
90 | description=f"Order of the articles. Choose from {Order.LATEST.value} or {Order.OLDEST.value}",
91 | )
92 | page: Optional[int] = Field(default=1, description="Page number of the articles. Default: 1")
93 | count: Optional[int] = Field(default=48, description="Number of articles per page. Default: 48")
94 |
95 | @staticmethod
96 | def from_arguments(arguments: dict) -> "Article":
97 | return Article(
98 | username=arguments.get("username"),
99 | topicname=arguments.get("topicname"),
100 | order=Order.from_str(arguments.get("order", Order.LATEST.value)),
101 | page=arguments.get("page", 1),
102 | count=arguments.get("count", 48),
103 | )
104 |
105 | def to_query_param(self) -> dict:
106 | param = {}
107 | if self.username:
108 | param["username"] = self.username.lower()
109 | if self.topicname:
110 | param["topicname"] = self.topicname.lower()
111 | if self.order:
112 | param["order"] = self.order.value
113 | if self.page:
114 | param["page"] = self.page
115 | if self.count:
116 | param["count"] = self.count
117 | return param
118 |
119 | @staticmethod
120 | def tool() -> Tool:
121 | return Tool(
122 | name=ZennTool.ARTICLE.value,
123 | description="Fetch articles from Zenn.dev",
124 | inputSchema={
125 | "type": "object",
126 | "properties": {
127 | "username": {"type": "string", "description": Article.model_fields["username"].description},
128 | "topicname": {"type": "string", "description": Article.model_fields["topicname"].description},
129 | "order": {
130 | "type": "string",
131 | "description": Article.model_fields["order"].description,
132 | "enum": [Order.LATEST.value, Order.OLDEST.value],
133 | },
134 | "page": {"type": "integer", "description": Article.model_fields["page"].description},
135 | "count": {"type": "integer", "description": Article.model_fields["count"].description},
136 | },
137 | "required": [],
138 | },
139 | )
140 |
141 |
142 | class Book(BaseModel):
143 | """Fetch books from Zenn.dev"""
144 |
145 | model_config = ConfigDict(
146 | validate_assignment=True,
147 | frozen=True,
148 | extra="forbid",
149 | )
150 |
151 | username: Optional[str] = Field(default=None, description="Username of the book author")
152 | topicname: Optional[str] = Field(default=None, description="Topic name of the book")
153 | order: Optional[Order] = Field(
154 | default=Order.LATEST,
155 | description=f"Order of the books. Choose from {Order.LATEST.value} or {Order.OLDEST.value}. Default: {Order.LATEST.value}",
156 | )
157 | page: Optional[int] = Field(default=1, description="Page number of the books. Default: 1")
158 | count: Optional[int] = Field(default=48, description="Number of books per page. Default: 48")
159 |
160 | @staticmethod
161 | def from_arguments(arguments: dict) -> "Book":
162 | return Book(
163 | username=arguments.get("username"),
164 | topicname=arguments.get("topicname"),
165 | order=Order.from_str(arguments.get("order", Order.LATEST.value)),
166 | page=arguments.get("page", 1),
167 | count=arguments.get("count", 48),
168 | )
169 |
170 | def to_query_param(self) -> dict:
171 | param = {}
172 | if self.username:
173 | param["username"] = self.username.lower()
174 | if self.topicname:
175 | param["topicname"] = self.topicname.lower()
176 | if self.order:
177 | param["order"] = self.order.value
178 | if self.page:
179 | param["page"] = self.page
180 | if self.count:
181 | param["count"] = self.count
182 | return param
183 |
184 | @staticmethod
185 | def tool() -> Tool:
186 | return Tool(
187 | name=ZennTool.BOOK.value,
188 | description="Fetch books from Zenn.dev",
189 | inputSchema={
190 | "type": "object",
191 | "properties": {
192 | "username": {"type": "string", "description": Book.model_fields["username"].description},
193 | "topicname": {"type": "string", "description": Book.model_fields["topicname"].description},
194 | "order": {
195 | "type": "string",
196 | "description": Book.model_fields["order"].description,
197 | "enum": [Order.LATEST.value, Order.OLDEST.value],
198 | },
199 | "page": {"type": "integer", "description": Book.model_fields["page"].description},
200 | "count": {"type": "integer", "description": Book.model_fields["count"].description},
201 | },
202 | "required": [],
203 | },
204 | )
205 |
206 |
207 | async def request(resource: URLResource, query: dict) -> dict:
208 | url = f"{BASE_URL}{resource.value}"
209 | async with httpx.AsyncClient() as client:
210 | response = await client.get(url, params=query)
211 | response.raise_for_status()
212 | return response.json()
213 |
214 |
215 | async def fetch_articles(query: Article) -> dict:
216 | return await request(URLResource.ARTICLES, query.to_query_param())
217 |
218 |
219 | async def fetch_books(query: Book) -> dict:
220 | return await request(URLResource.BOOKS, query.to_query_param())
221 |
222 |
223 | async def handle_articles(arguments: dict) -> dict:
224 | query = Article.from_arguments(arguments)
225 | return await fetch_articles(query)
226 |
227 |
228 | async def handle_books(arguments: dict) -> dict:
229 | query = Book.from_arguments(arguments)
230 | return await fetch_books(query)
231 |
232 |
233 | @server.list_prompts()
234 | async def handle_list_prompts() -> list[Prompt]:
235 | return []
236 |
237 |
238 | @server.list_resources()
239 | async def handle_list_resources() -> list[Resource]:
240 | return []
241 |
242 |
243 | @server.list_tools()
244 | async def list_tools() -> list[Tool]:
245 | return [Article.tool(), Book.tool()]
246 |
247 |
248 | @server.call_tool()
249 | async def call_tool(
250 | name: str,
251 | arguments: dict,
252 | ) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
253 | try:
254 | logger.debug(f"Calling tool: {name} with arguments: {arguments}")
255 | match name:
256 | case ZennTool.ARTICLE.value:
257 | result = await handle_articles(arguments)
258 | case ZennTool.BOOK.value:
259 | result = await handle_books(arguments)
260 | case _:
261 | raise ValueError(f"Unknown tool: {name}")
262 |
263 | return [TextContent(type="text", text=json.dumps(result, indent=2, ensure_ascii=False))]
264 |
265 | except Exception as e:
266 | logger.error(f"Error processing {APP_NAME} query: {str(e)}")
267 | raise ValueError(f"Error processing {APP_NAME} query: {str(e)}")
268 |
269 |
270 | async def serve():
271 | logger.info("Starting MCP server for Zenn")
272 | async with stdio_server() as (read_stream, write_stream):
273 | await server.run(
274 | read_stream,
275 | write_stream,
276 | InitializationOptions(
277 | server_name=APP_NAME,
278 | server_version=APP_VERSION,
279 | capabilities=server.get_capabilities(
280 | notification_options=NotificationOptions(resources_changed=True),
281 | experimental_capabilities={},
282 | ),
283 | ),
284 | )
285 |
```