block-style-cms-mcp 0.0.3__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.
@@ -0,0 +1,15 @@
1
+ node_modules/
2
+ .env
3
+ tmp/
4
+ dist/
5
+ .venv/
6
+ **/__pycache__/
7
+ **/*.egg-info/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ *.pyc
11
+ .DS_Store
12
+ coverage/
13
+ packages/python/tests/_closed_loop_work/
14
+ packages/python/tests/closed_loop_report.json
15
+ packages/python/tests/trilingual_longform_report.json
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.5
2
+ Name: block-style-cms-mcp
3
+ Version: 0.0.3
4
+ Summary: MCP server for Block Style CMS (wraps block-style-cms-sdk)
5
+ Author-email: Biscuit Zhou <biscuit_zhou@outlook.com>
6
+ License: MIT
7
+ Keywords: blocknote,cms,markdown,mcp
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: block-style-cms-sdk>=0.0.3
19
+ Requires-Dist: mcp>=2.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=8.0; extra == 'dev'
22
+ Requires-Dist: ruff>=0.9; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # block-style-cms-mcp
26
+
27
+ Python [MCP](https://modelcontextprotocol.io/) server for [Block Style CMS](https://github.com/Byte-Biscuit/block-style-cms). Thin stdio wrapper around [`block-style-cms-sdk`](../python).
28
+
29
+ ## Credentials (where they live)
30
+
31
+ CMS **base URL** and **API token** are **not** tool arguments. Put them in the MCP host config `env` block (e.g. Cursor `mcp.json` / Settings → MCP):
32
+
33
+ | Parameter | Env var | Required |
34
+ |-----------|---------|----------|
35
+ | CMS base URL | `BSC_BASE_URL` | yes |
36
+ | API token | `BSC_API_KEY` | yes |
37
+ | Auth style `api-key` or `bearer` | `BSC_AUTH_STYLE` | no (default `api-key`) |
38
+
39
+ Do not commit real tokens. Do not pass them as tool inputs (they would land in chat logs).
40
+
41
+ ### Cursor example
42
+
43
+ ```json
44
+ {
45
+ "mcpServers": {
46
+ "block-style-cms": {
47
+ "command": "uvx",
48
+ "args": ["block-style-cms-mcp"],
49
+ "env": {
50
+ "BSC_BASE_URL": "https://cms.example.com",
51
+ "BSC_API_KEY": "your-api-token"
52
+ }
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ Local checkout (before PyPI):
59
+
60
+ ```json
61
+ {
62
+ "mcpServers": {
63
+ "block-style-cms": {
64
+ "command": "uv",
65
+ "args": [
66
+ "run",
67
+ "--directory",
68
+ "/absolute/path/to/packages/mcp-python",
69
+ "block-style-cms-mcp"
70
+ ],
71
+ "env": {
72
+ "BSC_BASE_URL": "https://cms.example.com",
73
+ "BSC_API_KEY": "your-api-token"
74
+ }
75
+ }
76
+ }
77
+ }
78
+ ```
79
+
80
+ ## Tools
81
+
82
+ | Tool | Purpose |
83
+ |------|---------|
84
+ | `list_locales` | Supported article languages |
85
+ | `list_channels` | Site channels |
86
+ | `upload_image` | Upload local image |
87
+ | `delete_image` | Delete library image by filename |
88
+ | `publish_from_markdown` | Create article from markdown |
89
+ | `update_from_markdown` | Update article from markdown (`id` required) |
90
+ | `get_article` | Get by slug + id (optional `include_md_content`) |
91
+ | `get_article_by_slug` | Get by slug + locale (optional `include_md_content`) |
92
+ | `list_articles` | List metadata with filters (`published` query: `published` \| `draft` \| `all`, **not** `true`/`false`) |
93
+ | `update_article_metadata` | Metadata-only update |
94
+ | `set_article_published` | Publish / unpublish |
95
+ | `delete_article` | Delete by slug + id |
96
+ | `delete_article_by_slug` | Delete by slug + locale |
97
+
98
+ Article closed-loop design: see repo [`docs/`](../../docs/README.md).
99
+
100
+ Path arguments (`md_file`, `assets_dir`, `file_path`, `cover_file`) must be readable by the MCP process (typically paths under the host workspace).
101
+
102
+ `list_articles.published` accepts only `published` | `draft` | `all` (CMS query enum). Boolean strings fail. Create/update and `set_article_published` still use a real boolean for the article’s published flag.
103
+
104
+ ## Develop
105
+
106
+ ```bash
107
+ cd packages/mcp-python
108
+ uv sync --extra dev
109
+ uv run ruff check .
110
+ uv run pytest
111
+ ```
112
+
113
+ ## Release
114
+
115
+ 1. Ensure the required `block-style-cms-sdk` version is already on PyPI.
116
+ 2. Bump `version` in `pyproject.toml` (and keep the SDK dependency pin in sync).
117
+ 3. Tag `mcp-py-vX.Y.Z`, push, and **Publish** a GitHub Release for that tag.
118
+ 4. GitHub Actions uploads to PyPI via [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC; GitHub Environment **`pypi-mcp`**, separate from the SDK’s `pypi` environment).
119
+
120
+ Example: version `0.0.3` → tag `mcp-py-v0.0.3` → GitHub Release → PyPI.
121
+
122
+ Setup checklist:
123
+
124
+ 1. GitHub → Settings → Environments → create **`pypi-mcp`**.
125
+ 2. Deployment branches and tags: allow tag pattern **`mcp-py-v*`** (optionally branch `master` for manual dry-runs).
126
+ 3. On PyPI, register a Trusted Publisher for project `block-style-cms-mcp` pointing at workflow `mcp-python-release.yml` (and environment name `pypi-mcp` if the form asks for it).
@@ -0,0 +1,102 @@
1
+ # block-style-cms-mcp
2
+
3
+ Python [MCP](https://modelcontextprotocol.io/) server for [Block Style CMS](https://github.com/Byte-Biscuit/block-style-cms). Thin stdio wrapper around [`block-style-cms-sdk`](../python).
4
+
5
+ ## Credentials (where they live)
6
+
7
+ CMS **base URL** and **API token** are **not** tool arguments. Put them in the MCP host config `env` block (e.g. Cursor `mcp.json` / Settings → MCP):
8
+
9
+ | Parameter | Env var | Required |
10
+ |-----------|---------|----------|
11
+ | CMS base URL | `BSC_BASE_URL` | yes |
12
+ | API token | `BSC_API_KEY` | yes |
13
+ | Auth style `api-key` or `bearer` | `BSC_AUTH_STYLE` | no (default `api-key`) |
14
+
15
+ Do not commit real tokens. Do not pass them as tool inputs (they would land in chat logs).
16
+
17
+ ### Cursor example
18
+
19
+ ```json
20
+ {
21
+ "mcpServers": {
22
+ "block-style-cms": {
23
+ "command": "uvx",
24
+ "args": ["block-style-cms-mcp"],
25
+ "env": {
26
+ "BSC_BASE_URL": "https://cms.example.com",
27
+ "BSC_API_KEY": "your-api-token"
28
+ }
29
+ }
30
+ }
31
+ }
32
+ ```
33
+
34
+ Local checkout (before PyPI):
35
+
36
+ ```json
37
+ {
38
+ "mcpServers": {
39
+ "block-style-cms": {
40
+ "command": "uv",
41
+ "args": [
42
+ "run",
43
+ "--directory",
44
+ "/absolute/path/to/packages/mcp-python",
45
+ "block-style-cms-mcp"
46
+ ],
47
+ "env": {
48
+ "BSC_BASE_URL": "https://cms.example.com",
49
+ "BSC_API_KEY": "your-api-token"
50
+ }
51
+ }
52
+ }
53
+ }
54
+ ```
55
+
56
+ ## Tools
57
+
58
+ | Tool | Purpose |
59
+ |------|---------|
60
+ | `list_locales` | Supported article languages |
61
+ | `list_channels` | Site channels |
62
+ | `upload_image` | Upload local image |
63
+ | `delete_image` | Delete library image by filename |
64
+ | `publish_from_markdown` | Create article from markdown |
65
+ | `update_from_markdown` | Update article from markdown (`id` required) |
66
+ | `get_article` | Get by slug + id (optional `include_md_content`) |
67
+ | `get_article_by_slug` | Get by slug + locale (optional `include_md_content`) |
68
+ | `list_articles` | List metadata with filters (`published` query: `published` \| `draft` \| `all`, **not** `true`/`false`) |
69
+ | `update_article_metadata` | Metadata-only update |
70
+ | `set_article_published` | Publish / unpublish |
71
+ | `delete_article` | Delete by slug + id |
72
+ | `delete_article_by_slug` | Delete by slug + locale |
73
+
74
+ Article closed-loop design: see repo [`docs/`](../../docs/README.md).
75
+
76
+ Path arguments (`md_file`, `assets_dir`, `file_path`, `cover_file`) must be readable by the MCP process (typically paths under the host workspace).
77
+
78
+ `list_articles.published` accepts only `published` | `draft` | `all` (CMS query enum). Boolean strings fail. Create/update and `set_article_published` still use a real boolean for the article’s published flag.
79
+
80
+ ## Develop
81
+
82
+ ```bash
83
+ cd packages/mcp-python
84
+ uv sync --extra dev
85
+ uv run ruff check .
86
+ uv run pytest
87
+ ```
88
+
89
+ ## Release
90
+
91
+ 1. Ensure the required `block-style-cms-sdk` version is already on PyPI.
92
+ 2. Bump `version` in `pyproject.toml` (and keep the SDK dependency pin in sync).
93
+ 3. Tag `mcp-py-vX.Y.Z`, push, and **Publish** a GitHub Release for that tag.
94
+ 4. GitHub Actions uploads to PyPI via [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC; GitHub Environment **`pypi-mcp`**, separate from the SDK’s `pypi` environment).
95
+
96
+ Example: version `0.0.3` → tag `mcp-py-v0.0.3` → GitHub Release → PyPI.
97
+
98
+ Setup checklist:
99
+
100
+ 1. GitHub → Settings → Environments → create **`pypi-mcp`**.
101
+ 2. Deployment branches and tags: allow tag pattern **`mcp-py-v*`** (optionally branch `master` for manual dry-runs).
102
+ 3. On PyPI, register a Trusted Publisher for project `block-style-cms-mcp` pointing at workflow `mcp-python-release.yml` (and environment name `pypi-mcp` if the form asks for it).
@@ -0,0 +1,61 @@
1
+ [project]
2
+ name = "block-style-cms-mcp"
3
+ version = "0.0.3"
4
+ description = "MCP server for Block Style CMS (wraps block-style-cms-sdk)"
5
+ authors = [
6
+ { name = "Biscuit Zhou", email = "biscuit_zhou@outlook.com" }
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.10"
10
+ license = { text = "MIT" }
11
+ keywords = ["cms", "mcp", "blocknote", "markdown"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = [
24
+ "block-style-cms-sdk>=0.0.3",
25
+ "mcp>=2.0",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ dev = [
30
+ "pytest>=8.0",
31
+ "ruff>=0.9",
32
+ ]
33
+
34
+ [project.scripts]
35
+ block-style-cms-mcp = "block_style_cms_mcp:main"
36
+
37
+ [build-system]
38
+ requires = ["hatchling"]
39
+ build-backend = "hatchling.build"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/block_style_cms_mcp"]
43
+
44
+ # Local monorepo only (not shipped in the PyPI package metadata).
45
+ # Published installs resolve block-style-cms-sdk>=0.0.3 from PyPI.
46
+ [tool.uv.sources]
47
+ block-style-cms-sdk = { path = "../python", editable = true }
48
+
49
+ [tool.ruff]
50
+ line-length = 120
51
+ target-version = "py310"
52
+ src = ["src", "tests"]
53
+
54
+ [tool.ruff.lint]
55
+ select = ["E", "F", "I", "UP", "B"]
56
+
57
+ [tool.ruff.format]
58
+ quote-style = "double"
59
+
60
+ [tool.pytest.ini_options]
61
+ testpaths = ["tests"]
@@ -0,0 +1,7 @@
1
+ """MCP server for Block Style CMS."""
2
+
3
+ from .server import create_server, main
4
+
5
+ __all__ = ["create_server", "main"]
6
+
7
+ __version__ = "0.0.1"
@@ -0,0 +1,3 @@
1
+ from .server import main
2
+
3
+ main()
@@ -0,0 +1,52 @@
1
+ """Load CMS credentials from host-injected environment variables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from typing import Literal
8
+
9
+ AuthStyle = Literal["api-key", "bearer"]
10
+
11
+ ENV_BASE_URL = "BSC_BASE_URL"
12
+ ENV_API_KEY = "BSC_API_KEY"
13
+ ENV_AUTH_STYLE = "BSC_AUTH_STYLE"
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Settings:
18
+ base_url: str
19
+ api_key: str
20
+ auth_style: AuthStyle = "api-key"
21
+
22
+
23
+ def load_settings(
24
+ environ: dict[str, str] | None = None,
25
+ ) -> Settings:
26
+ """Read BSC_* env vars. Raises SystemExit with a clear message if missing."""
27
+ env = environ if environ is not None else os.environ
28
+ missing: list[str] = []
29
+ base_url = (env.get(ENV_BASE_URL) or "").strip()
30
+ api_key = (env.get(ENV_API_KEY) or "").strip()
31
+ if not base_url:
32
+ missing.append(ENV_BASE_URL)
33
+ if not api_key:
34
+ missing.append(ENV_API_KEY)
35
+ if missing:
36
+ names = ", ".join(missing)
37
+ raise SystemExit(
38
+ f"Missing required environment variable(s): {names}. "
39
+ "Set them in the MCP host config env block "
40
+ "(e.g. Cursor mcp.json), not as tool arguments."
41
+ )
42
+
43
+ raw_style = (env.get(ENV_AUTH_STYLE) or "api-key").strip().lower()
44
+ if raw_style not in ("api-key", "bearer"):
45
+ raise SystemExit(
46
+ f"Invalid {ENV_AUTH_STYLE}={raw_style!r}; expected 'api-key' or 'bearer'."
47
+ )
48
+ return Settings(
49
+ base_url=base_url,
50
+ api_key=api_key,
51
+ auth_style=raw_style, # type: ignore[arg-type]
52
+ )
@@ -0,0 +1,287 @@
1
+ """stdio MCP server wrapping BlockStyleCmsClient."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from block_style_cms_sdk import BlockStyleCmsClient
8
+ from block_style_cms_sdk.models import ArticleListPublishedFilter, ImageUploadOptions
9
+ from mcp.server.mcpserver import MCPServer
10
+
11
+ from .config import Settings, load_settings
12
+ from .tools import (
13
+ TOOL_DELETE_ARTICLE,
14
+ TOOL_DELETE_ARTICLE_BY_SLUG,
15
+ TOOL_DELETE_IMAGE,
16
+ TOOL_GET_ARTICLE,
17
+ TOOL_GET_ARTICLE_BY_SLUG,
18
+ TOOL_LIST_ARTICLES,
19
+ TOOL_LIST_CHANNELS,
20
+ TOOL_LIST_LOCALES,
21
+ TOOL_PUBLISH_FROM_MARKDOWN,
22
+ TOOL_SET_ARTICLE_PUBLISHED,
23
+ TOOL_UPDATE_ARTICLE_METADATA,
24
+ TOOL_UPDATE_FROM_MARKDOWN,
25
+ TOOL_UPLOAD_IMAGE,
26
+ )
27
+
28
+
29
+ def create_server(
30
+ client: BlockStyleCmsClient,
31
+ *,
32
+ name: str = "block-style-cms",
33
+ ) -> MCPServer:
34
+ """Build an MCP server bound to an existing SDK client (injectable for tests)."""
35
+ mcp = MCPServer(name)
36
+
37
+ @mcp.tool(name=TOOL_LIST_LOCALES)
38
+ def list_locales() -> list[dict[str, Any]]:
39
+ """List supported article locales from the CMS."""
40
+ return [loc.model_dump() for loc in client.content.list_locales()]
41
+
42
+ @mcp.tool(name=TOOL_LIST_CHANNELS)
43
+ def list_channels() -> list[dict[str, Any]]:
44
+ """List site channels from CMS settings."""
45
+ return [ch.model_dump() for ch in client.content.list_channels()]
46
+
47
+ @mcp.tool(name=TOOL_UPLOAD_IMAGE)
48
+ def upload_image(
49
+ file_path: str,
50
+ quality: int | None = None,
51
+ max_width: int | None = None,
52
+ max_height: int | None = None,
53
+ enable_webp: bool | None = None,
54
+ ) -> dict[str, Any]:
55
+ """Upload a local image file to the CMS media library.
56
+
57
+ file_path must be readable from this MCP process (usually the host workspace).
58
+ """
59
+ opts: ImageUploadOptions | None = None
60
+ if any(v is not None for v in (quality, max_width, max_height, enable_webp)):
61
+ opts = ImageUploadOptions(
62
+ quality=quality,
63
+ maxWidth=max_width,
64
+ maxHeight=max_height,
65
+ enableWebP=enable_webp,
66
+ )
67
+ meta = client.media.upload_image_file(file_path, opts)
68
+ return meta.model_dump()
69
+
70
+ @mcp.tool(name=TOOL_DELETE_IMAGE)
71
+ def delete_image(filename: str) -> Any:
72
+ """Delete an image from the CMS media library by filename."""
73
+ return client.media.delete_image(filename)
74
+
75
+ @mcp.tool(name=TOOL_PUBLISH_FROM_MARKDOWN)
76
+ def publish_from_markdown(
77
+ title: str,
78
+ slug: str,
79
+ language: str,
80
+ assets_dir: str,
81
+ md_content: str | None = None,
82
+ md_file: str | None = None,
83
+ summary: str = "",
84
+ tags: list[str] | None = None,
85
+ keywords: list[str] | None = None,
86
+ cover: str = "",
87
+ cover_file: str | None = None,
88
+ published: bool = False,
89
+ locale_param: str | None = None,
90
+ ) -> dict[str, Any]:
91
+ """Publish an article from markdown, uploading relative local images.
92
+
93
+ language is a site language tag (e.g. zh-CN, en-US, ja-JP); call
94
+ list_locales first for values this CMS accepts. Provide md_content or
95
+ md_file. assets_dir is required to resolve relative image paths. Paths
96
+ are relative to the MCP process working directory / host workspace.
97
+ """
98
+ result = client.articles.publish_from_markdown(
99
+ title=title,
100
+ slug=slug,
101
+ language=language,
102
+ assets_dir=assets_dir,
103
+ md_content=md_content,
104
+ md_file=md_file,
105
+ summary=summary,
106
+ tags=tags,
107
+ keywords=keywords,
108
+ cover=cover,
109
+ cover_file=cover_file,
110
+ published=published,
111
+ locale_param=locale_param,
112
+ )
113
+ return result.model_dump()
114
+
115
+ @mcp.tool(name=TOOL_UPDATE_FROM_MARKDOWN)
116
+ def update_from_markdown(
117
+ id: str,
118
+ title: str,
119
+ slug: str,
120
+ language: str,
121
+ assets_dir: str,
122
+ md_content: str | None = None,
123
+ md_file: str | None = None,
124
+ summary: str = "",
125
+ tags: list[str] | None = None,
126
+ keywords: list[str] | None = None,
127
+ cover: str = "",
128
+ cover_file: str | None = None,
129
+ published: bool = False,
130
+ locale_param: str | None = None,
131
+ ) -> dict[str, Any]:
132
+ """Update an existing article from markdown (requires id).
133
+
134
+ Prefer editing mdContent from get_article so images stay absolute http(s)
135
+ URLs and uploads are skipped. Only relative local paths are uploaded.
136
+ """
137
+ result = client.articles.update_from_markdown(
138
+ id=id,
139
+ title=title,
140
+ slug=slug,
141
+ language=language,
142
+ assets_dir=assets_dir,
143
+ md_content=md_content,
144
+ md_file=md_file,
145
+ summary=summary,
146
+ tags=tags,
147
+ keywords=keywords,
148
+ cover=cover,
149
+ cover_file=cover_file,
150
+ published=published,
151
+ locale_param=locale_param,
152
+ )
153
+ return result.model_dump()
154
+
155
+ @mcp.tool(name=TOOL_GET_ARTICLE)
156
+ def get_article(
157
+ slug: str,
158
+ id: str,
159
+ include_md_content: bool = False,
160
+ ) -> dict[str, Any]:
161
+ """Get full article by slug and id (BlockNote content by default).
162
+
163
+ Set include_md_content=true to also receive mdContent export.
164
+ """
165
+ return client.articles.get(
166
+ slug, id, include_md_content=include_md_content
167
+ ).model_dump()
168
+
169
+ @mcp.tool(name=TOOL_GET_ARTICLE_BY_SLUG)
170
+ def get_article_by_slug(
171
+ slug: str,
172
+ locale: str,
173
+ include_md_content: bool = False,
174
+ ) -> dict[str, Any]:
175
+ """Get full article by slug and locale (BlockNote content by default).
176
+
177
+ Set include_md_content=true to also receive mdContent export.
178
+ """
179
+ return client.articles.get_by_slug(
180
+ slug, locale, include_md_content=include_md_content
181
+ ).model_dump()
182
+
183
+ @mcp.tool(name=TOOL_LIST_ARTICLES)
184
+ def list_articles(
185
+ locale: str | None = None,
186
+ tag: str | None = None,
187
+ search_term: str | None = None,
188
+ published: ArticleListPublishedFilter | None = None,
189
+ page_index: int | None = None,
190
+ page_size: int | None = None,
191
+ ) -> dict[str, Any]:
192
+ """List article metadata with optional filters.
193
+
194
+ published is a CMS status filter token, not a boolean:
195
+ "published" | "draft" | "all". Do not pass "true"/"false"
196
+ (the API rejects them). Omit or use "all" for no status filter.
197
+ This is unrelated to set_article_published / create-time
198
+ published:bool fields.
199
+ """
200
+ return client.articles.list(
201
+ locale=locale,
202
+ tag=tag,
203
+ search_term=search_term,
204
+ published=published,
205
+ page_index=page_index,
206
+ page_size=page_size,
207
+ ).model_dump()
208
+
209
+ @mcp.tool(name=TOOL_UPDATE_ARTICLE_METADATA)
210
+ def update_article_metadata(
211
+ id: str,
212
+ slug: str,
213
+ title: str,
214
+ locale: str,
215
+ summary: str = "",
216
+ tags: list[str] | None = None,
217
+ keywords: list[str] | None = None,
218
+ image: str = "",
219
+ published: bool | None = None,
220
+ locale_param: str | None = None,
221
+ ) -> dict[str, Any]:
222
+ """Update article metadata without replacing content blocks."""
223
+ fields: dict[str, Any] = {
224
+ "id": id,
225
+ "slug": slug,
226
+ "title": title,
227
+ "locale": locale,
228
+ "summary": summary,
229
+ "tags": tags or [],
230
+ "keywords": keywords or [],
231
+ "image": image,
232
+ }
233
+ if published is not None:
234
+ fields["published"] = published
235
+ if locale_param:
236
+ fields["__locale__"] = locale_param
237
+ return client.articles.update_metadata(**fields)
238
+
239
+ @mcp.tool(name=TOOL_SET_ARTICLE_PUBLISHED)
240
+ def set_article_published(
241
+ id: str,
242
+ slug: str,
243
+ published: bool,
244
+ locale: str | None = None,
245
+ locale_param: str | None = None,
246
+ ) -> dict[str, Any]:
247
+ """Publish or unpublish an article (loads current metadata first)."""
248
+ return client.articles.set_published(
249
+ id=id,
250
+ slug=slug,
251
+ published=published,
252
+ locale=locale,
253
+ locale_param=locale_param,
254
+ )
255
+
256
+ @mcp.tool(name=TOOL_DELETE_ARTICLE)
257
+ def delete_article(slug: str, id: str) -> dict[str, Any]:
258
+ """Delete an article by slug and id."""
259
+ return client.articles.delete(slug, id)
260
+
261
+ @mcp.tool(name=TOOL_DELETE_ARTICLE_BY_SLUG)
262
+ def delete_article_by_slug(slug: str, locale: str) -> dict[str, Any]:
263
+ """Delete an article by slug and locale."""
264
+ return client.articles.delete_by_slug(slug, locale)
265
+
266
+ return mcp
267
+
268
+
269
+ def build_client(settings: Settings) -> BlockStyleCmsClient:
270
+ return BlockStyleCmsClient(
271
+ base_url=settings.base_url,
272
+ api_key=settings.api_key,
273
+ auth_style=settings.auth_style,
274
+ )
275
+
276
+
277
+ def main() -> None:
278
+ settings = load_settings()
279
+ client = build_client(settings)
280
+ try:
281
+ create_server(client).run()
282
+ finally:
283
+ client.close()
284
+
285
+
286
+ if __name__ == "__main__":
287
+ main()
@@ -0,0 +1,34 @@
1
+ """Tool names (schemas come from MCPServer handler signatures)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # Public MCP tool names (snake_case; stable contract).
6
+ TOOL_LIST_LOCALES = "list_locales"
7
+ TOOL_LIST_CHANNELS = "list_channels"
8
+ TOOL_UPLOAD_IMAGE = "upload_image"
9
+ TOOL_DELETE_IMAGE = "delete_image"
10
+ TOOL_PUBLISH_FROM_MARKDOWN = "publish_from_markdown"
11
+ TOOL_UPDATE_FROM_MARKDOWN = "update_from_markdown"
12
+ TOOL_GET_ARTICLE = "get_article"
13
+ TOOL_GET_ARTICLE_BY_SLUG = "get_article_by_slug"
14
+ TOOL_LIST_ARTICLES = "list_articles"
15
+ TOOL_UPDATE_ARTICLE_METADATA = "update_article_metadata"
16
+ TOOL_SET_ARTICLE_PUBLISHED = "set_article_published"
17
+ TOOL_DELETE_ARTICLE = "delete_article"
18
+ TOOL_DELETE_ARTICLE_BY_SLUG = "delete_article_by_slug"
19
+
20
+ TOOL_NAMES = (
21
+ TOOL_LIST_LOCALES,
22
+ TOOL_LIST_CHANNELS,
23
+ TOOL_UPLOAD_IMAGE,
24
+ TOOL_DELETE_IMAGE,
25
+ TOOL_PUBLISH_FROM_MARKDOWN,
26
+ TOOL_UPDATE_FROM_MARKDOWN,
27
+ TOOL_GET_ARTICLE,
28
+ TOOL_GET_ARTICLE_BY_SLUG,
29
+ TOOL_LIST_ARTICLES,
30
+ TOOL_UPDATE_ARTICLE_METADATA,
31
+ TOOL_SET_ARTICLE_PUBLISHED,
32
+ TOOL_DELETE_ARTICLE,
33
+ TOOL_DELETE_ARTICLE_BY_SLUG,
34
+ )