Somewhere in your organisation there is an Oracle Forms application from 1998 that still runs payroll. Its logic lives in binary `.fmb` and `.pll` modules that exactly one program can open — Forms Builder — and the developer who wrote it retired years ago. Your AI assistant, which will happily read any Kotlin file you point it at, is completely blind to it.

That's an *input* problem, not a model problem. [Oracle Forms MCP](https://github.com/aoreshkov/oracle-forms-mcp) is an MCP server that solves it: point it at a directory of modules and it converts them to text, indexes them, and serves the pieces — blocks, items, triggers, program units, PL/SQL — as MCP tools and resources. Kotlin Multiplatform core, JVM server, Apache-2.0.

```text
You:  What does ORDERS.fmb do?
AI →  list_modules                 → ORDERS.fmb (NOT_CACHED), MAINMENU.mmb, UTILS.pll …
AI →  fetch_module ORDERS.fmb      → converted + indexed (2 blocks, 3 triggers, 3 program units)
AI →  get_module_overview ORDERS   → blocks, triggers, LOVs, record groups, windows, canvases …
You:  Show me the validation logic on the ORDERS block.
AI →  list_triggers block=ORDERS   → WHEN-VALIDATE-ITEM (on ORDER_ID), WHEN-VALIDATE-RECORD
AI →  get_trigger ORDERS WHEN-VALIDATE-ITEM  → the decoded PL/SQL body
You:  That validation is the legacy pre-2010 path — note it so we remember.
AI →  annotate_element ORDERS trigger WHEN-VALIDATE-ITEM kind=note "Legacy pre-2010 path" → saved
```

## The hard part is the front door, not the protocol

Writing the MCP layer took days. Getting *text* out of a `.fmb` took considerably longer, and it shaped everything above it.

Oracle ships the only converters — `frmf2xml` for `.fmb`/`.mmb`/`.olb`, `frmcmp_batch` for `.pll` — and they are proprietary, so nothing can bundle them. Three modes fall out of that, in strict precedence: a site-supplied `--convert-command`, then an `ORACLE_HOME` installation, then **copy-mode**, where pre-converted `orders_fmb.xml` / `utils.pld` files already sit next to the modules. Copy-mode is what makes the Docker image and the bundled sample forms work on a laptop with no Oracle anything.

The tools themselves are 1990s-era and behave like it. `frmf2xml` writes into the *process working directory*, so the server runs it with the output directory as its cwd rather than passing a path. Exit codes are unreliable, so success is judged by the output file existing, being non-empty, and being **newer than the invocation** — which has the amusing consequence that a wrapper script using `cp -p` to copy a pre-generated file is rejected, because it preserved the source's mtime.

The resulting XML is large and version-dependent, so parsing is a single streaming StAX pass with one firm rule: **the parser never fails on unknown vocabulary**. An element nobody has seen before is skipped generically rather than throwing. A parser that dies on an unfamiliar tag is worthless against thirty years of Forms releases.

## Designing the tool surface

Current MCP guidance converges on a few principles — one bounded context per server, a curated tool surface, bounded resources, reads cheap and writes deliberate. Here's how those landed in practice.

**Sixteen tools is more than "curated" likes.** I'll defend it anyway: they aren't sixteen peers. They're two verbs — `list_*` and `get_*` — over one noun space, plus a five-tool annotation layer. The server's `instructions` field states the entry sequence explicitly (`list_modules` → `fetch_module` → `get_module_overview` → drill down), which is what actually keeps a model from flailing. Tool *count* is a bad proxy; tool *shape* is the real thing.

**Every tool declares its behaviour.** All eleven read tools carry `readOnlyHint = true`, `destructiveHint = false`, `idempotentHint = true`, `openWorldHint = false` — a closed, local, side-effect-free domain. Clients that gate or auto-approve on annotations can do so correctly instead of guessing.

**Every tool declares an `outputSchema` and returns both shapes.** One DTO is serialized once and handed back twice: as `structuredContent` matching the schema, and as pretty JSON text for clients without structured-output support. DTO fields are all defaulted, so adding one is forward-compatible rather than a breaking change. A `ToolRegistrationTest` fails the build if any tool ships without a title, annotations, and a schema — the convention is enforced, not documented.

**Errors are content, not exceptions.** Expected failures — a bad argument, an un-fetched module, a conversion that died — come back as `isError` results whose text is written *for the model*: every message names the tool call that fixes the situation. "Module ORDERS is stale, call fetch_module" is a message an agent can act on. A stack trace is not.

**Token cost is a design constraint.** `search_source` paginates via `offset`/`nextOffset`. The list tools take `verbosity=concise|detailed`, defaulting to concise. And the index itself stays small by construction: PL/SQL bodies never live in `index.json` — they're extracted to `.sql` sidecars and referenced by line range, so `get_trigger` reads exactly one body instead of the model paying for a megabyte of JSON to find it.

## Annotations: letting the model write back

The part I'd build again first. Reading a form is only half the job — the other half is *remembering* what you worked out about it, because nobody fully understands these applications on the first pass.

So the server has a small write surface: `annotate_element` (notes, tags, summaries, classifications) and `relate_elements` (directed cross-references — this trigger *calls* that program unit). Three rules make it durable:

1. **Asserted, never derived.** Annotations live in their own store, not in the fingerprinted cache. Deleting the cache or re-fetching a module doesn't touch them.
2. **Keyed by identity, not position.** The key is module + kind + name + owner path — never a line range. Re-indexing moves line numbers; it doesn't move a trigger's name.
3. **Drift is flagged, never deleted.** If the source changed after a note was written, the note comes back marked `staleAgainstSource`. Silently dropping a colleague's reasoning because a file was edited is the wrong default in every direction.

The read tools surface an element's annotations inline, so knowledge accumulated in March shows up in August's session without anyone asking for it.

## Where the spec is going, and where Kotlin is

The [2026-07-28 specification](https://modelcontextprotocol.io/specification/2026-07-28) is a bigger jump than the version string suggests. The protocol core went **stateless**: the `initialize`/`initialized` handshake and `Mcp-Session-Id` are gone, each request carries its protocol version and client identity in `_meta`, and any request can hit any instance behind a load balancer. Server-initiated round trips are replaced by **MRTR** — a tool returns `input_required`, the client retries with `inputResponses`. `tools/list` and friends gained `ttlMs` and `cacheScope`. Tasks moved out of the experimental core into a formal `io.modelcontextprotocol/tasks` extension. And there's finally a deprecation policy, which immediately put Roots, Sampling, Logging and the legacy HTTP+SSE transport on a twelve-month clock.

Honest status: **this server speaks `2025-11-25`, and that is the newest the Kotlin SDK offers.** The Tier 1 SDKs (TypeScript, Python, Go, C#) shipped `2026-07-28`; Rust is in beta; the Kotlin SDK's `LATEST_PROTOCOL_VERSION` is still `2025-11-25` as of today. If you build MCP servers in Kotlin, that gap is the thing to plan around — not the thing to be surprised by.

The good news is that most of the migration is architecture I'd want regardless. The server holds **no per-session state**: everything durable is on disk, fingerprinted, and addressed by a stable key, which is precisely the shape a stateless core wants. Cacheable list results are a near-free win for a tool list that never changes. The one real casualty is the logging capability — server→client log forwarding is deprecated, so that plumbing is now on a countdown rather than a foundation.

## One protocol detail that will bite you

On stdio, **stdout is the protocol channel.** Any library that prints a banner, any stray `println`, any logger defaulting to console output corrupts the JSON-RPC stream, and the failure looks like a mysterious client-side parse error rather than "something printed." Here every log goes Kermit → SLF4J → Logback → **stderr**, and the routing is installed before the SDK constructs its first logger. Budget an afternoon for this on any stdio server; it is never the bug you expect.

## Try it

Claude Code, two lines and no clone:

```
/plugin marketplace add aoreshkov/oracle-forms-mcp
/plugin install oracle-forms@oracle-forms-mcp
```

Claude Desktop gets a one-click `.mcpb` bundle from the [latest release](https://github.com/aoreshkov/oracle-forms-mcp/releases/latest); everything else can run the GHCR image or the release zip, and it's listed on the [MCP Registry](https://registry.modelcontextprotocol.io) as `io.github.aoreshkov/oracle-forms-mcp`. The repo ships sample forms, so you can see the whole pipeline work before pointing it at anything real:

```
server --forms-dir sample-forms
```

**Source (Apache-2.0):** https://github.com/aoreshkov/oracle-forms-mcp — issues and Forms-XML edge cases especially welcome. If you have a `.fmb` this parser chokes on, I want it.

*Enjoyed this? I also wrote about [giving your AI agent the real sources of any Kotlin/Java library](real-sources-for-your-ai-agent.html).*
