nyc-restroom-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Ortiz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # nyc-restroom-mcp
2
+
3
+ Ask Claude "where's the nearest public bathroom?" and get a real answer.
4
+
5
+ This is an [MCP](https://modelcontextprotocol.io) server, a small plugin that
6
+ gives an AI assistant (like Claude Desktop or Claude Code) new abilities. This
7
+ one lets it search New York City's official public restroom data, live from
8
+ [NYC Open Data](https://opendata.cityofnewyork.us/). Once installed, you can
9
+ ask things like:
10
+
11
+ - "Find a public restroom near Union Square that's open right now."
12
+ - "Which restrooms near me are wheelchair accessible?"
13
+ - "Is the bathroom at Jaime Campiz Playground actually clean?" (it looks up
14
+ the latest NYC Parks inspection report)
15
+
16
+ It adds two tools:
17
+
18
+ | Tool | What it does |
19
+ | --------------------- | ---------------------------------------------------------------------------------------------------------------------- |
20
+ | `find_restrooms` | Lists open public restrooms near a location, closest first, with hours, accessibility, and changing-station info. |
21
+ | `get_restroom_status` | Looks up one restroom by name or coordinates, including its most recent NYC Parks inspection rating, where one exists. |
22
+
23
+ If you don't give it a location, it can detect yours automatically (details
24
+ below). Everything is read-only: it only ever fetches public city data.
25
+
26
+ ## Install
27
+
28
+ You need [Node.js](https://nodejs.org) 20 or newer. No download or setup
29
+ beyond the snippet for your app.
30
+
31
+ **Claude Code** (one command):
32
+
33
+ ```bash
34
+ claude mcp add nyc-restroom -- npx -y nyc-restroom-mcp
35
+ ```
36
+
37
+ **Claude Desktop**: go to Settings -> Developer -> Edit Config and add this to
38
+ `claude_desktop_config.json`:
39
+
40
+ ```json
41
+ {
42
+ "mcpServers": {
43
+ "nyc-restroom": {
44
+ "command": "npx",
45
+ "args": ["-y", "nyc-restroom-mcp"]
46
+ }
47
+ }
48
+ }
49
+ ```
50
+
51
+ Any other MCP client works the same way: have it launch
52
+ `npx -y nyc-restroom-mcp` as a local (stdio) server.
53
+
54
+ ## Good to know
55
+
56
+ - **Automatic location.** If you ask for nearby restrooms without saying
57
+ where you are, the server tries to figure it out: first from your device
58
+ (macOS only, if you `brew install corelocationcli` and grant it Location
59
+ Services access), otherwise a rough guess from your IP address. If neither
60
+ works, or you're outside NYC, it simply asks for explicit coordinates
61
+ instead of guessing wrong.
62
+ - **Privacy.** Location detection only happens when you omit coordinates,
63
+ and your location is never written to logs. Pass explicit coordinates and
64
+ no location lookup happens at all.
65
+ - **Live data.** Every answer comes straight from NYC Open Data (with a
66
+ short-lived in-memory cache), so results are as current as the city's own
67
+ records. Inspection reports only exist for restrooms run by NYC Parks;
68
+ library and other restrooms will honestly say no inspection data exists.
69
+ - **More detail.** The [docs/](docs/) directory covers the full
70
+ [tool reference](docs/tools.md), [location detection](docs/location.md),
71
+ [data sources](docs/data-sources.md), [configuration](docs/configuration.md),
72
+ and [security model](docs/security.md). The reasoning behind every design
73
+ decision lives in [DECISIONS.md](DECISIONS.md).
74
+
75
+ ### Optional settings
76
+
77
+ Set these as environment variables if you need them (most people don't):
78
+
79
+ | Variable | Purpose |
80
+ | ------------------- | -------------------------------------------------------------------- |
81
+ | `SOCRATA_APP_TOKEN` | A free NYC Open Data app token, for higher rate limits. |
82
+ | `LOG_LEVEL` | `error`, `warn`, or `info` (default `info`). Logs go to stderr only. |
83
+
84
+ A few more exist for testing and advanced setups; see
85
+ [docs/configuration.md](docs/configuration.md).
86
+
87
+ ## Contributing
88
+
89
+ Issues and pull requests are welcome at
90
+ [DanielOrtiz0220/nyc-restroom-mcp](https://github.com/DanielOrtiz0220/nyc-restroom-mcp).
91
+
92
+ Development uses [Bun](https://bun.sh) (1.1+):
93
+
94
+ ```bash
95
+ git clone https://github.com/DanielOrtiz0220/nyc-restroom-mcp.git
96
+ cd nyc-restroom-mcp
97
+ bun install
98
+ bun test # full unit + end-to-end suite, runs completely offline
99
+ bun run typecheck # tsc --noEmit
100
+ bun run lint # eslint, zero warnings allowed
101
+ bun run build # compile src/ to dist/ (what the npm package ships)
102
+ ```
103
+
104
+ To run your local copy inside an MCP client instead of the published package:
105
+
106
+ ```bash
107
+ claude mcp add nyc-restroom -- bun "$(pwd)/src/index.ts"
108
+ ```
109
+
110
+ Useful extras: `bun run test:live` runs one smoke test against the real NYC
111
+ Open Data API (needs network), and `bun test --coverage` reports coverage
112
+ (85%+ lines required on `src/lib/`). Before opening a PR, please make sure
113
+ `bun test`, `bun run typecheck`, and `bun run lint` all pass. The full
114
+ development guide (test architecture, project structure, invariants to
115
+ preserve) is in [docs/development.md](docs/development.md), and
116
+ [DECISIONS.md](DECISIONS.md) explains why things work the way they do.
117
+
118
+ ## License
119
+
120
+ [MIT](LICENSE)
package/dist/index.js ADDED
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+ // Server bootstrap + tool registration.
3
+ //
4
+ // This uses the SDK's low-level `Server` (not the high-level `McpServer`)
5
+ // deliberately: the high-level class pre-validates tool arguments itself and
6
+ // surfaces failures as a raw stringified ZodError, bypassing this codebase's
7
+ // own actionable-error formatting (shared.ts's formatZodError). With the
8
+ // low-level API, raw arguments flow straight into each tool handler, which
9
+ // validates against the full refined zod schema and owns every error message
10
+ // end to end - SPEC.md's "validation failures return actionable tool errors"
11
+ // contract, enforced in exactly one place per tool.
12
+ //
13
+ // Per SPEC.md, stdout is reserved entirely for the MCP JSON-RPC protocol -
14
+ // every log line anywhere in this codebase goes through `lib/logger.ts`,
15
+ // which only ever writes to stderr.
16
+ import { readFileSync } from 'node:fs';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
19
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
20
+ import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
21
+ import { z } from 'zod';
22
+ import { findRestroomsInputSchema, getRestroomStatusInputSchema, } from './schemas.js';
23
+ import { SocrataClient } from './lib/socrata.js';
24
+ import { TtlLruCache } from './lib/cache.js';
25
+ import { Geolocator } from './lib/geolocate.js';
26
+ import { DeviceLocationProvider } from './lib/device-location.js';
27
+ import { LocationChain } from './lib/location-chain.js';
28
+ import { logger } from './lib/logger.js';
29
+ import { createFindRestroomsHandler } from './tools/find-restrooms.js';
30
+ import { createGetRestroomStatusHandler } from './tools/get-restroom-status.js';
31
+ /** Reads name/version from package.json so the advertised server identity can't drift from the manifest. */
32
+ function readServerInfo() {
33
+ const packageJsonPath = fileURLToPath(new URL('../package.json', import.meta.url));
34
+ const raw = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
35
+ const name = typeof raw.name === 'string' ? raw.name : 'nyc-restroom-mcp';
36
+ const version = typeof raw.version === 'string' ? raw.version : '0.0.0';
37
+ return { name, version };
38
+ }
39
+ /**
40
+ * Converts a zod tool-input schema into the JSON Schema advertised via
41
+ * tools/list. Cross-field `.refine()` rules aren't representable in JSON
42
+ * Schema and are dropped here - each handler enforces them by parsing the
43
+ * full zod schema itself.
44
+ */
45
+ function toInputJsonSchema(schema) {
46
+ const json = { ...z.toJSONSchema(schema, { io: 'input' }) };
47
+ // The $schema meta-key is noise in a tools/list response.
48
+ delete json.$schema;
49
+ return json;
50
+ }
51
+ // Both tools only ever read data (no write operations, per SPEC.md) and reach
52
+ // out to the open network (NYC Open Data; find_restrooms's no-coordinates
53
+ // path also uses a geoip service), so readOnlyHint/openWorldHint let clients
54
+ // apply their read-only-tool safety policy instead of a stricter default.
55
+ const READ_ONLY_OPEN_WORLD = { readOnlyHint: true, openWorldHint: true };
56
+ const TOOLS = [
57
+ {
58
+ name: 'find_restrooms',
59
+ title: 'Find nearby NYC public restrooms',
60
+ description: 'Finds operational NYC public restrooms near a given point, sorted by distance ascending. ' +
61
+ 'latitude/longitude may both be omitted to search from your current location, detected ' +
62
+ 'automatically (precise on-device location if available, otherwise an approximate ' +
63
+ 'IP-based guess) - only used when that location is within New York City, otherwise this ' +
64
+ 'tool returns an error asking for explicit coordinates instead. Radius defaults to 800m ' +
65
+ 'and is clamped to a 5000m maximum. Set open_now to filter to restrooms currently open ' +
66
+ '(restrooms with unparseable hours are kept, marked "unknown", rather than guessed at).',
67
+ inputSchema: toInputJsonSchema(findRestroomsInputSchema),
68
+ annotations: READ_ONLY_OPEN_WORLD,
69
+ },
70
+ {
71
+ name: 'get_restroom_status',
72
+ title: 'Get a specific NYC restroom and its latest inspection status',
73
+ description: 'Looks up one NYC public restroom by name or by nearest coordinates, and - where NYC Parks ' +
74
+ 'inspection data is available for it - reports its most recent condition ratings. Only ' +
75
+ 'restrooms operated by NYC Parks are covered by inspection data; other operators (libraries, ' +
76
+ 'automated public toilets, private operators) will report status "no_inspection_data".',
77
+ inputSchema: toInputJsonSchema(getRestroomStatusInputSchema),
78
+ annotations: READ_ONLY_OPEN_WORLD,
79
+ },
80
+ ];
81
+ /** Builds the Server with both tools wired up, unconnected to any transport (so tests can construct one). */
82
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- Server's deprecation notice says "only use for advanced use cases"; owning tool-argument validation (see file header) is exactly that case.
83
+ function buildServer() {
84
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- see above.
85
+ const server = new Server(readServerInfo(), { capabilities: { tools: {} } });
86
+ // One SocrataClient for the whole process: it is stateless aside from its
87
+ // constructor options (baseUrl/token/fetchFn), all of which are read once
88
+ // from the environment at construction time (see lib/socrata.ts) - this is
89
+ // the injectable-fetch seam SPEC.md requires stay intact end to end, so
90
+ // SOCRATA_BASE_URL (used by E2E tests to point at a local fixture server)
91
+ // flows through untouched here.
92
+ const socrata = new SocrataClient();
93
+ // One Geolocator (IP-based fallback) and one DeviceLocationProvider
94
+ // (precise on-device CoreLocation, tried first) for the whole process,
95
+ // composed into a single LocationChain - see src/lib/location-chain.ts and
96
+ // DECISIONS.md's Phase 8 section for the full chain design. Geolocator's
97
+ // constructor reads GEOIP_BASE_URL from the environment once (defaulting
98
+ // to the real ipapi.co), and DeviceLocationProvider's constructor reads
99
+ // CORELOCATION_CMD once (defaulting to "CoreLocationCLI" resolved via
100
+ // PATH) - both are the seams E2E tests use to point this server at local
101
+ // fixtures/stubs instead of the real services - see
102
+ // tests/e2e/geolocation.e2e.test.ts. Only find_restrooms uses this; per
103
+ // DECISIONS.md's scoping note, get_restroom_status's coordinate variant
104
+ // still requires explicit latitude/longitude and never auto-locates.
105
+ const locationChain = new LocationChain({
106
+ deviceLocation: new DeviceLocationProvider(),
107
+ geolocator: new Geolocator(),
108
+ });
109
+ // A single restrooms-dataset cache is shared between both tools, since
110
+ // both query i7jb-7jku (find_restrooms via within_circle + status filter,
111
+ // get_restroom_status via name search or an unfiltered within_circle
112
+ // "nearest" search). Each query shape uses a distinctly prefixed cache key
113
+ // ("circle:", "name:", "nearest:" - see the two tool files), so sharing one
114
+ // instance cannot cause a cross-query-shape collision; it just means the
115
+ // two tools' upstream restroom lookups share one LRU capacity budget
116
+ // instead of each needing their own. The three Parks Inspection Program
117
+ // hop caches are separate instances because each holds a differently
118
+ // shaped row type (SitesRow / InspectionsRow / ConditionsRow) and is only
119
+ // ever used by get_restroom_status.
120
+ const restroomsCache = new TtlLruCache();
121
+ const sitesCache = new TtlLruCache();
122
+ const inspectionsCache = new TtlLruCache();
123
+ const conditionsCache = new TtlLruCache();
124
+ const handlers = {
125
+ find_restrooms: createFindRestroomsHandler({ socrata, restroomsCache, locationChain }),
126
+ get_restroom_status: createGetRestroomStatusHandler({
127
+ socrata,
128
+ restroomsCache,
129
+ sitesCache,
130
+ inspectionsCache,
131
+ conditionsCache,
132
+ }),
133
+ };
134
+ server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: TOOLS }));
135
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
136
+ const handler = handlers[request.params.name];
137
+ if (handler === undefined) {
138
+ // Per the MCP spec, an unknown tool name is a protocol-level invalid
139
+ // params error, not a tool execution error.
140
+ throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${request.params.name}`);
141
+ }
142
+ return handler(request.params.arguments ?? {});
143
+ });
144
+ return server;
145
+ }
146
+ // Documented defaults for the two overridable base URLs - kept here (not
147
+ // imported from lib/socrata.ts/lib/geolocate.ts) purely to avoid a second
148
+ // export just for this comparison; if either module's own default ever
149
+ // changes, this constant must be updated to match.
150
+ const DEFAULT_SOCRATA_BASE_URL = 'https://data.cityofnewyork.us';
151
+ const DEFAULT_GEOIP_BASE_URL = 'https://ipapi.co';
152
+ /**
153
+ * Logs one info line for `envVarName` when it is set to something other than
154
+ * `defaultUrl` - only the origin (scheme + host + port) is logged, never the
155
+ * full URL or any embedded credentials (a URL can carry a `user:pass@host`
156
+ * component), per this project's "never log anything sensitive" convention.
157
+ * If the override isn't even a parseable URL, that fact alone is logged
158
+ * instead of the raw value, for the same reason.
159
+ */
160
+ function logBaseUrlOverrideIfPresent(envVarName, value, defaultUrl) {
161
+ if (value === undefined || value === defaultUrl) {
162
+ return;
163
+ }
164
+ let origin;
165
+ try {
166
+ origin = new URL(value).origin;
167
+ }
168
+ catch {
169
+ origin = '(override is not a parseable URL)';
170
+ }
171
+ logger.info(`${envVarName} is overridden from its documented default`, { origin });
172
+ }
173
+ /** Startup notice when either upstream base URL is overridden, so logs show whether a deployment talks to the real hosts or a leftover test override. */
174
+ function logStartupOverrideNotices() {
175
+ logBaseUrlOverrideIfPresent('SOCRATA_BASE_URL', process.env.SOCRATA_BASE_URL, DEFAULT_SOCRATA_BASE_URL);
176
+ logBaseUrlOverrideIfPresent('GEOIP_BASE_URL', process.env.GEOIP_BASE_URL, DEFAULT_GEOIP_BASE_URL);
177
+ }
178
+ /**
179
+ * Exits cleanly (code 0) once stdin closes: with stdin gone, a stdio-only
180
+ * server can never receive another message, and without this it would linger
181
+ * as an orphan process (verified live). Both 'end' and 'close' are listened
182
+ * for - which fires first depends on how the client closed the pipe - with a
183
+ * guard so firing both doesn't log/exit twice.
184
+ */
185
+ function installStdinCloseHandler() {
186
+ let hasExited = false;
187
+ const handleStdinClosed = () => {
188
+ if (hasExited) {
189
+ return;
190
+ }
191
+ hasExited = true;
192
+ logger.info('stdin closed - shutting down cleanly', {});
193
+ process.exit(0);
194
+ };
195
+ process.stdin.on('end', handleStdinClosed);
196
+ process.stdin.on('close', handleStdinClosed);
197
+ }
198
+ async function main() {
199
+ logStartupOverrideNotices();
200
+ installStdinCloseHandler();
201
+ const server = buildServer();
202
+ const transport = new StdioServerTransport();
203
+ await server.connect(transport);
204
+ const info = readServerInfo();
205
+ logger.info('nyc-restroom-mcp server started', { name: info.name, version: info.version });
206
+ }
207
+ main().catch((error) => {
208
+ // A failure here means the server never successfully attached to its
209
+ // transport (or crashed before doing so) - there is no client connection
210
+ // to report an MCP tool error through, so the only correct move is to log
211
+ // to stderr (stdout stays clean either way, since nothing has been
212
+ // written to it yet) and exit nonzero so the parent process (Claude
213
+ // Desktop/Code, or a shell) sees the failure.
214
+ logger.error('nyc-restroom-mcp failed to start', {
215
+ error: error instanceof Error ? error.message : String(error),
216
+ });
217
+ process.exit(1);
218
+ });
@@ -0,0 +1,86 @@
1
+ // Reads a fetch() Response body up to a byte cap, refusing to buffer past
2
+ // it. Used by both lib/socrata.ts (5 MB cap) and lib/geolocate.ts (64 KB
3
+ // cap) so neither client will ever fully buffer an arbitrarily large
4
+ // upstream response into memory - see DECISIONS.md's security-review
5
+ // section: a reviewer demonstrated parsing and caching a 300k-row response
6
+ // from an unbounded `response.json()` call, which is a DoS vector (a
7
+ // malicious or misbehaving upstream, or a MITM on an unauthenticated HTTP
8
+ // connection, could hand this server a body large enough to exhaust its
9
+ // memory).
10
+ //
11
+ // `Content-Length` is checked first when present, purely as a fast rejection
12
+ // for the common case - it is NOT trusted on its own, since a server can
13
+ // omit it, lie about it, or send a chunked response with no length header at
14
+ // all. The actual enforcement happens while streaming: this function reads
15
+ // the body in chunks via the underlying `ReadableStreamDefaultReader` and
16
+ // aborts (cancelling the stream) the moment the running total exceeds
17
+ // `maxBytes`, regardless of what any header claimed.
18
+ /**
19
+ * Reads any `ReadableStream<Uint8Array>` up to `maxBytes`, decoding it as
20
+ * UTF-8 text on success. This is the lower-level primitive both
21
+ * `readBodyWithCap` (below, for fetch() Response bodies) and
22
+ * `lib/device-location.ts` (for a spawned CoreLocationCLI subprocess's
23
+ * stdout, which is a stream but never wrapped in a Response) build on -
24
+ * neither an HTTP response nor a subprocess's stdout should ever be buffered
25
+ * without a hard ceiling, for the same DoS reasoning documented at the top
26
+ * of this file.
27
+ */
28
+ export async function readStreamWithCap(stream, maxBytes) {
29
+ const reader = stream.getReader();
30
+ const chunks = [];
31
+ let totalBytes = 0;
32
+ // Sequential await-per-chunk is the only correct way to read a stream.
33
+ for (;;) {
34
+ const { done, value } = await reader.read();
35
+ if (done) {
36
+ break;
37
+ }
38
+ totalBytes += value.byteLength;
39
+ if (totalBytes > maxBytes) {
40
+ // Stop pulling more data immediately - cancelling releases the
41
+ // underlying connection/pipe rather than leaving it half-read.
42
+ await reader.cancel();
43
+ return {
44
+ ok: false,
45
+ reason: `stream exceeded the ${String(maxBytes)}-byte cap while reading`,
46
+ };
47
+ }
48
+ chunks.push(value);
49
+ }
50
+ const combined = new Uint8Array(totalBytes);
51
+ let offset = 0;
52
+ for (const chunk of chunks) {
53
+ combined.set(chunk, offset);
54
+ offset += chunk.byteLength;
55
+ }
56
+ return { ok: true, text: new TextDecoder().decode(combined) };
57
+ }
58
+ /**
59
+ * Reads `response.body` up to `maxBytes`, decoding it as UTF-8 text on
60
+ * success. Returns `{ ok: false, reason }` instead of throwing when the cap
61
+ * is exceeded (by a trusted `Content-Length` pre-check or by the streamed
62
+ * byte count), so callers can turn that into whatever "clean, non-retryable
63
+ * failure" shape their own error type uses (SocrataError, or a
64
+ * GeolocateResultUnavailable) without this module needing to know about
65
+ * either one.
66
+ */
67
+ export async function readBodyWithCap(response, maxBytes) {
68
+ const contentLengthHeader = response.headers.get('content-length');
69
+ if (contentLengthHeader !== null) {
70
+ const contentLength = Number(contentLengthHeader);
71
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) {
72
+ return {
73
+ ok: false,
74
+ reason: `Content-Length ${String(contentLength)} exceeds the ${String(maxBytes)}-byte cap`,
75
+ };
76
+ }
77
+ }
78
+ const body = response.body;
79
+ if (body === null) {
80
+ // No body at all (e.g. a 204-style response) - nothing to bound, and
81
+ // JSON.parse('') will fail the same way it would for any other empty
82
+ // body, which is the caller's existing "not valid JSON" failure path.
83
+ return { ok: true, text: '' };
84
+ }
85
+ return readStreamWithCap(body, maxBytes);
86
+ }
@@ -0,0 +1,78 @@
1
+ // Generic in-memory TTL + LRU cache, backed by a plain `Map`. SPEC.md rules
2
+ // out any external cache dependency (Redis etc), and the whole server is a
3
+ // single long-lived process, so a `Map` with lazy expiry checks on read is
4
+ // all that's needed - see DECISIONS.md's "Cache design" entry for the
5
+ // reasoning behind each choice below (lazy expiry vs a timer, why there's no
6
+ // injected clock, how LRU order is tracked).
7
+ const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
8
+ const DEFAULT_MAX_ENTRIES = 500;
9
+ /**
10
+ * A TTL + LRU cache keyed by `K`, storing values of type `V`.
11
+ *
12
+ * TTL: each entry expires `ttlMs` after it was last written. Expiry is
13
+ * checked lazily on `get` - there is no background timer, since nothing in
14
+ * this server needs proactive eviction (the cache is only ever read in
15
+ * response to an incoming tool call).
16
+ *
17
+ * LRU: `Map` preserves insertion order under iteration, so recency is
18
+ * tracked by deleting and re-inserting a key whenever it is read (`get` hit)
19
+ * or written (`set`, including an update to an existing key). When a `set`
20
+ * would push the cache past `maxEntries`, the least-recently-used entry
21
+ * (the first key in iteration order) is evicted first.
22
+ *
23
+ * This class reads `Date.now()` directly rather than taking an injected
24
+ * clock function. `bun:test`'s `setSystemTime` patches the global `Date`
25
+ * object, which `Date.now()` observes automatically, so a custom clock seam
26
+ * would add a constructor parameter for no test benefit in this codebase.
27
+ */
28
+ export class TtlLruCache {
29
+ store = new Map();
30
+ ttlMs;
31
+ maxEntries;
32
+ constructor(options = {}) {
33
+ this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
34
+ this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
35
+ }
36
+ /**
37
+ * Returns the cached value for `key`, or `undefined` if there is no entry
38
+ * or the entry has expired. A hit refreshes the key's recency (moves it to
39
+ * the "most recently used" end); an expired entry is deleted as a side
40
+ * effect of being observed, so it doesn't linger and count against
41
+ * `maxEntries`.
42
+ */
43
+ get(key) {
44
+ const entry = this.store.get(key);
45
+ if (entry === undefined) {
46
+ return undefined;
47
+ }
48
+ if (Date.now() >= entry.expiresAt) {
49
+ this.store.delete(key);
50
+ return undefined;
51
+ }
52
+ // Delete + re-insert marks this key most-recently-used.
53
+ this.store.delete(key);
54
+ this.store.set(key, entry);
55
+ return entry.value;
56
+ }
57
+ /**
58
+ * Stores `value` under `key` with a fresh TTL starting now, evicting the
59
+ * least-recently-used entry first if the cache is at capacity.
60
+ */
61
+ set(key, value) {
62
+ // If this key already exists, drop it first so the size check below is
63
+ // accurate (an update to an existing key should never trigger eviction
64
+ // of a different entry).
65
+ this.store.delete(key);
66
+ if (this.store.size >= this.maxEntries) {
67
+ const oldestKey = this.store.keys().next().value;
68
+ if (oldestKey !== undefined) {
69
+ this.store.delete(oldestKey);
70
+ }
71
+ }
72
+ this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
73
+ }
74
+ /** Number of entries currently stored, including any not-yet-expired-but-stale ones. */
75
+ get size() {
76
+ return this.store.size;
77
+ }
78
+ }