Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

---

## [0.1.3] — 2026-05-26

### Added

#### `@ikko-dev/ghost-mcp`

- **`GHOST_URL_LOCK` environment variable** — when set, the OAuth login form
pre-fills the Ghost site URL field with the configured value and renders it as
`readonly`, so users only need to supply their Staff Access Token. The lock is
also enforced server-side on `POST /authorize`: the submitted `ghostUrl` is
silently replaced with the lock value, preventing bypass via crafted requests.

```bash
GHOST_URL_LOCK=https://my-blog.ghost.io \
OAUTH_SECRET=$(openssl rand -hex 32) \
npx ghost-mcp-server
```

- **`parseGhostUrlLock()`** — exported from `@ikko-dev/ghost-mcp`, reads
`GHOST_URL_LOCK` from the environment and returns it trimmed, or `undefined`
if unset or empty.

---

## [0.1.2] — 2026-05-26

### Added
Expand Down
34 changes: 24 additions & 10 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,19 @@ The server is stateless — credentials are AES-256-GCM-encrypted into the OAuth
authorization code, then base64url-encoded into the access token the MCP client
holds and sends as `Authorization: Bearer <token>` on each call.

#### Locking the Ghost URL

When deploying for a single known Ghost instance, set `GHOST_URL_LOCK` to
pre-fill the login form's Ghost site URL and make it read-only. Users only need
to enter their Staff Access Token. The lock is also enforced server-side —
crafted POST requests cannot bypass it.

```bash
GHOST_URL_LOCK=https://my-blog.ghost.io \
OAUTH_SECRET=$(openssl rand -hex 32) \
npx ghost-mcp-server
```

### HTTP — Public content mode

No authentication. Pre-configured at startup for a single Ghost site. Exposes
Expand Down Expand Up @@ -132,16 +145,17 @@ protected resource so MCP clients discover the correct OAuth flow. The public

## Environment variables

| Variable | Description | Required |
| ------------------- | ------------------------------------------------------- | ----------- |
| `GHOST_URL` | Ghost site URL (no trailing slash) | public/dual |
| `GHOST_CONTENT_KEY` | Content API key (26-char hex from a Custom Integration) | public/dual |
| `OAUTH_SECRET` | 64-char hex AES-256-GCM key for OAuth token encryption | HTTP admin |
| `PORT` | HTTP listen port (default `3000`) | — |
| `HOST` | HTTP bind address (default `0.0.0.0`) | — |
| `GHOST_READ_ONLY` | Set `true` to disable `ghost_write` | — |
| `GHOST_PUBLIC` | Set `true` to enable public content mode | — |
| `GHOST_DUAL` | Set `true` to enable dual mode | — |
| Variable | Description | Required |
| ------------------- | ------------------------------------------------------------------------------------ | ----------- |
| `GHOST_URL` | Ghost site URL (no trailing slash) | public/dual |
| `GHOST_CONTENT_KEY` | Content API key (26-char hex from a Custom Integration) | public/dual |
| `OAUTH_SECRET` | 64-char hex AES-256-GCM key for OAuth token encryption | HTTP admin |
| `PORT` | HTTP listen port (default `3000`) | — |
| `HOST` | HTTP bind address (default `0.0.0.0`) | — |
| `GHOST_READ_ONLY` | Set `true` to disable `ghost_write` | — |
| `GHOST_PUBLIC` | Set `true` to enable public content mode | — |
| `GHOST_DUAL` | Set `true` to enable dual mode | — |
| `GHOST_URL_LOCK` | Lock the OAuth login form to a specific Ghost URL; field is pre-filled and read-only | — |

`--public` and `--dual` are mutually exclusive. Using both exits with an error.

Expand Down
11 changes: 11 additions & 0 deletions packages/mcp/src/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,14 @@ export function parsePublicFlag(): boolean {
export function parseDualModeFlag(): boolean {
return process.argv.includes("--dual") || process.env.GHOST_DUAL === "true";
}

/**
* Returns the value of `GHOST_URL_LOCK` if set, otherwise `undefined`.
*
* When set, the OAuth login form pre-fills the Ghost site URL field with this
* value and marks it as read-only, and the `POST /authorize` handler ignores
* whatever the client submits for `ghostUrl` and always uses this value instead.
*/
export function parseGhostUrlLock(): string | undefined {
return process.env.GHOST_URL_LOCK?.trim() || undefined;
}
142 changes: 140 additions & 2 deletions packages/mcp/src/oauth.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest";
import { mockEvent } from "h3";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { createProtectedResourceHandler, createS256Challenge } from "./oauth.ts";
import {
authorizeGetHandler,
authorizePostHandler,
createProtectedResourceHandler,
createS256Challenge,
} from "./oauth.ts";

describe("createS256Challenge", () => {
it("produces a base64url-encoded SHA-256 digest", () => {
Expand Down Expand Up @@ -55,3 +61,135 @@ describe("createProtectedResourceHandler", () => {
expect(result).toHaveProperty("scopes_supported");
});
});

// ─── GHOST_URL_LOCK ──────────────────────────────────────────────────────────

/** Convenience: invoke a defineEventHandler-wrapped handler with an h3 event. */
function invokeHandler<T>(handler: unknown, event: unknown): Promise<T> {
return (handler as (e: unknown) => Promise<T>)(event);
}

/** Minimal PKCE query params for GET /authorize. */
const PKCE_QUERY = new URLSearchParams({
redirect_uri: "http://localhost:12345/callback",
state: "test-state",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256",
}).toString();

describe("GHOST_URL_LOCK — GET /authorize", () => {
beforeEach(() => {
vi.stubEnv("GHOST_URL_LOCK", "https://locked.ghost.io");
});

afterEach(() => {
vi.unstubAllEnvs();
});

it("pre-fills the Ghost site URL field with the locked value", async () => {
const event = mockEvent(`/authorize?${PKCE_QUERY}`);
const html = await invokeHandler<string>(authorizeGetHandler, event);
expect(html).toContain('value="https://locked.ghost.io"');
});

it("renders the URL input as readonly", async () => {
const event = mockEvent(`/authorize?${PKCE_QUERY}`);
const html = await invokeHandler<string>(authorizeGetHandler, event);
// The ghostUrl input must have the readonly attribute (not just the CSS rule mention)
expect(html).toMatch(/id="ghostUrl"[^>]* readonly/);
});

it("shows a lock notice mentioning the locked URL", async () => {
const event = mockEvent(`/authorize?${PKCE_QUERY}`);
const html = await invokeHandler<string>(authorizeGetHandler, event);
expect(html).toContain('class="notice-lock"');
expect(html).toContain("https://locked.ghost.io");
});
});

describe("GHOST_URL_LOCK — GET /authorize (lock not set)", () => {
beforeEach(() => {
// Ensure GHOST_URL_LOCK is not set for these tests regardless of global env state
vi.stubEnv("GHOST_URL_LOCK", "");
});

afterEach(() => {
vi.unstubAllEnvs();
});

it("does not show the lock notice when GHOST_URL_LOCK is not set", async () => {
const event = mockEvent(`/authorize?${PKCE_QUERY}`);
const html = await invokeHandler<string>(authorizeGetHandler, event);
expect(html).not.toContain('class="notice-lock"');
// The ghostUrl input must NOT have the readonly attribute (only the CSS rule mentions "readonly")
expect(html).not.toMatch(/id="ghostUrl"[^>]* readonly/);
});
});

describe("GHOST_URL_LOCK — POST /authorize (server-side enforcement)", () => {
beforeEach(() => {
vi.stubEnv("GHOST_URL_LOCK", "https://locked.ghost.io");
});

afterEach(() => {
vi.unstubAllEnvs();
});

it("succeeds using the locked URL even when ghostUrl is omitted from POST body", async () => {
const body = new URLSearchParams({
// ghostUrl intentionally omitted — lock must supply it
staffAccessToken: "abc123:def4567890abcdef",
redirectUri: "http://localhost:12345/callback",
state: "test-state",
codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
codeChallengeMethod: "S256",
}).toString();
const event = mockEvent("/authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body,
});
const html = await invokeHandler<string>(authorizePostHandler, event);
// Should reach the success page, not an error form
expect(html).toContain("Successfully Connected");
});

it("ignores a crafted ghostUrl in the POST body and uses the lock value instead", async () => {
const body = new URLSearchParams({
ghostUrl: "https://attacker.ghost.io", // should be silently replaced
staffAccessToken: "abc123:def4567890abcdef",
redirectUri: "http://localhost:12345/callback",
state: "test-state",
codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
codeChallengeMethod: "S256",
}).toString();
const event = mockEvent("/authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body,
});
const html = await invokeHandler<string>(authorizePostHandler, event);
// Successful response — the lock URL was used, not the attacker URL
expect(html).toContain("Successfully Connected");
// The attacker URL must not appear in the success page redirect
expect(html).not.toContain("attacker.ghost.io");
});

it("shows the lock notice when re-rendering the form on error", async () => {
const body = new URLSearchParams({
// No staffAccessToken → validation error → form re-rendered
redirectUri: "http://localhost:12345/callback",
state: "test-state",
codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
codeChallengeMethod: "S256",
}).toString();
const event = mockEvent("/authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body,
});
const html = await invokeHandler<string>(authorizePostHandler, event);
expect(html).toContain('class="notice-lock"');
expect(html).toContain("https://locked.ghost.io");
});
});
28 changes: 22 additions & 6 deletions packages/mcp/src/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { createHash } from "node:crypto";

import { createAuthToken, debugAuth, summarizeSecret } from "./auth.ts";
import { createAuthCode, decodeAuthCode } from "./crypto.ts";
import { parseGhostUrlLock } from "./flags.ts";

function baseUrl(event: H3Event): string {
const host = event.req.headers.get("host") ?? "localhost:3000";
Expand Down Expand Up @@ -145,13 +146,13 @@ export const authorizeGetHandler = defineEventHandler((event: H3Event) => {
state,
codeChallenge,
codeChallengeMethod: codeChallengeMethod || "S256",
ghostUrlLock: parseGhostUrlLock(),
});
});

export const authorizePostHandler = defineEventHandler(async (event: H3Event) => {
const body = ((await readBody(event)) as Record<string, string>) ?? {};
const {
ghostUrl,
staffAccessToken,
contentApiKey,
redirectUri,
Expand All @@ -160,6 +161,11 @@ export const authorizePostHandler = defineEventHandler(async (event: H3Event) =>
codeChallengeMethod,
} = body;

// Server-side enforcement: if GHOST_URL_LOCK is set, always use its value
// regardless of what the client submitted, preventing bypass via crafted requests.
const ghostUrlLock = parseGhostUrlLock();
const ghostUrl = ghostUrlLock ?? body.ghostUrl;

event.res.headers.set("Content-Type", "text/html; charset=utf-8");

if (!redirectUri) {
Expand All @@ -186,6 +192,7 @@ export const authorizePostHandler = defineEventHandler(async (event: H3Event) =>
codeChallenge,
codeChallengeMethod,
ghostUrl,
ghostUrlLock,
error: "Ghost site URL and at least one credential are required",
});
}
Expand All @@ -198,6 +205,7 @@ export const authorizePostHandler = defineEventHandler(async (event: H3Event) =>
codeChallenge,
codeChallengeMethod,
ghostUrl,
ghostUrlLock,
error: "Ghost site URL is not a valid URL",
});
}
Expand Down Expand Up @@ -322,9 +330,14 @@ function renderLoginForm(params: {
codeChallenge?: string;
codeChallengeMethod?: string;
ghostUrl?: string;
/** When set, the Ghost site URL field is pre-filled and locked to this value. */
ghostUrlLock?: string;
error?: string;
}): string {
const { redirectUri, state, codeChallenge, codeChallengeMethod, ghostUrl, error } = params;
const { redirectUri, state, codeChallenge, codeChallengeMethod, ghostUrlLock, error } = params;
// When locked, always use the lock value for the URL field.
const ghostUrl = ghostUrlLock ?? params.ghostUrl;
const isLocked = Boolean(ghostUrlLock);
return `<!DOCTYPE html>
<html lang="en">
<head>
Expand All @@ -346,10 +359,12 @@ function renderLoginForm(params: {
.subtitle { text-align: center; color: #666; font-size: 14px; margin-bottom: 28px; }
.error { background: #fee2e2; border: 1px solid #fecaca; color: #dc2626; padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 14px; }
.notice { background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534; padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 13px; line-height: 1.4; }
.notice-lock { background: #eff6ff; border: 1px solid #bfdbfe; color: #1e40af; padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 13px; line-height: 1.4; }
.form-group { margin-bottom: 18px; }
label { display: block; font-size: 14px; font-weight: 500; color: #374151; margin-bottom: 6px; }
input { width: 100%; padding: 11px 14px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 15px; }
input:focus { outline: none; border-color: #ff1a75; box-shadow: 0 0 0 3px rgba(255,26,117,0.18); }
input[readonly] { background: #f3f4f6; color: #6b7280; cursor: default; }
.help-text { font-size: 12px; color: #6b7280; margin-top: 4px; }
.help-text a { color: #ff1a75; text-decoration: none; }
.help-text a:hover { text-decoration: underline; }
Expand All @@ -363,8 +378,9 @@ function renderLoginForm(params: {
<div class="container">
<div class="logo">Ghost<span>·</span></div>
<h1>Connect to your Ghost site</h1>
<p class="subtitle">Enter your site URL and a Staff Access Token to let Claude work with your blog.</p>
<p class="subtitle">${isLocked ? "Enter your Staff Access Token to let Claude work with this Ghost site." : "Enter your site URL and a Staff Access Token to let Claude work with your blog."}</p>
${error ? `<div class="error">${escapeHtml(error)}</div>` : ""}
${isLocked ? `<div class="notice-lock">&#128274; This server is restricted to <strong>${escapeHtml(ghostUrlLock!)}</strong>. The Ghost site URL cannot be changed.</div>` : ""}
<div class="notice">
<strong>Your credentials are not stored on this server.</strong> They are encrypted into the OAuth code and held by your MCP client (Claude Desktop, claude.ai, etc.).
</div>
Expand All @@ -375,9 +391,9 @@ function renderLoginForm(params: {
<input type="hidden" name="codeChallengeMethod" value="${escapeHtml(codeChallengeMethod ?? "S256")}">

<div class="form-group">
<label for="ghostUrl">Ghost site URL *</label>
<input type="url" id="ghostUrl" name="ghostUrl" required value="${escapeHtml(ghostUrl ?? "")}" placeholder="https://my-blog.ghost.io" autocomplete="url">
<p class="help-text">The public URL of your Ghost site. No trailing slash.</p>
<label for="ghostUrl">Ghost site URL${isLocked ? "" : " *"}</label>
<input type="url" id="ghostUrl" name="ghostUrl"${isLocked ? " readonly" : " required"} value="${escapeHtml(ghostUrl ?? "")}" placeholder="https://my-blog.ghost.io" autocomplete="url">
<p class="help-text">${isLocked ? "Pre-configured by the server administrator." : "The public URL of your Ghost site. No trailing slash."}</p>
</div>

<div class="form-group">
Expand Down
13 changes: 12 additions & 1 deletion packages/mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,19 @@
* GHOST_DUAL - Dual mode: /mcp (public) + /admin/mcp (OAuth admin)
* GHOST_URL - Ghost site URL (required for --public and --dual)
* GHOST_CONTENT_KEY- Content API key (required for --public and --dual)
* GHOST_URL_LOCK - Lock the OAuth login form to a specific Ghost URL
* OAUTH_SECRET - AES-256-GCM key for OAuth tokens. Required in production.
*/

import { toNodeHandler } from "h3/node";
import { createServer, type Server } from "node:http";

import { parseDualModeFlag, parsePublicFlag, parseReadOnlyFlag } from "./flags.ts";
import {
parseDualModeFlag,
parseGhostUrlLock,
parsePublicFlag,
parseReadOnlyFlag,
} from "./flags.ts";
import { createHealthApp, createMcpRequestHandler, type HttpServerOptions } from "./http.ts";
import { SessionManager } from "./sessions.ts";
import { VERSION } from "./version.ts";
Expand Down Expand Up @@ -191,6 +197,11 @@ export function startHttpServer(
console.log("OAuth 2.1 (login form):");
console.log(` GET ${base}/authorize`);
console.log(` POST ${base}/token`);
const urlLock = parseGhostUrlLock();
if (urlLock) {
console.log("");
console.log(`Ghost URL lock: ${urlLock} (login form is restricted to this site)`);
}
if (readOnly) {
console.log("");
console.log("Mode: READ-ONLY (write operations disabled)");
Expand Down
Loading