diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..a6ee19f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,77 @@ +name: Bug report +description: Report something that is not working as expected +title: "[Bug]: " +labels: + - bug +body: + - type: markdown + attributes: + value: | + Thanks for helping improve Substack MCP. Never paste your session token or a complete Cookie header into an issue. + + - type: input + id: version + attributes: + label: Substack MCP version + description: Run `npm view substack-mcp version` or provide the Docker image tag you use. + placeholder: 1.3.0 + validations: + required: true + + - type: dropdown + id: installation + attributes: + label: Installation method + options: + - NPX + - Docker + - From source + - Other + validations: + required: true + + - type: input + id: client + attributes: + label: MCP client + description: Include the client version when possible. + placeholder: Claude Desktop 1.x, Cursor 1.x, VS Code 1.x, etc. + + - type: input + id: environment + attributes: + label: Operating system and runtime + description: Include your OS and, for NPX or source installs, the output of `node --version`. + placeholder: macOS 15.6, Node.js v22.18.0 + + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + description: Tell us which tool was called, with which non-secret inputs, and what happened. + placeholder: | + 1. Configure the server using NPX + 2. Ask the client to list recent drafts + 3. The call fails with ... + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + + - type: textarea + id: logs + attributes: + label: Sanitized logs + description: Paste the relevant JSON log lines. Remove tokens, cookies, email addresses, post content, and other private data first. + render: shell + + - type: checkboxes + id: secrets + attributes: + label: Security check + options: + - label: I removed session tokens, Cookie headers, email addresses, and other private data from this report. + required: true diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index b74eb6a..bf57773 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -6,9 +6,11 @@ on: tags: [ 'v*' ] jobs: - build-and-publish: - name: Build and Publish + publish-npm: + name: Publish to npm runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout code @@ -18,8 +20,75 @@ jobs: uses: actions/setup-node@v7 with: node-version-file: '.nvmrc' - + - name: Run publish script run: ./ops/publish-npm.sh env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + publish-registry: + name: Publish MCP Registry metadata + needs: publish-npm + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Prepare MCP Registry metadata + run: | + PACKAGE_VERSION=$(jq -r '.version' package.json) + jq --arg version "$PACKAGE_VERSION" \ + '.version = $version + | (.packages[] | select(.registryType == "npm").version) = $version + | (.packages[] | select(.registryType == "oci").identifier) = "docker.io/marcomoauro/substack-mcp:v\($version)"' \ + server.json > "$RUNNER_TEMP/server.json" + + - name: Wait for the versioned Docker image + env: + MCP_SERVER_NAME: io.github.marcomoauro/substack-mcp + run: | + PACKAGE_VERSION=$(jq -r '.version' package.json) + IMAGE="docker.io/marcomoauro/substack-mcp:v${PACKAGE_VERSION}" + + for attempt in $(seq 1 30); do + if docker pull "$IMAGE" >/dev/null 2>&1; then + LABEL=$(docker inspect --format '{{ index .Config.Labels "io.modelcontextprotocol.server.name" }}' "$IMAGE") + if [ "$LABEL" = "$MCP_SERVER_NAME" ]; then + echo "$IMAGE is available with the required ownership label" + exit 0 + fi + fi + + if [ "$attempt" -lt 30 ]; then + echo "Waiting for $IMAGE (attempt $attempt/30)" + sleep 10 + fi + done + + echo "Error: $IMAGE was not published with ownership label $MCP_SERVER_NAME" + exit 1 + + - name: Install MCP Registry publisher + env: + MCP_PUBLISHER_VERSION: 1.8.1 + run: | + ARCHIVE=mcp-publisher_linux_amd64.tar.gz + RELEASE_URL="https://github.com/modelcontextprotocol/registry/releases/download/v${MCP_PUBLISHER_VERSION}" + + cd "$RUNNER_TEMP" + curl -fsSLO "$RELEASE_URL/$ARCHIVE" + curl -fsSL "$RELEASE_URL/registry_${MCP_PUBLISHER_VERSION}_checksums.txt" -o checksums.txt + awk -v file="$ARCHIVE" '$2 == file' checksums.txt | sha256sum --check - + tar xzf "$ARCHIVE" mcp-publisher + + - name: Authenticate to the MCP Registry + run: | + "$RUNNER_TEMP/mcp-publisher" login github-oidc + + - name: Publish to the MCP Registry + run: | + "$RUNNER_TEMP/mcp-publisher" publish "$RUNNER_TEMP/server.json" diff --git a/CLAUDE.md b/CLAUDE.md index d08ed86..6aeb0dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,22 @@ noisier, tally `# pass`, failures `not ok`. Grep for both, or a perfectly green version comes back empty and reads as a broken command: `grep -E '^(#|ℹ) (tests|pass|fail)'` for the tally, `grep -E '^(not ok|✖)'` for what broke. +## Distribution and releases + +Release tags publish three artifacts: the npm package through `.github/workflows/npm-publish.yml`, +the multi-architecture Docker image through `.github/workflows/docker-build-push.yml`, and metadata +for both installation methods to the official MCP Registry. The registry job is separate from the +npm job so a registry failure can be rerun without attempting to publish the same immutable npm +version twice. It waits for the independently built versioned Docker image before publishing the +combined metadata. + +`package.json`'s `mcpName` and the Dockerfile label +`io.modelcontextprotocol.server.name` are ownership proofs. Both must equal +`io.github.marcomoauro/substack-mcp`; removing either makes the corresponding package fail registry +publication. `server.json` is the source metadata, but its checked-in version is not a second release +version to bump by hand: the registry job derives the top-level version, the npm package version and +the `v` Docker identifier from `package.json` before publishing. + ## Layout - `src/index.js` — entrypoint only: env check, `createServer()`, stdio transport. Keep it thin. diff --git a/Dockerfile b/Dockerfile index 7787213..4dcb96f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,11 @@ FROM node:24.19.0-alpine +LABEL io.modelcontextprotocol.server.name="io.github.marcomoauro/substack-mcp" + COPY ./ /opt WORKDIR /opt RUN npm ci --omit=dev && \ npm cache clean --force; -CMD ["node", "src/index.js"] \ No newline at end of file +CMD ["node", "src/index.js"] diff --git a/README.md b/README.md index bc7e26c..2fce684 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,98 @@ A Model Context Protocol (MCP) Server for [Substack](https://substack.com) enabl [![Docker Pulls](https://img.shields.io/docker/pulls/marcomoauro/substack-mcp.svg)](https://hub.docker.com/r/marcomoauro/substack-mcp) [![npm downloads](https://img.shields.io/npm/dm/substack-mcp.svg)](https://www.npmjs.com/package/substack-mcp) +Create and publish posts, work with subscribers and analytics, browse your reader feeds, manage +tags and comments, and upload images — 27 tools exposed through one MCP server. + +> [!IMPORTANT] +> Substack does not provide a public API for these operations. This server uses your authenticated +> web session. Treat the session token exactly like a password: keep it local, never commit it, and +> never include it or a complete Cookie header in a bug report. + +## Quick start + +The fastest installation uses [Node.js 22 or newer](https://nodejs.org/) and `npx`. + +### 1. Collect your Substack credentials + +Sign in to Substack in your browser and open your publication dashboard. You need three values: + +- **Publication URL** — the full base URL of your publication, for example + `https://your-publication.substack.com`. +- **Session token** — open your browser's developer tools, select **Network**, filter to + **Fetch/XHR**, and reload the dashboard. Open a successful authenticated request to your + publication. Under **Request Headers**, find the `Cookie` header and locate a session cookie named + `substack.sid` or `connect.sid`. Copy its value without the cookie name or the rest of the header. + If both names appear with different values, test them separately and locally with the read-only + verification in step 3; never paste either value into an issue. +- **User ID** — in the same Network panel, search for a successful `publication_user` request. In + its JSON response, copy the numeric `id` inside the `user` object. + +If the browser UI differs, the illustrated [credential guide](https://implementing.substack.com/p/mcp-server-for-substack) +shows the same requests. If authentication later stops working, sign in again and repeat these steps +to obtain the current token. + +### 2. Add the server to your MCP client + +For clients that accept MCP JSON configuration, add: + +```json +{ + "mcpServers": { + "substack": { + "command": "npx", + "args": ["-y", "substack-mcp@latest"], + "env": { + "SUBSTACK_PUBLICATION_URL": "https://your-publication.substack.com", + "SUBSTACK_SESSION_TOKEN": "your-session-token", + "SUBSTACK_USER_ID": "your-user-id" + } + } + } +} +``` + +Replace the three example values, save the configuration, and restart your MCP client. Consult your +client's documentation if it uses a different configuration format. + +### 3. Verify the connection + +Ask your client: + +> List my five most recent Substack drafts. + +The client should call `list_posts` with `status: "drafts"`. If it fails, check the client's MCP +logs and the [logging section](#-logs) below before opening an issue. + +
+Use Docker instead of Node.js + +With Docker installed, use this server configuration: + +```json +{ + "mcpServers": { + "substack": { + "command": "docker", + "args": [ + "run", "-i", "--rm", + "-e", "SUBSTACK_PUBLICATION_URL", + "-e", "SUBSTACK_SESSION_TOKEN", + "-e", "SUBSTACK_USER_ID", + "marcomoauro/substack-mcp:latest" + ], + "env": { + "SUBSTACK_PUBLICATION_URL": "https://your-publication.substack.com", + "SUBSTACK_SESSION_TOKEN": "your-session-token", + "SUBSTACK_USER_ID": "your-user-id" + } + } + } +} +``` + +
+ ## 🛠 Available Tools
@@ -514,86 +606,17 @@ accept, rather than dropping it silently. > broken upstream rather than mis-called.
-### 📋 Requirements - -- Substack tokens, follow my [guide](https://implementing.substack.com/p/mcp-server-for-substack) to obtain them: - - Session token - - Publication URL - - User ID -- An LLM client that supports Model Context Protocol (MCP), such as Claude Desktop, Cursors, or GitHub Copilot -- Docker - -### 🔌 Installation - -#### Introduction -The installation process is standardized across all MCP clients. It involves manually adding a configuration object to your client's MCP configuration JSON file. -> If you're unsure how to configure an MCP with your client, please refer to your MCP client's official documentation. - -#### 🧩 Engines - -Option 1: Using NPX - -This option requires Node.js 22 or newer to be installed on your system. - -1. Add the following to your MCP configuration file: -```json -{ - "mcpServers": { - "substack-api": { - "command": "npx", - "args": ["-y", "substack-mcp@latest"], - "env": { - "SUBSTACK_PUBLICATION_URL": "", - "SUBSTACK_SESSION_TOKEN": "", - "SUBSTACK_USER_ID": "" - } - } - } -} -``` - -2. Replace ``, `` and `` with your credentials. - -Option 2: Using Docker - -This option requires Docker to be installed on your system. - -1. Add the following to your MCP configuration file: -```json -{ - "mcpServers": { - "substack-api": { - "command": "docker", - "args": [ - "run", "-i", "--rm", - "-e", "SUBSTACK_PUBLICATION_URL", - "-e", "SUBSTACK_SESSION_TOKEN", - "-e", "SUBSTACK_USER_ID", - "marcomoauro/substack-mcp:latest" - ], - "env": { - "SUBSTACK_PUBLICATION_URL": "", - "SUBSTACK_SESSION_TOKEN": "", - "SUBSTACK_USER_ID": "" - } - } - } -} -``` - -2. Replace ``, `` and `` with your credentials. - -### 🏗 Running from Source +## 🏗 Running from Source Use this if you want to hack on the server itself. There is no build step — the sources are plain ESM and run as they are. -#### Node.js +### Node.js ```bash git clone https://github.com/marcomoauro/substack-mcp.git cd substack-mcp -npm install +npm ci ``` Then add to your MCP config: @@ -614,7 +637,7 @@ Then add to your MCP config: } ``` -#### Docker +### Docker ```bash git clone https://github.com/marcomoauro/substack-mcp.git @@ -646,7 +669,7 @@ Then add to your MCP config: } ``` -### 🪵 Logs +## 🪵 Logs The server logs what it does as one JSON object per line, on **stderr** — MCP clients collect it into their own log file (on macOS, Claude Desktop writes it to @@ -672,7 +695,7 @@ is written: Your session token is never written to the log, at any level. -## 💻 Popular Clients that supports MCPs +## 💻 Popular MCP clients > For a complete list of MCP clients and their feature support, visit the [official MCP clients page](https://modelcontextprotocol.io/clients). diff --git a/package.json b/package.json index d20a042..1749a28 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "substack-mcp", "version": "1.3.0", + "mcpName": "io.github.marcomoauro/substack-mcp", "description": "A Model Context Protocol (MCP) Server for Substack enabling LLM clients to interact with Substack's API for automations like creating posts, managing drafts, and more.", "type": "module", "main": "src/index.js", @@ -26,8 +27,12 @@ }, "keywords": [ "mcp", + "mcp-server", "mcpserver", + "model-context-protocol", "substack", + "newsletter", + "claude", "ai", "post", "draft" diff --git a/server.json b/server.json new file mode 100644 index 0000000..7d737ef --- /dev/null +++ b/server.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.marcomoauro/substack-mcp", + "title": "Substack MCP Server", + "description": "Manage Substack publishing, subscribers, analytics, reader feeds, comments, and images through MCP.", + "repository": { + "url": "https://github.com/marcomoauro/substack-mcp", + "source": "github" + }, + "websiteUrl": "https://implementing.substack.com/p/mcp-server-for-substack", + "version": "1.3.0", + "packages": [ + { + "registryType": "npm", + "registryBaseUrl": "https://registry.npmjs.org", + "identifier": "substack-mcp", + "version": "1.3.0", + "runtimeHint": "npx", + "transport": { + "type": "stdio" + }, + "environmentVariables": [ + { + "name": "SUBSTACK_PUBLICATION_URL", + "description": "Full URL of your Substack publication, including https://", + "placeholder": "https://your-publication.substack.com", + "isRequired": true, + "isSecret": false + }, + { + "name": "SUBSTACK_SESSION_TOKEN", + "description": "Value of the substack.sid or connect.sid cookie from your authenticated Substack session", + "isRequired": true, + "isSecret": true + }, + { + "name": "SUBSTACK_USER_ID", + "description": "Numeric user ID returned by Substack's publication_user request", + "placeholder": "123456789", + "isRequired": true, + "isSecret": false + } + ] + }, + { + "registryType": "oci", + "identifier": "docker.io/marcomoauro/substack-mcp:v1.3.0", + "transport": { + "type": "stdio" + }, + "environmentVariables": [ + { + "name": "SUBSTACK_PUBLICATION_URL", + "description": "Full URL of your Substack publication, including https://", + "placeholder": "https://your-publication.substack.com", + "isRequired": true, + "isSecret": false + }, + { + "name": "SUBSTACK_SESSION_TOKEN", + "description": "Value of the substack.sid or connect.sid cookie from your authenticated Substack session", + "isRequired": true, + "isSecret": true + }, + { + "name": "SUBSTACK_USER_ID", + "description": "Numeric user ID returned by Substack's publication_user request", + "placeholder": "123456789", + "isRequired": true, + "isSecret": false + } + ] + } + ] +}