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
3 changes: 3 additions & 0 deletions src/backend/custom.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Organization, Prisma } from '@prisma/client';
import { AuthInfo } from '@modelcontextprotocol/server';
import { User as SharedUser } from 'shared';

declare global {
Expand All @@ -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;
}
}
}
14 changes: 12 additions & 2 deletions src/backend/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion src/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<string, string>;
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<string, string | undefined>;
const projects = await McpService.getProjects(req.organization, carNumber, offset ? Number(offset) : undefined);

res.status(200).json(projects);
} catch (error: unknown) {
Expand Down Expand Up @@ -74,7 +67,8 @@ export default class McpController {
static async getTasks(req: Request, res: Response, next: NextFunction) {
try {
const { wbsNum } = req.params as Record<string, string>;
const tasks = await McpService.getTasks(wbsNum, req.organization);
const { offset } = req.query as Record<string, string | undefined>;
const tasks = await McpService.getTasks(wbsNum, req.organization, offset ? Number(offset) : undefined);

res.status(200).json(tasks);
} catch (error: unknown) {
Expand Down
44 changes: 44 additions & 0 deletions src/backend/src/mcp/errors.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>): Promise<CallToolResult> => {
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
};
}
};
41 changes: 41 additions & 0 deletions src/backend/src/mcp/handler.ts
Original file line number Diff line number Diff line change
@@ -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'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't we check that authInfo is there and give a good error if not

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not user input so that wouldn't really be helpful

});

export const mcpNodeHandler = toNodeHandler(mcpHandler);
194 changes: 194 additions & 0 deletions src/backend/src/mcp/tools.ts
Original file line number Diff line number Diff line change
@@ -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 = <Shape extends z.ZodRawShape>(
server: McpServer,
name: string,
config: { title: string; description: string; inputSchema: z.ZodObject<Shape> },
handler: (args: z.infer<z.ZodObject<Shape>>) => Promise<CallToolResult>
): 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 }) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could the model send something like "next Monday" Wouldn't an invalid date slip past both range checks since NaN comparisons are false, and then it would be a 500 that says 'report this to the software team'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

withToolErrors('Split the request into one week at a time.', async () =>
McpService.getEvents(new Date(startDate), new Date(endDate), organization)
)
);

return server;
};
Loading
Loading