Skip to content
Hack Your WorldHome Assistant · lighting · home projects

Workbench

My MediaWiki MCP Server Could Read After Login Expired

Editorial illustration of healthy document reads beside failed authenticated writes and an unreachable container port
Editorial illustration of the partial authentication and container-network failures in this article.

My MediaWiki MCP server could still search and read pages after its login expired. That made the failure look smaller than it was. The read side was healthy, the write side had quietly lost its identity, and the status tool was reporting the credentials I had configured instead of the session MediaWiki could actually see.

The current code, releases, and full change history are public in ttpears/mediawiki-mcp. I maintain the project. This is the failure story behind versions 2.4 and 2.5, not a generic MCP setup guide.

The login had expired, but the server had not stopped

The server logs into MediaWiki with a bot password and keeps the cookies for later Action API requests. My original client called login() at startup, set an internal loggedIn flag, and treated that flag as truth for the rest of the process.

MediaWiki did not share that assumption. The server-side session could expire while the Node process and its in-memory flag stayed alive. Public reads continued to work because MediaWiki permits them without a login. A search result therefore looked like evidence that the integration was fine. It was only evidence that the anonymous path was fine.

The next write could fail as an anonymous user, produce a misleading permissions error, or—on a wiki that permits anonymous edits—be attributed to an IP address or temporary account. The dangerous part was not a clean authentication error. It was the gap between the identity my process believed it had and the identity the wiki actually received.

I made the write request tell the truth

MediaWiki already has the right guard for this. Its Action API accepts assert=user on a request. If the request is no longer associated with a registered account, MediaWiki returns assertuserfailed instead of carrying on anonymously. The official API documentation explicitly describes session expiration as one reason to use it.

I now add that assertion to every write made with bot credentials:

private writeParams(params) {
  return this.hasBotCredentials()
    ? { ...params, assert: 'user' }
    : params;
}

The failed assertion is useful because it turns a vague symptom into a recoverable state. The client clears its cached login state and CSRF token, logs in again, and retries the write once. One retry is deliberate. If a fresh login does not repair the operation, repeated attempts would hide a real permissions or configuration problem.

try {
  return await write();
} catch (error) {
  if (!hasCredentials || !isAuthError(error)) throw error;
  await refreshLogin();
  return write();
}

The exact implementation and regression tests landed in the authentication repair commit. Tests cover the failed assertion, re-login, new CSRF token, one retry, anonymous clients, multipart uploads, and the case where the retry still fails.

The status tool was checking configuration, not reality

The server also had a list-wikis tool. It knew that a username and password existed in configuration, so it could make an authenticated-looking report without checking the live cookie session. That is the monitoring version of the same bug.

The status path now asks MediaWiki for action=query&meta=userinfo. It reports the authenticated username, an anonymous state, or an authentication error. For bot-password sessions it can re-establish an expired login before reporting. The answer comes from the system that decides whether the request is authenticated, not from a stale boolean in my process.

I also changed startup behavior. A failed initial login no longer replaces the credentialed client with a permanently anonymous one. The client stays registered with its credentials, public reads remain available, and the next write gets a chance to authenticate again.

The Docker image had a different kind of false health

The second failure needed no expired credential. The HTTP server defaulted to localhost. In the Alpine-based image, that name resolved to IPv6 loopback first, so the process listened on ::1 inside the container.

The image exposed port 8009 and a Compose file could publish it, but port publication does not make a loopback-only process reachable from outside its own container. Docker forwards traffic to the container port; an application still has to listen on an address that accepts that traffic. Docker’s port-publishing documentation makes that host-to-container mapping explicit.

The built-in health check made the mismatch more obvious. It called 127.0.0.1:8009/health, while the process was listening on IPv6 loopback. The container could start, keep running, expose the expected number, and remain permanently unhealthy.

I fixed the container default in the image, where listening on all container interfaces is the useful behavior:

ENV MEDIAWIKI_MCP_HOST=0.0.0.0

HEALTHCHECK ... \
  CMD wget -qO- http://127.0.0.1:8009/health || exit 1

The local CLI and stdio path still default to localhost. I did not widen the default everywhere just to make Docker work. The container gets a container-appropriate default, and an explicit environment value still wins. The complete change is in the Docker repair commit.

A green health endpoint cannot account for abandoned sessions

The HTTP transport kept each MCP session in a map. Cleanly closed transports removed themselves through an onclose callback. A crashed client, dropped connection, or proxy timeout could disappear without that callback ever firing.

Each abandoned entry retained more than an identifier. It pinned a transport, an MCP server, a wiki orchestrator, and a live client per configured wiki. The process could answer its health endpoint while memory kept accumulating behind it.

I pulled session ownership into a registry with two separate limits:

  • A 30-minute idle timeout, checked periodically and again whenever a session is looked up.
  • A default cap of 1,000 concurrent sessions, after which new initialization requests are rejected.

Expired sessions are closed before their references disappear. POST, GET, and DELETE now use the same expiry-checked lookup instead of applying lifecycle rules to only one route. The implementation is visible in the session-registry commit.

The timeout and cap solve different failures. A timeout reclaims abandoned work. A cap bounds the damage if sessions arrive faster than the timeout can remove them. Neither depends on every client behaving politely.

What I check now

These bugs changed what I mean by a healthy connector:

  1. Readiness: Can the process answer at the address another container or client will actually use?
  2. Identity: Does the upstream system confirm the current user, not merely the local configuration?
  3. Capability: Can a protected write complete without falling back to anonymous behavior?
  4. Recovery: Does an expired session trigger one bounded repair attempt and then surface the real error?
  5. Retention: What remains in memory when a client vanishes without closing?
  6. Capacity: Is there a hard point where the service refuses more state?

I also moved the runtime, CI matrix, and container image to Node 24 together. The Node.js release schedule currently lists 24 as Active LTS. Changing all three surfaces in one commit matters because a green test on one Node version says less if the published image runs another.

I am more interested in these checks than a large dashboard full of green lights. A read-only probe can be useful, but it should be labeled as a read-only probe. A process check proves only that the process exists. An HTTP check made from inside a container proves only that the selected in-container address works. Each signal needs a plain statement of what it does not prove.

For this service, a small set of targeted failures carries more weight: expire the authentication state and attempt a protected write; start the published image without an explicit host override and call it through the mapped port; abandon a session without sending a clean close and advance the registry clock. Those tests match the ways the service actually failed. They also leave less room for a broad “healthy” label to blur several unrelated boundaries into one reassuring color.

For this review I installed the current source with semver-compatible dependencies, then ran its type check, production build, and full test command. All 24 test files and 201 tests passed. That verifies the current code path in my local checkout; it is not a claim that I reproduced an hour-long bot-session expiry or an abandoned-client workload against a live wiki during this review.

The pattern behind all three failures

Each bug came from trusting a nearby signal:

  • A successful read was mistaken for a valid authenticated session.
  • An exposed port was mistaken for a reachable listener.
  • A running process was mistaken for bounded session state.

The repair in each case moved the check to the boundary that owns the truth. MediaWiki confirms the user. The container listens on a reachable address. The registry owns expiry and capacity. That is the part I will reuse in other integrations: test the boundary, not the comforting proxy beside it.

Disclosure: I maintain MediaWiki MCP. It is open-source software under the MIT license. Links to the repository and documentation are not affiliate links. The hero image is an editorial illustration.