@@ -10,7 +10,7 @@ import { createRequire } from 'node:module';
1010import { z } from 'zod' ;
1111import { api , ApiError } from '../lib/api.js' ;
1212import { readConfig } from '../lib/config.js' ;
13- import { resolveProject } from '../lib/resolve.js' ;
13+ import { resolveClient , resolveProject } from '../lib/resolve.js' ;
1414import { parseDuration } from '../lib/format.js' ;
1515
1616const 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+
6879const 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
266320type 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+
277361async 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 ( / ^ P r o j e c t n o t f o u n d : / . test ( message ) || / ^ C l i e n t n o t f o u n d : / . test ( message ) ) {
535+ try {
536+ const { clients } = await api . listClients ( ) ;
537+ if ( clients . length === 0 ) return err ( `${ message } . ${ COLD_START_HINTS . noClients } ` ) ;
538+ if ( / ^ P r o j e c t n o t f o u n d : / . 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