Skip to content

Commit 3d3f819

Browse files
authored
feat: make Hub MCP self-hosting production-ready (#66)
* feat: make Hub MCP self-hosting production-ready * chore: additional test * chore: remove "local development server" from response * chore: address pr comments * chore: address pr comments * chore: fixed logging
1 parent 2fe37d9 commit 3d3f819

15 files changed

Lines changed: 2192 additions & 60 deletions

File tree

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/test_db?sslmode=disable
2727
# Default: 8080
2828
PORT=8080
2929

30+
# Public base URL advertised in runtime OpenAPI responses (optional but recommended for production)
31+
# Set this when Hub is exposed through ingress, TLS termination, a reverse proxy, or a path prefix.
32+
# Examples:
33+
# PUBLIC_BASE_URL=https://hub.example.com
34+
# PUBLIC_BASE_URL=https://example.com/hub
35+
# PUBLIC_BASE_URL=
36+
3037
# Logging level (optional)
3138
# Default: info
3239
# Valid values: debug, info, warn, error

Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ COPY --from=builder /go/bin/river /usr/local/bin/river
4343

4444
# Copy migration files
4545
COPY --from=builder /build/migrations /app/migrations
46+
COPY --from=builder /build/openapi.yaml /app/openapi.yaml
4647

4748
# Switch to non-root user
4849
USER app

Makefile

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: all test help tests tests-coverage check-coverage build build-api build-worker build-backfill-embeddings run run-api run-worker run-backfill-embeddings init-db clean docker-up docker-down docker-clean deps install-tools fmt lint lint-new lint-openapi dev-setup test-all test-unit schemathesis install-hooks migrate-status migrate-validate river-migrate
1+
.PHONY: all test help tests mcp-smoke tests-coverage check-coverage build build-api build-worker build-backfill-embeddings run run-api run-worker run-backfill-embeddings init-db clean docker-up docker-down docker-clean deps install-tools fmt lint lint-new lint-openapi dev-setup test-all test-unit schemathesis install-hooks migrate-status migrate-validate river-migrate
22

33
# Aliases for checkmake/lint expectations
44
all: build
@@ -19,6 +19,7 @@ help:
1919
@echo " make run-backfill-embeddings - Run the backfill-embeddings command (enqueues embedding jobs; loads .env)"
2020
@echo " make test-unit - Run unit tests (fast, no database)"
2121
@echo " make tests - Run integration tests"
22+
@echo " make mcp-smoke - Run the live MCP package smoke test (requires Hub env vars)"
2223
@echo " make test-all - Run all tests (unit + integration)"
2324
@echo " make tests-coverage - Run tests with coverage report"
2425
@echo " make check-coverage - Run tests and fail if coverage below COVERAGE_THRESHOLD (excludes cmd/api)"
@@ -49,6 +50,13 @@ tests:
4950
@echo "Running integration tests..."
5051
@(set -a && [ -f .env ] && . ./.env && set +a; go test ./tests/... -v -timeout 120s)
5152

53+
# Run the opt-in live MCP package smoke test.
54+
# Requires HUB_API_KEY, FORMBRICKS_HUB_BASE_URL (or HUB_BASE_URL), and either
55+
# HUB_MCP_PACKAGE or HUB_MCP_COMMAND/HUB_MCP_ARGS.
56+
mcp-smoke:
57+
@echo "Running MCP package smoke test..."
58+
@(set -a && [ -f .env ] && . ./.env && set +a; RUN_MCP_SMOKE_TEST=1 go test ./tests -run TestMCPPackageSmoke -count=1 -v -timeout 120s)
59+
5260
# Run unit tests (fast, no database required)
5361
test-unit:
5462
@echo "Running unit tests..."

charts/hub/templates/NOTES.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ Required configuration
77
- DATABASE_URL (Secret): PostgreSQL connection string (requires pgvector extension)
88
- API_KEY (Secret): protects all /v1/* endpoints
99

10+
Recommended production configuration
11+
------------------------------------
12+
- PUBLIC_BASE_URL (ConfigMap/env): set this when Hub is exposed through ingress, TLS termination,
13+
or a reverse-proxy path prefix so /openapi.yaml and /openapi.json advertise the correct public URL.
14+
1015
Secret management options (choose one):
1116
1) Chart-managed Secret:
1217
values.secrets.create=true and set:

charts/hub/values.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ config:
104104
existingConfigMap: ""
105105
data:
106106
PORT: "8080"
107+
# Recommended for production when Hub is exposed through ingress, TLS termination,
108+
# or a reverse-proxy path prefix. Example: https://hub.example.com or
109+
# https://example.com/hub
110+
PUBLIC_BASE_URL: ""
107111
LOG_LEVEL: "info"
108112
SHUTDOWN_TIMEOUT_SECONDS: "30"
109113
MESSAGE_PUBLISHER_QUEUE_MAX_SIZE: "16384"

cmd/api/app.go

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,10 @@ var (
4646
"google-gemini requires EMBEDDING_GOOGLE_CLOUD_PROJECT and EMBEDDING_GOOGLE_CLOUD_LOCATION")
4747
)
4848

49-
const riverQueueDepthInterval = 15 * time.Second
49+
const (
50+
riverQueueDepthInterval = 15 * time.Second
51+
startupCleanupTimeout = 5 * time.Second
52+
)
5053

5154
// embeddingProviderAndModel returns (provider, model) when embeddings are enabled: both EMBEDDING_PROVIDER
5255
// and EMBEDDING_MODEL must be set and the provider must be supported. Otherwise returns ("", "") so no
@@ -254,6 +257,8 @@ func NewApp(cfg *config.Config, db *pgxpool.Pool) (*App, error) {
254257
feedbackRecordsService, embeddingsRepo, embeddingMetrics,
255258
metrics, riverWorkers)
256259
if err != nil {
260+
cleanupNewAppStartupFailure(context.Background(), messageManager, nil, tracerProvider, meterProvider)
261+
257262
if errors.Is(err, service.ErrEmbeddingProviderAPIKey) {
258263
return nil, fmt.Errorf("%w: %s", errEmbeddingProviderAPIKeyRequired, embeddingProviderName)
259264
}
@@ -275,19 +280,7 @@ func NewApp(cfg *config.Config, db *pgxpool.Pool) (*App, error) {
275280
Workers: riverWorkers,
276281
})
277282
if err != nil {
278-
messageManager.Shutdown()
279-
280-
if tracerProvider != nil {
281-
if err2 := observability.ShutdownTracerProvider(context.Background(), tracerProvider); err2 != nil {
282-
slog.Error("shutdown tracer provider after River client error", "error", err2)
283-
}
284-
}
285-
286-
if meterProvider != nil {
287-
if err2 := observability.ShutdownMeterProvider(context.Background(), meterProvider); err2 != nil {
288-
slog.Error("shutdown meter provider after River client error", "error", err2)
289-
}
290-
}
283+
cleanupNewAppStartupFailure(context.Background(), messageManager, nil, tracerProvider, meterProvider)
291284

292285
return nil, fmt.Errorf("create River client: %w", err)
293286
}
@@ -327,8 +320,15 @@ func NewApp(cfg *config.Config, db *pgxpool.Pool) (*App, error) {
327320
feedbackRecordsHandler := handlers.NewFeedbackRecordsHandler(feedbackRecordsService)
328321
healthHandler := handlers.NewHealthHandler()
329322

323+
openapiHandler, err := handlers.NewOpenAPIHandler(handlers.ResolveOpenAPISpecPath(), cfg.Server.PublicBaseURL)
324+
if err != nil {
325+
cleanupNewAppStartupFailure(context.Background(), messageManager, riverClient, tracerProvider, meterProvider)
326+
327+
return nil, fmt.Errorf("create openapi handler: %w", err)
328+
}
329+
330330
server := newHTTPServer(
331-
cfg, healthHandler, feedbackRecordsHandler, webhooksHandler, searchHandler,
331+
cfg, healthHandler, openapiHandler, feedbackRecordsHandler, webhooksHandler, searchHandler,
332332
meterProvider, tracerProvider,
333333
)
334334

@@ -344,11 +344,12 @@ func NewApp(cfg *config.Config, db *pgxpool.Pool) (*App, error) {
344344
}, nil
345345
}
346346

347-
// newHTTPServer builds the HTTP server and muxes (no auth on /health, API key on /v1/).
347+
// newHTTPServer builds the HTTP server and muxes (no auth on /health or /openapi.*, API key on /v1/).
348348
// Handler chain: RequestID -> otelhttp(Logging(mux)) so access logs get trace_id/span_id from context.
349349
func newHTTPServer(
350350
cfg *config.Config,
351351
health *handlers.HealthHandler,
352+
openapi *handlers.OpenAPIHandler,
352353
feedback *handlers.FeedbackRecordsHandler,
353354
webhooks *handlers.WebhooksHandler,
354355
search *handlers.SearchHandler,
@@ -357,6 +358,8 @@ func newHTTPServer(
357358
) *http.Server {
358359
public := http.NewServeMux()
359360
public.HandleFunc("GET /health", health.Check)
361+
public.HandleFunc("GET /openapi.yaml", openapi.YAML)
362+
public.HandleFunc("GET /openapi.json", openapi.JSON)
360363

361364
protected := http.NewServeMux()
362365
protected.HandleFunc("POST /v1/feedback-records", feedback.Create)
@@ -500,6 +503,31 @@ func shutdownObservability(ctx context.Context, tracer *sdktrace.TracerProvider,
500503
return first
501504
}
502505

506+
func cleanupNewAppStartupFailure(
507+
ctx context.Context,
508+
messageManager *service.MessagePublisherManager,
509+
riverClient *river.Client[pgx.Tx],
510+
tracerProvider *sdktrace.TracerProvider,
511+
meterProvider *sdkmetric.MeterProvider,
512+
) {
513+
cleanupCtx, cancel := context.WithTimeout(ctx, startupCleanupTimeout)
514+
defer cancel()
515+
516+
if messageManager != nil {
517+
messageManager.Shutdown()
518+
}
519+
520+
if riverClient != nil {
521+
if err := riverClient.Stop(cleanupCtx); err != nil {
522+
slog.Error("river stop after startup error", "error", err)
523+
}
524+
}
525+
526+
if err := shutdownObservability(cleanupCtx, tracerProvider, meterProvider); err != nil {
527+
slog.Error("shutdown observability after startup error", "error", err)
528+
}
529+
}
530+
503531
// Shutdown stops the server, message publisher, and River in order. Call after Run returns.
504532
// Observability is shut down once via defer; its error is returned only when server and River shut down successfully.
505533
func (a *App) Shutdown(ctx context.Context) (err error) {

0 commit comments

Comments
 (0)