Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 21 additions & 13 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,25 +1,33 @@
# Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile
FROM node:lts-alpine
FROM node:lts-alpine AS builder

# Set working directory
WORKDIR /app

# Copy package files
COPY package*.json ./
RUN npm ci --ignore-scripts

# Install dependencies without running any scripts
RUN npm install --ignore-scripts

# Copy the rest of the source code
COPY . .

# Build the project
RUN npm run build
RUN npm prune --omit=dev --ignore-scripts

FROM node:lts-alpine

WORKDIR /app

COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/build ./build

# Expose default MCP port for streamable-http transport
EXPOSE 3001

# Default to streamable-http transport in Docker deployments
ENV STREAMABLE_HTTP_HOST=0.0.0.0 \
ENV NODE_ENV=production \
STREAMABLE_HTTP_HOST=0.0.0.0 \
STREAMABLE_HTTP_PORT=3001 \
OPIK_TOOLSETS=all
CMD ["node", "build/cli.js", "serve", "--transport", "streamable-http", "--port", "3001"]

HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -q -O /dev/null "http://127.0.0.1:${STREAMABLE_HTTP_PORT:-3001}/health" || exit 1

USER node

CMD ["node", "build/cli.js", "serve", "--transport", "streamable-http"]
37 changes: 37 additions & 0 deletions docs/streamable-http-transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ This document describes remote/self-hosted transport for `opik-mcp`.
## Endpoints

- `GET /health`
- `GET /healthz`
- `GET /ready`
- `GET /readyz`
- `GET /ping`
- `POST|GET|DELETE /mcp` (MCP Streamable HTTP)

## Auth and Tenant Routing
Expand Down Expand Up @@ -63,6 +67,39 @@ Health:
curl -s http://127.0.0.1:3001/health
```

Ping:

```bash
curl -s http://127.0.0.1:3001/ping
```

## Kubernetes Probes (Helm)

Recommended HTTP probes for Kubernetes deployments:

```yaml
livenessProbe:
httpGet:
path: /health
port: 3001
initialDelaySeconds: 10
periodSeconds: 10

readinessProbe:
httpGet:
path: /readyz
port: 3001
initialDelaySeconds: 5
periodSeconds: 10

startupProbe:
httpGet:
path: /healthz
port: 3001
failureThreshold: 30
periodSeconds: 5
```

Initialize (capture `mcp-session-id` response header):

```bash
Expand Down
27 changes: 27 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import { loadOpikResources } from './resources/opik-resources.js';
// Import configuration
import { loadConfig } from './config.js';
const config = loadConfig();
let activeTransport: { close?: () => Promise<void> | void } | null = null;
let isShuttingDown = false;

function toErrorMessage(error: unknown): string {
if (error instanceof Error) {
Expand Down Expand Up @@ -170,6 +172,7 @@ export async function main() {
}

// Connect the server to the transport
activeTransport = transport as { close?: () => Promise<void> | void };
logToFile('Connecting server to transport');
await server.connect(transport);

Expand All @@ -187,6 +190,24 @@ export async function main() {
logToFile('Main function completed successfully');
}

async function shutdown(signal: NodeJS.Signals): Promise<void> {
if (isShuttingDown) {
return;
}

isShuttingDown = true;
logToFile(`Received ${signal}; shutting down transport`);

try {
await Promise.resolve(activeTransport?.close?.());
logToFile('Transport closed successfully');
process.exit(0);
} catch (error) {
logToFile(`Error during shutdown: ${toErrorMessage(error)}`);
process.exit(1);
}
}

// Used by Smithery capability scanning when importing the module.
export function createSandboxServer() {
return server;
Expand All @@ -204,6 +225,12 @@ function shouldAutoStart(): boolean {

// Start the server only when this file is the process entrypoint.
if (shouldAutoStart()) {
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
process.on(signal, () => {
void shutdown(signal);
});
}

main().catch((error) => {
const message = toErrorMessage(error);
logToFile(`Error starting server: ${message}`);
Expand Down
6 changes: 5 additions & 1 deletion src/transports/streamable-http-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,11 +234,15 @@ export class StreamableHttpTransport implements Transport {
});
}

this.app.get('/health', (_req, res) => {
this.app.get(['/health', '/healthz', '/ready', '/readyz'], (_req, res) => {
const response: HealthResponse = { status: 'ok' };
res.json(response);
});

this.app.get('/ping', (_req, res) => {
res.status(200).type('text/plain').send('pong');
});

this.app.get(
[
'/.well-known/oauth-protected-resource',
Expand Down
20 changes: 16 additions & 4 deletions tests/transports/streamable-http-transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,26 @@ describe('StreamableHttpTransport', () => {
);
});

test('responds to health check', async () => {
test('responds to health and readiness aliases', async () => {
await transport.start();

const response = await fetch(`http://127.0.0.1:${currentPort}/health`);
const data = (await response.json()) as { status: string };
for (const path of ['/health', '/healthz', '/ready', '/readyz']) {
const response = await fetch(`http://127.0.0.1:${currentPort}${path}`);
const data = (await response.json()) as { status: string };

expect(response.status).toBe(200);
expect(data.status).toBe('ok');
}
});

test('responds to ping', async () => {
await transport.start();

const response = await fetch(`http://127.0.0.1:${currentPort}/ping`);
const data = await response.text();

expect(response.status).toBe(200);
expect(data.status).toBe('ok');
expect(data).toBe('pong');
});

test('rejects unauthenticated MCP requests', async () => {
Expand Down