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
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [1.1.0] - unreleased

### Fixed

- Reject unknown tool parameters instead of silently dropping them. Every
tool's advertised `inputSchema` now sets `additionalProperties: false`, and
every argument schema is `zod.strict()`, so an invented filter raises an
error naming the bad key and the tool's accepted parameters rather than
returning unfiltered results for a different question ([#10])

## [1.0.2] - 2026-07-06

Expand Down Expand Up @@ -65,3 +73,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#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
[#10]: https://github.com/BetaNYC/nyc-record-mcp/issues/10
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.2",
"version": "1.1.0",
"description": "MCP server for NYC City Record notices via NYC Open Data",
"keywords": [
"mcp",
Expand Down
137 changes: 12 additions & 125 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { createRequire } from "node:module";
import { z } from "zod";
import {
searchNotices,
getNoticesByAgency,
Expand All @@ -15,12 +14,8 @@ 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");
import { TOOLS, parseToolArgs } from "./tools.js";

const require = createRequire(import.meta.url);
const { version } = require("../package.json") as { version: string };
Expand All @@ -30,162 +25,54 @@ const server = new Server(
{ 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: [...NOTICE_TYPES],
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(ListToolsRequestSchema, async () => ({ tools: TOOLS }));

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 { query, limit } = parseToolArgs("search_notices", 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 { agency_name, limit } = parseToolArgs("get_notices_by_agency", 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.enum(NOTICE_TYPES), limit: z.number().max(100).optional() })
.parse(args);
const { notice_type, limit } = parseToolArgs("get_notices_by_type", 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 { limit } = parseToolArgs("get_procurement_notices", 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 { limit } = parseToolArgs("get_public_hearings", 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 { limit } = parseToolArgs("get_open_solicitations", 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: isoDate,
end_date: isoDate,
limit: z.number().max(200).optional(),
})
.parse(args);
const { start_date, end_date, limit } = parseToolArgs(
"get_notices_by_date_range",
args
);
const results = await getNoticesByDateRange(start_date, end_date, limit ?? 50);
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
}
Expand Down
Loading
Loading