
giantswarm/mcp-timescale
Developer toolsRead-only MCP server for TimescaleDB and PostgreSQL databases, acting on the caller's identity
Overview
Read-only MCP server for TimescaleDB and PostgreSQL databases that . An agent gets the catalog (schemas, tables, hypertables, chunks, continuous aggregates, background jobs) and a guarded SELECT surface; the server gets the person behind every call and refuses to run as nobody. Built on mark3labs/mcp-go, giantswarm/mcp-oauth (OAuth 2.1 resource server, token forwarding) and giantswarm/mcp-toolkit (logging, tracing, health, response cap and per-tool timeout middleware); jackc/pgx talks to PostgreSQL. Every release carries one binary per platform (mcp-timescale- - : linux, darwin and windows on amd64 and arm64) next to its cosign Sigstore bundle ( .bundle), the multi-arch image gsoci.azurecr.io/giantswarm/mcp-timescale: and the chart in the Giant Swarm catalog.
README
mcp-timescale
Read-only MCP server for TimescaleDB and
PostgreSQL databases that acts on the caller’s identity. An agent gets the
catalog (schemas, tables, hypertables, chunks, continuous aggregates,
background jobs) and a guarded SELECT surface; the server gets the person
behind every call and refuses to run as nobody.
Built on mark3labs/mcp-go,
giantswarm/mcp-oauth (OAuth 2.1
resource server, token forwarding) and
giantswarm/mcp-toolkit (logging,
tracing, health, response cap and per-tool timeout middleware);
jackc/pgx talks to PostgreSQL.
Installation
Every release carries
one binary per platform (mcp-timescale--: linux, darwin and
windows on amd64 and arm64) next to its cosign Sigstore bundle
(.bundle), the multi-arch image
gsoci.azurecr.io/giantswarm/mcp-timescale: and the chart in the
Giant Swarm catalog.
# a release binary (verify by hand with cosign v3: verify-blob --bundle .bundle)
curl -fsSLo mcp-timescale https://github.com/giantswarm/mcp-timescale/releases/latest/download/mcp-timescale-linux-amd64
chmod +x mcp-timescale
# or from source
go install github.com/giantswarm/mcp-timescale@latest
An installed binary keeps itself current:
mcp-timescale self-update
self-update downloads the latest release’s binary together with its bundle
and installs it only after the bundle verifies for a CircleCI build of
giantswarm/mcp-timescale against the Sigstore public-good trust root. A
release without a bundle, or a download that does not match its signature, is
refused and the installed binary stays as it is.
CLI
mcp-timescale # same as `mcp-timescale serve`
mcp-timescale serve # --transport stdio|sse|streamable-http, --mcp-addr, --metrics-addr, --databases-file, --debug
mcp-timescale version # version, commit and build time
mcp-timescale self-update # verified update to the latest release
version and --version report the release version; a plain go build
reports Go’s VCS build info (+dirty with untracked files), the generated
make build stamps the same identifiers the CI does (pkg/project).
There is no --read-only or --non-destructive switch as in mcp-capi or
mcp-kubernetes: every tool is read-only by construction, except
timescale_insert_row, which exists only when a database configures an
insert path (see Inserting rows). tools.ReadOnlyTools
and tools.WriteTools are the whole tool classification, the tool tests
compare them with what the server registers and with each tool’s annotations,
and the server logs its posture at startup:
Write policy readOnly=true writeMode=none tools=12 mutatingTools=0
Write policy readOnly=false writeMode=insert tools=13 mutatingTools=1 # with an insert path
Tools
Every tool is read-only (readOnlyHint: true, destructiveHint: false,
idempotentHint: true, openWorldHint: false), has a strict input schema
(unknown arguments are rejected) and returns one JSON document as text
content. Every tool except timescale_list_databases takes database (the
configured name); it is optional when exactly one database is configured.
| Tool | Arguments | Returns |
|---|---|---|
timescale_list_databases |
— | The databases the caller may use: name, description, host, port, dbname, sslmode, reachable (3 s probe), PostgreSQL and TimescaleDB versions, hidden_count for databases the allowlist excludes. |
timescale_get_database_info |
database |
Server version, TimescaleDB version and license, current role, transaction_read_only, database size, timezone, server time, installed extensions, number of hypertables / continuous aggregates / jobs. |
timescale_list_schemas |
database |
Non-system schemas with owner, relation count and comment (TimescaleDB-internal schemas hidden). |
timescale_list_tables |
database, schema?, include_views? (true), limit? (200), offset? (0), include_sizes? (auto) |
One page of tables and views, ordered by schema and name, with kind, owner, row estimate, is_hypertable, is_continuous_aggregate, comment; total_count and truncated describe the page. total_bytes / total_size (hypertable_size semantics for hypertables, chunks included, computed in one pass for the page) when the page holds fewer than 50 relations or include_sizes is true; otherwise sizes_included: false and a note. |
timescale_describe_table |
database, schema, table |
Columns (type, nullable, default, comment), primary key, indexes, foreign keys, view definition; for hypertables the dimensions (time column, chunk interval), chunk count, compression settings, detailed size and policies; for continuous aggregates the definition, source hypertable and refresh policy. |
timescale_list_hypertables |
database, schema?, limit? (200), offset? (0), include_sizes? (auto) |
One page of hypertables, ordered by schema and name, with owner, dimensions, chunk count, compression flag, time column and chunk interval; materialization hypertables of continuous aggregates are marked. Sizes follow the same rule as timescale_list_tables; timescale_describe_table has one hypertable’s detailed size. |
timescale_list_chunks |
database, schema, hypertable, limit? (50), newest_first? (true) |
Chunks with time / integer range, compression state, tablespace, creation time and size. |
timescale_list_continuous_aggregates |
database |
Continuous aggregates with definition, source and materialization hypertable, materialized_only, compression and refresh policy. Empty on the Apache-licensed build. |
timescale_list_jobs |
database |
Background jobs (retention, compression, reorder, refresh policies, user-defined actions) with schedule, config, target and statistics. |
timescale_query |
database, sql, max_rows? (100), timeout_seconds? |
One SELECT / WITH … SELECT / VALUES / TABLE / EXPLAIN / SHOW statement: {database, columns:[{name,type}], rows:[[…]], row_count, truncated, duration_ms}. |
timescale_explain |
database, sql, analyze? (false), format? (text) |
EXPLAIN (ANALYZE, BUFFERS, FORMAT …) of a SELECT-like statement; the plan as text or parsed JSON. |
timescale_sample_rows |
database, schema, table, limit? (20) |
The newest rows of a table (hypertables ordered by their time dimension); identifiers are validated against the catalog first. |
timescale_insert_row |
database, table, row |
Only with an insert path: inserts one row ({column: value}) into a writable table and commits it: {database, table, columns, inserted, duration_ms}. Not read-only, not idempotent, not destructive. |
Inserting rows
A database entry can opt into one write: inserting rows into a fixed list of tables, for records an agent produces (a case, an annotation, a finding).
insert:
host: db-primary.example.svc # defaults to the database host; reads may stay on a replica
usernameFile: /etc/mcp-timescale/secrets/plant-insert/username
passwordFile: /etc/mcp-timescale/secrets/plant-insert/password
tables: [werk.cases]
The insert path has its own credentials and no fallback to the reader’s; give
that role INSERT on the listed tables and nothing else (no SELECT,
UPDATE or DELETE). The statement is built from the allowlisted table and
validated column names only, INSERT INTO t (cols) SELECT cols FROM json_populate_record(NULL::t, $1::json), so PostgreSQL converts the JSON
values to the column types and every value is one bind parameter. It runs in
its own committed transaction attributed through application_name like
every read.
Values are encoded for a model: timestamps as RFC 3339 strings (timestamp
without zone and date keep their zone-less form), numeric as strings (no
float rounding), bytea as base64, uuid / inet / interval as text,
NaN / ±Infinity as strings, json / jsonb and arrays as JSON values.
The server also publishes instructions that tell a model how to approach
time-series data here: time_bucket() over a bounded time range, continuous
aggregates for long ranges, timescale_describe_table for the time column and
chunk interval.
Catalog listings are bounded so they stay predictable under the statement
timeout on a database with hundreds of hypertables or thousands of chunks:
timescale_list_tables and timescale_list_hypertables return one page
(limit, offset) in a stable schema-then-name order and report
total_count and truncated. Sizes are the expensive part — every chunk file
has to be measured — so a page of 50 or more relations skips them by default
and says so in note; include_sizes: true sizes that page in one set-based
statement over TimescaleDB’s per-chunk size view instead of one
hypertable_size() call per row, and include_sizes: false never sizes.
timescale_describe_table remains the place for one hypertable’s detailed
size.
Authentication and the caller’s identity
With OAUTH_ENABLED=true (the chart default) mcp-timescale is an OAuth 2.1
resource server built on mcp-oauth.
Two kinds of bearer are accepted on /mcp:
- an OIDC ID token forwarded by an aggregator such as
muster (
auth.forwardToken: trueon itsMCPServerCR), when its audience is listed inOAUTH_TRUSTED_AUDIENCES— single sign-on, no state on this server; - an access token issued by mcp-timescale’s own authorization server after an interactive login at the configured identity provider (Dex).
Either way the server acts on the caller’s identity:
- A tool call without a validated identity is refused
(
no caller identity: this server acts on the caller's identity and refuses anonymous calls). There is no fallback identity. - Each database carries an optional allowlist (
allowedGroups,allowedUsers); the caller must match one entry when either list is set.timescale_list_databasesshows only databases the caller may use. - Every statement runs in a
READ ONLYtransaction on a connection whose session defaults are read-only, and the transaction is always rolled back. The database role itself should be a reader. The only exception istimescale_insert_rowon a database with aninsertpath, on its own connection and role. - The database sees the person:
application_nameismcp-timescale/for the duration of the transaction (visible inpg_stat_activityand with%ainlog_line_prefix), and the server logs one structured audit line per call with tool, caller, database, duration, rows, truncation and error class.
Read-only is enforced in layers — role, session defaults, READ ONLY
transaction, single-statement extended protocol, and a SQL classifier that
refuses data-modifying keywords, SET/RESET, SELECT … INTO, row locks,
multiple statements and side-effect functions (pg_terminate_backend,
pg_read_file, set_config, advisory locks, dblink, …) with a clear
message before any round trip. Rows are bounded by a server-side cursor
(max_rows + 1 rows leave the database, never more), max_rows by the
database’s maxRows, timeout_seconds by its statementTimeout. See
docs/ARCHITECTURE.md.
Without OAuth (--transport stdio or OAUTH_ENABLED=false) every call runs as
the caller local; the server logs a warning at startup. Use that for local
development only.
Configuration
Databases
Databases come from a YAML file, MCP_TIMESCALE_DATABASES_FILE
(--databases-file, default /etc/mcp-timescale/databases.yaml):
databases:
- name: demo # tool-facing id, [a-z0-9-]{1,63}
description: Factory demo data # optional, shown by list_databases
host: timescale-demo-repl.timescale-demo.svc.cluster.local
port: 5432
dbname: timescaledb
sslmode: require # disable | require | verify-ca | verify-full
sslRootCertFile: "" # optional CA bundle for verify-*
usernameFile: /etc/mcp-timescale/secrets/demo/username # or username: reader
passwordFile: /etc/mcp-timescale/secrets/demo/password # or passwordEnv: DEMO_PASSWORD
allowedGroups: [] # optional; empty lists = every authenticated caller
allowedUsers: [] # optional emails (case-insensitive)
maxRows: 500 # hard cap for rows returned (default 500, max 5000)
statementTimeout: 30s # default 30s, max 10m
maxConnections: 4 # pool size (default 4)
Unknown keys, duplicate names, missing credentials and out-of-range values fail startup. Passwords are read from the file or environment variable at startup and never logged. An empty inventory is allowed (the server starts and reports that nothing is configured) so a chart can be installed before its databases exist.
For local use MCP_TIMESCALE_DSN=postgres://… adds a database named
default without a file.
Environment
Every knob is an env var; flags override. The OAuth knobs come straight from
mcp-oauth/oauthconfig.
| Variable | Default | Purpose |
|---|---|---|
MCP_TIMESCALE_DATABASES_FILE |
/etc/mcp-timescale/databases.yaml |
Database inventory (see above) |
MCP_TIMESCALE_DSN |
— | Single database default for local runs |
MCP_TRANSPORT |
streamable-http | stdio | sse | streamable-http |
MCP_ADDR |
:8080 | MCP HTTP listener |
METRICS_ADDR |
:9091 | /metrics, /healthz, /readyz |
DEBUG |
false | Debug logging |
OAUTH_ENABLED |
false | Set true in production |
OAUTH_PROVIDER |
— | dex | google | github |
OAUTH_ISSUER |
— | This server’s own /oauth/* base URL |
OAUTH_DEX_ISSUER_URL |
— | Upstream Dex issuer (provider=dex) |
OAUTH_DEX_CLIENT_ID / OAUTH_DEX_CLIENT_SECRET[_FILE] |
— | Dex client |
OAUTH_TRUSTED_AUDIENCES |
— | Audiences of forwarded ID tokens (muster’s Dex client ID) |
OAUTH_ALLOW_PRIVATE_URLS |
false | Let the Dex issuer and its JWKS resolve to private/loopback addresses (Dex behind an internal-only load balancer); TLS stays verified. MCP_OAUTH_ALLOW_PRIVATE_URLS is accepted as an alias |
OAUTH_STORAGE_BACKEND |
memory | memory | valkey (valkey for >1 replica with interactive logins) |
OTEL_EXPORTER_OTLP_ENDPOINT |
— | Tracing; empty disables |
The per-tool timeout middleware is sized from the largest configured
statementTimeout (+15 s, at least 45 s) so it never fires before PostgreSQL
does.
Local quickstart
docker run -d --name tsdb -e POSTGRES_PASSWORD=pw -p 127.0.0.1:5432:5432 timescale/timescaledb:2.29.2-pg17
export MCP_TIMESCALE_DSN='postgres://postgres:[email protected]:5432/postgres?sslmode=disable'
go run . serve --transport stdio # Claude Desktop / Cursor: stdio, caller "local"
OAUTH_ENABLED=false go run . serve # streamable-HTTP on :8080 for mcp-inspector
Or against a real cluster, with the same read-only role the chart would use:
kubectl -n timescale-demo port-forward svc/timescale-demo-repl 5432:5432 &
cat > /tmp/databases.yaml <<EOF
databases:
- name: demo
host: 127.0.0.1
dbname: timescaledb
sslmode: require
username: timescaledb_data_reader_user
passwordEnv: DEMO_PASSWORD
EOF
DEMO_PASSWORD=$(kubectl -n timescale-demo get secret timescaledb-data-reader-user.timescale-demo.credentials.postgresql.acid.zalan.do -o jsonpath='{.data.password}' | base64 -d) \
go run . serve --transport stdio --databases-file /tmp/databases.yaml
make test runs the unit tests; make test-integration starts a throwaway
TimescaleDB container and runs the integration suite (hypertable with
compressed and uncompressed chunks, continuous aggregate, policies,
truncation, refused writes, attribution in pg_stat_activity, statement
timeout, a generated schema of 500 hypertables that both listings must page
and size within a second, and the listings as a plain reader role).
MCP_TIMESCALE_TEST_DSN points it at an existing database instead; the suite
creates schemas public.metrics, many and the role mcp_it_reader there.
Kubernetes
The chart in helm/mcp-timescale ships hardened defaults: non-root,
read-only root filesystem, no capabilities, no ServiceAccount token, no RBAC
(the server never talks to the Kubernetes API), ServiceMonitor on, OAuth on.
Database credentials are mounted from Secrets (0440) and referenced by path
from the generated databases.yaml; nothing secret lands in a ConfigMap.
oauth:
issuerURL: https://mcp-timescale.example.com
dex:
issuerURL: https://dex.example.com
clientIDSecretRef: {name: mcp-timescale-dex, key: client-id}
clientSecretSecretRef: {name: mcp-timescale-dex, key: client-secret}
trustedAudiences:
- # forwarded ID tokens
databases:
- name: demo
description: Factory demo data
host: timescale-demo-repl.timescale-demo.svc.cluster.local # the replica service
dbname: timescaledb
sslmode: require
secretRef:
# Zalando postgres-operator credentials Secret of a reader role.
name: timescaledb-data-reader-user.timescale-demo.credentials.postgresql.acid.zalan.do
usernameKey: username
passwordKey: password
allowedGroups: [data-analysts]
maxRows: 500
statementTimeout: 30s
gatewayAPI: # expose through an existing Gateway
enabled: true
httpRoute:
parentRefs:
- name: giantswarm-default
namespace: envoy-gateway-system
hostnames: [mcp-timescale.example.com]
backendTrafficPolicy:
enabled: true # Envoy Gateway: no request timeout on MCP streams
The HTTPRoute forwards /, so /mcp, /oauth/* and /.well-known/* share
the Service. storage.kind defaults to memory and replicas to 1: with
forwarded tokens no OAuth state needs sharing. Interactive logins across more
than one replica need storage.kind: valkey. networkPolicy.enabled is off by
default because the databases and Dex are reached by DNS name; the values file
documents the egress rules to add when enabling it (TCP 5432 to the database
namespaces, TCP 443 for Dex JWKS).
Registering with muster
muster forwards the session’s IdP ID token byte-identical when the
MCPServer CR carries a forwardToken auth block; the server validates it
against Dex’s JWKS and requires the audience to be in
oauth.trustedAudiences:
apiVersion: muster.giantswarm.io/v1alpha1
kind: MCPServer
metadata:
name: timescale
spec:
type: streamable-http
url: https://mcp-timescale.example.com/mcp
description: Read-only TimescaleDB access, acting as the caller
auth:
type: oauth
forwardToken: true
Tools then appear in muster with the x_timescale_ prefix
(x_timescale_timescale_query, …) and every call reaches the database as the
person who is logged in to muster.
When the installation’s Dex resolves to a private address (an internal-only
load balancer in front of it), set oauth.allowPrivateURLs: true. Otherwise
mcp-oauth’s SSRF guard refuses the JWKS fetch (Token validation failed … resolved to restricted IP) and every forwarded token is rejected. The flag
lifts the guard for the Dex issuer and its JWKS only; TLS verification stays
on.
Development
| Path | Purpose |
|---|---|
cmd/ |
cobra entry — serve.go wires config, pools, tools and transports; version, self-update |
pkg/project/ |
build identifiers (version, commit, build time) stamped at link time |
internal/config/ |
databases.yaml loading, validation, allowlists |
internal/sqlguard/ |
read-only SQL classifier (tokenizer + keyword rules) |
internal/timescale/ |
registry of pools, attributed read-only sessions, cursor-bounded queries, catalog introspection, value encoding |
internal/tools/ |
the MCP tools, argument validation, identity and allowlist checks, audit log |
internal/server/ |
OAuth wiring, transport mux, caller context, /metrics |
helm/mcp-timescale/ |
Helm chart; tests/ are helm-unittest suites, ci/ci-values.yaml renders every feature |
scripts/integration-test.sh |
Docker-backed integration run (make test-integration) |
make build (stamps the version), make test, make lint and make fmt come
from the generated Makefile. make test-integration runs the Docker-backed
integration suite, make helm-lint / make helm-template render the chart
with helm/mcp-timescale/ci/ci-values.yaml, make helm-test runs the chart
unit tests and make govulncheck scans the module graph.
pre-commit run --all-files is what CI runs: gofmt, goimports, golangci-lint
(gosec, goconst), go mod tidy, the values schema and helm-docs.
.cursor/rules/ carries the architecture invariants and this workflow for
agents working in the repo.
Releasing
Releases are cut automatically from Conventional Commit PR titles (feat:,
fix:, feat!:; chore: and ci: bump the patch too, docs: releases
nothing): the Auto Release workflow tags the merge commit, and the CircleCI
tag pipeline attaches the six platform binaries with their cosign bundles to
the GitHub Release, pushes the multi-arch image to gsoci and the chart to the
Giant Swarm catalog. The CI itself is generated by devctl from this repo’s
entry in giantswarm/github (gen.flavours: generic, app, cli) and kept in
sync by the align-files workflow — change it there, not in the generated
files.
Security
Report vulnerabilities per SECURITY.md. The image is distroless, runs as
non-root with readOnlyRootFilesystem: true, drops all Linux capabilities and
mounts no ServiceAccount token. The server holds read-only database
credentials only and cannot escalate them: every statement is confined to a
read-only transaction on a read-only session.
Install
docker run -d --name tsdb -e POSTGRES_PASSWORD=pw -p 127.0.0.1:5432:5432 timescale/timescaledb:2.29.2-pg17