@kapio/mosaic-mcp
by kapio-hub
README.md
# Mosaic MCP Kit
`@kapio/mosaic-mcp` is the shared frame for MCP servers that sign people in through a [Mosaic](https://kapio.eu) instance. A server brings its tools; the kit brings everything around them:
- Streamable HTTP transport: `POST /mcp` (JSON or SSE), sessions via `Mcp-Session-Id`, CORS, `GET /health`
- Sign-in through Mosaic: Bearer access tokens checked against the instance JWKS (EdDSA, `iss`, `aud`, `exp`, `nbf`)
- OAuth discovery (RFC 9728): `/.well-known/oauth-protected-resource[/mcp]` and `WWW-Authenticate: Bearer resource_metadata="…"` on 401
- Static token ways for the transition and the emergency access, compared in constant time
- One JSON log line per `tools/call` with the person, never arguments, results or token values
No runtime dependency: `node:http` and `node:crypto`, Node 20 or newer.
## Use
```js
const { createMcpServer } = require('@kapio/mosaic-mcp');
const McpHandler = require('./mcp-handler');
let app;
try {
app = createMcpServer({
service: 'kapio-harvest-mcp',
version: '1.1.0',
protocol: '2025-11-25',
handler: new McpHandler(),
extraHealth: () => ({ harvest: true }),
tokens: [{ name: 'notzugang', value: process.env.MCP_ACCESS_TOKEN }]
});
} catch (err) {
console.error(`FATAL: ${err.message}`);
process.exit(1);
}
app.listen(process.env.PORT || 8080);
```
| Environment | Example | Required |
| ---------------- | ------------------------------------------ | -------- |
| `OAUTH_ISSUER` | `https://mosaic.kapio.eu/api/auth` | yes |
| `MCP_RESOURCE` | `https://harvest-mcp.kapio.eu/mcp` | yes |
| `ALLOWED_ORIGINS`| `https://claude.ai` (default) | no |
Without `OAUTH_ISSUER` or `MCP_RESOURCE`, `createMcpServer` throws `ConfigError` and the server must not start.
## API
### `createMcpServer(options)`
| Option | Meaning |
| ----------------- | -------------------------------------------------------------------------------------------------------- |
| `service` | name in health and log |
| `handler` | object with `handleRequest(request, sessionId, identity)`; returns the JSON-RPC response or `null` (202) |
| `version`, `protocol` | shown in `/health` |
| `extraHealth` | function returning service fields for `/health` |
| `tokens` | `[{ name, value, instances?, write? }]`, static token ways; entries without value are dropped |
| `allowQueryToken` | `true` accepts `?token=` for static tokens; Mosaic tokens are only accepted by header |
| `issuer`, `resource`, `allowedOrigins`, `jwks`, `log`, `env` | overrides, mainly for tests |
Returns `{ server, listen(port), close(), describe(), issuer, resource, metadataUrl }`. `describe()` gives `{ service, version, protocol, resource, issuer, metadataUrl }` for a registrar.
`identity` passed to the handler:
| Way | Shape |
| ------------- | ------------------------------------------------------------ |
| Mosaic token | `{ via: 'oauth', email, sub, azp, scope }` |
| static token | `{ via: '<token name>', scope: <the token entry without value>, query }` |
The server decides what `scope.instances` and `scope.write` allow; the kit only finds the entry.
### `verifyAccessToken(token, { issuer, resource, jwks, now? })`
Resolves `{ sub, email, azp, exp, scope }` or rejects with `TokenError` whose `reason` is one of `malformed`, `alg`, `kid`, `signature`, `iss`, `aud`, `exp`, `nbf`, `jwks`.
| Rule | Check |
| ----------- | ------------------------------------------------------------------------ |
| algorithm | `EdDSA` only, key chosen by `kid` |
| issuer | `iss` equals the one configured issuer exactly |
| audience | `aud` (string or array) contains the resource |
| lifetime | `exp` required; `exp` and `nbf` with 60 s leeway |
`jwks` is a client from `createJwksClient({ uri })` or a plain `{ keys }` document.
### `createJwksClient({ uri, fetch?, now?, ttlMs?, minRefreshMs?, fetchTimeoutMs? })`
Caches the key set for ten minutes. An unknown `kid` reloads once, at most once per minute, then counts as unknown. With the issuer unreachable or the fetch hanging (aborted after `fetchTimeoutMs`, default 5000 ms), cached keys keep working and no more than one reload attempt runs per minute.
### `matchToken(given, tokens)`
Returns the matching entry or `null`. Compares SHA-256 digests with `timingSafeEqual` against every entry without leaving early.
## Call log
One line on stdout per `tools/call`:
```json
{"ts":"2026-09-17T08:00:00.000Z","service":"kapio-harvest-mcp","event":"tools/call","tool":"harvest_list_projects","via":"oauth","ok":true,"email":"person@example.com","sub":"…","azp":"…"}
```
Static token ways log `query: true|false` and the user agent capped at 120 bytes instead of `email`/`sub`/`azp`.
## Transition from URL tokens
1. A server switches to the kit with `allowQueryToken: true` and keeps its static tokens.
2. When the call log shows seven days without `"query":true`, `allowQueryToken` goes in its own commit.
3. What stays: scoped tokens by header for automations without a person, and the emergency token by header (`via: "notzugang"`).
## Shared test cases
`fixtures/verify-cases.json` holds signed tokens with expected outcomes at a fixed clock. Other implementations (the Python agent template) run the same file and pin it by `fixtures/verify-cases.json.sha256`. Regenerate only on purpose with `npm run fixtures`.
## Release
A tag `v<version>` matching `package.json` publishes to npm with provenance (`.github/workflows/publish.yml`).
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues