Jelajahi Sumber

feat: Streamable HTTP transport for container/server deployments

- src/config: WIKIJS_MCP_TRANSPORT (stdio default | http), WIKIJS_MCP_HOST,
  WIKIJS_MCP_PORT (default 3001), WIKIJS_MCP_BEARER_TOKEN; startup
  validation: http requires WIKIJS_MCP_HOST (no default on purpose) and a
  non-loopback bind requires a bearer token — the server exits otherwise
- src/transport/http.ts: Streamable HTTP listener built on the SDK's
  StreamableHTTPServerTransport and node:http only (no new dependencies —
  express is a transitive SDK dep, not importable under pnpm):
  POST/GET/DELETE /mcp, stateful sessions (one McpServer per session),
  /health liveness endpoint, constant-time bearer auth
- src/index.ts: transport dispatch, --transport stdio|http CLI flag
  overriding WIKIJS_MCP_TRANSPORT (same host/token validation applies),
  transport-specific startup logging
- Dockerfile (node:24-alpine, pnpm, multi-stage build) + .dockerignore,
  docker-compose mcp service next to wiki/db: WIKIJS_MCP_HOST=0.0.0.0,
  WIKIJS_MCP_BEARER_TOKEN required (compose refuses to start without it),
  healthcheck on /health
- env.example + README + AGENTS.md: the four new env vars, Streamable HTTP
  section (container usage, OpenCode remote config with oauth:false,
  validation rules), fixed stale clone URLs (Gogs), pnpm dlx variant,
  docker compose (v2 CLI) commands — docs rule: code and docs in one commit
- src/config/config.test.ts: 11 new unit tests (transport validation,
  loopback/non-loopback auth rule, port parsing, bearer comparison)

Verified: lint/build/test green (13 tests), HTTP smoke (11 checks: health,
401 without/with wrong token, initialize + session, tools/list, DELETE +
404 reuse, 400 without session, 404 unknown path), startup refusal for
http without host and for 0.0.0.0 without token
Paul Klumpp 2 minggu lalu
induk
melakukan
d0076a3422
10 mengubah file dengan 576 tambahan dan 21 penghapusan
  1. 11 0
      .dockerignore
  2. 15 3
      AGENTS.md
  3. 21 0
      Dockerfile
  4. 90 8
      README.md
  5. 30 0
      docker-compose.yml
  6. 10 1
      env.example
  7. 98 0
      src/config/config.test.ts
  8. 45 2
      src/config/index.ts
  9. 50 7
      src/index.ts
  10. 206 0
      src/transport/http.ts

+ 11 - 0
.dockerignore

@@ -0,0 +1,11 @@
+node_modules
+build
+Dockerfile
+docker-compose.yml
+.git
+README.md
+AGENTS.md
+CLAUDE.md
+Makefile
+.env
+*.md

+ 15 - 3
AGENTS.md

@@ -29,8 +29,10 @@ instances (GraphQL API). Three-layer architecture:
 2. **WikiJS client layer** (`src/wikijs/`) — `WikiJSClient` wraps GraphQL
    operations via the codegen SDK; queries in `src/wikijs/queries/pages.graphql`;
    generated types in `src/wikijs/generated/` (`schema-types.ts` + `graphql.ts`).
-3. **Entry point** (`src/index.ts`) — config from environment, stdio transport,
-   `--version` flag for smoke tests.
+3. **Entry point** (`src/index.ts`) — config from environment, transport selection
+   (`stdio` default or Streamable HTTP via `WIKIJS_MCP_TRANSPORT=http` /
+   `--transport http`, implemented in `src/transport/http.ts`), `--version`
+   flag for smoke tests.
 
 ### Tool pattern
 
@@ -58,7 +60,9 @@ for the npm-based GitHub CI workflows — they were not migrated).
 | codegen | `pnpm codegen` | `npm run codegen` |
 | run local | `node build/index.js` | same |
 
-Run the compiled server with env vars: `WIKIJS_URL=... WIKIJS_API_KEY=... node build/index.js`.
+Run the compiled server: `WIKIJS_URL=... WIKIJS_API_KEY=... node build/index.js`.
+The built server also auto-loads a `.env` from the current working directory
+(dotenv); environment variables take precedence over `.env` values.
 Note: the package's own bin `wikijs-mcp-edit` is only installed for
 *consumers* of the published package — package managers do not link the
 root package's own bin into `node_modules/.bin`, so `pnpm exec wikijs-mcp-edit`
@@ -82,6 +86,14 @@ does not work from the repo itself.
 - `WIKIJS_DEFAULT_LOCALE` — only used for URL building; the `create_page`
   zod default for `locale` is hardcoded `'en'` (known asymmetry, see
   `src/mcp/tools/createPage.ts`)
+- `WIKIJS_MCP_TRANSPORT` — `stdio` (default) or `http` (Streamable HTTP
+  listener for containers/servers; `--transport` CLI flag overrides)
+- `WIKIJS_MCP_HOST` — bind address; **required (no default) when transport is
+  `http`** — startup fails otherwise
+- `WIKIJS_MCP_PORT` — HTTP listener port (default `3001`)
+- `WIKIJS_MCP_BEARER_TOKEN` — bearer auth for the HTTP listener; **required
+  for non-loopback binds** (loopback = `127.0.0.1`, `localhost`, `::1`),
+  startup fails otherwise. Optional only on loopback.
 
 `.env` is loaded via dotenv; copy `env.example` as template.
 

+ 21 - 0
Dockerfile

@@ -0,0 +1,21 @@
+FROM node:24-alpine
+
+WORKDIR /app
+
+# pnpm is pre-installed in node:24-alpine; build + run stay static (no postinstall
+# scripts are needed — see allowBuilds in pnpm-workspace.yaml).
+COPY package.json package-lock.json pnpm-workspace.yaml ./
+RUN pnpm install --frozen-lockfile
+
+COPY tsconfig.json codegen.yml eslint.config.js ./
+COPY src ./src
+RUN pnpm run build && pnpm prune --prod
+
+ENV WIKIJS_MCP_TRANSPORT=http
+
+EXPOSE 3001
+# Liveness probe, no auth required:
+#   docker inspect --format='{{json .State.Health.Status}}' wikijs-mcp
+HEALTHCHECK CMD node -e "fetch('http://127.0.0.1:3001/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
+
+CMD ["node", "build/index.js"]

+ 90 - 8
README.md

@@ -17,13 +17,15 @@ This MCP server enables AI assistants to interact with WikiJS instances by provi
 ```bash
 # NOTE: the binary is named wikijs-mcp-edit, not after the package name:
 npx -p @cahaseler/wikijs-mcp wikijs-mcp-edit
+# pnpm equivalent (pnpx is a deprecated alias of pnpm dlx):
+pnpm dlx @cahaseler/wikijs-mcp wikijs-mcp-edit
 ```
 This always runs the *latest published* version from npm. For the current feature branch (see git remote), build from source instead.
 
 ### From source
 ```bash
-git clone <your-clone-url>
-cd wikijs-mcp
+git clone ssh://git@gogs.netdome.biz:10022/netdome/wikijs-mcp-server-cahaseler.git
+cd wikijs-mcp-server-cahaseler
 pnpm install      # or: npm install
 pnpm run build    # or: npm run build
 ```
@@ -132,6 +134,59 @@ Add to your Claude Desktop configuration:
 | `WIKIJS_API_KEY` | WikiJS API key | Yes | - |
 | `WIKIJS_ENABLE_EDIT` | Enable page editing functionality | No | false |
 | `WIKIJS_DEFAULT_LOCALE` | Locale used when building page URLs | No | en |
+| `WIKIJS_MCP_TRANSPORT` | `stdio` (client spawns the server) or `http` (Streamable HTTP listener; see below) | No | stdio |
+| `WIKIJS_MCP_HOST` | Bind address for the HTTP listener | **Yes when `WIKIJS_MCP_TRANSPORT=http`** — there is no default on purpose | - |
+| `WIKIJS_MCP_PORT` | Port for the HTTP listener | No | 3001 |
+| `WIKIJS_MCP_BEARER_TOKEN` | Bearer token protecting the HTTP listener | **Yes for non-loopback binds** (see below) | - |
+
+## Streamable HTTP transport (container / server mode)
+
+Use stdio only when your MCP client spawns the server process itself. To run
+the server next to Wiki.js in a container (client connects over the network),
+use the Streamable HTTP transport (MCP spec compliant, stateful sessions):
+
+```bash
+WIKIJS_URL=http://wiki:3000 WIKIJS_API_KEY=your-key \
+WIKIJS_MCP_TRANSPORT=http WIKIJS_MCP_HOST=0.0.0.0 WIKIJS_MCP_PORT=3001 \
+WIKIJS_MCP_BEARER_TOKEN=choose-a-long-random-token \
+node build/index.js
+```
+
+Startup logs the exact listener: `WikiJS MCP server listening on http://0.0.0.0:3001/mcp`.
+
+Endpoints: `POST/GET/DELETE /mcp` (MCP Streamable HTTP), `GET /health`
+(liveness probe, no auth). HTTP clients connect to `http://<host>:<port>/mcp`
+and must send `Authorization: Bearer <token>` on every request.
+
+**Startup validation — the server refuses to start otherwise:**
+- `WIKIJS_MCP_TRANSPORT=http` without `WIKIJS_MCP_HOST` → exit
+- Non-loopback `WIKIJS_MCP_HOST` (e.g. `0.0.0.0`) without `WIKIJS_MCP_BEARER_TOKEN` → exit
+
+A token is optional **only** for loopback binds (`127.0.0.1`, `localhost`, `::1`).
+
+OpenCode remote-MCP config (`opencode.json`):
+
+```json
+{
+  "mcp": {
+    "wikijs": {
+      "type": "remote",
+      "url": "http://wikijs-mcp:3001/mcp",
+      "oauth": false,
+      "headers": {
+        "Authorization": "Bearer choose-a-long-random-token"
+      }
+    }
+  }
+}
+```
+
+(`oauth: false` disables OpenCode's automatic OAuth discovery — the bearer
+token is passed explicitly via `headers` instead; secrets can also reference
+environment variables, e.g. `"Bearer {env:WIKIJS_MCP_BEARER_TOKEN}"`.)
+
+The CLI flag `--transport stdio|http` overrides `WIKIJS_MCP_TRANSPORT`
+(subject to the same host/token validation).
 
 ## Available Tools
 
@@ -184,8 +239,8 @@ Once configured, the following tools will be available (10 read / 6 write):
 
 1. Clone the repository:
 ```bash
-git clone https://github.com/cahaseler/wikijs-mcp.git
-cd wikijs-mcp
+git clone ssh://git@gogs.netdome.biz:10022/netdome/wikijs-mcp-server-cahaseler.git
+cd wikijs-mcp-server-cahaseler
 ```
 
 2. Install dependencies (pnpm primary, npm works too):
@@ -205,13 +260,27 @@ cp env.example .env
 To run a local WikiJS instance for testing:
 
 ```bash
-# Start WikiJS and PostgreSQL
-docker-compose up -d
+# Start WikiJS and PostgreSQL (also brings up the mcp service — see below)
+docker compose up -d
+
+# Start only WikiJS and PostgreSQL:
+docker compose up -d db wiki
 
 # The instance will be available at http://localhost:3000
 # Follow the setup wizard to create an admin account
 ```
 
+The `mcp` service in `docker-compose.yml` runs the built server in HTTP mode
+next to the wiki instance:
+
+```bash
+WIKIJS_MCP_BEARER_TOKEN=choose-a-long-random-token \
+WIKIJS_API_KEY=<key-from-wiki> \
+docker compose up -d mcp
+# -> MCP listener on http://localhost:3001/mcp (port published 3001:3001)
+```
+
+
 ### Building
 ```bash
 pnpm run build      # or: npm run build
@@ -223,15 +292,24 @@ make build
 
 ### Testing the MCP Server
 
-The server is a stdio MCP server — it is started *by* an MCP client, not manually as a daemon.
+The default transport is stdio — it is started *by* an MCP client, not manually as a daemon. For the HTTP listener see ["Streamable HTTP transport"](#streamable-http-transport-container--server-mode) above.
 
 ```bash
 # Quick smoke without a wiki connection: expect output = version from package.json
 node build/index.js --version
 
-# Run it manually (it then waits, silently, for MCP protocol frames on stdio):
+# Run it manually in stdio mode (it then waits, silently, for MCP protocol frames on stdin):
 WIKIJS_URL=http://localhost:3000 WIKIJS_API_KEY=your-key node build/index.js
 
+# Same, but explicit about the transport flag:
+WIKIJS_URL=http://localhost:3000 WIKIJS_API_KEY=your-key node build/index.js --transport stdio
+
+# Run the HTTP listener locally (loopback, no token required):
+WIKIJS_URL=http://localhost:3000 WIKIJS_API_KEY=your-key \
+WIKIJS_MCP_TRANSPORT=http WIKIJS_MCP_HOST=127.0.0.1 \
+node build/index.js
+# -> WikiJS MCP server listening on http://127.0.0.1:3001/mcp
+
 # The built server auto-loads .env from the current working directory (dotenv),
 # so from the repo root this is sufficient: node build/index.js — provided
 # you created .env via the Setup steps above. Note: env variables take
@@ -243,12 +321,16 @@ npx @modelcontextprotocol/inspector node build/index.js      # pnpm: pnpm dlx @m
 
 Missing env vars abort the server immediately with
 `WIKIJS_URL and WIKIJS_API_KEY must be set` on stderr.
+HTTP mode aborts when `WIKIJS_MCP_HOST` is missing or a non-loopback bind
+has no `WIKIJS_MCP_BEARER_TOKEN`.
 
 ## Security Considerations
 
 - **API Key Storage**: Never commit your API key to version control
 - **Edit Permissions**: The edit functionality is disabled by default. Only enable it if you trust the environment where the MCP server is running
 - **Scope Permissions**: When creating the WikiJS API key, consider using the minimum required permissions instead of full access
+- **HTTP transport auth**: A non-loopback HTTP bind requires `WIKIJS_MCP_BEARER_TOKEN` — the server refuses to start otherwise. Even with a token, prefer exposing the listener only to the network segment that needs it (container network, not the host's public interfaces)
+- **HTTP transport token scope**: The bearer token protects the MCP endpoint itself; it is separate from the WikiJS API key
 
 ## Troubleshooting
 

+ 30 - 0
docker-compose.yml

@@ -35,6 +35,36 @@ services:
     networks:
       - wikijs
 
+  # MCP server bridging agents and the wiki instance.
+  # Runs the Streamable HTTP transport (built from the current source tree).
+  #
+  # 0.0.0.0 is a non-loopback bind, so the image validation (src/config)
+  # requires WIKIJS_MCP_BEARER_TOKEN — set it to a long random value.
+  # To keep the listener on the host-only interface, use 127.0.0.1 instead
+  # and drop the published port.
+  mcp:
+    build: .
+    depends_on:
+      - wiki
+    environment:
+      WIKIJS_MCP_TRANSPORT: http
+      WIKIJS_MCP_HOST: 0.0.0.0
+      WIKIJS_MCP_PORT: "3001"
+      WIKIJS_MCP_BEARER_TOKEN: ${WIKIJS_MCP_BEARER_TOKEN:?set a bearer token for the non-loopback MCP bind}
+      WIKIJS_URL: http://wiki:3000
+      WIKIJS_API_KEY: ${WIKIJS_API_KEY:?create an API key in the wiki first (Admin > API Access)}
+      WIKIJS_ENABLE_EDIT: ${WIKIJS_ENABLE_EDIT:-false}
+    restart: unless-stopped
+    ports:
+      - "3001:3001"
+    healthcheck:
+      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3001/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
+      interval: 30s
+      timeout: 5s
+      retries: 3
+    networks:
+      - wikijs
+
 volumes:
   db-data:
 

+ 10 - 1
env.example

@@ -1,4 +1,13 @@
 WIKIJS_URL=http://localhost:3000
 WIKIJS_API_KEY=
 WIKIJS_ENABLE_EDIT=false
-WIKIJS_DEFAULT_LOCALE=en
+WIKIJS_DEFAULT_LOCALE=en
+
+# Transport: "stdio" (default) or "http" (Streamable HTTP, for containers/servers)
+# WIKIJS_MCP_TRANSPORT=stdio
+# When WIKIJS_MCP_TRANSPORT=http, WIKIJS_MCP_HOST is REQUIRED (no default on purpose):
+#   loopback (127.0.0.1, localhost, ::1)     -> bearer token optional
+#   anything else (e.g. 0.0.0.0)             -> WIKIJS_MCP_BEARER_TOKEN REQUIRED
+# WIKIJS_MCP_HOST=
+# WIKIJS_MCP_PORT=3001
+# WIKIJS_MCP_BEARER_TOKEN=

+ 98 - 0
src/config/config.test.ts

@@ -0,0 +1,98 @@
+import { describe, expect, it } from '@jest/globals';
+import { isBearerTokenValid } from '../transport/http.js';
+import { DEFAULT_HTTP_PORT, LOOPBACK_HOSTS, loadConfig } from './index.js';
+
+const BASE_ENV = {
+  WIKIJS_URL: 'http://localhost:3000',
+  WIKIJS_API_KEY: 'test-key'
+};
+
+describe('loadConfig transport validation', () => {
+  it('defaults to stdio and ignores MCP host settings', () => {
+    const cfg = loadConfig({ ...BASE_ENV });
+    expect('error' in cfg).toBe(false);
+    if ('error' in cfg) return;
+    expect(cfg.TRANSPORT).toBe('stdio');
+    expect(cfg.MCP_HOST).toBeUndefined();
+    expect(cfg.MCP_PORT).toBe(DEFAULT_HTTP_PORT);
+    expect(cfg.MCP_BEARER_TOKEN).toBeUndefined();
+  });
+
+  it('rejects an invalid transport value', () => {
+    const cfg = loadConfig({ ...BASE_ENV, WIKIJS_MCP_TRANSPORT: 'ws' });
+    expect('error' in cfg).toBe(true);
+    if (!('error' in cfg)) return;
+    expect(cfg.error).toContain('WIKIJS_MCP_TRANSPORT must be "stdio" or "http"');
+  });
+
+  it('rejects http without WIKIJS_MCP_HOST', () => {
+    const cfg = loadConfig({ ...BASE_ENV, WIKIJS_MCP_TRANSPORT: 'http' });
+    expect('error' in cfg).toBe(true);
+    if (!('error' in cfg)) return;
+    expect(cfg.error).toContain('WIKIJS_MCP_TRANSPORT=http requires WIKIJS_MCP_HOST');
+  });
+
+  it('rejects a non-loopback bind without a bearer token', () => {
+    const cfg = loadConfig({ ...BASE_ENV, WIKIJS_MCP_TRANSPORT: 'http', WIKIJS_MCP_HOST: '0.0.0.0' });
+    expect('error' in cfg).toBe(true);
+    if (!('error' in cfg)) return;
+    expect(cfg.error).toContain('WIKIJS_MCP_BEARER_TOKEN must be set for authentication');
+  });
+
+  it('accepts a non-loopback bind with a bearer token', () => {
+    const cfg = loadConfig({
+      ...BASE_ENV,
+      WIKIJS_MCP_TRANSPORT: 'http',
+      WIKIJS_MCP_HOST: '0.0.0.0',
+      WIKIJS_MCP_BEARER_TOKEN: 'secret'
+    });
+    expect('error' in cfg).toBe(false);
+    if ('error' in cfg) return;
+    expect(cfg.TRANSPORT).toBe('http');
+    expect(cfg.MCP_HOST).toBe('0.0.0.0');
+    expect(cfg.MCP_BEARER_TOKEN).toBe('secret');
+  });
+
+  it('accepts a loopback bind without a bearer token', () => {
+    for (const host of LOOPBACK_HOSTS) {
+      const cfg = loadConfig({ ...BASE_ENV, WIKIJS_MCP_TRANSPORT: 'http', WIKIJS_MCP_HOST: host });
+      expect('error' in cfg).toBe(false);
+      if ('error' in cfg) return;
+      expect(cfg.MCP_HOST).toBe(host);
+    }
+  });
+
+  it('rejects an invalid port', () => {
+    const cfg = loadConfig({
+      ...BASE_ENV,
+      WIKIJS_MCP_TRANSPORT: 'http',
+      WIKIJS_MCP_HOST: '127.0.0.1',
+      WIKIJS_MCP_PORT: 'not-a-port'
+    });
+    expect('error' in cfg).toBe(true);
+    if (!('error' in cfg)) return;
+    expect(cfg.error).toContain('WIKIJS_MCP_PORT must be an integer between 1 and 65535');
+  });
+
+  it('rejects a missing WIKIJS_URL/API_KEY regardless of transport', () => {
+    const cfg = loadConfig({ WIKIJS_MCP_TRANSPORT: 'http', WIKIJS_MCP_HOST: '0.0.0.0', WIKIJS_MCP_BEARER_TOKEN: 'x' });
+    expect('error' in cfg).toBe(true);
+    if (!('error' in cfg)) return;
+    expect(cfg.error).toContain('WIKIJS_URL and WIKIJS_API_KEY must be set');
+  });
+});
+
+describe('isBearerTokenValid', () => {
+  it('accepts the matching token', () => {
+    expect(isBearerTokenValid('secret', 'secret')).toBe(true);
+  });
+
+  it('rejects a wrong token', () => {
+    expect(isBearerTokenValid('wrong', 'secret')).toBe(false);
+  });
+
+  it('rejects tokens of different length without throwing', () => {
+    expect(isBearerTokenValid('', 'secret')).toBe(false);
+    expect(isBearerTokenValid('a-much-longer-token', 'secret')).toBe(false);
+  });
+});

+ 45 - 2
src/config/index.ts

@@ -1,21 +1,64 @@
+export type TransportMode = 'stdio' | 'http';
+
 export interface WikiConfig {
   WIKIJS_URL: string;
   WIKIJS_API_KEY: string;
   ENABLE_EDIT: boolean;
   DEFAULT_LOCALE: string;
+  TRANSPORT: TransportMode;
+  MCP_HOST: string | undefined;
+  MCP_PORT: number;
+  MCP_BEARER_TOKEN: string | undefined;
 }
 
+/** Hosts where an unauthenticated listener is acceptable (local-only access). */
+export const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
+
+export const DEFAULT_HTTP_PORT = 3001;
+
 export function loadConfig(env: NodeJS.ProcessEnv = process.env): WikiConfig | { error: string } {
   const url = env.WIKIJS_URL;
   const key = env.WIKIJS_API_KEY;
   if (!url || !key) {
     return { error: 'WIKIJS_URL and WIKIJS_API_KEY must be set (via environment or .env file)' };
   }
+
+  const transport = env.WIKIJS_MCP_TRANSPORT || 'stdio';
+  if (transport !== 'stdio' && transport !== 'http') {
+    return { error: `WIKIJS_MCP_TRANSPORT must be "stdio" or "http" (got "${transport}")` };
+  }
+
+  const host = env.WIKIJS_MCP_HOST;
+  const port = Number(env.WIKIJS_MCP_PORT || DEFAULT_HTTP_PORT);
+  const token = env.WIKIJS_MCP_BEARER_TOKEN;
+
+  if (transport === 'http') {
+    if (!host) {
+      return {
+        error:
+          'WIKIJS_MCP_TRANSPORT=http requires WIKIJS_MCP_HOST (bind address, e.g. 0.0.0.0 in a container or 127.0.0.1 for local only)'
+      };
+    }
+    if (!LOOPBACK_HOSTS.has(host) && !token) {
+      return {
+        error:
+          `WIKIJS_MCP_HOST="${host}" is not a loopback address — WIKIJS_MCP_BEARER_TOKEN must be set for authentication`
+      };
+    }
+    if (!Number.isInteger(port) || port < 1 || port > 65535) {
+      return { error: `WIKIJS_MCP_PORT must be an integer between 1 and 65535 (got "${env.WIKIJS_MCP_PORT}")` };
+    }
+  }
+
   return {
     WIKIJS_URL: url.replace(/\/+$/, ''),
     WIKIJS_API_KEY: key,
     ENABLE_EDIT: env.WIKIJS_ENABLE_EDIT === 'true',
-    DEFAULT_LOCALE: env.WIKIJS_DEFAULT_LOCALE || 'en'
+    DEFAULT_LOCALE: env.WIKIJS_DEFAULT_LOCALE || 'en',
+    TRANSPORT: transport,
+    MCP_HOST: host,
+    MCP_PORT: port,
+    MCP_BEARER_TOKEN: token
   };
 }
 
@@ -28,4 +71,4 @@ configDotenv({
   path: path.join(path.dirname(fileURLToPath(import.meta.url)), '../../.env'),
 });
 
-export const CONFIG = loadConfig();
+export const CONFIG = loadConfig();

+ 50 - 7
src/index.ts

@@ -1,10 +1,32 @@
 #!/usr/bin/env node
 
 import { WikiJSClient } from './wikijs/index.js';
-import { CONFIG } from './config/index.js';
+import { CONFIG, loadConfig } from './config/index.js';
 import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
 import { WikiJSMcpServer } from './mcp/index.js';
 import { getVersion } from './util/version.js';
+import { startHttpServer } from './transport/http.js';
+
+/** Parse `--transport stdio|http` or `--transport=stdio|http`; returns undefined when absent. */
+function parseTransportFlag(argv: string[]): string | { error: string } | undefined {
+  const equals = argv.find((a) => a.startsWith('--transport='));
+  if (equals) {
+    const value = equals.slice('--transport='.length);
+    if (value !== 'stdio' && value !== 'http') {
+      return { error: `--transport must be "stdio" or "http" (got "${value}")` };
+    }
+    return value;
+  }
+  const idx = argv.indexOf('--transport');
+  if (idx !== -1) {
+    const value = argv[idx + 1];
+    if (value !== 'stdio' && value !== 'http') {
+      return { error: `--transport must be "stdio" or "http" (got "${value ?? 'missing'}")` };
+    }
+    return value;
+  }
+  return undefined;
+}
 
 async function main() {
 	const version = getVersion();
@@ -14,25 +36,46 @@ async function main() {
 		return;
 	}
 
-	if ('error' in CONFIG) {
-		console.error(CONFIG.error);
+	const transportFlag = parseTransportFlag(process.argv);
+	if (transportFlag && typeof transportFlag === 'object') {
+		console.error(transportFlag.error);
+		process.exit(1);
+	}
+
+	const config = transportFlag
+		? loadConfig({ ...process.env, WIKIJS_MCP_TRANSPORT: transportFlag })
+		: CONFIG;
+
+	if ('error' in config) {
+		console.error(config.error);
 		process.exit(1);
 	}
-	const cfg = CONFIG;
+	const cfg = config;
 
 	console.error(`Version: ${version}`);
 
 	const wikiClient = new WikiJSClient(cfg.WIKIJS_URL, cfg.WIKIJS_API_KEY, cfg.DEFAULT_LOCALE);
-		
+
+	const description = 'You are a helpful assistant that can search for, read, create and edit pages in WikiJS. Prefer search_pages then get_page_by_id/get_page_by_path to read full content before editing. edit_page replaces the whole document: pass the complete new content.';
+
+	if (cfg.TRANSPORT === 'http') {
+		await startHttpServer(cfg, {
+			version,
+			description,
+			enableEdit: cfg.ENABLE_EDIT
+		}, wikiClient);
+		return;
+	}
+
 	const server = new WikiJSMcpServer(wikiClient, {
 		version,
-		description: 'You are a helpful assistant that can search for, read, create and edit pages in WikiJS. Prefer search_pages then get_page_by_id/get_page_by_path to read full content before editing. edit_page replaces the whole document: pass the complete new content.',
+		description,
 		enableEdit: cfg.ENABLE_EDIT
 	});
 
 	const transport = new StdioServerTransport();
 	await server.connect(transport);
-	console.error('WikiJS MCP server running on stdio');
+	console.error(`WikiJS MCP server running on stdio (no network port — the client connects via this process' stdin/stdout; for a network listener set WIKIJS_MCP_TRANSPORT=http, see README section "Streamable HTTP transport")`);
 }
 
 const isMain = (() => {

+ 206 - 0
src/transport/http.ts

@@ -0,0 +1,206 @@
+import http from 'node:http';
+import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
+import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
+import { WikiJSMcpServer } from '../mcp/index.js';
+import type { WikiJSClient } from '../wikijs/index.js';
+import type { WikiConfig } from '../config/index.js';
+
+export interface HttpServerOptions {
+  version: string;
+  description: string;
+  enableEdit: boolean;
+}
+
+interface Session {
+  transport: StreamableHTTPServerTransport;
+  server: WikiJSMcpServer;
+}
+
+/** Mirror of the SDK default (4 MiB) for pre-read bodies. */
+const MAX_BODY_BYTES = 4 * 1024 * 1024;
+
+/**
+ * Constant-time bearer token comparison. Both sides are SHA-256 hashed first
+ * so timingSafeEqual always receives buffers of equal length.
+ */
+export function isBearerTokenValid(provided: string, expected: string): boolean {
+  const ha = createHash('sha256').update(provided).digest();
+  const hb = createHash('sha256').update(expected).digest();
+  return timingSafeEqual(ha, hb);
+}
+
+interface ParsedBody {
+  ok: true;
+  value: unknown;
+}
+
+interface BodyError {
+  ok: false;
+  status: 400 | 413;
+  message: string;
+}
+
+async function readBody(req: http.IncomingMessage): Promise<ParsedBody | BodyError> {
+  const chunks: Buffer[] = [];
+  let size = 0;
+  for await (const chunk of req) {
+    size += (chunk as Buffer).length;
+    if (size > MAX_BODY_BYTES) {
+      return { ok: false, status: 413, message: `request body exceeds limit of ${MAX_BODY_BYTES} bytes` };
+    }
+    chunks.push(chunk as Buffer);
+  }
+  try {
+    return { ok: true, value: JSON.parse(Buffer.concat(chunks).toString('utf8')) };
+  } catch {
+    return { ok: false, status: 400, message: 'parse error: invalid JSON' };
+  }
+}
+
+function isInitializeMessage(value: unknown): boolean {
+  return (
+    typeof value === 'object' &&
+    value !== null &&
+    'method' in value &&
+    (value as { method?: unknown }).method === 'initialize'
+  );
+}
+
+function sendJson(res: http.ServerResponse, status: number, payload: Record<string, unknown>, headers: Record<string, string> = {}): void {
+  res.writeHead(status, { 'content-type': 'application/json', ...headers });
+  res.end(JSON.stringify(payload));
+}
+
+/**
+ * Start the MCP server in Streamable HTTP mode (stateful sessions).
+ *
+ * Routes:
+ * - POST   /mcp    initialize / messages (Accept: application/json, text/event-stream)
+ * - GET    /mcp    server-to-client SSE stream for an established session
+ * - DELETE /mcp    terminate a session
+ * - GET    /health liveness probe (no auth)
+ *
+ * When WIKIJS_MCP_BEARER_TOKEN is configured, every /mcp request must carry
+ * `Authorization: Bearer <token>`. Config validation already enforces that a
+ * non-loopback bind requires a token (see src/config/index.ts).
+ */
+export async function startHttpServer(
+  cfg: WikiConfig,
+  options: HttpServerOptions,
+  wikiClient: WikiJSClient
+): Promise<http.Server> {
+  const sessions = new Map<string, Session>();
+  const expectedToken = cfg.MCP_BEARER_TOKEN;
+
+  const server = http.createServer((req, res) => {
+    void handleRequest(req, res).catch((error) => {
+      console.error('Error handling request:', error);
+      if (!res.headersSent) {
+        sendJson(res, 500, { error: 'internal server error' });
+      } else {
+        res.end();
+      }
+    });
+  });
+
+  async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
+    const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
+    const pathname = url.pathname.replace(/\/+$/, '') || '/';
+
+    if (req.method === 'GET' && pathname === '/health') {
+      sendJson(res, 200, { status: 'ok', transport: 'http' });
+      return;
+    }
+
+    if (pathname !== '/mcp') {
+      sendJson(res, 404, { error: 'not found' });
+      return;
+    }
+
+    if (expectedToken) {
+      const authHeader = req.headers.authorization;
+      const provided = authHeader?.startsWith('Bearer ') ? authHeader.slice('Bearer '.length) : undefined;
+      if (!provided || !isBearerTokenValid(provided, expectedToken)) {
+        sendJson(res, 401, { error: 'unauthorized: Bearer token required' }, { 'www-authenticate': 'Bearer' });
+        return;
+      }
+    }
+
+    const sessionIdHeader = req.headers['mcp-session-id'];
+    const sessionId = typeof sessionIdHeader === 'string' ? sessionIdHeader : undefined;
+
+    if (req.method === 'POST') {
+      if (sessionId) {
+        const session = sessions.get(sessionId);
+        if (!session) {
+          sendJson(res, 404, { error: 'session not found' });
+          return;
+        }
+        const body = await readBody(req);
+        if (!body.ok) {
+          sendJson(res, body.status, { error: body.message });
+          return;
+        }
+        await session.transport.handleRequest(req, res, body.value);
+        return;
+      }
+
+      // No session id yet: only an initialize request may create a session.
+      const body = await readBody(req);
+      if (!body.ok) {
+        sendJson(res, body.status, { error: body.message });
+        return;
+      }
+      if (!isInitializeMessage(body.value)) {
+        sendJson(res, 400, { error: 'bad request: missing session id (initialize first)' });
+        return;
+      }
+
+      const transport = new StreamableHTTPServerTransport({
+        sessionIdGenerator: () => randomUUID(),
+        onsessioninitialized: (id) => {
+          sessions.set(id, { transport, server: mcpServer });
+        }
+      });
+      const mcpServer = new WikiJSMcpServer(wikiClient, {
+        version: options.version,
+        description: options.description,
+        enableEdit: options.enableEdit
+      });
+      transport.onclose = () => {
+        if (transport.sessionId) {
+          sessions.delete(transport.sessionId);
+        }
+      };
+      await mcpServer.connect(transport);
+      await transport.handleRequest(req, res, body.value);
+      return;
+    }
+
+    if (req.method === 'GET' || req.method === 'DELETE') {
+      if (!sessionId || !sessions.has(sessionId)) {
+        sendJson(res, 404, { error: 'session not found' });
+        return;
+      }
+      const session = sessions.get(sessionId)!;
+      await session.transport.handleRequest(req, res);
+      return;
+    }
+
+    sendJson(res, 405, { error: 'method not allowed' }, { allow: 'POST, GET, DELETE' });
+  }
+
+  await new Promise<void>((resolve) => {
+    server.listen(cfg.MCP_PORT, cfg.MCP_HOST, resolve);
+  });
+
+  const host = cfg.MCP_HOST ?? 'unknown';
+  const authMode = expectedToken ? 'bearer token' : 'none (loopback bind)';
+  console.error(`WikiJS MCP server listening on http://${host}:${cfg.MCP_PORT}/mcp`);
+  console.error(`transport: streamable http (stateful sessions), auth: ${authMode}`);
+  if (!expectedToken) {
+    console.error('warning: no WIKIJS_MCP_BEARER_TOKEN configured — only acceptable for loopback binds');
+  }
+
+  return server;
+}