Skip to content

feat(hack): add publishing tools for the blog - #640

Open
Timur Tukaev (tym83) wants to merge 2 commits into
mainfrom
feat/publishing-mcp
Open

feat(hack): add publishing tools for the blog#640
Timur Tukaev (tym83) wants to merge 2 commits into
mainfrom
feat/publishing-mcp

Conversation

@tym83

@tym83 Timur Tukaev (tym83) commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Two tools for the blog behind one implementation: publish_post creates a post, validate checks posts. They are available both as an MCP server over stdio and as a CI step, so a generated post and a hand-written one are held to identical rules.

Writing markdown into the right directory was never the hard part. Keeping posts consistent with rules that live scattered across documentation, templates and habit is — and every check here exists because the mistake it prevents has already been made in this repository: an image filename among the topics, a term invented while writing, a post with images laid out as a plain file.

What is added

  • hack/mcp/validate.py (the rules), core.py (the single write path), server.py (MCP over stdio, and the CI runner via --check), frontmatter.py, tests, and a reference README
  • .mcp.json registering the server, so an MCP-capable client picks it up from a checkout with no separate installation
  • data/community-links.yaml so the closing section has one source of truth instead of being copied into every post
  • .github/workflows/validate-content.yaml running the same checks on pull requests
  • A short section in the root README pointing at all of it

No new runtime dependencies. The MCP protocol is line-delimited JSON-RPC and is implemented directly rather than pulling in an SDK. PyYAML was already required; Pillow is used only to read image dimensions.

Design notes worth reviewing

Publishing validates before writing, and rolls back. If a later step fails, whatever was created is removed, so a failed publish never leaves debris to clean up by hand.

Link checking has two modes, and this is deliberate. With a build in public/, links resolve against the paths the site actually serves and an unresolved link is an error. Without a build, URLs are inferred from the content tree — and that is only approximate, because Hugo derives them through permalinks, per-page aliases and version directories. Reproducing all of that faithfully is not worth it, so in the approximate mode an unresolved link is a warning: the checker never fails on its own guesswork. The CI job builds first, so it runs strict.

I arrived at that split the hard way. An earlier version inferred URLs and confidently flagged working links as broken — it missed the blog permalink shape, files under static/, per-page aliases and version-agnostic /docs/... links, all of which return 200 on the live site.

Images are copied unchanged. Resizing and AVIF or WebP conversion belong to Hugo, which processes bundle resources natively and encodes AVIF as of 0.162. The Open Graph card is held to PNG or JPEG, because Telegram, LinkedIn and other parsers render neither SVG, AVIF nor WebP in og:image — and that preview is the reason the card exists.

Metadata is expected ready-made. Turning a draft into markdown and choosing taxonomy terms is the calling agent's job. This server lays the result out correctly and refuses what breaks the rules; it does not call out to anything.

Nothing here emits meta tags. The SEO and structured-data setup already lives in layouts/partials/hooks/head-end.html, and duplicating it would only conflict. The job is to guarantee the quality of the fields those templates read.

Out of scope on purpose: content/*/docs/** (that belongs to the release pipeline) and translations (they have their own review gates).

Verification

34 tests, each against a throwaway site in a temporary directory: python3 hack/mcp/test_mcp.py.

The tools have also been used for real: #641 publishes two posts produced with them.

Merge order

Merge #642 first. That PR repairs the existing content — front matter, dead links and bundle names — and brings the blog to zero errors. Until it lands, the validation job here would be red on pre-existing problems in 18 posts. With it merged, this job is green and can stay strict from the start.

No change is needed here either way; it is purely a question of order.

Two tools over one implementation: publish_post creates a post, validate
checks posts. Available as an MCP server over stdio and as a CI step, so a
generated post and a hand-written one are held to identical rules.

Writing markdown into the right directory was never the hard part. Keeping
posts consistent with rules scattered across documentation, templates and
habit is, and every check here exists because the mistake it prevents has
been made in this repository already: an image filename among the topics, a
term invented while writing, a post with images laid out as a plain file.

Publishing validates before it writes and removes whatever it created if a
later step fails, so a failed publish leaves nothing to clean up.

Link checking has two modes. With a build in public/ links resolve against
the paths the site actually serves and an unresolved link is an error.
Without one, URLs are inferred from the content tree — only approximate,
since Hugo derives them through permalinks, per-page aliases and version
directories — so an unresolved link is a warning and the checker never
fails on its own inference.

Images are copied unchanged: resizing and AVIF/WebP conversion are Hugo's
job, and it encodes AVIF natively as of 0.162. The Open Graph card is held
to PNG or JPEG because social parsers render neither SVG, AVIF nor WebP as
previews.

Community links move to data/community-links.yaml so changing one does not
mean editing published posts after the fact.

No new runtime dependencies: the MCP protocol is implemented directly, and
Pillow is used only to read image dimensions.

32 tests, each against a throwaway site in a temporary directory.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <6355522@gmail.com>
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@netlify

netlify Bot commented Aug 3, 2026

Copy link
Copy Markdown

Deploy Preview for cozystack ready!

Name Link
🔨 Latest commit 226adc2
🔍 Latest deploy log https://app.netlify.com/projects/cozystack/deploys/6a72204dd2c9ba0008d817b3
😎 Deploy Preview https://deploy-preview-640--cozystack.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Blog publishing and validation

Layer / File(s) Summary
Content validation foundation
hack/mcp/frontmatter.py, hack/mcp/validate.py
Adds front matter parsing and validation for post structure, taxonomy, descriptions, images, and links.
Transactional post publishing
hack/mcp/core.py, data/community-links.yaml
Adds post generation, shared content sections, image bundles, rollback handling, and optional Git commits.
MCP and CLI interface
hack/mcp/server.py, .mcp.json
Exposes publishing and validation through JSON-RPC, stdio, and --check mode.
Tests and repository integration
hack/mcp/test_mcp.py, hack/mcp/README.md, README.md, .gitignore, .github/workflows/validate-content.yaml
Adds tool tests, documentation, Python cache ignores, and pull-request content validation.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MCPClient
  participant MCPServer
  participant Publisher
  participant Validator
  participant Git
  MCPClient->>MCPServer: publish_post request
  MCPServer->>Publisher: post arguments
  Publisher->>Validator: validate generated post
  Publisher->>Git: stage and commit post
  Git-->>Publisher: commit metadata
  Publisher-->>MCPServer: PublishResult
  MCPServer-->>MCPClient: JSON-RPC result
Loading

Suggested reviewers: kvaps

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 32.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main addition of blog publishing tools under hack, although it does not mention validation tooling.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/publishing-mcp

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (4)
hack/mcp/test_mcp.py (1)

119-119: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Rename the loop variable l.

Ruff reports E741 for both lines. Use a descriptive name so the lint run stays clean.

♻️ Proposed fix
-    lines = [l for l in out.splitlines() if l and not l.startswith("-")]
+    lines = [line for line in out.splitlines() if line and not line.startswith("-")]
         front = "\n".join(
-            l for l in VALID_FRONT.strip().splitlines() if not l.startswith("description")
+            line
+            for line in VALID_FRONT.strip().splitlines()
+            if not line.startswith("description")
         )

Also applies to: 186-186

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/mcp/test_mcp.py` at line 119, Rename the list-comprehension loop
variable l in both affected comprehensions to a descriptive name, and update its
references within each expression so Ruff E741 is resolved without changing the
filtering behavior.

Source: Linters/SAST tools

hack/mcp/README.md (1)

33-40: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

State the test dependencies.

test_publish_bundle_with_image imports PIL, and all modules import yaml. Without PyYAML and Pillow the test run fails with an import error. The workflow installs both, but a local reader gets no hint here.

📝 Proposed addition
 ```bash
+python3 -m pip install PyYAML Pillow
 python3 hack/mcp/test_mcp.py
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @hack/mcp/README.md around lines 33 - 40, Add the required local test
dependency installation command to the “Running the tests” section before the
existing test command, specifying both PyYAML and Pillow so imports used by
test_mcp.py succeed.


</details>

<!-- cr-comment:v1:564bb30168152e4270206d82 -->

</blockquote></details>
<details>
<summary>hack/mcp/frontmatter.py (1)</summary><blockquote>

`38-50`: _🎯 Functional Correctness_ | _🔵 Trivial_ | _💤 Low value_

**Make the delimiter match line-anchored.**

`split` accepts any text that starts with `---`, and it finds the first occurrence of `\n---` anywhere. A closing delimiter is only valid when it stands alone on its line. A YAML block scalar that contains a line starting with `---`, or an opening line such as `---title: x`, is parsed incorrectly.

The current blog posts do not hit this, so this is a hardening nit.

<details>
<summary>♻️ Proposed stricter delimiter handling</summary>

```diff
-    if not text.startswith(DELIMITER):
+    if not re.match(rf"{DELIMITER}\s*\n", text):
         raise FrontMatterError("file does not start with '---'")
 
-    # Search for the closing delimiter on its own line.
-    rest = text[len(DELIMITER) :]
-    marker = f"\n{DELIMITER}"
-    end = rest.find(marker)
-    if end == -1:
+    # Search for the closing delimiter on its own line.
+    rest = text[len(DELIMITER) :]
+    closing = re.search(rf"\n{DELIMITER}[ \t]*(?:\n|$)", rest)
+    if closing is None:
         raise FrontMatterError("closing '---' not found")
 
-    fm = rest[:end]
-    body = rest[end + len(marker) :]
+    fm = rest[: closing.start()]
+    body = rest[closing.end() :]
     return fm.lstrip("\n"), body.lstrip("\n")

This requires import re at the top of the file.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/mcp/frontmatter.py` around lines 38 - 50, Update the frontmatter
splitter around the delimiter parsing to require the opening `---` and closing
`---` markers to occupy complete lines, allowing only permitted line-ending
whitespace as appropriate. Replace the broad prefix and substring searches with
line-anchored matching, while preserving the existing `FrontMatterError` cases
and returned frontmatter/body trimming behavior.
.mcp.json (1)

3-6: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use a path that does not depend on the MCP client working directory.

.mcp.json launches hack/mcp/server.py as a relative argument. If the target client starts the process outside the repository root, python3 can fail to find the server before server.py derives the repository root from __file__. Add an explicit cwd only for clients that support it, or document the required working directory.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.mcp.json around lines 3 - 6, Update the cozystack-website MCP configuration
to launch hack/mcp/server.py independently of the client’s working directory,
using an explicit supported cwd or an equivalent absolute/repository-root-based
path. Preserve the existing Python command and server entrypoint while ensuring
startup succeeds when invoked outside the repository root.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/validate-content.yaml:
- Around line 52-53: Make the Validate content step in the workflow non-blocking
by adding continue-on-error: true, allowing existing post validation failures
without failing pull requests. Keep the python3 hack/mcp/server.py --check
command unchanged so the validation report still runs.
- Around line 19-24: Add a job-level permissions block to the validate job,
granting only read access required for repository checkout and workflow
execution, with all unspecified scopes disabled. Keep the existing runs-on,
environment, and steps unchanged.

In `@hack/mcp/core.py`:
- Around line 175-183: Update the image-copy loop to reject duplicate source
basenames before copying: detect when a name already exists in the current image
set and raise PublishError, and also reject any basename matching the generated
post filename (such as index.md) before writing. Preserve the existing copying
and copied tracking for valid, unique image names.
- Around line 198-207: Update the publish flow around `_commit` and the
exception cleanup to track the created branch and original git state, then pass
that information to the rollback helper. Ensure failure cleanup unstages paths,
checks out the original branch, and deletes any branch created by `_commit`,
while preserving the existing file rollback behavior and successful commit flow.

In `@hack/mcp/server.py`:
- Around line 198-202: Constrain the resolved path in the rel-handling branch
before calling validate.validate_post: after resolving (root / rel), verify it
remains within root and reject outside targets with the existing no-such-file
response. Preserve validation for paths inside the repository, including nested
paths, and ensure absolute paths and ../ traversal cannot escape root.
- Around line 328-331: Update the args.check branch and tool_validate contract
to return or expose structured validation reports alongside the rendered output,
then derive the exit status from those reports rather than searching output for
"FAIL". Ensure missing paths produce a nonzero status and warning-only results
remain successful, while preserving the existing printed text.

In `@hack/mcp/validate.py`:
- Around line 236-237: Update the index-page handling around the served.add call
to replace str(rel.parent).strip(".") with an explicit root-directory check: add
an empty served path only when rel.parent is the intended root, otherwise add
the parent path without removing leading dots from dot-directories such as
.well-known. Preserve dot-directory names exactly for link resolution.

---

Nitpick comments:
In @.mcp.json:
- Around line 3-6: Update the cozystack-website MCP configuration to launch
hack/mcp/server.py independently of the client’s working directory, using an
explicit supported cwd or an equivalent absolute/repository-root-based path.
Preserve the existing Python command and server entrypoint while ensuring
startup succeeds when invoked outside the repository root.

In `@hack/mcp/frontmatter.py`:
- Around line 38-50: Update the frontmatter splitter around the delimiter
parsing to require the opening `---` and closing `---` markers to occupy
complete lines, allowing only permitted line-ending whitespace as appropriate.
Replace the broad prefix and substring searches with line-anchored matching,
while preserving the existing `FrontMatterError` cases and returned
frontmatter/body trimming behavior.

In `@hack/mcp/README.md`:
- Around line 33-40: Add the required local test dependency installation command
to the “Running the tests” section before the existing test command, specifying
both PyYAML and Pillow so imports used by test_mcp.py succeed.

In `@hack/mcp/test_mcp.py`:
- Line 119: Rename the list-comprehension loop variable l in both affected
comprehensions to a descriptive name, and update its references within each
expression so Ruff E741 is resolved without changing the filtering behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a811be7a-81ff-4eb3-afe1-5456d67db7bd

📥 Commits

Reviewing files that changed from the base of the PR and between 76f9f0b and c589f09.

📒 Files selected for processing (11)
  • .github/workflows/validate-content.yaml
  • .gitignore
  • .mcp.json
  • README.md
  • data/community-links.yaml
  • hack/mcp/README.md
  • hack/mcp/core.py
  • hack/mcp/frontmatter.py
  • hack/mcp/server.py
  • hack/mcp/test_mcp.py
  • hack/mcp/validate.py

Comment on lines +19 to +24
jobs:
validate:
runs-on: ubuntu-latest
env:
HUGO_VERSION: 0.164.0
steps:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Add a least-privilege permissions block.

The job only reads the repository. Without an explicit permissions key, the job inherits the repository or organization default, which can include write scopes for GITHUB_TOKEN. The job runs repository code, including npm install lifecycle scripts and ./hack/download_openapi.sh, so a broad token increases the impact of a malicious dependency.

🔒️ Proposed fix
 jobs:
   validate:
     runs-on: ubuntu-latest
+    permissions:
+      contents: read
     env:
       HUGO_VERSION: 0.164.0
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
jobs:
validate:
runs-on: ubuntu-latest
env:
HUGO_VERSION: 0.164.0
steps:
jobs:
validate:
runs-on: ubuntu-latest
permissions:
contents: read
env:
HUGO_VERSION: 0.164.0
steps:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/validate-content.yaml around lines 19 - 24, Add a
job-level permissions block to the validate job, granting only read access
required for repository checkout and workflow execution, with all unspecified
scopes disabled. Keep the existing runs-on, environment, and steps unchanged.

Comment on lines +52 to +53
- name: Validate content
run: python3 hack/mcp/server.py --check

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

The strict validation step fails on the existing posts.

The PR description states that 18 published posts already have validation errors. This step exits non-zero, so every pull request that touches content/** or hack/mcp/** fails until those posts are corrected.

Choose one path before merge:

  • Correct the 18 posts in this PR or in a preceding PR.
  • Keep the step non-blocking for now, for example with continue-on-error: true, and remove that once the content is clean.

I can generate the report of failing posts and the required front matter corrections, or open a tracking issue. Tell me which you prefer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/validate-content.yaml around lines 52 - 53, Make the
Validate content step in the workflow non-blocking by adding continue-on-error:
true, allowing existing post validation failures without failing pull requests.
Keep the python3 hack/mcp/server.py --check command unchanged so the validation
report still runs.

Comment thread hack/mcp/core.py
Comment on lines +175 to +183
copied = []
for src in images:
source = Path(src).expanduser()
if not source.exists():
raise PublishError(f"image not found: {source}")
dest = target.parent / source.name
shutil.copy2(source, dest)
created.append(dest)
copied.append(source.name)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Reject duplicate image basenames.

dest is derived from source.name. Two source paths with the same basename, for example /a/card.png and /b/card.png, copy over each other, and copied reports the name twice. A source named index.md overwrites the post that was just written.

Fail early on both cases.

♻️ Proposed guard
         copied = []
         for src in images:
             source = Path(src).expanduser()
             if not source.exists():
                 raise PublishError(f"image not found: {source}")
+            if source.name == "index.md" or source.name in copied:
+                raise PublishError(f"image name conflicts: {source.name}")
             dest = target.parent / source.name
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
copied = []
for src in images:
source = Path(src).expanduser()
if not source.exists():
raise PublishError(f"image not found: {source}")
dest = target.parent / source.name
shutil.copy2(source, dest)
created.append(dest)
copied.append(source.name)
copied = []
for src in images:
source = Path(src).expanduser()
if not source.exists():
raise PublishError(f"image not found: {source}")
if source.name == "index.md" or source.name in copied:
raise PublishError(f"image name conflicts: {source.name}")
dest = target.parent / source.name
shutil.copy2(source, dest)
created.append(dest)
copied.append(source.name)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/mcp/core.py` around lines 175 - 183, Update the image-copy loop to
reject duplicate source basenames before copying: detect when a name already
exists in the current image set and raise PublishError, and also reject any
basename matching the generated post filename (such as index.md) before writing.
Preserve the existing copying and copied tracking for valid, unique image names.

Comment thread hack/mcp/core.py
Comment on lines +198 to +207
if commit:
result.branch, result.commit = _commit(
root, target, target_dir, branch or f"blog/{slug}", title
)

return result

except Exception:
_rollback(created)
raise

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Roll back the git state as well, not only the files.

The module docstring states that a failed publish leaves the repository exactly as it was. _commit can create a branch and stage paths before it fails, for example when git commit is rejected because user.email is not configured. The except block then removes the files, but the new branch stays checked out and the paths stay in the index. The user must clean up by hand, which the docstring and hack/mcp/README.md promise is unnecessary.

Track what _commit changed and undo it on failure.

🐛 Proposed direction
         if commit:
-            result.branch, result.commit = _commit(
-                root, target, target_dir, branch or f"blog/{slug}", title
-            )
+            original = _git(root, "rev-parse", "--abbrev-ref", "HEAD")
+            created_branch: str | None = None
+            try:
+                result.branch, result.commit = _commit(
+                    root, target, target_dir, branch or f"blog/{slug}", title
+                )
+            except Exception:
+                _git_cleanup(root, original, created_branch, target_dir or target)
+                raise

_commit must report the branch it created so the cleanup helper can unstage the paths, return to original, and delete that branch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/mcp/core.py` around lines 198 - 207, Update the publish flow around
`_commit` and the exception cleanup to track the created branch and original git
state, then pass that information to the rollback helper. Ensure failure cleanup
unstages paths, checks out the original branch, and deletes any branch created
by `_commit`, while preserving the existing file rollback behavior and
successful commit flow.

Comment thread hack/mcp/server.py
Comment on lines +198 to +202
if rel:
path = (root / rel).resolve()
if not path.exists():
return f"{rel}: no such file"
reports = {rel: validate.validate_post(path, site)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Constrain path to the repository root.

rel arrives from the MCP client. (root / rel).resolve() accepts ../ segments and absolute paths, so validate reads any file the process can read. Front matter values from that file are then echoed in the returned error messages.

Reject targets outside root.

🔒️ Proposed fix
     if rel:
-        path = (root / rel).resolve()
+        path = (root / rel).resolve()
+        try:
+            path.relative_to(root.resolve())
+        except ValueError:
+            return f"{rel}: path is outside the repository"
         if not path.exists():
             return f"{rel}: no such file"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if rel:
path = (root / rel).resolve()
if not path.exists():
return f"{rel}: no such file"
reports = {rel: validate.validate_post(path, site)}
if rel:
path = (root / rel).resolve()
try:
path.relative_to(root.resolve())
except ValueError:
return f"{rel}: path is outside the repository"
if not path.exists():
return f"{rel}: no such file"
reports = {rel: validate.validate_post(path, site)}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/mcp/server.py` around lines 198 - 202, Constrain the resolved path in
the rel-handling branch before calling validate.validate_post: after resolving
(root / rel), verify it remains within root and reject outside targets with the
existing no-such-file response. Preserve validation for paths inside the
repository, including nested paths, and ensure absolute paths and ../ traversal
cannot escape root.

Comment thread hack/mcp/server.py
Comment on lines +328 to +331
if args.check:
output = tool_validate(root, {"path": args.path} if args.path else {})
print(output)
return 1 if "FAIL" in output else 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not derive the exit code from the substring FAIL.

Two problems follow from this check:

  • tool_validate returns "<path>: no such file" for a missing --path. That output contains no FAIL, so --check --path <typo> exits 0 and the CI job passes without validating anything.
  • Any path or message that contains the text FAIL flips a warning-only run to a failure.

Compute the status from the reports instead of from the rendered text.

🐛 Proposed direction
     if args.check:
-        output = tool_validate(root, {"path": args.path} if args.path else {})
-        print(output)
-        return 1 if "FAIL" in output else 0
+        if args.path and not (root / args.path).exists():
+            print(f"{args.path}: no such file")
+            return 2
+        site = validate.Site(root)
+        if args.path:
+            reports = {args.path: validate.validate_post(root / args.path, site)}
+        else:
+            reports = validate.validate_tree(site)
+        print(tool_validate(root, {"path": args.path} if args.path else {}))
+        return 1 if any(r.errors for r in reports.values()) else 0

A cleaner variant is to let tool_validate return the reports next to the text, so the reports are built once.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if args.check:
output = tool_validate(root, {"path": args.path} if args.path else {})
print(output)
return 1 if "FAIL" in output else 0
if args.check:
if args.path and not (root / args.path).exists():
print(f"{args.path}: no such file")
return 2
site = validate.Site(root)
if args.path:
reports = {args.path: validate.validate_post(root / args.path, site)}
else:
reports = validate.validate_tree(site)
print(tool_validate(root, {"path": args.path} if args.path else {}))
return 1 if any(r.errors for r in reports.values()) else 0
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/mcp/server.py` around lines 328 - 331, Update the args.check branch and
tool_validate contract to return or expose structured validation reports
alongside the rendered output, then derive the exit status from those reports
rather than searching output for "FAIL". Ensure missing paths produce a nonzero
status and warning-only results remain successful, while preserving the existing
printed text.

Comment thread hack/mcp/validate.py
Comment on lines +236 to +237
if rel.name == "index.html":
served.add(str(rel.parent).strip("."))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Replace strip(".") with an explicit root check.

str(rel.parent).strip(".") strips dot characters from both ends of the whole path string. For public/index.html the intent is to add "", and that works. For a page under a dot-directory, such as public/.well-known/x/index.html, the served path becomes well-known/x. A link to /.well-known/x/ then fails resolution and the strict CI job reports an error.

🐛 Proposed fix
                 if rel.name == "index.html":
-                    served.add(str(rel.parent).strip("."))
+                    parent = rel.parent
+                    served.add("" if parent == Path(".") else str(parent))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if rel.name == "index.html":
served.add(str(rel.parent).strip("."))
if rel.name == "index.html":
parent = rel.parent
served.add("" if parent == Path(".") else str(parent))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/mcp/validate.py` around lines 236 - 237, Update the index-page handling
around the served.add call to replace str(rel.parent).strip(".") with an
explicit root-directory check: add an empty served path only when rel.parent is
the intended root, otherwise add the parent path without removing leading dots
from dot-directories such as .well-known. Preserve dot-directory names exactly
for link resolution.

yaml.safe_dump folds a long title across two lines. The fold survives into
the templates, so og:title and the JSON-LD headline both came out with the
break — and quoted twice on top of that.

Front matter is now written directly: double-quoted scalars on one line,
dates left unquoted so Hugo reads them as dates, and indented lists, which
also matches how the hand-written posts look.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <6355522@gmail.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
hack/mcp/frontmatter.py (1)

112-112: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the YAML document marker with an explicit suffix check.

rstrip("...") removes any trailing . characters. It does not remove the literal ... suffix. Ruff reports B005 on this line. Use an explicit suffix check.

Proposed fix
-    return yaml.safe_dump(value, default_flow_style=True).strip().rstrip("...").strip()
+    rendered = yaml.safe_dump(value, default_flow_style=True).strip()
+    if rendered.endswith("..."):
+        rendered = rendered[:-3].rstrip()
+    return rendered
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/mcp/frontmatter.py` at line 112, Update the YAML serialization return
expression in the frontmatter formatting function to remove the document marker
only when the dumped value explicitly ends with the literal "..." suffix,
replacing rstrip("...") with an explicit suffix check while preserving the
existing trimming behavior.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@hack/mcp/frontmatter.py`:
- Around line 113-115: Update the string-scalar handling in _scalar to use a
YAML-compatible encoder that escapes control characters such as newline,
carriage return, and tab, while preserving correct handling of backslashes and
quotes. Ensure the emitted scalar remains valid for yaml.safe_load and
round-trips the original string value.

---

Nitpick comments:
In `@hack/mcp/frontmatter.py`:
- Line 112: Update the YAML serialization return expression in the frontmatter
formatting function to remove the document marker only when the dumped value
explicitly ends with the literal "..." suffix, replacing rstrip("...") with an
explicit suffix check while preserving the existing trimming behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c6152fa3-2630-448f-bbf0-8ea39f31885f

📥 Commits

Reviewing files that changed from the base of the PR and between c589f09 and 226adc2.

📒 Files selected for processing (2)
  • hack/mcp/frontmatter.py
  • hack/mcp/test_mcp.py

Comment thread hack/mcp/frontmatter.py
Comment on lines +113 to +115
text = str(value)
escaped = text.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files matching frontmatter.py:\n'
fd -a 'frontmatter\.py$' . || true

if [ -f hack/mcp/frontmatter.py ]; then
  printf '\nLine count:\n'
  wc -l hack/mcp/frontmatter.py
  printf '\nRelevant section:\n'
  sed -n '1,180p' hack/mcp/frontmatter.py | cat -n
  printf '\nSearch for _scalar and emit/load uses:\n'
  rg -n '_scalar|emit|load|frontmatter|read|dump|scalar' hack/mcp/frontmatter.py
fi

printf '\nCheck YAML/JSON dependency presence hints:\n'
fd -a 'pyproject.toml|requirements.*|setup.*|Pipfile|uv.lock|poetry.lock' . | sed -n '1,40p'
rg -n '"pyyaml|PyYAML|pyyaml|jsonschema|mcp' pyproject.toml requirements*.txt setup.* Pipfile uv.lock poetry.lock 2>/dev/null || true

Repository: cozystack/website

Length of output: 7450


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import json
import yaml

samples = [
    "line1\nline2",
    "tab\there",
    "carriage\rreturn",
    "escape\\\quote",
    "quote\"here",
    "both\"&\\\n",
]

for value in samples:
    escaped = value.replace("\\", "\\\\").replace('"', '\\"')
    emitted = f'"{escaped}"'
    loaded = yaml.safe_load(emitted)
    json_emitted = json.dumps(value, ensure_ascii=False)
    print(repr(value))
    print(emitted)
    print(repr(loaded))
    print(loaded == value)
    print(json_emitted)
    print()
PY

Repository: cozystack/website

Length of output: 272


🌐 Web query:

PyYAML safe_loader double quoted string control characters escape sequence documentation

💡 Result:

In PyYAML, the SafeLoader parses YAML according to standard YAML specifications [1]. Double-quoted scalars are the only scalar style that supports escape sequences, allowing you to include non-printable or special characters [1][2][3]. Escape Sequences Double-quoted strings in YAML support C-style escape sequences [2][4]. These sequences allow you to represent control characters and other non-printable characters [2][3]: - Hexadecimal Escapes: Use \x followed by 2 hex digits (e.g., \x0B for a vertical tab) [5][2][3]. - Unicode Escapes (16-bit): Use \u followed by 4 hex digits (e.g., \u0041 for 'A') [5][2][3]. - Unicode Escapes (32-bit): Use \U followed by 8 hex digits (e.g., \U00000041 for 'A') [5][2][3]. - Standard Predefined Escapes: Common sequences include \t (tab), \n (line feed), \r (carriage return), \" (double quote), and \\ (backslash) [5][2][3]. Usage Notes - Interpretation: Escape sequences are only interpreted within double-quoted scalars [2][3]. In other styles (plain, single-quoted, literal, or folded), backslashes do not have special meaning [2][3]. - Parsing Errors: If an invalid escape sequence is used—such as an incorrect number of hex digits or an unrecognized escape character—PyYAML will raise a ScannerError [6]. - Character Limitations: While double-quoted strings allow you to encode many characters, the YAML specification defines a set of non-printable characters that must be escaped [2][4]. Additionally, PyYAML's SafeLoader may raise a ReaderError if it encounters certain control characters in the input stream that are explicitly excluded by the YAML specification, regardless of whether they are escaped [7][8]. If you encounter issues with specific Unicode characters or emojis in older versions of PyYAML, upgrading to a newer version is often required to resolve scanner and reader bugs [7][8].

Citations:


Escape control characters in string scalars.

_scalar only escapes backslashes and double quotes, so embedded \n, \r, or \t characters are emitted literally. YAML double-quoted scalars must encode these as escape sequences, otherwise dump can create invalid/multiline front matter and load can read the value back unchanged.

Use a YAML/YAML-compatible encoder for string scalars, especially since the emitted output is parsed by yaml.safe_load.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/mcp/frontmatter.py` around lines 113 - 115, Update the string-scalar
handling in _scalar to use a YAML-compatible encoder that escapes control
characters such as newline, carriage return, and tab, while preserving correct
handling of backslashes and quotes. Ensure the emitted scalar remains valid for
yaml.safe_load and round-trips the original string value.

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-contained blog publishing/validation tooling, additive (+2075/-0, all new files), no new runtime deps, 34/34 tests pass and are non-degenerate. CI uses the safe pull_request trigger (fork code runs with a read-only token, no secrets — no privilege escalation). No blocking issues. Two minor points worth addressing before merge:

  1. hack/mcp/core.py — incomplete git rollback. In _commit, when publishing from main a git checkout -b <branch> happens first; if the commit then fails (e.g. a pre-commit hook), _rollback restores files on disk but leaves HEAD on the newly-created branch. This contradicts the PR's "a failed publish leaves nothing to clean up" claim. Suggest: remember the original branch and return to it (and delete the created branch) on the failure path, or create the branch only after a successful commit.

  2. hack/mcp/validate.py + core.py — taxonomy check is fail-open. If data/taxonomy.yaml is missing/empty the term sets are empty and the check is silently skipped (warning in validate, silent return in publish). Since the workflow's paths: includes that file, a PR that removes/renames it triggers the job but it degrades to warn-only and goes green with taxonomy effectively unvalidated. Suggest: in the --check path treat a missing taxonomy as an error, not a warning.

Minor hardening (optional, consistent with the rest of the repo otherwise): no permissions: block on the workflow (recommend contents: read); npm install vs npm ci; third-party actions pinned by tag rather than SHA.

Timur Tukaev (tym83) added a commit that referenced this pull request Aug 5, 2026
## Summary

Brings every blog post in line with the rules the publishing checks in
#640 enforce: **83 posts, zero errors**, verified against a full
production build.

This is the content-side companion to #640. With it merged, the
validation job there can stay strict from day one.

## Front matter

Nine posts had no `author` and no `description`. Both feed the meta
description and the JSON-LD `BlogPosting`, so an empty description
leaves search results and structured data blank.

The author of each was taken from the `**Author**:` line the post
already carried in its own body — **not** from git history. That
distinction matters here: all nine were committed by the same person,
while the posts themselves were written by two different authors. Going
by the committer would have credited five articles to the wrong person.

Descriptions are written from the content of each post.

## Links

36 dead targets repaired:

- The bulk pointed at `/docs/v1/`, a version directory that has never
existed. Retargeted to `v1.6`.
- Blog cross-links in the DIY series carried a day segment
(`/blog/2024/04/05/...`), which the permalink pattern does not produce.
- Two pages had moved: `virtualization/gpu-passthrough` is now
`virtualization/gpu`, and `applications/virtual-machine` is now
`virtualization/vm-instance`.
- A link to the Kubefarm article was written as internal, although it
lives on kubernetes.io — part 3 of the same series already links to it
correctly.
- `/community/` does not exist; that link now points at the community
meeting calendar directly.

Every replacement was verified to exist, either in the build or with a
request against the live site.

## Directory names

Five bundles disagreed with their own front matter about slug or date,
and two carried no date prefix at all.

The directories were renamed rather than the front matter edited, and
that direction is deliberate: permalinks derive from `slug` and `date`,
so renaming the directory leaves **every published URL untouched**.
Verified for all five after the change.

## Verification

`python3 hack/mcp/server.py --check` from #640, against a full build: 83
posts, no errors.
Timur Tukaev (tym83) added a commit that referenced this pull request Aug 5, 2026
## Summary

Two posts for the blog:

- **Cozystack 1.6** — the release announcement: Talos Linux for tenant
workers, tenant-controlled OIDC, the SecurityGroup API, hierarchical
quotas, in-place etcd-operator adoption, and the upgrade notes.
- **Blockstor** — the open-sourcing announcement for the
LINSTOR-compatible storage control plane written from scratch in Go.

Both were produced with the publishing tools from #640 rather than
assembled by hand, so front matter, the page bundles, the Open Graph
cards and the closing community sections are generated and were
validated before anything was written.

## Verification

Both posts pass the publishing checks with no errors and no warnings,
against a full production build. Open Graph cards are 1200×630 PNG, so
previews render in Telegram, Slack and LinkedIn.

Rendered pages:

-
`/blog/2026/08/cozystack-1-6-talos-workers-tenant-sso-security-groups-hierarchical-quotas/`
- `/blog/2026/08/blockstor-linstor-compatible-storage-for-kubernetes/`

## Note on the Blockstor text

The Russian version of this announcement was already published on
OpenNet in May. The English text here is not a translation of it: it
covers what happened since, including the sixteen releases, the in-place
LINSTOR migration tool, the green Cozystack end-to-end run, and an
explicit call for contributors.

## Unrelated finding, worth a separate issue

While checking the rendered output I noticed the JSON-LD `BlogPosting`
block puts the title and description in doubly-quoted form:

```
"headline":"\"Cozystack 1.5: Gateway API, Default Backups, ...\""
```

This is not introduced by these posts — it reproduces on the existing
1.5 and LINSTOR posts too. `layouts/partials/hooks/head-end.html`
applies `jsonify` to values Hugo already returns quoted, so search
engines and AI crawlers read a headline wrapped in literal quote
characters. Happy to fix it in a separate PR if you agree it should go.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants