# 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:
--------------------------------------------------------------------------------
```
3.11.8
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
# PyPI configuration file
.pypirc
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
# mcp-server-zenn: Unofficial MCP server for Zenn (https://zenn.dev/)
## Overview
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/).
## Features
- Fetch a list of articles
- Fetch a list of books
## Run this project locally
This project is not yet set up for ephemeral environments (e.g. `uvx` usage). Run this project locally by cloning this repo:
```shell
git clone https://github.com/shibuiwilliam/mcp-server-zenn.git
```
You can launch the [MCP inspector](https://github.com/modelcontextprotocol/inspector) via [npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm):
```shell
npx @modelcontextprotocol/inspector uv --directory=src/mcp_server_zenn run mcp-server-zenn
```
Upon launching, the Inspector will display a URL that you can access in your browser to begin debugging.
OR
Add this tool as a MCP server:
```json
{
"zenn": {
"command": "uv",
"args": [
"--directory",
"/path/to/mcp-server-zenn",
"run",
"mcp-server-zenn"
]
}
}
```
## Deployment
(TODO)
## [License](./LICENSE)
```
--------------------------------------------------------------------------------
/src/mcp_server_zenn/__main__.py:
--------------------------------------------------------------------------------
```python
from . import main
if __name__ == "__main__":
main()
```
--------------------------------------------------------------------------------
/src/mcp_server_zenn/__init__.py:
--------------------------------------------------------------------------------
```python
import asyncio
from . import server
def main():
asyncio.run(server.serve())
__all__ = ["main", "server"]
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
# Use a Python image with uv pre-installed
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS uv
# Install the project into `/app`
WORKDIR /app
# Enable bytecode compilation
ENV UV_COMPILE_BYTECODE=1
# Copy from the cache instead of linking since it's a mounted volume
ENV UV_LINK_MODE=copy
# Install the project's dependencies using the lockfile and settings
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-install-project --no-dev --no-editable
# Then, add the rest of the project source code and install it
# Installing separately from its dependencies allows optimal layer caching
ADD . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --no-editable
FROM python:3.12-slim-bookworm
WORKDIR /app
COPY --from=uv /root/.local /root/.local
COPY --from=uv --chown=app:app /app/.venv /app/.venv
# Place executables in the environment at the front of the path
ENV PATH="/app/.venv/bin:$PATH"
# when running the container, add --db-path and a bind mount to the host's db file
ENTRYPOINT ["mcp-server-zenn"]
```
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
```toml
[project]
name = "mcp-server-zenn"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.11.8"
dependencies = [
"httpx>=0.28.1",
"mcp[cli]>=1.3.0",
"pydantic>=2.10.6",
]
[dependency-groups]
dev = [
"hatchling>=1.27.0",
"isort>=6.0.1",
"mypy>=1.15.0",
"ruff>=0.9.10",
]
[project.scripts]
mcp-server-zenn = "mcp_server_zenn:main"
[tool.hatch.build.targets.wheel]
packages = ["src/mcp_server_zenn"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.metadata]
allow-direct-references = true
[tool.mypy]
show_error_context = true
show_column_numbers = true
ignore_missing_imports = false
disallow_untyped_defs = true
no_implicit_optional = true
warn_return_any = true
warn_unused_ignores = true
warn_redundant_casts = true
[tool.ruff]
exclude = [
".bzr",
".direnv",
".eggs",
".git",
".git-rewrite",
".hg",
".ipynb_checkpoints",
".mypy_cache",
".nox",
".pants.d",
".pyenv",
".pytest_cache",
".pytype",
".ruff_cache",
".svn",
".tox",
".venv",
".vscode",
"__pypackages__",
"_build",
"buck-out",
"build",
"dist",
"node_modules",
"site-packages",
"venv",
]
line-length = 120
indent-width = 4
target-version = "py312"
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F"]
ignore = ["E203"]
fixable = ["ALL"]
unfixable = []
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
docstring-code-format = false
docstring-code-line-length = "dynamic"
```
--------------------------------------------------------------------------------
/src/mcp_server_zenn/server.py:
--------------------------------------------------------------------------------
```python
import json
import logging
from enum import Enum
from typing import Optional, Sequence
import httpx
from mcp.server import NotificationOptions, Server
from mcp.server.models import InitializationOptions
from mcp.server.stdio import stdio_server
from mcp.types import EmbeddedResource, ImageContent, Prompt, Resource, TextContent, Tool
from pydantic import BaseModel, ConfigDict, Field
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
APP_NAME = "mcp-server-zenn"
APP_VERSION = "0.0.0"
BASE_URL = "https://zenn.dev/api/"
server = Server(APP_NAME)
options = server.create_initialization_options()
class ZennTool(Enum):
ARTICLE = "article"
BOOK = "book"
@staticmethod
def from_str(tool: str) -> "ZennTool":
for tool_type in ZennTool:
if tool_type.value == tool.lower():
return tool_type
raise ValueError(f"Invalid tool value: {tool}")
class URLResource(Enum):
ARTICLES = "articles"
BOOKS = "books"
@staticmethod
def from_str(resource: str) -> "URLResource":
for resource_type in URLResource:
if resource_type.value == resource.lower():
return resource_type
raise ValueError(f"Invalid resource value: {resource}")
@staticmethod
def from_zenn_tool(tool: ZennTool) -> "URLResource":
if tool == ZennTool.ARTICLE:
return URLResource.ARTICLES
elif tool == ZennTool.BOOK:
return URLResource.BOOKS
else:
raise ValueError(f"Invalid tool value: {tool}")
def to_zenn_tool(self) -> ZennTool:
if self == URLResource.ARTICLES:
return ZennTool.ARTICLE
elif self == URLResource.BOOKS:
return ZennTool.BOOK
else:
raise ValueError(f"Invalid resource value: {self}")
class Order(Enum):
LATEST = "latest"
OLDEST = "oldest"
@staticmethod
def from_str(order: str) -> "Order":
for order_type in Order:
if order_type.value == order.lower():
return order_type
raise ValueError(f"Invalid order value: {order}")
class Article(BaseModel):
"""Fetch articles from Zenn.dev"""
model_config = ConfigDict(
validate_assignment=True,
frozen=True,
extra="forbid",
)
username: Optional[str] = Field(default=None, description="Username of the article author")
topicname: Optional[str] = Field(default=None, description="Topic name of the article")
order: Optional[Order] = Field(
default=Order.LATEST,
description=f"Order of the articles. Choose from {Order.LATEST.value} or {Order.OLDEST.value}",
)
page: Optional[int] = Field(default=1, description="Page number of the articles. Default: 1")
count: Optional[int] = Field(default=48, description="Number of articles per page. Default: 48")
@staticmethod
def from_arguments(arguments: dict) -> "Article":
return Article(
username=arguments.get("username"),
topicname=arguments.get("topicname"),
order=Order.from_str(arguments.get("order", Order.LATEST.value)),
page=arguments.get("page", 1),
count=arguments.get("count", 48),
)
def to_query_param(self) -> dict:
param = {}
if self.username:
param["username"] = self.username.lower()
if self.topicname:
param["topicname"] = self.topicname.lower()
if self.order:
param["order"] = self.order.value
if self.page:
param["page"] = self.page
if self.count:
param["count"] = self.count
return param
@staticmethod
def tool() -> Tool:
return Tool(
name=ZennTool.ARTICLE.value,
description="Fetch articles from Zenn.dev",
inputSchema={
"type": "object",
"properties": {
"username": {"type": "string", "description": Article.model_fields["username"].description},
"topicname": {"type": "string", "description": Article.model_fields["topicname"].description},
"order": {
"type": "string",
"description": Article.model_fields["order"].description,
"enum": [Order.LATEST.value, Order.OLDEST.value],
},
"page": {"type": "integer", "description": Article.model_fields["page"].description},
"count": {"type": "integer", "description": Article.model_fields["count"].description},
},
"required": [],
},
)
class Book(BaseModel):
"""Fetch books from Zenn.dev"""
model_config = ConfigDict(
validate_assignment=True,
frozen=True,
extra="forbid",
)
username: Optional[str] = Field(default=None, description="Username of the book author")
topicname: Optional[str] = Field(default=None, description="Topic name of the book")
order: Optional[Order] = Field(
default=Order.LATEST,
description=f"Order of the books. Choose from {Order.LATEST.value} or {Order.OLDEST.value}. Default: {Order.LATEST.value}",
)
page: Optional[int] = Field(default=1, description="Page number of the books. Default: 1")
count: Optional[int] = Field(default=48, description="Number of books per page. Default: 48")
@staticmethod
def from_arguments(arguments: dict) -> "Book":
return Book(
username=arguments.get("username"),
topicname=arguments.get("topicname"),
order=Order.from_str(arguments.get("order", Order.LATEST.value)),
page=arguments.get("page", 1),
count=arguments.get("count", 48),
)
def to_query_param(self) -> dict:
param = {}
if self.username:
param["username"] = self.username.lower()
if self.topicname:
param["topicname"] = self.topicname.lower()
if self.order:
param["order"] = self.order.value
if self.page:
param["page"] = self.page
if self.count:
param["count"] = self.count
return param
@staticmethod
def tool() -> Tool:
return Tool(
name=ZennTool.BOOK.value,
description="Fetch books from Zenn.dev",
inputSchema={
"type": "object",
"properties": {
"username": {"type": "string", "description": Book.model_fields["username"].description},
"topicname": {"type": "string", "description": Book.model_fields["topicname"].description},
"order": {
"type": "string",
"description": Book.model_fields["order"].description,
"enum": [Order.LATEST.value, Order.OLDEST.value],
},
"page": {"type": "integer", "description": Book.model_fields["page"].description},
"count": {"type": "integer", "description": Book.model_fields["count"].description},
},
"required": [],
},
)
async def request(resource: URLResource, query: dict) -> dict:
url = f"{BASE_URL}{resource.value}"
async with httpx.AsyncClient() as client:
response = await client.get(url, params=query)
response.raise_for_status()
return response.json()
async def fetch_articles(query: Article) -> dict:
return await request(URLResource.ARTICLES, query.to_query_param())
async def fetch_books(query: Book) -> dict:
return await request(URLResource.BOOKS, query.to_query_param())
async def handle_articles(arguments: dict) -> dict:
query = Article.from_arguments(arguments)
return await fetch_articles(query)
async def handle_books(arguments: dict) -> dict:
query = Book.from_arguments(arguments)
return await fetch_books(query)
@server.list_prompts()
async def handle_list_prompts() -> list[Prompt]:
return []
@server.list_resources()
async def handle_list_resources() -> list[Resource]:
return []
@server.list_tools()
async def list_tools() -> list[Tool]:
return [Article.tool(), Book.tool()]
@server.call_tool()
async def call_tool(
name: str,
arguments: dict,
) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
try:
logger.debug(f"Calling tool: {name} with arguments: {arguments}")
match name:
case ZennTool.ARTICLE.value:
result = await handle_articles(arguments)
case ZennTool.BOOK.value:
result = await handle_books(arguments)
case _:
raise ValueError(f"Unknown tool: {name}")
return [TextContent(type="text", text=json.dumps(result, indent=2, ensure_ascii=False))]
except Exception as e:
logger.error(f"Error processing {APP_NAME} query: {str(e)}")
raise ValueError(f"Error processing {APP_NAME} query: {str(e)}")
async def serve():
logger.info("Starting MCP server for Zenn")
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
InitializationOptions(
server_name=APP_NAME,
server_version=APP_VERSION,
capabilities=server.get_capabilities(
notification_options=NotificationOptions(resources_changed=True),
experimental_capabilities={},
),
),
)
```