- name
- create-evenfire-mcp-server
- description
- Build, test, and package an MCP (Model Context Protocol) server for Evenfire so it can live in the org catalog, install on a cluster, and be used by the chat agent. Covers the two server shapes (a local image you build, or a remote wrapper around a hosted endpoint), the streamableHttp/stdio server code contract, Dockerfile hardening, the registry.json schema, the naming rule, local handshake testing, how the platform deploys it (direct vs stdio-bridge vs egress-proxy), and how to attach it to a chat agent via a Context. Use when creating, changing, testing, or onboarding an MCP server/connector.
# Building an Evenfire MCP server
> **Verified against Evenfire `dev` at commit `21d9a7d5d` (2026-08-11).**
> `git describe`: `v0.3.0-915-g21d9a7d5d`. CRD API `clerum.io/v1alpha1`,
> `clerum-crds` chart `0.7.0`. Field names, enums, endpoints, and commands below
> match that revision. See [VERSIONS.md](../VERSIONS.md).
An MCP server exposes tools (and optionally resources/prompts) over the Model
Context Protocol. On Evenfire it reaches the chat agent through `mcp-host`, which
discovers servers a Context allows and prefixes every tool as
`serverName__toolName` (double underscore). A server is packaged as one directory
carrying a `registry.json` (and, for a local image, source + a Dockerfile).
Everything here is verified against the platform code
(`host-context-controller`, `mcp-host`, `control-api`, the `clerum.io` McpServer
and Context CRDs) and the shipped server collection. To publish what you build,
use the `publish-evenfire-plugin` skill. If instead you want the MCP server to
ship inside a larger UI plugin, add it as a `transport` workload with the
`create-evenfire-plugin` skill.
## 1. Pick the shape
| Shape | When | Directory contents | transport |
|---|---|---|---|
| Local, own code | you write the tools (call a vendor REST API, a chain RPC, etc.) | `src/`, `package.json`, `Dockerfile`, `registry.json` | `streamableHttp` |
| Local, reference wrapper (Node) | a thin image around an upstream stdio MCP server | `Dockerfile`, `registry.json` (no `src`) | `stdio` + a `command[]` |
| Local, reference wrapper (Python) | an upstream Python stdio server exposed over HTTP via `supergateway` | `Dockerfile`, `registry.json` | `streamableHttp` (no `command`) |
| Remote wrapper | the vendor already hosts a stable MCP endpoint | JUST `registry.json` (no image) | `streamableHttp` (never `stdio`) |
Prefer a remote wrapper whenever the vendor hosts a stable endpoint: nothing to
build or maintain, Evenfire fronts it with an nginx egress proxy. Write your own
code only when there is no good upstream. Name the directory `mcp-<vendor>` for a
local server, `mcp-<vendor>-remote` for a remote wrapper.
## 2. The own-code server contract (local, streamableHttp)
The server MUST listen on `0.0.0.0:PORT` (default 3000) and serve MCP over
StreamableHTTP at `/mcp`. Binding `127.0.0.1` makes the pod fail its probe. A
minimal, verified `src/index.ts`:
```typescript
import express from 'express'
import { randomUUID } from 'node:crypto'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import { z } from 'zod'
const PORT = Number(process.env.PORT ?? 3000)
const API_KEY = process.env.EXAMPLE_API_KEY // must equal a credentialSchema key name
function buildServer(): McpServer {
const server = new McpServer({ name: 'mcp-example', version: '1.0.0' })
server.tool(
'get_thing',
'Fetch a thing by id from the Example API.',
{ id: z.string().describe('the thing id') },
async ({ id }) => {
const res = await fetch(`https://api.example.com/things/${id}`, {
headers: { authorization: `Bearer ${API_KEY}` },
})
if (!res.ok) return { content: [{ type: 'text', text: `error ${res.status}` }], isError: true }
return { content: [{ type: 'text', text: JSON.stringify(await res.json()) }] }
},
)
return server
}
const app = express()
app.use(express.json())
const transports = new Map<string, StreamableHTTPServerTransport>()
app.post('/mcp', async (req, res) => {
const sid = req.header('mcp-session-id')
let transport = sid ? transports.get(sid) : undefined
if (!transport) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: randomUUID,
onsessioninitialized: (id) => transports.set(id, transport!),
})
await buildServer().connect(transport)
}
await transport.handleRequest(req, res, req.body)
})
app.get('/mcp', async (req, res) => {
const t = transports.get(req.header('mcp-session-id') ?? '')
if (!t) return res.status(400).end()
await t.handleRequest(req, res)
})
app.delete('/mcp', async (req, res) => {
const t = transports.get(req.header('mcp-session-id') ?? '')
if (!t) return res.status(400).end()
await t.handleRequest(req, res)
})
app.get('/health', (_req, res) => res.json({ ok: true }))
app.listen(PORT, '0.0.0.0', () => console.log(`mcp-example on :${PORT}/mcp`))
```
Notes verified against the platform:
- The MCP session id travels in the `mcp-session-id` request header.
- `/health` is a convenience for humans and for stdio-bridge/remote deploys. For
a LOCAL streamableHttp server the platform readiness probe is a **tcpSocket**
probe on the port, not `httpGet /health`. What matters is that the process
opens a TCP listener on the declared port bound to `0.0.0.0`.
- A tool handler returns `{ content: [{ type: 'text', text }], isError? }`.
- Credential env-var names read by the code (here `EXAMPLE_API_KEY`) MUST match
the `credentialSchema.keys[].name` in `registry.json` exactly, or an
operator-entered secret never reaches the running server.
## 3. Dockerfile (own-code, hardened)
```dockerfile
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
FROM node:24-alpine
LABEL org.opencontainers.image.title="mcp-example" \
org.opencontainers.image.description="Example MCP server." \
org.opencontainers.image.source="https://github.com/<org>/<repo>" \
org.opencontainers.image.version="1.0.0" \
org.opencontainers.image.licenses="MIT"
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package.json ./
ENV PORT=3000
EXPOSE 3000
USER node
HEALTHCHECK --interval=30s --timeout=3s --start-period=25s --retries=3 \
CMD node -e "require('net').connect(3000,'127.0.0.1').on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))"
CMD ["node", "dist/index.js"]
```
`USER node` maps to uid 1000, which matches the platform default `runAsUser`.
Build multi-arch the same way as any plugin image (see `publish-evenfire-plugin`).
### 3.1 Reference-server wrappers (no own code)
To ship an existing upstream MCP server, wrap it rather than rewrite it.
Node stdio server: a thin image that installs the upstream package with NO `CMD`,
plus a `stdio` `registry.json` whose `command[]` points at the installed
entrypoint under `/mcp-bin` (the stdio-bridge sidecar copies `/app` into
`/mcp-bin` and spawns it):
```dockerfile
FROM node:22-alpine
WORKDIR /app
RUN npm init -y >/dev/null 2>&1 && npm install @modelcontextprotocol/server-memory@<ver> && npm cache clean --force
USER node
# no CMD: the stdio-bridge sidecar launches the server (see registry.json command[])
```
```json
{
"name": "mcp-memory",
"version": "1.0.0",
"description": "Knowledge-graph memory (upstream reference server, stdio via the bridge).",
"category": "productivity",
"serverMode": "local",
"transport": "stdio",
"command": ["node", "/mcp-bin/node_modules/@modelcontextprotocol/server-memory/dist/index.js"],
"port": 3000,
"tools": ["create_entities", "read_graph", "search_nodes"]
}
```
Python stdio server: the Node stdio-bridge cannot spawn a Python process, so ship
`supergateway` in the image to expose the upstream stdio server over
StreamableHTTP, and use `transport: streamableHttp` with no `command`.
## 4. registry.json (the authoring artifact)
The directory name, `registry.json` `.name`, the pushed image repo segment, and
the scoped catalog name MUST all be the same string. This is a hard identity
constraint: control-api rejects a mismatch with a 422 at BOTH publish and install
(`imageRef repo ... must equal the entry name ... cross-org pull would be denied`),
and the collection's `publish.sh` fails even earlier, locally, on a `dir != name`
mismatch. The name must be a DNS-1123 label (`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`).
The npm `package.json` `.name` is unrelated and is NOT the catalog name.
Local own-code server:
```json
{
"name": "mcp-example",
"description": "Example MCP server: reads things from the Example API.",
"category": "data",
"tags": ["example", "api"],
"transport": "streamableHttp",
"port": 3000,
"egressSummary": { "domains": ["api.example.com"], "ports": [443] },
"tools": ["get_thing"],
"credentialSchema": {
"required": true,
"authType": "api-key",
"keys": [
{
"name": "EXAMPLE_API_KEY",
"label": "Example API Key",
"kind": "api-key",
"semanticType": "plain-string",
"description": "Get one at example.com/apikey."
}
]
}
}
```
Remote wrapper (no image, `.version` required here because there is no
`package.json`):
```json
{
"name": "mcp-example-remote",
"version": "1.0.0",
"description": "Example (hosted): evenfire fronts the vendor MCP endpoint via nginx egress proxy. Keyless.",
"category": "docs",
"tags": ["example", "remote", "wrapper"],
"serverMode": "remote",
"transport": "streamableHttp",
"port": 3000,
"tools": ["resolve", "query"],
"remoteEndpoints": [{ "url": "https://mcp.example.com/mcp" }],
"docsUrl": "https://example.com",
"egressSummary": { "domains": ["mcp.example.com"], "ports": [443] }
}
```
Field rules verified against the publisher and control-api:
- Local own-code: `serverMode` and `version` are optional (default `local`;
version comes from `package.json`). Own-code servers use `transport:
streamableHttp` and no `command`.
- Local stdio reference wrapper: no `src`/`package.json`; `.version` lives in
`registry.json`; `transport: stdio` and a non-empty `command[]` array (spawned
by the stdio-bridge sidecar). Omitting `command` on stdio, or adding `command`
on a non-stdio transport, fails publish.
- Remote: `serverMode: remote`, `version` required, `remoteEndpoints: [{url}]`
(HTTPS only, at least one), transport must NOT be `stdio`, optional
`authHeaders: [{header, valueTemplate}]` (the `${VAR}` placeholder must match a
`credentialSchema.keys[].name`). Remote may carry `authHeaders`; local may not.
- `egressSummary` has exactly two shapes. Exact-host:
`{ "domains": [hosts], "ports": [ints] }` (bare hostnames, no scheme/path/
wildcard/IP). Public-web: exactly `{ "wideCidr": true, "ports": [80, 443] }`
(partial or with domains is rejected). Omit it entirely when the server has no
external egress. Use public-web ONLY for genuinely dynamic public destinations
(a CDN or a runtime-resolved host), not to dodge a finite host list.
- `credentialSchema` is `{ required, authType, keys: [{name, label?, kind?,
semanticType?, description?}] }` or `null`/omitted for a keyless server. Only
`keys[].name` is load-bearing (it must equal the env var the code reads or the
`authHeaders` `${VAR}`). `authType` is free-form catalog metadata, not a
validated enum, do not rely on a fixed vocabulary.
- `tools[]` is catalog metadata for discovery. Keep it in sync with the tools the
server actually advertises (`mcp-smoke.sh`'s `tools/list` is the check). A
server may also expose MCP resources and prompts, not only tools; the platform
advertises whatever the server returns at runtime, `tools[]` is just the
catalog listing.
- Do not author `transport: sse`. It exists in the CRD but is not a supported
authoring path in the collection.
## 5. Test locally before publishing
Two levels, both runnable without a cluster:
1. Run the server and drive the MCP handshake against it. The bundled
`scripts/mcp-smoke.sh` does `initialize` -> `tools/list` -> optional
`tools/call` over StreamableHTTP and prints each response. Point it at a local
`npm run dev` (`http://localhost:3000/mcp`), a running container, or any live
remote endpoint:
```bash
Ver no GitHub