From 2e623c4a9a3fbc8c5300af1fd0f31559a392ec8c Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 15 Oct 2026 16:13:38 +0200 Subject: [PATCH] feat(tasks): reject a taskDefaultTtlMs above taskMaxTtlMs A default retention above the ceiling is silently capped, so the value the operator configured is never the one tasks get. Throw a TypeError at registration instead, comparing an explicit taskDefaultTtlMs against taskMaxTtlMs or its 1 hour default. An unset taskDefaultTtlMs is still capped by a smaller taskMaxTtlMs, as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UF4S3w7iA83V9DVsasLJzM --- README.md | 8 ++++---- src/handlers.ts | 2 +- src/index.ts | 6 ++++++ test/task-options-validation.test.ts | 9 ++++++--- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c01ba993..4442b432 100644 --- a/README.md +++ b/README.md @@ -382,10 +382,10 @@ A client-requested `ttl` must be a non-negative integer number of milliseconds; `CreateTaskResult`. `taskDefaultTtlMs`, `taskMaxTtlMs` and `taskLeaseMs` must be positive integer numbers of -milliseconds and `taskMaxRetainedPerPrincipal` a positive integer. Registration throws a -`TypeError` naming the option otherwise, so a typo or a value read from an environment variable -as a string fails at startup rather than failing every task. A `taskDefaultTtlMs` above -`taskMaxTtlMs` is capped to it. +milliseconds, `taskMaxRetainedPerPrincipal` a positive integer, and `taskDefaultTtlMs` must not +exceed `taskMaxTtlMs` (default 1 hour). Registration throws a `TypeError` naming the option +otherwise, so a typo or a value read from an environment variable as a string fails at startup +rather than failing every task. Tasks are stored in memory by default and in Redis when a `redis` option is given, so any instance can serve a poll for a task created on another. diff --git a/src/handlers.ts b/src/handlers.ts index 985240c9..bafd498e 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -923,7 +923,7 @@ const DEFAULT_POLL_INTERVAL = 1000 const DEFAULT_TASK_TTL = 60_000 /** Ceiling on retention, so a client cannot pin resources indefinitely */ -const MAX_TASK_TTL = 3600_000 +export const MAX_TASK_TTL = 3600_000 /** * Floor on retention. A smaller ttl (0 included) would let the task expire diff --git a/src/index.ts b/src/index.ts index f255cb26..29e85ca7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ import oauthClientPlugin from './auth/oauth-client.ts' import authRoutesPlugin from './routes/auth-routes.ts' import { quitWithTimeout } from './redis-quit-with-timeout.ts' import { createJsonSchemaValidator } from './validation/json-schema-validator.ts' +import { MAX_TASK_TTL } from './handlers.ts' import { createMcpClient, type McpClient, @@ -110,6 +111,11 @@ const mcpPlugin = fp(async function (app: FastifyInstance, opts: MCPPluginOption throw new TypeError(`${name} must be a positive integer number of milliseconds, got ${String(value)}`) } } + // An unset default is capped by the ceiling, but an explicit one above it is a mistake + const taskMaxTtlMs = opts.taskMaxTtlMs ?? MAX_TASK_TTL + if (opts.taskDefaultTtlMs !== undefined && opts.taskDefaultTtlMs > taskMaxTtlMs) { + throw new TypeError(`taskDefaultTtlMs (${opts.taskDefaultTtlMs}) must not exceed taskMaxTtlMs (${taskMaxTtlMs})`) + } const tools = new Map() const resources = new Map() const prompts = new Map() diff --git a/test/task-options-validation.test.ts b/test/task-options-validation.test.ts index 2330872c..b487386e 100644 --- a/test/task-options-validation.test.ts +++ b/test/task-options-validation.test.ts @@ -61,9 +61,12 @@ describe('task options validation (#302)', () => { await assertRegistrationAccepts(t, { taskMaxRetainedPerPrincipal: 20 }) }) - test('registration accepts a taskDefaultTtlMs above taskMaxTtlMs, which is capped', async (t: TestContext) => { - await assertRegistrationAccepts(t, { taskDefaultTtlMs: 120_000, taskMaxTtlMs: 60_000 }) - await assertRegistrationAccepts(t, { taskDefaultTtlMs: 2 * 3600_000 }) + test('registration throws when taskDefaultTtlMs exceeds taskMaxTtlMs', async (t: TestContext) => { + await assertRegistrationRejects(t, { taskDefaultTtlMs: 120_000, taskMaxTtlMs: 60_000 }, /taskDefaultTtlMs \(120000\) must not exceed taskMaxTtlMs \(60000\)/) + }) + + test('registration throws when taskDefaultTtlMs exceeds the default taskMaxTtlMs', async (t: TestContext) => { + await assertRegistrationRejects(t, { taskDefaultTtlMs: 2 * 3600_000 }, /taskDefaultTtlMs \(7200000\) must not exceed taskMaxTtlMs \(3600000\)/) }) test('registration accepts taskDefaultTtlMs equal to taskMaxTtlMs', async (t: TestContext) => {