MCP Server for GemStone
# Native GemStone MCP Server A [Model Context Protocol](https://modelcontextprotocol.io) server written natively in GemStone Smalltalk. It runs **inside** the image and executes tool calls directly — no Node.js process, no GCI/FFI bridge. The goal is to replace the GCI-based Jasper MCP server with one that any MCP client can reach over plain HTTP. **Status:** the Streamable HTTP transport, per-client worker gems, 31 base tools (+9 optional Python), OAuth 2.1 / JWT + TLS, per-router worker identity and server-initiated messages are built and verified end-to-end — by curl, by a TLS run, and by the in-image suites. What is *not* built is listed under [Future work](#future-work). **A note about versions.** The supported images are **3.7.5 and 3.7.6+**, whose implementation of `System continueTransaction` the server's view handling relies on — see [docs/GemStone_Notes.md](docs/GemStone_Notes.md#version-to-version-differences) for the measurements. Within that range what you get depends on the image, and `install.sh` works it out for you: | image | base server (`run-server.sh`) | OAuth/OIDC front end (`run-auth-server.sh`) | |---|---|---| | **3.7.5** | yes | yes, against a local IdP | | **3.7.6+** | yes | yes, including an external OIDC IdP | `src/auth` needs `JsonWebToken` and `JwtSecurityData` (and, to log a worker in, `GsTsExternalSession>>jwtPassword:`), none of which exist before 3.7.5, so `install.sh` detects what the image has rather than asking. The 3.7.6 line is a separate matter: earlier releases have a bug connecting to an *external* OIDC IdP. ## Other documentation This README is the reference for what the server is and how to run it. Alongside it: * [CHANGELOG.md](CHANGELOG.md) — what changed per release. * [docs/Development.md](docs/Development.md) — **start here to contribute**: environment, the per-change loop, canonical file-outs, testing, branches and releases. * [docs/GemStone_Notes.md](docs/GemStone_Notes.md) — image and kernel behaviour that has cost this project time, most of it invisible in the source. Its first section is the silent failures. * [docs/MCP_Client_Notes.md](docs/MCP_Client_Notes.md) — measured client behaviour, and where the protocol revisions contradict each other. * [docs/blind-write-guardrail.md](docs/blind-write-guardrail.md), [docs/session-lifetime.md](docs/session-lifetime.md), [docs/utf8-wire.md](docs/utf8-wire.md) — deep dives on three subsystems. * [.claude/CLAUDE.md](.claude/CLAUDE.md) — the same process as `docs/Development.md`, condensed for a coding agent with the traps attached. Machine-specific facts go in a gitignored `.claude/CLAUDE.local.md` (template: `.claude/CLAUDE.local.md.example`). ## Install & run ```bash export GEMSTONE=/path/to/GemStone64Bit3.7.x # product dir ./install.sh --check # verify the environment and report what would be installed ./install.sh # file in the classes and commit; auth included if the image can ./install.sh --auth # ...and fail loudly if it cannot, instead of quietly skipping ./install.sh --no-auth # ...or leave the auth group out of an image that could take it ./install.sh --grail # ...plus the optional Grail/Python toolset (Grail image only) MCP_PORT=8000 ./run-server.sh # fork a detached, independent localhost server gem and return MCP_WORKER_USER=McpReadOnly ./run-server.sh # ...every worker gem as a restricted GemStone user MCP_TOOLSETS="McpBrowsingToolset McpSearchToolset" ./run-server.sh # ...only these tools MCP_WORKER_CLASS=MyMcpServer ./run-server.sh # ...a subclass as the worker # ...the core tools PLUS the optional Python toolset. Naming it is what turns it on: installing # src/grail puts McpGrailToolset in the image, and nothing more. Same shape for your own toolset. MCP_TOOLSETS="McpBrowsingToolset McpExecutionToolset McpListingToolset McpMutationToolset \ McpSearchToolset McpSessionToolset McpTestingToolset McpGrailToolset" \ MCP_GRAIL_DIR=/path/to/Grail ./run-server.sh MCP_TRACE=1 ./run-server.sh # ...logging every message a client sends (see Message trace) ./run-auth-server.sh # ...the OAuth/OIDC network-facing server (McpAuthRouter) ``` `install.sh` and the `run-*.sh` scripts use topaz; set `GEMSTONE`, `GS_STONE`, `GS_USER`, `GS_PASS` to match your environment — and read **Environment** below before assuming those four are enough, because on many machines they are not. `install.sh` files the code in with topaz, one group at a time: `src/core` and `src/tests` always, `src/auth` when the image can compile it, and `src/grail` on `--grail` (or `MCP_WITH_GRAIL=1`). The two optional groups are selected differently on purpose. Loading `McpAuthRouter` is inert — nothing instantiates it until you fork one with `run-auth-server.sh` — so it can be detected rather than asked about, and `install.sh` probes the image for `JsonWebToken` to decide. Loading `McpGrailToolset` is inert too, deliberately: it is **not** in the default tool surface (`McpServer class>>defaultToolsetNames`), so filing it in adds a class to the image and changes no server. It nonetheless stays **opt-in at install**, because the probe that works for auth does not work here — `ModuleAst` being present does not mean the Grail these tools were written against, and a group that cannot compile takes the file-in down with it. Use `--auth` to turn a skip into an error, `--no-auth` to force one; `--check` reports the decision without installing anything. `run-server.sh` builds a base `McpRouter` instance and calls its `forkOnPort:` (`run-auth-server.sh` builds an OIDC-configured `McpAuthRouter` — resource-server config as code, no commit), which launches a detached, independent front-end gem and returns; stop it with `./stop-server.sh` (by port), or the `System stopSession: <id>` / `kill <pid>` line it prints. The front end resolves the tool surface per session: `MCP_TOOLSETS` if it is set, otherwise the core seven and nothing else. **No toolset joins that surface by being loaded** — an optional one, this project's `McpGrailToolset` included, is served only by a router that names it. That keeps having a toolset in the image and running it as two decisions, which matters when the toolset carries a dependency of its own: Grail's tools read the checkout on disk that `MCP_GRAIL_DIR` names. ### Environment Every script here sources `gs-env.sh`, which resolves the environment and refuses to continue on a misconfigured one. `--check` runs that resolution and reports without doing anything else; it is the first thing to run on a machine you have not installed on before. ``` $ ./install.sh --check product /opt/gemstone/GemStone64Bit3.7.5-x86_64.Linux global dir /opt/gemstone servers visible to this client: Status Version Owner Pid Port Started Type Name OK 3.7.5 gsadmin 96453 65166 Aug 23 12:31 Netldi gs64ldi OK 3.7.5 gsadmin 60042 56820 Aug 20 08:53 Stone gs64stone OK: environment looks usable for gs64stone. ``` **`GEMSTONE_GLOBAL_DIR` is the variable that decides whether anything works**, and it is the one the old four-variable advice left out. Get it wrong and every script fails at `login` with: ``` could not find server 'gs64stone' on host 'somehost' because service not found, getaddrinfo failed, EAI error 8 ... Number: 4065 ``` That message names `getaddrinfo`, so it reads like a DNS or `/etc/services` problem. It is not. With no `/etc/services` entries a stone and a netldi each bind an **ephemeral** port and record it in `$GEMSTONE_GLOBAL_DIR/locks/<name>..LCK`; clients read those lock files. A client pointed at a different `GEMSTONE_GLOBAL_DIR` than the stone was *started* with finds no lock file and falls back to a hostname/service lookup, which fails. The product's built-in default is `/opt/gemstone` (then `/usr/gemstone`), so any installation keeping its locks elsewhere must tell its clients where. `.setenv.example` is a starting point: copy it to `.setenv` (git-ignored) and edit it for your machine. Most of it is optional — `gs-env.sh` discovers it rather than making you guess: it asks `gslist` under each candidate and uses the one where the running servers actually are, saying so when it has to correct or supply a value. `gslist` is the authority here — it reads the same lock files the GCI client does. Do **not** reach for `/etc/services`. Registering a stone or netldi there is unnecessary once `GEMSTONE_GLOBAL_DIR` is right, and it is a trap: netldi binds the port named in `/etc/services` only if it is **restarted** after the entry exists, so an entry added to a running system is stale by construction and points at a port nothing is listening on. **Which scripts need a netldi.** `install.sh` talks only to the stone, so it runs fine on a host with no netldi at all. The `run-*.sh` scripts need one — not because of how they log in, but because `McpRouter>>forkOnPort:` and every per-client worker create a `GsTsExternalSession`, and netldi is what forks those gems. `run-unit-tests.sh` needs one too: four of its suites spawn a real worker gem (`McpExternalSessionTest`, `McpTransactionTest` and `McpWorkerDeadlineTest` always, `McpAuthTest` where the auth group is installed). It asks the image which are present rather than insisting unconditionally. Each script checks for what it actually needs, and says which is missing. **Linked vs RPC.** These scripts run `topaz -l` (linked). That is deliberate, and it is not the cause of the error above: a linked login resolves the stone through the same lock files, so it needs `GEMSTONE_GLOBAL_DIR` and nothing else — no netldi, no NRS, no service entries. Dropping `-l` routes the login through netldi instead, which works equally well once `GEMSTONE_GLOBAL_DIR` is right, but it would make `install.sh` depend on a netldi it otherwise has no use for. ## Transport A single e