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
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,18 @@ resolving an iteration by name or ID) and `TimesheetAcceptService` owns accept;
`ts accept` commands and the `UpdateTimesheet` / `AcceptSuggestedTimesheet` MCP tools are adapters
over them. Never build a `TimesheetRequest` for an edit anywhere else.

`TimesheetCreateService` is the same prepare/apply pair for new entries and owns every resolution a
create needs: sell price from the client rate for the billable type, category from the repo mapping
then the last fortnight's entries, location from the WFH defaults, deducted minutes to hours, and the
read-back that turns an empty write response into the saved row. `ts create` and the
`CreateTimesheet` MCP tool are adapters over it; never build a `TimesheetRequest` for a create
elsewhere either.

The API cannot price a row for a client with no active rate, so `PrepareAsync` stops and reports it
rather than resolving it. Creating a rate stays with the caller: `ts create` keeps its interactive
prompt, and MCP returns the `tp rate create` recovery command. Neither the service nor MCP ever
writes a rate.

`ts update` and `ts delete` refuse suggested entries locally (`tp ts accept <id>` first) rather than
letting the API answer a bare 400; the MCP delete tool shares that check. Accept fails before the API
call when the project uses iterations and none can be resolved, listing the available ones.
Expand Down
7 changes: 6 additions & 1 deletion scripts/e2e/mcp_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ def run_smoke(process, state):
# The write goes through MCP on purpose, so a create tool the server rejects keeps this gate
# red instead of being routed around.
state["createAttempted"] = True
call_tool(
create_result = call_tool(
process,
"create_timesheet",
{
Expand All @@ -334,6 +334,11 @@ def run_smoke(process, state):

created = matches[0]
created_id = created["timeId"]
reported_id = create_result.get("timesheetId") if isinstance(create_result, dict) else None
if reported_id not in (None, created_id):
Comment on lines +337 to +338
raise SmokeFailure(
f"create_timesheet reported id {reported_id} but the row read back is {created_id}"
)
for field, expected in (("clientId", CLIENT_ID), ("projectId", project_id), ("date", date)):
if created.get(field) != expected:
raise SmokeFailure(
Expand Down
1 change: 1 addition & 0 deletions src/SSW.TimePro.Cli/Features/Mcp/McpHostCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ protected override async Task<int> ExecuteAsync(CommandContext context, Settings
builder.Services.AddSingleton<LeaveCreateService>();
builder.Services.AddSingleton<LeaveUpdateService>();
builder.Services.AddSingleton<LeaveBalanceImportService>();
builder.Services.AddSingleton<TimesheetCreateService>();
builder.Services.AddSingleton<TimesheetUpdateService>();
builder.Services.AddSingleton<TimesheetAcceptService>();

Expand Down
67 changes: 40 additions & 27 deletions src/SSW.TimePro.Cli/Features/Mcp/Tools/TimesheetMcpTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ModelContextProtocol.Server;
using SSW.TimePro.Cli.Features.Rates;
using SSW.TimePro.Cli.Features.Timesheets;
using SSW.TimePro.Cli.Infrastructure.ApiClient;
using SSW.TimePro.Cli.Infrastructure.Config;
using SSW.TimePro.Cli.Shared;
using SSW.TimePro.Cli.Infrastructure.Output;
using SSW.TimePro.Cli.Shared.Models;

namespace SSW.TimePro.Cli.Features.Mcp.Tools;
Expand All @@ -15,6 +16,7 @@ public class TimesheetMcpTools
{
private readonly ITimeProApiClient _api;
private readonly IConfigService _config;
private readonly TimesheetCreateService _creates;
private readonly TimesheetUpdateService _updates;
private readonly TimesheetAcceptService _accepts;

Expand All @@ -28,11 +30,13 @@ public class TimesheetMcpTools
public TimesheetMcpTools(
ITimeProApiClient api,
IConfigService config,
TimesheetCreateService creates,
TimesheetUpdateService updates,
TimesheetAcceptService accepts)
{
_api = api;
_config = config;
_creates = creates;
_updates = updates;
_accepts = accepts;
}
Expand Down Expand Up @@ -96,38 +100,47 @@ public async Task<string> CreateTimesheet(
if (tenant?.EmployeeId is null)
return """{"error": "Not logged in"}""";

// Resolve location from WFH defaults if not specified
if (string.IsNullOrEmpty(location))
try
{
var global = _config.LoadGlobalConfig();
var dateOnly = DateOnly.ParseExact(date, "yyyy-MM-dd");
var dayName = dateOnly.DayOfWeek.ToString();
location = global.WfhDays.Contains(dayName, StringComparer.OrdinalIgnoreCase)
? "Home" : LocationResolver.Resolve(global.DefaultLocation);
var prepared = await _creates.PrepareAsync(
tenant.EmployeeId,
new TimesheetCreateOptions(
ClientId: clientId,
ProjectId: projectId,
Date: date,
Start: startTime,
End: endTime,
Description: description,
Location: location,
Category: categoryId,
IterationId: iterationId,
Billable: billableId),
ct);

// Creating a rate is a deliberate act with its own approval, so the agent is told how
// to set one rather than having one written on its behalf.
if (prepared.NoActiveRate)
return NoActiveRateError(clientId);

var result = await _creates.ApplyAsync(prepared.Plan!, ct);

// Serialised with the CLI's options so both surfaces answer with the same document.
return OutputHelper.SerializeJson(result);
}
else
catch (TimesheetValidationException ex)
{
location = LocationResolver.Resolve(location);
return JsonSerializer.Serialize(new { error = ex.Message }, JsonOpts);
}
}

var request = new TimesheetRequest
{
EmpId = tenant.EmployeeId,
ClientId = clientId,
ProjectId = projectId,
IterationId = iterationId,
DateCreated = date,
TimeStart = $"{date}T{startTime}:00",
TimeEnd = $"{date}T{endTime}:00",
Note = description,
LocationId = location,
CategoryId = categoryId,
BillableId = billableId
};
private static string NoActiveRateError(string clientId) => JsonSerializer.Serialize(new
{
error = RateGuard.NoActiveRateMessage(clientId),

var response = await _api.CreateTimesheetAsync(request, ct);
return JsonSerializer.Serialize(response, JsonOpts);
}
// No recommendation lookup: an agent is told how to set a rate, not offered an amount that
// would take a second API call to produce.
recovery = RateGuard.BuildRecovery(clientId, new RateRecommendation(0m, 0m, RateSource.None))
}, JsonOpts);

[McpServerTool]
[Description("List iterations/sprints for a project. Returns empty list if the project doesn't use iterations. If the list is non-empty, an iteration ID is required when creating timesheets for this project.")]
Expand Down
32 changes: 18 additions & 14 deletions src/SSW.TimePro.Cli/Features/Rates/RateGuard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,33 @@ namespace SSW.TimePro.Cli.Features.Rates;
/// </summary>
public static class RateGuard
{
public static string NoActiveRateMessage(string clientId) =>
$"No active rate for client '{clientId}' (expired or not set). " +
"Set a rate using the recovery command below, then retry.";

/// <summary>The recovery recipe, shared by the CLI error envelope and the MCP error payload.</summary>
public static object BuildRecovery(string clientId, RateRecommendation rec) => new
{
reason = "no_active_rate",
clientId,
recommended = rec.Source == RateSource.None
? null
: (object)new { rate = rec.Rate, prepaidRate = rec.PrepaidRate, source = rec.Source.ToString() },
steps = RateResolver.BuildRecoveryOptions(clientId, rec)
};

public static void ReportNoActiveRate(string clientId, RateRecommendation rec, bool json)
{
var steps = RateResolver.BuildRecoveryOptions(clientId, rec);
var msg = $"No active rate for client '{clientId}' (expired or not set). " +
"Set a rate using the recovery command below, then retry.";
var msg = NoActiveRateMessage(clientId);

if (json)
{
var recovery = new
{
reason = "no_active_rate",
clientId,
recommended = rec.Source == RateSource.None
? null
: (object)new { rate = rec.Rate, prepaidRate = rec.PrepaidRate, source = rec.Source.ToString() },
steps
};
OutputHelper.WriteJsonError(msg, code: null, detail: null, recovery: recovery);
OutputHelper.WriteJsonError(msg, code: null, detail: null, recovery: BuildRecovery(clientId, rec));
}
else
{
OutputHelper.WriteError(msg);
foreach (var s in steps)
foreach (var s in RateResolver.BuildRecoveryOptions(clientId, rec))
OutputHelper.WriteInfo($" [{s.Action}] {s.Command}");
}
}
Expand Down
Loading