How to Build Custom MCP Tools for Project Management: 3 Real Examples with Code

Most "build custom MCP tools" guides stop at the API wrapper: expose create_issue, expose list_issues, done. That's a connector, not a tool. A useful custom MCP tool combines two or three primitives your tracker already gives you into something nobody had to do by hand before.
Here are three you can actually build today, each composed from tools a task tracker already exposes over MCP, plus what to reach for alongside them.
What to pair alongside your tracker, not replace it with
Before building anything custom, it's worth knowing what's already solved. Three MCP servers show up constantly in real engineering workflows, and none of them compete with your tracker, they sit next to it:
- [GitHub MCP Server](https://github.com/github/github-mcp-server) (official): exposes repository state directly, PRs, commits, CI runs, so a custom tool doesn't need its own GitHub client to answer "is this PR green yet."
- [code-review-mcp](https://github.com/praneybehl/code-review-mcp) (open source): diffs staged changes or a branch comparison and runs them through a model of your choice for review, parameterized by focus area and project context.
- Sequential Thinking MCP: breaks a fuzzy judgment call, like "how complex is this change" into an explicit chain of steps instead of a single black-box guess.
The three tools below combine these with a tracker's own MCP surface (TAM's, in these examples) to do things neither one does alone.
Tool 1: a standup digest that reads real state instead of asking people to type it
The standard version of this idea just summarizes a Slack channel. The more useful version pulls from two sources that are actually true: what moved in the tracker, and what happened in the repo.
server.tool(
"standup_digest",
{ sinceHours: z.number().default(24) },
async ({ sinceHours }) => {
const since = new Date(Date.now() - sinceHours * 3600_000);
const { issues } = await tam.issueList({ status: "in_progress" });
const perIssue = await Promise.all(
issues.map(async (issue) => {
const { activity } = await tam.issueActivityList({ issueId: issue.id });
const recent = activity.filter((a) => new Date(a.createdAt) >= since);
const { pullRequests } = await tam.githubPrStatus({ issueId: issue.id });
return { issue, recent, pullRequests };
})
);
return formatDigest(perIssue.filter((entry) => entry.recent.length > 0));
}
);tam.issue.list returns { issues }, not a bare array, and tam.issue.activity.list is scoped to one issue at a time, there's no cross-issue "everything since X" feed, so the digest starts from tam.issue.list for what's actually in flight, then pulls each issue's own activity and filters it client-side against the time window. tam.github.pr.status takes the issue ID directly (no separate PR URL lookup needed) and returns pullRequests, so a PR's state rides along with the ticket it belongs to. Nobody has to remember to post an update. The update is a query, not a report someone writes.
Tool 2: a stale-PR nudger that checks the tracker, not just GitHub
"PR open more than 48 hours" is easy to detect from GitHub alone. It's a worse signal than it looks, because a PR can sit open on purpose while someone's waiting on a design decision. The useful version cross-references the tracker's own state before nudging anyone.
server.tool(
"flag_stale_prs",
{ hoursThreshold: z.number().default(48) },
async ({ hoursThreshold }) => {
const { issues } = await tam.issueList({ status: "in_progress" });
for (const issue of issues) {
const { pullRequests } = await tam.githubPrStatus({ issueId: issue.id });
const openPr = pullRequests.find((pr) => pr.state === "open");
if (!openPr) continue;
const hoursSinceActivity = (Date.now() - new Date(openPr.updatedAt).getTime()) / 3600_000;
if (hoursSinceActivity < hoursThreshold) continue;
// Bucketed by day: a retry of this same run reuses the key and collapses,
// but tomorrow's run (if the PR is still stale) gets a fresh nudge instead
// of being silently deduplicated forever.
const dayBucket = new Date().toISOString().slice(0, 10);
await tam.issueAddProgress({
issueId: issue.id,
content: `No PR activity for ${Math.round(hoursSinceActivity)}h. Flagging for review.`,
idempotencyKey: `stale-pr-${issue.id}-${dayBucket}`
});
}
}
);This only fires for issues the tracker itself still considers active work, using tam.issue.list scoped to in_progress, checked against tam.github.pr.status for the open PR's own state and updatedAt, with the nudge logged back through tam.issue.add_progress, visible on the ticket instead of lost in a Slack thread nobody reopens.
Tool 3: a story point estimator that shows its reasoning and writes it down
Story point estimation done by a single LLM call in a chat window produces a number with no reasoning attached, which is exactly why people don't trust it. Running the diff through a structured reasoning step first, then writing the result directly into the tracker, fixes both problems at once.
server.tool(
"estimate_story_points_from_diff",
{ issueId: z.string(), storyPointsFieldId: z.string(), diff: z.string() },
async ({ issueId, storyPointsFieldId, diff }) => {
const reasoning = await sequentialThinking.run({
steps: ["identify changed modules", "assess test coverage impact", "flag cross-service risk"],
input: diff
});
const points = scoreFromReasoning(reasoning); // your Fibonacci mapping
// expectedUpdatedAt: null only means "no value exists yet." If this issue
// was already estimated once, the write needs that value's own updatedAt
// or TAM rejects it as a conflicting overwrite.
const { values } = await tam.customFieldValueList({ issueId });
const existing = values.find((v) => v.fieldId === storyPointsFieldId);
await tam.customFieldValueSet({
issueId,
fieldId: storyPointsFieldId, // from tam.custom_field.definition.list
value: points,
expectedUpdatedAt: existing?.updatedAt ?? null,
// Stable across retries of the same diff, so a network retry collapses
// into one write instead of fighting the version check above; a genuinely
// different diff (new commits) hashes to a new key and re-estimates.
idempotencyKey: `estimate-${issueId}-${hashDiff(diff)}`
});
return { points, reasoning };
}
);The Sequential Thinking step is what turns "the model said 5" into an estimate with a visible chain of steps behind it. tam.custom_field.value.set takes the field by ID, not by name, and enforces the same expected-version check as everything else that writes to TAM: null only works for a field that's never been set, so re-estimating an already-scored issue means reading its current value first.
The pattern underneath all three
Every one of these follows the same shape: read from the tracker's real state, combine it with something outside the tracker (git, a reasoning step, a scan result), then write the outcome back through the tracker's own tools instead of a side channel. That's the same primitive set TAM's own @tam facilitator agent is built from and is rolling out on top of: per-issue activity, linked PR status, and custom fields, all reachable and writable over MCP today. Building a custom tool on top of TAM isn't building an alternative to what TAM already does. It's writing a narrower, team-specific version of the same thing while the built-in one rolls out.
If you haven't wired up the MCP connection these examples assume, connecting Cursor or Claude Code to TAM is the starting point, and building an MCP server from scratch covers the scoping and security details these three tools rely on once more than one agent is calling them.