diff --git a/src/backend/custom.d.ts b/src/backend/custom.d.ts index 39cdc3623c..b937d55c5d 100644 --- a/src/backend/custom.d.ts +++ b/src/backend/custom.d.ts @@ -1,4 +1,5 @@ import { Organization, Prisma } from '@prisma/client'; +import { AuthInfo } from '@modelcontextprotocol/server'; import { User as SharedUser } from 'shared'; declare global { @@ -7,6 +8,8 @@ declare global { currentUser: SharedUser; organization: Organization; currentCar?: Prisma.CarGetPayload<{ include: { wbsElement: true } }>; + /** set by attachAuthInfo and read by the MCP handler via toNodeHandler */ + auth?: AuthInfo; } } } diff --git a/src/backend/index.ts b/src/backend/index.ts index e610f4a422..cde8783ab0 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -30,7 +30,9 @@ import calendarRouter from './src/routes/calendar.routes.js'; import prospectiveSponsorRouter from './src/routes/prospective-sponsor.routes.js'; import attendanceRouter from './src/routes/attendance.routes.js'; import icsRouter from './src/routes/ics.routes.js'; -import mcpRouter from './src/routes/mcp.routes.js'; +import agentRouter from './src/routes/agent.routes.js'; +import { mcpNodeHandler } from './src/mcp/handler.js'; +import { attachAuthInfo, requireApiToken } from './src/utils/mcp-auth.utils.js'; import dashboardsRouter from './src/routes/dashboards.routes.js'; const app = express(); @@ -94,7 +96,15 @@ app.use('/ics', icsRouter); // API token routes — mounted before the JWT middleware so that per-user API tokens authenticate here // and ONLY here. Keeping this above the cookie middleware is what stops a token from reaching the // rest of the API. -app.use('/mcp', mcpRouter); +app.use('/agent', agentRouter); + +// The MCP endpoint, authenticated with the same per-user API tokens. JSON-RPC requires POST, so it +// cannot sit behind the readOnlyGuard the /agent router uses and is mounted separately; registering +// every tool through registerReadOnlyTool in src/mcp/tools.ts is what keeps it read only instead. +// The handler is async, so its rejections are forwarded to the error handler rather than dropped. +app.all('/mcp', requireApiToken, attachAuthInfo, (req, res, next) => { + mcpNodeHandler(req, res, req.body).catch(next); +}); // ensure each request is authorized using JWT app.use(isProd ? requireJwtProd : requireJwtDev); diff --git a/src/backend/package.json b/src/backend/package.json index 704c6adb8d..7206b9b274 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -10,6 +10,8 @@ "prisma:manual": "tsx --import dotenv/config ./src/prisma/manual.ts" }, "dependencies": { + "@modelcontextprotocol/node": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", "@prisma/client": "^6.2.1", "@slack/bolt": "^3.22.0", "@types/concat-stream": "^2.0.0", @@ -39,7 +41,8 @@ "nodemailer": "^6.9.1", "prisma": "^6.2.1", "shared": "1.0.0", - "twisters": "^1.1.0" + "twisters": "^1.1.0", + "zod": "^4.4.3" }, "devDependencies": { "@types/express-jwt": "^6.0.4", diff --git a/src/backend/src/controllers/mcp.controllers.ts b/src/backend/src/controllers/agent.controllers.ts similarity index 81% rename from src/backend/src/controllers/mcp.controllers.ts rename to src/backend/src/controllers/agent.controllers.ts index e23fd13aa2..e3745c46a9 100644 --- a/src/backend/src/controllers/mcp.controllers.ts +++ b/src/backend/src/controllers/agent.controllers.ts @@ -1,9 +1,8 @@ import { NextFunction, Request, Response } from 'express'; import { getRoleInOrganization } from '../utils/mcp-auth.utils.js'; -import { HttpException } from '../utils/errors.utils.js'; import McpService from '../services/mcp.services.js'; -export default class McpController { +export default class AgentController { /** * Confirms an API token is valid and reports who it resolved to. This is deliberately verbose * about identity so a client can verify the whole token -> user -> organization -> role chain. @@ -32,16 +31,10 @@ export default class McpController { } } - static async getProjectsByCarNumber(req: Request, res: Response, next: NextFunction) { + static async getProjects(req: Request, res: Response, next: NextFunction) { try { - const { carNumber } = req.params as Record; - const parsedCarNumber = Number(carNumber); - - if (!Number.isInteger(parsedCarNumber) || parsedCarNumber < 0) { - throw new HttpException(400, `"${carNumber}" is not a valid car number`); - } - - const projects = await McpService.getProjectsByCarNumber(parsedCarNumber, req.organization); + const { carNumber, offset } = req.query as Record; + const projects = await McpService.getProjects(req.organization, carNumber, offset ? Number(offset) : undefined); res.status(200).json(projects); } catch (error: unknown) { @@ -74,7 +67,8 @@ export default class McpController { static async getTasks(req: Request, res: Response, next: NextFunction) { try { const { wbsNum } = req.params as Record; - const tasks = await McpService.getTasks(wbsNum, req.organization); + const { offset } = req.query as Record; + const tasks = await McpService.getTasks(wbsNum, req.organization, offset ? Number(offset) : undefined); res.status(200).json(tasks); } catch (error: unknown) { diff --git a/src/backend/src/mcp/errors.ts b/src/backend/src/mcp/errors.ts new file mode 100644 index 0000000000..c967d73099 --- /dev/null +++ b/src/backend/src/mcp/errors.ts @@ -0,0 +1,44 @@ +import { CallToolResult } from '@modelcontextprotocol/server'; +import { HttpException } from '../utils/errors.utils.js'; + +/** + * Serializes a tool result. + * @param value the value to return to the model + */ +export const toolJson = (value: unknown): CallToolResult => ({ + content: [{ type: 'text', text: JSON.stringify(value) }] +}); + +/** + * Runs a tool handler, turning failures into tool errors the model can read and recover from + * rather than protocol errors it never sees. + * + * Expected failures (a bad WBS number, a missing project) carry their message plus a hint telling + * the model how to get valid input. Anything unexpected is logged and reported generically, so + * database internals never reach the model or the user. + * + * @param recoveryHint what the model should try next when the call fails for an expected reason + * @param run the handler to execute + */ +export const withToolErrors = async (recoveryHint: string, run: () => Promise): Promise => { + try { + return toolJson(await run()); + } catch (error: unknown) { + if (error instanceof HttpException) { + // punctuate before the hint so the two sentences do not run together for the model + const message = /[.!?]$/.test(error.message) ? error.message : `${error.message}.`; + + return { + content: [{ type: 'text', text: `${message} ${recoveryHint}`.trim() }], + isError: true + }; + } + + console.error('[mcp] unexpected tool failure:', error); + + return { + content: [{ type: 'text', text: 'FinishLine failed to handle this request. Report this to the software team.' }], + isError: true + }; + } +}; diff --git a/src/backend/src/mcp/handler.ts b/src/backend/src/mcp/handler.ts new file mode 100644 index 0000000000..0828a64cf5 --- /dev/null +++ b/src/backend/src/mcp/handler.ts @@ -0,0 +1,41 @@ +import { createMcpHandler } from '@modelcontextprotocol/server'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { HttpException } from '../utils/errors.utils.js'; +import { AgentContext, buildMcpServer } from './tools.js'; + +/** + * Reads the authenticated caller back out of the pass-through authInfo attachAuthInfo set. + * + * Nothing should reach this handler without going through requireApiToken and attachAuthInfo first, + * so a miss is a wiring mistake rather than a bad token. Failing loudly here beats destructuring + * undefined inside the server factory and surfacing as an unexplained protocol error. + * + * @param authInfo the pass-through auth the transport forwarded + * @returns the authenticated user and organization + * @throws if the caller was never resolved + */ +const getAgentContext = (authInfo: unknown): AgentContext => { + const context = (authInfo as { extra?: unknown } | undefined)?.extra as AgentContext | undefined; + + if (!context?.user || !context?.organization) { + throw new HttpException(401, 'Authentication Failed: the request never resolved to a user and organization'); + } + + return context; +}; + +/** + * The MCP handler for FinishLine. + * + * The factory runs once per request, so each request gets a fresh server built for its own caller + * and nothing is retained between requests. That is what makes the endpoint stateless and safe to + * run behind a load balancer with more than one instance. + * + * responseMode 'json' pins plain JSON responses instead of upgrading to server sent events; we emit + * no progress or logging notifications, and it keeps the endpoint easy to test with curl. + */ +const mcpHandler = createMcpHandler(({ authInfo }) => buildMcpServer(getAgentContext(authInfo)), { + responseMode: 'json' +}); + +export const mcpNodeHandler = toNodeHandler(mcpHandler); diff --git a/src/backend/src/mcp/tools.ts b/src/backend/src/mcp/tools.ts new file mode 100644 index 0000000000..0034a742d9 --- /dev/null +++ b/src/backend/src/mcp/tools.ts @@ -0,0 +1,194 @@ +import { Organization } from '@prisma/client'; +import { CallToolResult, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; +import { User } from 'shared'; +import McpService from '../services/mcp.services.js'; +import { withToolErrors } from './errors.js'; + +/** The authenticated caller a server instance is built for. */ +export interface AgentContext { + user: User; + organization: Organization; +} + +/** + * Shared explanation of WBS numbering. The model has no prior knowledge of this scheme, and the + * tool description is the only place it can learn it. + */ +const WBS_NUM_DESCRIPTION = + 'A project WBS number, formatted "car.project.work_package" — for example "1.2.0". The third component is ' + + 'always 0 for a project; something like "1.2.3" is a work package inside that project, not a ' + + 'project, and will be rejected. Get valid numbers from finishline_list_projects.'; + +const LIST_PROJECTS_HINT = 'Call finishline_list_projects to see the valid WBS numbers.'; + +/** + * The shape a WBS number has to have. The caller here is a model turning free text into arguments, + * so it will happily send "1.2" or the project's name. Enforcing the shape in the schema means the + * framework rejects those with a validation error naming the field, which the model can act on, + * rather than the string reaching the service. validateWBS parses each section with parseInt, so + * without this something like "1abc.2.0" would be quietly read as 1.2.0 instead of refused. + */ +const wbsNumSchema = z + .string() + .regex(/^\d+\.\d+\.\d+$/, 'WBS number must be three numbers separated by dots, like "1.2.0"') + .describe(WBS_NUM_DESCRIPTION); + +/** + * An ISO calendar date. Same reasoning as the WBS number: a model will send "next Monday" if the + * schema lets it, and an unparseable date is far cheaper to reject here than downstream. + * @param description what the date means, for the model + */ +const isoDateSchema = (description: string) => + z.iso.date('Date must be an ISO date, such as "2026-09-01".').describe(description); + +/** + * The offset into a paged list. Only the two tools whose lists can outgrow a page take one; the + * response carries a nextOffset to feed back in, so the model never has to work the arithmetic out. + * @param items what is being paged, for the model + */ +const offsetSchema = (items: string) => + z + .number() + .int() + .min(0) + .optional() + .describe( + `How many ${items} to skip. Omit this for the first page, then pass the nextOffset from the ` + + 'previous response to get the next one. A response with no nextOffset is the last page.' + ); + +/** + * Registers a read only tool. + * + * Every tool goes through here, and readOnlyHint is set in one place rather than per tool. The /mcp + * endpoint accepts POST (JSON-RPC requires it) so it cannot sit behind the readOnlyGuard the /agent + * router uses; this is what keeps the endpoint read only instead. Adding a tool that writes means + * changing this function, which is deliberately the same edit as revisiting the auth in front of it. + * + * @param server the server to register on + * @param name the tool name the model calls + * @param config the tool's title, description, and input schema + * @param handler the tool implementation + */ +const registerReadOnlyTool = ( + server: McpServer, + name: string, + config: { title: string; description: string; inputSchema: z.ZodObject }, + handler: (args: z.infer>) => Promise +): void => { + server.registerTool(name, { ...config, annotations: { readOnlyHint: true } }, handler); +}; + +/** + * Builds a FinishLine MCP server for one authenticated caller. Called once per request, so nothing + * here is shared between callers. + * @param context the authenticated user and their organization + */ +export const buildMcpServer = (context: AgentContext): McpServer => { + const server = new McpServer({ name: 'finishline', version: '1.0.0' }); + const { organization } = context; + + registerReadOnlyTool( + server, + 'finishline_list_projects', + { + title: 'List projects', + description: + 'List the projects on a car, with their names, WBS numbers, and one-line summaries. Start ' + + 'here when the user names a project in words rather than by number, then match the name to ' + + 'a WBS number and use the other tools. Cars are identified by a number and are consecutive. ' + + 'Omit carNumber to use the newest car, which is almost always what the user means; the response ' + + 'reports which car number was actually used. Results come back a page at a time: total is how ' + + 'many the car has, and nextOffset is present only while more remain.', + inputSchema: z.object({ + carNumber: z + .number() + .int() + .min(0) + .optional() + .describe('Which car to list projects for. Omit this to use the newest car.'), + offset: offsetSchema('projects') + }) + }, + async ({ carNumber, offset }) => withToolErrors('', async () => McpService.getProjects(organization, carNumber, offset)) + ); + + registerReadOnlyTool( + server, + 'finishline_get_project', + { + title: 'Get project details', + description: + 'Get the core details of one project: its status, budget, lead and manager, teams, links, ' + + 'start and end dates, and how many work packages it has. The dates and the status are ' + + "derived from the project's work packages, so a project with no work packages is inactive " + + 'and has no dates. Use finishline_get_work_packages for the schedule detail behind these dates.', + inputSchema: z.object({ wbsNum: wbsNumSchema }) + }, + async ({ wbsNum }) => withToolErrors(LIST_PROJECTS_HINT, async () => McpService.getProject(wbsNum, organization)) + ); + + registerReadOnlyTool( + server, + 'finishline_get_work_packages', + { + title: 'Get work packages', + description: + 'List the work packages of a project. Work packages are the scheduled phases of ' + + 'a project: each has a start date, a duration in weeks, an end date, a status (INACTIVE, ' + + 'ACTIVE, or COMPLETE), a stage, and description bullets grouped by type — typically ' + + '"Deliverables" and "Expected Activities", though an organization can rename these. ' + + "To judge whether a project is behind schedule, compare each work package's end date and " + + "status against today's date: a work package whose end date has passed but whose status is " + + 'not COMPLETE is late. blockedBy lists the WBS numbers that must finish first.', + inputSchema: z.object({ wbsNum: wbsNumSchema }) + }, + async ({ wbsNum }) => withToolErrors(LIST_PROJECTS_HINT, async () => McpService.getWorkPackages(wbsNum, organization)) + ); + + registerReadOnlyTool( + server, + 'finishline_get_tasks', + { + title: 'Get tasks', + description: + 'List the tasks for a project. Tasks are smaller items of work than work packages, with a ' + + 'status (IN_BACKLOG, IN_PROGRESS, DONE), a priority, an optional deadline, and assignees. ' + + 'This includes tasks attached directly to the project and tasks attached to any of its work ' + + 'packages; parentWbsNum and parentName say which. Use this to answer questions about how a ' + + 'team is keeping up with its work. A busy project has many tasks, so results come back a page ' + + 'at a time: total is how many the project has, and nextOffset is present only while more remain. ' + + 'Page through them all before answering a question that counts or totals tasks.', + inputSchema: z.object({ wbsNum: wbsNumSchema, offset: offsetSchema('tasks') }) + }, + async ({ wbsNum, offset }) => + withToolErrors(LIST_PROJECTS_HINT, async () => McpService.getTasks(wbsNum, organization, offset)) + ); + + registerReadOnlyTool( + server, + 'finishline_get_events', + { + title: 'Get calendar events', + description: + 'List the calendar events scheduled in a date range, across every calendar in the ' + + 'organization. Each event reports the calendars it appears on, its type, its teams, and its ' + + 'scheduled times. A recurring event is returned once with only the occurrences that fall ' + + 'inside the requested range, and recurring is true when it repeats outside that range too. ' + + 'The range cannot be wider than 7 days; ask for one week at a time.', + inputSchema: z.object({ + startDate: isoDateSchema('Start of the range, as an ISO date such as "2026-09-01".'), + endDate: isoDateSchema( + 'End of the range, as an ISO date. Must be on or after startDate and no more than 7 days later.' + ) + }) + }, + async ({ startDate, endDate }) => + withToolErrors('Split the request into one week at a time.', async () => + McpService.getEvents(new Date(startDate), new Date(endDate), organization) + ) + ); + + return server; +}; diff --git a/src/backend/src/routes/agent.routes.ts b/src/backend/src/routes/agent.routes.ts new file mode 100644 index 0000000000..537f78168a --- /dev/null +++ b/src/backend/src/routes/agent.routes.ts @@ -0,0 +1,25 @@ +import express from 'express'; +import { query } from 'express-validator'; +import AgentController from '../controllers/agent.controllers.js'; +import { readOnlyGuard, requireApiToken } from '../utils/mcp-auth.utils.js'; +import { isDate, validateInputs } from '../utils/validation.utils.js'; + +const agentRouter = express.Router(); + +// this router authenticates with per-user API tokens rather than the session cookie, and is read only +agentRouter.use(requireApiToken); +agentRouter.use(readOnlyGuard); + +agentRouter.get('/health', AgentController.healthCheck); +agentRouter.get('/projects', query('offset').optional().isInt({ min: 0 }), validateInputs, AgentController.getProjects); +agentRouter.get('/projects/:wbsNum', AgentController.getProject); +agentRouter.get('/projects/:wbsNum/work-packages', AgentController.getWorkPackages); +agentRouter.get( + '/projects/:wbsNum/tasks', + query('offset').optional().isInt({ min: 0 }), + validateInputs, + AgentController.getTasks +); +agentRouter.get('/events', isDate(query('startDate')), isDate(query('endDate')), validateInputs, AgentController.getEvents); + +export default agentRouter; diff --git a/src/backend/src/routes/mcp.routes.ts b/src/backend/src/routes/mcp.routes.ts deleted file mode 100644 index c8ce209bfa..0000000000 --- a/src/backend/src/routes/mcp.routes.ts +++ /dev/null @@ -1,20 +0,0 @@ -import express from 'express'; -import { query } from 'express-validator'; -import McpController from '../controllers/mcp.controllers.js'; -import { readOnlyGuard, requireApiToken } from '../utils/mcp-auth.utils.js'; -import { isDate, validateInputs } from '../utils/validation.utils.js'; - -const mcpRouter = express.Router(); - -// this router authenticates with per-user API tokens rather than the session cookie, and is read only -mcpRouter.use(requireApiToken); -mcpRouter.use(readOnlyGuard); - -mcpRouter.get('/health', McpController.healthCheck); -mcpRouter.get('/cars/:carNumber/projects', McpController.getProjectsByCarNumber); -mcpRouter.get('/projects/:wbsNum', McpController.getProject); -mcpRouter.get('/projects/:wbsNum/work-packages', McpController.getWorkPackages); -mcpRouter.get('/projects/:wbsNum/tasks', McpController.getTasks); -mcpRouter.get('/events', isDate(query('startDate')), isDate(query('endDate')), validateInputs, McpController.getEvents); - -export default mcpRouter; diff --git a/src/backend/src/services/mcp.services.ts b/src/backend/src/services/mcp.services.ts index 3a8ce167b6..b88a3e11bb 100644 --- a/src/backend/src/services/mcp.services.ts +++ b/src/backend/src/services/mcp.services.ts @@ -1,5 +1,5 @@ import { Organization } from '@prisma/client'; -import { McpEvent, McpProjectDetail, McpProjectSummary, McpTask, McpWorkPackage, validateWBS } from 'shared'; +import { McpEvent, McpProjectDetail, McpProjectList, McpTaskList, McpWorkPackage, validateWBS } from 'shared'; import prisma from '../prisma/prisma.js'; import { HttpException, NotFoundException } from '../utils/errors.utils.js'; import { buildScheduledTimesOverlap } from '../utils/calendar.utils.js'; @@ -19,6 +19,25 @@ import { mcpEventTransformer } from '../transformers/mcp/events.transformer.js'; const MAX_EVENT_RANGE_DAYS = 7; const MS_PER_DAY = 1000 * 60 * 60 * 24; +/** + * How many items a page of a list endpoint holds. Only the lists that can realistically run past + * this are paged: a car's projects and a project's tasks. A project's work packages are its handful + * of phases, and events are already bounded by the one week range, so both return whole. + */ +const PAGE_SIZE = 100; + +/** + * Works out the offset the caller should ask for next, which is absent once the page just returned + * reaches the end of the list. Returning it saves the model from doing the arithmetic itself. + * + * @param offset the offset this page started at + * @param pageLength how many items this page holds + * @param total how many items exist in total + * @returns the next offset, or undefined when there is nothing left + */ +const nextOffsetOf = (offset: number, pageLength: number, total: number): number | undefined => + offset + pageLength < total ? offset + pageLength : undefined; + /** * Parses a wbs number from a route param and asserts that it identifies a project rather than a * work package or a car. @@ -69,20 +88,73 @@ const findProject = async (wbsNum: string, organization: Organization) => { export default class McpService { /** - * Gets every project on a car, with just enough to identify and describe it. - * @param carNumber the car number, e.g. 3 + * Gets the number of the newest car in an organization. There is no "current car" flag in the + * data; the convention across the app is that the highest car number is the newest. * @param organization the organization the request is scoped to + * @returns the newest car's number + * @throws if the organization has no cars */ - static async getProjectsByCarNumber(carNumber: number, organization: Organization): Promise { - const projects = await prisma.project.findMany({ - where: { - wbsElement: { carNumber, dateDeleted: null, organizationId: organization.organizationId } - }, - orderBy: { wbsElement: { projectNumber: 'asc' } }, - ...getMcpProjectSummaryQueryArgs() + static async getCurrentCarNumber(organization: Organization): Promise { + const car = await prisma.car.findFirst({ + where: { wbsElement: { organizationId: organization.organizationId, dateDeleted: null } }, + orderBy: { wbsElement: { carNumber: 'desc' } }, + select: { wbsElement: { select: { carNumber: true } } } }); - return projects.map(mcpProjectSummaryTransformer); + if (!car) throw new HttpException(404, 'This organization has no cars'); + + return car.wbsElement.carNumber; + } + + /** + * Gets a page of the projects on a car, with just enough to identify and describe it. Defaults to + * the newest car so that a caller does not need to know which car is current. + * @param organization the organization the request is scoped to + * @param carNumber the car number to look at, defaulting to the newest car + * @param offset how many projects to skip, for paging through a car with more than a page of them + * @returns the resolved car number alongside a page of its projects + */ + static async getProjects( + organization: Organization, + carNumber?: string | number, + offset: number = 0 + ): Promise { + let resolvedCarNumber: number; + + if (carNumber === undefined || carNumber === '') { + resolvedCarNumber = await McpService.getCurrentCarNumber(organization); + } else { + resolvedCarNumber = Number(carNumber); + if (!Number.isInteger(resolvedCarNumber) || resolvedCarNumber < 0) { + throw new HttpException(400, `"${carNumber}" is not a valid car number`); + } + } + + const where = { + wbsElement: { + carNumber: resolvedCarNumber, + dateDeleted: null, + organizationId: organization.organizationId + } + }; + + const [total, projects] = await prisma.$transaction([ + prisma.project.count({ where }), + prisma.project.findMany({ + where, + orderBy: { wbsElement: { projectNumber: 'asc' } }, + skip: offset, + take: PAGE_SIZE, + ...getMcpProjectSummaryQueryArgs() + }) + ]); + + return { + carNumber: resolvedCarNumber, + projects: projects.map(mcpProjectSummaryTransformer), + total, + nextOffset: nextOffsetOf(offset, projects.length, total) + }; } /** @@ -121,12 +193,17 @@ export default class McpService { } /** - * Gets the tasks for a project, including tasks on the project's work packages. This matches how - * the rest of the app scopes a project's tasks. + * Gets a page of the tasks for a project, including tasks on the project's work packages. This + * matches how the rest of the app scopes a project's tasks. + * + * This is the list most likely to be long: an active project accumulates tasks across every one + * of its work packages, so it is paged rather than returned whole. + * * @param wbsNum the project's wbs number * @param organization the organization the request is scoped to + * @param offset how many tasks to skip, for paging through a project with more than a page of them */ - static async getTasks(wbsNum: string, organization: Organization): Promise { + static async getTasks(wbsNum: string, organization: Organization, offset: number = 0): Promise { const { projectId, wbsElementId } = await findProject(wbsNum, organization); const workPackages = await prisma.work_Package.findMany({ @@ -136,17 +213,28 @@ export default class McpService { const wbsElementIds = [wbsElementId, ...workPackages.map((workPackage) => workPackage.wbsElementId)]; - const tasks = await prisma.task.findMany({ - where: { - dateDeleted: null, - wbsElementId: { in: wbsElementIds }, - wbsElement: { dateDeleted: null, organizationId: organization.organizationId } - }, - orderBy: { dateCreated: 'asc' }, - ...getMcpTaskQueryArgs() - }); + const where = { + dateDeleted: null, + wbsElementId: { in: wbsElementIds }, + wbsElement: { dateDeleted: null, organizationId: organization.organizationId } + }; - return tasks.map((task) => mcpTaskTransformer(task, wbsNum)); + const [total, tasks] = await prisma.$transaction([ + prisma.task.count({ where }), + prisma.task.findMany({ + where, + orderBy: { dateCreated: 'asc' }, + skip: offset, + take: PAGE_SIZE, + ...getMcpTaskQueryArgs() + }) + ]); + + return { + tasks: tasks.map((task) => mcpTaskTransformer(task, wbsNum)), + total, + nextOffset: nextOffsetOf(offset, tasks.length, total) + }; } /** @@ -155,9 +243,15 @@ export default class McpService { * @param startDate the start of the range * @param endDate the end of the range * @param organization the organization the request is scoped to - * @throws if the range is inverted or wider than a week + * @throws if either date is unparseable, or the range is inverted or wider than a week */ static async getEvents(startDate: Date, endDate: Date, organization: Organization): Promise { + // an unparseable date is NaN, and every comparison against NaN is false, so this has to come + // first or a bad date slips past both range checks and fails inside the query instead + if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) { + throw new HttpException(400, 'startDate and endDate must be valid ISO dates, such as "2026-09-01"'); + } + if (endDate < startDate) throw new HttpException(400, 'endDate must be on or after startDate'); // the overlap filter matches slots that start at or before the end of the range, so an endDate of diff --git a/src/backend/src/utils/mcp-auth.utils.ts b/src/backend/src/utils/mcp-auth.utils.ts index ded3ccf647..97f3d6cecd 100644 --- a/src/backend/src/utils/mcp-auth.utils.ts +++ b/src/backend/src/utils/mcp-auth.utils.ts @@ -114,3 +114,21 @@ export const getRoleInOrganization = async (userId: string, organizationId: stri return role?.roleType as Role | undefined; }; + +/** + * Hands the authenticated caller to the MCP handler. + * + * toNodeHandler forwards req.auth to the handler as its pass-through authInfo, and AuthInfo.extra + * is the documented place for data of our own, so the MCP server factory reads the user and + * organization back out of it. Must run after requireApiToken. + */ +export const attachAuthInfo = (req: Request, _res: Response, next: NextFunction) => { + req.auth = { + token: '', + clientId: req.currentUser.userId, + scopes: [], + extra: { user: req.currentUser, organization: req.organization } + }; + + return next(); +}; diff --git a/src/backend/tests/unit/mcp.test.ts b/src/backend/tests/unit/mcp.test.ts index 095a3d3de1..ac75563659 100644 --- a/src/backend/tests/unit/mcp.test.ts +++ b/src/backend/tests/unit/mcp.test.ts @@ -37,8 +37,9 @@ describe('MCP Endpoint Tests', () => { await makeProject(1); await createTestProject(user, orgId, undefined, otherCarId, 2, 1); - const projects = await McpService.getProjectsByCarNumber(1, organization); + const { carNumber, projects } = await McpService.getProjects(organization, 1); + expect(carNumber).toBe(1); expect(projects).toHaveLength(1); expect(projects[0].wbsNum).toBe('1.1.0'); expect(projects[0].viewOnFinishline).toContain('/projects/1.1.0'); @@ -48,14 +49,62 @@ describe('MCP Endpoint Tests', () => { await makeProject(1); await createTestProject(user, orgId, undefined, carId, 1, 2, new Date()); - const projects = await McpService.getProjectsByCarNumber(1, organization); + const { projects } = await McpService.getProjects(organization, 1); expect(projects).toHaveLength(1); expect(projects[0].wbsNum).toBe('1.1.0'); }); it('returns an empty list for a car with no projects', async () => { - expect(await McpService.getProjectsByCarNumber(9, organization)).toEqual([]); + expect((await McpService.getProjects(organization, 9)).projects).toEqual([]); + }); + + it('defaults to the newest car when no car number is given', async () => { + // car 1 already exists from the setup; car 2 is newer + const newerCarId = (await createTestCar(orgId, user.userId, 2)).carId; + await makeProject(1); + await createTestProject(user, orgId, undefined, newerCarId, 2, 1); + + const { carNumber, projects } = await McpService.getProjects(organization); + + expect(carNumber).toBe(2); + expect(projects).toHaveLength(1); + expect(projects[0].wbsNum).toBe('2.1.0'); + }); + + it('pages through a car with more projects than fit in one page', async () => { + // one more than the page size, so the first page is full and a second page holds the remainder + for (let projectNumber = 1; projectNumber <= 101; projectNumber++) { + await makeProject(projectNumber); + } + + const firstPage = await McpService.getProjects(organization, 1); + + expect(firstPage.projects).toHaveLength(100); + expect(firstPage.total).toBe(101); + expect(firstPage.nextOffset).toBe(100); + + const secondPage = await McpService.getProjects(organization, 1, firstPage.nextOffset); + + expect(secondPage.projects).toHaveLength(1); + expect(secondPage.total).toBe(101); + expect(secondPage.nextOffset).toBeUndefined(); + expect(secondPage.projects[0].wbsNum).toBe('1.101.0'); + }); + + it('reports no next page when the projects fit in one', async () => { + await makeProject(1); + + const { total, nextOffset } = await McpService.getProjects(organization, 1); + + expect(total).toBe(1); + expect(nextOffset).toBeUndefined(); + }); + + it('rejects a non numeric car number', async () => { + await expect(async () => await McpService.getProjects(organization, 'abc')).rejects.toThrow( + new HttpException(400, '"abc" is not a valid car number') + ); }); }); @@ -186,8 +235,10 @@ describe('MCP Endpoint Tests', () => { await makeTask(projectWbsElement.wbsElementId, 'Project task'); await makeTask(workPackage.wbsElementId, 'Work package task'); - const tasks = await McpService.getTasks('1.1.0', organization); + const { tasks, total, nextOffset } = await McpService.getTasks('1.1.0', organization); + expect(total).toBe(2); + expect(nextOffset).toBeUndefined(); expect(tasks.map((task) => task.title).sort()).toEqual(['Project task', 'Work package task']); expect(tasks.find((task) => task.title === 'Work package task')?.parentWbsNum).toBe('1.1.1'); expect(tasks.find((task) => task.title === 'Project task')?.parentWbsNum).toBe('1.1.0'); @@ -201,7 +252,9 @@ describe('MCP Endpoint Tests', () => { }); await makeTask(projectWbsElement.wbsElementId, 'Project task'); - const [task] = await McpService.getTasks('1.1.0', organization); + const { + tasks: [task] + } = await McpService.getTasks('1.1.0', organization); expect(task.assignees).toEqual([`${user.firstName} ${user.lastName}`]); expect(task.createdBy).toBe(`${user.firstName} ${user.lastName}`); @@ -209,6 +262,47 @@ describe('MCP Endpoint Tests', () => { expect(JSON.stringify(task)).not.toContain('googleAuthId'); }); + it('pages through a project with more tasks than fit in one page', async () => { + const project = await makeProject(1); + const projectWbsElement = await prisma.project.findUniqueOrThrow({ + where: { projectId: project.projectId }, + select: { wbsElementId: true } + }); + + // one more than the page size, so the first page is full and a second page holds the remainder + for (let taskNumber = 1; taskNumber <= 101; taskNumber++) { + await makeTask(projectWbsElement.wbsElementId, `Task ${taskNumber}`); + } + + const firstPage = await McpService.getTasks('1.1.0', organization); + + expect(firstPage.tasks).toHaveLength(100); + expect(firstPage.total).toBe(101); + expect(firstPage.nextOffset).toBe(100); + + const secondPage = await McpService.getTasks('1.1.0', organization, firstPage.nextOffset); + + expect(secondPage.tasks).toHaveLength(1); + expect(secondPage.total).toBe(101); + expect(secondPage.nextOffset).toBeUndefined(); + }); + + it('counts only the tasks that survive the soft delete filter when paging', async () => { + const project = await makeProject(1); + const projectWbsElement = await prisma.project.findUniqueOrThrow({ + where: { projectId: project.projectId }, + select: { wbsElementId: true } + }); + await makeTask(projectWbsElement.wbsElementId, 'Live task'); + const deleted = await makeTask(projectWbsElement.wbsElementId, 'Deleted task'); + await prisma.task.update({ where: { taskId: deleted.taskId }, data: { dateDeleted: new Date() } }); + + const { tasks, total } = await McpService.getTasks('1.1.0', organization); + + expect(total).toBe(1); + expect(tasks.map((task) => task.title)).toEqual(['Live task']); + }); + it('excludes soft deleted tasks', async () => { const project = await makeProject(1); const projectWbsElement = await prisma.project.findUniqueOrThrow({ @@ -218,7 +312,7 @@ describe('MCP Endpoint Tests', () => { const task = await makeTask(projectWbsElement.wbsElementId, 'Deleted task'); await prisma.task.update({ where: { taskId: task.taskId }, data: { dateDeleted: new Date() } }); - expect(await McpService.getTasks('1.1.0', organization)).toEqual([]); + expect((await McpService.getTasks('1.1.0', organization)).tasks).toEqual([]); }); }); @@ -322,5 +416,11 @@ describe('MCP Endpoint Tests', () => { async () => await McpService.getEvents(new Date('2026-09-07'), new Date('2026-09-01'), organization) ).rejects.toThrow(new HttpException(400, 'endDate must be on or after startDate')); }); + + it('rejects an unparseable date rather than letting NaN slip past the range checks', async () => { + await expect( + async () => await McpService.getEvents(new Date('next Monday'), new Date('2026-09-07'), organization) + ).rejects.toThrow(new HttpException(400, 'startDate and endDate must be valid ISO dates, such as "2026-09-01"')); + }); }); }); diff --git a/src/shared/src/types/mcp-types.ts b/src/shared/src/types/mcp-types.ts index 2349edc8f3..1c9c71b9b6 100644 --- a/src/shared/src/types/mcp-types.ts +++ b/src/shared/src/types/mcp-types.ts @@ -18,6 +18,16 @@ export interface McpProjectSummary { viewOnFinishline: string; } +export interface McpProjectList { + /** the car these projects belong to, resolved to the newest car when the caller did not specify */ + carNumber: number; + projects: McpProjectSummary[]; + /** how many projects the car has in total, so the caller knows whether it has them all */ + total: number; + /** the offset to request for the next page, absent when this page is the last one */ + nextOffset?: number; +} + export interface McpLink { type: string; url: string; @@ -75,6 +85,14 @@ export interface McpTask { viewOnFinishline: string; } +export interface McpTaskList { + tasks: McpTask[]; + /** how many tasks the project has in total, so the caller knows whether it has them all */ + total: number; + /** the offset to request for the next page, absent when this page is the last one */ + nextOffset?: number; +} + export interface McpEventTime { startTime: Date; endTime: Date; diff --git a/yarn.lock b/yarn.lock index 12e7c0731f..3b76e258b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4232,6 +4232,15 @@ __metadata: languageName: node linkType: hard +"@hono/node-server@npm:^1.19.9": + version: 1.19.17 + resolution: "@hono/node-server@npm:1.19.17" + peerDependencies: + hono: ^4 + checksum: 10/b3fb8f627e59caafdcf49b463d5263931ccc404cb2d58999b9863636e68dcf01e919a73a449761db07349bd39dc91983b68a9f549b4c9927fde8c2de7e498689 + languageName: node + linkType: hard + "@hookform/resolvers@npm:^3.10.0": version: 3.10.0 resolution: "@hookform/resolvers@npm:3.10.0" @@ -5036,6 +5045,40 @@ __metadata: languageName: node linkType: hard +"@modelcontextprotocol/core@npm:2.0.0": + version: 2.0.0 + resolution: "@modelcontextprotocol/core@npm:2.0.0" + dependencies: + zod: "npm:^4.2.0" + checksum: 10/65d695e0fb8def8fcaa72f49866dcf6adaefb06b03bfb17691c11a07b147ef60cbb0076f38438c774dfa8ceff21b14a9c21aa1d30fa665fe83e4bb2a447a9d51 + languageName: node + linkType: hard + +"@modelcontextprotocol/node@npm:^2.0.0": + version: 2.0.0 + resolution: "@modelcontextprotocol/node@npm:2.0.0" + dependencies: + "@hono/node-server": "npm:^1.19.9" + peerDependencies: + "@modelcontextprotocol/server": ^2.0.0 + hono: ^4.11.4 + peerDependenciesMeta: + hono: + optional: true + checksum: 10/d214fcb52e4d2740840a54fd98b0ad8e835f79765d14611fca3778a537bd779ddce71073455aa7f459d5d479985401cd3239c8056cc8134061d2aba60d0d5709 + languageName: node + linkType: hard + +"@modelcontextprotocol/server@npm:^2.0.0": + version: 2.0.0 + resolution: "@modelcontextprotocol/server@npm:2.0.0" + dependencies: + "@modelcontextprotocol/core": "npm:2.0.0" + zod: "npm:^4.2.0" + checksum: 10/32d50b224f8f00b60886c2c84a53d3c0b6a0e9cd37afe4782c895795cb979c4e57736b6b663966cf50da145f3b926fc559648d521abcdadd22de4366a085e681 + languageName: node + linkType: hard + "@mswjs/interceptors@npm:^0.41.2": version: 0.41.3 resolution: "@mswjs/interceptors@npm:0.41.3" @@ -9262,6 +9305,8 @@ __metadata: version: 0.0.0-use.local resolution: "backend@workspace:src/backend" dependencies: + "@modelcontextprotocol/node": "npm:^2.0.0" + "@modelcontextprotocol/server": "npm:^2.0.0" "@prisma/client": "npm:^6.2.1" "@slack/bolt": "npm:^3.22.0" "@types/concat-stream": "npm:^2.0.0" @@ -9301,6 +9346,7 @@ __metadata: twisters: "npm:^1.1.0" typescript: "npm:^5.7.3" vitest: "npm:^3.0.0" + zod: "npm:^4.4.3" languageName: unknown linkType: soft @@ -27607,6 +27653,13 @@ __metadata: languageName: node linkType: hard +"zod@npm:^4.2.0, zod@npm:^4.4.3": + version: 4.4.3 + resolution: "zod@npm:4.4.3" + checksum: 10/804b9a42aa8f35f2b3c5a8dff906291cb749115f83ee2afe3576d70b5b5c53c965365c7f4967690647a9c54af9838ff232a85ff9577a0a36c44b68bc6cdefe36 + languageName: node + linkType: hard + "zwitch@npm:^2.0.0": version: 2.0.4 resolution: "zwitch@npm:2.0.4"