#
tokens: 4394/50000 8/8 files
lines: off (toggle) GitHub
raw markdown copy
# 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={},
                ),
            ),
        )

```