Skip to content
Draft
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"build": "tsc",
"dev": "tsc --watch",
"start": "node dist/index.js",
"test": "npm run build && node --test \"test/*.test.mjs\"",
"prepare": "npm run build"
},
"dependencies": {
Expand Down
61 changes: 21 additions & 40 deletions src/city-record.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;

function buildUrl(params: Record<string, string>): string {
async function sodaFetch(params: Record<string, string>): Promise<CityRecordNotice[]> {
const url = new URL(`${BASE_URL}/resource/${DATASET_ID}.json`);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
Expand All @@ -44,11 +25,7 @@ function buildUrl(params: Record<string, string>): string {
if (appToken) {
url.searchParams.set("$$app_token", appToken);
}
return url.toString();
}

async function sodaFetch(params: Record<string, string>): Promise<CityRecordNotice[]> {
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}`);
}
Expand All @@ -70,8 +47,12 @@ export async function getNoticesByAgency(
agencyName: string,
limit = 25
): Promise<CityRecordNotice[]> {
// 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",
});
Expand Down
203 changes: 2 additions & 201 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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);
Loading
Loading