diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index f2486244..b435eba7 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -69,6 +69,8 @@ jobs: push: true build-args: | SENTRY_RELEASE=${{ github.sha }} + APP_COMMIT=${{ github.sha }} + APP_BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }} # Only the source map upload uses it; nothing Sentry-related that # decides where an instance reports is baked into the public image. secrets: | diff --git a/Dockerfile b/Dockerfile index e503ca13..59f6be47 100644 --- a/Dockerfile +++ b/Dockerfile @@ -120,6 +120,12 @@ ENV NEXT_TELEMETRY_DISABLED=1 # Release reported by backend and frontend when an operator sets SENTRY_DSN. ARG SENTRY_RELEASE= ENV SENTRY_RELEASE=$SENTRY_RELEASE +# Build provenance: commit and build date, shown in the UI and startup log. +ARG APP_VERSION= +ARG APP_COMMIT= +ENV APP_COMMIT=$APP_COMMIT +ARG APP_BUILD_DATE= +ENV APP_BUILD_DATE=$APP_BUILD_DATE RUN addgroup --system --gid 1001 appuser && \ adduser --system --uid 1001 appuser @@ -153,7 +159,9 @@ RUN chmod +x ./start.sh RUN mkdir -p /app/diagnostics && chown appuser:appuser /app/diagnostics ENV HEAP_SNAPSHOT_DIR=/app/diagnostics -LABEL org.opencontainers.image.title="AnythingMCP" \ +LABEL org.opencontainers.image.revision="${APP_COMMIT}" \ + org.opencontainers.image.version="${APP_VERSION}" \ + org.opencontainers.image.title="AnythingMCP" \ org.opencontainers.image.description="Convert any API into an MCP server — REST, SOAP, GraphQL, Database, MCP Bridge. Self-hosted MCP middleware." \ org.opencontainers.image.url="https://github.com/HelpCode-ai/anythingmcp" \ org.opencontainers.image.source="https://github.com/HelpCode-ai/anythingmcp" \ diff --git a/docs/deployment.md b/docs/deployment.md index fafbdd84..45b53f9b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -199,6 +199,18 @@ cd packages/frontend && npm start --- +### Build provenance + +When building the image yourself, pass the commit and build date so the UI and startup log show real values instead of `dev`: + +```bash +docker build \ + --build-arg APP_COMMIT=$(git rev-parse HEAD) \ + --build-arg APP_BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \ + -t anythingmcp:local . +``` + + ## Reverse Proxy & HTTPS ### Caddy (Recommended — Automatic SSL) diff --git a/docs/operations/observability.md b/docs/operations/observability.md index fbfb44c9..3e031402 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -64,7 +64,9 @@ Service identity: | Env | Default | |---|---| | `OTEL_SERVICE_NAME` | `anythingmcp-backend` | -| `OTEL_SERVICE_VERSION` | `npm_package_version` | +| `OTEL_SERVICE_VERSION` | `APP_VERSION` (from `package.json`) | + +The `service.version` value now comes from `APP_VERSION` (resolved from `package.json`), with `APP_COMMIT` and `APP_BUILD_DATE` available as build-time env vars. | `OTEL_DEPLOYMENT_ENVIRONMENT` | `NODE_ENV` | Auth: standard `OTEL_EXPORTER_OTLP_HEADERS` (e.g. `authorization=Bearer …`). @@ -193,3 +195,4 @@ When a customer reports a problem, the **first** thing to ask is "what's the `X- - No PII in error reports: the organization id is the only identifier, and no IP address is sent. If a self-hoster wants any of these enabled, every knob is a documented env var. If we add one that isn't, that's a bug — file it. + diff --git a/packages/backend/src/common/app-version.spec.ts b/packages/backend/src/common/app-version.spec.ts new file mode 100644 index 00000000..7523ce9a --- /dev/null +++ b/packages/backend/src/common/app-version.spec.ts @@ -0,0 +1,51 @@ +describe('app-version', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + jest.resetModules(); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + jest.resetModules(); + }); + + it('APP_COMMIT falls back to dev when unset', () => { + delete process.env.APP_COMMIT; + delete process.env.SENTRY_RELEASE; + const mod = require('./app-version'); + expect(mod.APP_COMMIT).toBe('dev'); + }); + + it('APP_COMMIT prefers APP_COMMIT over SENTRY_RELEASE', () => { + process.env.APP_COMMIT = 'abc1234'; + process.env.SENTRY_RELEASE = 'def5678'; + const mod = require('./app-version'); + expect(mod.APP_COMMIT).toBe('abc1234'); + }); + + it('APP_COMMIT uses SENTRY_RELEASE when APP_COMMIT is unset', () => { + delete process.env.APP_COMMIT; + process.env.SENTRY_RELEASE = 'def5678'; + const mod = require('./app-version'); + expect(mod.APP_COMMIT).toBe('def5678'); + }); + + it('APP_BUILD_DATE defaults to null', () => { + delete process.env.APP_BUILD_DATE; + const mod = require('./app-version'); + expect(mod.APP_BUILD_DATE).toBeNull(); + }); + + it('APP_BUILD_DATE reads from env', () => { + process.env.APP_BUILD_DATE = '2026-10-10T12:00:00Z'; + const mod = require('./app-version'); + expect(mod.APP_BUILD_DATE).toBe('2026-10-10T12:00:00Z'); + }); + + it('APP_VERSION is a non-empty string', () => { + const mod = require('./app-version'); + expect(mod.APP_VERSION).toBeTruthy(); + expect(typeof mod.APP_VERSION).toBe('string'); + }); +}); diff --git a/packages/backend/src/common/app-version.ts b/packages/backend/src/common/app-version.ts new file mode 100644 index 00000000..dcd3d608 --- /dev/null +++ b/packages/backend/src/common/app-version.ts @@ -0,0 +1,45 @@ +/** + * Single source of truth for the backend version, commit and build date. + * + * The version is resolved by walking up from this file rather than with a + * fixed relative path: tsc emits to `dist/src/...`, so the package.json sits + * one level further up in the build than in the source tree, and the runtime + * image moves it again (`/app/backend/package.json` against + * `/app/backend/dist/src/common/`). A hardcoded `../../package.json` is + * correct in exactly one of those three layouts, and being wrong throws + * MODULE_NOT_FOUND at import time — which takes the whole backend down for a + * string that appears in one handshake field. Hence also the try/catch: this + * can degrade, it cannot fail to boot. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +function readPackageVersion(): string { + let dir = __dirname; + for (let i = 0; i < 6; i++) { + try { + const pkg = JSON.parse( + readFileSync(join(dir, 'package.json'), 'utf8'), + ) as { name?: string; version?: string }; + if (pkg.name && pkg.version) return pkg.version; + } catch { + /* keep walking */ + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return '0.0.0'; +} + +/** Package version, read from the nearest package.json. */ +export const APP_VERSION: string = readPackageVersion(); + +/** Short commit SHA, or 'dev' when unset (local builds, source runs). */ +export const APP_COMMIT: string = + process.env.APP_COMMIT || process.env.SENTRY_RELEASE || 'dev'; + +/** Build date (ISO), or null when unset. */ +export const APP_BUILD_DATE: string | null = + process.env.APP_BUILD_DATE || null; diff --git a/packages/backend/src/health/health.module.ts b/packages/backend/src/health/health.module.ts index 42c720a2..4da36594 100644 --- a/packages/backend/src/health/health.module.ts +++ b/packages/backend/src/health/health.module.ts @@ -1,13 +1,14 @@ import { Module } from '@nestjs/common'; import { TerminusModule } from '@nestjs/terminus'; import { HealthController } from './health.controller'; +import { VersionController } from './version.controller'; import { SentryVerifyController } from './sentry-verify.controller'; import { UsersModule } from '../users/users.module'; import { ProcessVitalsService } from '../common/process-vitals.service'; @Module({ imports: [TerminusModule, UsersModule], - controllers: [HealthController, SentryVerifyController], + controllers: [HealthController, SentryVerifyController, VersionController], providers: [ProcessVitalsService], }) export class HealthModule {} diff --git a/packages/backend/src/health/version.controller.ts b/packages/backend/src/health/version.controller.ts new file mode 100644 index 00000000..9cc23da4 --- /dev/null +++ b/packages/backend/src/health/version.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { AuthGuard } from '@nestjs/passport'; +import { APP_VERSION, APP_COMMIT, APP_BUILD_DATE } from '../common/app-version'; + +@ApiTags('Version') +@Controller('api/version') +@UseGuards(AuthGuard('jwt')) +export class VersionController { + @Get() + @ApiBearerAuth() + getVersion() { + return { + version: APP_VERSION, + commit: APP_COMMIT, + buildDate: APP_BUILD_DATE, + deploymentMode: process.env.DEPLOYMENT_MODE || 'self-hosted', + }; + } +} diff --git a/packages/backend/src/instrument.ts b/packages/backend/src/instrument.ts index 3c478c57..d18334c8 100644 --- a/packages/backend/src/instrument.ts +++ b/packages/backend/src/instrument.ts @@ -11,6 +11,7 @@ * ./common/sentry-scrub.ts for the exact rules. */ import * as Sentry from '@sentry/nestjs'; +import { APP_VERSION } from './common/app-version'; import { scrubBreadcrumb, scrubEvent } from './common/sentry-scrub'; import { sampleRateFor } from './common/sentry-sampler'; @@ -28,7 +29,7 @@ if (dsn) { process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || 'development', - release: process.env.SENTRY_RELEASE || process.env.npm_package_version, + release: process.env.SENTRY_RELEASE || APP_VERSION, // Tracing is opt-in on top of error reporting because it adds overhead. // SENTRY_TRACES_SAMPLE_RATE applies to HTTP requests; MCP calls take diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index d0dfbe49..4fc08829 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -18,6 +18,7 @@ import { startTracing } from './tracing'; startTracing(); import { NestFactory } from '@nestjs/core'; +import { APP_VERSION, APP_COMMIT, APP_BUILD_DATE } from './common/app-version'; import { Logger, ValidationPipe } from '@nestjs/common'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { ConfigService } from '@nestjs/config'; @@ -206,6 +207,9 @@ async function bootstrap() { }); } + const mode = process.env.DEPLOYMENT_MODE || 'self-hosted'; + const shortCommit = /^[0-9a-f]{40}$/.test(APP_COMMIT) ? APP_COMMIT.slice(0, 7) : APP_COMMIT; + logger.log(`AnythingMCP v${APP_VERSION} (commit ${shortCommit}, built ${APP_BUILD_DATE ?? 'unknown'}, mode ${mode})`); logger.log(`AnythingMCP backend running on: http://localhost:${port}`); logger.log(`Swagger docs: http://localhost:${port}/api/docs`); logger.log(`MCP endpoint (global): http://localhost:${port}/mcp`); diff --git a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts index df070ae3..4cc08863 100644 --- a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts +++ b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts @@ -1,3 +1,4 @@ +import { APP_VERSION } from '../common/app-version'; import { Controller, Post, @@ -77,40 +78,6 @@ import { planServerResources, } from './mcp-resources'; import { jsonSchemaToZodShape, stripEnvVarParams } from './tool-schema.util'; -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; - -/** - * Backend version, reported as the demo server's version (was a hardcoded 1.0.0). - * - * Resolved by walking up from this file rather than with a fixed relative path: - * tsc emits to `dist/src/...`, so the package.json sits one level further up in - * the build than in the source tree, and the runtime image moves it again - * (`/app/backend/package.json` against `/app/backend/dist/src/mcp-server/`). - * A hardcoded `../../package.json` is correct in exactly one of those three - * layouts, and being wrong throws MODULE_NOT_FOUND at import time — which takes - * the whole backend down for a string that appears in one handshake field. - * Hence also the try/catch: this can degrade, it cannot fail to boot. - */ -function readAppVersion(): string { - let dir = __dirname; - for (let i = 0; i < 6; i++) { - try { - const pkg = JSON.parse( - readFileSync(join(dir, 'package.json'), 'utf8'), - ) as { name?: string; version?: string }; - if (pkg.name && pkg.version) return pkg.version; - } catch { - /* keep walking */ - } - const parent = dirname(dir); - if (parent === dir) break; - dir = parent; - } - return '0.0.0'; -} - -const APP_VERSION: string = readAppVersion(); function trimSlash(url: string | undefined): string { return (url || '').trim().replace(/\/+$/, ''); diff --git a/packages/backend/src/tracing.ts b/packages/backend/src/tracing.ts index e0a11fa5..ed6aef82 100644 --- a/packages/backend/src/tracing.ts +++ b/packages/backend/src/tracing.ts @@ -13,6 +13,7 @@ * (Sentry for error correlation, OTLP for an in-house collector). */ import { NodeSDK } from '@opentelemetry/sdk-node'; +import { APP_VERSION } from './common/app-version'; import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; import { resourceFromAttributes } from '@opentelemetry/resources'; @@ -32,8 +33,7 @@ export function startTracing(): void { [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || 'anythingmcp-backend', [ATTR_SERVICE_VERSION]: process.env.OTEL_SERVICE_VERSION || - process.env.npm_package_version || - '0.0.0', + APP_VERSION, [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: process.env.OTEL_DEPLOYMENT_ENVIRONMENT || process.env.NODE_ENV || diff --git a/packages/frontend/src/app/settings/admin/page.tsx b/packages/frontend/src/app/settings/admin/page.tsx index e947259c..7a69130a 100644 --- a/packages/frontend/src/app/settings/admin/page.tsx +++ b/packages/frontend/src/app/settings/admin/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; import { useAuth } from '@/lib/auth-context'; -import { adminSettings, type AlertWebhookInput } from '@/lib/api'; +import { adminSettings, versionApi, type AlertWebhookInput } from '@/lib/api'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { AppSelect } from '@/components/ui/select'; @@ -30,6 +30,8 @@ export default function SettingsAdminPage() { // Footer links const [footerLinks, setFooterLinks] = useState>([]); + const [versionInfo, setVersionInfo] = useState<{ version: string; commit: string; buildDate: string | null; deploymentMode: string } | null>(null); + const [versionMsg, setVersionMsg] = useState(''); const [footerMsg, setFooterMsg] = useState(''); // Connector-failure alert webhook @@ -63,6 +65,7 @@ export default function SettingsAdminPage() { }).catch(() => {}); adminSettings.getFooterLinks(token).then(setFooterLinks).catch(() => {}); + versionApi.get(token).then(setVersionInfo).catch(() => {}); adminSettings.getAlertWebhook(token).then((data) => { if (!('url' in data)) return; @@ -526,6 +529,41 @@ export default function SettingsAdminPage() { )} + + {/* About this instance */} + +

About this instance

+ {!versionInfo && ( +

Version info unavailable.

+ )} + {versionInfo && ( +
+

+ Version {versionInfo.version} · commit{' '} + + {/^[0-9a-f]{40}$/.test(versionInfo.commit) ? versionInfo.commit.slice(0, 7) : versionInfo.commit} + + {versionInfo.buildDate && <> · built {versionInfo.buildDate.slice(0, 10)}} +

+ + {versionMsg && ( +

+ {versionMsg} +

+ )} +
+ )} +
); } diff --git a/packages/frontend/src/components/footer.tsx b/packages/frontend/src/components/footer.tsx index db94c3fb..0c457b1a 100644 --- a/packages/frontend/src/components/footer.tsx +++ b/packages/frontend/src/components/footer.tsx @@ -2,7 +2,8 @@ import { useState, useEffect } from 'react'; import { useTheme } from '@/lib/theme-context'; -import { siteSettings } from '@/lib/api'; +import { siteSettings, versionApi } from '@/lib/api'; +import { useAuth } from '@/lib/auth-context'; function SunIcon({ size = 16 }: { size?: number }) { return ( @@ -37,14 +38,70 @@ interface FooterLink { url: string; } +interface VersionInfo { + version: string; + commit: string; +} + +/** Fetch the version once per session; every mount shares the same promise. */ +let versionPromise: Promise | null = null; + +function getVersionOnce(token: string): Promise { + if (!versionPromise) { + versionPromise = versionApi + .get(token) + .then((v) => ({ version: v.version, commit: v.commit })) + .catch(() => { + // A failed request must not poison the cache: a token that expired + // mid-session would hide the version until a full page reload. + versionPromise = null; + return null; + }); + } + return versionPromise; +} + +/** Shorten a 40-char SHA for display; pass anything else through. */ +function shortSha(c: string): string { + return /^[0-9a-f]{40}$/.test(c) ? c.slice(0, 7) : c; +} + +function VersionBadge({ version }: { version: VersionInfo }) { + const sha = shortSha(version.commit); + return ( + + v{version.version} ·{' '} + {/^[0-9a-f]{7}$/.test(sha) && version.commit.length === 40 ? ( + + {sha} + + ) : ( + sha + )} + + ); +} + export function Footer() { const { theme, setTheme } = useTheme(); + const { token } = useAuth(); const [links, setLinks] = useState([]); + const [version, setVersion] = useState<{ version: string; commit: string } | null>(null); useEffect(() => { siteSettings.footerLinks().then(setLinks).catch(() => {}); }, []); + useEffect(() => { + if (!token) return; + getVersionOnce(token).then(setVersion).catch(() => {}); + }, [token]); + const themeOptions = [ { value: 'light' as const, label: 'Light', icon: SunIcon }, { value: 'dark' as const, label: 'Dark', icon: MoonIcon }, @@ -60,6 +117,12 @@ export function Footer() { © {new Date().getFullYear()} AnythingMCP · Powered by helpcode.ai + {version && ( + <> + · + + + )} {links.length > 0 && ( <> · diff --git a/packages/frontend/src/lib/api.ts b/packages/frontend/src/lib/api.ts index ab2b4244..97295218 100644 --- a/packages/frontend/src/lib/api.ts +++ b/packages/frontend/src/lib/api.ts @@ -1013,6 +1013,11 @@ export const siteSettings = { request>('/api/site-settings/footer-links'), }; +export const versionApi = { + get: (token: string) => + request<{ version: string; commit: string; buildDate: string | null; deploymentMode: string }>('/api/version', { token, skipAutoLogout: true }), +}; + // Admin Settings export const adminSettings = { getSmtp: (token: string) =>