From 450764a846d1e8e4ced02972a740c34e2c25d4ec Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 21:19:10 -0500 Subject: [PATCH 01/18] Add `tc-mcp` project with initial implementation - Introduced `Yllibed.TenantCloudClient.Mcp` project targeting .NET 10. - Implemented `InstallCommand` for registering `tc-mcp` with Claude Desktop and Claude Code. - Added dependency injection for MCP JSON-RPC tools (e.g., UserTools, ContactTools). - Updated solution file and dependency versions in `Directory.Packages.props`. --- src/Directory.Packages.props | 2 + src/TenantCloud.slnx | 1 + .../InstallCommand.cs | 172 ++++++++++++++++++ src/Yllibed.TenantCloudClient.Mcp/Program.cs | 54 ++++++ .../Yllibed.TenantCloudClient.Mcp.csproj | 29 +++ 5 files changed, 258 insertions(+) create mode 100644 src/Yllibed.TenantCloudClient.Mcp/InstallCommand.cs create mode 100644 src/Yllibed.TenantCloudClient.Mcp/Program.cs create mode 100644 src/Yllibed.TenantCloudClient.Mcp/Yllibed.TenantCloudClient.Mcp.csproj diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 46c1aad..0262332 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -12,6 +12,8 @@ + + diff --git a/src/TenantCloud.slnx b/src/TenantCloud.slnx index 3317364..4f24719 100644 --- a/src/TenantCloud.slnx +++ b/src/TenantCloud.slnx @@ -1,5 +1,6 @@ + diff --git a/src/Yllibed.TenantCloudClient.Mcp/InstallCommand.cs b/src/Yllibed.TenantCloudClient.Mcp/InstallCommand.cs new file mode 100644 index 0000000..fbfcc77 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Mcp/InstallCommand.cs @@ -0,0 +1,172 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Yllibed.TenantCloudClient.Mcp; + +internal static class InstallCommand +{ + public static int Run(ReadOnlySpan args) + { + if (args.Length == 0) + { + Console.Error.WriteLine("Usage: tc-mcp install "); + Console.Error.WriteLine(" Targets: claude-desktop, claude-code"); + return 1; + } + + var target = args[0]; + + if (string.Equals(target, "claude-desktop", StringComparison.OrdinalIgnoreCase)) + { + return InstallClaudeDesktop(); + } + + if (string.Equals(target, "claude-code", StringComparison.OrdinalIgnoreCase)) + { + return InstallClaudeCode(); + } + + Console.Error.WriteLine($"Unknown install target: {target}"); + Console.Error.WriteLine(" Supported targets: claude-desktop, claude-code"); + return 1; + } + + private static int InstallClaudeDesktop() + { + var exePath = Environment.ProcessPath; + if (string.IsNullOrEmpty(exePath)) + { + Console.Error.WriteLine("Error: Could not determine the current executable path."); + return 1; + } + + var configPath = GetClaudeDesktopConfigPath(); + if (configPath is null) + { + Console.Error.WriteLine("Error: Could not determine Claude Desktop config location for this platform."); + return 1; + } + + var configDir = Path.GetDirectoryName(configPath)!; + if (!Directory.Exists(configDir)) + { + Directory.CreateDirectory(configDir); + } + + JsonNode root; + if (File.Exists(configPath)) + { + var json = File.ReadAllText(configPath); + root = JsonNode.Parse(json) ?? new JsonObject(); + } + else + { + root = new JsonObject(); + } + + var servers = root["mcpServers"]?.AsObject(); + if (servers is null) + { + servers = new JsonObject(); + root["mcpServers"] = servers; + } + + servers["tc-mcp"] = new JsonObject + { + ["command"] = exePath, + ["args"] = new JsonArray(), + }; + + var options = new JsonSerializerOptions { WriteIndented = true }; + var output = root.ToJsonString(options); + + // Atomic write: temp file + rename + var tempPath = configPath + ".tmp"; + File.WriteAllText(tempPath, output); + File.Move(tempPath, configPath, overwrite: true); + + Console.WriteLine($"Registered tc-mcp in {configPath}"); + Console.WriteLine("Please restart Claude Desktop to pick up the changes."); + return 0; + } + + private static string? GetClaudeDesktopConfigPath() + { + if (OperatingSystem.IsWindows()) + { + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + return Path.Combine(appData, "Claude", "claude_desktop_config.json"); + } + + if (OperatingSystem.IsMacOS()) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(home, "Library", "Application Support", "Claude", "claude_desktop_config.json"); + } + + // Linux — no standard Claude Desktop config path + return null; + } + + private static int InstallClaudeCode() + { + var exePath = Environment.ProcessPath; + if (string.IsNullOrEmpty(exePath)) + { + Console.Error.WriteLine("Error: Could not determine the current executable path."); + return 1; + } + + try + { + var psi = new ProcessStartInfo + { + FileName = "claude", + ArgumentList = { "mcp", "add", "--transport", "stdio", "tc-mcp", "--", exePath }, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + using var process = Process.Start(psi); + if (process is null) + { + Console.Error.WriteLine("Error: Failed to start 'claude' CLI. Is Claude Code installed?"); + return 1; + } + + process.WaitForExit(); + + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + + if (!string.IsNullOrWhiteSpace(stdout)) + { + Console.WriteLine(stdout); + } + + if (!string.IsNullOrWhiteSpace(stderr)) + { + Console.Error.WriteLine(stderr); + } + + if (process.ExitCode == 0) + { + Console.WriteLine("Successfully registered tc-mcp with Claude Code."); + } + else + { + Console.Error.WriteLine($"claude mcp add exited with code {process.ExitCode}."); + } + + return process.ExitCode; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + Console.Error.WriteLine("Make sure the 'claude' CLI is installed and available on your PATH."); + return 1; + } + } +} diff --git a/src/Yllibed.TenantCloudClient.Mcp/Program.cs b/src/Yllibed.TenantCloudClient.Mcp/Program.cs new file mode 100644 index 0000000..838d098 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Mcp/Program.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; +using Yllibed.TenantCloudClient; +using Yllibed.TenantCloudClient.Cdp; +using Yllibed.TenantCloudClient.Mcp; +using Yllibed.TenantCloudClient.Mcp.Tools; + +if (args.Length > 0 && string.Equals(args[0], "install", StringComparison.OrdinalIgnoreCase)) +{ + return InstallCommand.Run(args.AsSpan(1)); +} + +var builder = Host.CreateApplicationBuilder(args); + +builder.Logging.AddConsole(options => +{ + // stdout is reserved for MCP JSON-RPC; route all logs to stderr + options.LogToStandardErrorThreshold = LogLevel.Trace; +}); + +builder.Services.AddSecureTokenStore(); + +// Register CdpTokenProvider directly to set init-only properties via object initializer +builder.Services.AddSingleton(sp => + new CdpTokenProvider(new CdpTokenProviderOptions + { + TokenStore = sp.GetService(), + AllowInteractiveLogin = true, + })); + +builder.Services.AddTenantCloudClient(); + +builder.Services + .AddMcpServer(o => + { + o.ServerInfo = new() + { + Name = "tc-mcp", + Version = typeof(Program).Assembly.GetName().Version?.ToString() ?? "dev", + }; + }) + .WithStdioServerTransport() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools(); + +await builder.Build().RunAsync().ConfigureAwait(false); + +return 0; diff --git a/src/Yllibed.TenantCloudClient.Mcp/Yllibed.TenantCloudClient.Mcp.csproj b/src/Yllibed.TenantCloudClient.Mcp/Yllibed.TenantCloudClient.Mcp.csproj new file mode 100644 index 0000000..2c13c7a --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Mcp/Yllibed.TenantCloudClient.Mcp.csproj @@ -0,0 +1,29 @@ + + + + Exe + net10.0 + tc-mcp + false + + CS1591 + + + + true + true + true + true + + + + + + + + + + + + + From 2a20441d11c048404a41262663d3face64946948 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 21:19:21 -0500 Subject: [PATCH 02/18] Add MCP JSON-RPC tools for Transaction, User, Lease, Contact, and Unit management --- .../Tools/ContactTools.cs | 41 ++++++++ .../Tools/LeaseTools.cs | 48 ++++++++++ .../Tools/TransactionTools.cs | 94 +++++++++++++++++++ .../Tools/UnitTools.cs | 45 +++++++++ .../Tools/UserTools.cs | 29 ++++++ 5 files changed, 257 insertions(+) create mode 100644 src/Yllibed.TenantCloudClient.Mcp/Tools/ContactTools.cs create mode 100644 src/Yllibed.TenantCloudClient.Mcp/Tools/LeaseTools.cs create mode 100644 src/Yllibed.TenantCloudClient.Mcp/Tools/TransactionTools.cs create mode 100644 src/Yllibed.TenantCloudClient.Mcp/Tools/UnitTools.cs create mode 100644 src/Yllibed.TenantCloudClient.Mcp/Tools/UserTools.cs diff --git a/src/Yllibed.TenantCloudClient.Mcp/Tools/ContactTools.cs b/src/Yllibed.TenantCloudClient.Mcp/Tools/ContactTools.cs new file mode 100644 index 0000000..facbc1c --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Mcp/Tools/ContactTools.cs @@ -0,0 +1,41 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using Yllibed.TenantCloudClient.HttpMessages; + +namespace Yllibed.TenantCloudClient.Mcp.Tools; + +[McpServerToolType] +internal sealed class ContactTools +{ + [McpServerTool(Name = "list_contacts"), Description("List contacts (tenants, professionals) from TenantCloud. Use 'role' to filter by type.")] + public static async Task ListContacts( + ITcClient client, + [Description("Filter by role: tenant, professional, moved_in, archived")] string? role, + [Description("Maximum number of results to return (default 100)")] int? maxResults, + CancellationToken ct) + { + try + { + var source = client.Contacts; + + source = role?.ToLowerInvariant() switch + { + "tenant" => source.OnlyTenants(), + "professional" => source.OnlyProfessionals(), + "moved_in" => source.OnlyMovedIn(), + "archived" => source.OnlyArchived(), + null => source, + _ => source, + }; + + var data = await source.GetAll(ct, maxResults ?? 100).ConfigureAwait(false); + var result = new ListResult(data.AsEnumerable().ToArray()); + return JsonSerializer.Serialize(result, McpJsonContext.Default.ListResultTcContact); + } + catch (TcClientException ex) + { + return $"Error: {ex.Message} (HTTP {(int)ex.HttpStatus})"; + } + } +} diff --git a/src/Yllibed.TenantCloudClient.Mcp/Tools/LeaseTools.cs b/src/Yllibed.TenantCloudClient.Mcp/Tools/LeaseTools.cs new file mode 100644 index 0000000..4e472ce --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Mcp/Tools/LeaseTools.cs @@ -0,0 +1,48 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using Yllibed.TenantCloudClient.HttpMessages; + +namespace Yllibed.TenantCloudClient.Mcp.Tools; + +[McpServerToolType] +internal sealed class LeaseTools +{ + [McpServerTool(Name = "list_leases"), Description("List leases from TenantCloud. Can filter by property, unit, or status.")] + public static async Task ListLeases( + ITcClient client, + [Description("Filter by property ID")] long? propertyId, + [Description("Filter by unit ID")] long? unitId, + [Description("Filter by status: active")] string? status, + [Description("Maximum number of results to return (default 100)")] int? maxResults, + CancellationToken ct) + { + try + { + var source = client.Leases; + + if (propertyId.HasValue) + { + source = source.ForProperty(propertyId.Value); + } + + if (unitId.HasValue) + { + source = source.ForUnit(unitId.Value); + } + + if (string.Equals(status, "active", StringComparison.OrdinalIgnoreCase)) + { + source = source.OnlyActive(); + } + + var data = await source.GetAll(ct, maxResults ?? 100).ConfigureAwait(false); + var result = new ListResult(data.AsEnumerable().ToArray()); + return JsonSerializer.Serialize(result, McpJsonContext.Default.ListResultTcLease); + } + catch (TcClientException ex) + { + return $"Error: {ex.Message} (HTTP {(int)ex.HttpStatus})"; + } + } +} diff --git a/src/Yllibed.TenantCloudClient.Mcp/Tools/TransactionTools.cs b/src/Yllibed.TenantCloudClient.Mcp/Tools/TransactionTools.cs new file mode 100644 index 0000000..51a1694 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Mcp/Tools/TransactionTools.cs @@ -0,0 +1,94 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using Yllibed.TenantCloudClient.HttpMessages; + +namespace Yllibed.TenantCloudClient.Mcp.Tools; + +[McpServerToolType] +internal sealed class TransactionTools +{ + [McpServerTool(Name = "list_transactions"), Description("List financial transactions from TenantCloud. Can filter by tenant, property, unit, status, or category.")] + public static async Task ListTransactions( + ITcClient client, + [Description("Filter by tenant/contact ID")] long? tenantId, + [Description("Filter by property ID")] long? propertyId, + [Description("Filter by unit ID")] long? unitId, + [Description("Filter by status: due, paid, partial, pending, void, with_balance, overdue, waive")] string? status, + [Description("Filter by category: income, expense, refund, credits, liability")] string? category, + [Description("Maximum number of results to return (default 100)")] int? maxResults, + CancellationToken ct) + { + try + { + var source = client.Transactions; + + if (tenantId.HasValue) + { + source = source.ForTenant(tenantId.Value); + } + + if (propertyId.HasValue) + { + source = source.ForProperty(propertyId.Value); + } + + if (unitId.HasValue) + { + source = source.ForUnit(unitId.Value); + } + + if (status is not null && TryParseTransactionStatus(status, out var parsedStatus)) + { + source = source.ForStatus(parsedStatus); + } + + if (category is not null && TryParseTransactionCategory(category, out var parsedCategory)) + { + source = source.ForCategory(parsedCategory); + } + + var data = await source.GetAll(ct, maxResults ?? 100).ConfigureAwait(false); + var result = new ListResult(data.AsEnumerable().ToArray()); + return JsonSerializer.Serialize(result, McpJsonContext.Default.ListResultTcTransaction); + } + catch (TcClientException ex) + { + return $"Error: {ex.Message} (HTTP {(int)ex.HttpStatus})"; + } + } + + private static bool TryParseTransactionStatus(string value, out TcTransactionStatus result) + { + result = value.ToLowerInvariant() switch + { + "due" => TcTransactionStatus.Due, + "paid" => TcTransactionStatus.Paid, + "partial" => TcTransactionStatus.Partial, + "pending" => TcTransactionStatus.Pending, + "void" => TcTransactionStatus.Void, + "with_balance" => TcTransactionStatus.WithBalance, + "overdue" => TcTransactionStatus.Overdue, + "waive" => TcTransactionStatus.Waive, + _ => default, + }; + + return value.ToLowerInvariant() is "due" or "paid" or "partial" or "pending" or "void" + or "with_balance" or "overdue" or "waive"; + } + + private static bool TryParseTransactionCategory(string value, out TcTransactionCategory result) + { + result = value.ToLowerInvariant() switch + { + "income" => TcTransactionCategory.Income, + "expense" => TcTransactionCategory.Expense, + "refund" => TcTransactionCategory.Refund, + "credits" => TcTransactionCategory.Credits, + "liability" => TcTransactionCategory.Liability, + _ => default, + }; + + return value.ToLowerInvariant() is "income" or "expense" or "refund" or "credits" or "liability"; + } +} diff --git a/src/Yllibed.TenantCloudClient.Mcp/Tools/UnitTools.cs b/src/Yllibed.TenantCloudClient.Mcp/Tools/UnitTools.cs new file mode 100644 index 0000000..27fa26a --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Mcp/Tools/UnitTools.cs @@ -0,0 +1,45 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using Yllibed.TenantCloudClient.HttpMessages; + +namespace Yllibed.TenantCloudClient.Mcp.Tools; + +[McpServerToolType] +internal sealed class UnitTools +{ + [McpServerTool(Name = "list_units"), Description("List rental units from TenantCloud. Can filter by property or occupancy status.")] + public static async Task ListUnits( + ITcClient client, + [Description("Filter by property ID")] long? propertyId, + [Description("Filter by occupancy: occupied, vacant")] string? occupancy, + [Description("Maximum number of results to return (default 100)")] int? maxResults, + CancellationToken ct) + { + try + { + var source = client.Units; + + if (propertyId.HasValue) + { + source = source.ForProperty(propertyId.Value); + } + + source = occupancy?.ToLowerInvariant() switch + { + "occupied" => source.OnlyOccuped(), + "vacant" => source.OnlyVacant(), + null => source, + _ => source, + }; + + var data = await source.GetAll(ct, maxResults ?? 100).ConfigureAwait(false); + var result = new ListResult(data.AsEnumerable().ToArray()); + return JsonSerializer.Serialize(result, McpJsonContext.Default.ListResultTcUnit); + } + catch (TcClientException ex) + { + return $"Error: {ex.Message} (HTTP {(int)ex.HttpStatus})"; + } + } +} diff --git a/src/Yllibed.TenantCloudClient.Mcp/Tools/UserTools.cs b/src/Yllibed.TenantCloudClient.Mcp/Tools/UserTools.cs new file mode 100644 index 0000000..12b170c --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Mcp/Tools/UserTools.cs @@ -0,0 +1,29 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; + +namespace Yllibed.TenantCloudClient.Mcp.Tools; + +[McpServerToolType] +internal sealed class UserTools +{ + [McpServerTool(Name = "get_user_info"), Description("Get information about the currently signed-in TenantCloud user.")] + public static async Task GetUserInfo(ITcClient client, CancellationToken ct) + { + try + { + var user = await client.GetUserInfo(ct).ConfigureAwait(false); + + if (user is null) + { + return "No user info available. You may not be authenticated."; + } + + return JsonSerializer.Serialize(user, McpJsonContext.Default.TcUserInfo); + } + catch (TcClientException ex) + { + return $"Error: {ex.Message} (HTTP {(int)ex.HttpStatus})"; + } + } +} From a2d4150794b624cf8750c0462cfd247a31dd57d9 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 21:19:48 -0500 Subject: [PATCH 03/18] Add extensive documentation, publish pipeline, and MCP enhancements - Added detailed documentation: `client-library.md`, `nuget-cdp.md`, `nuget-client.md`, `authentication.md`, and `mcp-server.md`. - Implemented `publish-binaries` and `release` steps in CI pipeline for binary artifacts and GitHub Releases. - Updated NuGet packaging metadata to include new docs and rename README files. - Enhanced `Yllibed.TenantCloudClient.Mcp` with MCP tools, custom JSON serialization, and `ListResult` wrapper class for paginated results. - Added `SchemaResource` for MCP guide resource and expanded tool support in `McpJsonContext`. --- .github/workflows/ci.yml | 99 ++++++++- README.md | 204 +++--------------- docs/authentication.md | 100 +++++++++ docs/client-library.md | 112 ++++++++++ docs/mcp-server.md | 97 +++++++++ docs/nuget-cdp.md | 34 +++ docs/nuget-client.md | 58 +++++ .../Yllibed.TenantCloudClient.Cdp.csproj | 2 +- .../ListResult.cs | 9 + .../McpJsonContext.cs | 17 ++ .../SchemaResource.cs | 150 +++++++++++++ .../Tools/PropertyTools.cs | 28 +++ .../Yllibed.TenantCloudClient.csproj | 2 +- 13 files changed, 728 insertions(+), 184 deletions(-) create mode 100644 docs/authentication.md create mode 100644 docs/client-library.md create mode 100644 docs/mcp-server.md create mode 100644 docs/nuget-cdp.md create mode 100644 docs/nuget-client.md create mode 100644 src/Yllibed.TenantCloudClient.Mcp/ListResult.cs create mode 100644 src/Yllibed.TenantCloudClient.Mcp/McpJsonContext.cs create mode 100644 src/Yllibed.TenantCloudClient.Mcp/SchemaResource.cs create mode 100644 src/Yllibed.TenantCloudClient.Mcp/Tools/PropertyTools.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26256a4..adaeaa3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,13 +127,108 @@ jobs: --api-key ${{ secrets.NUGET_API_KEY }} --skip-duplicate + publish-binaries: + name: Publish Binaries + needs: build-test-pack + if: success() && github.event_name == 'push' + runs-on: ubuntu-latest + strategy: + matrix: + rid: [win-x64, osx-x64, osx-arm64, linux-x64] + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: '10.0.x' + dotnet-quality: ga + + - name: Publish for ${{ matrix.rid }} + run: >- + dotnet publish + src/Yllibed.TenantCloudClient.Mcp/Yllibed.TenantCloudClient.Mcp.csproj + -c Release + -r ${{ matrix.rid }} + -o publish/${{ matrix.rid }} + + - name: Upload binary + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: tc-mcp-${{ matrix.rid }} + path: publish/${{ matrix.rid }}/tc-mcp* + retention-days: 14 + + release: + name: Create Release + needs: [build-test-pack, publish-binaries] + if: success() && github.event_name == 'push' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/release/')) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: '10.0.x' + dotnet-quality: ga + + - name: Resolve version + id: version + shell: pwsh + run: | + dotnet tool install -g nbgv + $v = nbgv get-version -v NuGetPackageVersion + echo "version=$v" >> $env:GITHUB_OUTPUT + + - name: Download packages + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: packages + path: artifacts/packages + + - name: Download binaries + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: tc-mcp-* + path: artifacts/binaries + merge-multiple: false + + - name: Prepare release assets + shell: bash + run: | + mkdir -p release-assets + cp artifacts/packages/*.nupkg release-assets/ 2>/dev/null || true + for dir in artifacts/binaries/tc-mcp-*/; do + rid=$(basename "$dir" | sed 's/tc-mcp-//') + for f in "$dir"tc-mcp*; do + [ -f "$f" ] || continue + ext="${f##*.}" + base="${f%.*}" + if [ "$ext" = "pdb" ] || [ "$ext" = "xml" ]; then + continue + fi + if [ "$ext" = "$f" ] || [ "$ext" = "$(basename "$f")" ]; then + cp "$f" "release-assets/tc-mcp-${rid}" + else + cp "$f" "release-assets/tc-mcp-${rid}.${ext}" + fi + done + done + ls -la release-assets/ + - name: Create GitHub Release - if: success() && github.event_name == 'push' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/release/')) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: >- gh release create "v${{ steps.version.outputs.version }}" - "${{ runner.temp }}/packages/*.nupkg" + release-assets/* --title "v${{ steps.version.outputs.version }}" --generate-notes ${{ contains(steps.version.outputs.version, '-') && '--prerelease' || '' }} diff --git a/README.md b/README.md index 409b9df..6999001 100644 --- a/README.md +++ b/README.md @@ -1,207 +1,51 @@ # Yllibed.TenantCloudClient -Unofficial .NET client library for [TenantCloud](https://tenantcloud.com), a rental property management platform. +Unofficial .NET toolkit for [TenantCloud](https://tenantcloud.com), a rental property management platform. Includes a client library for programmatic access and an MCP server for AI agent integration. -[![Build Status](https://dev.azure.com/yllibed/TenantCloudClient/_apis/build/status/yllibed.TenantCloudClient?branchName=master)](https://dev.azure.com/yllibed/TenantCloudClient/_build/latest?definitionId=1&branchName=master) [![Nuget](https://img.shields.io/nuget/dt/Yllibed.TenantCloudClient.svg?label=nuget.org)](https://www.nuget.org/packages/Yllibed.TenantCloudClient) +[![CI](https://github.com/yllibed/TenantCloudClient/actions/workflows/ci.yml/badge.svg)](https://github.com/yllibed/TenantCloudClient/actions/workflows/ci.yml) [![NuGet](https://img.shields.io/nuget/dt/Yllibed.TenantCloudClient.svg?label=nuget.org)](https://www.nuget.org/packages/Yllibed.TenantCloudClient) > **This is not an official TenantCloud product.** TenantCloud does not provide a public API; this library works against their internal endpoints. -## Packages +## Packages & Binaries -| Package | Description | -|---------|-------------| -| [`Yllibed.TenantCloudClient`](https://www.nuget.org/packages/Yllibed.TenantCloudClient/) | Core library: API client, token store abstractions, and OS-native secure storage | -| [`Yllibed.TenantCloudClient.Cdp`](https://www.nuget.org/packages/Yllibed.TenantCloudClient.Cdp/) | Chrome DevTools Protocol token provider (extracts tokens from a running browser) | +| Component | Type | Description | +|-----------|------|-------------| +| [`Yllibed.TenantCloudClient`](https://www.nuget.org/packages/Yllibed.TenantCloudClient/) | NuGet | Core library: API client, token store abstractions, OS-native secure storage | +| [`Yllibed.TenantCloudClient.Cdp`](https://www.nuget.org/packages/Yllibed.TenantCloudClient.Cdp/) | NuGet | Chrome DevTools Protocol token provider (extracts tokens from a running browser) | +| `tc-mcp` | Binary | MCP server for AI agents (Claude Desktop, Claude Code, Cursor, etc.) | -Both packages target **net8.0** and **net10.0** with no external runtime dependencies beyond `System.Text.Json` and `Microsoft.Extensions.DependencyInjection.Abstractions`. +## Documentation + +- **[Client Library](docs/client-library.md)** — Quick start, DI setup, API reference, filters, paginated sources +- **[MCP Server](docs/mcp-server.md)** — Installation, auto-configuration for AI agents, available tools +- **[Authentication](docs/authentication.md)** — CDP flow, SecureTokenStore, FileTokenStore, custom providers ## Quick start -### With dependency injection +### Client library (NuGet) ```csharp services .AddSecureTokenStore() // ITcTokenStore → OS credential store .AddCdpTokenProvider() // ITcAuthTokenProvider → browser extraction + auto-refresh .AddTenantCloudClient(); // ITcClient → TcClient -``` - -Then inject `ITcClient` wherever you need it: - -```csharp -public class MyService(ITcClient tc) -{ - public async Task WhoAmI(CancellationToken ct) - => await tc.GetUserInfo(ct); -} -``` - -### Without dependency injection - -```csharp -var tokenStore = new SecureTokenStore(); -var tokenProvider = new CdpTokenProvider(new CdpTokenProviderOptions -{ - TokenStore = tokenStore, - AllowInteractiveLogin = true, -}); - -using var client = new TcClient(tokenProvider); -var user = await client.GetUserInfo(ct); -``` - -## Authentication - -`TcClient` requires an `ITcAuthTokenProvider` to supply Bearer tokens. The library does not store or manage credentials directly. - -```csharp -public interface ITcAuthTokenProvider -{ - Task GetToken(CancellationToken ct); - Task OnTokenRejected(CancellationToken ct, string rejectedToken); -} -``` - -### Built-in: `CdpTokenProvider` - -Provided by the **Yllibed.TenantCloudClient.Cdp** package. Extracts auth tokens from a running Chromium browser via the Chrome DevTools Protocol, with automatic JWT refresh. - -```csharp -services.AddCdpTokenProvider(options => -{ - options.DebugPort = 9222; // CDP debug port (default) - options.AllowInteractiveLogin = true; // launch a browser if no session found -}); -``` - -The provider follows a multi-step strategy: -1. In-memory cache (if the JWT is still valid) -2. Token store (load + refresh if expired) -3. CDP extraction from an existing browser tab on `app.tenantcloud.com` -4. Interactive login (if `AllowInteractiveLogin` is enabled) — launches a browser window and waits for the user to sign in - -### Custom provider - -Implement `ITcAuthTokenProvider` and register it before calling `AddTenantCloudClient()`: - -```csharp -services.AddSingleton(); -services.AddTenantCloudClient(); -``` - -## Token persistence - -`ITcTokenStore` allows tokens to survive across process restarts. Both the core library and the CDP provider can use it. -```csharp -public interface ITcTokenStore -{ - Task LoadAsync(CancellationToken ct); - Task SaveAsync(TcTokenSet tokens, CancellationToken ct); -} -``` - -### Built-in stores - -| Store | Package | Description | -|-------|---------|-------------| -| `SecureTokenStore` | Core | OS-native credential storage: **DPAPI** (Windows), **Keychain** (macOS), **Secret Service** (Linux) | -| `FileTokenStore` | Cdp | Plain JSON file with atomic writes (useful for headless/CI scenarios) | - -```csharp -// OS-native secure storage (recommended) -services.AddSecureTokenStore(); - -// With custom options -services.AddSecureTokenStore(options => -{ - options.ServiceName = "MyApp"; - options.AccountKey = "production"; -}); - -// Or file-based (from the Cdp package, register manually) -services.AddSingleton(new FileTokenStore("/path/to/tokens.json")); -``` - -### Custom store - -Implement `ITcTokenStore` to persist tokens wherever you need (database, Azure Key Vault, etc.): - -```csharp -services.AddSingleton(); +// Then inject ITcClient wherever you need it +var user = await tc.GetUserInfo(ct); +var contacts = await tc.Contacts.OnlyTenants().GetAll(ct); ``` -## API reference - -### `ITcClient` - -| Member | Type | Description | -|--------|------|-------------| -| `GetUserInfo(ct)` | `Task` | Current signed-in user info | -| `Contacts` | `IPaginatedSource` | Contacts (tenants, professionals) | -| `Properties` | `IPaginatedSource` | Properties | -| `Units` | `IPaginatedSource` | Rental units | -| `Transactions` | `IPaginatedSource` | Financial transactions | -| `Leases` | `IPaginatedSource` | Leases | - -### Paginated sources +### MCP server (binary) -Each collection is an `IPaginatedSource`. Call `.GetAll(ct)` to fetch all pages, or `.GetAll(ct, maxResults: n)` to cap the fetch: +Download `tc-mcp` from [GitHub Releases](https://github.com/yllibed/TenantCloudClient/releases), then: -```csharp -var contacts = await client.Contacts.OnlyMovedIn().GetAll(ct); +```bash +# Auto-configure for Claude Desktop or Claude Code +tc-mcp install claude-desktop +tc-mcp install claude-code ``` -The result is a `ReadOnlySequence` (one segment per API page). Use `.AsEnumerable()` to bridge to LINQ: - -```csharp -var names = (await client.Contacts.GetAll(ct)) - .AsEnumerable() - .Select(c => c.Name) - .ToArray(); -``` - -### Filters - -Filters are chainable extension methods that narrow the API query before fetching. - -**Contacts** -- `.OnlyTenants()` — tenant contacts only -- `.OnlyMovedIn()` — tenants with active leases -- `.OnlyProfessionals()` — professional contacts only -- `.OnlyArchived()` — archived contacts - -**Leases** -- `.OnlyActive()` — active leases -- `.ForProperty(propertyId)` — filter by property -- `.ForUnit(unitId)` — filter by unit - -**Units** -- `.OnlyOccuped()` — units with active leases -- `.OnlyVacant()` — vacant units -- `.ForProperty(propertyId)` — filter by property - -**Transactions** -- `.ForTenant(tenantId)` — filter by tenant -- `.ForProperty(propertyId)` — filter by property -- `.ForUnit(unitId)` — filter by unit -- `.ForStatus(TcTransactionStatus)` — filter by status (`Due`, `Paid`, `Partial`, `Pending`, `Void`, `WithBalance`, `Overdue`, `Waive`) -- `.ForCategory(TcTransactionCategory)` — filter by category (`Income`, `Expense`, `Refund`, `Credits`, `Liability`) -- `.SortByDateDescending()` — reverse chronological order - -### Example: overdue income per property - -```csharp -var balancePerProperty = (await client.Transactions - .ForCategory(TcTransactionCategory.Income) - .ForStatus(TcTransactionStatus.WithBalance) - .GetAll(ct)) - .AsEnumerable() - .Where(t => t.PropertyId != null) - .GroupBy(t => (long)t.PropertyId!, t => t.Balance) - .Select(g => new { PropertyId = g.Key, Balance = g.Sum() }) - .ToArray(); -``` +Then ask your AI agent: *"List my TenantCloud properties"* or *"Who are my tenants?"* ## License diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..edf9d98 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,100 @@ +# Authentication + +TenantCloud does not offer a public OAuth or API-key flow. This library authenticates by extracting JWT tokens from a running browser session via the Chrome DevTools Protocol (CDP). + +## How the CDP flow works + +1. **Browser extraction** — connects to a Chromium browser's CDP debug port, looks for a tab on `app.tenantcloud.com`, and reads `access_token` + `fingerprint` from `localStorage` and `tc_refresh_token` from cookies +2. **Token refresh** — when the JWT expires, the library calls TenantCloud's refresh endpoint with the refresh token and fingerprint +3. **Persist & loop** — refreshed tokens are saved to the configured `ITcTokenStore` for next launch + +### Interactive login + +If `AllowInteractiveLogin` is enabled and no existing session is found, the provider launches a temporary Chromium instance pointing to the TenantCloud login page. Once you sign in, tokens are extracted automatically. + +## Token persistence — `ITcTokenStore` + +Tokens can survive across process restarts via `ITcTokenStore`: + +```csharp +public interface ITcTokenStore +{ + Task LoadAsync(CancellationToken ct); + Task SaveAsync(TcTokenSet tokens, CancellationToken ct); +} +``` + +### `SecureTokenStore` (recommended) + +Uses the OS-native credential store: + +| OS | Backend | +|----|---------| +| Windows | DPAPI (`CredWrite` / `CredRead`) | +| macOS | Keychain (`security` CLI) | +| Linux | Secret Service D-Bus API | + +```csharp +services.AddSecureTokenStore(); + +// With custom options +services.AddSecureTokenStore(options => +{ + options.ServiceName = "MyApp"; + options.AccountKey = "production"; +}); +``` + +### `FileTokenStore` + +Plain JSON file with atomic writes. Useful for headless/CI scenarios where no credential store is available: + +```csharp +services.AddSingleton(new FileTokenStore("/path/to/tokens.json")); +``` + +> **Security note**: the file contains sensitive refresh tokens. Protect it with appropriate file permissions. + +### Custom store + +Implement `ITcTokenStore` to persist tokens wherever you need (database, Azure Key Vault, etc.): + +```csharp +services.AddSingleton(); +``` + +## Token provider — `ITcAuthTokenProvider` + +The token provider is responsible for supplying Bearer tokens to `TcClient`: + +```csharp +public interface ITcAuthTokenProvider +{ + Task GetToken(CancellationToken ct); + Task OnTokenRejected(CancellationToken ct, string rejectedToken); +} +``` + +### Built-in: `CdpTokenProvider` + +Provided by the `Yllibed.TenantCloudClient.Cdp` package. Multi-step strategy: + +1. In-memory cache (if the JWT is still valid) +2. Token store (load + refresh if expired) +3. CDP extraction from an existing browser tab +4. Interactive login (if `AllowInteractiveLogin` is enabled) + +```csharp +services.AddCdpTokenProvider(options => +{ + options.DebugPort = 9222; + options.AllowInteractiveLogin = true; +}); +``` + +### Custom provider + +```csharp +services.AddSingleton(); +services.AddTenantCloudClient(); +``` diff --git a/docs/client-library.md b/docs/client-library.md new file mode 100644 index 0000000..be97980 --- /dev/null +++ b/docs/client-library.md @@ -0,0 +1,112 @@ +# Client Library + +The `Yllibed.TenantCloudClient` and `Yllibed.TenantCloudClient.Cdp` NuGet packages provide programmatic access to TenantCloud data. + +Both packages target **net8.0** and **net10.0** with no external runtime dependencies beyond `System.Text.Json` and `Microsoft.Extensions.DependencyInjection.Abstractions`. + +## Quick start + +### With dependency injection + +```csharp +services + .AddSecureTokenStore() // ITcTokenStore → OS credential store + .AddCdpTokenProvider() // ITcAuthTokenProvider → browser extraction + auto-refresh + .AddTenantCloudClient(); // ITcClient → TcClient +``` + +Then inject `ITcClient` wherever you need it: + +```csharp +public class MyService(ITcClient tc) +{ + public async Task WhoAmI(CancellationToken ct) + => await tc.GetUserInfo(ct); +} +``` + +### Without dependency injection + +```csharp +var tokenStore = new SecureTokenStore(); +var tokenProvider = new CdpTokenProvider(new CdpTokenProviderOptions +{ + TokenStore = tokenStore, + AllowInteractiveLogin = true, +}); + +using var client = new TcClient(tokenProvider); +var user = await client.GetUserInfo(ct); +``` + +## API reference + +### `ITcClient` + +| Member | Type | Description | +|--------|------|-------------| +| `GetUserInfo(ct)` | `Task` | Current signed-in user info | +| `Contacts` | `IPaginatedSource` | Contacts (tenants, professionals) | +| `Properties` | `IPaginatedSource` | Properties | +| `Units` | `IPaginatedSource` | Rental units | +| `Transactions` | `IPaginatedSource` | Financial transactions | +| `Leases` | `IPaginatedSource` | Leases | + +### Paginated sources + +Each collection is an `IPaginatedSource`. Call `.GetAll(ct)` to fetch all pages, or `.GetAll(ct, maxResults: n)` to cap the fetch: + +```csharp +var contacts = await client.Contacts.OnlyMovedIn().GetAll(ct); +``` + +The result is a `ReadOnlySequence` (one segment per API page). Use `.AsEnumerable()` to bridge to LINQ: + +```csharp +var names = (await client.Contacts.GetAll(ct)) + .AsEnumerable() + .Select(c => c.Name) + .ToArray(); +``` + +### Filters + +Filters are chainable extension methods that narrow the API query before fetching. + +**Contacts** +- `.OnlyTenants()` — tenant contacts only +- `.OnlyMovedIn()` — tenants with active leases +- `.OnlyProfessionals()` — professional contacts only +- `.OnlyArchived()` — archived contacts + +**Leases** +- `.OnlyActive()` — active leases +- `.ForProperty(propertyId)` — filter by property +- `.ForUnit(unitId)` — filter by unit + +**Units** +- `.OnlyOccuped()` — units with active leases +- `.OnlyVacant()` — vacant units +- `.ForProperty(propertyId)` — filter by property + +**Transactions** +- `.ForTenant(tenantId)` — filter by tenant +- `.ForProperty(propertyId)` — filter by property +- `.ForUnit(unitId)` — filter by unit +- `.ForStatus(TcTransactionStatus)` — filter by status (`Due`, `Paid`, `Partial`, `Pending`, `Void`, `WithBalance`, `Overdue`, `Waive`) +- `.ForCategory(TcTransactionCategory)` — filter by category (`Income`, `Expense`, `Refund`, `Credits`, `Liability`) +- `.SortByDateDescending()` — reverse chronological order + +### Example: overdue income per property + +```csharp +var balancePerProperty = (await client.Transactions + .ForCategory(TcTransactionCategory.Income) + .ForStatus(TcTransactionStatus.WithBalance) + .GetAll(ct)) + .AsEnumerable() + .Where(t => t.PropertyId != null) + .GroupBy(t => (long)t.PropertyId!, t => t.Balance) + .Select(g => new { PropertyId = g.Key, Balance = g.Sum() }) + .ToArray(); +``` diff --git a/docs/mcp-server.md b/docs/mcp-server.md new file mode 100644 index 0000000..f0ab44f --- /dev/null +++ b/docs/mcp-server.md @@ -0,0 +1,97 @@ +# MCP Server (`tc-mcp`) + +`tc-mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server that exposes TenantCloud data to AI agents like Claude Desktop, Claude Code, and Cursor. + +## Installation + +Download the binary for your platform from [GitHub Releases](https://github.com/yllibed/TenantCloudClient/releases): + +| Platform | Binary | +|----------|--------| +| Windows x64 | `tc-mcp-win-x64.exe` | +| macOS x64 | `tc-mcp-osx-x64` | +| macOS ARM | `tc-mcp-osx-arm64` | +| Linux x64 | `tc-mcp-linux-x64` | + +The binary is self-contained (no .NET runtime required). + +## Auto-configuration + +```bash +# For Claude Desktop +tc-mcp install claude-desktop + +# For Claude Code +tc-mcp install claude-code +``` + +### What `install` does + +**Claude Desktop** — patches the config JSON at: +- Windows: `%APPDATA%\Claude\claude_desktop_config.json` +- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` + +**Claude Code** — runs `claude mcp add --transport stdio tc-mcp -- ` + +## Manual configuration + +### Claude Desktop + +Add to your `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "tc-mcp": { + "command": "/path/to/tc-mcp", + "args": [] + } + } +} +``` + +### Claude Code + +```bash +claude mcp add --transport stdio tc-mcp -- /path/to/tc-mcp +``` + +### Cursor / other MCP clients + +Use stdio transport with the `tc-mcp` binary path as the command. + +## Available tools + +| Tool | Description | Filters | +|------|-------------|---------| +| `get_user_info` | Current signed-in user profile | — | +| `list_contacts` | Tenants, professionals, etc. | `role`, `maxResults` | +| `list_properties` | Rental properties | `maxResults` | +| `list_units` | Rental units | `propertyId`, `occupancy`, `maxResults` | +| `list_transactions` | Financial transactions | `tenantId`, `propertyId`, `unitId`, `status`, `category`, `maxResults` | +| `list_leases` | Lease agreements | `propertyId`, `unitId`, `status`, `maxResults` | + +## Available resources + +| URI | Description | +|-----|-------------| +| `tc://guide` | Tool usage guide — entities, fields, filters, and how to resolve names to IDs | + +## Authentication + +On first use, `tc-mcp` will attempt to authenticate via: + +1. **Stored token** — loaded from the OS secure credential store (DPAPI / Keychain / Secret Service) +2. **Running browser** — extracts tokens from a Chromium browser tab open on `app.tenantcloud.com` +3. **Interactive login** — launches a browser window for you to sign in + +Tokens are cached and refreshed automatically. See [Authentication](authentication.md) for details. + +## Example questions to ask your AI agent + +- "Who are my tenants?" +- "What properties do I have?" +- "Which units are vacant?" +- "Show me overdue transactions" +- "List active leases for property 12345" +- "What is the total rent balance?" diff --git a/docs/nuget-cdp.md b/docs/nuget-cdp.md new file mode 100644 index 0000000..59ac6a0 --- /dev/null +++ b/docs/nuget-cdp.md @@ -0,0 +1,34 @@ +# Yllibed.TenantCloudClient.Cdp + +Chrome DevTools Protocol (CDP) based token provider for [Yllibed.TenantCloudClient](https://www.nuget.org/packages/Yllibed.TenantCloudClient/). Extracts auth tokens from an existing browser session with automatic JWT refresh. + +## Quick start + +```csharp +services + .AddSecureTokenStore() // Persist tokens in OS credential store + .AddCdpTokenProvider() // Extract tokens from browser via CDP + .AddTenantCloudClient(); // Register ITcClient +``` + +## Options + +```csharp +services.AddCdpTokenProvider(options => +{ + options.DebugPort = 9222; // CDP debug port (default) + options.AllowInteractiveLogin = true; // Launch browser if no session found + options.BrowserExecutablePath = null; // Auto-detect Chromium browser +}); +``` + +## How it works + +1. **In-memory cache** — returns the cached JWT if still valid +2. **Token store** — loads persisted tokens and refreshes if expired +3. **CDP extraction** — connects to a running Chromium browser and extracts tokens from a TenantCloud tab +4. **Interactive login** — launches a temporary browser window for sign-in (if enabled) + +No external NuGet dependencies beyond the base client library. + +For full documentation, see the [GitHub repository](https://github.com/yllibed/TenantCloudClient). diff --git a/docs/nuget-client.md b/docs/nuget-client.md new file mode 100644 index 0000000..2e449fa --- /dev/null +++ b/docs/nuget-client.md @@ -0,0 +1,58 @@ +# Yllibed.TenantCloudClient + +Unofficial .NET client library for [TenantCloud](https://tenantcloud.com), a rental property management platform. + +> **This is not an official TenantCloud product.** TenantCloud does not provide a public API; this library works against their internal endpoints. + +## Quick start + +### With dependency injection + +```csharp +services + .AddSecureTokenStore() // ITcTokenStore → OS credential store + .AddCdpTokenProvider() // ITcAuthTokenProvider (from Yllibed.TenantCloudClient.Cdp) + .AddTenantCloudClient(); // ITcClient → TcClient +``` + +Then inject `ITcClient`: + +```csharp +public class MyService(ITcClient tc) +{ + public async Task WhoAmI(CancellationToken ct) + => await tc.GetUserInfo(ct); +} +``` + +### Without dependency injection + +```csharp +var tokenProvider = new CdpTokenProvider(new CdpTokenProviderOptions +{ + TokenStore = new SecureTokenStore(), + AllowInteractiveLogin = true, +}); + +using var client = new TcClient(tokenProvider); +var user = await client.GetUserInfo(ct); +``` + +## API + +| Member | Type | Description | +|--------|------|-------------| +| `GetUserInfo(ct)` | `Task` | Current signed-in user | +| `Contacts` | `IPaginatedSource` | Contacts (tenants, professionals) | +| `Properties` | `IPaginatedSource` | Properties | +| `Units` | `IPaginatedSource` | Rental units | +| `Transactions` | `IPaginatedSource` | Financial transactions | +| `Leases` | `IPaginatedSource` | Leases | + +Filters: `.OnlyTenants()`, `.OnlyActive()`, `.ForProperty(id)`, `.ForStatus(...)`, and more. + +```csharp +var tenants = await client.Contacts.OnlyTenants().GetAll(ct); +``` + +For full documentation, see the [GitHub repository](https://github.com/yllibed/TenantCloudClient). diff --git a/src/Yllibed.TenantCloudClient.Cdp/Yllibed.TenantCloudClient.Cdp.csproj b/src/Yllibed.TenantCloudClient.Cdp/Yllibed.TenantCloudClient.Cdp.csproj index 4e9f8ee..a1d63be 100644 --- a/src/Yllibed.TenantCloudClient.Cdp/Yllibed.TenantCloudClient.Cdp.csproj +++ b/src/Yllibed.TenantCloudClient.Cdp/Yllibed.TenantCloudClient.Cdp.csproj @@ -27,7 +27,7 @@ - + diff --git a/src/Yllibed.TenantCloudClient.Mcp/ListResult.cs b/src/Yllibed.TenantCloudClient.Mcp/ListResult.cs new file mode 100644 index 0000000..e588415 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Mcp/ListResult.cs @@ -0,0 +1,9 @@ +namespace Yllibed.TenantCloudClient.Mcp; + +/// +/// Wrapper for list results returned by MCP tools. +/// +public sealed record ListResult(T[] Data) +{ + public int Count => Data.Length; +} diff --git a/src/Yllibed.TenantCloudClient.Mcp/McpJsonContext.cs b/src/Yllibed.TenantCloudClient.Mcp/McpJsonContext.cs new file mode 100644 index 0000000..531d295 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Mcp/McpJsonContext.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; +using Yllibed.TenantCloudClient.HttpMessages; + +namespace Yllibed.TenantCloudClient.Mcp; + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(TcUserInfo))] +[JsonSerializable(typeof(ListResult))] +[JsonSerializable(typeof(ListResult))] +[JsonSerializable(typeof(ListResult))] +[JsonSerializable(typeof(ListResult))] +[JsonSerializable(typeof(ListResult))] +internal partial class McpJsonContext : JsonSerializerContext +{ +} diff --git a/src/Yllibed.TenantCloudClient.Mcp/SchemaResource.cs b/src/Yllibed.TenantCloudClient.Mcp/SchemaResource.cs new file mode 100644 index 0000000..5d83e70 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Mcp/SchemaResource.cs @@ -0,0 +1,150 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; + +namespace Yllibed.TenantCloudClient.Mcp; + +[McpServerResourceType] +internal sealed class SchemaResource +{ + [McpServerResource( + UriTemplate = "tc://guide", + Name = "guide", + Title = "TenantCloud Tool Usage Guide", + MimeType = "text/markdown")] + [Description("Guide for using TenantCloud tools: entities, fields, filters, and how to resolve names to IDs.")] + public static string GetSchema() => Schema; + + private const string Schema = """ + # TenantCloud Tool Usage Guide + + ## Entities + + ### UserInfo + Current signed-in user profile. + - `id` (long) — User ID + - `email` (string) — Email address + - `firstName`, `lastName` (string) — Name + - `company` (string) — Company name + - `phone` (string) — Phone number + - `address1`, `address2`, `city`, `state`, `zip` (string) — Address + + ### Contact + A tenant, professional, or other contact. + - `id` (long) — Contact ID + - `name`, `firstName`, `lastName` (string) — Name + - `email1`, `email2`, `email3` (string) — Up to 3 email addresses + - `phone1`, `phone2`, `phone3` (string) — Up to 3 phone numbers + - `status` (TcTenantStatus) — Contact status + + ### Property + A rental property. + - `id` (long) — Property ID + - `name` (string) — Property name + - `address1` (string) — Street address + - `cityAddress` (string) — City and state + - `status` (string) — Property status + + ### Unit + A rental unit within a property. + - `id` (long) — Unit ID + - `propertyId` (long) — Parent property ID + - `name` (string) — Unit name + - `description` (string?) — Description + - `price` (decimal) — Rent price + - `isRented` (bool) — Currently occupied + - `isPetAllowed` (bool) — Pets allowed + - `isFurnished` (bool) — Furnished + - `isUtilities` (bool) — Utilities included + + ### Transaction + A financial transaction (rent, expense, etc.). + - `id` (long) — Transaction ID + - `unitId` (long?) — Associated unit + - `propertyId` (long?) — Associated property + - `detail` (string?) — Description + - `amount` (decimal) — Total amount + - `paid` (decimal) — Amount paid + - `balance` (decimal) — Remaining balance + - `currency` (string) — Currency code + - `dueDate` (DateTimeOffset) — Due date + - `paidAt` (DateTimeOffset?) — Payment date + - `category` (TcTransactionCategory) — Income, Expense, Refund, Credits, Liability + - `status` (TcTransactionStatus) — Due, Paid, Partial, Pending, Void, WithBalance, Overdue, Waive + - `isRecurring` (bool) — Recurring transaction + + ### Lease + A lease agreement. + - `id` (long) — Lease ID + - `name` (string?) — Lease name + - `unitId` (long) — Associated unit + - `startDate` (DateTime) — Start date + - `endDate` (DateTime?) — End date (null = month-to-month) + - `status` (TcLeaseStatus) — Active, Archived, Ended, Expired, Future, Pending, etc. + + ## Tool Filters + + ### list_contacts + - `role` (string?) — Filter by role: `tenant`, `professional`, `moved_in`, `archived` + - `maxResults` (int?) — Max results to return (default 100) + + ### list_properties + - `maxResults` (int?) — Max results to return (default 100) + + ### list_units + - `propertyId` (long?) — Filter by property ID + - `occupancy` (string?) — Filter by occupancy: `occupied`, `vacant` + - `maxResults` (int?) — Max results to return (default 100) + + ### list_transactions + - `tenantId` (long?) — Filter by tenant/contact ID + - `propertyId` (long?) — Filter by property ID + - `unitId` (long?) — Filter by unit ID + - `status` (string?) — Filter by status: `due`, `paid`, `partial`, `pending`, `void`, `with_balance`, `overdue`, `waive` + - `category` (string?) — Filter by category: `income`, `expense`, `refund`, `credits`, `liability` + - `maxResults` (int?) — Max results to return (default 100) + + ### list_leases + - `propertyId` (long?) — Filter by property ID + - `unitId` (long?) — Filter by unit ID + - `status` (string?) — Filter by status: `active` + - `maxResults` (int?) — Max results to return (default 100) + + ## ID Resolution — IMPORTANT + + Users refer to entities by **name**, not by ID. All filter parameters that accept an ID + (propertyId, unitId, tenantId) require a numeric ID. You must resolve names to IDs first. + + ### Resolution patterns + + **Property by name/address** → call `list_properties`, then match by `name` or `address1` fields. + **Unit by name** → call `list_units` (optionally filtered by propertyId), then match by `name`. + **Tenant by name** → call `list_contacts` with role=tenant, then match by `name`, `firstName`, or `lastName`. + + ### Multi-step example + + User asks: "List active leases for my property on Evergreen Terrace" + 1. Call `list_properties` → find the property where `address1` or `name` contains "Evergreen Terrace" → get its `id` + 2. Call `list_leases` with `propertyId=` and `status=active` + + User asks: "Show transactions for John Smith" + 1. Call `list_contacts` with `role=tenant` → find contact where `name` contains "John Smith" → get its `id` + 2. Call `list_transactions` with `tenantId=` + + User asks: "Which tenants are in unit 3B at Maple Heights?" + 1. Call `list_properties` → find "Maple Heights" → get its `id` + 2. Call `list_units` with `propertyId=` → find "3B" → get its `id` + 3. Call `list_leases` with `unitId=` and `status=active` → get tenant info from lease names + + Always resolve in order: property → unit → tenant/lease/transaction. + When the match is ambiguous, present the candidates and ask the user to clarify. + + ## Typical Questions + - "Who are my tenants?" → list_contacts with role=tenant + - "What properties do I have?" → list_properties + - "Which units are vacant?" → list_units with occupancy=vacant + - "What is the rent balance for property X?" → resolve property name → list_transactions with propertyId + status=with_balance + - "Show me active leases for [address]" → resolve property → list_leases with propertyId + status=active + - "Show transactions for [tenant name]" → resolve contact → list_transactions with tenantId + - "Who am I logged in as?" → get_user_info + """; +} diff --git a/src/Yllibed.TenantCloudClient.Mcp/Tools/PropertyTools.cs b/src/Yllibed.TenantCloudClient.Mcp/Tools/PropertyTools.cs new file mode 100644 index 0000000..6ef238f --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Mcp/Tools/PropertyTools.cs @@ -0,0 +1,28 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using Yllibed.TenantCloudClient.HttpMessages; + +namespace Yllibed.TenantCloudClient.Mcp.Tools; + +[McpServerToolType] +internal sealed class PropertyTools +{ + [McpServerTool(Name = "list_properties"), Description("List rental properties from TenantCloud.")] + public static async Task ListProperties( + ITcClient client, + [Description("Maximum number of results to return (default 100)")] int? maxResults, + CancellationToken ct) + { + try + { + var data = await client.Properties.GetAll(ct, maxResults ?? 100).ConfigureAwait(false); + var result = new ListResult(data.AsEnumerable().ToArray()); + return JsonSerializer.Serialize(result, McpJsonContext.Default.ListResultTcProperty); + } + catch (TcClientException ex) + { + return $"Error: {ex.Message} (HTTP {(int)ex.HttpStatus})"; + } + } +} diff --git a/src/Yllibed.TenantCloudClient/Yllibed.TenantCloudClient.csproj b/src/Yllibed.TenantCloudClient/Yllibed.TenantCloudClient.csproj index 5b1a8fe..77932e3 100644 --- a/src/Yllibed.TenantCloudClient/Yllibed.TenantCloudClient.csproj +++ b/src/Yllibed.TenantCloudClient/Yllibed.TenantCloudClient.csproj @@ -35,7 +35,7 @@ - + From 500cd13d05efd8817ae33947f6b31ae3cc220997 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 21:24:21 -0500 Subject: [PATCH 04/18] Add win-arm64 to publish matrix and fix binary table in docs --- .github/workflows/ci.yml | 2 +- docs/mcp-server.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adaeaa3..3b1671d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,7 +134,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - rid: [win-x64, osx-x64, osx-arm64, linux-x64] + rid: [win-x64, win-arm64, osx-x64, osx-arm64, linux-x64] steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 diff --git a/docs/mcp-server.md b/docs/mcp-server.md index f0ab44f..e02e625 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -9,8 +9,9 @@ Download the binary for your platform from [GitHub Releases](https://github.com/ | Platform | Binary | |----------|--------| | Windows x64 | `tc-mcp-win-x64.exe` | +| Windows ARM64 | `tc-mcp-win-arm64.exe` | | macOS x64 | `tc-mcp-osx-x64` | -| macOS ARM | `tc-mcp-osx-arm64` | +| macOS ARM64 | `tc-mcp-osx-arm64` | | Linux x64 | `tc-mcp-linux-x64` | The binary is self-contained (no .NET runtime required). From 7852572701c7e4682efef0c05e95c0f7fb43ce86 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 21:28:23 -0500 Subject: [PATCH 05/18] Add portable and linux-arm64 builds to publish matrix --- .github/workflows/ci.yml | 15 ++++++++++++++- docs/mcp-server.md | 4 +++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b1671d..46df855 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,7 +134,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - rid: [win-x64, win-arm64, osx-x64, osx-arm64, linux-x64] + rid: [any, win-x64, win-arm64, osx-x64, osx-arm64, linux-x64, linux-arm64] steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -148,6 +148,7 @@ jobs: dotnet-quality: ga - name: Publish for ${{ matrix.rid }} + if: matrix.rid != 'any' run: >- dotnet publish src/Yllibed.TenantCloudClient.Mcp/Yllibed.TenantCloudClient.Mcp.csproj @@ -155,6 +156,18 @@ jobs: -r ${{ matrix.rid }} -o publish/${{ matrix.rid }} + - name: Publish portable + if: matrix.rid == 'any' + run: >- + dotnet publish + src/Yllibed.TenantCloudClient.Mcp/Yllibed.TenantCloudClient.Mcp.csproj + -c Release + -p:SelfContained=false + -p:PublishSingleFile=false + -p:PublishTrimmed=false + -p:PublishReadyToRun=false + -o publish/any + - name: Upload binary uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: diff --git a/docs/mcp-server.md b/docs/mcp-server.md index e02e625..81f555f 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -13,8 +13,10 @@ Download the binary for your platform from [GitHub Releases](https://github.com/ | macOS x64 | `tc-mcp-osx-x64` | | macOS ARM64 | `tc-mcp-osx-arm64` | | Linux x64 | `tc-mcp-linux-x64` | +| Linux ARM64 | `tc-mcp-linux-arm64` | +| Portable (.NET 10) | `tc-mcp-any` | -The binary is self-contained (no .NET runtime required). +Platform-specific binaries are self-contained (no .NET runtime required). The portable build requires the .NET 10 runtime installed on the machine. ## Auto-configuration From c65868c46414b93e23791618e31873dac1186e99 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 21:50:34 -0500 Subject: [PATCH 06/18] Run publish-binaries on PRs with reduced matrix (any + win-x64) --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46df855..f5c9248 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,11 +130,10 @@ jobs: publish-binaries: name: Publish Binaries needs: build-test-pack - if: success() && github.event_name == 'push' runs-on: ubuntu-latest strategy: matrix: - rid: [any, win-x64, win-arm64, osx-x64, osx-arm64, linux-x64, linux-arm64] + rid: ${{ github.event_name == 'push' && fromJson('["any","win-x64","win-arm64","osx-x64","osx-arm64","linux-x64","linux-arm64"]') || fromJson('["any","win-x64"]') }} steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 From 54b0180295a39aa858728f5fca53a0250c4d02bb Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 22:30:41 -0500 Subject: [PATCH 07/18] Return structured CallToolResult with IsError from MCP tools --- src/Yllibed.TenantCloudClient.Mcp/ToolResults.cs | 12 ++++++++++++ .../Tools/ContactTools.cs | 9 +++++---- .../Tools/LeaseTools.cs | 7 ++++--- .../Tools/PropertyTools.cs | 7 ++++--- .../Tools/TransactionTools.cs | 7 ++++--- src/Yllibed.TenantCloudClient.Mcp/Tools/UnitTools.cs | 7 ++++--- src/Yllibed.TenantCloudClient.Mcp/Tools/UserTools.cs | 9 +++++---- 7 files changed, 38 insertions(+), 20 deletions(-) create mode 100644 src/Yllibed.TenantCloudClient.Mcp/ToolResults.cs diff --git a/src/Yllibed.TenantCloudClient.Mcp/ToolResults.cs b/src/Yllibed.TenantCloudClient.Mcp/ToolResults.cs new file mode 100644 index 0000000..d6e9ec0 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Mcp/ToolResults.cs @@ -0,0 +1,12 @@ +using ModelContextProtocol.Protocol; + +namespace Yllibed.TenantCloudClient.Mcp; + +internal static class ToolResults +{ + public static CallToolResult Success(string text) => + new() { Content = [new TextContentBlock { Text = text }] }; + + public static CallToolResult Error(string message) => + new() { Content = [new TextContentBlock { Text = message }], IsError = true }; +} diff --git a/src/Yllibed.TenantCloudClient.Mcp/Tools/ContactTools.cs b/src/Yllibed.TenantCloudClient.Mcp/Tools/ContactTools.cs index facbc1c..09a19bf 100644 --- a/src/Yllibed.TenantCloudClient.Mcp/Tools/ContactTools.cs +++ b/src/Yllibed.TenantCloudClient.Mcp/Tools/ContactTools.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Text.Json; +using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Yllibed.TenantCloudClient.HttpMessages; @@ -9,9 +10,9 @@ namespace Yllibed.TenantCloudClient.Mcp.Tools; internal sealed class ContactTools { [McpServerTool(Name = "list_contacts"), Description("List contacts (tenants, professionals) from TenantCloud. Use 'role' to filter by type.")] - public static async Task ListContacts( + public static async Task ListContacts( ITcClient client, - [Description("Filter by role: tenant, professional, moved_in, archived")] string? role, + [Description("Filter by role: tenant, professional, moved_in, archived")] string role, [Description("Maximum number of results to return (default 100)")] int? maxResults, CancellationToken ct) { @@ -31,11 +32,11 @@ public static async Task ListContacts( var data = await source.GetAll(ct, maxResults ?? 100).ConfigureAwait(false); var result = new ListResult(data.AsEnumerable().ToArray()); - return JsonSerializer.Serialize(result, McpJsonContext.Default.ListResultTcContact); + return ToolResults.Success(JsonSerializer.Serialize(result, McpJsonContext.Default.ListResultTcContact)); } catch (TcClientException ex) { - return $"Error: {ex.Message} (HTTP {(int)ex.HttpStatus})"; + return ToolResults.Error($"{ex.Message} (HTTP {(int)ex.HttpStatus})"); } } } diff --git a/src/Yllibed.TenantCloudClient.Mcp/Tools/LeaseTools.cs b/src/Yllibed.TenantCloudClient.Mcp/Tools/LeaseTools.cs index 4e472ce..ccad712 100644 --- a/src/Yllibed.TenantCloudClient.Mcp/Tools/LeaseTools.cs +++ b/src/Yllibed.TenantCloudClient.Mcp/Tools/LeaseTools.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Text.Json; +using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Yllibed.TenantCloudClient.HttpMessages; @@ -9,7 +10,7 @@ namespace Yllibed.TenantCloudClient.Mcp.Tools; internal sealed class LeaseTools { [McpServerTool(Name = "list_leases"), Description("List leases from TenantCloud. Can filter by property, unit, or status.")] - public static async Task ListLeases( + public static async Task ListLeases( ITcClient client, [Description("Filter by property ID")] long? propertyId, [Description("Filter by unit ID")] long? unitId, @@ -38,11 +39,11 @@ public static async Task ListLeases( var data = await source.GetAll(ct, maxResults ?? 100).ConfigureAwait(false); var result = new ListResult(data.AsEnumerable().ToArray()); - return JsonSerializer.Serialize(result, McpJsonContext.Default.ListResultTcLease); + return ToolResults.Success(JsonSerializer.Serialize(result, McpJsonContext.Default.ListResultTcLease)); } catch (TcClientException ex) { - return $"Error: {ex.Message} (HTTP {(int)ex.HttpStatus})"; + return ToolResults.Error($"{ex.Message} (HTTP {(int)ex.HttpStatus})"); } } } diff --git a/src/Yllibed.TenantCloudClient.Mcp/Tools/PropertyTools.cs b/src/Yllibed.TenantCloudClient.Mcp/Tools/PropertyTools.cs index 6ef238f..2810d9c 100644 --- a/src/Yllibed.TenantCloudClient.Mcp/Tools/PropertyTools.cs +++ b/src/Yllibed.TenantCloudClient.Mcp/Tools/PropertyTools.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Text.Json; +using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Yllibed.TenantCloudClient.HttpMessages; @@ -9,7 +10,7 @@ namespace Yllibed.TenantCloudClient.Mcp.Tools; internal sealed class PropertyTools { [McpServerTool(Name = "list_properties"), Description("List rental properties from TenantCloud.")] - public static async Task ListProperties( + public static async Task ListProperties( ITcClient client, [Description("Maximum number of results to return (default 100)")] int? maxResults, CancellationToken ct) @@ -18,11 +19,11 @@ public static async Task ListProperties( { var data = await client.Properties.GetAll(ct, maxResults ?? 100).ConfigureAwait(false); var result = new ListResult(data.AsEnumerable().ToArray()); - return JsonSerializer.Serialize(result, McpJsonContext.Default.ListResultTcProperty); + return ToolResults.Success(JsonSerializer.Serialize(result, McpJsonContext.Default.ListResultTcProperty)); } catch (TcClientException ex) { - return $"Error: {ex.Message} (HTTP {(int)ex.HttpStatus})"; + return ToolResults.Error($"{ex.Message} (HTTP {(int)ex.HttpStatus})"); } } } diff --git a/src/Yllibed.TenantCloudClient.Mcp/Tools/TransactionTools.cs b/src/Yllibed.TenantCloudClient.Mcp/Tools/TransactionTools.cs index 51a1694..10fec00 100644 --- a/src/Yllibed.TenantCloudClient.Mcp/Tools/TransactionTools.cs +++ b/src/Yllibed.TenantCloudClient.Mcp/Tools/TransactionTools.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Text.Json; +using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Yllibed.TenantCloudClient.HttpMessages; @@ -9,7 +10,7 @@ namespace Yllibed.TenantCloudClient.Mcp.Tools; internal sealed class TransactionTools { [McpServerTool(Name = "list_transactions"), Description("List financial transactions from TenantCloud. Can filter by tenant, property, unit, status, or category.")] - public static async Task ListTransactions( + public static async Task ListTransactions( ITcClient client, [Description("Filter by tenant/contact ID")] long? tenantId, [Description("Filter by property ID")] long? propertyId, @@ -50,11 +51,11 @@ public static async Task ListTransactions( var data = await source.GetAll(ct, maxResults ?? 100).ConfigureAwait(false); var result = new ListResult(data.AsEnumerable().ToArray()); - return JsonSerializer.Serialize(result, McpJsonContext.Default.ListResultTcTransaction); + return ToolResults.Success(JsonSerializer.Serialize(result, McpJsonContext.Default.ListResultTcTransaction)); } catch (TcClientException ex) { - return $"Error: {ex.Message} (HTTP {(int)ex.HttpStatus})"; + return ToolResults.Error($"{ex.Message} (HTTP {(int)ex.HttpStatus})"); } } diff --git a/src/Yllibed.TenantCloudClient.Mcp/Tools/UnitTools.cs b/src/Yllibed.TenantCloudClient.Mcp/Tools/UnitTools.cs index 27fa26a..3d7cd63 100644 --- a/src/Yllibed.TenantCloudClient.Mcp/Tools/UnitTools.cs +++ b/src/Yllibed.TenantCloudClient.Mcp/Tools/UnitTools.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Text.Json; +using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Yllibed.TenantCloudClient.HttpMessages; @@ -9,7 +10,7 @@ namespace Yllibed.TenantCloudClient.Mcp.Tools; internal sealed class UnitTools { [McpServerTool(Name = "list_units"), Description("List rental units from TenantCloud. Can filter by property or occupancy status.")] - public static async Task ListUnits( + public static async Task ListUnits( ITcClient client, [Description("Filter by property ID")] long? propertyId, [Description("Filter by occupancy: occupied, vacant")] string? occupancy, @@ -35,11 +36,11 @@ public static async Task ListUnits( var data = await source.GetAll(ct, maxResults ?? 100).ConfigureAwait(false); var result = new ListResult(data.AsEnumerable().ToArray()); - return JsonSerializer.Serialize(result, McpJsonContext.Default.ListResultTcUnit); + return ToolResults.Success(JsonSerializer.Serialize(result, McpJsonContext.Default.ListResultTcUnit)); } catch (TcClientException ex) { - return $"Error: {ex.Message} (HTTP {(int)ex.HttpStatus})"; + return ToolResults.Error($"{ex.Message} (HTTP {(int)ex.HttpStatus})"); } } } diff --git a/src/Yllibed.TenantCloudClient.Mcp/Tools/UserTools.cs b/src/Yllibed.TenantCloudClient.Mcp/Tools/UserTools.cs index 12b170c..a53e041 100644 --- a/src/Yllibed.TenantCloudClient.Mcp/Tools/UserTools.cs +++ b/src/Yllibed.TenantCloudClient.Mcp/Tools/UserTools.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Text.Json; +using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; namespace Yllibed.TenantCloudClient.Mcp.Tools; @@ -8,7 +9,7 @@ namespace Yllibed.TenantCloudClient.Mcp.Tools; internal sealed class UserTools { [McpServerTool(Name = "get_user_info"), Description("Get information about the currently signed-in TenantCloud user.")] - public static async Task GetUserInfo(ITcClient client, CancellationToken ct) + public static async Task GetUserInfo(ITcClient client, CancellationToken ct) { try { @@ -16,14 +17,14 @@ public static async Task GetUserInfo(ITcClient client, CancellationToken if (user is null) { - return "No user info available. You may not be authenticated."; + return ToolResults.Error("No user info available. You may not be authenticated."); } - return JsonSerializer.Serialize(user, McpJsonContext.Default.TcUserInfo); + return ToolResults.Success(JsonSerializer.Serialize(user, McpJsonContext.Default.TcUserInfo)); } catch (TcClientException ex) { - return $"Error: {ex.Message} (HTTP {(int)ex.HttpStatus})"; + return ToolResults.Error($"{ex.Message} (HTTP {(int)ex.HttpStatus})"); } } } From 2ad693a066e942086a64fadedd2846d4b43fb051 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 22:42:31 -0500 Subject: [PATCH 08/18] Add dynamic token provider resolution with CI and interactive login support - Enhanced `TestBase` to dynamically resolve token provider using `TC_AUTH_TOKEN`, CI variables, and interactive login when applicable. - Introduced `CdpTokenProvider` with secure token storage and support for non-CI interactive login. - Updated project file to include `Yllibed.TenantCloudClient.Cdp` dependency for handling CDP-based token resolution. --- .../TestBase.cs | 59 ++++++++++++++++--- .../Yllibed.TenantCloudClient.Tests.csproj | 1 + 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/Yllibed.TenantCloudClient.Tests/TestBase.cs b/src/Yllibed.TenantCloudClient.Tests/TestBase.cs index 04f06c5..abe401e 100644 --- a/src/Yllibed.TenantCloudClient.Tests/TestBase.cs +++ b/src/Yllibed.TenantCloudClient.Tests/TestBase.cs @@ -1,24 +1,69 @@ +using Yllibed.TenantCloudClient.Cdp; + namespace Yllibed.TenantCloudClient.Tests; public class TestBase { - private static readonly string? s_envToken = - Environment.GetEnvironmentVariable("TC_AUTH_TOKEN"); + private static readonly bool IsCI = + !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) + || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("TF_BUILD")); + + private static ITcAuthTokenProvider? s_cachedProvider; + private static bool s_providerResolved; protected ITcAuthTokenProvider TokenProvider { get; private set; } = null!; [TestInitialize] - public void EnsureTokenAvailable() + public async Task EnsureTokenAvailable() { - var token = s_envToken; + if (!s_providerResolved) + { + s_cachedProvider = await ResolveProvider().ConfigureAwait(false); + s_providerResolved = true; + } - if (string.IsNullOrEmpty(token)) + if (s_cachedProvider is null) { Assert.Inconclusive( "No TenantCloud auth token available. " + - "Set TC_AUTH_TOKEN to run integration tests."); + "Set TC_AUTH_TOKEN or sign in via tc-mcp to run integration tests."); + } + + TokenProvider = s_cachedProvider; + } + + private static async Task ResolveProvider() + { + // 1. Environment variable (CI / manual override) + var envToken = Environment.GetEnvironmentVariable("TC_AUTH_TOKEN"); + if (!string.IsNullOrEmpty(envToken)) + { + return new StaticTokenProvider(envToken); + } + + // 2. CdpTokenProvider: store → browser → interactive login (local only) + if (SecureTokenStore.IsSupported) + { + try + { + var provider = new CdpTokenProvider(new CdpTokenProviderOptions + { + TokenStore = new SecureTokenStore(), + AllowInteractiveLogin = !IsCI, + }); + + var token = await provider.GetToken(CancellationToken.None).ConfigureAwait(false); + if (!string.IsNullOrEmpty(token)) + { + return provider; + } + } + catch + { + // Provider unavailable — fall through + } } - TokenProvider = new StaticTokenProvider(token); + return null; } } diff --git a/src/Yllibed.TenantCloudClient.Tests/Yllibed.TenantCloudClient.Tests.csproj b/src/Yllibed.TenantCloudClient.Tests/Yllibed.TenantCloudClient.Tests.csproj index c468b42..ed24013 100644 --- a/src/Yllibed.TenantCloudClient.Tests/Yllibed.TenantCloudClient.Tests.csproj +++ b/src/Yllibed.TenantCloudClient.Tests/Yllibed.TenantCloudClient.Tests.csproj @@ -12,6 +12,7 @@ + From da77ddf15cfb41692cef6d7e74a4d43124e0b04a Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 22:42:40 -0500 Subject: [PATCH 09/18] Refactor date serialization to use `DateFormats.TryParse` and add ISO date string test --- .../Given_JsonConverters.cs | 12 ++++++ .../HttpMessages/DateFormats.cs | 41 +++++++++++++++++++ ...JsonStringDateToDateTimeOffsetConverter.cs | 10 +---- ...ngDateToNullableDateTimeOffsetConverter.cs | 10 +---- 4 files changed, 55 insertions(+), 18 deletions(-) create mode 100644 src/Yllibed.TenantCloudClient/HttpMessages/DateFormats.cs diff --git a/src/Yllibed.TenantCloudClient.Tests/Given_JsonConverters.cs b/src/Yllibed.TenantCloudClient.Tests/Given_JsonConverters.cs index 39d4b28..1c0aacd 100644 --- a/src/Yllibed.TenantCloudClient.Tests/Given_JsonConverters.cs +++ b/src/Yllibed.TenantCloudClient.Tests/Given_JsonConverters.cs @@ -84,6 +84,18 @@ public void DateConverter_ReadsDateString() result.DueDate.Year.Should().Be(2024); } + [TestMethod] + public void DateConverter_ReadsIsoDateString() + { + var json = """{"id": 1, "date": "2026-02-01", "amount": 0, "paid": 0, "balance": 0}"""; + var result = JsonSerializer.Deserialize(json, Options); + + result.Should().NotBeNull(); + result!.DueDate.Month.Should().Be(2); + result.DueDate.Day.Should().Be(1); + result.DueDate.Year.Should().Be(2026); + } + [TestMethod] public void NullableDateConverter_ReadsNull() { diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/DateFormats.cs b/src/Yllibed.TenantCloudClient/HttpMessages/DateFormats.cs new file mode 100644 index 0000000..4333c2c --- /dev/null +++ b/src/Yllibed.TenantCloudClient/HttpMessages/DateFormats.cs @@ -0,0 +1,41 @@ +using System.Globalization; + +namespace Yllibed.TenantCloudClient.HttpMessages; + +internal static class DateFormats +{ + private static readonly string[] ExactFormats = + [ + "yyyy-MM-dd", + "M/d/yyyy", + "MM/dd/yyyy", + "MM/d/yyyy", + "M/dd/yyyy", + ]; + + public static bool TryParse(string? value, out DateTimeOffset result) + { + result = default; + + if (value is null) + { + return false; + } + + // Try exact date-only formats first + if (DateTimeOffset.TryParseExact(value, ExactFormats, DateTimeFormatInfo.InvariantInfo, + DateTimeStyles.AssumeLocal | DateTimeStyles.AllowWhiteSpaces, out result)) + { + return true; + } + + // Fallback: ISO 8601 with time (e.g. "2026-02-01T23:00:49.000000Z") + if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, out result)) + { + return true; + } + + return false; + } +} diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToDateTimeOffsetConverter.cs b/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToDateTimeOffsetConverter.cs index 91fcee8..c98df91 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToDateTimeOffsetConverter.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToDateTimeOffsetConverter.cs @@ -4,18 +4,10 @@ namespace Yllibed.TenantCloudClient.HttpMessages; public class JsonStringDateToDateTimeOffsetConverter : JsonConverter { - private static readonly string[] Formats = - [ - "M/d/yyyy", - "MM/dd/yyyy", - "MM/d/yyyy", - "M/dd/yyyy", - ]; - public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { var str = reader.GetString(); - if (DateTimeOffset.TryParseExact(str, Formats, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeLocal | DateTimeStyles.AllowWhiteSpaces, out var dto)) + if (DateFormats.TryParse(str, out var dto)) { return dto; } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToNullableDateTimeOffsetConverter.cs b/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToNullableDateTimeOffsetConverter.cs index 540fb2e..78a34b1 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToNullableDateTimeOffsetConverter.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToNullableDateTimeOffsetConverter.cs @@ -4,14 +4,6 @@ namespace Yllibed.TenantCloudClient.HttpMessages; public class JsonStringDateToNullableDateTimeOffsetConverter : JsonConverter { - private static readonly string[] Formats = - [ - "M/d/yyyy", - "MM/dd/yyyy", - "MM/d/yyyy", - "M/dd/yyyy", - ]; - public override DateTimeOffset? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { var str = reader.GetString(); @@ -20,7 +12,7 @@ public class JsonStringDateToNullableDateTimeOffsetConverter : JsonConverter Date: Sun, 15 Feb 2026 22:45:46 -0500 Subject: [PATCH 10/18] Add command parsing, help display, and install enhancements to `tc-mcp` - Implemented command parsing to distinguish between `install`, `mcp`, and unrecognized commands. - Added a user-friendly help menu with usage instructions for `tc-mcp`. - Modified `InstallCommand` to include "mcp" as a default argument for smooth integration with Claude tools. --- .../InstallCommand.cs | 4 +- src/Yllibed.TenantCloudClient.Mcp/Program.cs | 93 ++++++++++++------- 2 files changed, 62 insertions(+), 35 deletions(-) diff --git a/src/Yllibed.TenantCloudClient.Mcp/InstallCommand.cs b/src/Yllibed.TenantCloudClient.Mcp/InstallCommand.cs index fbfcc77..2e520a8 100644 --- a/src/Yllibed.TenantCloudClient.Mcp/InstallCommand.cs +++ b/src/Yllibed.TenantCloudClient.Mcp/InstallCommand.cs @@ -75,7 +75,7 @@ private static int InstallClaudeDesktop() servers["tc-mcp"] = new JsonObject { ["command"] = exePath, - ["args"] = new JsonArray(), + ["args"] = new JsonArray("mcp"), }; var options = new JsonSerializerOptions { WriteIndented = true }; @@ -123,7 +123,7 @@ private static int InstallClaudeCode() var psi = new ProcessStartInfo { FileName = "claude", - ArgumentList = { "mcp", "add", "--transport", "stdio", "tc-mcp", "--", exePath }, + ArgumentList = { "mcp", "add", "--transport", "stdio", "tc-mcp", "--", exePath, "mcp" }, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, diff --git a/src/Yllibed.TenantCloudClient.Mcp/Program.cs b/src/Yllibed.TenantCloudClient.Mcp/Program.cs index 838d098..798c421 100644 --- a/src/Yllibed.TenantCloudClient.Mcp/Program.cs +++ b/src/Yllibed.TenantCloudClient.Mcp/Program.cs @@ -7,48 +7,75 @@ using Yllibed.TenantCloudClient.Mcp; using Yllibed.TenantCloudClient.Mcp.Tools; -if (args.Length > 0 && string.Equals(args[0], "install", StringComparison.OrdinalIgnoreCase)) +var command = args.Length > 0 ? args[0] : null; + +if (string.Equals(command, "install", StringComparison.OrdinalIgnoreCase)) { return InstallCommand.Run(args.AsSpan(1)); } -var builder = Host.CreateApplicationBuilder(args); +if (string.Equals(command, "mcp", StringComparison.OrdinalIgnoreCase)) +{ + return await RunMcpServer(args[1..]).ConfigureAwait(false); +} + +// No command or unknown command — show help +PrintHelp(); +return command is null ? 0 : 1; -builder.Logging.AddConsole(options => +static void PrintHelp() { - // stdout is reserved for MCP JSON-RPC; route all logs to stderr - options.LogToStandardErrorThreshold = LogLevel.Trace; -}); + var version = typeof(Program).Assembly.GetName().Version?.ToString() ?? "dev"; + Console.WriteLine($"tc-mcp v{version} — TenantCloud MCP server"); + Console.WriteLine(); + Console.WriteLine("Usage: tc-mcp "); + Console.WriteLine(); + Console.WriteLine("Commands:"); + Console.WriteLine(" mcp Start the MCP server (stdio transport)"); + Console.WriteLine(" install claude-desktop Register in Claude Desktop config"); + Console.WriteLine(" install claude-code Register in Claude Code via CLI"); +} -builder.Services.AddSecureTokenStore(); +static async Task RunMcpServer(string[] args) +{ + var builder = Host.CreateApplicationBuilder(args); -// Register CdpTokenProvider directly to set init-only properties via object initializer -builder.Services.AddSingleton(sp => - new CdpTokenProvider(new CdpTokenProviderOptions + builder.Logging.AddConsole(options => { - TokenStore = sp.GetService(), - AllowInteractiveLogin = true, - })); + // stdout is reserved for MCP JSON-RPC; route all logs to stderr + options.LogToStandardErrorThreshold = LogLevel.Trace; + }); -builder.Services.AddTenantCloudClient(); + builder.Services.AddSecureTokenStore(); -builder.Services - .AddMcpServer(o => - { - o.ServerInfo = new() + // Register CdpTokenProvider directly to set init-only properties via object initializer + builder.Services.AddSingleton(sp => + new CdpTokenProvider(new CdpTokenProviderOptions { - Name = "tc-mcp", - Version = typeof(Program).Assembly.GetName().Version?.ToString() ?? "dev", - }; - }) - .WithStdioServerTransport() - .WithTools() - .WithTools() - .WithTools() - .WithTools() - .WithTools() - .WithTools(); - -await builder.Build().RunAsync().ConfigureAwait(false); - -return 0; + TokenStore = sp.GetService(), + AllowInteractiveLogin = true, + })); + + builder.Services.AddTenantCloudClient(); + + builder.Services + .AddMcpServer(o => + { + o.ServerInfo = new() + { + Name = "tc-mcp", + Version = typeof(Program).Assembly.GetName().Version?.ToString() ?? "dev", + }; + }) + .WithStdioServerTransport() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools(); + + await builder.Build().RunAsync().ConfigureAwait(false); + + return 0; +} From 249be51f748230196be51bb68fadca43d133bad7 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 22:50:53 -0500 Subject: [PATCH 11/18] Improve CI to handle portable builds as `.zip` and update documentation for MCP usage adjustments --- .github/workflows/ci.yml | 9 +++++++-- docs/mcp-server.md | 16 ++++++++-------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5c9248..fd093c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -219,14 +219,19 @@ jobs: cp artifacts/packages/*.nupkg release-assets/ 2>/dev/null || true for dir in artifacts/binaries/tc-mcp-*/; do rid=$(basename "$dir" | sed 's/tc-mcp-//') + if [ "$rid" = "any" ]; then + # Portable build: zip all non-debug files + (cd "$dir" && zip -j "../../release-assets/tc-mcp-any.zip" \ + $(ls tc-mcp* | grep -v '\.pdb$' | grep -v '\.xml$')) + continue + fi for f in "$dir"tc-mcp*; do [ -f "$f" ] || continue ext="${f##*.}" - base="${f%.*}" if [ "$ext" = "pdb" ] || [ "$ext" = "xml" ]; then continue fi - if [ "$ext" = "$f" ] || [ "$ext" = "$(basename "$f")" ]; then + if [ "$ext" = "$(basename "$f")" ]; then cp "$f" "release-assets/tc-mcp-${rid}" else cp "$f" "release-assets/tc-mcp-${rid}.${ext}" diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 81f555f..d3eb0ca 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -6,17 +6,17 @@ Download the binary for your platform from [GitHub Releases](https://github.com/yllibed/TenantCloudClient/releases): -| Platform | Binary | -|----------|--------| +| Platform | Asset | +|----------|-------| | Windows x64 | `tc-mcp-win-x64.exe` | | Windows ARM64 | `tc-mcp-win-arm64.exe` | | macOS x64 | `tc-mcp-osx-x64` | | macOS ARM64 | `tc-mcp-osx-arm64` | | Linux x64 | `tc-mcp-linux-x64` | | Linux ARM64 | `tc-mcp-linux-arm64` | -| Portable (.NET 10) | `tc-mcp-any` | +| Portable (.NET 10) | `tc-mcp-any.zip` | -Platform-specific binaries are self-contained (no .NET runtime required). The portable build requires the .NET 10 runtime installed on the machine. +Platform-specific binaries are self-contained single-file executables (no .NET runtime required). The portable build is a zip containing `tc-mcp.dll` and its dependencies — run with `dotnet tc-mcp.dll mcp`. ## Auto-configuration @@ -34,7 +34,7 @@ tc-mcp install claude-code - Windows: `%APPDATA%\Claude\claude_desktop_config.json` - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` -**Claude Code** — runs `claude mcp add --transport stdio tc-mcp -- ` +**Claude Code** — runs `claude mcp add --transport stdio tc-mcp -- mcp` ## Manual configuration @@ -47,7 +47,7 @@ Add to your `claude_desktop_config.json`: "mcpServers": { "tc-mcp": { "command": "/path/to/tc-mcp", - "args": [] + "args": ["mcp"] } } } @@ -56,12 +56,12 @@ Add to your `claude_desktop_config.json`: ### Claude Code ```bash -claude mcp add --transport stdio tc-mcp -- /path/to/tc-mcp +claude mcp add --transport stdio tc-mcp -- /path/to/tc-mcp mcp ``` ### Cursor / other MCP clients -Use stdio transport with the `tc-mcp` binary path as the command. +Use stdio transport with the `tc-mcp` binary path as the command and `mcp` as the first argument. ## Available tools From 1f6a57231066af2046afed29cf5b27dc4b7aaf31 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 23:00:45 -0500 Subject: [PATCH 12/18] Add login and logout commands with secure token handling across OS backends - Introduced `tc-mcp login` and `tc-mcp logout` commands for authentication and credential removal, stored securely using platform-specific solutions (DPAPI on Windows, Keychain on macOS, Secret Service on Linux). - Added `DeleteAsync` support to `ISecureStorageBackend` implementations and `ITcTokenStore` for token deletion. - Updated documentation with usage instructions for login/logout commands. --- docs/mcp-server.md | 16 +++++ .../FileTokenStore.cs | 10 ++++ .../AuthCommands.cs | 59 +++++++++++++++++++ src/Yllibed.TenantCloudClient.Mcp/Program.cs | 12 ++++ .../ITcTokenStore.cs | 1 + .../Internal/ISecureStorageBackend.cs | 1 + .../Internal/LinuxSecretServiceBackend.cs | 9 +++ .../Internal/MacOsKeychainBackend.cs | 9 +++ .../Internal/WindowsDpapiBackend.cs | 11 ++++ .../SecureTokenStore.cs | 15 +++++ 10 files changed, 143 insertions(+) create mode 100644 src/Yllibed.TenantCloudClient.Mcp/AuthCommands.cs diff --git a/docs/mcp-server.md b/docs/mcp-server.md index d3eb0ca..582d1eb 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -18,6 +18,22 @@ Download the binary for your platform from [GitHub Releases](https://github.com/ Platform-specific binaries are self-contained single-file executables (no .NET runtime required). The portable build is a zip containing `tc-mcp.dll` and its dependencies — run with `dotnet tc-mcp.dll mcp`. +## Authentication + +Before using the MCP server, authenticate with TenantCloud: + +```bash +tc-mcp login +``` + +This opens a browser window for you to sign in. Tokens are stored in the OS secure credential store (DPAPI on Windows, Keychain on macOS, Secret Service on Linux). + +To remove stored credentials: + +```bash +tc-mcp logout +``` + ## Auto-configuration ```bash diff --git a/src/Yllibed.TenantCloudClient.Cdp/FileTokenStore.cs b/src/Yllibed.TenantCloudClient.Cdp/FileTokenStore.cs index 23c667a..bd76e80 100644 --- a/src/Yllibed.TenantCloudClient.Cdp/FileTokenStore.cs +++ b/src/Yllibed.TenantCloudClient.Cdp/FileTokenStore.cs @@ -52,4 +52,14 @@ public async Task SaveAsync(TcTokenSet tokens, CancellationToken ct) throw; } } + + public Task DeleteAsync(CancellationToken ct) + { + if (File.Exists(_filePath)) + { + File.Delete(_filePath); + } + + return Task.CompletedTask; + } } diff --git a/src/Yllibed.TenantCloudClient.Mcp/AuthCommands.cs b/src/Yllibed.TenantCloudClient.Mcp/AuthCommands.cs new file mode 100644 index 0000000..fd25409 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Mcp/AuthCommands.cs @@ -0,0 +1,59 @@ +using Yllibed.TenantCloudClient.Cdp; + +namespace Yllibed.TenantCloudClient.Mcp; + +internal static class AuthCommands +{ + public static async Task LoginAsync() + { + if (!SecureTokenStore.IsSupported) + { + await Console.Error.WriteLineAsync("Error: Secure token storage is not supported on this platform.").ConfigureAwait(false); + return 1; + } + + var store = new SecureTokenStore(); + + // Check if already logged in with a valid token + var existing = await store.LoadAsync(CancellationToken.None).ConfigureAwait(false); + if (existing is not null) + { + await Console.Out.WriteLineAsync("Already logged in. Use 'tc-mcp logout' first to re-authenticate.").ConfigureAwait(false); + return 0; + } + + await Console.Out.WriteLineAsync("Logging in to TenantCloud...").ConfigureAwait(false); + + using var provider = new CdpTokenProvider(new CdpTokenProviderOptions + { + TokenStore = store, + AllowInteractiveLogin = true, + }); + + var token = await provider.GetToken(CancellationToken.None).ConfigureAwait(false); + + if (string.IsNullOrEmpty(token)) + { + await Console.Error.WriteLineAsync("Login failed. Could not obtain a valid token.").ConfigureAwait(false); + return 1; + } + + await Console.Out.WriteLineAsync("Login successful. Tokens stored in secure credential store.").ConfigureAwait(false); + return 0; + } + + public static async Task LogoutAsync() + { + if (!SecureTokenStore.IsSupported) + { + await Console.Error.WriteLineAsync("Error: Secure token storage is not supported on this platform.").ConfigureAwait(false); + return 1; + } + + var store = new SecureTokenStore(); + await store.DeleteAsync(CancellationToken.None).ConfigureAwait(false); + + await Console.Out.WriteLineAsync("Logged out. Stored tokens have been removed.").ConfigureAwait(false); + return 0; + } +} diff --git a/src/Yllibed.TenantCloudClient.Mcp/Program.cs b/src/Yllibed.TenantCloudClient.Mcp/Program.cs index 798c421..0e539fc 100644 --- a/src/Yllibed.TenantCloudClient.Mcp/Program.cs +++ b/src/Yllibed.TenantCloudClient.Mcp/Program.cs @@ -19,6 +19,16 @@ return await RunMcpServer(args[1..]).ConfigureAwait(false); } +if (string.Equals(command, "login", StringComparison.OrdinalIgnoreCase)) +{ + return await AuthCommands.LoginAsync().ConfigureAwait(false); +} + +if (string.Equals(command, "logout", StringComparison.OrdinalIgnoreCase)) +{ + return await AuthCommands.LogoutAsync().ConfigureAwait(false); +} + // No command or unknown command — show help PrintHelp(); return command is null ? 0 : 1; @@ -32,6 +42,8 @@ static void PrintHelp() Console.WriteLine(); Console.WriteLine("Commands:"); Console.WriteLine(" mcp Start the MCP server (stdio transport)"); + Console.WriteLine(" login Authenticate and store tokens"); + Console.WriteLine(" logout Remove stored tokens"); Console.WriteLine(" install claude-desktop Register in Claude Desktop config"); Console.WriteLine(" install claude-code Register in Claude Code via CLI"); } diff --git a/src/Yllibed.TenantCloudClient/ITcTokenStore.cs b/src/Yllibed.TenantCloudClient/ITcTokenStore.cs index c3f23bd..9cf95cb 100644 --- a/src/Yllibed.TenantCloudClient/ITcTokenStore.cs +++ b/src/Yllibed.TenantCloudClient/ITcTokenStore.cs @@ -7,4 +7,5 @@ public interface ITcTokenStore { Task LoadAsync(CancellationToken ct); Task SaveAsync(TcTokenSet tokens, CancellationToken ct); + Task DeleteAsync(CancellationToken ct); } diff --git a/src/Yllibed.TenantCloudClient/Internal/ISecureStorageBackend.cs b/src/Yllibed.TenantCloudClient/Internal/ISecureStorageBackend.cs index 263798f..ac2256c 100644 --- a/src/Yllibed.TenantCloudClient/Internal/ISecureStorageBackend.cs +++ b/src/Yllibed.TenantCloudClient/Internal/ISecureStorageBackend.cs @@ -7,4 +7,5 @@ internal interface ISecureStorageBackend { Task LoadAsync(string serviceName, string accountKey, CancellationToken ct); Task SaveAsync(string serviceName, string accountKey, byte[] data, CancellationToken ct); + Task DeleteAsync(string serviceName, string accountKey, CancellationToken ct); } diff --git a/src/Yllibed.TenantCloudClient/Internal/LinuxSecretServiceBackend.cs b/src/Yllibed.TenantCloudClient/Internal/LinuxSecretServiceBackend.cs index 99d5cb3..6004b15 100644 --- a/src/Yllibed.TenantCloudClient/Internal/LinuxSecretServiceBackend.cs +++ b/src/Yllibed.TenantCloudClient/Internal/LinuxSecretServiceBackend.cs @@ -41,4 +41,13 @@ public async Task SaveAsync(string serviceName, string accountKey, byte[] data, $"secret-tool store failed (exit code {exitCode}): {stderr.Trim()}"); } } + + public async Task DeleteAsync(string serviceName, string accountKey, CancellationToken ct) + { + await CliHelper.RunAsync( + "secret-tool", + $"clear service \"{serviceName}\" account \"{accountKey}\"", + stdinData: null, + ct).ConfigureAwait(false); + } } diff --git a/src/Yllibed.TenantCloudClient/Internal/MacOsKeychainBackend.cs b/src/Yllibed.TenantCloudClient/Internal/MacOsKeychainBackend.cs index 6d40919..d851724 100644 --- a/src/Yllibed.TenantCloudClient/Internal/MacOsKeychainBackend.cs +++ b/src/Yllibed.TenantCloudClient/Internal/MacOsKeychainBackend.cs @@ -41,4 +41,13 @@ public async Task SaveAsync(string serviceName, string accountKey, byte[] data, $"security add-generic-password failed (exit code {exitCode}): {stderr.Trim()}"); } } + + public async Task DeleteAsync(string serviceName, string accountKey, CancellationToken ct) + { + await CliHelper.RunAsync( + "security", + $"delete-generic-password -s \"{serviceName}\" -a \"{accountKey}\"", + stdinData: null, + ct).ConfigureAwait(false); + } } diff --git a/src/Yllibed.TenantCloudClient/Internal/WindowsDpapiBackend.cs b/src/Yllibed.TenantCloudClient/Internal/WindowsDpapiBackend.cs index 7436652..17c3553 100644 --- a/src/Yllibed.TenantCloudClient/Internal/WindowsDpapiBackend.cs +++ b/src/Yllibed.TenantCloudClient/Internal/WindowsDpapiBackend.cs @@ -110,6 +110,17 @@ public Task SaveAsync(string serviceName, string accountKey, byte[] data, Cancel return Task.CompletedTask; } + public Task DeleteAsync(string serviceName, string accountKey, CancellationToken ct) + { + var filePath = GetFilePath(accountKey); + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + + return Task.CompletedTask; + } + private static string GetFilePath(string accountKey) { var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); diff --git a/src/Yllibed.TenantCloudClient/SecureTokenStore.cs b/src/Yllibed.TenantCloudClient/SecureTokenStore.cs index 09353f0..23c1341 100644 --- a/src/Yllibed.TenantCloudClient/SecureTokenStore.cs +++ b/src/Yllibed.TenantCloudClient/SecureTokenStore.cs @@ -73,6 +73,21 @@ await _backend.SaveAsync( } } + /// + public async Task DeleteAsync(CancellationToken ct) + { + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + await _backend.DeleteAsync( + _options.ServiceName, _options.AccountKey, ct).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + private static ISecureStorageBackend CreateBackend() { if (OperatingSystem.IsWindows()) From 87183f10eba51850bd29b08e3a36d51c2c07e9da Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 23:23:46 -0500 Subject: [PATCH 13/18] Add `EntityCache` for ID-to-name resolution and enrich MCP tool outputs with human-readable references - Introduced `EntityCache` for caching properties, units, and contacts, enabling fast ID lookups. - Enhanced MCP tools (`list_transactions`, `list_leases`, `list_units`) to enrich JSON results with a `references` section mapping IDs to names. - Added `EntityEnricher` for appending enriched reference data to tool outputs. - Implemented `EntityResources` for resolving entity details via MCP resources. --- .../EntityCache.cs | 83 ++++++++++++++ .../EntityEnricher.cs | 84 ++++++++++++++ .../EntityResources.cs | 108 ++++++++++++++++++ src/Yllibed.TenantCloudClient.Mcp/Program.cs | 4 +- .../SchemaResource.cs | 27 +++++ .../Tools/LeaseTools.cs | 5 +- .../Tools/TransactionTools.cs | 5 +- .../Tools/UnitTools.cs | 5 +- 8 files changed, 317 insertions(+), 4 deletions(-) create mode 100644 src/Yllibed.TenantCloudClient.Mcp/EntityCache.cs create mode 100644 src/Yllibed.TenantCloudClient.Mcp/EntityEnricher.cs create mode 100644 src/Yllibed.TenantCloudClient.Mcp/EntityResources.cs diff --git a/src/Yllibed.TenantCloudClient.Mcp/EntityCache.cs b/src/Yllibed.TenantCloudClient.Mcp/EntityCache.cs new file mode 100644 index 0000000..d72a5b4 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Mcp/EntityCache.cs @@ -0,0 +1,83 @@ +using Yllibed.TenantCloudClient.HttpMessages; + +namespace Yllibed.TenantCloudClient.Mcp; + +/// +/// Caches properties, units, and contacts for fast ID-to-name resolution. +/// +internal sealed class EntityCache(ITcClient client) +{ + private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(5); + + private readonly SemaphoreSlim _semaphore = new(1, 1); + private Dictionary? _properties; + private Dictionary? _units; + private Dictionary? _contacts; + private DateTime _loadedAt; + + public async Task GetPropertyNameAsync(long id, CancellationToken ct) + { + await EnsureCacheAsync(ct).ConfigureAwait(false); + return _properties!.TryGetValue(id, out var p) ? (p.Name ?? p.Address) : null; + } + + public async Task GetUnitNameAsync(long id, CancellationToken ct) + { + await EnsureCacheAsync(ct).ConfigureAwait(false); + return _units!.TryGetValue(id, out var u) ? u.Name : null; + } + + public async Task GetContactNameAsync(long id, CancellationToken ct) + { + await EnsureCacheAsync(ct).ConfigureAwait(false); + return _contacts!.TryGetValue(id, out var c) ? c.Name : null; + } + + public async Task GetPropertyAsync(long id, CancellationToken ct) + { + await EnsureCacheAsync(ct).ConfigureAwait(false); + return _properties!.GetValueOrDefault(id); + } + + public async Task GetUnitAsync(long id, CancellationToken ct) + { + await EnsureCacheAsync(ct).ConfigureAwait(false); + return _units!.GetValueOrDefault(id); + } + + public async Task GetContactAsync(long id, CancellationToken ct) + { + await EnsureCacheAsync(ct).ConfigureAwait(false); + return _contacts!.GetValueOrDefault(id); + } + + private async Task EnsureCacheAsync(CancellationToken ct) + { + if (_properties is not null && DateTime.UtcNow - _loadedAt < CacheTtl) + { + return; + } + + await _semaphore.WaitAsync(ct).ConfigureAwait(false); + try + { + if (_properties is not null && DateTime.UtcNow - _loadedAt < CacheTtl) + { + return; + } + + var properties = await client.Properties.GetAll(ct).ConfigureAwait(false); + var units = await client.Units.GetAll(ct).ConfigureAwait(false); + var contacts = await client.Contacts.GetAll(ct).ConfigureAwait(false); + + _properties = properties.AsEnumerable().ToDictionary(p => p.Id); + _units = units.AsEnumerable().ToDictionary(u => u.Id); + _contacts = contacts.AsEnumerable().ToDictionary(c => c.Id); + _loadedAt = DateTime.UtcNow; + } + finally + { + _semaphore.Release(); + } + } +} diff --git a/src/Yllibed.TenantCloudClient.Mcp/EntityEnricher.cs b/src/Yllibed.TenantCloudClient.Mcp/EntityEnricher.cs new file mode 100644 index 0000000..6870f15 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Mcp/EntityEnricher.cs @@ -0,0 +1,84 @@ +using System.Globalization; +using System.Text.Json.Nodes; + +namespace Yllibed.TenantCloudClient.Mcp; + +/// +/// Post-processes tool JSON output to append a references lookup table +/// that maps foreign-key IDs to human-readable names. +/// +internal static class EntityEnricher +{ + /// + /// Parses the JSON, collects property_id and unit_id values from the + /// data array, resolves them via the cache, and appends a references + /// section to the root object. + /// + public static async Task EnrichAsync(string json, EntityCache cache, CancellationToken ct) + { + var node = JsonNode.Parse(json); + if (node is not JsonObject root || root["data"] is not JsonArray dataArray) + { + return json; + } + + var propertyIds = CollectIds(dataArray, "property_id"); + var unitIds = CollectIds(dataArray, "unit_id"); + + var references = new JsonObject(); + + await AddReferencesAsync(references, "properties", propertyIds, cache.GetPropertyNameAsync, ct).ConfigureAwait(false); + await AddReferencesAsync(references, "units", unitIds, cache.GetUnitNameAsync, ct).ConfigureAwait(false); + + if (references.Count > 0) + { + root["references"] = references; + } + + return root.ToJsonString(); + } + + private static HashSet CollectIds(JsonArray dataArray, string fieldName) + { + var ids = new HashSet(); + foreach (var item in dataArray) + { + if (item is JsonObject obj + && obj[fieldName] is JsonValue v + && v.TryGetValue(out var id)) + { + ids.Add(id); + } + } + + return ids; + } + + private static async Task AddReferencesAsync( + JsonObject references, + string sectionName, + HashSet ids, + Func> resolver, + CancellationToken ct) + { + if (ids.Count == 0) + { + return; + } + + var section = new JsonObject(); + foreach (var id in ids) + { + var name = await resolver(id, ct).ConfigureAwait(false); + if (name is not null) + { + section[id.ToString(CultureInfo.InvariantCulture)] = name; + } + } + + if (section.Count > 0) + { + references[sectionName] = section; + } + } +} diff --git a/src/Yllibed.TenantCloudClient.Mcp/EntityResources.cs b/src/Yllibed.TenantCloudClient.Mcp/EntityResources.cs new file mode 100644 index 0000000..3e81a0d --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Mcp/EntityResources.cs @@ -0,0 +1,108 @@ +using System.ComponentModel; +using System.Globalization; +using System.Text.Json.Nodes; +using ModelContextProtocol.Server; + +namespace Yllibed.TenantCloudClient.Mcp; + +[McpServerResourceType] +internal sealed class EntityResources +{ + [McpServerResource( + UriTemplate = "tc://property/{id}", + Name = "property", + Title = "Property details by ID", + MimeType = "application/json")] + [Description("Look up a property by its numeric ID. Returns name, address, and status.")] + public static async Task GetProperty( + long id, + EntityCache cache, + CancellationToken ct) + { + var property = await cache.GetPropertyAsync(id, ct).ConfigureAwait(false); + if (property is null) + { + return NotFound("property", id); + } + + var obj = new JsonObject + { + ["id"] = property.Id, + ["name"] = property.Name, + ["address"] = property.Address, + ["status"] = property.Status, + }; + + return obj.ToJsonString(); + } + + [McpServerResource( + UriTemplate = "tc://unit/{id}", + Name = "unit", + Title = "Unit details by ID", + MimeType = "application/json")] + [Description("Look up a rental unit by its numeric ID. Returns name, property, price, and occupancy.")] + public static async Task GetUnit( + long id, + EntityCache cache, + CancellationToken ct) + { + var unit = await cache.GetUnitAsync(id, ct).ConfigureAwait(false); + if (unit is null) + { + return NotFound("unit", id); + } + + var propertyName = await cache.GetPropertyNameAsync(unit.PropertyId, ct).ConfigureAwait(false); + + var obj = new JsonObject + { + ["id"] = unit.Id, + ["name"] = unit.Name, + ["propertyId"] = unit.PropertyId, + ["propertyName"] = propertyName, + ["price"] = unit.Price, + ["isRented"] = unit.IsRented, + }; + + return obj.ToJsonString(); + } + + [McpServerResource( + UriTemplate = "tc://contact/{id}", + Name = "contact", + Title = "Contact details by ID", + MimeType = "application/json")] + [Description("Look up a contact by its numeric ID. Returns name, emails, and phones.")] + public static async Task GetContact( + long id, + EntityCache cache, + CancellationToken ct) + { + var contact = await cache.GetContactAsync(id, ct).ConfigureAwait(false); + if (contact is null) + { + return NotFound("contact", id); + } + + var obj = new JsonObject + { + ["id"] = contact.Id, + ["name"] = contact.Name, + ["firstName"] = contact.FirstName, + ["lastName"] = contact.LastName, + ["emails"] = new JsonArray(contact.ValidEmails + .Where(e => e is not null) + .Select(e => (JsonNode)JsonValue.Create(e)!) + .ToArray()), + ["phones"] = new JsonArray(contact.ValidPhones + .Select(p => (JsonNode)JsonValue.Create(p)!) + .ToArray()), + }; + + return obj.ToJsonString(); + } + + private static string NotFound(string entity, long id) => + string.Create(CultureInfo.InvariantCulture, $"{entity} {id} not found"); +} diff --git a/src/Yllibed.TenantCloudClient.Mcp/Program.cs b/src/Yllibed.TenantCloudClient.Mcp/Program.cs index 0e539fc..61061ef 100644 --- a/src/Yllibed.TenantCloudClient.Mcp/Program.cs +++ b/src/Yllibed.TenantCloudClient.Mcp/Program.cs @@ -69,6 +69,7 @@ static async Task RunMcpServer(string[] args) })); builder.Services.AddTenantCloudClient(); + builder.Services.AddSingleton(); builder.Services .AddMcpServer(o => @@ -85,7 +86,8 @@ static async Task RunMcpServer(string[] args) .WithTools() .WithTools() .WithTools() - .WithTools(); + .WithTools() + .WithResources(); await builder.Build().RunAsync().ConfigureAwait(false); diff --git a/src/Yllibed.TenantCloudClient.Mcp/SchemaResource.cs b/src/Yllibed.TenantCloudClient.Mcp/SchemaResource.cs index 5d83e70..6da70d4 100644 --- a/src/Yllibed.TenantCloudClient.Mcp/SchemaResource.cs +++ b/src/Yllibed.TenantCloudClient.Mcp/SchemaResource.cs @@ -17,6 +17,33 @@ internal sealed class SchemaResource private const string Schema = """ # TenantCloud Tool Usage Guide + ## Output Presentation — CRITICAL + + You MUST present **human-readable names** to the user, never raw numeric IDs. + + Tool responses that contain foreign-key IDs (`property_id`, `unit_id`) automatically + include a `references` section at the end of the JSON with resolved names: + + ```json + { + "data": [ ... ], + "count": 5, + "references": { + "properties": { "6587": "742 Evergreen Terrace" }, + "units": { "11899": "Chambre 3" } + } + } + ``` + + Use the `references` table to replace IDs with names when presenting results to the user. + For detailed entity information, read the MCP resources: + - `tc://property/{id}` — full property details + - `tc://unit/{id}` — full unit details (includes parent property name) + - `tc://contact/{id}` — full contact details + + When a reference is missing (entity not found in cache), resolve it by calling + the corresponding list tool (`list_properties`, `list_units`, `list_contacts`). + ## Entities ### UserInfo diff --git a/src/Yllibed.TenantCloudClient.Mcp/Tools/LeaseTools.cs b/src/Yllibed.TenantCloudClient.Mcp/Tools/LeaseTools.cs index ccad712..febdf62 100644 --- a/src/Yllibed.TenantCloudClient.Mcp/Tools/LeaseTools.cs +++ b/src/Yllibed.TenantCloudClient.Mcp/Tools/LeaseTools.cs @@ -12,6 +12,7 @@ internal sealed class LeaseTools [McpServerTool(Name = "list_leases"), Description("List leases from TenantCloud. Can filter by property, unit, or status.")] public static async Task ListLeases( ITcClient client, + EntityCache cache, [Description("Filter by property ID")] long? propertyId, [Description("Filter by unit ID")] long? unitId, [Description("Filter by status: active")] string? status, @@ -39,7 +40,9 @@ public static async Task ListLeases( var data = await source.GetAll(ct, maxResults ?? 100).ConfigureAwait(false); var result = new ListResult(data.AsEnumerable().ToArray()); - return ToolResults.Success(JsonSerializer.Serialize(result, McpJsonContext.Default.ListResultTcLease)); + var json = JsonSerializer.Serialize(result, McpJsonContext.Default.ListResultTcLease); + json = await EntityEnricher.EnrichAsync(json, cache, ct).ConfigureAwait(false); + return ToolResults.Success(json); } catch (TcClientException ex) { diff --git a/src/Yllibed.TenantCloudClient.Mcp/Tools/TransactionTools.cs b/src/Yllibed.TenantCloudClient.Mcp/Tools/TransactionTools.cs index 10fec00..1489dcf 100644 --- a/src/Yllibed.TenantCloudClient.Mcp/Tools/TransactionTools.cs +++ b/src/Yllibed.TenantCloudClient.Mcp/Tools/TransactionTools.cs @@ -12,6 +12,7 @@ internal sealed class TransactionTools [McpServerTool(Name = "list_transactions"), Description("List financial transactions from TenantCloud. Can filter by tenant, property, unit, status, or category.")] public static async Task ListTransactions( ITcClient client, + EntityCache cache, [Description("Filter by tenant/contact ID")] long? tenantId, [Description("Filter by property ID")] long? propertyId, [Description("Filter by unit ID")] long? unitId, @@ -51,7 +52,9 @@ public static async Task ListTransactions( var data = await source.GetAll(ct, maxResults ?? 100).ConfigureAwait(false); var result = new ListResult(data.AsEnumerable().ToArray()); - return ToolResults.Success(JsonSerializer.Serialize(result, McpJsonContext.Default.ListResultTcTransaction)); + var json = JsonSerializer.Serialize(result, McpJsonContext.Default.ListResultTcTransaction); + json = await EntityEnricher.EnrichAsync(json, cache, ct).ConfigureAwait(false); + return ToolResults.Success(json); } catch (TcClientException ex) { diff --git a/src/Yllibed.TenantCloudClient.Mcp/Tools/UnitTools.cs b/src/Yllibed.TenantCloudClient.Mcp/Tools/UnitTools.cs index 3d7cd63..485cf8a 100644 --- a/src/Yllibed.TenantCloudClient.Mcp/Tools/UnitTools.cs +++ b/src/Yllibed.TenantCloudClient.Mcp/Tools/UnitTools.cs @@ -12,6 +12,7 @@ internal sealed class UnitTools [McpServerTool(Name = "list_units"), Description("List rental units from TenantCloud. Can filter by property or occupancy status.")] public static async Task ListUnits( ITcClient client, + EntityCache cache, [Description("Filter by property ID")] long? propertyId, [Description("Filter by occupancy: occupied, vacant")] string? occupancy, [Description("Maximum number of results to return (default 100)")] int? maxResults, @@ -36,7 +37,9 @@ public static async Task ListUnits( var data = await source.GetAll(ct, maxResults ?? 100).ConfigureAwait(false); var result = new ListResult(data.AsEnumerable().ToArray()); - return ToolResults.Success(JsonSerializer.Serialize(result, McpJsonContext.Default.ListResultTcUnit)); + var json = JsonSerializer.Serialize(result, McpJsonContext.Default.ListResultTcUnit); + json = await EntityEnricher.EnrichAsync(json, cache, ct).ConfigureAwait(false); + return ToolResults.Success(json); } catch (TcClientException ex) { From aa7545a92b66459675608b095c5018335357711b Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 23:33:59 -0500 Subject: [PATCH 14/18] Remove `[JsonIgnore]` attributes from `Id` properties in HTTP message classes --- src/Yllibed.TenantCloudClient/HttpMessages/TcContact.cs | 1 - src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs | 1 - src/Yllibed.TenantCloudClient/HttpMessages/TcProperty.cs | 1 - src/Yllibed.TenantCloudClient/HttpMessages/TcTransaction.cs | 1 - src/Yllibed.TenantCloudClient/HttpMessages/TcUnit.cs | 1 - 5 files changed, 5 deletions(-) diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcContact.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcContact.cs index a84d48f..c91263e 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcContact.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcContact.cs @@ -4,7 +4,6 @@ namespace Yllibed.TenantCloudClient.HttpMessages; public partial class TcContact : IHasId { - [JsonIgnore] public long Id { get; set; } [JsonPropertyName("email")] diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs index da3e002..c61b5f4 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs @@ -2,7 +2,6 @@ namespace Yllibed.TenantCloudClient.HttpMessages; public class TcLease : IHasId { - [JsonIgnore] public long Id { get; set; } [JsonPropertyName("name")] diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcProperty.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcProperty.cs index ccde902..33e0374 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcProperty.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcProperty.cs @@ -4,7 +4,6 @@ namespace Yllibed.TenantCloudClient.HttpMessages; public class TcProperty : IHasId { - [JsonIgnore] public long Id { get; set; } [JsonPropertyName("name")] diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcTransaction.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcTransaction.cs index 12a2595..fd92977 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcTransaction.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcTransaction.cs @@ -2,7 +2,6 @@ namespace Yllibed.TenantCloudClient.HttpMessages; public class TcTransaction : IHasId { - [JsonIgnore] public long Id { get; set; } [JsonPropertyName("unit_id")] diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcUnit.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcUnit.cs index b9bd484..569396f 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcUnit.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcUnit.cs @@ -2,7 +2,6 @@ namespace Yllibed.TenantCloudClient.HttpMessages; public class TcUnit : IHasId { - [JsonIgnore] public long Id { get; set; } [JsonPropertyName("property_id")] From 7448e7a4174daafc5370e53478ea33bdd7493ca8 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 23:38:38 -0500 Subject: [PATCH 15/18] Add support for `JsonTcLeaseStatusConverter` and improve serialization - Implemented `JsonTcLeaseStatusConverter` for custom serialization/deserialization of `TcLeaseStatus`. - Updated `JsonTcTransactionStatusConverter` to use string values instead of byte values for `TcTransactionStatus`. - Enhanced MCP outputs by resolving unit names with associated property names in `EntityEnricher`. - Registered `SchemaResource` in MCP tool builder. --- .../EntityEnricher.cs | 17 +++++++- src/Yllibed.TenantCloudClient.Mcp/Program.cs | 1 + .../JsonTcLeaseStatusConverter.cs | 40 +++++++++++++++++++ .../JsonTcTransactionStatusConverter.cs | 13 +++++- .../HttpMessages/TcLease.cs | 1 + 5 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 src/Yllibed.TenantCloudClient/HttpMessages/JsonTcLeaseStatusConverter.cs diff --git a/src/Yllibed.TenantCloudClient.Mcp/EntityEnricher.cs b/src/Yllibed.TenantCloudClient.Mcp/EntityEnricher.cs index 6870f15..c3db5df 100644 --- a/src/Yllibed.TenantCloudClient.Mcp/EntityEnricher.cs +++ b/src/Yllibed.TenantCloudClient.Mcp/EntityEnricher.cs @@ -28,7 +28,7 @@ public static async Task EnrichAsync(string json, EntityCache cache, Can var references = new JsonObject(); await AddReferencesAsync(references, "properties", propertyIds, cache.GetPropertyNameAsync, ct).ConfigureAwait(false); - await AddReferencesAsync(references, "units", unitIds, cache.GetUnitNameAsync, ct).ConfigureAwait(false); + await AddReferencesAsync(references, "units", unitIds, ResolveUnitWithProperty(cache), ct).ConfigureAwait(false); if (references.Count > 0) { @@ -54,6 +54,21 @@ private static HashSet CollectIds(JsonArray dataArray, string fieldName) return ids; } + private static Func> ResolveUnitWithProperty(EntityCache cache) + { + return async (id, ct) => + { + var unit = await cache.GetUnitAsync(id, ct).ConfigureAwait(false); + if (unit is null) + { + return null; + } + + var propertyName = await cache.GetPropertyNameAsync(unit.PropertyId, ct).ConfigureAwait(false); + return propertyName is not null ? $"{unit.Name} ({propertyName})" : unit.Name; + }; + } + private static async Task AddReferencesAsync( JsonObject references, string sectionName, diff --git a/src/Yllibed.TenantCloudClient.Mcp/Program.cs b/src/Yllibed.TenantCloudClient.Mcp/Program.cs index 61061ef..2ed3c27 100644 --- a/src/Yllibed.TenantCloudClient.Mcp/Program.cs +++ b/src/Yllibed.TenantCloudClient.Mcp/Program.cs @@ -87,6 +87,7 @@ static async Task RunMcpServer(string[] args) .WithTools() .WithTools() .WithTools() + .WithResources() .WithResources(); await builder.Build().RunAsync().ConfigureAwait(false); diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/JsonTcLeaseStatusConverter.cs b/src/Yllibed.TenantCloudClient/HttpMessages/JsonTcLeaseStatusConverter.cs new file mode 100644 index 0000000..3da88a9 --- /dev/null +++ b/src/Yllibed.TenantCloudClient/HttpMessages/JsonTcLeaseStatusConverter.cs @@ -0,0 +1,40 @@ +namespace Yllibed.TenantCloudClient.HttpMessages; + +public class JsonTcLeaseStatusConverter : JsonConverter +{ + public override TcLeaseStatus Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.Number: + return (TcLeaseStatus)reader.GetByte(); + case JsonTokenType.String: + var str = reader.GetString(); + if (Enum.TryParse(str, true, out var result)) + { + return result; + } + + throw new NotSupportedException($"Unknown lease status {str}"); + default: + throw new NotSupportedException($"Type {reader.TokenType} not supported"); + } + } + + public override void Write(Utf8JsonWriter writer, TcLeaseStatus value, JsonSerializerOptions options) + { + writer.WriteStringValue(value switch + { + TcLeaseStatus.Active => "active", + TcLeaseStatus.Archived => "archived", + TcLeaseStatus.Ended => "ended", + TcLeaseStatus.Expired => "expired", + TcLeaseStatus.ExpiresIn => "expires_in", + TcLeaseStatus.Future => "future", + TcLeaseStatus.InsurancePending => "insurance_pending", + TcLeaseStatus.NotActive => "not_active", + TcLeaseStatus.Pending => "pending", + _ => value.ToString().ToLowerInvariant(), + }); + } +} diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/JsonTcTransactionStatusConverter.cs b/src/Yllibed.TenantCloudClient/HttpMessages/JsonTcTransactionStatusConverter.cs index 9c8afcb..a803dd2 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/JsonTcTransactionStatusConverter.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/JsonTcTransactionStatusConverter.cs @@ -23,6 +23,17 @@ public override TcTransactionStatus Read(ref Utf8JsonReader reader, Type typeToC public override void Write(Utf8JsonWriter writer, TcTransactionStatus value, JsonSerializerOptions options) { - writer.WriteNumberValue((byte)value); + writer.WriteStringValue(value switch + { + TcTransactionStatus.Due => "due", + TcTransactionStatus.Paid => "paid", + TcTransactionStatus.Partial => "partial", + TcTransactionStatus.Pending => "pending", + TcTransactionStatus.Void => "void", + TcTransactionStatus.WithBalance => "with_balance", + TcTransactionStatus.Overdue => "overdue", + TcTransactionStatus.Waive => "waive", + _ => value.ToString().ToLowerInvariant(), + }); } } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs index c61b5f4..b4ba71b 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs @@ -20,6 +20,7 @@ public class TcLease : IHasId public long UnitId { get; set; } [JsonPropertyName("lease_status")] + [JsonConverter(typeof(JsonTcLeaseStatusConverter))] public TcLeaseStatus Status { get; set; } [JsonIgnore] From fb60e5f37e4a9cf69fdc6431294189e395f64cd4 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 23:47:04 -0500 Subject: [PATCH 16/18] Add support for `user_client_id` and `user_payer_id` references in MCP outputs - Added `TenantId` and `PayerId` properties to `TcLease` and `TcTransaction` respectively. - Updated `EntityEnricher` to resolve `user_client_id` and `user_payer_id` as `contacts`. - Enhanced `SchemaResource` to include `contacts` in the `references` section for enriched MCP outputs. --- src/Yllibed.TenantCloudClient.Mcp/EntityEnricher.cs | 3 +++ src/Yllibed.TenantCloudClient.Mcp/SchemaResource.cs | 10 +++++++--- src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs | 4 ++++ .../HttpMessages/TcTransaction.cs | 4 ++++ 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/Yllibed.TenantCloudClient.Mcp/EntityEnricher.cs b/src/Yllibed.TenantCloudClient.Mcp/EntityEnricher.cs index c3db5df..35537d3 100644 --- a/src/Yllibed.TenantCloudClient.Mcp/EntityEnricher.cs +++ b/src/Yllibed.TenantCloudClient.Mcp/EntityEnricher.cs @@ -24,11 +24,14 @@ public static async Task EnrichAsync(string json, EntityCache cache, Can var propertyIds = CollectIds(dataArray, "property_id"); var unitIds = CollectIds(dataArray, "unit_id"); + var contactIds = CollectIds(dataArray, "user_client_id"); + contactIds.UnionWith(CollectIds(dataArray, "user_payer_id")); var references = new JsonObject(); await AddReferencesAsync(references, "properties", propertyIds, cache.GetPropertyNameAsync, ct).ConfigureAwait(false); await AddReferencesAsync(references, "units", unitIds, ResolveUnitWithProperty(cache), ct).ConfigureAwait(false); + await AddReferencesAsync(references, "contacts", contactIds, cache.GetContactNameAsync, ct).ConfigureAwait(false); if (references.Count > 0) { diff --git a/src/Yllibed.TenantCloudClient.Mcp/SchemaResource.cs b/src/Yllibed.TenantCloudClient.Mcp/SchemaResource.cs index 6da70d4..84f428e 100644 --- a/src/Yllibed.TenantCloudClient.Mcp/SchemaResource.cs +++ b/src/Yllibed.TenantCloudClient.Mcp/SchemaResource.cs @@ -21,8 +21,9 @@ internal sealed class SchemaResource You MUST present **human-readable names** to the user, never raw numeric IDs. - Tool responses that contain foreign-key IDs (`property_id`, `unit_id`) automatically - include a `references` section at the end of the JSON with resolved names: + Tool responses that contain foreign-key IDs (`property_id`, `unit_id`, `user_client_id`, + `user_payer_id`) automatically include a `references` section at the end of the JSON with + resolved names: ```json { @@ -30,7 +31,8 @@ internal sealed class SchemaResource "count": 5, "references": { "properties": { "6587": "742 Evergreen Terrace" }, - "units": { "11899": "Chambre 3" } + "units": { "11899": "Chambre 3 (742 Evergreen Terrace)" }, + "contacts": { "3099201": "John Smith" } } } ``` @@ -88,6 +90,7 @@ A financial transaction (rent, expense, etc.). - `id` (long) — Transaction ID - `unitId` (long?) — Associated unit - `propertyId` (long?) — Associated property + - `payerId` (long?) — Payer contact ID (see `references.contacts`) - `detail` (string?) — Description - `amount` (decimal) — Total amount - `paid` (decimal) — Amount paid @@ -104,6 +107,7 @@ A lease agreement. - `id` (long) — Lease ID - `name` (string?) — Lease name - `unitId` (long) — Associated unit + - `tenantId` (long?) — Tenant contact ID (see `references.contacts`) - `startDate` (DateTime) — Start date - `endDate` (DateTime?) — End date (null = month-to-month) - `status` (TcLeaseStatus) — Active, Archived, Ended, Expired, Future, Pending, etc. diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs index b4ba71b..e7f53b9 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs @@ -19,6 +19,10 @@ public class TcLease : IHasId [JsonPropertyName("unit_id")] public long UnitId { get; set; } + [JsonPropertyName("user_client_id")] + [JsonConverter(typeof(JsonAutoNullableLongConverter))] + public long? TenantId { get; set; } + [JsonPropertyName("lease_status")] [JsonConverter(typeof(JsonTcLeaseStatusConverter))] public TcLeaseStatus Status { get; set; } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcTransaction.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcTransaction.cs index fd92977..62a2ffe 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcTransaction.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcTransaction.cs @@ -12,6 +12,10 @@ public class TcTransaction : IHasId [JsonConverter(typeof(JsonAutoNullableLongConverter))] public long? PropertyId { get; set; } + [JsonPropertyName("user_payer_id")] + [JsonConverter(typeof(JsonAutoNullableLongConverter))] + public long? PayerId { get; set; } + [JsonPropertyName("detail")] public string? Detail { get; set; } From 49bcd0efe792c93a79118c5e9530cfec5c663018 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 16 Feb 2026 00:01:40 -0500 Subject: [PATCH 17/18] - Refactor `ToSerializedString` to use `switch` expression for cleaner implementation. - Enhance `EntityCache` to include signed-in user in `contacts` for resolving `user_payer_id` references. --- .../EntityCache.cs | 14 ++++++++++ ...TcTransactionsPaginatedSourceExtensions.cs | 28 ++++++++----------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/Yllibed.TenantCloudClient.Mcp/EntityCache.cs b/src/Yllibed.TenantCloudClient.Mcp/EntityCache.cs index d72a5b4..79d1e69 100644 --- a/src/Yllibed.TenantCloudClient.Mcp/EntityCache.cs +++ b/src/Yllibed.TenantCloudClient.Mcp/EntityCache.cs @@ -73,6 +73,20 @@ private async Task EnsureCacheAsync(CancellationToken ct) _properties = properties.AsEnumerable().ToDictionary(p => p.Id); _units = units.AsEnumerable().ToDictionary(u => u.Id); _contacts = contacts.AsEnumerable().ToDictionary(c => c.Id); + + // Add the signed-in user to the contacts dictionary so that + // user_payer_id references can be resolved to a name. + var userInfo = await client.GetUserInfo(ct).ConfigureAwait(false); + if (userInfo is not null && !_contacts.ContainsKey(userInfo.Id)) + { + _contacts[userInfo.Id] = new TcContact + { + Id = userInfo.Id, + FirstName = userInfo.FirstName, + LastName = userInfo.LastName, + Name = $"{userInfo.FirstName} {userInfo.LastName}".Trim(), + }; + } _loadedAt = DateTime.UtcNow; } finally diff --git a/src/Yllibed.TenantCloudClient/TcTransactionsPaginatedSourceExtensions.cs b/src/Yllibed.TenantCloudClient/TcTransactionsPaginatedSourceExtensions.cs index defe047..94e8be3 100644 --- a/src/Yllibed.TenantCloudClient/TcTransactionsPaginatedSourceExtensions.cs +++ b/src/Yllibed.TenantCloudClient/TcTransactionsPaginatedSourceExtensions.cs @@ -66,23 +66,17 @@ public static IPaginatedSource SortByDateDescending(this IPaginat internal static string ToSerializedString(this TcTransactionStatus status) { - switch (status) + return status switch { - case TcTransactionStatus.Due: - case TcTransactionStatus.Paid: - case TcTransactionStatus.Partial: - case TcTransactionStatus.Pending: - case TcTransactionStatus.Void: - var b = (byte)status; - return b.ToString(NumberFormatInfo.InvariantInfo); - case TcTransactionStatus.WithBalance: - return "with_balance"; - case TcTransactionStatus.Overdue: - return "overdue"; - case TcTransactionStatus.Waive: - return "waive"; - default: - throw new ArgumentOutOfRangeException(nameof(status), status, "Unknown status"); - } + TcTransactionStatus.Due => "due", + TcTransactionStatus.Paid => "paid", + TcTransactionStatus.Partial => "partial", + TcTransactionStatus.Pending => "pending", + TcTransactionStatus.Void => "void", + TcTransactionStatus.WithBalance => "with_balance", + TcTransactionStatus.Overdue => "overdue", + TcTransactionStatus.Waive => "waive", + _ => throw new ArgumentOutOfRangeException(nameof(status), status, "Unknown status"), + }; } } From 103abe93584c3d3683b6623f3d7a199eb38bf64b Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 16 Feb 2026 00:16:21 -0500 Subject: [PATCH 18/18] Add `MoveOutDate` to `TcLease` and update `SchemaResource` documentation --- .../SchemaResource.cs | 34 ++++++++++++++----- .../HttpMessages/TcLease.cs | 3 ++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/Yllibed.TenantCloudClient.Mcp/SchemaResource.cs b/src/Yllibed.TenantCloudClient.Mcp/SchemaResource.cs index 84f428e..51b0fe4 100644 --- a/src/Yllibed.TenantCloudClient.Mcp/SchemaResource.cs +++ b/src/Yllibed.TenantCloudClient.Mcp/SchemaResource.cs @@ -110,9 +110,20 @@ A lease agreement. - `tenantId` (long?) — Tenant contact ID (see `references.contacts`) - `startDate` (DateTime) — Start date - `endDate` (DateTime?) — End date (null = month-to-month) + - `moveOutDate` (DateTime?) — Actual move-out date (null if still active) - `status` (TcLeaseStatus) — Active, Archived, Ended, Expired, Future, Pending, etc. - ## Tool Filters + ## Tool Filters — CRITICAL + + You MUST use server-side filters whenever the user's question targets a specific + property, unit, tenant, or status. **NEVER fetch all data and filter client-side** + when a filter parameter exists for the criterion. Server-side filtering is faster, + returns less data, and avoids hitting pagination limits. + + For example, if the user asks about a specific unit: + 1. Resolve the unit ID (see ID Resolution below) + 2. Pass `unitId` to `list_leases` and `list_transactions` — do NOT call these + tools without filters and then search through the results yourself. ### list_contacts - `role` (string?) — Filter by role: `tenant`, `professional`, `moved_in`, `archived` @@ -170,12 +181,19 @@ 1. Call `list_properties` → find "Maple Heights" → get its `id` When the match is ambiguous, present the candidates and ask the user to clarify. ## Typical Questions - - "Who are my tenants?" → list_contacts with role=tenant - - "What properties do I have?" → list_properties - - "Which units are vacant?" → list_units with occupancy=vacant - - "What is the rent balance for property X?" → resolve property name → list_transactions with propertyId + status=with_balance - - "Show me active leases for [address]" → resolve property → list_leases with propertyId + status=active - - "Show transactions for [tenant name]" → resolve contact → list_transactions with tenantId - - "Who am I logged in as?" → get_user_info + + Always resolve names to IDs first, then use the appropriate filters. + + - "Who are my tenants?" → `list_contacts` with `role=tenant` + - "What properties do I have?" → `list_properties` + - "Which units are vacant?" → `list_units` with `occupancy=vacant` + - "Which units are vacant at [property]?" → resolve property → `list_units` with `propertyId` + `occupancy=vacant` + - "What is the rent balance for property X?" → resolve property → `list_transactions` with `propertyId` + `status=with_balance` + - "Show me active leases for [address]" → resolve property → `list_leases` with `propertyId` + `status=active` + - "Show transactions for [tenant name]" → resolve contact → `list_transactions` with `tenantId` + - "Show overdue rent for unit 3B at [property]" → resolve property → resolve unit → `list_transactions` with `unitId` + `status=overdue` + `category=income` + - "What leases are on unit [name]?" → resolve unit → `list_leases` with `unitId` + - "Show all expenses for [property]" → resolve property → `list_transactions` with `propertyId` + `category=expense` + - "Who am I logged in as?" → `get_user_info` """; } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs index e7f53b9..0498ff5 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs @@ -16,6 +16,9 @@ public class TcLease : IHasId [JsonPropertyName("rent_to")] public DateTime? EndDate { get; set; } + [JsonPropertyName("move_out_date")] + public DateTime? MoveOutDate { get; set; } + [JsonPropertyName("unit_id")] public long UnitId { get; set; }