Skip to content
Open
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
10 changes: 9 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" \
Expand Down
12 changes: 12 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion docs/operations/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 …`).
Expand Down Expand Up @@ -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.

51 changes: 51 additions & 0 deletions packages/backend/src/common/app-version.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
45 changes: 45 additions & 0 deletions packages/backend/src/common/app-version.ts
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 2 additions & 1 deletion packages/backend/src/health/health.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
20 changes: 20 additions & 0 deletions packages/backend/src/health/version.controller.ts
Original file line number Diff line number Diff line change
@@ -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',
};
}
}
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
4 changes: 4 additions & 0 deletions packages/backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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`);
Expand Down
35 changes: 1 addition & 34 deletions packages/backend/src/mcp-server/mcp-endpoint.controller.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { APP_VERSION } from '../common/app-version';
import {
Controller,
Post,
Expand Down Expand Up @@ -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(/\/+$/, '');
Expand Down
4 changes: 2 additions & 2 deletions packages/backend/src/tracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 ||
Expand Down
40 changes: 39 additions & 1 deletion packages/frontend/src/app/settings/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -30,6 +30,8 @@ export default function SettingsAdminPage() {

// Footer links
const [footerLinks, setFooterLinks] = useState<Array<{ label: string; url: string }>>([]);
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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -526,6 +529,41 @@ export default function SettingsAdminPage() {
)}
</div>
</Card>

{/* About this instance */}
<Card>
<h3 className="text-sm font-semibold text-[var(--text)] mb-2">About this instance</h3>
{!versionInfo && (
<p className="text-sm text-[var(--text-3)]">Version info unavailable.</p>
)}
{versionInfo && (
<div className="space-y-2">
<p className="text-sm text-[var(--text-2)]">
Version <span className="font-mono">{versionInfo.version}</span> · commit{' '}
<span className="font-mono">
{/^[0-9a-f]{40}$/.test(versionInfo.commit) ? versionInfo.commit.slice(0, 7) : versionInfo.commit}
</span>
{versionInfo.buildDate && <> · built {versionInfo.buildDate.slice(0, 10)}</>}
</p>
<Button
variant="secondary"
size="sm"
onClick={async () => {
const text = `AnythingMCP v${versionInfo.version} (commit ${versionInfo.commit}, built ${versionInfo.buildDate ?? 'unknown'}, mode ${versionInfo.deploymentMode})`;
const ok = await copyText(text);
setVersionMsg(ok ? 'Copied to clipboard' : 'Copy failed');
}}
>
Copy version info
</Button>
{versionMsg && (
<p className={`text-sm ${versionMsg === 'Copy failed' ? 'text-[var(--danger)]' : 'text-[var(--ok)]'}`}>
{versionMsg}
</p>
)}
</div>
)}
</Card>
</div>
);
}
Loading
Loading