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
69 changes: 69 additions & 0 deletions mcp/packages/ferrtrack-mcp/src/tools/__tests__/annotations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, it, expect, vi } from 'vitest';
import type { McpServer } from '@ferrlabs/mcp-core';

process.env.FERRLABS_API_TOKEN = 'test-token';

type Annotations = { readOnlyHint?: boolean; destructiveHint?: boolean };

const annotations = new Map<string, Annotations | undefined>();
const mockServer = {
tool: vi.fn((name: string, _desc: string, _schema: unknown, ...rest: unknown[]) => {
annotations.set(name, rest.length > 1 ? (rest[0] as Annotations) : undefined);
}),
} as unknown as McpServer;

const DESTRUCTIVE = ['delete_issue_comment', 'delete_cycle', 'delete_milestone'];
const READ_ONLY = [
'list_track_projects',
'get_project',
'list_track_issues',
'get_issue',
'list_issue_links',
'list_issue_comments',
'list_cycles',
'get_cycle',
'list_cycle_issues',
'list_milestones',
'get_milestone',
'list_track_users',
'search_track',
];

describe('tool annotations', async () => {
const { registerProjectTools } = await import('../projects.js');
const { registerIssueDetailsTool } = await import('../issue-details.js');
const { registerIssueTools } = await import('../issues.js');
const { registerCommentTools } = await import('../comments.js');
const { registerCycleTools } = await import('../cycles.js');
const { registerMilestoneTools } = await import('../milestones.js');
const { registerSearchTools } = await import('../search.js');
registerProjectTools(mockServer);
registerIssueDetailsTool(mockServer);
registerIssueTools(mockServer);
registerCommentTools(mockServer);
registerCycleTools(mockServer);
registerMilestoneTools(mockServer);
registerSearchTools(mockServer);

it.each(DESTRUCTIVE)('%s is marked destructive', (name) => {
expect(annotations.get(name)?.destructiveHint).toBe(true);
});

it.each(READ_ONLY)('%s is marked read-only', (name) => {
expect(annotations.get(name)?.readOnlyHint).toBe(true);
});

it('marks no other tool destructive or read-only', () => {
const flagged = new Set([...DESTRUCTIVE, ...READ_ONLY]);
for (const [name, a] of annotations) {
if (!flagged.has(name)) {
expect(a?.destructiveHint, name).toBeUndefined();
expect(a?.readOnlyHint, name).toBeUndefined();
}
}
});

it('covers every registered tool', () => {
expect(annotations.size).toBe(DESTRUCTIVE.length + READ_ONLY.length + 11);
});
});
4 changes: 2 additions & 2 deletions mcp/packages/ferrtrack-mcp/src/tools/__tests__/writes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ const handlers = new Map<string, Handler>();
const schemas = new Map<string, Record<string, ZodTypeAny>>();
const mockServer = {
tool: vi.fn(
(name: string, _desc: string, schema: Record<string, ZodTypeAny>, handler: Handler) => {
handlers.set(name, handler);
(name: string, _desc: string, schema: Record<string, ZodTypeAny>, ...rest: unknown[]) => {
handlers.set(name, rest[rest.length - 1] as Handler);
schemas.set(name, schema);
},
),
Expand Down
2 changes: 2 additions & 0 deletions mcp/packages/ferrtrack-mcp/src/tools/comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function registerCommentTools(server: McpServer) {
{
issue_ref: z.string().min(1).describe('Issue ref, e.g. "FT-12"'),
},
{ readOnlyHint: true },
async ({ issue_ref }) => {
const token = await getToken();
const comments = await trackRequest<Comment[]>(
Expand Down Expand Up @@ -76,6 +77,7 @@ export function registerCommentTools(server: McpServer) {
issue_ref: z.string().min(1).describe('Issue ref, e.g. "FT-12"'),
comment_id: z.string().min(1).describe('Comment id'),
},
{ destructiveHint: true },
async ({ issue_ref, comment_id }) => {
const token = await getToken();
await trackRequest<void>(
Expand Down
4 changes: 4 additions & 0 deletions mcp/packages/ferrtrack-mcp/src/tools/cycles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export function registerCycleTools(server: McpServer) {
{
cycle_id: z.string().min(1).describe('Cycle id'),
},
{ readOnlyHint: true },
async ({ cycle_id }) => {
const token = await getToken();
const cycle = await trackRequest<Cycle>(`/cycles/${encodeURIComponent(cycle_id)}`, { token });
Expand All @@ -44,6 +45,7 @@ export function registerCycleTools(server: McpServer) {
{
project_slug: z.string().min(1).describe('Project slug'),
},
{ readOnlyHint: true },
async ({ project_slug }) => {
const token = await getToken();
const cycles = await trackRequest<Cycle[]>(
Expand All @@ -62,6 +64,7 @@ export function registerCycleTools(server: McpServer) {
{
cycle_id: z.string().min(1).describe('Cycle id'),
},
{ readOnlyHint: true },
async ({ cycle_id }) => {
const token = await getToken();
const issues = await trackRequest<CycleIssue[]>(
Expand Down Expand Up @@ -150,6 +153,7 @@ export function registerCycleTools(server: McpServer) {
{
cycle_id: z.string().min(1).describe('Cycle id'),
},
{ destructiveHint: true },
async ({ cycle_id }) => {
const token = await getToken();
await trackRequest<void>(`/cycles/${encodeURIComponent(cycle_id)}`, {
Expand Down
1 change: 1 addition & 0 deletions mcp/packages/ferrtrack-mcp/src/tools/issue-details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export function registerIssueDetailsTool(server: McpServer) {
{
issue_ref: z.string().min(1).describe('Issue ref, e.g. "FT-12"'),
},
{ readOnlyHint: true },
async ({ issue_ref }) => {
const token = await getToken();
const issue = await trackRequest<IssueDetails>(`/issues/${encodeURIComponent(issue_ref)}`, {
Expand Down
2 changes: 2 additions & 0 deletions mcp/packages/ferrtrack-mcp/src/tools/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export function registerIssueTools(server: McpServer) {
.optional()
.describe('Max issues to return (default 50).'),
},
{ readOnlyHint: true },
async ({ project_slug, status, kind, assignee_id, limit }) => {
const issues = await fetchProjectIssues(project_slug, { status, kind, assignee_id, limit });
return {
Expand Down Expand Up @@ -149,6 +150,7 @@ export function registerIssueTools(server: McpServer) {
{
issue_ref: z.string().min(1).describe('Issue ref, e.g. "FT-12"'),
},
{ readOnlyHint: true },
async ({ issue_ref }) => {
const token = await getToken();
const links = await trackRequest<unknown>(`/issues/${encodeURIComponent(issue_ref)}/links`, {
Expand Down
3 changes: 3 additions & 0 deletions mcp/packages/ferrtrack-mcp/src/tools/milestones.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function registerMilestoneTools(server: McpServer) {
{
milestone_id: z.string().min(1).describe('Milestone id'),
},
{ readOnlyHint: true },
async ({ milestone_id }) => {
const token = await getToken();
const milestone = await trackRequest<Milestone>(
Expand All @@ -38,6 +39,7 @@ export function registerMilestoneTools(server: McpServer) {
{
project_slug: z.string().min(1).describe('Project slug'),
},
{ readOnlyHint: true },
async ({ project_slug }) => {
const token = await getToken();
const milestones = await trackRequest<Milestone[]>(
Expand Down Expand Up @@ -111,6 +113,7 @@ export function registerMilestoneTools(server: McpServer) {
{
milestone_id: z.string().min(1).describe('Milestone id'),
},
{ destructiveHint: true },
async ({ milestone_id }) => {
const token = await getToken();
await trackRequest<void>(`/milestones/${encodeURIComponent(milestone_id)}`, {
Expand Down
2 changes: 2 additions & 0 deletions mcp/packages/ferrtrack-mcp/src/tools/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function registerProjectTools(server: McpServer) {
'list_track_projects',
"List FerrTrack projects in the caller's active organization.",
{},
{ readOnlyHint: true },
async () => {
const projects = await fetchProjects();
return {
Expand All @@ -36,6 +37,7 @@ export function registerProjectTools(server: McpServer) {
{
project_slug: z.string().min(1).describe('Project slug'),
},
{ readOnlyHint: true },
async ({ project_slug }) => {
const token = await getToken();
const project = await trackRequest<Project>(`/projects/${encodeURIComponent(project_slug)}`, {
Expand Down
2 changes: 2 additions & 0 deletions mcp/packages/ferrtrack-mcp/src/tools/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export function registerSearchTools(server: McpServer) {
.optional()
.describe('Max hits to return (default 25).'),
},
{ readOnlyHint: true },
async ({ q, limit }) => {
const token = await getToken();
const params = new URLSearchParams({ q });
Expand All @@ -47,6 +48,7 @@ export function registerSearchTools(server: McpServer) {
'list_track_users',
"List FerrTrack users in the caller's org — useful for resolving an `assignee_id` before calling create_issue or update_issue.",
{},
{ readOnlyHint: true },
async () => {
const token = await getToken();
const users = await trackRequest<TrackUser[]>('/users', { token });
Expand Down
Loading