Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
450764a
Add `tc-mcp` project with initial implementation
carldebilly Feb 16, 2026
2a20441
Add MCP JSON-RPC tools for Transaction, User, Lease, Contact, and Uni…
carldebilly Feb 16, 2026
a2d4150
Add extensive documentation, publish pipeline, and MCP enhancements
carldebilly Feb 16, 2026
500cd13
Add win-arm64 to publish matrix and fix binary table in docs
carldebilly Feb 16, 2026
7852572
Add portable and linux-arm64 builds to publish matrix
carldebilly Feb 16, 2026
c65868c
Run publish-binaries on PRs with reduced matrix (any + win-x64)
carldebilly Feb 16, 2026
54b0180
Return structured CallToolResult with IsError from MCP tools
carldebilly Feb 16, 2026
2ad693a
Add dynamic token provider resolution with CI and interactive login s…
carldebilly Feb 16, 2026
da77ddf
Refactor date serialization to use `DateFormats.TryParse` and add ISO…
carldebilly Feb 16, 2026
601ef02
Add command parsing, help display, and install enhancements to `tc-mcp`
carldebilly Feb 16, 2026
249be51
Improve CI to handle portable builds as `.zip` and update documentati…
carldebilly Feb 16, 2026
1f6a572
Add login and logout commands with secure token handling across OS ba…
carldebilly Feb 16, 2026
87183f1
Add `EntityCache` for ID-to-name resolution and enrich MCP tool outpu…
carldebilly Feb 16, 2026
aa7545a
Remove `[JsonIgnore]` attributes from `Id` properties in HTTP message…
carldebilly Feb 16, 2026
7448e7a
Add support for `JsonTcLeaseStatusConverter` and improve serialization
carldebilly Feb 16, 2026
fb60e5f
Add support for `user_client_id` and `user_payer_id` references in MC…
carldebilly Feb 16, 2026
49bcd0e
- Refactor `ToSerializedString` to use `switch` expression for cleane…
carldebilly Feb 16, 2026
103abe9
Add `MoveOutDate` to `TcLease` and update `SchemaResource` documentation
carldebilly Feb 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 114 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,13 +127,125 @@ jobs:
--api-key ${{ secrets.NUGET_API_KEY }}
--skip-duplicate

publish-binaries:
name: Publish Binaries
needs: build-test-pack
runs-on: ubuntu-latest
strategy:
matrix:
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
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 }}
if: matrix.rid != 'any'
run: >-
dotnet publish
src/Yllibed.TenantCloudClient.Mcp/Yllibed.TenantCloudClient.Mcp.csproj
-c Release
-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:
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-//')
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##*.}"
if [ "$ext" = "pdb" ] || [ "$ext" = "xml" ]; then
continue
fi
if [ "$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' || '' }}
204 changes: 24 additions & 180 deletions README.md
Original file line number Diff line number Diff line change
@@ -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<TcUserInfo?> 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<string?> 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<ITcAuthTokenProvider, MyCustomTokenProvider>();
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<TcTokenSet?> 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<ITcTokenStore>(new FileTokenStore("/path/to/tokens.json"));
```

### Custom store

Implement `ITcTokenStore` to persist tokens wherever you need (database, Azure Key Vault, etc.):

```csharp
services.AddSingleton<ITcTokenStore, MyDatabaseTokenStore>();
// 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<TcUserInfo?>` | Current signed-in user info |
| `Contacts` | `IPaginatedSource<TcContact>` | Contacts (tenants, professionals) |
| `Properties` | `IPaginatedSource<TcProperty>` | Properties |
| `Units` | `IPaginatedSource<TcUnit>` | Rental units |
| `Transactions` | `IPaginatedSource<TcTransaction>` | Financial transactions |
| `Leases` | `IPaginatedSource<TcLease>` | Leases |

### Paginated sources
### MCP server (binary)

Each collection is an `IPaginatedSource<T>`. 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<T>` (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

Expand Down
Loading