Skip to content
Closed
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
2 changes: 2 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
12 changes: 11 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,14 @@ 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
# Commit and build date shown in the footer and the startup log. A local
# build leaves them empty; the process then reports commit "dev".
ARG APP_COMMIT=
ENV APP_COMMIT=$APP_COMMIT
ARG APP_BUILD_DATE=
ENV APP_BUILD_DATE=$APP_BUILD_DATE
# OCI version label only. The process reads its version from package.json.
ARG APP_VERSION=

RUN addgroup --system --gid 1001 appuser && \
adduser --system --uid 1001 appuser
Expand Down Expand Up @@ -159,7 +167,9 @@ LABEL org.opencontainers.image.title="AnythingMCP" \
org.opencontainers.image.source="https://github.com/HelpCode-ai/anythingmcp" \
org.opencontainers.image.documentation="https://github.com/HelpCode-ai/anythingmcp#readme" \
org.opencontainers.image.vendor="helpcode.ai GmbH" \
org.opencontainers.image.licenses="AGPL-3.0-only"
org.opencontainers.image.licenses="AGPL-3.0-only" \
org.opencontainers.image.revision="${APP_COMMIT}" \
org.opencontainers.image.version="${APP_VERSION}"

USER appuser
EXPOSE 3000 4000
Expand Down
15 changes: 15 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,21 @@ curl http://localhost:4000/health

The container runs `prisma migrate deploy` on startup, then launches both backend and frontend.

### Build provenance

The running process reads its version from `packages/backend/package.json`. The git commit and the build date come from image build arguments. A local `docker compose build` passes neither, so the footer and the startup log show commit `dev` and `built unknown`.

Self-hosted and Cloud both show the same values. Cloud runs the published image, so the commit is the one that image was built from. Either way, signed-in users see them in the footer and admins can copy them from Settings → Administration. `GET /api/version` requires a JWT. The public `/health/server-info` route does not include the version.

```bash
docker build \
--build-arg APP_COMMIT=$(git rev-parse HEAD) \
--build-arg APP_BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
.
```

`APP_COMMIT` has to be the full 40-character SHA. The footer links that SHA to the commit and shows the first seven characters; `dev` stays plain text. `APP_VERSION` is optional and only fills the `org.opencontainers.image.version` label. The publish workflow sets `APP_COMMIT` and `APP_BUILD_DATE`.

### Without Docker

```bash
Expand Down
6 changes: 4 additions & 2 deletions docs/operations/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ What leaves the process is an allowlist (`packages/backend/src/common/sentry-scr
|---|---|---|
| `SENTRY_DSN` | unset | enables reporting for the process that reads it |
| `SENTRY_ENVIRONMENT` | `NODE_ENV` | environment tag |
| `SENTRY_RELEASE` | image build | release tag; the published image sets it to the commit |
| `SENTRY_RELEASE` | image build | release tag; the published image sets it to the commit. Unset, the backend reports `APP_VERSION` |
| `SENTRY_TRACES_SAMPLE_RATE` | 0 | backend (or Next.js server) transactions |
| `SENTRY_PROFILES_SAMPLE_RATE` | 0 | backend CPU profiling |
| `SENTRY_BROWSER_TRACES_SAMPLE_RATE` | 0 | browser transactions (frontend container) |
Expand All @@ -64,9 +64,11 @@ Service identity:
| Env | Default |
|---|---|
| `OTEL_SERVICE_NAME` | `anythingmcp-backend` |
| `OTEL_SERVICE_VERSION` | `npm_package_version` |
| `OTEL_SERVICE_VERSION` | `APP_VERSION` |
| `OTEL_DEPLOYMENT_ENVIRONMENT` | `NODE_ENV` |

`service.version` is `OTEL_SERVICE_VERSION` when that is set, and otherwise `APP_VERSION` (the version in the backend `package.json`). Inside the image `npm_package_version` is unset, because the backend starts with `node dist/src/main.js`.

Auth: standard `OTEL_EXPORTER_OTLP_HEADERS` (e.g. `authorization=Bearer …`).

Suggested collectors:
Expand Down
101 changes: 101 additions & 0 deletions packages/backend/src/common/app-version.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
APP_BUILD_DATE,
APP_COMMIT,
APP_VERSION,
formatVersionLogLine,
readAppBuildDate,
readAppCommit,
readAppVersion,
shortCommit,
} from './app-version';

const FULL_SHA = '3bd9f8b0a1c2d3e4f567890abcdef1234567890a';

describe('app-version', () => {
it('reads the backend package.json version', () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8'),
) as { version: string };
expect(APP_VERSION).toBe(pkg.version);
expect(readAppVersion()).toBe(pkg.version);
});

it('falls back to 0.0.0 when no package.json is found', () => {
expect(readAppVersion('/definitely-missing-amcp/a/b')).toBe('0.0.0');
});

describe('readAppCommit', () => {
it('falls back to dev when unset', () => {
expect(readAppCommit({})).toBe('dev');
});

it('prefers APP_COMMIT over SENTRY_RELEASE', () => {
expect(readAppCommit({ APP_COMMIT: FULL_SHA, SENTRY_RELEASE: 'other' })).toBe(FULL_SHA);
});

it('uses SENTRY_RELEASE when APP_COMMIT is unset', () => {
expect(readAppCommit({ SENTRY_RELEASE: FULL_SHA })).toBe(FULL_SHA);
});

it('treats an empty APP_COMMIT as unset', () => {
expect(readAppCommit({ APP_COMMIT: '', SENTRY_RELEASE: FULL_SHA })).toBe(FULL_SHA);
expect(readAppCommit({ APP_COMMIT: '' })).toBe('dev');
});
});

describe('readAppBuildDate', () => {
it('defaults to null', () => {
expect(readAppBuildDate({})).toBeNull();
});

it('reads APP_BUILD_DATE', () => {
expect(readAppBuildDate({ APP_BUILD_DATE: '2026-10-04' })).toBe('2026-10-04');
});

it('treats an empty value as unset', () => {
expect(readAppBuildDate({ APP_BUILD_DATE: '' })).toBeNull();
});
});

it('exports the values read from the process environment', () => {
expect(APP_COMMIT).toBe(readAppCommit());
expect(APP_BUILD_DATE).toBe(readAppBuildDate());
});

describe('shortCommit', () => {
it('shortens a 40-hex SHA to seven characters', () => {
expect(shortCommit(FULL_SHA)).toBe('3bd9f8b');
});

it('leaves dev and an already-short value unchanged', () => {
expect(shortCommit('dev')).toBe('dev');
expect(shortCommit('3bd9f8b')).toBe('3bd9f8b');
});
});

describe('formatVersionLogLine', () => {
it('prints the short SHA and the build date', () => {
expect(
formatVersionLogLine({
version: '0.18.0',
commit: FULL_SHA,
buildDate: '2026-10-04',
deploymentMode: 'self-hosted',
}),
).toBe('AnythingMCP v0.18.0 (commit 3bd9f8b, built 2026-10-04, mode self-hosted)');
});

it('prints dev and built unknown when the image has no provenance', () => {
expect(
formatVersionLogLine({
version: '0.24.0',
commit: 'dev',
buildDate: null,
deploymentMode: 'cloud',
}),
).toBe('AnythingMCP v0.24.0 (commit dev, built unknown, mode cloud)');
});
});
});
72 changes: 72 additions & 0 deletions packages/backend/src/common/app-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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/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.
*/
export function readAppVersion(startDir: string = __dirname): string {
let dir = startDir;
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';
}

/**
* Commit baked into the image. `APP_COMMIT` wins; published images that only
* set `SENTRY_RELEASE` still report that sha. Source checkouts and local
* image builds have neither, and show `dev`.
*/
export function readAppCommit(env: NodeJS.ProcessEnv = process.env): string {
return env.APP_COMMIT || env.SENTRY_RELEASE || 'dev';
}

/** Set by the image build. Local builds leave it unset. */
export function readAppBuildDate(env: NodeJS.ProcessEnv = process.env): string | null {
return env.APP_BUILD_DATE || null;
}

export const APP_VERSION: string = readAppVersion();
export const APP_COMMIT: string = readAppCommit();
export const APP_BUILD_DATE: string | null = readAppBuildDate();

/** Seven characters of a 40-hex SHA. `dev` and an already-short value stay as written. */
export function shortCommit(commit: string): string {
return /^[0-9a-f]{40}$/.test(commit) ? commit.slice(0, 7) : commit;
}

/**
* Startup line. The commit is shortened for the log; `GET /api/version` keeps
* the full value so the footer can link to it.
*/
export function formatVersionLogLine(input: {
version: string;
commit: string;
buildDate: string | null;
deploymentMode: string;
}): string {
const built = input.buildDate || 'unknown';
return (
`AnythingMCP v${input.version} ` +
`(commit ${shortCommit(input.commit)}, built ${built}, mode ${input.deploymentMode})`
);
}
5 changes: 5 additions & 0 deletions packages/backend/src/health/health.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export class HealthController {
private readonly deployment: DeploymentService,
) {}

/**
* Public. Version and commit are intentionally absent: a scanner should
* not learn the build from an unauthenticated probe. Signed-in clients
* use `GET /api/version`.
*/
@Get('server-info')
async getServerInfo() {
const authMode = this.configService.get<string>('MCP_AUTH_MODE') || 'none';
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/health/health.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HealthController } from './health.controller';
import { SentryVerifyController } from './sentry-verify.controller';
import { VersionController } from './version.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 {}
23 changes: 23 additions & 0 deletions packages/backend/src/health/version.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { AuthGuard } from '@nestjs/passport';
import { APP_BUILD_DATE, APP_COMMIT, APP_VERSION } from '../common/app-version';
import { DeploymentService } from '../common/deployment.service';
import { VersionController } from './version.controller';

describe('VersionController', () => {
it('requires a JWT', () => {
const guards = Reflect.getMetadata(GUARDS_METADATA, VersionController);
expect(guards).toEqual([AuthGuard('jwt')]);
});

it('returns the full commit and the deployment mode', () => {
const deployment = { mode: 'self-hosted' } as DeploymentService;
const body = new VersionController(deployment).getVersion();
expect(body).toEqual({
version: APP_VERSION,
commit: APP_COMMIT,
buildDate: APP_BUILD_DATE,
deploymentMode: 'self-hosted',
});
});
});
36 changes: 36 additions & 0 deletions packages/backend/src/health/version.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { APP_BUILD_DATE, APP_COMMIT, APP_VERSION } from '../common/app-version';
import { DeploymentService } from '../common/deployment.service';

/**
* Build identity for signed-in users.
*
* Kept off `/health/server-info`. That route is unauthenticated, and a
* version there is a free fingerprint for vulnerability scanners.
*/
@ApiTags('Version')
@ApiBearerAuth()
@UseGuards(AuthGuard('jwt'))
@Controller('api')
export class VersionController {
constructor(private readonly deployment: DeploymentService) {}

@Get('version')
@ApiOperation({ summary: 'Version, commit and deployment mode of this instance' })
getVersion(): {
version: string;
commit: string;
buildDate: string | null;
deploymentMode: string;
} {
return {
version: APP_VERSION,
// Full SHA. The startup log and the footer shorten it for display.
commit: APP_COMMIT,
buildDate: APP_BUILD_DATE,
deploymentMode: this.deployment.mode,
};
}
}
3 changes: 2 additions & 1 deletion packages/backend/src/instrument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions packages/backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ import helmet from 'helmet';
import { AppModule } from './app.module';
import { mcpStrategy } from './mcp-server/mcp-strategy';
import { McpAuthExceptionFilter } from './auth/mcp-auth-exception.filter';
import {
APP_BUILD_DATE,
APP_COMMIT,
APP_VERSION,
formatVersionLogLine,
} from './common/app-version';
import { DeploymentService } from './common/deployment.service';
import { validateRequiredSecretsAtStartup } from './common/secrets.util';

async function bootstrap() {
Expand Down Expand Up @@ -206,6 +213,14 @@ async function bootstrap() {
});
}

logger.log(
formatVersionLogLine({
version: APP_VERSION,
commit: APP_COMMIT,
buildDate: APP_BUILD_DATE,
deploymentMode: app.get(DeploymentService).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`);
Expand Down
Loading
Loading