From a86439e8a2de38b8f03e6b9ca299d40da76482ce Mon Sep 17 00:00:00 2001 From: Noel Hidalgo Date: Sun, 5 Jul 2026 23:56:21 -0400 Subject: [PATCH 1/2] refactor: migrate to McpServer.registerTool, single version source, slim data typing - Replace low-level Server (hand-written ListTools JSON schemas, per-case zod .parse, 7-way switch, repeated JSON content envelopes) with McpServer.registerTool; tool names, descriptions, and input schemas are unchanged from a client's perspective. - Read the server version from package.json via createRequire instead of a hardcoded "1.0.0" that had drifted from package.json's 1.0.1. - Replace the 32-field CityRecordNotice type with Record (results are only JSON.stringify'd); field list kept as a doc comment. - Inline buildUrl into its single caller sodaFetch. - Add minimal node:test setup (npm test) with a tool-list regression test using the SDK's InMemoryTransport. Co-Authored-By: Claude Opus 4.8 --- package.json | 1 + src/city-record.ts | 55 ++++-------- src/index.ts | 203 +------------------------------------------- src/server.ts | 164 +++++++++++++++++++++++++++++++++++ test/tools.test.mjs | 36 ++++++++ 5 files changed, 219 insertions(+), 240 deletions(-) create mode 100644 src/server.ts create mode 100644 test/tools.test.mjs diff --git a/package.json b/package.json index 5170c34..bd3c8d8 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "build": "tsc", "dev": "tsc --watch", "start": "node dist/index.js", + "test": "npm run build && node --test test/", "prepare": "npm run build" }, "dependencies": { diff --git a/src/city-record.ts b/src/city-record.ts index a6b5888..c690d58 100644 --- a/src/city-record.ts +++ b/src/city-record.ts @@ -1,41 +1,22 @@ const BASE_URL = "https://data.cityofnewyork.us"; const DATASET_ID = "dg92-zbpx"; -export type CityRecordNotice = { - request_id?: string; - start_date?: string; - end_date?: string; - agency_name?: string; - type_of_notice_description?: string; - category_description?: string; - short_title?: string; - selection_method_description?: string; - section_name?: string; - special_case_reason_description?: string; - pin?: string; - due_date?: string; - address_to_request?: string; - contact_name?: string; - contact_phone?: string; - email?: string; - contract_amount?: string; - contact_fax?: string; - additional_description_1?: string; - additional_description_2?: string; - additional_description_3?: string; - vendor_name?: string; - vendor_address?: string; - document_links?: string; - event_date?: string; - building_name?: string; - street_address_1?: string; - street_address_2?: string; - city?: string; - state?: string; - zip_code?: string; -}; +/** + * Notices come from the NYC Open Data "City Record Online" dataset + * (dg92-zbpx). Rows are passed through to clients as JSON without + * field-level access; known fields for reference: + * request_id, start_date, end_date, agency_name, + * type_of_notice_description, category_description, short_title, + * selection_method_description, section_name, + * special_case_reason_description, pin, due_date, address_to_request, + * contact_name, contact_phone, email, contract_amount, contact_fax, + * additional_description_1..3, vendor_name, vendor_address, + * document_links, event_date, building_name, street_address_1, + * street_address_2, city, state, zip_code. + */ +export type CityRecordNotice = Record; -function buildUrl(params: Record): string { +async function sodaFetch(params: Record): Promise { const url = new URL(`${BASE_URL}/resource/${DATASET_ID}.json`); for (const [key, value] of Object.entries(params)) { url.searchParams.set(key, value); @@ -44,11 +25,7 @@ function buildUrl(params: Record): string { if (appToken) { url.searchParams.set("$$app_token", appToken); } - return url.toString(); -} - -async function sodaFetch(params: Record): Promise { - const res = await fetch(buildUrl(params)); + const res = await fetch(url.toString()); if (!res.ok) { throw new Error(`NYC Open Data API error ${res.status}: ${res.statusText}`); } diff --git a/src/index.ts b/src/index.ts index ce4d146..46c61a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,205 +1,6 @@ #!/usr/bin/env node -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { - CallToolRequestSchema, - ListToolsRequestSchema, -} from "@modelcontextprotocol/sdk/types.js"; -import { z } from "zod"; -import { - searchNotices, - getNoticesByAgency, - getNoticesByType, - getProcurementNotices, - getPublicHearings, - getOpenSolicitations, - getNoticesByDateRange, -} from "./city-record.js"; - -const server = new Server( - { name: "nyc-record-mcp", version: "1.0.0" }, - { capabilities: { tools: {} } } -); - -server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: "search_notices", - description: - "Full-text search across all NYC City Record notices. Returns recent matching notices sorted by date.", - inputSchema: { - type: "object", - properties: { - query: { type: "string", description: "Search term" }, - limit: { type: "number", description: "Max results (default 25, max 100)" }, - }, - required: ["query"], - }, - }, - { - name: "get_notices_by_agency", - description: - "Get City Record notices published by a specific city agency (partial name match).", - inputSchema: { - type: "object", - properties: { - agency_name: { type: "string", description: "Agency name or partial name, e.g. 'DCAS', 'Parks'" }, - limit: { type: "number", description: "Max results (default 25, max 100)" }, - }, - required: ["agency_name"], - }, - }, - { - name: "get_notices_by_type", - description: - "Get notices filtered by type. Valid types: Solicitation, Award, Intent to Award, Intent to Negotiate, Public Hearings, Public Comment, Meeting, Notice, Vendor List, Sale.", - inputSchema: { - type: "object", - properties: { - notice_type: { - type: "string", - enum: [ - "Solicitation", - "Award", - "Intent to Award", - "Intent to Negotiate", - "Public Hearings", - "Public Comment", - "Meeting", - "Notice", - "Vendor List", - "Sale", - ], - description: "Notice type", - }, - limit: { type: "number", description: "Max results (default 25, max 100)" }, - }, - required: ["notice_type"], - }, - }, - { - name: "get_procurement_notices", - description: - "Get recent procurement-related notices: solicitations, awards, intent to award, vendor lists. Useful for tracking open contracts and recent awards.", - inputSchema: { - type: "object", - properties: { - limit: { type: "number", description: "Max results (default 25, max 100)" }, - }, - }, - }, - { - name: "get_public_hearings", - description: - "Get recent public hearings, public comment periods, and agency meetings from the City Record.", - inputSchema: { - type: "object", - properties: { - limit: { type: "number", description: "Max results (default 25, max 100)" }, - }, - }, - }, - { - name: "get_open_solicitations", - description: - "Get active solicitations (RFPs, RFQs, IFBs) where the due date has not yet passed. Sorted by due date ascending — soonest deadlines first.", - inputSchema: { - type: "object", - properties: { - limit: { type: "number", description: "Max results (default 25, max 100)" }, - }, - }, - }, - { - name: "get_notices_by_date_range", - description: - "Get all City Record notices published within a date range.", - inputSchema: { - type: "object", - properties: { - start_date: { type: "string", description: "Start date, YYYY-MM-DD" }, - end_date: { type: "string", description: "End date, YYYY-MM-DD" }, - limit: { type: "number", description: "Max results (default 50, max 200)" }, - }, - required: ["start_date", "end_date"], - }, - }, - ], -})); - -server.setRequestHandler(CallToolRequestSchema, async (request) => { - const { name, arguments: args } = request.params; - - try { - switch (name) { - case "search_notices": { - const { query, limit } = z - .object({ query: z.string(), limit: z.number().max(100).optional() }) - .parse(args); - const results = await searchNotices(query, limit ?? 25); - return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] }; - } - - case "get_notices_by_agency": { - const { agency_name, limit } = z - .object({ agency_name: z.string(), limit: z.number().max(100).optional() }) - .parse(args); - const results = await getNoticesByAgency(agency_name, limit ?? 25); - return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] }; - } - - case "get_notices_by_type": { - const { notice_type, limit } = z - .object({ notice_type: z.string(), 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) }] }; - } - - case "get_procurement_notices": { - const { limit } = z - .object({ limit: z.number().max(100).optional() }) - .parse(args ?? {}); - const results = await getProcurementNotices(limit ?? 25); - return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] }; - } - - case "get_public_hearings": { - const { limit } = z - .object({ limit: z.number().max(100).optional() }) - .parse(args ?? {}); - const results = await getPublicHearings(limit ?? 25); - return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] }; - } - - case "get_open_solicitations": { - const { limit } = z - .object({ limit: z.number().max(100).optional() }) - .parse(args ?? {}); - const results = await getOpenSolicitations(limit ?? 25); - return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] }; - } - - case "get_notices_by_date_range": { - const { start_date, end_date, limit } = z - .object({ - start_date: z.string(), - end_date: z.string(), - limit: z.number().max(200).optional(), - }) - .parse(args); - const results = await getNoticesByDateRange(start_date, end_date, limit ?? 50); - return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] }; - } - - default: - return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true }; - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { content: [{ type: "text", text: `Error: ${message}` }], isError: true }; - } -}); +import { buildServer } from "./server.js"; const transport = new StdioServerTransport(); -await server.connect(transport); +await buildServer().connect(transport); diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..c3d8348 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,164 @@ +import { createRequire } from "node:module"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { + searchNotices, + getNoticesByAgency, + getNoticesByType, + getProcurementNotices, + getPublicHearings, + getOpenSolicitations, + getNoticesByDateRange, +} from "./city-record.js"; + +const { version } = createRequire(import.meta.url)("../package.json") as { + version: string; +}; + +function jsonResult(results: unknown) { + return { + content: [ + { type: "text" as const, text: JSON.stringify(results, null, 2) }, + ], + }; +} + +export function buildServer(): McpServer { + const server = new McpServer({ name: "nyc-record-mcp", version }); + + server.registerTool( + "search_notices", + { + description: + "Full-text search across all NYC City Record notices. Returns recent matching notices sorted by date.", + inputSchema: { + query: z.string().describe("Search term"), + limit: z + .number() + .max(100) + .optional() + .describe("Max results (default 25, max 100)"), + }, + }, + async ({ query, limit }) => jsonResult(await searchNotices(query, limit ?? 25)) + ); + + server.registerTool( + "get_notices_by_agency", + { + description: + "Get City Record notices published by a specific city agency (partial name match).", + inputSchema: { + agency_name: z + .string() + .describe("Agency name or partial name, e.g. 'DCAS', 'Parks'"), + limit: z + .number() + .max(100) + .optional() + .describe("Max results (default 25, max 100)"), + }, + }, + async ({ agency_name, limit }) => + jsonResult(await getNoticesByAgency(agency_name, limit ?? 25)) + ); + + server.registerTool( + "get_notices_by_type", + { + description: + "Get notices filtered by type. Valid types: Solicitation, Award, Intent to Award, Intent to Negotiate, Public Hearings, Public Comment, Meeting, Notice, Vendor List, Sale.", + inputSchema: { + notice_type: z + .enum([ + "Solicitation", + "Award", + "Intent to Award", + "Intent to Negotiate", + "Public Hearings", + "Public Comment", + "Meeting", + "Notice", + "Vendor List", + "Sale", + ]) + .describe("Notice type"), + limit: z + .number() + .max(100) + .optional() + .describe("Max results (default 25, max 100)"), + }, + }, + async ({ notice_type, limit }) => + jsonResult(await getNoticesByType(notice_type, limit ?? 25)) + ); + + server.registerTool( + "get_procurement_notices", + { + description: + "Get recent procurement-related notices: solicitations, awards, intent to award, vendor lists. Useful for tracking open contracts and recent awards.", + inputSchema: { + limit: z + .number() + .max(100) + .optional() + .describe("Max results (default 25, max 100)"), + }, + }, + async ({ limit }) => jsonResult(await getProcurementNotices(limit ?? 25)) + ); + + server.registerTool( + "get_public_hearings", + { + description: + "Get recent public hearings, public comment periods, and agency meetings from the City Record.", + inputSchema: { + limit: z + .number() + .max(100) + .optional() + .describe("Max results (default 25, max 100)"), + }, + }, + async ({ limit }) => jsonResult(await getPublicHearings(limit ?? 25)) + ); + + server.registerTool( + "get_open_solicitations", + { + description: + "Get active solicitations (RFPs, RFQs, IFBs) where the due date has not yet passed. Sorted by due date ascending — soonest deadlines first.", + inputSchema: { + limit: z + .number() + .max(100) + .optional() + .describe("Max results (default 25, max 100)"), + }, + }, + async ({ limit }) => jsonResult(await getOpenSolicitations(limit ?? 25)) + ); + + server.registerTool( + "get_notices_by_date_range", + { + description: "Get all City Record notices published within a date range.", + inputSchema: { + start_date: z.string().describe("Start date, YYYY-MM-DD"), + end_date: z.string().describe("End date, YYYY-MM-DD"), + limit: z + .number() + .max(200) + .optional() + .describe("Max results (default 50, max 200)"), + }, + }, + async ({ start_date, end_date, limit }) => + jsonResult(await getNoticesByDateRange(start_date, end_date, limit ?? 50)) + ); + + return server; +} diff --git a/test/tools.test.mjs b/test/tools.test.mjs new file mode 100644 index 0000000..0706a54 --- /dev/null +++ b/test/tools.test.mjs @@ -0,0 +1,36 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { buildServer } from "../dist/server.js"; + +test("tool list exposes the seven City Record tools, names unchanged", async () => { + const server = buildServer(); + const client = new Client({ name: "test-client", version: "0.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([ + server.connect(serverTransport), + client.connect(clientTransport), + ]); + + const { tools } = await client.listTools(); + assert.deepEqual( + tools.map((t) => t.name).sort(), + [ + "get_notices_by_agency", + "get_notices_by_date_range", + "get_notices_by_type", + "get_open_solicitations", + "get_procurement_notices", + "get_public_hearings", + "search_notices", + ] + ); + for (const tool of tools) { + assert.ok(tool.description && tool.description.length > 0, `${tool.name} has a description`); + assert.equal(tool.inputSchema.type, "object"); + } + + await client.close(); + await server.close(); +}); From a034f12892cef2b291fecdca9975252c19239d9e Mon Sep 17 00:00:00 2001 From: Noel Hidalgo Date: Sun, 5 Jul 2026 23:56:52 -0400 Subject: [PATCH 2/2] fix: stop double-encoding the SoQL like pattern in getNoticesByAgency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The $where value embedded %25 and encodeURIComponent output, which URLSearchParams.set then encoded again — Socrata received a literal '%25NAME%25' pattern (with %20 for spaces) instead of the % wildcard, so agency matches with spaces or special characters silently failed. Per the SoQL like docs (https://dev.socrata.com/docs/functions/like.html) the wildcard is a literal % in the query; %25 is only its URL encoding, which the HTTP layer already applies once. Build the SoQL value unencoded (escaping single quotes by doubling) and let searchParams.set do the single encoding pass. Test written first; it fails against the old code and passes now. Also fix the test script glob (node --test with a bare directory arg fails on newer Node). Co-Authored-By: Claude Opus 4.8 --- package.json | 2 +- src/city-record.ts | 6 +++++- test/encoding.test.mjs | 29 +++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 test/encoding.test.mjs diff --git a/package.json b/package.json index bd3c8d8..7335da9 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "build": "tsc", "dev": "tsc --watch", "start": "node dist/index.js", - "test": "npm run build && node --test test/", + "test": "npm run build && node --test \"test/*.test.mjs\"", "prepare": "npm run build" }, "dependencies": { diff --git a/src/city-record.ts b/src/city-record.ts index c690d58..cc6b160 100644 --- a/src/city-record.ts +++ b/src/city-record.ts @@ -47,8 +47,12 @@ export async function getNoticesByAgency( agencyName: string, limit = 25 ): Promise { + // 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, "''"); return sodaFetch({ - $where: `upper(agency_name) like upper('%25${encodeURIComponent(agencyName)}%25')`, + $where: `upper(agency_name) like upper('%${escaped}%')`, $limit: String(limit), $order: "start_date DESC", }); diff --git a/test/encoding.test.mjs b/test/encoding.test.mjs new file mode 100644 index 0000000..01b453a --- /dev/null +++ b/test/encoding.test.mjs @@ -0,0 +1,29 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { getNoticesByAgency } from "../dist/city-record.js"; + +test("getNoticesByAgency single-encodes the SoQL like pattern", async (t) => { + let capturedUrl; + t.mock.method(globalThis, "fetch", async (url) => { + capturedUrl = String(url); + return new Response("[]", { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + + await getNoticesByAgency("Parks & Recreation"); + + const where = new URL(capturedUrl).searchParams.get("$where"); + // URLSearchParams.get decodes once; the decoded SoQL must contain the + // literal % wildcard (SoQL `like`: https://dev.socrata.com/docs/functions/like.html) + // and the raw agency name — no residual percent-encoding from a second pass. + assert.equal( + where, + "upper(agency_name) like upper('%Parks & Recreation%')" + ); + // The raw query string must encode % exactly once (%25, not %2525). + const rawQuery = capturedUrl.split("?")[1]; + assert.ok(rawQuery.includes("%25"), "wildcard is URL-encoded once"); + assert.ok(!rawQuery.includes("%2525"), "wildcard is not double-encoded"); +});