Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.0.2] - 2026-07-06

### Fixed

- Stop double-encoding the SoQL `like` pattern in `get_notices_by_agency` —
agency queries with special characters now match correctly ([#6])
- Escape all string values embedded in SoQL `$where` clauses via a shared
`soqlString()` helper (quote-doubling), covering `get_notices_by_type`,
`get_notices_by_agency`, and `get_notices_by_date_range` ([#7])
- Enforce `notice_type` with a `zod` enum derived from a single
`NOTICE_TYPES` constant (live-verified against the dataset), and validate
date params as `YYYY-MM-DD` ([#7])
- `get_open_solicitations` computes "today" in America/New_York instead of
UTC, which dropped same-day deadlines after 8pm ET ([#7])
- Include the (truncated, 300-char) Socrata error body in thrown errors for
actionable diagnostics ([#7])
- Server now reports its version from package.json instead of a hardcoded
string ([#7])

### Security

Expand Down Expand Up @@ -42,10 +56,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
and date range, and retrieving procurement notices, public hearings, and
open solicitations

[Unreleased]: https://github.com/BetaNYC/nyc-record-mcp/compare/cf5a4da...HEAD
[Unreleased]: https://github.com/BetaNYC/nyc-record-mcp/compare/v1.0.2...HEAD
[1.0.2]: https://github.com/BetaNYC/nyc-record-mcp/compare/cf5a4da...v1.0.2
[1.0.1]: https://github.com/BetaNYC/nyc-record-mcp/compare/65deb20...cf5a4da
[1.0.0]: https://github.com/BetaNYC/nyc-record-mcp/releases
[#2]: https://github.com/BetaNYC/nyc-record-mcp/pull/2
[#3]: https://github.com/BetaNYC/nyc-record-mcp/pull/3
[#4]: https://github.com/BetaNYC/nyc-record-mcp/pull/4
[#6]: https://github.com/BetaNYC/nyc-record-mcp/pull/6
[#7]: https://github.com/BetaNYC/nyc-record-mcp/pull/7
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,12 @@ npm run build
npm start
```

Run the test suite (builds first, then runs the Node test runner against `test/*.test.mjs`):

```bash
npm test
```

---

## Configuration
Expand Down Expand Up @@ -251,7 +257,13 @@ With an optional app token:

### Claude Code

Add to your project's `.claude/settings.json`:
Add it with the CLI:

```bash
claude mcp add nyc-record -- npx -y @betanyc/nyc-record-mcp
```

Or add to your project's `.mcp.json` (checked in, shared with your team):

```json
{
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@betanyc/nyc-record-mcp",
"version": "1.0.1",
"version": "1.0.2",
"description": "MCP server for NYC City Record notices via NYC Open Data",
"keywords": [
"mcp",
Expand Down
63 changes: 58 additions & 5 deletions src/city-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,41 @@ export type CityRecordNotice = {
zip_code?: string;
};

/**
* All distinct type_of_notice_description values in the dataset.
* Live-verified 2026-07-06 via
* `$select=type_of_notice_description,count(1)&$group=type_of_notice_description`:
* exactly these 10 values exist (plus ~956k rows with a null type).
*/
export const NOTICE_TYPES = [
"Solicitation",
"Award",
"Intent to Award",
"Intent to Negotiate",
"Public Hearings",
"Public Comment",
"Meeting",
"Notice",
"Vendor List",
"Sale",
] as const;

export type NoticeType = (typeof NOTICE_TYPES)[number];

/**
* Escape a value for embedding in a single-quoted SoQL string literal.
* Single quotes are escaped by doubling per SQL rules
* (https://dev.socrata.com/docs/datatypes/text.html).
*/
export function soqlString(value: string): string {
return value.replace(/'/g, "''");
}

/** Today's date (YYYY-MM-DD) in America/New_York, regardless of host timezone. */
export function nyToday(now: Date = new Date()): string {
return new Intl.DateTimeFormat("en-CA", { timeZone: "America/New_York" }).format(now);
}

function buildUrl(params: Record<string, string>): string {
const url = new URL(`${BASE_URL}/resource/${DATASET_ID}.json`);
for (const [key, value] of Object.entries(params)) {
Expand All @@ -50,7 +85,17 @@ function buildUrl(params: Record<string, string>): string {
async function sodaFetch(params: Record<string, string>): Promise<CityRecordNotice[]> {
const res = await fetch(buildUrl(params));
if (!res.ok) {
throw new Error(`NYC Open Data API error ${res.status}: ${res.statusText}`);
// Socrata puts the useful diagnostic (e.g. malformed SoQL) in the body.
let detail = "";
try {
const body = (await res.text()).trim();
if (body) {
detail = ` — ${body.length > 300 ? `${body.slice(0, 300)}…` : body}`;
}
} catch {
// body unreadable; fall through with status line only
}
throw new Error(`NYC Open Data API error ${res.status}: ${res.statusText}${detail}`);
}
return res.json() as Promise<CityRecordNotice[]>;
}
Expand All @@ -73,7 +118,7 @@ export async function getNoticesByAgency(
// SoQL `like` uses a literal `%` wildcard (https://dev.socrata.com/docs/functions/like.html).
// Build the SoQL value unencoded; URLSearchParams performs the single
// URL-encoding pass. Single quotes are escaped by doubling per SQL rules.
const escaped = agencyName.replace(/'/g, "''");
const escaped = soqlString(agencyName);
return sodaFetch({
$where: `upper(agency_name) like upper('%${escaped}%')`,
$limit: String(limit),
Expand All @@ -86,7 +131,7 @@ export async function getNoticesByType(
limit = 25
): Promise<CityRecordNotice[]> {
return sodaFetch({
$where: `type_of_notice_description='${noticeType}'`,
$where: `type_of_notice_description='${soqlString(noticeType)}'`,
$limit: String(limit),
$order: "start_date DESC",
});
Expand All @@ -95,6 +140,7 @@ export async function getNoticesByType(
export async function getProcurementNotices(
limit = 25
): Promise<CityRecordNotice[]> {
// Values live-verified against the dataset 2026-07-06 (see NOTICE_TYPES).
return sodaFetch({
$where:
"type_of_notice_description in ('Solicitation','Award','Intent to Award','Intent to Negotiate','Vendor List')",
Expand All @@ -106,6 +152,7 @@ export async function getProcurementNotices(
export async function getPublicHearings(
limit = 25
): Promise<CityRecordNotice[]> {
// Values live-verified against the dataset 2026-07-06 (see NOTICE_TYPES).
return sodaFetch({
$where:
"type_of_notice_description in ('Public Hearings','Public Comment','Meeting','Notice')",
Expand All @@ -117,7 +164,9 @@ export async function getPublicHearings(
export async function getOpenSolicitations(
limit = 25
): Promise<CityRecordNotice[]> {
const today = new Date().toISOString().split("T")[0];
// Use the New York calendar date, not UTC: between 8pm and midnight ET the
// UTC date is already "tomorrow", which would wrongly drop same-day deadlines.
const today = nyToday();
return sodaFetch({
$where: `type_of_notice_description='Solicitation' AND due_date >= '${today}'`,
$limit: String(limit),
Expand All @@ -131,7 +180,11 @@ export async function getNoticesByDateRange(
limit = 50
): Promise<CityRecordNotice[]> {
return sodaFetch({
$where: `start_date >= '${startDate}' AND start_date <= '${endDate}'`,
// Live-verified 2026-07-06: every start_date in the dataset carries a
// midnight time component (query with date_extract_hh/mm != 0 returned 0
// rows), so `<= endDate` safely includes the full end day. Revisit if the
// dataset ever starts publishing non-midnight start_date values.
$where: `start_date >= '${soqlString(startDate)}' AND start_date <= '${soqlString(endDate)}'`,
$limit: String(limit),
$order: "start_date DESC",
});
Expand Down
30 changes: 14 additions & 16 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { createRequire } from "node:module";
import { z } from "zod";
import {
searchNotices,
Expand All @@ -14,10 +15,18 @@ import {
getPublicHearings,
getOpenSolicitations,
getNoticesByDateRange,
NOTICE_TYPES,
} from "./city-record.js";

const isoDate = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "must be a date in YYYY-MM-DD format");

const require = createRequire(import.meta.url);
const { version } = require("../package.json") as { version: string };

const server = new Server(
{ name: "nyc-record-mcp", version: "1.0.0" },
{ name: "nyc-record-mcp", version },
{ capabilities: { tools: {} } }
);

Expand Down Expand Up @@ -58,18 +67,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
properties: {
notice_type: {
type: "string",
enum: [
"Solicitation",
"Award",
"Intent to Award",
"Intent to Negotiate",
"Public Hearings",
"Public Comment",
"Meeting",
"Notice",
"Vendor List",
"Sale",
],
enum: [...NOTICE_TYPES],
description: "Notice type",
},
limit: { type: "number", description: "Max results (default 25, max 100)" },
Expand Down Expand Up @@ -150,7 +148,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {

case "get_notices_by_type": {
const { notice_type, limit } = z
.object({ notice_type: z.string(), limit: z.number().max(100).optional() })
.object({ notice_type: z.enum(NOTICE_TYPES), limit: z.number().max(100).optional() })
.parse(args);
const results = await getNoticesByType(notice_type, limit ?? 25);
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
Expand Down Expand Up @@ -183,8 +181,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
case "get_notices_by_date_range": {
const { start_date, end_date, limit } = z
.object({
start_date: z.string(),
end_date: z.string(),
start_date: isoDate,
end_date: isoDate,
limit: z.number().max(200).optional(),
})
.parse(args);
Expand Down
82 changes: 82 additions & 0 deletions test/soql.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
soqlString,
nyToday,
getNoticesByType,
getNoticesByAgency,
getNoticesByDateRange,
searchNotices,
} from "../dist/city-record.js";

function mockFetch(t, { status = 200, body = "[]", statusText = "OK" } = {}) {
const captured = { url: undefined };
t.mock.method(globalThis, "fetch", async (url) => {
captured.url = String(url);
return new Response(body, {
status,
statusText,
headers: { "content-type": "application/json" },
});
});
return captured;
}

test("soqlString doubles embedded single quotes", () => {
assert.equal(soqlString("O'Brien's"), "O''Brien''s");
assert.equal(soqlString("no quotes"), "no quotes");
});

test("getNoticesByType escapes single quotes in the $where clause", async (t) => {
const captured = mockFetch(t);
await getNoticesByType("Int'l Notice");
const where = new URL(captured.url).searchParams.get("$where");
assert.equal(where, "type_of_notice_description='Int''l Notice'");
});

test("getNoticesByAgency escapes single quotes", async (t) => {
const captured = mockFetch(t);
await getNoticesByAgency("Mayor's Office");
const where = new URL(captured.url).searchParams.get("$where");
assert.equal(where, "upper(agency_name) like upper('%Mayor''s Office%')");
});

test("getNoticesByDateRange escapes quotes in date params", async (t) => {
const captured = mockFetch(t);
await getNoticesByDateRange("2026-01-01' OR '1'='1", "2026-02-01");
const where = new URL(captured.url).searchParams.get("$where");
assert.equal(
where,
"start_date >= '2026-01-01'' OR ''1''=''1' AND start_date <= '2026-02-01'"
);
});

test("nyToday returns the New York calendar date", () => {
// 2026-07-07T02:00Z is still 2026-07-06 10pm in New York (EDT, UTC-4).
assert.equal(nyToday(new Date("2026-07-07T02:00:00Z")), "2026-07-06");
// Midday UTC matches the same NY date.
assert.equal(nyToday(new Date("2026-07-06T12:00:00Z")), "2026-07-06");
// Winter (EST, UTC-5): 2026-01-02T04:59Z is 2026-01-01 11:59pm in NY.
assert.equal(nyToday(new Date("2026-01-02T04:59:00Z")), "2026-01-01");
});

test("API errors include truncated response body", async (t) => {
const longBody = "x".repeat(500);
mockFetch(t, { status: 400, statusText: "Bad Request", body: longBody });
await assert.rejects(searchNotices("q"), (err) => {
assert.match(err.message, /NYC Open Data API error 400: Bad Request/);
assert.ok(err.message.includes("x".repeat(300)), "includes body text");
assert.ok(!err.message.includes("x".repeat(301)), "truncated to ~300 chars");
assert.ok(err.message.endsWith("…"), "signals truncation");
return true;
});
});

test("API errors with short bodies include the full body", async (t) => {
mockFetch(t, {
status: 400,
statusText: "Bad Request",
body: '{"message":"Could not parse SoQL query"}',
});
await assert.rejects(searchNotices("q"), /Could not parse SoQL query/);
});
Loading