#
tokens: 6101/50000 8/8 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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 | 
```