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 +21 -0
- package/README.md +120 -0
- package/dist/index.js +218 -0
- package/dist/lib/bounded-body.js +86 -0
- package/dist/lib/cache.js +78 -0
- package/dist/lib/device-location.js +251 -0
- package/dist/lib/fetch-errors.js +28 -0
- package/dist/lib/geo.js +83 -0
- package/dist/lib/geolocate.js +160 -0
- package/dist/lib/hours.js +250 -0
- package/dist/lib/location-chain.js +110 -0
- package/dist/lib/logger.js +37 -0
- package/dist/lib/socrata.js +138 -0
- package/dist/schemas.js +224 -0
- package/dist/tools/find-restrooms.js +238 -0
- package/dist/tools/get-restroom-status.js +335 -0
- package/dist/tools/shared.js +205 -0
- package/package.json +60 -0
- package/server.json +35 -0
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
|
+
}
|