Timecalc
FreeNot checkedAn MCP server allowing Agents to deterministically calculate date math expressions.
About
An MCP server allowing Agents to deterministically calculate date math expressions.
README
timecalc gives AI agents and humans a small, reliable calculator for date, time-zone, and duration arithmetic. It runs as both a Model Context Protocol (MCP) server and a command-line tool, with both interfaces backed by the same Temporal evaluator.
Why an MCP date calculator?
Date math is deceptively difficult. Month lengths vary, leap years matter, daylight-saving transitions make “one day” different from “24 hours,” and an answer involving “today” is meaningless without a clock and time zone. Language models should not have to approximate those rules or generate ad hoc date code.
The timecalc MCP server lets an agent translate a user's request into one constrained expression and delegate the calculation to Bun's implementation of JavaScript Temporal. The result is typed and machine-readable. Expressions are inspectable, and calculations can be replayed with the same explicit context. The evaluator never uses JavaScript eval and does not parse natural-language dates itself.
At a glance:
- One MCP tool:
evaluate_date_expression - Four Temporal types:
PlainDate,Instant,ZonedDateTime, andDuration - Explicit semantics: calendar dates, absolute instants, zoned times, and durations remain distinct
- Current-time support: deterministic context injection or an opt-in host clock and time zone
- Same behavior everywhere: MCP and CLI share the parser, evaluator, errors, and serialization
- Portable releases: standalone executables do not require Bun at runtime
Guide to this README
- Start with Quick start to run the CLI or configure an MCP client.
- Read Evaluation context and now before handling “now,” “today,” or local-time questions.
- Use the Language overview and Operator reference when writing expressions.
- See the CLI and MCP server sections for complete interface details.
- Building an agent that queries time-series data? See Using timecalc with TimescaleDB.
- Contributors can jump to Architecture and Development.
Quick start
There are three ways to install timecalc. All of them deliver the same standalone executable; none require Bun.
| Method | Best for | Requires |
|---|---|---|
| Release binary | Fastest startup; any MCP client or shell use | curl and tar (or unzip) |
| npm / npx | MCP clients whose config expects an npx command |
Node.js 20+ and npm |
| Claude Code plugin | Claude Code; installs the MCP server and the Agent Skill together | Claude Code, Node.js 20+ and npm |
Install a release binary (recommended)
On Linux, macOS, or Windows with a POSIX shell such as Git Bash, install the latest release with:
curl -fsSL https://raw.githubusercontent.com/timescale/timecalc-mcp/main/install.sh | sh
The installer detects the operating system and architecture, downloads the matching asset from the latest GitHub release, verifies it against the release's SHA256SUMS, and installs timecalc into $HOME/.local/bin or $HOME/bin. On macOS it also applies a local ad-hoc signature with Bun's recommended JIT entitlements and removes the download quarantine attribute after checksum verification. It never requires Bun. Override the destination or install a specific release when needed:
curl -fsSL https://raw.githubusercontent.com/timescale/timecalc-mcp/main/install.sh \
| TIMECALC_INSTALL_DIR="$HOME/.local/bin" TIMECALC_VERSION=v0.4.0 sh
Ensure the selected installation directory is on PATH. To inspect the installer before running it, download install.sh and execute it locally with sh install.sh.
For manual installation, download the archive for your platform:
| Platform | Release asset |
|---|---|
| Linux AMD64 | timecalc-v<version>-linux-amd64.tar.gz |
| Linux ARM64 | timecalc-v<version>-linux-arm64.tar.gz |
| macOS AMD64 (Intel) | timecalc-v<version>-darwin-amd64.tar.gz |
| macOS ARM64 (Apple Silicon) | timecalc-v<version>-darwin-arm64.tar.gz |
| Windows AMD64 | timecalc-v<version>-windows-amd64.zip |
| Windows ARM64 | timecalc-v<version>-windows-arm64.zip |
Each archive contains timecalc (or timecalc.exe), LICENSE, and NOTICE. The executable includes Bun and all runtime dependencies, so Bun does not need to be installed.
Download SHA256SUMS from the same release and verify the archive before extracting it. For example, on Linux:
ARCHIVE=timecalc-vX.Y.Z-linux-amd64.tar.gz # replace X.Y.Z
grep -F "$ARCHIVE" SHA256SUMS | sha256sum -c -
On Linux or macOS, extract the archive and install the executable:
VERSION=X.Y.Z # replace with the release version
TARGET=linux-amd64 # or linux-arm64 / darwin-amd64 / darwin-arm64
ARCHIVE="timecalc-v${VERSION}-${TARGET}.tar.gz"
mkdir -p "$HOME/.local/bin"
tar -xzf "$ARCHIVE"
install -m 0755 timecalc "$HOME/.local/bin/timecalc"
"$HOME/.local/bin/timecalc" --version
Add $HOME/.local/bin to PATH if it is not already present. Release macOS binaries are ad-hoc signed with Bun's recommended JIT entitlements but are not Developer ID signed or notarized. A manually downloaded binary may therefore require explicit Gatekeeper approval; the verified installer handles the local ad-hoc signing and quarantine removal automatically.
On Windows, verify the archive against SHA256SUMS, extract the .zip, and move timecalc.exe to a directory on PATH. Then confirm the executable from PowerShell:
.\timecalc.exe --version
To develop timecalc or run it from source, see Development.
Install with npm
The executables are also published to npm as @tigerdata/timecalc. That package is a small Node.js launcher; the executable itself comes from a platform-specific package (@tigerdata/timecalc-linux-amd64, @tigerdata/timecalc-darwin-arm64, and so on) selected through optionalDependencies, so npm downloads only the one that matches the machine.
Run without installing:
npx -y @tigerdata/timecalc '(add 2025-01-31 P1M)'
Or install globally:
npm install -g @tigerdata/timecalc
timecalc --version
Each executable embeds the Bun runtime and is 60-90 MB on disk (roughly 25-40 MB compressed), so the first npx run downloads more than a typical npm package. Later runs start from the npm cache. The release binary route above avoids the Node.js launcher and starts slightly faster; prefer it when the MCP client can run an arbitrary command.
Install the Claude Code plugin
For Claude Code, a plugin installs the MCP server and the Agent Skill in one step:
/plugin marketplace add timescale/timecalc-mcp
/plugin install timecalc@timecalc
The plugin starts the server with npx -y @tigerdata/timecalc mcp --system-context, so Node.js and npm must be on PATH. If you already added a timecalc MCP server to Claude Code by hand, remove it first to avoid registering the tool twice. The plugin source lives in plugins/timecalc/.
As an MCP server
For an interactive agent, system-context mode is usually the most useful configuration because many agent harnesses do not expose their current clock or local time zone:
{
"mcpServers": {
"timecalc": {
"command": "timecalc",
"args": ["mcp", "--system-context"]
}
}
}
This still permits a caller to override the clock and zone for reproducible calculations. Use args: ["mcp"] instead when all context must be supplied explicitly. If the client should fetch timecalc itself, use "command": "npx" with "args": ["-y", "@tigerdata/timecalc", "mcp", "--system-context"]. See MCP client configuration for source-checkout configuration.
Typical agent requests include:
- “What date is 30 days from today?”
- “How many calendar months are between these dates?”
- “Convert this timestamp to America/New_York.”
- “Will adding one day across this DST boundary preserve the local hour?”
A current-local-date calculation is explicit in the DSL:
(to-date (with-time-zone (now) (default-time-zone)))
As a CLI
# Deterministic date arithmetic; no clock or zone is needed
timecalc '(add 2025-01-31 P1M)'
# Use the host clock and time zone
timecalc --system-context \
'(to-date (with-time-zone (now) (default-time-zone)))'
Evaluation context and now
now is represented as a Temporal.Instant: one absolute point on the timeline. A time zone is a separate context value because the same instant can correspond to different local dates around the world.
There are two context modes:
- Deterministic mode is the default. The evaluator does not read the host clock or time zone. Expressions that do not depend on current context work without any extra input.
(now)and(default-time-zone)returnMISSING_CONTEXTunless their values are supplied explicitly. - System-context mode is opt-in.
--system-contextfills missing values fromTemporal.Now.instant()andTemporal.Now.timeZoneId(). Explicitnowand time-zone inputs always take precedence. The system zone belongs to the process running timecalc; it may be UTC or otherwise differ from the end user's zone, especially in a container or on a remote host.
For a user-specific local-time question, pass that user's IANA zone explicitly instead of assuming the system zone.
The context is resolved once at the start of each evaluation. Therefore, every (now) within one expression returns the same instant. In system-context mode, a later tool call resolves a new instant. Successful structured results include the resolved context so an answer involving “now” or “today” is auditable.
Convert the instant before asking calendar questions:
; Current instant
(now)
; Current zoned date-time
(with-time-zone (now) (default-time-zone))
; Current local date
(to-date (with-time-zone (now) (default-time-zone)))
The optional default calendar is validated and reported as context, but current operators do not use it for implicit conversion. Temporal values continue to carry their own calendars.
Language overview
Expressions use S-expression syntax:
(operator positional-argument ... :keyword-option value ...)
For example:
(subtract
2025-12-31
2025-01-01
:largest-unit "months")
There must be exactly one top-level expression. Positional arguments must come before keyword options. A semicolon begins a comment that continues through the end of the line:
; Move to the next calendar day
(add
2025-12-31
P1D)
The canonical grammar is src/grammar.ebnf, written in ISO/IEC 14977 EBNF. Generated railroad diagrams are available as HTML and Markdown.
Temporal literals
Temporal values are unquoted and self-describing. Constructors and type qualifiers are not used.
| DSL literal | Temporal type | Meaning |
|---|---|---|
2025-01-31 |
Temporal.PlainDate |
Calendar date without a time or zone |
2025-06-01T12:00:00Z |
Temporal.Instant |
Absolute point on the timeline |
2025-06-01T08:00:00-04:00[America/New_York] |
Temporal.ZonedDateTime |
Local time, offset, and time-zone identifier |
P1M, PT24H, -P2D |
Temporal.Duration |
Calendar and/or elapsed-time amount |
The lexer determines a Temporal type from the literal's structure, and the corresponding Temporal from() method validates its value. Classification never depends on the operator receiving the value.
Canonical v1 literal profiles use:
- four-digit years and two-digit month/day fields;
- uppercase
TandZ; - an explicit
Zor numeric offset for instants; - both an offset and bracketed identifier for zoned date-times;
- uppercase ISO 8601 duration components.
Quoted lookalikes remain strings:
P1M ; Temporal.Duration
"P1M" ; string
The language also has string, finite number, and boolean scalar values for options and operator results.
Calendar time versus elapsed time
Temporal distinguishes calendar units from fixed elapsed-time units. Across a daylight-saving transition, one calendar day may not contain 24 hours:
(add 2025-03-08T12:00:00-05:00[America/New_York] P1D)
; 2025-03-09T12:00:00-04:00[America/New_York]
(add 2025-03-08T12:00:00-05:00[America/New_York] PT24H)
; 2025-03-09T13:00:00-04:00[America/New_York]
Use P1D for “the same local time tomorrow” and PT24H for exactly 24 elapsed hours.
Operator reference
The machine-readable source of truth is src/operators/catalog.ts.
Arithmetic and differences
| Operator | Signature | Result |
|---|---|---|
add |
(add temporal duration [:overflow "constrain"|"reject"]) |
Same type as the first argument |
subtract |
(subtract temporal duration [:overflow "constrain"|"reject"]) |
Same type as the first argument |
subtract |
(subtract temporal compatible-temporal [difference options]) |
Duration |
add accepts a PlainDate, Instant, or ZonedDateTime followed by a Duration.
subtract is overloaded but always means left minus right:
- when the right operand is a
Duration, it subtracts that amount and returns the same type as the left operand; - when both operands have the same Temporal type, it returns the signed duration between them.
(subtract 2025-03-03 P2D)
; 2025-03-01
(subtract 2025-03-03 2025-03-01)
; P2D
(subtract 2025-03-01 2025-03-03)
; -P2D
The :overflow option applies when adding or subtracting a duration from a date or zoned date-time, not instant arithmetic.
Subtracting two Temporal values supports these difference options:
:largest-unit string
:smallest-unit string
:rounding-increment positive-integer
:rounding-mode string
Example:
(subtract 2025-12-31 2025-01-01 :largest-unit "months")
; P11M30D
Comparison
| Operator | Signature | Result |
|---|---|---|
compare |
(compare value value [:relative-to temporal]) |
-1, 0, or 1 |
equals |
(equals value value) |
Boolean |
Operands must have the same Temporal type. Duration comparison may require :relative-to when calendar units are involved:
(compare
P1D
PT24H
:relative-to 2025-03-08T12:00:00-05:00[America/New_York])
; -1
Duration equality is structural: P1D and PT24H are not equal even when they happen to span the same elapsed time in a particular context.
Rounding
(round 2025-01-01T12:34:56Z :smallest-unit "minute")
; 2025-01-01T12:35:00Z
round supports Instant, ZonedDateTime, and Duration. Options are:
:smallest-unit string
:rounding-increment positive-integer
:rounding-mode string
:largest-unit string ; Duration only
:relative-to temporal ; Duration only
Temporal requires the appropriate unit option for the value being rounded.
Context and conversion
| Operator | Signature | Result |
|---|---|---|
now |
(now) |
Context's current Instant |
default-time-zone |
(default-time-zone) |
Context's time-zone identifier |
with-time-zone |
(with-time-zone instant-or-zoned-date-time "zone") |
ZonedDateTime |
to-instant |
(to-instant zoned-date-time) |
Instant |
to-date |
(to-date zoned-date-time) |
Local PlainDate |
now and default-time-zone read the resolved evaluation context described above. They return MISSING_CONTEXT when the corresponding value is unavailable.
(with-time-zone 2025-06-01T12:00:00Z "America/New_York")
; 2025-06-01T08:00:00-04:00[America/New_York]
(to-instant 2025-06-01T08:00:00-04:00[America/New_York])
; 2025-06-01T12:00:00Z
(to-date (with-time-zone (now) (default-time-zone)))
; the current local date
In system-context mode, the host clock is sampled once before evaluation; (now) only reads that captured instant.
Inspection
| Operator | Accepted type |
|---|---|
year, month, day |
PlainDate, ZonedDateTime |
hour, minute, second |
ZonedDateTime |
day-of-week, day-of-year, week-of-year |
PlainDate, ZonedDateTime |
days-in-month, days-in-year, months-in-year |
PlainDate, ZonedDateTime |
offset, time-zone-id |
ZonedDateTime |
calendar-id |
PlainDate, ZonedDateTime |
Inspection operators take one argument and return a number or string:
(day-of-week 2025-06-01)
; 7
(offset 2025-06-01T08:00:00-04:00[America/New_York])
; -04:00
CLI
Evaluate
eval is optional:
timecalc '(add 2025-01-31 P1M)'
timecalc eval '(add 2025-01-31 P1M)'
Expressions containing shell-significant characters or whitespace should be quoted. Use -- before a top-level negative literal if required by your shell or invocation environment.
Read from stdin
echo '(day-of-week 2025-06-01)' | timecalc --stdin
An expression argument and --stdin cannot be used together.
Validate
validate performs parsing, type checking, and evaluation:
$ timecalc validate '(add 2025-01-31 P1M)'
valid
JSON output
timecalc --json --pretty \
'(add 2025-03-08T12:00:00-05:00[America/New_York] P1D)'
{
"ok": true,
"type": "zoned-date-time",
"value": "2025-03-09T12:00:00-04:00[America/New_York]",
"calendar": "iso8601",
"timeZone": "America/New_York",
"offset": "-04:00"
}
Errors are structured when --json is used:
{
"ok": false,
"error": {
"code": "TYPE_MISMATCH",
"message": "add expected duration as argument 2, received date",
"span": { "start": 0, "end": 27 },
"line": 1,
"column": 1
}
}
CLI options
--stdin Read the expression from standard input
--json Emit structured JSON
--pretty Pretty-print JSON; requires --json
--now <instant> Inject an explicit evaluation clock
--time-zone <zone> Set an explicit default time zone
--calendar <calendar> Set an explicit default calendar
--system-context Use the host clock and time zone as defaults
-o, --output <file> Set grammar diagram output for `grammar`
-h, --help Show help
-V, --version Show version
(now) reads --now, and (default-time-zone) reads --time-zone. With --system-context, missing values come from Temporal.Now.instant() and Temporal.Now.timeZoneId(); explicit options take precedence. See Evaluation context and now for the complete resolution rules. Resolved context is included in successful JSON output.
CLI exit codes:
| Code | Meaning |
|---|---|
0 |
Success |
1 |
Invalid expression, type, Temporal operation, or value |
2 |
Invalid CLI usage or internal failure |
The grammar command is a source-development utility because it depends on the project's diagram generator. Release-binary users can read the committed HTML and Markdown diagrams directly.
MCP server
The MCP server is built into the standalone executable and communicates over stdio. Start it in deterministic mode with:
timecalc mcp
Or enable host clock and time-zone defaults for interactive use:
timecalc mcp --system-context
The server is stateless and exposes exactly one tool:
evaluate_date_expression
The tool is annotated as read-only, non-destructive, and closed-world. It is idempotent in deterministic mode and marked non-idempotent when the server enables system context.
Tool input
A context-free request only needs an expression:
{
"expression": "(add 2025-01-31 P1M)"
}
A deterministic current-local-date request supplies its clock and zone explicitly:
{
"expression": "(to-date (with-time-zone (now) (default-time-zone)))",
"now": "2025-01-01T04:30:00Z",
"defaultTimeZone": "America/New_York"
}
| Field | Required | Description |
|---|---|---|
expression |
yes | One DSL expression, at most 64 KiB |
now |
no | Explicit Temporal instant for clock-dependent operations |
defaultTimeZone |
no | IANA or fixed-offset time-zone identifier |
defaultCalendar |
no | Temporal calendar identifier; validated and reported, but not implicitly applied by current operators |
Unknown input properties are rejected. Optional context fields are validated and passed to the shared evaluator. (now) and (default-time-zone) read the corresponding values. In system-context mode, explicit request fields override host defaults.
Successful result
MCP responses include concise text for display and structured content for programmatic use. When context is resolved, structuredContent records the exact values used:
{
"content": [
{ "type": "text", "text": "2024-12-31" }
],
"structuredContent": {
"ok": true,
"type": "date",
"value": "2024-12-31",
"calendar": "iso8601",
"context": {
"now": "2025-01-01T04:30:00Z",
"defaultTimeZone": "America/New_York"
}
}
}
For context-free expressions, the result remains concise:
{
"content": [
{ "type": "text", "text": "2025-02-28" }
],
"structuredContent": {
"ok": true,
"type": "date",
"value": "2025-02-28",
"calendar": "iso8601"
}
}
Error result
Expression errors are tool errors rather than server crashes:
{
"isError": true,
"content": [
{
"type": "text",
"text": "TYPE_MISMATCH at 1:1: add expected duration as argument 2, received date"
}
],
"structuredContent": {
"ok": false,
"error": {
"code": "TYPE_MISMATCH",
"message": "add expected duration as argument 2, received date",
"span": { "start": 0, "end": 27 },
"line": 1,
"column": 1
}
}
}
Possible error codes are:
LEX_ERROR
PARSE_ERROR
UNKNOWN_OPERATOR
ARITY_ERROR
UNKNOWN_OPTION
DUPLICATE_OPTION
TYPE_MISMATCH
INVALID_TEMPORAL_VALUE
INVALID_TEMPORAL_OPERATION
MISSING_CONTEXT
RESOURCE_LIMIT
INTERNAL_ERROR
Stack traces, host paths, and environment details are not returned. In stdio mode, stdout is reserved exclusively for MCP protocol messages; diagnostics go to stderr.
Agent Skill
A portable Agent Skill for teaching compatible agents when and how to use the MCP server is included at .agents/skills/timecalc/SKILL.md.
Clients that discover project skills from .agents/skills/ can use it directly. Claude Code users get it automatically from the plugin. For other clients, copy the .agents/skills/timecalc/ directory into that client's skill directory. The skill assumes the timecalc MCP server is already configured and exposes evaluate_date_expression.
The copy under plugins/timecalc/skills/ is generated from .agents/skills/timecalc/ by ./bun run plugin:sync; CI fails if the two diverge.
MCP client configuration
Use the installed release binary for normal MCP operation (or npx -y @tigerdata/timecalc in place of timecalc if the client should fetch it from npm). For interactive agents that cannot discover the current time or host zone, enable system context:
{
"mcpServers": {
"timecalc": {
"command": "timecalc",
"args": ["mcp", "--system-context"]
}
}
}
System-context mode resolves missing request context from the host for each tool call. The runtime and host environment, including TZ where supported, determine the default time zone. This is the time zone of the machine or container running timecalc, not necessarily the end user's time zone.
For deterministic operation, omit --system-context. Context-free calculations still work normally; expressions using (now) or (default-time-zone) then require explicit tool inputs:
{
"mcpServers": {
"timecalc": {
"command": "timecalc",
"args": ["mcp"]
}
}
}
Contributors running directly from a source checkout can configure an absolute source path instead:
{
"mcpServers": {
"timecalc": {
"command": "bun",
"args": [
"run",
"/absolute/path/to/timecalc/src/cli.ts",
"mcp",
"--system-context"
]
}
}
}
Launch MCP Inspector from a source checkout with:
./bun run mcp:inspect
Pinned protocol dependencies:
@modelcontextprotocol/sdk1.30.0- Zod 4.5.4
Only stdio transport is implemented. HTTP transport is intentionally deferred until authentication, origin, session, and rate-limiting requirements are defined.
Using timecalc with TimescaleDB
When an agent turns a natural-language question into SQL over time-series data, the aggregation is rarely the hard part. The time predicate is. "Yesterday," "the same week last year," and "daily buckets" each have one correct meaning and several plausible wrong ones, and PostgreSQL will evaluate whichever one the agent writes without complaint:
- Zone. Hypertable timestamps are
timestamptz(UTC instants); the user's "Monday" is inAmerica/Chicago.date_trunc('week', now())in a UTC session can start the week on a different Monday than the user expects. - Calendar versus elapsed. "Last month" is a calendar month,
now() - interval '30 days'is not. "Same week last year" could be 52 weeks, 364 days, or one year, and each gives a different range. - Daylight saving. A local "day" is 23 or 25 hours twice a year.
time_bucket('1 day', time)without thetimezoneargument buckets in UTC. - Boundaries.
BETWEENon timestamps is inclusive at both ends; time windows should be half-open.
The pattern that avoids this is resolve, then query: use timecalc to turn the user's intent into explicit instants, then write SQL whose WHERE clause contains literal bounds. The query becomes reproducible and reviewable, and the semantic decisions are visible in the expression rather than buried in now() arithmetic. Every example below uses now = 2026-09-03T14:22:07Z so the results can be reproduced with --now.
A local calendar day as instant bounds
"Average CPU per hour yesterday" for a user in Chicago. Floor the current zoned time to the start of today, step back one calendar day, and convert both bounds to instants:
(to-instant (subtract (round (with-time-zone (now) "America/Chicago")
:smallest-unit "day" :rounding-mode "floor")
P1D))
; 2026-09-02T05:00:00Z
(to-instant (round (with-time-zone (now) "America/Chicago")
:smallest-unit "day" :rounding-mode "floor"))
; 2026-09-03T05:00:00Z
SELECT time_bucket('1 hour', time, 'America/Chicago') AS bucket, avg(cpu)
FROM metrics
WHERE time >= '2026-09-02T05:00:00Z' AND time < '2026-09-03T05:00:00Z'
GROUP BY bucket
ORDER BY bucket;
The timezone argument to time_bucket matters for buckets of a day or longer and for zones with non-hour offsets; passing it always keeps the query correct if the bucket width changes.
Rolling window versus calendar week
"The last seven days" is a rolling window ending at the start of today:
(to-instant (subtract (round (with-time-zone (now) "America/Chicago")
:smallest-unit "day" :rounding-mode "floor")
P7D))
; 2026-08-27T05:00:00Z
"This week" is a calendar week starting Monday. Temporal rounds to days and smaller only, so find the weekday first and then step back the right number of days in a second call:
(day-of-week (to-date (with-time-zone (now) "America/Chicago")))
; 4 (Thursday; 1 = Monday, 7 = Sunday)
(to-instant (subtract (round (with-time-zone (now) "America/Chicago")
:smallest-unit "day" :rounding-mode "floor")
P3D))
; 2026-08-31T05:00:00Z
For weekly buckets that start on the same Monday, pass that instant as time_bucket's origin:
SELECT time_bucket('7 days', time, 'America/Chicago', origin => '2026-08-31T05:00:00Z') AS week, ...
The same window last year
Shift a zoned bound by a calendar year before converting it, so the result lands on the same local date even though the offset may differ:
(to-instant (subtract (round (with-time-zone (now) "America/Chicago")
:smallest-unit "day" :rounding-mode "floor")
P1Y))
; 2025-09-03T05:00:00Z
Subtracting P1Y from an already-converted Instant is rejected, because a year has no fixed length. That error is intentional: it forces the calendar-versus-elapsed decision to be made in a zone.
Month boundaries
Temporal does not round to months, so resolve month boundaries with calendar-date arithmetic and let PostgreSQL attach the zone:
(subtract 2026-09-01 P1M)
; 2026-08-01
WHERE time >= timestamptz '2026-08-01 00:00 America/Chicago'
AND time < timestamptz '2026-09-01 00:00 America/Chicago'
If the agent writes a zoned literal directly, timecalc validates the offset against the zone. 2026-01-01T00:00:00-05:00[America/Chicago] is rejected with INVALID_TEMPORAL_VALUE because Chicago is at -06:00 in January, which is exactly the mistake a model is likely to make when it types offsets from memory.
Gap filling and retention cutoffs
time_bucket_gapfill requires explicit start and finish arguments; the resolved instants from any example above are what it needs:
SELECT time_bucket_gapfill('15 minutes', time,
start => '2026-09-02T05:00:00Z'::timestamptz,
finish => '2026-09-03T05:00:00Z'::timestamptz) AS bucket,
locf(avg(cpu))
FROM metrics
WHERE time >= '2026-09-02T05:00:00Z' AND time < '2026-09-03T05:00:00Z'
GROUP BY bucket
ORDER BY bucket;
A retention or compression question such as "how much data is older than 90 days" needs a cutoff instant. Ninety calendar days and 2,160 elapsed hours are different quantities; choose deliberately:
(to-instant (subtract (with-time-zone (now) "UTC") P90D)) ; calendar days in UTC
; 2026-06-05T14:22:07Z
(subtract (now) PT2160H) ; exactly 90 x 24 hours
; 2026-06-05T14:22:07Z
They agree here because UTC has no transitions. Across a daylight-saving change they differ: from 2026-04-15T12:00:00Z, ninety calendar days back in America/Chicago is 2026-01-15T13:00:00Z, while PT2160H back is 2026-01-15T12:00:00Z. Writing (subtract (now) P90D) is rejected outright, because an Instant cannot take calendar units.
Why not do this in SQL?
PostgreSQL's date arithmetic is correct and calendar-aware. The point is not to replace it but to make the agent's semantic choices explicit and checkable before a query runs. With now() in the WHERE clause, the agent's reasoning about the window and the database's evaluation of it happen at different moments and in different zones, and a wrong choice still produces a plausible number. With resolved instants, the window is a literal in the query, the choice of zone and calendar unit is visible in the timecalc expression, and the Agent Skill tells the agent to ask for a zone when the answer depends on one.
Determinism and safety
- Deterministic mode never uses the host clock or local time zone implicitly.
- System-context mode is explicit, samples the clock once per evaluation, reports resolved context in successful structured output, and marks the MCP tool non-idempotent.
- Explicit request context overrides system defaults.
- Tests use injected fixed clocks and explicit zones for system-context behavior.
- Source is parsed into an AST and never passed to JavaScript
eval. - The DSL has no filesystem, network, process, environment, import, variable, macro, or user-function primitives.
- MCP evaluation is stateless.
- Temporal exceptions are converted to stable public errors.
Resource limits for untrusted input:
| Resource | Limit |
|---|---|
| Source length | 64 KiB |
| Expression nesting | 100 levels |
| AST nodes | 10,000 |
| String literal length | 32 KiB |
| Top-level expressions | 1 |
Current scope and limitations
The initial language supports Temporal.PlainDate, Temporal.Instant, Temporal.ZonedDateTime, and Temporal.Duration.
It does not currently support:
Temporal.PlainTimeorTemporal.PlainDateTime;- natural-language dates such as “next Tuesday”;
- locale-specific input formats;
- business calendars, holidays, or business-day arithmetic;
- recurrence rules or schedule generation;
- variables, user-defined functions, or macros;
- implicit conversion between Temporal types (explicit
with-time-zone,to-instant, andto-dateconversions are available); - remote MCP transports.
Architecture
The CLI and MCP server share the complete evaluation pipeline:
CLI ─┐
├── service → parser → typed AST → evaluator → serializer
MCP ─┘
Important files:
| Path | Purpose |
|---|---|
| src/grammar.ebnf | Canonical ISO/IEC 14977 grammar |
| src/parser.ts | Hand-written parser and resource limits |
| src/evaluator.ts | Strictly typed Temporal operator evaluation |
| src/operators/catalog.ts | Operator names, signatures, and descriptions |
| src/service.ts | Shared CLI/MCP evaluation boundary |
| src/cli.ts | CLI entry point |
| src/mcp.ts | MCP server, schemas, and stdio transport |
| test/cases.yaml | Data-driven DSL conformance cases |
Development
The repository pins Bun 1.4.0 through the ./bun wrapper script, which downloads that exact version into download/ on first use and then executes it. Use ./bun in place of bun for every command below; CI does the same, so local and CI behavior match without a separate Bun installation.
Install exactly the locked dependencies:
./bun ci
Run the complete test suite:
./bun test
Run the YAML conformance suite with per-case output:
./bun run test:cases
Run strict TypeScript checking:
./bun run typecheck
Lint and regenerate both the HTML and Markdown grammar diagrams:
./bun run grammar:lint
./bun run grammar:diagram
Generate only one format when needed:
./bun run grammar:diagram:html
./bun run grammar:diagram:markdown
Check that the Claude Code plugin's copy of the Agent Skill matches the canonical skill, or refresh it after editing .agents/skills/timecalc/:
./bun run plugin:check
./bun run plugin:sync
Standalone executables
Build all release executables with Bun:
./bun run build:executables
The version embedded in the executables and their filenames comes from package.json; pass --version X.Y.Z to override it. Outputs are written to dist/:
| Target | Output |
|---|---|
| Linux AMD64 | timecalc-v1.2.3-linux-amd64 |
| Linux ARM64 | timecalc-v1.2.3-linux-arm64 |
| macOS AMD64 | timecalc-v1.2.3-darwin-amd64 |
| macOS ARM64 | timecalc-v1.2.3-darwin-arm64 |
| Windows AMD64 | timecalc-v1.2.3-windows-amd64.exe |
| Windows ARM64 | timecalc-v1.2.3-windows-arm64.exe |
The target table is defined once in scripts/targets.ts and shared by the executable and npm build scripts. The executables contain the Bun runtime and all runtime dependencies; users do not need to install Bun. When the build runs on macOS, it automatically re-signs each macOS executable with the JIT entitlements recommended for Bun standalone executables and verifies the signature. A macOS target cross-compiled on another operating system is left unsigned with a warning; release builds run those targets on macOS.
Build and run a locally signed macOS executable with:
./bun run build:executables -- --target darwin-arm64
codesign --verify --deep --strict dist/timecalc-v*-darwin-arm64
dist/timecalc-v*-darwin-arm64 --version
Build a subset by repeating --target:
./bun run build:executables -- \
--target linux-amd64 \
--target darwin-arm64
Use --outdir PATH to change the output directory. Run ./bun run build:executables -- --help for the complete interface.
npm packages
The npm distribution consists of a launcher package and one package per executable:
| Package | Contents |
|---|---|
@tigerdata/timecalc |
Node.js launcher (bin/timecalc.js) with exact-pinned optionalDependencies on the platform packages |
@tigerdata/timecalc-<target> |
bin/timecalc (or timecalc.exe) for one target, with os and cpu fields so npm installs only the matching package |
The launcher source is committed in npm/timecalc/; its package.json there is a template whose version is always 0.0.0. Everything that is published is generated into the gitignored npm/dist/ directory:
./bun run build:executables
./bun run build:npm
build:npm copies each executable from dist/, writes the platform package.json files, and stamps the release version into the launcher's version and optionalDependencies. Pass --target to generate a subset, --version X.Y.Z to override the version, and --placeholder to generate metadata-only packages (only needed to create a brand-new package on npm, for example when adding a target). Test the result locally by packing and installing the tarballs into a scratch project:
(cd npm/dist/darwin-arm64 && npm pack --pack-destination /tmp/timecalc-npm)
(cd npm/dist/timecalc && npm pack --pack-destination /tmp/timecalc-npm)
mkdir -p /tmp/timecalc-npm/project && cd /tmp/timecalc-npm/project && npm init -y
npm install ../tigerdata-timecalc-darwin-arm64-*.tgz ../tigerdata-timecalc-*.tgz
npx timecalc --version
CI performs the same check for linux-amd64 on every push and pull request.
Releasing
Releases are cut from main with @tigerdata/bump-release:
./bun release patch # or minor, major, or an explicit X.Y.Z
The script refuses to run unless the working tree is clean, the current branch is main and up to date with origin/main, and the new version is greater than the current one and not already tagged. It then bumps version in package.json, commits release: vX.Y.Z, creates the annotated tag vX.Y.Z, and pushes the commit and tag together. Pushing the tag triggers the release workflow.
Automation
The GitHub Actions workflow in .github/workflows/ci.yml runs on pushes to main, pull requests, and manual dispatches. It installs the locked dependencies with ./bun ci, type-checks the project, lints the grammar, checks the plugin skill copy, runs the Bun and YAML test suites, builds the Linux AMD64 executable, and installs and runs the generated npm packages through npx.
The release workflow in .github/workflows/release.yml runs when main is tagged with an exact vX.Y.Z tag that matches the version in package.json (which ./bun release guarantees). It:
- reruns all checks and verifies that the tag matches
package.json; - builds the six executables, running the macOS targets on a macOS runner so they are signed and verified with JIT entitlements;
- packages each executable with
LICENSEandNOTICE, generatesSHA256SUMS, and publishes a GitHub Release with generated release notes (Unix assets use.tar.gz; Windows assets use.zip); - generates the npm packages and publishes them, platform packages first and the launcher last, using npm trusted publishing (GitHub Actions OIDC; no npm tokens are stored). Packages whose version already exists on npm are skipped, so a failed run can be retried.
When adding a new target, its @tigerdata/timecalc-<target> package must first be created on npm with a manual publish (build:npm --placeholder) and then have its trusted publisher configured to this repository's release.yml workflow; the existing packages are already set up.
The automated suite covers:
- literal classification and parser errors;
- calendar, leap-year, and DST semantics;
- every v1 operator;
- CLI text, JSON, stdin, validation, and exit behavior;
- shared-service context validation and resource limits;
- direct MCP handler behavior;
- MCP tool discovery and calls over an in-memory transport;
- a real spawned stdio MCP process;
- parity between MCP output and all YAML fixtures.
License
Copyright 2026 Timescale, Inc., d/b/a Tiger Data.
Licensed under the Apache License, Version 2.0. See NOTICE for attribution information.
Installing Timecalc
This server has no published package — it is built from source. Open the repository and follow its README.
▸ github.com/timescale/timecalc-mcpFAQ
Is Timecalc MCP free?
Yes, Timecalc MCP is free — one-click install via Unyly at no cost.
Does Timecalc need an API key?
No, Timecalc runs without API keys or environment variables.
Is Timecalc hosted or self-hosted?
Self-hosted: the server runs locally on your machine via the install command above.
How do I install Timecalc in Claude Desktop, Claude Code or Cursor?
Open Timecalc on unyly.org, pick your client tab (Claude Desktop, Claude Code, Cursor) and press Install — the config is generated automatically, no JSON editing.
Related MCPs
GitHub
PRs, issues, code search, CI status
by GitHubFilesystem
Secure file operations with configurable access controls.
Memory
Knowledge graph-based persistent memory system.
Template MCP Server
A CLI tool to create a new Model Context Protocol server project with TypeScript support, dual transport options, and an extensible structure
by mcpdotdirectAmap Maps Mcp Server
MCP server for using the AMap Maps API
by duxiaohuiSupabase
Database, auth and storage
by SupabaseEverything
Reference / test server with prompts, resources, and tools.
Git
Tools to read, search, and manipulate Git repositories.
Sequential Thinking
Dynamic and reflective problem-solving through thought sequences.
Time
Time and timezone conversion capabilities.
Compare Timecalc with
Not sure what to pick?
Find your stack in 60 seconds
Author?
Embed badge for your README
Browse similar
All development MCPs
