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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,8 +316,8 @@ tests fail until you do.
variants; `Goldens/Mcp/Tools/*.json` is the raw text each tool returned. An API failure escaping
a tool as a protocol error rather than an `isError` payload is part of what is snapshotted.
- `McpStdioClient` launches the real `tp mcp` with `TIMEPRO_CLI_CONFIG_DIR` pointing at a throwaway
config. `Goldens/Mcp/Discovery/` holds the `tools/list` snapshots with accounting off (18 tools)
and on (47); `Goldens/Mcp/Calls/` holds `tools/call` envelopes.
config. `Goldens/Mcp/Discovery/` holds the `tools/list` snapshots with accounting off (19 tools)
and on (48); `Goldens/Mcp/Calls/` holds `tools/call` envelopes.
- `TimesheetToolsUsingApiDirectly` is the shrink-only allowlist of timesheet tools still calling
`ITimeProApiClient` themselves; `ToolIlScanner` reads the tools' IL (constructor inspection cannot
answer it, since the shared services take the client as an argument).
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ tp ts get 2026-03-12 # Specific date
| `tp whats-new [--url]` | Show embedded Markdown release notes, or print the latest known release-notes URL |
| `tp skills create TARGET [--global] [--force]` | Generate unified agent skill files using enabled feature packs |
| `tp user me` | Show current user info |
| `tp user list [QUERY]` | List users and match names/emails to EmpIDs (`--emp-id`, `--email`, `--all`, `--json`) |
| `tp user list [QUERY]` | List users and match names/emails to EmpIDs (`--emp-id`, `--email`, `--all`, `--staff`, `--json`) |
| `tp user get EMP_ID` | Show focused user details by EmpID (`--json`) |
| `tp blog list` | Latest blog posts (`--mine`, `--limit N`, `--all`) |
| `tp mcp` | Start MCP server (stdio); `--tenant NAME` binds the session to a specific tenant config without changing the global active tenant |
Expand Down Expand Up @@ -624,7 +624,7 @@ Current default tool groups include:
| Group | Examples |
|-------|----------|
| Timesheets | Get, create, update, delete, suggested timesheets, accept suggestions, list iterations, `check_week` (leave-aware weekly coverage) |
| Lookup | Search clients, list projects, get client rate, CRM bookings, location and repo mapping |
| Lookup | Search clients, list projects, get client rate, CRM bookings, location and repo mapping, `list_staff` (active staff expected to log timesheets) |
| Leave | List EasyLeave entries (optionally filtered by `empId`), create and safely update EasyLeave requests with dry-run previews, `get_leave_balance` (days since last leave + 12-month hours), and `get_leave_balance_status` (Xero balance sync status) |

Optional accounting MCP tools are enabled with:
Expand Down
13 changes: 13 additions & 0 deletions src/SSW.TimePro.Cli/Features/Mcp/Tools/LookupMcpTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using ModelContextProtocol.Server;
using SSW.TimePro.Cli.Features.Projects;
using SSW.TimePro.Cli.Features.Rates;
using SSW.TimePro.Cli.Features.Users;
using SSW.TimePro.Cli.Infrastructure.ApiClient;
using SSW.TimePro.Cli.Infrastructure.Config;
using SSW.TimePro.Cli.Infrastructure.Paths;
Expand Down Expand Up @@ -91,6 +92,18 @@ public async Task<string> GetCrmBookings(
return JsonSerializer.Serialize(results, JsonOpts);
}

[McpServerTool]
[Description("List active staff expected to log timesheets (empId, name, email). Excludes admin, service, work experience, contractor and retired accounts. Pair with check_week per empId to find missing timesheets.")]
public async Task<string> ListStaff(CancellationToken ct = default)
{
var tenant = _config.LoadActiveTenantConfig();
if (tenant?.EmployeeId is null)
return """{"error": "Not logged in"}""";

var staff = await StaffDirectory.ListAsync(_api, ct);
return JsonSerializer.Serialize(staff, JsonOpts);
}

[McpServerTool]
[Description("Get the WFH/location defaults and repo mapping for a given path.")]
public string GetLocationAndMapping(
Expand Down
14 changes: 13 additions & 1 deletion src/SSW.TimePro.Cli/Features/Users/ListCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ public class Settings : CommandSettings
[Description("Include former employees")]
public bool All { get; set; }

[CommandOption("--staff")]
[Description("Only active staff expected to log timesheets (excludes admin, service, work experience, contractor and retired accounts)")]
public bool Staff { get; set; }

[CommandOption("--limit <N>")]
[Description("Maximum rows to show; 0 means no limit (default: 50)")]
public int Limit { get; set; } = 50;
Expand All @@ -60,9 +64,17 @@ protected override async Task<int> ExecuteAsync(CommandContext context, Settings
return 1;
}

if (settings.Staff && settings.All)
{
OutputHelper.WriteError("--staff lists active employees only and cannot be combined with --all.");
return 1;
}

try
{
var users = await _api.ListUsersAsync(settings.All, cancellationToken);
var users = settings.Staff
? await StaffDirectory.ListAsync(_api, cancellationToken)
: await _api.ListUsersAsync(settings.All, cancellationToken);
var filtered = ApplyFilters(users, settings).ToList();
var visible = settings.Limit == 0
? filtered
Expand Down
56 changes: 56 additions & 0 deletions src/SSW.TimePro.Cli/Features/Users/StaffDirectory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using SSW.TimePro.Cli.Infrastructure.ApiClient;
using SSW.TimePro.Cli.Shared.Models;

namespace SSW.TimePro.Cli.Features.Users;

/// <summary>
/// The active employees expected to log timesheets — the roster a "who is missing timesheets"
/// check runs against. Shared by <c>tp user list --staff</c> and the <c>ListStaff</c> MCP tool.
/// </summary>
public static class StaffDirectory
{
/// <summary>
/// Categories that never log timesheets: office/admin (<c>O</c>, <c>OA-E</c>), external
/// contractors (<c>EXCON</c>) and work experience (<c>WE</c>). Uncategorised accounts are
/// excluded too; they are service, bot and admin logins.
/// </summary>
private static readonly HashSet<string> ExcludedCategories =
new(["O", "OA-E", "EXCON", "WE"], StringComparer.OrdinalIgnoreCase);

/// <summary>Retired accounts are renamed with a "zz" prefix rather than given an end date.</summary>
private const string RetiredPrefix = "zz";

private const int MaxConcurrentDetailReads = 8;

/// <summary>
/// Lists active staff. The dropdown carries no category, so each remaining employee's detail
/// is read (bounded concurrency); "zz" accounts are dropped first to skip their reads.
/// </summary>
public static async Task<List<EmployeeSummary>> ListAsync(ITimeProApiClient api, CancellationToken ct)
{
var candidates = (await api.ListUsersAsync(includeFormerEmployees: false, ct))
.Where(u => !IsRetired(u.Name))
.ToList();

var categories = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
await Parallel.ForEachAsync(
candidates,
new ParallelOptions { MaxDegreeOfParallelism = MaxConcurrentDetailReads, CancellationToken = ct },
async (user, token) =>
{
var detail = await api.GetUserAsync(user.EmpId!, token);
lock (categories)
categories[user.EmpId!] = detail?.CategoryId;
});

return candidates
.Where(u => IsStaffCategory(categories.GetValueOrDefault(u.EmpId!)))
.ToList();
}

private static bool IsRetired(string? name) =>
name?.TrimStart().StartsWith(RetiredPrefix, StringComparison.OrdinalIgnoreCase) == true;

private static bool IsStaffCategory(string? categoryId) =>
!string.IsNullOrWhiteSpace(categoryId) && !ExcludedCategories.Contains(categoryId.Trim());
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"count": 47,
"count": 48,
"tools": [
{
"description": "Accept a suggested timesheet, converting it into a real timesheet. Returns the entry as saved.",
Expand Down Expand Up @@ -1219,6 +1219,17 @@
},
"name": "list_recurring_invoices"
},
{
"description": "List active staff expected to log timesheets (empId, name, email). Excludes admin, service, work experience, contractor and retired accounts. Pair with check_week per empId to find missing timesheets.",
"execution": {
"taskSupport": "optional"
},
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "list_staff"
},
{
"description": "Query timesheets across empIds, clients, projects and a date range. Returns detailed rows including hours and sell price; sell prices/amounts are treated as ex-GST for invoice reconciliation. employeeIds is accepted as an alias for empIds.",
"execution": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"count": 18,
"count": 19,
"tools": [
{
"description": "Accept a suggested timesheet, converting it into a real timesheet. Returns the entry as saved.",
Expand Down Expand Up @@ -529,6 +529,17 @@
},
"name": "list_iterations"
},
{
"description": "List active staff expected to log timesheets (empId, name, email). Excludes admin, service, work experience, contractor and retired accounts. Pair with check_week per empId to find missing timesheets.",
"execution": {
"taskSupport": "optional"
},
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "list_staff"
},
{
"description": "Search for clients by name. Returns client IDs and names.",
"execution": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[
{
"email": "bob@northwind.example",
"empId": "BOB",
"name": "Bob Northwind"
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[
{
"email": "bob@northwind.example",
"empId": "BOB",
"name": "Bob Northwind"
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"message": "TimePro API returned 500 Internal Server Error",
"responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}",
"statusCode": 500,
"threw": "ApiException"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[]
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[
{
"email": "bob@northwind.example",
"empId": "BOB",
"name": "Bob Northwind"
}
]
12 changes: 11 additions & 1 deletion tests/SSW.TimePro.Cli.Integration/Mcp/McpCliParityTable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public sealed record ParityRow(string ToolMethod, string? CliCommandPath)
}

/// <summary>
/// The CLI/MCP pairing for all 47 tools. Rows flip <c>ExpectParity</c> to true as the unification
/// The CLI/MCP pairing for all 48 tools. Rows flip <c>ExpectParity</c> to true as the unification
/// slices land; the five tools with no CLI command at all are the shrink-only allowlist.
/// </summary>
public static class McpCliParityTable
Expand Down Expand Up @@ -354,6 +354,16 @@ public static class McpCliParityTable
new("GET", "/api/Timesheets/GetTimesheetListViewModel")
]
},

// Appended after the executable rows: Parity goldens are named by row index.
new("ListStaff", "user list")
{
CliArgs = ["user", "list", "--staff", "--limit", "0", "--json"],
InvokeTool = (h, ct) => h.Lookups.ListStaff(ct),
ExpectParity = true,
Note = "Both surfaces project StaffDirectory.ListAsync."
},

new("GetLocationAndMapping", "location info") { Note = "MCP merges location defaults and repo mapping." },
new("GetLeaveEntries", "leave list") { Note = "MCP returns the items array, CLI the envelope." },
new("GetLeaveBalance", "leave balance") { Note = "Separate projections." },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ namespace SSW.TimePro.Cli.Integration.Mcp;
[Collection(McpStdioCollection.Name)]
public class McpStdioDiscoveryTests
{
public const int DefaultToolCount = 18;
public const int AccountingEnabledToolCount = 47;
public const int DefaultToolCount = 19;
public const int AccountingEnabledToolCount = 48;

[Fact]
public async Task ToolsList_WithAccountingDisabled_MatchesGolden()
Expand Down
6 changes: 6 additions & 0 deletions tests/SSW.TimePro.Cli.Integration/Mcp/McpToolCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ private static IReadOnlyList<McpToolCase> BuildPopulated() =>
EmptyBody = "[]"
},

new("ListStaff", "populated", (h, ct) => h.Lookups.ListStaff(ct))
{
PrimaryRoute = "/api/Employees/DropDown",
EmptyBody = "[]"
},

new("GetLocationAndMapping", "populated",
(h, _) => Task.FromResult(h.Lookups.GetLocationAndMapping("~/code/traders-app")))
{
Expand Down
37 changes: 36 additions & 1 deletion tests/SSW.TimePro.Cli.Integration/Mcp/NorthwindApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,11 @@ public static class NorthwindApi
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};

/// <summary>Registers every route the 47 MCP tools can reach, populated with Northwind data.</summary>
/// <summary>Registers every route the 48 MCP tools can reach, populated with Northwind data.</summary>
public static void StubAll(WireMockServer server)
{
StubIdentity(server);
StubStaff(server);
StubLookups(server);
StubTimesheets(server);
StubLeave(server);
Expand Down Expand Up @@ -106,6 +107,40 @@ private static void StubIdentity(WireMockServer server)
});
}

/// <summary>Work-experience colleague the staff roster must drop by category.</summary>
public const string WorkExperienceEmpId = "TIM";

/// <summary>Retired colleague the staff roster must drop by the "zz" name prefix.</summary>
public const string RetiredEmpId = "ZZR";

private static void StubStaff(WireMockServer server)
{
Json(server, "/api/Employees/DropDown", "GET", new List<EmployeeDropdownItem>
{
new() { Text = $"{EmpName} ({EmpId})", Value = EmpId },
new() { Text = $"Tim Northwind ({WorkExperienceEmpId})", Value = WorkExperienceEmpId },
new() { Text = $"zzRita zzNorthwind ({RetiredEmpId})", Value = RetiredEmpId }
});

Json(server, "/api/Employees/GetByIds", "POST", new List<EmployeeSummary>
{
new() { EmpId = EmpId, Name = EmpName, Email = EmpEmail },
new() { EmpId = WorkExperienceEmpId, Name = "Tim Northwind", Email = "tim@northwind.example" },
new() { EmpId = RetiredEmpId, Name = "zzRita zzNorthwind", Email = "rita@northwind.example" }
});

Json(server, $"/api/employees/{EmpId}", "GET", new EmployeeDetail
{
EmpId = EmpId, FirstName = "Bob", Surname = "Northwind", Email = EmpEmail, CategoryId = "PM-E"
});

Json(server, $"/api/employees/{WorkExperienceEmpId}", "GET", new EmployeeDetail
{
EmpId = WorkExperienceEmpId, FirstName = "Tim", Surname = "Northwind",
Email = "tim@northwind.example", CategoryId = "WE"
});
}

// ───────────────────────── Lookups ─────────────────────────

public const string SentinelDisplayText = "Empty - Please add the project";
Expand Down
45 changes: 45 additions & 0 deletions tests/SSW.TimePro.Cli.Tests/Features/Users/StaffDirectoryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using FluentAssertions;
using NSubstitute;
using SSW.TimePro.Cli.Features.Users;
using SSW.TimePro.Cli.Infrastructure.ApiClient;
using SSW.TimePro.Cli.Shared.Models;
using Xunit;

namespace SSW.TimePro.Cli.Tests.Features.Users;

public class StaffDirectoryTests
{
[Fact]
public async Task ListAsync_KeepsOnlyCategorisedStaffOutsideExcludedCategories()
{
var api = Substitute.For<ITimeProApiClient>();
var ct = TestContext.Current.CancellationToken;

api.ListUsersAsync(false, Arg.Any<CancellationToken>()).Returns(
[
new EmployeeSummary { EmpId = "BOB", Name = "Bob Northwind" },
new EmployeeSummary { EmpId = "OFF", Name = "Olive Northwind" },
new EmployeeSummary { EmpId = "OAE", Name = "Oscar Northwind" },
new EmployeeSummary { EmpId = "CON", Name = "Connie Northwind" },
new EmployeeSummary { EmpId = "TIM", Name = "Tim Northwind" },
new EmployeeSummary { EmpId = "SVC", Name = "Northwind Service" },
new EmployeeSummary { EmpId = "ZZR", Name = "zzRita zzNorthwind" }
]);

Detail(api, "BOB", "PM-E");
Detail(api, "OFF", "O");
Detail(api, "OAE", "oa-e");
Detail(api, "CON", "EXCON");
Detail(api, "TIM", "WE");
Detail(api, "SVC", null);

var staff = await StaffDirectory.ListAsync(api, ct);

staff.Select(s => s.EmpId).Should().Equal("BOB");
await api.DidNotReceive().GetUserAsync("ZZR", Arg.Any<CancellationToken>());
}

private static void Detail(ITimeProApiClient api, string empId, string? categoryId) =>
api.GetUserAsync(empId, Arg.Any<CancellationToken>())
.Returns(new EmployeeDetail { EmpId = empId, CategoryId = categoryId });
}
Loading