Skip to content

Commit cd0071e

Browse files
Add submit_indexnow and convert_to_webp tools to blog-mcp
- submit_indexnow: notify IndexNow (Bing and other participating engines) that given URLs are new/changed, for faster crawling than waiting on discovery or Google's low-quota manual indexing button. Host/key are hardcoded to this site's own published IndexNow key file; no credentials needed. Verified end-to-end (HTTP 200) against a real published post URL. - convert_to_webp: standalone PNG/JPEG -> optimized WebP conversion, reusing the existing sharp-based toWebpBuffer/resolveImageInput helpers already used by upload_image, for cases that just need a local optimized file with no GitHub repo/upload involved. Verified against a real 984KB PNG (97% size reduction, valid WebP output). Both smoke-tested by spawning the built server and driving the actual MCP stdio protocol (initialize/tools-list/tools-call), not just tsc.
1 parent 7209d9c commit cd0071e

4 files changed

Lines changed: 122 additions & 2 deletions

File tree

‎tools/blog-mcp/README.md‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
A local MCP (Model Context Protocol) server that gives an AI agent (Claude Code, or any
44
other MCP client) direct tools for running ScriptXeno's blog workflow: reading and writing
5-
posts, creating the per-post image-hosting repos the site uses, converting/uploading images,
6-
and generating new images with Gemini's image model ("nano banana").
5+
posts, creating the per-post image-hosting repos the site uses, converting/uploading/optimizing
6+
images, generating new images with Gemini's image model ("nano banana"), and notifying
7+
IndexNow once a post is live.
78

89
It runs as a local Node process over stdio — the MCP client (Claude Code) spawns and manages
910
it automatically per `.mcp.json` at the repo root. You never start it by hand.
@@ -142,6 +143,18 @@ Provide **either** `localPath` or `imageData` — not neither. Converts to PNG +
142143
the Claude Code conversation, the agent can pass its bytes straight through as `imageData`
143144
— no need to save it to disk or know a file path first.
144145

146+
#### `convert_to_webp`
147+
| Input | Type | Notes |
148+
|---|---|---|
149+
| `localPath` | string | optional — absolute path to a source PNG/JPEG/etc. Output defaults to the same path with a `.webp` extension. |
150+
| `imageData` | string | optional — base64 source bytes. Takes precedence if both are given. Output defaults into `tools/blog-mcp/generated/`. |
151+
| `outputPath` | string | optional — override the output path entirely |
152+
| `quality` | number | default `82` |
153+
154+
Standalone WebP conversion via `sharp` — no GitHub repo or upload involved, just a local file
155+
in, a local file out. Returns `{ outputPath, originalBytes, optimizedBytes, savedPercent }`.
156+
Use `upload_image` instead when the result also needs hosting on a post's image repo.
157+
145158
#### `generate_image`
146159
| Input | Type | Notes |
147160
|---|---|---|
@@ -166,6 +179,20 @@ other pending changes in the working tree are left alone. This pushes straight t
166179
which triggers `pages-deploy.yml` and goes live. There is no undo tool; revert with
167180
`git revert <sha>` if needed (the returned `commitSha` is exactly what you'd revert).
168181

182+
#### `submit_indexnow`
183+
| Input | Type | Notes |
184+
|---|---|---|
185+
| `urls` | string[] | required, 1–10,000 full URLs, all on `scriptxeno.github.io` |
186+
187+
Notifies IndexNow (Bing and other participating engines) that the given URLs are new or
188+
changed, so they get crawled faster than waiting on organic discovery — a workaround for the
189+
low daily quota on Google's own manual "Request indexing" button (Google itself doesn't
190+
participate in IndexNow, so this doesn't help Google specifically). Host and key are hardcoded
191+
in `src/tools/indexnow.ts` to this site's own key file,
192+
`https://scriptxeno.github.io/8224327bc30e4fd28859be57075c7439.txt` — no credentials needed.
193+
Call it with the real post URL(s) right after `publish_post` once the page is confirmed live.
194+
Returns `{ submitted, status, ok }`.
195+
169196
## Design notes
170197

171198
### Thumbnail house style
@@ -277,3 +304,4 @@ repo-creation was indistinguishable from failure and got reported as an error.)
277304
| `generate_image` returns HTTP 429 with `limit: 0` | Free-tier quota for image models is zero regardless of model — billing must be enabled on the Gemini key's Google Cloud project |
278305
| `generate_image` throws "Could not locate image data" | The Interactions API's response shape has changed — the error includes the raw JSON; update `findImageData()` in `src/tools/generate_image.ts` with the real field path |
279306
| `publish_post` fails with "nothing to commit" | The post file's content is identical to what's already committed |
307+
| `submit_indexnow` returns a 422 | The URL doesn't actually resolve on `scriptxeno.github.io` yet — GitHub Pages needs a few seconds after `publish_post` before a brand-new URL is live; confirm with `curl` first, same as before publishing anywhere else |

‎tools/blog-mcp/src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { registerImageTools } from "./tools/images.js";
88
import { registerGenerateImageTool } from "./tools/generate_image.js";
99
import { registerPublishTool } from "./tools/publish.js";
1010
import { registerThumbnailTool } from "./tools/thumbnail.js";
11+
import { registerIndexNowTool } from "./tools/indexnow.js";
1112

1213
dotenv.config({ path: path.join(repoRoot, "tools", "blog-mcp", ".env") });
1314

@@ -18,6 +19,7 @@ registerImageTools(server);
1819
registerGenerateImageTool(server);
1920
registerPublishTool(server);
2021
registerThumbnailTool(server);
22+
registerIndexNowTool(server);
2123

2224
const transport = new StdioServerTransport();
2325
await server.connect(transport);

‎tools/blog-mcp/src/tools/images.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import fs from "node:fs";
2+
import path from "node:path";
23
import { z } from "zod";
34
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
45
import { createRepo, enablePages, getRepo, putFileContents } from "../lib/github.js";
56
import { toPngBuffer, toTinyPlaceholderBuffer, toWebpBuffer } from "../lib/images.js";
67
import { STATIC_LQIP } from "../lib/frontmatter.js";
8+
import { generatedDir } from "../lib/paths.js";
79

810
function textResult(value: unknown) {
911
return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
@@ -119,4 +121,44 @@ export function registerImageTools(server: McpServer) {
119121
});
120122
}
121123
);
124+
125+
server.registerTool(
126+
"convert_to_webp",
127+
{
128+
title: "Convert image to WebP",
129+
description:
130+
"Convert a local PNG/JPEG (or other sharp-supported format) to an optimized WebP file. Provide `localPath` (output defaults to the same file with a .webp extension) or `imageData` (base64 bytes — output goes to the generated/ staging folder). Returns the output path plus original/optimized byte sizes.",
131+
inputSchema: {
132+
localPath: z.string().optional().describe("Absolute local path to the source image."),
133+
imageData: z.string().optional().describe("Base64-encoded source image bytes. Takes precedence over localPath if both are given."),
134+
outputPath: z.string().optional().describe("Absolute path for the .webp output. Defaults next to localPath, or into generated/ for imageData."),
135+
quality: z.number().int().min(1).max(100).default(82),
136+
},
137+
},
138+
async ({ localPath, imageData, outputPath, quality }) => {
139+
const resolved = resolveImageInput({ localPath, imageData });
140+
if (!Buffer.isBuffer(resolved)) return errorResult(resolved.error);
141+
const input = resolved;
142+
143+
let outPath = outputPath;
144+
if (!outPath) {
145+
if (localPath) {
146+
outPath = localPath.replace(/\.[^./\\]+$/, "") + ".webp";
147+
} else {
148+
fs.mkdirSync(generatedDir, { recursive: true });
149+
outPath = path.join(generatedDir, `${Date.now()}.webp`);
150+
}
151+
}
152+
153+
const webpBuffer = await toWebpBuffer(input, quality);
154+
fs.writeFileSync(outPath, webpBuffer);
155+
156+
return textResult({
157+
outputPath: outPath,
158+
originalBytes: input.length,
159+
optimizedBytes: webpBuffer.length,
160+
savedPercent: Math.round((1 - webpBuffer.length / input.length) * 100),
161+
});
162+
}
163+
);
122164
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { z } from "zod";
2+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3+
4+
const HOST = "scriptxeno.github.io";
5+
const KEY = "8224327bc30e4fd28859be57075c7439";
6+
const KEY_LOCATION = `https://${HOST}/${KEY}.txt`;
7+
8+
function textResult(value: unknown) {
9+
return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
10+
}
11+
12+
function errorResult(message: string) {
13+
return { content: [{ type: "text" as const, text: message }], isError: true };
14+
}
15+
16+
export function registerIndexNowTool(server: McpServer) {
17+
server.registerTool(
18+
"submit_indexnow",
19+
{
20+
title: "Submit URLs to IndexNow",
21+
description:
22+
"Notify IndexNow (Bing, and other participating search engines) that one or more scriptxeno.github.io URLs are new or updated, so they get crawled faster than waiting on discovery. Pass full URLs (e.g. from publish_post's result).",
23+
inputSchema: {
24+
urls: z.array(z.string().url()).min(1).max(10000),
25+
},
26+
},
27+
async ({ urls }) => {
28+
const offHost = urls.filter((u) => new URL(u).host !== HOST);
29+
if (offHost.length > 0) {
30+
return errorResult(`All URLs must be on ${HOST}. Off-host: ${offHost.join(", ")}`);
31+
}
32+
33+
try {
34+
const res = await fetch("https://api.indexnow.org/indexnow", {
35+
method: "POST",
36+
headers: { "Content-Type": "application/json; charset=utf-8" },
37+
body: JSON.stringify({ host: HOST, key: KEY, keyLocation: KEY_LOCATION, urlList: urls }),
38+
});
39+
// IndexNow returns 200/202 on success with an empty body -- text() rather than
40+
// json() since a non-2xx error body isn't guaranteed to be valid JSON either.
41+
const body = await res.text();
42+
return textResult({ submitted: urls.length, status: res.status, ok: res.ok, body: body || undefined });
43+
} catch (err) {
44+
return errorResult(err instanceof Error ? err.message : String(err));
45+
}
46+
}
47+
);
48+
}

0 commit comments

Comments
 (0)