Skip to content

Commit 1dc8bac

Browse files
ktamas77claude
andcommitted
feat(mcp): cold-start hints + create_client/create_project tools
Mirror of the hosted MCP server (timebook backend): empty list_clients/list_projects/list_entries return actionable setup hints, not-found errors explain the setup path on fresh accounts, and the new create_client/create_project tools let agents do first-run setup in-conversation. 16 new tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f4a1697 commit 1dc8bac

3 files changed

Lines changed: 283 additions & 3 deletions

File tree

src/lib/api.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,10 @@ export interface TimeEntry {
130130
export const api = {
131131
me: () => request<{ user: User }>('/api/auth/me'),
132132
listClients: () => request<{ clients: Client[] }>('/api/clients'),
133+
createClient: (input: { name: string; email?: string }) =>
134+
request<{ client: Client }>('/api/clients', { method: 'POST', body: input }),
135+
createProject: (input: { name: string; clientId: string; description?: string }) =>
136+
request<{ project: Project }>('/api/projects', { method: 'POST', body: input }),
133137
listProjects: () => request<{ projects: Project[] }>('/api/projects'),
134138
listRates: () => request<{ rates: Rate[] }>('/api/rates'),
135139
activeTimer: () => request<{ entry: TimeEntry | null }>('/api/time-entries/active'),

src/mcp/server.test.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Cold-start hints + create tools — mirror of the hosted MCP server's
2+
// behavior (timebook repo backend/src/mcp/tools-coldstart.test.ts).
3+
import { describe, it, expect, vi, beforeEach } from 'vitest';
4+
5+
vi.mock('../lib/api.js', () => ({
6+
api: {
7+
me: vi.fn(),
8+
listProjects: vi.fn(),
9+
listClients: vi.fn(),
10+
listEntries: vi.fn(),
11+
listRates: vi.fn(),
12+
createClient: vi.fn(),
13+
createProject: vi.fn(),
14+
activeTimer: vi.fn(),
15+
startTimer: vi.fn(),
16+
stopTimer: vi.fn(),
17+
logTime: vi.fn(),
18+
},
19+
ApiError: class ApiError extends Error {
20+
constructor(
21+
public status: number,
22+
message: string,
23+
) {
24+
super(message);
25+
}
26+
},
27+
}));
28+
vi.mock('../lib/config.js', () => ({
29+
readConfig: vi.fn(async () => ({ token: 'tbk_test' })),
30+
}));
31+
32+
import { handleTool, COLD_START_HINTS } from './server.js';
33+
import { api } from '../lib/api.js';
34+
35+
const mocked = api as unknown as Record<string, ReturnType<typeof vi.fn>>;
36+
37+
const textOf = (result: any): string =>
38+
(result.content ?? []).map((c: any) => c.text ?? '').join('\n');
39+
40+
beforeEach(() => {
41+
vi.clearAllMocks();
42+
});
43+
44+
describe('cold-start hints', () => {
45+
it('list_clients empty → create_client hint', async () => {
46+
mocked.listClients.mockResolvedValue({ clients: [] });
47+
const res = await handleTool('list_clients', {});
48+
expect(res.isError).not.toBe(true);
49+
expect(textOf(res)).toContain(COLD_START_HINTS.noClients);
50+
});
51+
52+
it('list_clients with data → no hint', async () => {
53+
mocked.listClients.mockResolvedValue({ clients: [{ id: 'c1', name: 'Acme' }] });
54+
const res = await handleTool('list_clients', {});
55+
expect(textOf(res)).not.toContain('create_client (just a name');
56+
});
57+
58+
it('list_projects empty + no clients → client-first hint', async () => {
59+
mocked.listProjects.mockResolvedValue({ projects: [] });
60+
mocked.listClients.mockResolvedValue({ clients: [] });
61+
const res = await handleTool('list_projects', {});
62+
expect(textOf(res)).toContain(COLD_START_HINTS.noClients);
63+
});
64+
65+
it('list_projects empty + clients exist → project hint', async () => {
66+
mocked.listProjects.mockResolvedValue({ projects: [] });
67+
mocked.listClients.mockResolvedValue({ clients: [{ id: 'c1', name: 'Acme' }] });
68+
const res = await handleTool('list_projects', {});
69+
expect(textOf(res)).toContain(COLD_START_HINTS.noProjects);
70+
});
71+
72+
it('list_entries empty on a set-up account → entries hint', async () => {
73+
mocked.listEntries.mockResolvedValue({ entries: [] });
74+
mocked.listClients.mockResolvedValue({ clients: [{ id: 'c1' }] });
75+
mocked.listProjects.mockResolvedValue({ projects: [{ id: 'p1', name: 'X' }] });
76+
const res = await handleTool('list_entries', {});
77+
expect(textOf(res)).toContain(COLD_START_HINTS.noEntries);
78+
});
79+
80+
it('list_entries empty on an EMPTY account → client-first hint', async () => {
81+
mocked.listEntries.mockResolvedValue({ entries: [] });
82+
mocked.listClients.mockResolvedValue({ clients: [] });
83+
mocked.listProjects.mockResolvedValue({ projects: [] });
84+
const res = await handleTool('list_entries', {});
85+
expect(textOf(res)).toContain(COLD_START_HINTS.noClients);
86+
});
87+
88+
it('start_timer project-not-found on empty account → setup guidance', async () => {
89+
mocked.listProjects.mockResolvedValue({ projects: [] });
90+
mocked.listClients.mockResolvedValue({ clients: [] });
91+
const res = await handleTool('start_timer', { project: 'Website' });
92+
expect(res.isError).toBe(true);
93+
expect(textOf(res)).toContain('Project not found: Website');
94+
expect(textOf(res)).toContain(COLD_START_HINTS.noClients);
95+
});
96+
97+
it('project-not-found on a populated account stays a plain error', async () => {
98+
mocked.listProjects.mockResolvedValue({ projects: [{ id: 'p1', name: 'Other' }] });
99+
mocked.listClients.mockResolvedValue({ clients: [{ id: 'c1', name: 'Acme' }] });
100+
const res = await handleTool('start_timer', { project: 'Website' });
101+
expect(res.isError).toBe(true);
102+
expect(textOf(res)).not.toContain('create_client');
103+
});
104+
});
105+
106+
describe('create_client', () => {
107+
it('creates and returns the next-step hint', async () => {
108+
mocked.createClient.mockResolvedValue({ client: { id: 'c9', name: 'Acme' } });
109+
const res = await handleTool('create_client', { name: 'Acme' });
110+
expect(res.isError).not.toBe(true);
111+
expect(mocked.createClient).toHaveBeenCalledWith({ name: 'Acme' });
112+
expect(textOf(res)).toContain('create_project');
113+
});
114+
115+
it('passes the optional email through', async () => {
116+
mocked.createClient.mockResolvedValue({ client: { id: 'c9' } });
117+
await handleTool('create_client', { name: 'Acme', email: 'a@acme.com' });
118+
expect(mocked.createClient).toHaveBeenCalledWith({ name: 'Acme', email: 'a@acme.com' });
119+
});
120+
121+
it('rejects a missing name via zod', async () => {
122+
const res = await handleTool('create_client', {});
123+
expect(res.isError).toBe(true);
124+
expect(textOf(res)).toContain('Invalid arguments');
125+
});
126+
});
127+
128+
describe('create_project', () => {
129+
it('resolves the client by exact name', async () => {
130+
mocked.listClients.mockResolvedValue({ clients: [{ id: 'c1', name: 'Acme' }] });
131+
mocked.createProject.mockResolvedValue({ project: { id: 'p9', name: 'Website' } });
132+
const res = await handleTool('create_project', { name: 'Website', client: 'Acme' });
133+
expect(res.isError).not.toBe(true);
134+
expect(mocked.createProject).toHaveBeenCalledWith({ name: 'Website', clientId: 'c1' });
135+
expect(textOf(res)).toContain('start_timer');
136+
});
137+
138+
it('unknown client on an empty account → client-first guidance', async () => {
139+
mocked.listClients.mockResolvedValue({ clients: [] });
140+
const res = await handleTool('create_project', { name: 'Website', client: 'Acme' });
141+
expect(res.isError).toBe(true);
142+
expect(textOf(res)).toContain(COLD_START_HINTS.noClients);
143+
});
144+
145+
it('rejects missing fields via zod', async () => {
146+
expect((await handleTool('create_project', { name: 'X' })).isError).toBe(true);
147+
expect((await handleTool('create_project', { client: 'X' })).isError).toBe(true);
148+
});
149+
});

src/mcp/server.ts

Lines changed: 130 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { createRequire } from 'node:module';
1010
import { z } from 'zod';
1111
import { api, ApiError } from '../lib/api.js';
1212
import { readConfig } from '../lib/config.js';
13-
import { resolveProject } from '../lib/resolve.js';
13+
import { resolveClient, resolveProject } from '../lib/resolve.js';
1414
import { parseDuration } from '../lib/format.js';
1515

1616
const pkg = createRequire(import.meta.url)('../../package.json') as { version: string };
@@ -65,6 +65,17 @@ const updateEntryInput = z.object({
6565
rate: z.string().optional().describe('Switch billable rate (id or name).'),
6666
});
6767

68+
const createClientInput = z.object({
69+
name: z.string().min(1, '`name` is required'),
70+
email: z.string().optional(),
71+
});
72+
73+
const createProjectInput = z.object({
74+
name: z.string().min(1, '`name` is required'),
75+
client: z.string().min(1, '`client` is required (id or exact name)'),
76+
description: z.string().optional(),
77+
});
78+
6879
const deleteEntryInput = z.object({
6980
id: z.string().describe('Entry id (uuid) to delete.'),
7081
});
@@ -261,6 +272,49 @@ const TOOLS: Tool[] = [
261272
openWorldHint: true,
262273
},
263274
},
275+
// Cold-start tools: mirror of the hosted MCP server (backend
276+
// src/mcp/tools.ts) so agent-first setup works over stdio too.
277+
{
278+
name: 'create_client',
279+
description:
280+
'Create a client (the person/company you bill). Needed before any project or time entry can exist. Typical first step on a fresh account.',
281+
inputSchema: {
282+
type: 'object',
283+
properties: {
284+
name: { type: 'string', description: "Client name, e.g. 'Acme Corp'." },
285+
email: { type: 'string', description: 'Optional billing/contact email.' },
286+
},
287+
required: ['name'],
288+
additionalProperties: false,
289+
},
290+
annotations: {
291+
readOnlyHint: false,
292+
destructiveHint: false,
293+
idempotentHint: false,
294+
openWorldHint: true,
295+
},
296+
},
297+
{
298+
name: 'create_project',
299+
description:
300+
'Create a project under a client. Time entries are always tracked against a project. Typical second step on a fresh account, after create_client.',
301+
inputSchema: {
302+
type: 'object',
303+
properties: {
304+
name: { type: 'string', description: "Project name, e.g. 'Website redesign'." },
305+
client: { type: 'string', description: 'Client - id or exact name.' },
306+
description: { type: 'string', description: 'Optional project description.' },
307+
},
308+
required: ['name', 'client'],
309+
additionalProperties: false,
310+
},
311+
annotations: {
312+
readOnlyHint: false,
313+
destructiveHint: false,
314+
idempotentHint: false,
315+
openWorldHint: true,
316+
},
317+
},
264318
];
265319

266320
type ToolResult = CallToolResult;
@@ -274,6 +328,36 @@ const err = (message: string): ToolResult => ({
274328
isError: true,
275329
});
276330

331+
// Like ok(), plus a guidance line the agent can act on (mirrors the hosted
332+
// MCP server's cold-start behavior — keep the wording in sync with
333+
// backend/src/mcp/tools.ts in the timebook repo).
334+
const okWithHint = (data: unknown, hint: string): ToolResult => ({
335+
content: [
336+
{ type: 'text', text: JSON.stringify(data, null, 2) },
337+
{ type: 'text', text: hint },
338+
],
339+
});
340+
341+
export const COLD_START_HINTS = {
342+
noClients:
343+
'This account has no clients yet. Create one with create_client (just a name is enough), then add a project with create_project — after that you can start timers and log time.',
344+
noProjects:
345+
'There are clients but no projects yet. Create one with create_project (name + client), then you can start timers and log time against it.',
346+
noEntries:
347+
'No time entries yet. Start a timer with start_timer or log past work with log_time (both need a project).',
348+
} as const;
349+
350+
async function coldStartHintFor(emptyWhat: 'projects' | 'entries'): Promise<string> {
351+
try {
352+
const [{ clients }, { projects }] = await Promise.all([api.listClients(), api.listProjects()]);
353+
if (clients.length === 0) return COLD_START_HINTS.noClients;
354+
if (projects.length === 0) return COLD_START_HINTS.noProjects;
355+
} catch {
356+
// Counting failed — fall through to the generic hint for the surface.
357+
}
358+
return emptyWhat === 'entries' ? COLD_START_HINTS.noEntries : COLD_START_HINTS.noProjects;
359+
}
360+
277361
async function ensureLoggedIn(): Promise<void> {
278362
const config = await readConfig();
279363
if (!config.token) {
@@ -283,7 +367,7 @@ async function ensureLoggedIn(): Promise<void> {
283367
}
284368
}
285369

286-
async function handleTool(name: string, args: unknown): Promise<ToolResult> {
370+
export async function handleTool(name: string, args: unknown): Promise<ToolResult> {
287371
try {
288372
await ensureLoggedIn();
289373

@@ -294,10 +378,12 @@ async function handleTool(name: string, args: unknown): Promise<ToolResult> {
294378
}
295379
case 'list_projects': {
296380
const { projects } = await api.listProjects();
381+
if (projects.length === 0) return okWithHint(projects, await coldStartHintFor('projects'));
297382
return ok(projects);
298383
}
299384
case 'list_clients': {
300385
const { clients } = await api.listClients();
386+
if (clients.length === 0) return okWithHint(clients, COLD_START_HINTS.noClients);
301387
return ok(clients);
302388
}
303389
case 'get_active_timer': {
@@ -374,6 +460,7 @@ async function handleTool(name: string, args: unknown): Promise<ToolResult> {
374460
endDate: input.endDate,
375461
});
376462
const limit = input.limit ?? DEFAULT_ENTRY_LIMIT;
463+
if (entries.length === 0) return okWithHint(entries, await coldStartHintFor('entries'));
377464
return ok(entries.slice(0, limit));
378465
}
379466
case 'update_entry': {
@@ -406,6 +493,30 @@ async function handleTool(name: string, args: unknown): Promise<ToolResult> {
406493
const result = await api.deleteEntry(input.id);
407494
return ok(result);
408495
}
496+
case 'create_client': {
497+
const input = createClientInput.parse(args ?? {});
498+
const { client } = await api.createClient({
499+
name: input.name,
500+
...(input.email ? { email: input.email } : {}),
501+
});
502+
return okWithHint(
503+
client,
504+
'Client created. Next: create_project (name + this client), then start_timer or log_time.',
505+
);
506+
}
507+
case 'create_project': {
508+
const input = createProjectInput.parse(args ?? {});
509+
const client = await resolveClient(input.client);
510+
const { project } = await api.createProject({
511+
name: input.name,
512+
clientId: client.id,
513+
...(input.description ? { description: input.description } : {}),
514+
});
515+
return okWithHint(
516+
project,
517+
'Project created. You can now start_timer or log_time against it.',
518+
);
519+
}
409520
default:
410521
return err(`Unknown tool: ${name}`);
411522
}
@@ -416,7 +527,23 @@ async function handleTool(name: string, args: unknown): Promise<ToolResult> {
416527
if (e instanceof ApiError) {
417528
return err(`API error (${e.status}): ${e.message}`);
418529
}
419-
return err(e instanceof Error ? e.message : String(e));
530+
const message = e instanceof Error ? e.message : String(e);
531+
// Cold-start enrichment: "not found" on a fresh account usually means
532+
// nothing exists yet — tell the agent how to fix that instead of
533+
// leaving it to guess.
534+
if (/^Project not found:/.test(message) || /^Client not found:/.test(message)) {
535+
try {
536+
const { clients } = await api.listClients();
537+
if (clients.length === 0) return err(`${message}. ${COLD_START_HINTS.noClients}`);
538+
if (/^Project not found:/.test(message)) {
539+
const { projects } = await api.listProjects();
540+
if (projects.length === 0) return err(`${message}. ${COLD_START_HINTS.noProjects}`);
541+
}
542+
} catch {
543+
// fall through to the plain error
544+
}
545+
}
546+
return err(message);
420547
}
421548
}
422549

0 commit comments

Comments
 (0)