Skip to content

Commit 0fa6bfb

Browse files
ktamas77claude
andcommitted
feat(mcp): polish tool definitions for the MCP Registry quality bar
Improvements to make every tool call easier for an LLM to get right and to prevent runaway context consumption: - Read MCP server version from package.json instead of the hardcoded '0.1.1' string (was already wrong by 0.1.2). - Add per-tool annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) — Claude Connectors review flags ~30% of submissions for missing annotations. - Tighten parameter descriptions: every field on start_timer / log_time / list_entries now explains accepted formats, examples, and what to use list_projects for. The bare `description` and `rate` fields are documented properly. - list_entries now defaults to 50 entries (max 500) with a strict integer bound on `limit` in both JSON Schema and the Zod runtime validator. A Timebook account with thousands of entries was previously blowing the model's context. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9268b42 commit 0fa6bfb

1 file changed

Lines changed: 95 additions & 24 deletions

File tree

src/mcp/server.ts

Lines changed: 95 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,17 @@ import {
66
type CallToolResult,
77
type Tool,
88
} from '@modelcontextprotocol/sdk/types.js';
9+
import { createRequire } from 'node:module';
910
import { z } from 'zod';
1011
import { api, ApiError } from '../lib/api.js';
1112
import { readConfig } from '../lib/config.js';
1213
import { resolveProject } from '../lib/resolve.js';
1314
import { parseDuration } from '../lib/format.js';
1415

16+
const pkg = createRequire(import.meta.url)('../../package.json') as { version: string };
17+
18+
const DEFAULT_ENTRY_LIMIT = 50;
19+
1520
// Each tool's input schema is described twice: once as a Zod schema for runtime
1621
// validation (parse the args before calling the API), once as JSON Schema for
1722
// the MCP `tools/list` response (clients show this to the model).
@@ -38,84 +43,150 @@ const listEntriesInput = z.object({
3843
project: z.string().optional(),
3944
startDate: z.string().optional().describe('ISO-8601 earliest start time'),
4045
endDate: z.string().optional().describe('ISO-8601 latest start time'),
41-
limit: z.number().int().positive().optional(),
46+
limit: z.number().int().min(1).max(500).optional(),
4247
});
4348

4449
const TOOLS: Tool[] = [
4550
{
4651
name: 'whoami',
47-
description: 'Return the currently authenticated Timebook user.',
52+
description: 'Return the currently authenticated Timebook user (id, email, name).',
4853
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
54+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
4955
},
5056
{
5157
name: 'list_projects',
52-
description: 'List all projects available to the current token.',
58+
description:
59+
'List all projects available to the current token. Returns id, name, and client for each project.',
5360
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
61+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
5462
},
5563
{
5664
name: 'list_clients',
5765
description: 'List all clients available to the current token.',
5866
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
67+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
5968
},
6069
{
6170
name: 'get_active_timer',
62-
description: 'Return the currently running timer, or null if none.',
71+
description:
72+
'Return the currently running timer (project, description, started_at), or null if no timer is running.',
6373
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
74+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
6475
},
6576
{
6677
name: 'start_timer',
6778
description:
68-
'Start a timer on a project. Stops any other running timer first (Timebook allows only one active timer).',
79+
'Start a timer on a project. Stops any other running timer first Timebook allows only one active timer at a time.',
6980
inputSchema: {
7081
type: 'object',
7182
properties: {
72-
project: { type: 'string', description: 'Project id or exact name' },
73-
description: { type: 'string' },
74-
rate: { type: 'string', description: 'Rate id or exact name' },
83+
project: {
84+
type: 'string',
85+
description: 'Project id (UUID) or exact project name. Use list_projects to discover.',
86+
},
87+
description: {
88+
type: 'string',
89+
description: 'What the user is working on (visible in the time entry).',
90+
},
91+
rate: {
92+
type: 'string',
93+
description: 'Optional rate id (UUID) or exact rate name (e.g. "Software Development").',
94+
},
7595
},
7696
required: ['project'],
7797
additionalProperties: false,
7898
},
99+
annotations: {
100+
readOnlyHint: false,
101+
destructiveHint: false,
102+
idempotentHint: false,
103+
openWorldHint: true,
104+
},
79105
},
80106
{
81107
name: 'stop_timer',
82-
description: 'Stop the currently running timer.',
108+
description:
109+
'Stop the currently running timer. Returns { stopped: false } if no timer was running.',
83110
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
111+
annotations: {
112+
readOnlyHint: false,
113+
destructiveHint: true,
114+
idempotentHint: true,
115+
openWorldHint: true,
116+
},
84117
},
85118
{
86119
name: 'log_time',
87120
description:
88-
'Log a manual time entry. Provide either `duration`, or both `startTime` and `endTime`.',
121+
'Log a manual (past) time entry. Provide either `duration` (relative to now), or both `startTime` and `endTime` (absolute ISO-8601 timestamps).',
89122
inputSchema: {
90123
type: 'object',
91124
properties: {
92-
project: { type: 'string', description: 'Project id or exact name' },
93-
description: { type: 'string' },
125+
project: {
126+
type: 'string',
127+
description: 'Project id (UUID) or exact project name.',
128+
},
129+
description: {
130+
type: 'string',
131+
description: 'What the user worked on.',
132+
},
94133
duration: {
95134
type: 'string',
96-
description: 'e.g. "1h", "45m", "1h30m", "1.5h", "1:30", or "90" (minutes)',
135+
description:
136+
'How long the work took. Accepts "1h", "45m", "1h30m", "1.5h", "1:30", or "90" (interpreted as minutes).',
137+
},
138+
startTime: {
139+
type: 'string',
140+
description:
141+
'ISO-8601 start time (e.g. "2026-05-04T09:00:00Z"). Required if duration is omitted.',
142+
},
143+
endTime: {
144+
type: 'string',
145+
description: 'ISO-8601 end time. Required if duration is omitted.',
146+
},
147+
rate: {
148+
type: 'string',
149+
description: 'Optional rate id or exact rate name (e.g. "Software Development").',
97150
},
98-
startTime: { type: 'string', description: 'ISO-8601' },
99-
endTime: { type: 'string', description: 'ISO-8601' },
100-
rate: { type: 'string' },
101151
},
102152
required: ['project'],
103153
additionalProperties: false,
104154
},
155+
annotations: {
156+
readOnlyHint: false,
157+
destructiveHint: false,
158+
idempotentHint: false,
159+
openWorldHint: true,
160+
},
105161
},
106162
{
107163
name: 'list_entries',
108-
description: 'List recent time entries, optionally filtered by project and date range.',
164+
description: `List recent time entries, optionally filtered by project and/or date range. Returns at most ${DEFAULT_ENTRY_LIMIT} entries by default; pass a higher \`limit\` to see more.`,
109165
inputSchema: {
110166
type: 'object',
111167
properties: {
112-
project: { type: 'string', description: 'Project id or exact name' },
113-
startDate: { type: 'string', description: 'ISO-8601' },
114-
endDate: { type: 'string', description: 'ISO-8601' },
115-
limit: { type: 'number' },
168+
project: {
169+
type: 'string',
170+
description: 'Optional project id or exact name. Omit to list across all projects.',
171+
},
172+
startDate: {
173+
type: 'string',
174+
description: 'ISO-8601 — only entries whose start time is on or after this.',
175+
},
176+
endDate: {
177+
type: 'string',
178+
description: 'ISO-8601 — only entries whose start time is on or before this.',
179+
},
180+
limit: {
181+
type: 'integer',
182+
description: `Maximum number of entries to return. Defaults to ${DEFAULT_ENTRY_LIMIT}.`,
183+
minimum: 1,
184+
maximum: 500,
185+
},
116186
},
117187
additionalProperties: false,
118188
},
189+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
119190
},
120191
];
121192

@@ -229,8 +300,8 @@ async function handleTool(name: string, args: unknown): Promise<ToolResult> {
229300
startDate: input.startDate,
230301
endDate: input.endDate,
231302
});
232-
const limited = input.limit ? entries.slice(0, input.limit) : entries;
233-
return ok(limited);
303+
const limit = input.limit ?? DEFAULT_ENTRY_LIMIT;
304+
return ok(entries.slice(0, limit));
234305
}
235306
default:
236307
return err(`Unknown tool: ${name}`);
@@ -248,7 +319,7 @@ async function handleTool(name: string, args: unknown): Promise<ToolResult> {
248319

249320
export async function runMcpServer(): Promise<void> {
250321
const server = new Server(
251-
{ name: 'timebook', version: '0.1.1' },
322+
{ name: 'timebook', version: pkg.version },
252323
{ capabilities: { tools: {} } },
253324
);
254325

0 commit comments

Comments
 (0)