this repo has no description
20
fork

Configure Feed

Select the types of activity you want to include in your feed.

release: 0.14.1

+2843 -64
+99
.github/workflows/release-binaries.yml
··· 1 + name: Release binaries 2 + 3 + on: 4 + workflow_dispatch: 5 + push: 6 + branches: 7 + - main 8 + tags: 9 + - "v*" 10 + 11 + permissions: 12 + contents: write 13 + 14 + jobs: 15 + build: 16 + strategy: 17 + matrix: 18 + target: 19 + - name: x86_64-unknown-linux-gnu 20 + os: ubuntu-latest 21 + suffix: linux-amd64 22 + - name: aarch64-unknown-linux-gnu 23 + os: ubuntu-latest 24 + suffix: linux-arm64 25 + cross: true 26 + - name: x86_64-apple-darwin 27 + os: macos-latest 28 + suffix: darwin-amd64 29 + - name: aarch64-apple-darwin 30 + os: macos-latest 31 + suffix: darwin-arm64 32 + binary: 33 + - name: atpcid 34 + package: atproto-dasl 35 + features: clap 36 + - name: atpmcp 37 + package: atpmcp 38 + features: "" 39 + - name: atpxrpc 40 + package: atpxrpc 41 + features: "" 42 + 43 + runs-on: ${{ matrix.target.os }} 44 + 45 + steps: 46 + - uses: actions/checkout@v4 47 + 48 + - name: Install Rust toolchain 49 + uses: dtolnay/rust-toolchain@stable 50 + with: 51 + targets: ${{ matrix.target.name }} 52 + 53 + - uses: Swatinem/rust-cache@v2 54 + with: 55 + key: ${{ matrix.target.name }}-${{ matrix.binary.name }} 56 + 57 + - name: Install cross 58 + if: matrix.target.cross 59 + run: cargo install cross --git https://github.com/cross-rs/cross 60 + 61 + - name: Build 62 + run: | 63 + FEATURES="${{ matrix.binary.features }}" 64 + BUILD_CMD="cargo" 65 + if [ "${{ matrix.target.cross }}" = "true" ]; then 66 + BUILD_CMD="cross" 67 + fi 68 + if [ -n "$FEATURES" ]; then 69 + $BUILD_CMD build --release --target ${{ matrix.target.name }} --features "$FEATURES" --bin ${{ matrix.binary.name }} -p ${{ matrix.binary.package }} 70 + else 71 + $BUILD_CMD build --release --target ${{ matrix.target.name }} --bin ${{ matrix.binary.name }} -p ${{ matrix.binary.package }} 72 + fi 73 + 74 + - name: Package binary 75 + run: | 76 + cp target/${{ matrix.target.name }}/release/${{ matrix.binary.name }} ${{ matrix.binary.name }}-${{ matrix.target.suffix }} 77 + chmod +x ${{ matrix.binary.name }}-${{ matrix.target.suffix }} 78 + 79 + - uses: actions/upload-artifact@v4 80 + with: 81 + name: ${{ matrix.binary.name }}-${{ matrix.target.suffix }} 82 + path: ${{ matrix.binary.name }}-${{ matrix.target.suffix }} 83 + 84 + release: 85 + needs: build 86 + runs-on: ubuntu-latest 87 + if: startsWith(github.ref, 'refs/tags/') 88 + 89 + steps: 90 + - uses: actions/download-artifact@v4 91 + with: 92 + path: artifacts 93 + merge-multiple: true 94 + 95 + - name: Create release 96 + uses: softprops/action-gh-release@v2 97 + with: 98 + files: artifacts/* 99 + generate_release_notes: true
+13 -1
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.14.1] - 2026-03-08 11 + ### Added 12 + - `atpxrpc` CLI tool for persistent XRPC session management with proxy support 13 + - `--out` argument for atpxrpc proxy and base commands for output control 14 + - `--bytes` and `--manual` proxy flags to atpxrpc CLI 15 + - 5 new tools to atpmcp MCP server 16 + 17 + ### Changed 18 + - Updated GitHub Actions workflow to build atpxrpc and atpmcp release binaries 19 + - Updated README to reflect full Rust crates workspace 20 + 10 21 ## [0.14.0] - 2026-02-21 11 22 12 23 ## [0.13.0] - 2025-09-21 ··· 183 194 - Core DID document handling 184 195 - Cryptographic key operations for P-256 curves 185 196 197 + [0.14.1]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.14.1 186 198 [0.14.0]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.14.0 187 199 [0.13.0]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.13.0 188 200 [0.12.0]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.12.0 ··· 204 216 [0.7.0]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.7.0 205 217 [0.6.0]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.6.0 206 218 [0.5.0]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.5.0 207 - [0.4.0]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.4.0 219 + [0.4.0]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.4.0
+6
CLAUDE.md
··· 33 33 - **Verify records**: `cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- <record>` (verifies all signatures) 34 34 - **Verify attestation**: `cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- <record> <attestation>` (verifies specific attestation) 35 35 36 + #### DASL Operations 37 + - **Compute CID**: `cargo run --features clap --bin atpcid -- '<json_or_data>'` (auto-detects record vs raw CID; use `-` for stdin, `--raw` to force RAW CID, `--record` to force DAG-CBOR record CID) 38 + 36 39 #### Record Operations 37 40 - **Generate CID**: `cat record.json | cargo run --features clap --bin atproto-record-cid` (reads JSON from stdin, outputs CID) 38 41 ··· 156 159 - **`src/url.rs`**: URL utilities for AT Protocol services 157 160 158 161 ### CLI Tools (require --features clap) 162 + 163 + #### DASL Operations (atproto-dasl) 164 + - **`crates/atproto-dasl/src/bin/atpcid.rs`**: Compute CIDs from input data (auto-detects DAG-CBOR record vs RAW CID) 159 165 160 166 #### Identity Management (atproto-identity) 161 167 - **`src/bin/atproto-identity-resolve.rs`**: Resolve AT Protocol handles and DIDs to canonical identifiers
+103 -15
Cargo.lock
··· 109 109 checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 110 110 111 111 [[package]] 112 + name = "atpmcp" 113 + version = "0.14.1" 114 + dependencies = [ 115 + "anyhow", 116 + "async-trait", 117 + "atproto-client", 118 + "atproto-dasl", 119 + "atproto-extras", 120 + "atproto-identity", 121 + "atproto-lexicon", 122 + "atproto-record", 123 + "reqwest", 124 + "serde", 125 + "serde_json", 126 + "thiserror 2.0.18", 127 + "tokio", 128 + "tracing", 129 + "tracing-subscriber", 130 + ] 131 + 132 + [[package]] 112 133 name = "atproto-attestation" 113 - version = "0.14.0" 134 + version = "0.14.1" 114 135 dependencies = [ 115 136 "anyhow", 116 137 "async-trait", ··· 136 157 137 158 [[package]] 138 159 name = "atproto-client" 139 - version = "0.14.0" 160 + version = "0.14.1" 140 161 dependencies = [ 141 162 "anyhow", 142 163 "async-trait", ··· 160 181 161 182 [[package]] 162 183 name = "atproto-dasl" 163 - version = "0.14.0" 184 + version = "0.14.1" 164 185 dependencies = [ 165 186 "anyhow", 166 187 "axum", ··· 188 209 189 210 [[package]] 190 211 name = "atproto-extras" 191 - version = "0.14.0" 212 + version = "0.14.1" 192 213 dependencies = [ 193 214 "anyhow", 194 215 "async-trait", ··· 203 224 204 225 [[package]] 205 226 name = "atproto-identity" 206 - version = "0.14.0" 227 + version = "0.14.1" 207 228 dependencies = [ 208 229 "anyhow", 209 230 "async-trait", ··· 234 255 235 256 [[package]] 236 257 name = "atproto-jetstream" 237 - version = "0.14.0" 258 + version = "0.14.1" 238 259 dependencies = [ 239 260 "anyhow", 240 261 "async-trait", ··· 256 277 257 278 [[package]] 258 279 name = "atproto-lexicon" 259 - version = "0.14.0" 280 + version = "0.14.1" 260 281 dependencies = [ 261 282 "anyhow", 262 283 "async-trait", ··· 281 302 282 303 [[package]] 283 304 name = "atproto-oauth" 284 - version = "0.14.0" 305 + version = "0.14.1" 285 306 dependencies = [ 286 307 "anyhow", 287 308 "async-trait", ··· 312 333 313 334 [[package]] 314 335 name = "atproto-oauth-aip" 315 - version = "0.14.0" 336 + version = "0.14.1" 316 337 dependencies = [ 317 338 "anyhow", 318 339 "atproto-identity", ··· 327 348 328 349 [[package]] 329 350 name = "atproto-oauth-axum" 330 - version = "0.14.0" 351 + version = "0.14.1" 331 352 dependencies = [ 332 353 "anyhow", 333 354 "async-trait", ··· 356 377 357 378 [[package]] 358 379 name = "atproto-record" 359 - version = "0.14.0" 380 + version = "0.14.1" 360 381 dependencies = [ 361 382 "anyhow", 362 383 "async-trait", ··· 377 398 378 399 [[package]] 379 400 name = "atproto-repo" 380 - version = "0.14.0" 401 + version = "0.14.1" 381 402 dependencies = [ 382 403 "anyhow", 383 404 "async-trait", ··· 400 421 401 422 [[package]] 402 423 name = "atproto-tap" 403 - version = "0.14.0" 424 + version = "0.14.1" 404 425 dependencies = [ 405 426 "atproto-client", 406 427 "atproto-identity", ··· 423 444 424 445 [[package]] 425 446 name = "atproto-xrpcs" 426 - version = "0.14.0" 447 + version = "0.14.1" 427 448 dependencies = [ 428 449 "anyhow", 429 450 "async-trait", ··· 449 470 450 471 [[package]] 451 472 name = "atproto-xrpcs-helloworld" 452 - version = "0.14.0" 473 + version = "0.14.1" 453 474 dependencies = [ 454 475 "anyhow", 455 476 "async-trait", ··· 475 496 ] 476 497 477 498 [[package]] 499 + name = "atpxrpc" 500 + version = "0.14.1" 501 + dependencies = [ 502 + "anyhow", 503 + "atproto-client", 504 + "atproto-identity", 505 + "bytes", 506 + "clap", 507 + "dirs", 508 + "reqwest", 509 + "rpassword", 510 + "secrecy", 511 + "serde", 512 + "serde_json", 513 + "thiserror 2.0.18", 514 + "tokio", 515 + ] 516 + 517 + [[package]] 478 518 name = "autocfg" 479 519 version = "1.5.0" 480 520 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 999 1039 ] 1000 1040 1001 1041 [[package]] 1042 + name = "dirs" 1043 + version = "6.0.0" 1044 + source = "registry+https://github.com/rust-lang/crates.io-index" 1045 + checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" 1046 + dependencies = [ 1047 + "dirs-sys", 1048 + ] 1049 + 1050 + [[package]] 1051 + name = "dirs-sys" 1052 + version = "0.5.0" 1053 + source = "registry+https://github.com/rust-lang/crates.io-index" 1054 + checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" 1055 + dependencies = [ 1056 + "libc", 1057 + "option-ext", 1058 + "redox_users", 1059 + "windows-sys 0.61.2", 1060 + ] 1061 + 1062 + [[package]] 1002 1063 name = "displaydoc" 1003 1064 version = "0.2.5" 1004 1065 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1733 1794 checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" 1734 1795 1735 1796 [[package]] 1797 + name = "libredox" 1798 + version = "0.1.12" 1799 + source = "registry+https://github.com/rust-lang/crates.io-index" 1800 + checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" 1801 + dependencies = [ 1802 + "bitflags", 1803 + "libc", 1804 + ] 1805 + 1806 + [[package]] 1736 1807 name = "linux-raw-sys" 1737 1808 version = "0.11.0" 1738 1809 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1917 1988 version = "0.2.1" 1918 1989 source = "registry+https://github.com/rust-lang/crates.io-index" 1919 1990 checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" 1991 + 1992 + [[package]] 1993 + name = "option-ext" 1994 + version = "0.2.0" 1995 + source = "registry+https://github.com/rust-lang/crates.io-index" 1996 + checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 1920 1997 1921 1998 [[package]] 1922 1999 name = "p256" ··· 2271 2348 checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 2272 2349 dependencies = [ 2273 2350 "bitflags", 2351 + ] 2352 + 2353 + [[package]] 2354 + name = "redox_users" 2355 + version = "0.5.2" 2356 + source = "registry+https://github.com/rust-lang/crates.io-index" 2357 + checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" 2358 + dependencies = [ 2359 + "getrandom 0.2.17", 2360 + "libredox", 2361 + "thiserror 2.0.18", 2274 2362 ] 2275 2363 2276 2364 [[package]]
+19 -13
Cargo.toml
··· 1 1 [workspace] 2 2 members = [ 3 + "crates/atpmcp", 3 4 "crates/atproto-client", 4 5 "crates/atproto-dasl", 5 6 "crates/atproto-extras", ··· 15 16 "crates/atproto-xrpcs", 16 17 "crates/atproto-lexicon", 17 18 "crates/atproto-attestation", 19 + "crates/atpxrpc", 18 20 ] 19 21 exclude = [ 20 22 "crates/atproto-dasl/fuzz", ··· 31 33 categories = ["command-line-utilities", "web-programming"] 32 34 33 35 [workspace.dependencies] 34 - atproto-attestation = { version = "0.14.0", path = "crates/atproto-attestation" } 35 - atproto-client = { version = "0.14.0", path = "crates/atproto-client" } 36 - atproto-dasl = { version = "0.14.0", path = "crates/atproto-dasl" } 37 - atproto-extras = { version = "0.14.0", path = "crates/atproto-extras" } 38 - atproto-identity = { version = "0.14.0", path = "crates/atproto-identity" } 39 - atproto-jetstream = { version = "0.14.0", path = "crates/atproto-jetstream" } 40 - atproto-oauth = { version = "0.14.0", path = "crates/atproto-oauth" } 41 - atproto-oauth-aip = { version = "0.14.0", path = "crates/atproto-oauth-aip" } 42 - atproto-oauth-axum = { version = "0.14.0", path = "crates/atproto-oauth-axum" } 43 - atproto-record = { version = "0.14.0", path = "crates/atproto-record" } 44 - atproto-repo = { version = "0.14.0", path = "crates/atproto-repo" } 45 - atproto-tap = { version = "0.14.0", path = "crates/atproto-tap" } 46 - atproto-xrpcs = { version = "0.14.0", path = "crates/atproto-xrpcs" } 36 + atproto-attestation = { version = "0.14.1", path = "crates/atproto-attestation" } 37 + atproto-client = { version = "0.14.1", path = "crates/atproto-client" } 38 + atproto-dasl = { version = "0.14.1", path = "crates/atproto-dasl" } 39 + atproto-extras = { version = "0.14.1", path = "crates/atproto-extras" } 40 + atproto-identity = { version = "0.14.1", path = "crates/atproto-identity" } 41 + atproto-jetstream = { version = "0.14.1", path = "crates/atproto-jetstream" } 42 + atproto-lexicon = { version = "0.14.1", path = "crates/atproto-lexicon" } 43 + atproto-oauth = { version = "0.14.1", path = "crates/atproto-oauth" } 44 + atproto-oauth-aip = { version = "0.14.1", path = "crates/atproto-oauth-aip" } 45 + atproto-oauth-axum = { version = "0.14.1", path = "crates/atproto-oauth-axum" } 46 + atproto-record = { version = "0.14.1", path = "crates/atproto-record" } 47 + atproto-repo = { version = "0.14.1", path = "crates/atproto-repo" } 48 + atproto-tap = { version = "0.14.1", path = "crates/atproto-tap" } 49 + atproto-xrpcs = { version = "0.14.1", path = "crates/atproto-xrpcs" } 50 + 51 + atpxrpc = { version = "0.14.1", path = "crates/atpxrpc" } 47 52 48 53 bitflags = "2" 49 54 anyhow = "1.0" ··· 58 63 criterion = { version = "0.5", features = ["html_reports"] } 59 64 compact_str = { version = "0.8", features = ["serde"] } 60 65 data-encoding = "2.5" 66 + dirs = "6" 61 67 ecdsa = { version = "0.16", features = ["std"] } 62 68 elliptic-curve = { version = "0.13", features = ["jwk", "serde"] } 63 69 futures = "0.3"
+2 -2
Dockerfile
··· 73 73 LABEL org.opencontainers.image.description="AT Protocol identity management tools" 74 74 LABEL org.opencontainers.image.authors="Nick Gerakines <nick.gerakines@gmail.com>" 75 75 LABEL org.opencontainers.image.source="https://tangled.org/ngerakines.me/atproto-crates" 76 - LABEL org.opencontainers.image.version="0.14.0" 76 + LABEL org.opencontainers.image.version="0.14.1" 77 77 LABEL org.opencontainers.image.licenses="MIT" 78 78 79 79 # Document available binaries 80 - LABEL binaries="atproto-identity-resolve,atproto-identity-key,atproto-identity-sign,atproto-identity-validate,atproto-attestation-sign,atproto-attestation-verify,atproto-record-cid,atproto-client-auth,atproto-client-app-password,atproto-client-dpop,atproto-oauth-service-token,atproto-oauth-tool,atproto-jetstream-consumer,atproto-xrpcs-helloworld,atproto-lexicon-resolve" 80 + LABEL binaries="atproto-identity-resolve,atproto-identity-key,atproto-identity-sign,atproto-identity-validate,atproto-attestation-sign,atproto-attestation-verify,atproto-record-cid,atproto-client-auth,atproto-client-app-password,atproto-client-dpop,atproto-oauth-service-token,atproto-oauth-tool,atproto-jetstream-consumer,atproto-xrpcs-helloworld,atproto-lexicon-resolve"
+26 -15
README.md
··· 1 - # AT Protocol Identity Rust Components 1 + # AT Protocol Rust Crates 2 2 3 - A comprehensive collection of Rust components for creating AT Protocol applications. This workspace provides essential building blocks for content-addressed data, identity management, repository operations, record signing, OAuth 2.0 authentication flows, HTTP client operations, XRPC services, and real-time event streaming. 3 + A Rust workspace of crates for building AT Protocol applications. This collection provides building blocks for content-addressed data, identity management, repository operations, record signing, OAuth 2.0 authentication flows, HTTP client operations, XRPC services, real-time event streaming, and developer tooling. 4 4 5 5 **Origin**: Parts of this project were extracted from the open-source [smokesignal.events](https://tangled.sh/@smokesignal.events/smokesignal) project, an AT Protocol event and RSVP management application. This library is released under the MIT license to enable broader AT Protocol ecosystem development. 6 6 7 7 ## Components 8 8 9 - This workspace contains 15 specialized crates that work together to provide complete AT Protocol application development capabilities: 9 + This workspace contains 17 crates that work together to provide complete AT Protocol application development capabilities: 10 10 11 11 ### Data Foundations 12 12 ··· 34 34 - **[`atproto-client`](crates/atproto-client/)** - HTTP client library supporting multiple authentication methods (DPoP, Bearer tokens, sessions) with native XRPC protocol operations and repository management. *Includes 4 CLI tools.* 35 35 - **[`atproto-xrpcs`](crates/atproto-xrpcs/)** - XRPC service framework providing JWT authorization extractors, DID resolution integration, and Axum middleware for building AT Protocol services. 36 36 - **[`atproto-xrpcs-helloworld`](crates/atproto-xrpcs-helloworld/)** - Complete example XRPC service demonstrating DID:web identity, service document generation, and JWT authentication patterns. *Includes 1 service binary.* 37 + - **[`atpxrpc`](crates/atpxrpc/)** - XRPC CLI client with persistent session management for making authenticated AT Protocol API calls. *Includes 1 CLI tool.* 37 38 38 39 ### Real-time Event Processing 39 40 40 41 - **[`atproto-jetstream`](crates/atproto-jetstream/)** - WebSocket consumer for AT Protocol Jetstream events with Zstandard compression, automatic reconnection, and configurable event filtering. *Includes 1 CLI tool.* 41 42 - **[`atproto-tap`](crates/atproto-tap/)** - TAP (Trusted Attestation Protocol) service consumer for filtered, verified AT Protocol repository events with MST integrity checks, automatic backfill, and acknowledgment-based delivery. *Includes 2 CLI tools.* 42 43 44 + ### Developer Tools 45 + 46 + - **[`atpmcp`](crates/atpmcp/)** - MCP (Model Context Protocol) server for AT Protocol DAG-CBOR CID generation, enabling AI assistants to compute content identifiers for records. *Includes 1 server binary.* 47 + 43 48 ### Utilities 44 49 45 50 - **[`atproto-extras`](crates/atproto-extras/)** - Facet parsing and rich text utilities for AT Protocol, extracting mentions, URLs, and hashtags from plain text with correct UTF-8 byte offset calculation. *Includes 1 CLI tool.* ··· 50 55 51 56 ```toml 52 57 [dependencies] 53 - atproto-dasl = "0.14.0" 54 - atproto-identity = "0.14.0" 55 - atproto-attestation = "0.14.0" 56 - atproto-record = "0.14.0" 57 - atproto-repo = "0.14.0" 58 - atproto-lexicon = "0.14.0" 59 - atproto-oauth = "0.14.0" 60 - atproto-oauth-aip = "0.14.0" 61 - atproto-client = "0.14.0" 62 - atproto-extras = "0.14.0" 63 - atproto-tap = "0.14.0" 58 + atproto-dasl = "0.14.1" 59 + atproto-identity = "0.14.1" 60 + atproto-attestation = "0.14.1" 61 + atproto-record = "0.14.1" 62 + atproto-repo = "0.14.1" 63 + atproto-lexicon = "0.14.1" 64 + atproto-oauth = "0.14.1" 65 + atproto-oauth-aip = "0.14.1" 66 + atproto-client = "0.14.1" 67 + atproto-extras = "0.14.1" 68 + atproto-tap = "0.14.1" 64 69 # Add others as needed 65 70 ``` 66 71 ··· 235 240 236 241 ## Command Line Tools 237 242 238 - The workspace includes 23 command-line tools across 11 crates, providing ready-to-use utilities for AT Protocol development and testing. Most CLI tools require the `clap` feature: 243 + The workspace includes command-line tools and server binaries across the crates, providing ready-to-use utilities for AT Protocol development and testing. Most CLI tools require the `clap` feature: 239 244 240 245 ```bash 241 246 # Build with CLI support ··· 290 295 291 296 # Rich text utilities (atproto-extras crate) 292 297 cargo run --package atproto-extras --features clap,cli,hickory-dns --bin atproto-extras-parse-facets -- "Hello @alice.bsky.social" 298 + 299 + # XRPC CLI client (atpxrpc crate) 300 + cargo run --package atpxrpc --bin atpxrpc -- --help 301 + 302 + # MCP server (atpmcp crate) 303 + cargo build -p atpmcp 293 304 ``` 294 305 295 306 ## Development
+41
crates/atpmcp/Cargo.toml
··· 1 + [package] 2 + name = "atpmcp" 3 + version = "0.14.1" 4 + description = "AT Protocol MCP server for DAG-CBOR CID generation" 5 + homepage = "https://tangled.org/ngerakines.me/atproto-crates" 6 + documentation = "https://docs.rs/atpmcp" 7 + 8 + edition.workspace = true 9 + rust-version.workspace = true 10 + authors.workspace = true 11 + repository.workspace = true 12 + license.workspace = true 13 + keywords.workspace = true 14 + categories.workspace = true 15 + 16 + [[bin]] 17 + name = "atpmcp" 18 + path = "src/main.rs" 19 + bench = false 20 + doc = true 21 + 22 + [dependencies] 23 + atproto-client.workspace = true 24 + atproto-dasl.workspace = true 25 + atproto-extras = { workspace = true, features = ["hickory-dns"] } 26 + atproto-identity = { workspace = true, features = ["hickory-dns"] } 27 + atproto-lexicon.workspace = true 28 + atproto-record.workspace = true 29 + 30 + anyhow.workspace = true 31 + async-trait.workspace = true 32 + reqwest.workspace = true 33 + serde.workspace = true 34 + serde_json.workspace = true 35 + thiserror.workspace = true 36 + tokio.workspace = true 37 + tracing.workspace = true 38 + tracing-subscriber.workspace = true 39 + 40 + [lints] 41 + workspace = true
+77
crates/atpmcp/README.md
··· 1 + # atpmcp 2 + 3 + A local MCP (Model Context Protocol) server for AT Protocol DAG-CBOR CID generation. 4 + 5 + ## Overview 6 + 7 + A standalone MCP server binary that communicates over stdio using JSON-RPC 2.0. It provides a `create_record_cid` tool that accepts a JSON object, serializes it to DAG-CBOR, hashes it with SHA-256, and returns the CIDv1 string. This allows AI assistants like Claude to compute content identifiers for AT Protocol records directly. 8 + 9 + ## Tools 10 + 11 + - **`create_record_cid`**: Accepts a JSON record object and returns its DAG-CBOR CID. The record is serialized using deterministic DAG-CBOR encoding, hashed with SHA-256, and returned as a CIDv1 base32 string. 12 + 13 + ## Building 14 + 15 + ```bash 16 + cargo build -p atpmcp 17 + ``` 18 + 19 + ## Claude Code Integration 20 + 21 + Add the following to your Claude Code MCP configuration (`~/.claude/claude_code_config.json`): 22 + 23 + ```json 24 + { 25 + "mcpServers": { 26 + "atpmcp": { 27 + "command": "/path/to/atpmcp" 28 + } 29 + } 30 + } 31 + ``` 32 + 33 + Replace `/path/to/atpmcp` with the absolute path to the compiled binary (e.g., `target/release/atpmcp` after running `cargo build -p atpmcp --release`). 34 + 35 + Or, if `atpmcp` is already in your PATH: 36 + 37 + ```bash 38 + tmp=$(jq '.mcpServers.atpmcp = {"command": "atpmcp"}' ~/.claude/claude_code_config.json) && echo "$tmp" > ~/.claude/claude_code_config.json 39 + ``` 40 + 41 + Once configured, Claude Code will have access to the `create_record_cid` tool and can compute CIDs for AT Protocol records during conversations. 42 + 43 + ### Example usage in Claude Code 44 + 45 + Ask Claude to compute a CID: 46 + 47 + > What is the CID for this AT Protocol record? 48 + > ```json 49 + > { 50 + > "$type": "app.bsky.feed.post", 51 + > "text": "Hello AT Protocol!", 52 + > "createdAt": "2024-01-01T00:00:00.000Z" 53 + > } 54 + > ``` 55 + 56 + Claude will call the `create_record_cid` tool and return the CID string. 57 + 58 + ## Protocol Details 59 + 60 + The server implements the MCP stdio transport: 61 + 62 + - **stdin**: Receives newline-delimited JSON-RPC 2.0 messages 63 + - **stdout**: Sends newline-delimited JSON-RPC 2.0 responses 64 + - **stderr**: Tracing/logging output (controlled by `RUST_LOG` environment variable) 65 + 66 + Supported methods: 67 + 68 + | Method | Description | 69 + |--------|-------------| 70 + | `initialize` | MCP handshake, returns server capabilities | 71 + | `ping` | Health check | 72 + | `tools/list` | Lists available tools | 73 + | `tools/call` | Invokes a tool by name | 74 + 75 + ## License 76 + 77 + MIT License
+52
crates/atpmcp/src/errors.rs
··· 1 + //! Structured error types for atpmcp MCP server operations. 2 + //! 3 + //! All errors follow the project convention of prefixed error codes 4 + //! with descriptive messages: `error-atpmcp-{domain}-{number}` 5 + 6 + use thiserror::Error; 7 + 8 + /// Errors that can occur during tool operations. 9 + #[derive(Debug, Error)] 10 + pub enum ToolError { 11 + /// DAG-CBOR serialization of the JSON value failed. 12 + #[error("error-atpmcp-tool-1 Failed to serialize record to DAG-CBOR: {reason}")] 13 + SerializationFailed { 14 + /// Description of the serialization failure. 15 + reason: String, 16 + }, 17 + 18 + /// Lexicon schema validation failed. 19 + #[error("error-atpmcp-tool-2 Lexicon schema validation failed: {reason}")] 20 + ValidationFailed { 21 + /// Description of the validation failure. 22 + reason: String, 23 + }, 24 + 25 + /// Handle resolution failed. 26 + #[error("error-atpmcp-tool-3 Handle resolution failed: {reason}")] 27 + HandleResolutionFailed { 28 + /// Description of the resolution failure. 29 + reason: String, 30 + }, 31 + 32 + /// Identity resolution failed. 33 + #[error("error-atpmcp-tool-4 Identity resolution failed: {reason}")] 34 + IdentityResolutionFailed { 35 + /// Description of the resolution failure. 36 + reason: String, 37 + }, 38 + 39 + /// Facet parsing failed. 40 + #[error("error-atpmcp-tool-5 Facet parsing failed: {reason}")] 41 + FacetParsingFailed { 42 + /// Description of the parsing failure. 43 + reason: String, 44 + }, 45 + 46 + /// Record retrieval failed. 47 + #[error("error-atpmcp-tool-6 Record retrieval failed: {reason}")] 48 + RecordRetrievalFailed { 49 + /// Description of the retrieval failure. 50 + reason: String, 51 + }, 52 + }
+965
crates/atpmcp/src/main.rs
··· 1 + //! AT Protocol MCP server for identity, record, and lexicon operations. 2 + //! 3 + //! This binary implements a Model Context Protocol (MCP) server that 4 + //! communicates over stdio using JSON-RPC 2.0. It provides tools for 5 + //! CID computation, lexicon validation, identity resolution, facet 6 + //! parsing, and record retrieval. 7 + //! 8 + //! # MCP Protocol 9 + //! 10 + //! The server uses the MCP stdio transport: 11 + //! - stdin: receives newline-delimited JSON-RPC 2.0 messages 12 + //! - stdout: sends newline-delimited JSON-RPC 2.0 responses 13 + //! - stderr: used for tracing/logging output 14 + //! 15 + //! # Tools 16 + //! 17 + //! - `create_record_cid` — Compute the DAG-CBOR CID for a JSON record 18 + //! - `validate_lexicon_schema` — Validate a lexicon schema object 19 + //! - `resolve_handle_to_did` — Resolve an AT Protocol handle to a DID 20 + //! - `resolve_identity` — Resolve a DID to its full DID document 21 + //! - `parse_facets` — Parse rich text facets from plain text 22 + //! - `get_record` — Retrieve an AT Protocol record by AT-URI 23 + 24 + mod errors; 25 + 26 + use std::io::{BufRead, BufReader, Write}; 27 + use std::sync::Arc; 28 + 29 + use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver}; 30 + use errors::ToolError; 31 + use serde::{Deserialize, Serialize}; 32 + use serde_json::Value; 33 + use tracing_subscriber::EnvFilter; 34 + 35 + /// MCP protocol version supported by this server. 36 + const PROTOCOL_VERSION: &str = "2025-11-25"; 37 + 38 + /// JSON-RPC error code for parse errors. 39 + const PARSE_ERROR: i64 = -32700; 40 + 41 + /// JSON-RPC error code for invalid requests. 42 + const INVALID_REQUEST: i64 = -32600; 43 + 44 + /// JSON-RPC error code for method not found. 45 + const METHOD_NOT_FOUND: i64 = -32601; 46 + 47 + /// JSON-RPC error code for invalid params. 48 + const INVALID_PARAMS: i64 = -32602; 49 + 50 + // -- JSON-RPC types -- 51 + 52 + /// An incoming JSON-RPC 2.0 request or notification. 53 + #[derive(Debug, Deserialize)] 54 + struct JsonRpcMessage { 55 + /// Must be "2.0". 56 + #[allow(dead_code)] 57 + jsonrpc: String, 58 + /// Request ID. Absent for notifications. 59 + id: Option<Value>, 60 + /// Method name. 61 + method: String, 62 + /// Optional parameters. 63 + params: Option<Value>, 64 + } 65 + 66 + /// An outgoing JSON-RPC 2.0 response. 67 + #[derive(Debug, Serialize)] 68 + struct JsonRpcResponse { 69 + jsonrpc: &'static str, 70 + id: Value, 71 + #[serde(skip_serializing_if = "Option::is_none")] 72 + result: Option<Value>, 73 + #[serde(skip_serializing_if = "Option::is_none")] 74 + error: Option<JsonRpcError>, 75 + } 76 + 77 + /// A JSON-RPC 2.0 error object. 78 + #[derive(Debug, Serialize)] 79 + struct JsonRpcError { 80 + code: i64, 81 + message: String, 82 + #[serde(skip_serializing_if = "Option::is_none")] 83 + data: Option<Value>, 84 + } 85 + 86 + impl JsonRpcResponse { 87 + /// Create a success response. 88 + fn success(id: Value, result: Value) -> Self { 89 + Self { 90 + jsonrpc: "2.0", 91 + id, 92 + result: Some(result), 93 + error: None, 94 + } 95 + } 96 + 97 + /// Create an error response. 98 + fn error(id: Value, code: i64, message: impl Into<String>) -> Self { 99 + Self { 100 + jsonrpc: "2.0", 101 + id, 102 + result: None, 103 + error: Some(JsonRpcError { 104 + code, 105 + message: message.into(), 106 + data: None, 107 + }), 108 + } 109 + } 110 + } 111 + 112 + // -- MCP handlers -- 113 + 114 + /// Handle the `initialize` request. 115 + fn handle_initialize() -> Value { 116 + serde_json::json!({ 117 + "protocolVersion": PROTOCOL_VERSION, 118 + "capabilities": { 119 + "tools": {} 120 + }, 121 + "serverInfo": { 122 + "name": "atpmcp", 123 + "version": env!("CARGO_PKG_VERSION") 124 + } 125 + }) 126 + } 127 + 128 + /// Handle the `tools/list` request. 129 + fn handle_tools_list() -> Value { 130 + serde_json::json!({ 131 + "tools": [ 132 + { 133 + "name": "create_record_cid", 134 + "description": "Compute the DAG-CBOR CID for a JSON record. Accepts a JSON object, serializes it to DAG-CBOR, hashes with SHA-256, and returns the CIDv1 string.", 135 + "inputSchema": { 136 + "type": "object", 137 + "properties": { 138 + "record": { 139 + "type": "object", 140 + "description": "The JSON record object to compute a CID for." 141 + } 142 + }, 143 + "required": ["record"], 144 + "additionalProperties": false 145 + } 146 + }, 147 + { 148 + "name": "validate_lexicon_schema", 149 + "description": "Validate a lexicon schema object. Accepts a JSON object representing a lexicon schema and validates its structure, version, NSID, and definitions.", 150 + "inputSchema": { 151 + "type": "object", 152 + "properties": { 153 + "schema": { 154 + "type": "object", 155 + "description": "The lexicon schema to validate." 156 + } 157 + }, 158 + "required": ["schema"], 159 + "additionalProperties": false 160 + } 161 + }, 162 + { 163 + "name": "resolve_handle_to_did", 164 + "description": "Resolve an AT Protocol handle to its DID. Accepts a handle string (e.g. 'alice.bsky.social') and returns the resolved DID (e.g. 'did:plc:abc123').", 165 + "inputSchema": { 166 + "type": "object", 167 + "properties": { 168 + "handle": { 169 + "type": "string", 170 + "description": "The AT Protocol handle to resolve." 171 + } 172 + }, 173 + "required": ["handle"], 174 + "additionalProperties": false 175 + } 176 + }, 177 + { 178 + "name": "resolve_identity", 179 + "description": "Resolve a DID to its full DID document. Accepts a DID string and returns the complete DID document as JSON.", 180 + "inputSchema": { 181 + "type": "object", 182 + "properties": { 183 + "did": { 184 + "type": "string", 185 + "description": "The DID to resolve." 186 + }, 187 + "plc_directory_hostname": { 188 + "type": "string", 189 + "description": "PLC directory hostname override, defaults to 'plc.directory'." 190 + } 191 + }, 192 + "required": ["did"], 193 + "additionalProperties": false 194 + } 195 + }, 196 + { 197 + "name": "parse_facets", 198 + "description": "Parse rich text facets (mentions, URLs, and hashtags) from plain text. Returns AT Protocol facets with correct UTF-8 byte offsets. Mentions are resolved to DIDs when possible.", 199 + "inputSchema": { 200 + "type": "object", 201 + "properties": { 202 + "text": { 203 + "type": "string", 204 + "description": "The plain text to parse for facets." 205 + } 206 + }, 207 + "required": ["text"], 208 + "additionalProperties": false 209 + } 210 + }, 211 + { 212 + "name": "get_record", 213 + "description": "Retrieve an AT Protocol record by AT-URI. Resolves the identity, finds the PDS endpoint, and retrieves the record using com.atproto.repo.getRecord with unauthenticated access.", 214 + "inputSchema": { 215 + "type": "object", 216 + "properties": { 217 + "uri": { 218 + "type": "string", 219 + "description": "The AT-URI of the record to retrieve (e.g. 'at://did:plc:abc123/app.bsky.feed.post/rkey')." 220 + }, 221 + "cid": { 222 + "type": "string", 223 + "description": "Specific version CID to retrieve." 224 + }, 225 + "plc_directory_hostname": { 226 + "type": "string", 227 + "description": "PLC directory hostname override, defaults to 'plc.directory'." 228 + } 229 + }, 230 + "required": ["uri"], 231 + "additionalProperties": false 232 + } 233 + } 234 + ] 235 + }) 236 + } 237 + 238 + /// Handle the `tools/call` request. 239 + async fn handle_tools_call( 240 + id: Value, 241 + params: Option<Value>, 242 + resolver: &InnerIdentityResolver, 243 + ) -> JsonRpcResponse { 244 + let Some(params) = params else { 245 + return JsonRpcResponse::error(id, INVALID_PARAMS, "Missing params"); 246 + }; 247 + 248 + let Some(name) = params.get("name").and_then(Value::as_str) else { 249 + return JsonRpcResponse::error(id, INVALID_PARAMS, "Missing tool name"); 250 + }; 251 + 252 + let arguments = params.get("arguments").cloned().unwrap_or(Value::Null); 253 + 254 + match name { 255 + "create_record_cid" => JsonRpcResponse::success(id, handle_create_record_cid(arguments)), 256 + "validate_lexicon_schema" => { 257 + JsonRpcResponse::success(id, handle_validate_lexicon_schema(arguments)) 258 + } 259 + "resolve_handle_to_did" => { 260 + JsonRpcResponse::success(id, handle_resolve_handle_to_did(arguments, resolver).await) 261 + } 262 + "resolve_identity" => { 263 + JsonRpcResponse::success(id, handle_resolve_identity(arguments, resolver).await) 264 + } 265 + "parse_facets" => { 266 + JsonRpcResponse::success(id, handle_parse_facets(arguments, resolver).await) 267 + } 268 + "get_record" => JsonRpcResponse::success(id, handle_get_record(arguments, resolver).await), 269 + _ => JsonRpcResponse::error(id, METHOD_NOT_FOUND, format!("Unknown tool: {name}")), 270 + } 271 + } 272 + 273 + /// Execute the `create_record_cid` tool. 274 + fn handle_create_record_cid(arguments: Value) -> Value { 275 + let record = arguments.get("record").cloned().unwrap_or(Value::Null); 276 + 277 + if !record.is_object() { 278 + return tool_error("The 'record' argument must be a JSON object."); 279 + } 280 + 281 + match atproto_dasl::compute_cid_for(&record) { 282 + Ok(cid) => tool_success(&cid.to_string()), 283 + Err(error) => { 284 + let tool_err = ToolError::SerializationFailed { 285 + reason: error.to_string(), 286 + }; 287 + tracing::error!(error = ?error, "DAG-CBOR serialization failed."); 288 + tool_error(&tool_err.to_string()) 289 + } 290 + } 291 + } 292 + 293 + /// Execute the `validate_lexicon_schema` tool. 294 + fn handle_validate_lexicon_schema(arguments: Value) -> Value { 295 + let schema = arguments.get("schema").cloned().unwrap_or(Value::Null); 296 + 297 + if !schema.is_object() { 298 + return tool_error("The 'schema' argument must be a JSON object."); 299 + } 300 + 301 + match atproto_lexicon::validation::schema_file::SchemaFile::from_value(schema) { 302 + Ok(schema_file) => tool_success(&format!("Lexicon schema '{}' is valid.", schema_file.id)), 303 + Err(error) => { 304 + let tool_err = ToolError::ValidationFailed { 305 + reason: error.to_string(), 306 + }; 307 + tracing::error!(error = ?error, "Lexicon schema validation failed."); 308 + tool_error(&tool_err.to_string()) 309 + } 310 + } 311 + } 312 + 313 + /// Execute the `resolve_handle_to_did` tool. 314 + async fn handle_resolve_handle_to_did(arguments: Value, resolver: &InnerIdentityResolver) -> Value { 315 + let Some(handle) = arguments.get("handle").and_then(Value::as_str) else { 316 + return tool_error("The 'handle' argument must be a string."); 317 + }; 318 + 319 + match resolver.resolve(handle).await { 320 + Ok(document) => tool_success(&document.id), 321 + Err(error) => { 322 + let tool_err = ToolError::HandleResolutionFailed { 323 + reason: error.to_string(), 324 + }; 325 + tracing::error!(error = ?error, handle = %handle, "Handle resolution failed."); 326 + tool_error(&tool_err.to_string()) 327 + } 328 + } 329 + } 330 + 331 + /// Execute the `resolve_identity` tool. 332 + async fn handle_resolve_identity( 333 + arguments: Value, 334 + default_resolver: &InnerIdentityResolver, 335 + ) -> Value { 336 + let Some(did) = arguments.get("did").and_then(Value::as_str) else { 337 + return tool_error("The 'did' argument must be a string."); 338 + }; 339 + 340 + let plc_hostname = arguments 341 + .get("plc_directory_hostname") 342 + .and_then(Value::as_str) 343 + .unwrap_or("plc.directory"); 344 + 345 + let resolver = if plc_hostname == "plc.directory" { 346 + default_resolver 347 + } else { 348 + &InnerIdentityResolver { 349 + dns_resolver: default_resolver.dns_resolver.clone(), 350 + http_client: default_resolver.http_client.clone(), 351 + plc_hostname: plc_hostname.to_string(), 352 + } 353 + }; 354 + 355 + match resolver.resolve(did).await { 356 + Ok(document) => match serde_json::to_string(&document) { 357 + Ok(json) => tool_success(&json), 358 + Err(error) => { 359 + let tool_err = ToolError::IdentityResolutionFailed { 360 + reason: error.to_string(), 361 + }; 362 + tracing::error!(error = ?error, "Failed to serialize DID document."); 363 + tool_error(&tool_err.to_string()) 364 + } 365 + }, 366 + Err(error) => { 367 + let tool_err = ToolError::IdentityResolutionFailed { 368 + reason: error.to_string(), 369 + }; 370 + tracing::error!(error = ?error, did = %did, "Identity resolution failed."); 371 + tool_error(&tool_err.to_string()) 372 + } 373 + } 374 + } 375 + 376 + /// Execute the `parse_facets` tool. 377 + async fn handle_parse_facets(arguments: Value, resolver: &InnerIdentityResolver) -> Value { 378 + let Some(text) = arguments.get("text").and_then(Value::as_str) else { 379 + return tool_error("The 'text' argument must be a string."); 380 + }; 381 + 382 + let limits = atproto_extras::FacetLimits::default(); 383 + 384 + match atproto_extras::parse_facets_from_text(text, resolver, &limits).await { 385 + Some(facets) => match serde_json::to_string(&facets) { 386 + Ok(json) => tool_success(&json), 387 + Err(error) => { 388 + let tool_err = ToolError::FacetParsingFailed { 389 + reason: error.to_string(), 390 + }; 391 + tracing::error!(error = ?error, "Failed to serialize facets."); 392 + tool_error(&tool_err.to_string()) 393 + } 394 + }, 395 + None => tool_success("[]"), 396 + } 397 + } 398 + 399 + /// Execute the `get_record` tool. 400 + async fn handle_get_record(arguments: Value, default_resolver: &InnerIdentityResolver) -> Value { 401 + let Some(uri) = arguments.get("uri").and_then(Value::as_str) else { 402 + return tool_error("The 'uri' argument must be a string."); 403 + }; 404 + 405 + let cid = arguments.get("cid").and_then(Value::as_str); 406 + 407 + let plc_hostname = arguments 408 + .get("plc_directory_hostname") 409 + .and_then(Value::as_str) 410 + .unwrap_or("plc.directory"); 411 + 412 + let aturi = match uri.parse::<atproto_record::aturi::ATURI>() { 413 + Ok(aturi) => aturi, 414 + Err(error) => { 415 + let tool_err = ToolError::RecordRetrievalFailed { 416 + reason: format!("Invalid AT-URI: {error}"), 417 + }; 418 + tracing::error!(error = ?error, uri = %uri, "Failed to parse AT-URI."); 419 + return tool_error(&tool_err.to_string()); 420 + } 421 + }; 422 + 423 + let resolver = if plc_hostname == "plc.directory" { 424 + default_resolver 425 + } else { 426 + &InnerIdentityResolver { 427 + dns_resolver: default_resolver.dns_resolver.clone(), 428 + http_client: default_resolver.http_client.clone(), 429 + plc_hostname: plc_hostname.to_string(), 430 + } 431 + }; 432 + 433 + let document = match resolver.resolve(&aturi.authority).await { 434 + Ok(doc) => doc, 435 + Err(error) => { 436 + let tool_err = ToolError::RecordRetrievalFailed { 437 + reason: format!("Failed to resolve identity: {error}"), 438 + }; 439 + tracing::error!(error = ?error, authority = %aturi.authority, "Identity resolution failed."); 440 + return tool_error(&tool_err.to_string()); 441 + } 442 + }; 443 + 444 + let pds_endpoints = document.pds_endpoints(); 445 + let Some(pds_url) = pds_endpoints.first() else { 446 + let tool_err = ToolError::RecordRetrievalFailed { 447 + reason: "No PDS endpoint found in DID document".to_string(), 448 + }; 449 + tracing::error!(did = %aturi.authority, "No PDS endpoint found."); 450 + return tool_error(&tool_err.to_string()); 451 + }; 452 + 453 + match atproto_client::com::atproto::repo::get_record( 454 + &default_resolver.http_client, 455 + &atproto_client::client::Auth::None, 456 + pds_url, 457 + &aturi.authority, 458 + &aturi.collection, 459 + &aturi.record_key, 460 + cid, 461 + ) 462 + .await 463 + { 464 + Ok(atproto_client::com::atproto::repo::GetRecordResponse::Record { value, .. }) => { 465 + match serde_json::to_string(&value) { 466 + Ok(json) => tool_success(&json), 467 + Err(error) => { 468 + let tool_err = ToolError::RecordRetrievalFailed { 469 + reason: error.to_string(), 470 + }; 471 + tracing::error!(error = ?error, "Failed to serialize record."); 472 + tool_error(&tool_err.to_string()) 473 + } 474 + } 475 + } 476 + Ok(atproto_client::com::atproto::repo::GetRecordResponse::Error(err)) => { 477 + let error_str = err.error.as_deref().unwrap_or("unknown"); 478 + let message_str = err.message.as_deref().unwrap_or("unknown"); 479 + let tool_err = ToolError::RecordRetrievalFailed { 480 + reason: format!("{error_str}: {message_str}"), 481 + }; 482 + tracing::error!(error = %error_str, message = %message_str, "Record retrieval returned error."); 483 + tool_error(&tool_err.to_string()) 484 + } 485 + Err(error) => { 486 + let tool_err = ToolError::RecordRetrievalFailed { 487 + reason: error.to_string(), 488 + }; 489 + tracing::error!(error = ?error, uri = %uri, "Record retrieval failed."); 490 + tool_error(&tool_err.to_string()) 491 + } 492 + } 493 + } 494 + 495 + /// Build a successful tool call result. 496 + fn tool_success(text: &str) -> Value { 497 + serde_json::json!({ 498 + "content": [{ "type": "text", "text": text }], 499 + "isError": false 500 + }) 501 + } 502 + 503 + /// Build an error tool call result. 504 + fn tool_error(text: &str) -> Value { 505 + serde_json::json!({ 506 + "content": [{ "type": "text", "text": text }], 507 + "isError": true 508 + }) 509 + } 510 + 511 + /// Route an incoming JSON-RPC message and return an optional response. 512 + /// 513 + /// Returns `None` for notifications (messages without an `id`). 514 + async fn route_message( 515 + msg: JsonRpcMessage, 516 + resolver: &InnerIdentityResolver, 517 + ) -> Option<JsonRpcResponse> { 518 + let id = match msg.id { 519 + Some(id) => id, 520 + None => { 521 + // Notification - no response. 522 + tracing::debug!(method = %msg.method, "Received notification."); 523 + return None; 524 + } 525 + }; 526 + 527 + let response = match msg.method.as_str() { 528 + "initialize" => JsonRpcResponse::success(id, handle_initialize()), 529 + "ping" => JsonRpcResponse::success(id, serde_json::json!({})), 530 + "tools/list" => JsonRpcResponse::success(id, handle_tools_list()), 531 + "tools/call" => handle_tools_call(id, msg.params, resolver).await, 532 + _ => JsonRpcResponse::error( 533 + id, 534 + METHOD_NOT_FOUND, 535 + format!("Method not found: {}", msg.method), 536 + ), 537 + }; 538 + 539 + Some(response) 540 + } 541 + 542 + #[tokio::main] 543 + async fn main() -> anyhow::Result<()> { 544 + tracing_subscriber::fmt() 545 + .with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into())) 546 + .with_writer(std::io::stderr) 547 + .with_ansi(false) 548 + .init(); 549 + 550 + tracing::info!("Starting atpmcp MCP server"); 551 + 552 + let dns_resolver = HickoryDnsResolver::create_resolver(&[]); 553 + let http_client = reqwest::Client::new(); 554 + let resolver = InnerIdentityResolver { 555 + dns_resolver: Arc::new(dns_resolver), 556 + http_client, 557 + plc_hostname: "plc.directory".to_string(), 558 + }; 559 + 560 + let stdin = std::io::stdin(); 561 + let reader = BufReader::new(stdin.lock()); 562 + let mut stdout = std::io::stdout().lock(); 563 + 564 + for line in reader.lines() { 565 + let line = match line { 566 + Ok(l) => l, 567 + Err(error) => { 568 + tracing::error!(error = ?error, "Failed to read from stdin."); 569 + break; 570 + } 571 + }; 572 + 573 + let line = line.trim().to_string(); 574 + if line.is_empty() { 575 + continue; 576 + } 577 + 578 + let msg: JsonRpcMessage = match serde_json::from_str(&line) { 579 + Ok(m) => m, 580 + Err(error) => { 581 + tracing::warn!(error = ?error, "Failed to parse JSON-RPC message."); 582 + let response = JsonRpcResponse::error(Value::Null, PARSE_ERROR, "Parse error"); 583 + let out = serde_json::to_string(&response)?; 584 + writeln!(stdout, "{out}")?; 585 + stdout.flush()?; 586 + continue; 587 + } 588 + }; 589 + 590 + if msg.jsonrpc != "2.0" { 591 + let id = msg.id.unwrap_or(Value::Null); 592 + let response = JsonRpcResponse::error(id, INVALID_REQUEST, "Invalid JSON-RPC version"); 593 + let out = serde_json::to_string(&response)?; 594 + writeln!(stdout, "{out}")?; 595 + stdout.flush()?; 596 + continue; 597 + } 598 + 599 + if let Some(response) = route_message(msg, &resolver).await { 600 + let out = serde_json::to_string(&response)?; 601 + writeln!(stdout, "{out}")?; 602 + stdout.flush()?; 603 + } 604 + } 605 + 606 + tracing::info!("atpmcp MCP server shutting down"); 607 + Ok(()) 608 + } 609 + 610 + #[cfg(test)] 611 + mod tests { 612 + use super::*; 613 + 614 + fn create_test_resolver() -> InnerIdentityResolver { 615 + let dns_resolver = HickoryDnsResolver::create_resolver(&[]); 616 + InnerIdentityResolver { 617 + dns_resolver: Arc::new(dns_resolver), 618 + http_client: reqwest::Client::new(), 619 + plc_hostname: "plc.directory".to_string(), 620 + } 621 + } 622 + 623 + #[test] 624 + fn test_handle_initialize() { 625 + let result = handle_initialize(); 626 + assert_eq!(result["protocolVersion"], PROTOCOL_VERSION); 627 + assert_eq!(result["serverInfo"]["name"], "atpmcp"); 628 + assert!(result["capabilities"]["tools"].is_object()); 629 + } 630 + 631 + #[test] 632 + fn test_handle_tools_list() { 633 + let result = handle_tools_list(); 634 + let tools = result["tools"].as_array().unwrap(); 635 + assert_eq!(tools.len(), 6); 636 + assert_eq!(tools[0]["name"], "create_record_cid"); 637 + assert_eq!(tools[1]["name"], "validate_lexicon_schema"); 638 + assert_eq!(tools[2]["name"], "resolve_handle_to_did"); 639 + assert_eq!(tools[3]["name"], "resolve_identity"); 640 + assert_eq!(tools[4]["name"], "parse_facets"); 641 + assert_eq!(tools[5]["name"], "get_record"); 642 + for tool in tools { 643 + assert!(tool["inputSchema"].is_object()); 644 + } 645 + } 646 + 647 + // -- create_record_cid tests -- 648 + 649 + #[test] 650 + fn test_create_record_cid_success() { 651 + let args = serde_json::json!({ 652 + "record": { 653 + "$type": "app.bsky.feed.post", 654 + "text": "Hello world", 655 + "createdAt": "2025-01-19T10:00:00.000Z" 656 + } 657 + }); 658 + let result = handle_create_record_cid(args); 659 + assert_eq!(result["isError"], false); 660 + 661 + let content = result["content"].as_array().unwrap(); 662 + assert_eq!(content.len(), 1); 663 + assert_eq!(content[0]["type"], "text"); 664 + 665 + let cid_str = content[0]["text"].as_str().unwrap(); 666 + assert!(!cid_str.is_empty()); 667 + } 668 + 669 + #[test] 670 + fn test_create_record_cid_deterministic() { 671 + let args = serde_json::json!({ 672 + "record": { "text": "deterministic test" } 673 + }); 674 + 675 + let r1 = handle_create_record_cid(args.clone()); 676 + let r2 = handle_create_record_cid(args); 677 + 678 + let cid1 = r1["content"][0]["text"].as_str().unwrap(); 679 + let cid2 = r2["content"][0]["text"].as_str().unwrap(); 680 + assert_eq!(cid1, cid2); 681 + } 682 + 683 + #[test] 684 + fn test_create_record_cid_different_inputs() { 685 + let args1 = serde_json::json!({ "record": { "text": "hello" } }); 686 + let args2 = serde_json::json!({ "record": { "text": "world" } }); 687 + 688 + let r1 = handle_create_record_cid(args1); 689 + let r2 = handle_create_record_cid(args2); 690 + 691 + let cid1 = r1["content"][0]["text"].as_str().unwrap(); 692 + let cid2 = r2["content"][0]["text"].as_str().unwrap(); 693 + assert_ne!(cid1, cid2); 694 + } 695 + 696 + #[test] 697 + fn test_create_record_cid_non_object() { 698 + let args = serde_json::json!({ "record": "not an object" }); 699 + let result = handle_create_record_cid(args); 700 + assert_eq!(result["isError"], true); 701 + } 702 + 703 + #[test] 704 + fn test_create_record_cid_missing_record() { 705 + let args = serde_json::json!({}); 706 + let result = handle_create_record_cid(args); 707 + assert_eq!(result["isError"], true); 708 + } 709 + 710 + // -- validate_lexicon_schema tests -- 711 + 712 + #[test] 713 + fn test_validate_lexicon_schema_success() { 714 + let args = serde_json::json!({ 715 + "schema": { 716 + "lexicon": 1, 717 + "id": "com.example.test", 718 + "defs": { 719 + "main": { 720 + "type": "record", 721 + "key": "tid", 722 + "record": { 723 + "type": "object", 724 + "required": ["text"], 725 + "properties": { 726 + "text": { "type": "string" } 727 + } 728 + } 729 + } 730 + } 731 + } 732 + }); 733 + let result = handle_validate_lexicon_schema(args); 734 + assert_eq!(result["isError"], false); 735 + let text = result["content"][0]["text"].as_str().unwrap(); 736 + assert!(text.contains("com.example.test")); 737 + assert!(text.contains("valid")); 738 + } 739 + 740 + #[test] 741 + fn test_validate_lexicon_schema_invalid_version() { 742 + let args = serde_json::json!({ 743 + "schema": { 744 + "lexicon": 99, 745 + "id": "com.example.test", 746 + "defs": { 747 + "main": { 748 + "type": "record", 749 + "key": "tid", 750 + "record": { 751 + "type": "object", 752 + "required": ["text"], 753 + "properties": { 754 + "text": { "type": "string" } 755 + } 756 + } 757 + } 758 + } 759 + } 760 + }); 761 + let result = handle_validate_lexicon_schema(args); 762 + assert_eq!(result["isError"], true); 763 + } 764 + 765 + #[test] 766 + fn test_validate_lexicon_schema_non_object() { 767 + let args = serde_json::json!({ "schema": "not an object" }); 768 + let result = handle_validate_lexicon_schema(args); 769 + assert_eq!(result["isError"], true); 770 + } 771 + 772 + #[test] 773 + fn test_validate_lexicon_schema_missing_schema() { 774 + let args = serde_json::json!({}); 775 + let result = handle_validate_lexicon_schema(args); 776 + assert_eq!(result["isError"], true); 777 + } 778 + 779 + #[test] 780 + fn test_validate_lexicon_schema_missing_main_def() { 781 + let args = serde_json::json!({ 782 + "schema": { 783 + "lexicon": 1, 784 + "id": "com.example.test", 785 + "defs": {} 786 + } 787 + }); 788 + let result = handle_validate_lexicon_schema(args); 789 + assert_eq!(result["isError"], true); 790 + } 791 + 792 + // -- resolve_handle_to_did tests -- 793 + 794 + #[tokio::test] 795 + async fn test_resolve_handle_to_did_missing_handle() { 796 + let resolver = create_test_resolver(); 797 + let args = serde_json::json!({}); 798 + let result = handle_resolve_handle_to_did(args, &resolver).await; 799 + assert_eq!(result["isError"], true); 800 + } 801 + 802 + #[tokio::test] 803 + async fn test_resolve_handle_to_did_non_string_handle() { 804 + let resolver = create_test_resolver(); 805 + let args = serde_json::json!({ "handle": 123 }); 806 + let result = handle_resolve_handle_to_did(args, &resolver).await; 807 + assert_eq!(result["isError"], true); 808 + } 809 + 810 + // -- resolve_identity tests -- 811 + 812 + #[tokio::test] 813 + async fn test_resolve_identity_missing_did() { 814 + let resolver = create_test_resolver(); 815 + let args = serde_json::json!({}); 816 + let result = handle_resolve_identity(args, &resolver).await; 817 + assert_eq!(result["isError"], true); 818 + } 819 + 820 + #[tokio::test] 821 + async fn test_resolve_identity_non_string_did() { 822 + let resolver = create_test_resolver(); 823 + let args = serde_json::json!({ "did": 123 }); 824 + let result = handle_resolve_identity(args, &resolver).await; 825 + assert_eq!(result["isError"], true); 826 + } 827 + 828 + // -- parse_facets tests -- 829 + 830 + #[tokio::test] 831 + async fn test_parse_facets_missing_text() { 832 + let resolver = create_test_resolver(); 833 + let args = serde_json::json!({}); 834 + let result = handle_parse_facets(args, &resolver).await; 835 + assert_eq!(result["isError"], true); 836 + } 837 + 838 + #[tokio::test] 839 + async fn test_parse_facets_non_string_text() { 840 + let resolver = create_test_resolver(); 841 + let args = serde_json::json!({ "text": 123 }); 842 + let result = handle_parse_facets(args, &resolver).await; 843 + assert_eq!(result["isError"], true); 844 + } 845 + 846 + #[tokio::test] 847 + async fn test_parse_facets_empty_text() { 848 + let resolver = create_test_resolver(); 849 + let args = serde_json::json!({ "text": "no facets here" }); 850 + let result = handle_parse_facets(args, &resolver).await; 851 + assert_eq!(result["isError"], false); 852 + assert_eq!(result["content"][0]["text"], "[]"); 853 + } 854 + 855 + #[tokio::test] 856 + async fn test_parse_facets_with_url() { 857 + let resolver = create_test_resolver(); 858 + let args = serde_json::json!({ "text": "Check out https://example.com" }); 859 + let result = handle_parse_facets(args, &resolver).await; 860 + assert_eq!(result["isError"], false); 861 + let text = result["content"][0]["text"].as_str().unwrap(); 862 + assert!(text.contains("https://example.com")); 863 + } 864 + 865 + #[tokio::test] 866 + async fn test_parse_facets_with_hashtag() { 867 + let resolver = create_test_resolver(); 868 + let args = serde_json::json!({ "text": "Hello #rust" }); 869 + let result = handle_parse_facets(args, &resolver).await; 870 + assert_eq!(result["isError"], false); 871 + let text = result["content"][0]["text"].as_str().unwrap(); 872 + assert!(text.contains("rust")); 873 + } 874 + 875 + // -- get_record tests -- 876 + 877 + #[tokio::test] 878 + async fn test_get_record_missing_uri() { 879 + let resolver = create_test_resolver(); 880 + let args = serde_json::json!({}); 881 + let result = handle_get_record(args, &resolver).await; 882 + assert_eq!(result["isError"], true); 883 + } 884 + 885 + #[tokio::test] 886 + async fn test_get_record_non_string_uri() { 887 + let resolver = create_test_resolver(); 888 + let args = serde_json::json!({ "uri": 123 }); 889 + let result = handle_get_record(args, &resolver).await; 890 + assert_eq!(result["isError"], true); 891 + } 892 + 893 + #[tokio::test] 894 + async fn test_get_record_invalid_aturi() { 895 + let resolver = create_test_resolver(); 896 + let args = serde_json::json!({ "uri": "not-a-valid-aturi" }); 897 + let result = handle_get_record(args, &resolver).await; 898 + assert_eq!(result["isError"], true); 899 + let text = result["content"][0]["text"].as_str().unwrap(); 900 + assert!(text.contains("Invalid AT-URI")); 901 + } 902 + 903 + // -- message routing tests -- 904 + 905 + #[tokio::test] 906 + async fn test_route_notification_returns_none() { 907 + let resolver = create_test_resolver(); 908 + let msg = JsonRpcMessage { 909 + jsonrpc: "2.0".to_string(), 910 + id: None, 911 + method: "notifications/initialized".to_string(), 912 + params: None, 913 + }; 914 + assert!(route_message(msg, &resolver).await.is_none()); 915 + } 916 + 917 + #[tokio::test] 918 + async fn test_route_unknown_method() { 919 + let resolver = create_test_resolver(); 920 + let msg = JsonRpcMessage { 921 + jsonrpc: "2.0".to_string(), 922 + id: Some(Value::Number(1.into())), 923 + method: "unknown/method".to_string(), 924 + params: None, 925 + }; 926 + let resp = route_message(msg, &resolver).await.unwrap(); 927 + assert!(resp.error.is_some()); 928 + assert_eq!(resp.error.unwrap().code, METHOD_NOT_FOUND); 929 + } 930 + 931 + #[tokio::test] 932 + async fn test_route_tools_call() { 933 + let resolver = create_test_resolver(); 934 + let msg = JsonRpcMessage { 935 + jsonrpc: "2.0".to_string(), 936 + id: Some(Value::Number(1.into())), 937 + method: "tools/call".to_string(), 938 + params: Some(serde_json::json!({ 939 + "name": "create_record_cid", 940 + "arguments": { 941 + "record": { "text": "test" } 942 + } 943 + })), 944 + }; 945 + let resp = route_message(msg, &resolver).await.unwrap(); 946 + assert!(resp.result.is_some()); 947 + assert!(resp.error.is_none()); 948 + } 949 + 950 + #[tokio::test] 951 + async fn test_route_tools_call_unknown_tool() { 952 + let resolver = create_test_resolver(); 953 + let msg = JsonRpcMessage { 954 + jsonrpc: "2.0".to_string(), 955 + id: Some(Value::Number(1.into())), 956 + method: "tools/call".to_string(), 957 + params: Some(serde_json::json!({ 958 + "name": "nonexistent_tool", 959 + "arguments": {} 960 + })), 961 + }; 962 + let resp = route_message(msg, &resolver).await.unwrap(); 963 + assert!(resp.error.is_some()); 964 + } 965 + }
+1 -1
crates/atproto-attestation/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-attestation" 3 - version = "0.14.0" 3 + version = "0.14.1" 4 4 description = "AT Protocol attestation utilities for creating and verifying record signatures" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-client/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-client" 3 - version = "0.14.0" 3 + version = "0.14.1" 4 4 description = "HTTP client for AT Protocol services with OAuth and identity integration" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+8 -1
crates/atproto-dasl/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-dasl" 3 - version = "0.14.0" 3 + version = "0.14.1" 4 4 description = "DASL (Data-Addressed Structures & Links) implementation for AT Protocol" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates" ··· 16 16 17 17 [[bin]] 18 18 name = "atproto-dasl" 19 + test = false 20 + bench = false 21 + doc = true 22 + required-features = ["clap"] 23 + 24 + [[bin]] 25 + name = "atpcid" 19 26 test = false 20 27 bench = false 21 28 doc = true
+174
crates/atproto-dasl/src/bin/atpcid.rs
··· 1 + //! Command-line tool for generating CIDs from input data. 2 + //! 3 + //! Computes either a DAG-CBOR record CID or a RAW CID depending on the input 4 + //! content. JSON objects (starting with `{`) produce DAG-CBOR record CIDs, 5 + //! while all other input produces RAW CIDs. This behavior can be overridden 6 + //! with the `--raw` or `--record` flags. 7 + //! 8 + //! # Example Usage 9 + //! 10 + //! ```bash 11 + //! # DAG-CBOR record CID from a JSON argument 12 + //! atpcid '{"text":"hello"}' 13 + //! 14 + //! # Multiple DAG-CBOR record CIDs 15 + //! atpcid '{"text":"hello"}' '{"text":"world"}' 16 + //! 17 + //! # DAG-CBOR record CID from stdin 18 + //! echo '{"text":"hello"}' | atpcid -- 19 + //! 20 + //! # RAW CID from binary stdin 21 + //! cat image.jpg | atpcid -- 22 + //! 23 + //! # Force RAW CID on JSON input 24 + //! atpcid --raw '{"text":"hello"}' 25 + //! 26 + //! # Force DAG-CBOR record CID from stdin 27 + //! cat data.json | atpcid --record -- 28 + //! 29 + //! # RAW CIDs from files 30 + //! atpcid --files --raw image1.jpg image2.jpg 31 + //! 32 + //! # DAG-CBOR record CID from a JSON file 33 + //! atpcid --files --record data.json 34 + //! ``` 35 + 36 + use atproto_dasl::{compute_cid_for, compute_raw_cid}; 37 + use clap::Parser; 38 + use std::io::{self, Read}; 39 + use std::path::Path; 40 + 41 + /// Compute a CID from input data. 42 + /// 43 + /// Accepts one or more input strings as positional arguments, or reads from 44 + /// stdin when no arguments are provided (use `--` to separate from flags). 45 + /// JSON objects produce DAG-CBOR record CIDs; other input produces RAW CIDs. 46 + /// Use `--raw` or `--record` to override auto-detection. 47 + #[derive(Parser)] 48 + #[command( 49 + name = "atpcid", 50 + version, 51 + about = "Compute CIDs from input data", 52 + long_about = " 53 + Compute a CID (Content Identifier) from input data. 54 + 55 + By default, the tool auto-detects the CID type based on the input: 56 + - Input starting with '{' is treated as a JSON record and produces a 57 + DAG-CBOR record CID (CIDv1, codec 0x71, SHA-256). 58 + - All other input produces a RAW CID (CIDv1, codec 0x55, SHA-256). 59 + 60 + Use --raw or --record to override auto-detection. 61 + 62 + INPUT MODES: 63 + Positional arguments are processed as individual inputs, each producing 64 + one CID. Use -- to read from stdin instead of positional arguments. 65 + Use --files to read file contents from positional argument paths. 66 + 67 + EXAMPLES: 68 + # DAG-CBOR record CID from a JSON argument 69 + atpcid '{\"text\":\"hello\"}' 70 + 71 + # Multiple CIDs from multiple arguments 72 + atpcid '{\"text\":\"hello\"}' '{\"text\":\"world\"}' 73 + 74 + # DAG-CBOR record CID from stdin 75 + echo '{\"text\":\"hello\"}' | atpcid -- 76 + 77 + # RAW CID from binary stdin 78 + cat image.jpg | atpcid -- 79 + 80 + # Force RAW CID on JSON input 81 + atpcid --raw '{\"text\":\"hello\"}' 82 + 83 + # Force DAG-CBOR record CID from stdin 84 + cat data.json | atpcid --record -- 85 + 86 + # RAW CIDs from files 87 + atpcid --files --raw image1.jpg image2.jpg 88 + 89 + # DAG-CBOR record CID from a JSON file 90 + atpcid --files --record data.json 91 + " 92 + )] 93 + struct Args { 94 + /// Input data strings, each producing one CID. When empty, reads from stdin. 95 + /// With --files, each argument is treated as a file path. 96 + inputs: Vec<String>, 97 + 98 + /// Force output as a RAW CID (codec 0x55) 99 + #[arg(long, conflicts_with = "record")] 100 + raw: bool, 101 + 102 + /// Force output as a DAG-CBOR record CID (codec 0x71) 103 + #[arg(long, conflicts_with = "raw")] 104 + record: bool, 105 + 106 + /// Treat positional arguments as file paths and read their contents 107 + #[arg(long)] 108 + files: bool, 109 + } 110 + 111 + fn compute_and_print(data: &[u8], raw: bool, record: bool) -> anyhow::Result<()> { 112 + let use_record = if raw { 113 + false 114 + } else if record { 115 + true 116 + } else { 117 + first_non_whitespace_byte(data) == Some(b'{') 118 + }; 119 + 120 + let cid = if use_record { 121 + let text = std::str::from_utf8(data)?; 122 + let json_value: serde_json::Value = serde_json::from_str(text)?; 123 + compute_cid_for(&json_value)? 124 + } else { 125 + compute_raw_cid(data) 126 + }; 127 + 128 + println!("{cid}"); 129 + 130 + Ok(()) 131 + } 132 + 133 + fn first_non_whitespace_byte(data: &[u8]) -> Option<u8> { 134 + data.iter().find(|b| !b.is_ascii_whitespace()).copied() 135 + } 136 + 137 + fn has_double_dash() -> bool { 138 + std::env::args_os().any(|arg| arg == "--") 139 + } 140 + 141 + fn main() -> anyhow::Result<()> { 142 + let read_stdin = has_double_dash(); 143 + let args = Args::parse(); 144 + 145 + anyhow::ensure!( 146 + !(read_stdin && args.files), 147 + "-- and --files cannot be used together" 148 + ); 149 + 150 + if args.files { 151 + anyhow::ensure!( 152 + !args.inputs.is_empty(), 153 + "--files requires at least one file path" 154 + ); 155 + for path in &args.inputs { 156 + let data = std::fs::read(Path::new(path))?; 157 + compute_and_print(&data, args.raw, args.record)?; 158 + } 159 + } else if read_stdin { 160 + let mut buf = Vec::new(); 161 + io::stdin().read_to_end(&mut buf)?; 162 + compute_and_print(&buf, args.raw, args.record)?; 163 + } else { 164 + anyhow::ensure!( 165 + !args.inputs.is_empty(), 166 + "no input provided; pass data as arguments, use -- for stdin, or use --files" 167 + ); 168 + for input in &args.inputs { 169 + compute_and_print(input.as_bytes(), args.raw, args.record)?; 170 + } 171 + } 172 + 173 + Ok(()) 174 + }
+1 -1
crates/atproto-extras/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-extras" 3 - version = "0.14.0" 3 + version = "0.14.1" 4 4 description = "AT Protocol extras - facet parsing and rich text utilities" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-identity/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-identity" 3 - version = "0.14.0" 3 + version = "0.14.1" 4 4 description = "AT Protocol identity management - DID resolution, handle resolution, and cryptographic operations" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-identity/src/bin/atproto-identity-plc-audit.rs
··· 487 487 let url = format!("{}/{}/log/audit", plc_url, did); 488 488 489 489 let client = reqwest::Client::builder() 490 - .user_agent("atproto-identity-plc-audit/0.14.0") 490 + .user_agent("atproto-identity-plc-audit/0.14.1") 491 491 .timeout(std::time::Duration::from_secs(30)) 492 492 .build()?; 493 493
+1 -1
crates/atproto-identity/src/bin/atproto-identity-plc-fork-viz.rs
··· 594 594 let url = format!("{}/{}/log/audit", plc_url, did); 595 595 596 596 let client = reqwest::Client::builder() 597 - .user_agent("atproto-identity-plc-fork-viz/0.14.0") 597 + .user_agent("atproto-identity-plc-fork-viz/0.14.1") 598 598 .timeout(std::time::Duration::from_secs(30)) 599 599 .build()?; 600 600
+1 -1
crates/atproto-jetstream/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-jetstream" 3 - version = "0.14.0" 3 + version = "0.14.1" 4 4 description = "AT Protocol Jetstream event consumer library with WebSocket streaming and compression support" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-lexicon/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-lexicon" 3 - version = "0.14.0" 3 + version = "0.14.1" 4 4 description = "AT Protocol lexicon resolution and validation" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-lexicon/README.md
··· 27 27 28 28 ```toml 29 29 [dependencies] 30 - atproto-lexicon = "0.14.0" 30 + atproto-lexicon = "0.14.1" 31 31 ``` 32 32 33 33 ## Usage
+1 -1
crates/atproto-oauth-aip/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-oauth-aip" 3 - version = "0.14.0" 3 + version = "0.14.1" 4 4 description = "ATProtocol AIP OAuth tools" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-oauth-axum/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-oauth-axum" 3 - version = "0.14.0" 3 + version = "0.14.1" 4 4 description = "Axum web framework integration for AT Protocol OAuth workflows" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-oauth/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-oauth" 3 - version = "0.14.0" 3 + version = "0.14.1" 4 4 description = "OAuth workflow implementation for AT Protocol - PKCE, DPoP, and secure authentication flows" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-record/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-record" 3 - version = "0.14.0" 3 + version = "0.14.1" 4 4 description = "AT Protocol record signature operations - cryptographic signing and verification for AT Protocol records" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-repo/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-repo" 3 - version = "0.14.0" 3 + version = "0.14.1" 4 4 description = "AT Protocol repository handling - CAR v1 serialization and Merkle Search Tree operations" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-tap/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-tap" 3 - version = "0.14.0" 3 + version = "0.14.1" 4 4 description = "AT Protocol TAP (Trusted Attestation Protocol) service consumer" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-xrpcs-helloworld/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-xrpcs-helloworld" 3 - version = "0.14.0" 3 + version = "0.14.1" 4 4 description = "Complete example implementation of an AT Protocol XRPC service with DID web functionality and JWT authentication" 5 5 edition.workspace = true 6 6 rust-version.workspace = true
+1 -1
crates/atproto-xrpcs/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-xrpcs" 3 - version = "0.14.0" 3 + version = "0.14.1" 4 4 description = "Core building blocks for implementing AT Protocol XRPC services with JWT authorization" 5 5 edition.workspace = true 6 6 rust-version.workspace = true
+31
crates/atpxrpc/Cargo.toml
··· 1 + [package] 2 + name = "atpxrpc" 3 + version = "0.14.1" 4 + description = "AT Protocol XRPC client with persistent session management" 5 + 6 + edition.workspace = true 7 + rust-version.workspace = true 8 + authors.workspace = true 9 + repository.workspace = true 10 + license.workspace = true 11 + keywords.workspace = true 12 + categories.workspace = true 13 + 14 + [dependencies] 15 + atproto-client.workspace = true 16 + atproto-identity = { workspace = true, features = ["hickory-dns"] } 17 + 18 + anyhow.workspace = true 19 + bytes.workspace = true 20 + clap.workspace = true 21 + dirs.workspace = true 22 + reqwest.workspace = true 23 + rpassword.workspace = true 24 + secrecy.workspace = true 25 + serde.workspace = true 26 + serde_json.workspace = true 27 + thiserror.workspace = true 28 + tokio.workspace = true 29 + 30 + [lints] 31 + workspace = true
+94
crates/atpxrpc/src/config.rs
··· 1 + //! Configuration file management for atpxrpc. 2 + //! 3 + //! Handles reading and writing the persistent config file that stores 4 + //! authenticated account sessions at `~/.config/atpxrpc/config.json`. 5 + 6 + use crate::errors::XrpcCliError; 7 + use anyhow::Result; 8 + use serde::{Deserialize, Serialize}; 9 + use std::path::PathBuf; 10 + 11 + /// Persistent configuration containing authenticated accounts. 12 + #[derive(Serialize, Deserialize, Default)] 13 + pub struct Config { 14 + /// List of authenticated accounts. 15 + pub accounts: Vec<Account>, 16 + } 17 + 18 + /// A stored account with session credentials. 19 + #[derive(Serialize, Deserialize, Clone)] 20 + pub struct Account { 21 + /// The account handle (e.g., "alice.bsky.social"). 22 + pub handle: String, 23 + /// The resolved DID for the account. 24 + pub did: String, 25 + /// The PDS endpoint URL. 26 + pub pds_endpoint: String, 27 + /// The app password used for re-authentication. 28 + pub app_password: String, 29 + /// The current JWT access token. 30 + pub access_jwt: String, 31 + /// The current JWT refresh token. 32 + pub refresh_jwt: String, 33 + } 34 + 35 + /// Returns the path to the config file. 36 + /// 37 + /// Checks `ATPXRPC_CONFIG` env var first, then falls back to 38 + /// `~/.config/atpxrpc/config.json` via `dirs::config_dir()`. 39 + pub fn config_path() -> Result<PathBuf> { 40 + if let Ok(path) = std::env::var("ATPXRPC_CONFIG") { 41 + return Ok(PathBuf::from(path)); 42 + } 43 + let config_dir = dirs::config_dir().ok_or(XrpcCliError::ConfigDirNotFound)?; 44 + Ok(config_dir.join("atpxrpc").join("config.json")) 45 + } 46 + 47 + /// Loads the config from disk. 48 + /// 49 + /// Returns a default empty config if the file doesn't exist. 50 + pub fn load_config() -> Result<Config> { 51 + let path = config_path()?; 52 + if !path.exists() { 53 + return Ok(Config::default()); 54 + } 55 + let content = std::fs::read_to_string(&path).map_err(|_| XrpcCliError::ConfigReadFailed { 56 + path: path.display().to_string(), 57 + })?; 58 + let config: Config = 59 + serde_json::from_str(&content).map_err(|_| XrpcCliError::ConfigReadFailed { 60 + path: path.display().to_string(), 61 + })?; 62 + Ok(config) 63 + } 64 + 65 + /// Saves the config to disk, creating parent directories as needed. 66 + /// 67 + /// Sets file permissions to 0600 on Unix to protect stored credentials. 68 + pub fn save_config(config: &Config) -> Result<()> { 69 + let path = config_path()?; 70 + if let Some(parent) = path.parent() { 71 + std::fs::create_dir_all(parent).map_err(|_| XrpcCliError::ConfigWriteFailed { 72 + path: path.display().to_string(), 73 + })?; 74 + } 75 + let content = 76 + serde_json::to_string_pretty(config).map_err(|_| XrpcCliError::ConfigWriteFailed { 77 + path: path.display().to_string(), 78 + })?; 79 + std::fs::write(&path, content).map_err(|_| XrpcCliError::ConfigWriteFailed { 80 + path: path.display().to_string(), 81 + })?; 82 + 83 + #[cfg(unix)] 84 + { 85 + use std::os::unix::fs::PermissionsExt; 86 + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).map_err(|_| { 87 + XrpcCliError::ConfigWriteFailed { 88 + path: path.display().to_string(), 89 + } 90 + })?; 91 + } 92 + 93 + Ok(()) 94 + }
+91
crates/atpxrpc/src/errors.rs
··· 1 + //! Structured error types for the atpxrpc CLI tool. 2 + //! 3 + //! All errors follow the project convention of prefixed error codes 4 + //! with descriptive messages using the format `error-atpxrpc-{domain}-{number}`. 5 + 6 + use thiserror::Error; 7 + 8 + /// Error types specific to the atpxrpc CLI tool. 9 + #[derive(Debug, Error)] 10 + pub enum XrpcCliError { 11 + /// Occurs when the config directory cannot be determined 12 + #[error("error-atpxrpc-config-1 Cannot determine config directory")] 13 + ConfigDirNotFound, 14 + 15 + /// Occurs when reading the config file fails 16 + #[error("error-atpxrpc-config-2 Failed to read config file: {path}")] 17 + ConfigReadFailed { 18 + /// The config file path 19 + path: String, 20 + }, 21 + 22 + /// Occurs when writing the config file fails 23 + #[error("error-atpxrpc-config-3 Failed to write config file: {path}")] 24 + ConfigWriteFailed { 25 + /// The config file path 26 + path: String, 27 + }, 28 + 29 + /// Occurs when no accounts are configured 30 + #[error("error-atpxrpc-account-1 No accounts configured, run 'atpxrpc login' first")] 31 + NoAccountsConfigured, 32 + 33 + /// Occurs when the specified handle is not found in config 34 + #[error("error-atpxrpc-account-2 Account not found: {handle}")] 35 + AccountNotFound { 36 + /// The handle that was not found 37 + handle: String, 38 + }, 39 + 40 + /// Occurs when multiple accounts exist but no --handle was specified 41 + #[error( 42 + "error-atpxrpc-account-3 Multiple accounts configured, use --handle to specify which account" 43 + )] 44 + AmbiguousAccount, 45 + 46 + /// Occurs when re-authentication with app password fails 47 + #[error("error-atpxrpc-session-1 Re-authentication failed: {error}")] 48 + ReAuthFailed { 49 + /// The underlying error 50 + error: String, 51 + }, 52 + 53 + /// Occurs when stdin JSON parsing fails 54 + #[error("error-atpxrpc-input-1 Failed to parse JSON from stdin: {error}")] 55 + StdinJsonParseFailed { 56 + /// The parse error details 57 + error: String, 58 + }, 59 + 60 + /// Occurs when reading raw bytes from stdin fails 61 + #[error("error-atpxrpc-input-2 Failed to read bytes from stdin: {error}")] 62 + StdinBytesReadFailed { 63 + /// The read error details 64 + error: String, 65 + }, 66 + 67 + /// Occurs when no PDS endpoint is found in DID document 68 + #[error("error-atpxrpc-resolve-1 No PDS endpoint found in DID document for: {did}")] 69 + NoPdsEndpointFound { 70 + /// The DID that was resolved 71 + did: String, 72 + }, 73 + 74 + /// Occurs when getting a service auth token fails 75 + #[error("error-atpxrpc-session-2 Failed to get service auth token: {error}")] 76 + ServiceAuthFailed { 77 + /// The underlying error 78 + error: String, 79 + }, 80 + 81 + /// Occurs when no matching service endpoint is found in a DID document 82 + #[error( 83 + "error-atpxrpc-resolve-2 No service endpoint found for {service_id} in DID document for: {did}" 84 + )] 85 + NoServiceEndpointFound { 86 + /// The DID that was resolved 87 + did: String, 88 + /// The service ID that was not found 89 + service_id: String, 90 + }, 91 + }
+1025
crates/atpxrpc/src/main.rs
··· 1 + //! AT Protocol XRPC client with persistent session management. 2 + //! 3 + //! Provides a streamlined CLI for making XRPC calls using stored 4 + //! app password sessions with automatic token refresh. 5 + 6 + mod config; 7 + mod errors; 8 + 9 + use anyhow::Result; 10 + use atproto_client::client::{ 11 + AppPasswordAuth, get_apppassword_json_with_headers, post_apppassword_bytes_with_headers, 12 + post_apppassword_json_with_headers, 13 + }; 14 + use atproto_client::com::atproto::server::{create_session, refresh_session}; 15 + use atproto_identity::{ 16 + config::{CertificateBundles, DnsNameservers, default_env, optional_env, version}, 17 + plc, 18 + resolve::{HickoryDnsResolver, resolve_subject}, 19 + url::build_url, 20 + web, 21 + }; 22 + use bytes::Bytes; 23 + use clap::{Parser, Subcommand}; 24 + use config::{Account, load_config, save_config}; 25 + use errors::XrpcCliError; 26 + use reqwest::header::{CONTENT_TYPE, HeaderMap}; 27 + use rpassword::read_password; 28 + use secrecy::{ExposeSecret, SecretString}; 29 + use std::io::{self, IsTerminal, Read, Write}; 30 + use std::path::PathBuf; 31 + 32 + /// AT Protocol XRPC client with persistent session management. 33 + #[derive(Parser)] 34 + #[command( 35 + name = "atpxrpc", 36 + version, 37 + about = "Make AT Protocol XRPC calls with persistent session management", 38 + args_conflicts_with_subcommands = true, 39 + long_about = " 40 + A command-line tool for making XRPC calls to AT Protocol services. 41 + Manages authentication sessions in a local config file, automatically 42 + refreshing expired tokens as needed. 43 + 44 + SUBCOMMANDS: 45 + login Log in with a handle and app password 46 + logout Remove a stored account 47 + accounts List all stored accounts 48 + 49 + XRPC CALLS (default when no subcommand matches): 50 + atpxrpc [--handle <handle>] <nsid> [key=value ...] 51 + 52 + Query (GET): Pass key=value pairs as positional arguments. 53 + Procedure (POST): Pipe JSON to stdin. 54 + Bytes (POST): Use --bytes to send raw bytes from stdin. 55 + 56 + EXAMPLES: 57 + atpxrpc login alice.bsky.social xxxx-xxxx-xxxx-xxxx 58 + atpxrpc com.atproto.repo.describeRepo repo=alice.bsky.social 59 + atpxrpc com.atproto.repo.getRecord repo=did:plc:... collection=app.bsky.feed.post rkey=abc 60 + jo repo=did:plc:... collection=app.bsky.feed.post record[text]=Hello | atpxrpc com.atproto.repo.createRecord 61 + atpxrpc --handle bob.bsky.social com.atproto.repo.listRecords repo=did:plc:... 62 + cat image.png | atpxrpc --bytes --content-type image/png com.atproto.repo.uploadBlob 63 + 64 + ENVIRONMENT VARIABLES: 65 + ATPXRPC_CONFIG Override config file path 66 + PLC_HOSTNAME PLC directory hostname (default: plc.directory) 67 + USER_AGENT HTTP user agent string 68 + CERTIFICATE_BUNDLES Additional CA certificate bundles 69 + DNS_NAMESERVERS Custom DNS nameserver addresses 70 + " 71 + )] 72 + struct Args { 73 + #[command(subcommand)] 74 + command: Option<Commands>, 75 + 76 + /// Handle of the account to use for the request 77 + #[arg(long, global = true)] 78 + handle: Option<String>, 79 + 80 + /// XRPC method NSID (e.g., com.atproto.repo.getRecord) 81 + nsid: Option<String>, 82 + 83 + /// Write response body to a file instead of stdout 84 + #[arg(long)] 85 + out: Option<PathBuf>, 86 + 87 + /// Send raw bytes from stdin instead of JSON (forces POST) 88 + #[arg(long)] 89 + bytes: bool, 90 + 91 + /// Content-Type header for --bytes mode 92 + #[arg(long, default_value = "application/octet-stream")] 93 + content_type: String, 94 + 95 + /// Query parameters as key=value pairs 96 + params: Vec<String>, 97 + } 98 + 99 + #[derive(Subcommand)] 100 + enum Commands { 101 + /// Log in with a handle and app password, storing the session 102 + Login { 103 + /// Handle or DID to authenticate as 104 + identifier: String, 105 + /// App password (prompted securely if not provided) 106 + #[arg(env = "ATPROTO_PASSWORD")] 107 + password: Option<String>, 108 + /// Print session details as JSON to stdout after login 109 + #[arg(long)] 110 + show: bool, 111 + }, 112 + /// Remove a stored account from the config 113 + Logout { 114 + /// Handle of the account to remove 115 + handle: String, 116 + }, 117 + /// List all stored accounts 118 + Accounts, 119 + /// Refresh the access token for an account if expired 120 + CheckAuth { 121 + /// Handle of the account to check (optional if only one account) 122 + handle: Option<String>, 123 + }, 124 + /// Send a proxied XRPC request through the PDS using the atproto-proxy header 125 + Proxy { 126 + /// Service audience in DID#serviceId format (e.g., did:web:api.bsky.app#bsky_fg) 127 + audience: String, 128 + /// XRPC method NSID (e.g., app.bsky.feed.getAuthorFeed) 129 + nsid: String, 130 + /// Write response body to a file instead of stdout 131 + #[arg(long)] 132 + out: Option<PathBuf>, 133 + /// Send raw bytes from stdin instead of JSON (forces POST) 134 + #[arg(long)] 135 + bytes: bool, 136 + /// Content-Type header for --bytes mode 137 + #[arg(long, default_value = "application/octet-stream")] 138 + content_type: String, 139 + /// Use manual service auth instead of the atproto-proxy header. 140 + /// Gets a service auth token via getServiceAuth, resolves the 141 + /// audience DID, and sends the request directly to the target service. 142 + #[arg(long)] 143 + manual: bool, 144 + /// Query parameters as key=value pairs (for GET), or pipe JSON to stdin (for POST) 145 + params: Vec<String>, 146 + }, 147 + } 148 + 149 + /// Builds the shared HTTP client from environment configuration. 150 + fn build_http_client() -> Result<reqwest::Client> { 151 + let certificate_bundles: CertificateBundles = optional_env("CERTIFICATE_BUNDLES").try_into()?; 152 + let default_user_agent = format!( 153 + "atpxrpc ({}; +https://tangled.org/ngerakines.me/atproto-crates)", 154 + version()? 155 + ); 156 + let user_agent = default_env("USER_AGENT", &default_user_agent); 157 + 158 + let mut client_builder = reqwest::Client::builder(); 159 + for ca_certificate in certificate_bundles.as_ref() { 160 + let cert = std::fs::read(ca_certificate)?; 161 + let cert = reqwest::Certificate::from_pem(&cert)?; 162 + client_builder = client_builder.add_root_certificate(cert); 163 + } 164 + client_builder = client_builder.user_agent(user_agent); 165 + Ok(client_builder.build()?) 166 + } 167 + 168 + /// Resolves an identifier to a DID and PDS endpoint. 169 + async fn resolve_pds( 170 + http_client: &reqwest::Client, 171 + identifier: &str, 172 + ) -> Result<(String, String, String)> { 173 + let dns_nameservers: DnsNameservers = optional_env("DNS_NAMESERVERS").try_into()?; 174 + let plc_hostname = default_env("PLC_HOSTNAME", "plc.directory"); 175 + let dns_resolver = HickoryDnsResolver::create_resolver(dns_nameservers.as_ref()); 176 + 177 + let did = resolve_subject(http_client, &dns_resolver, identifier).await?; 178 + 179 + let document = if did.starts_with("did:plc:") { 180 + plc::query(http_client, &plc_hostname, &did).await? 181 + } else if did.starts_with("did:web:") { 182 + web::query(http_client, &did).await? 183 + } else { 184 + anyhow::bail!("Unsupported DID method: {}", did); 185 + }; 186 + 187 + let pds_endpoints = document.pds_endpoints(); 188 + let pds_endpoint = pds_endpoints 189 + .first() 190 + .ok_or_else(|| XrpcCliError::NoPdsEndpointFound { did: did.clone() })? 191 + .to_string(); 192 + 193 + let handle = document 194 + .handles() 195 + .map(|h| h.to_string()) 196 + .unwrap_or_else(|| identifier.to_string()); 197 + 198 + Ok((did, pds_endpoint, handle)) 199 + } 200 + 201 + /// Checks if an XRPC response indicates an expired token. 202 + fn is_expired_token_error(response: &serde_json::Value) -> bool { 203 + response 204 + .get("error") 205 + .and_then(|v| v.as_str()) 206 + .is_some_and(|e| e == "ExpiredToken") 207 + } 208 + 209 + /// Makes a single XRPC request using the given account credentials. 210 + async fn make_request( 211 + http_client: &reqwest::Client, 212 + account: &Account, 213 + nsid: &str, 214 + is_procedure: bool, 215 + query_params: &[(String, String)], 216 + json_body: &Option<serde_json::Value>, 217 + additional_headers: &HeaderMap, 218 + ) -> Result<serde_json::Value> { 219 + let app_auth = AppPasswordAuth { 220 + access_token: account.access_jwt.clone(), 221 + }; 222 + 223 + if is_procedure { 224 + let body = json_body.clone().unwrap_or(serde_json::Value::Null); 225 + let url = build_url( 226 + &account.pds_endpoint, 227 + &format!("/xrpc/{}", nsid), 228 + std::iter::empty::<(&str, &str)>(), 229 + )? 230 + .to_string(); 231 + post_apppassword_json_with_headers(http_client, &app_auth, &url, body, additional_headers) 232 + .await 233 + } else { 234 + let url = build_url( 235 + &account.pds_endpoint, 236 + &format!("/xrpc/{}", nsid), 237 + query_params.iter().map(|(k, v)| (k.as_str(), v.as_str())), 238 + )? 239 + .to_string(); 240 + get_apppassword_json_with_headers(http_client, &app_auth, &url, additional_headers).await 241 + } 242 + } 243 + 244 + /// Makes a single XRPC bytes request using the given account credentials. 245 + async fn make_bytes_request( 246 + http_client: &reqwest::Client, 247 + account: &Account, 248 + nsid: &str, 249 + payload: Bytes, 250 + additional_headers: &HeaderMap, 251 + ) -> Result<Bytes> { 252 + let app_auth = AppPasswordAuth { 253 + access_token: account.access_jwt.clone(), 254 + }; 255 + let url = build_url( 256 + &account.pds_endpoint, 257 + &format!("/xrpc/{}", nsid), 258 + std::iter::empty::<(&str, &str)>(), 259 + )? 260 + .to_string(); 261 + post_apppassword_bytes_with_headers(http_client, &app_auth, &url, payload, additional_headers) 262 + .await 263 + } 264 + 265 + /// Executes an XRPC bytes request with automatic session refresh. 266 + /// 267 + /// Detects `ExpiredToken` errors by attempting to parse the raw response 268 + /// bytes as JSON. AT Protocol errors are always JSON, so a non-JSON 269 + /// response is treated as a successful result. 270 + async fn execute_bytes_with_refresh( 271 + http_client: &reqwest::Client, 272 + account: &mut Account, 273 + nsid: &str, 274 + payload: Bytes, 275 + additional_headers: &HeaderMap, 276 + ) -> Result<Bytes> { 277 + let response = make_bytes_request( 278 + http_client, 279 + account, 280 + nsid, 281 + payload.clone(), 282 + additional_headers, 283 + ) 284 + .await?; 285 + 286 + if let Ok(json_value) = serde_json::from_slice::<serde_json::Value>(&response) 287 + && is_expired_token_error(&json_value) 288 + { 289 + eprintln!("Session expired, refreshing..."); 290 + 291 + match refresh_session(http_client, &account.pds_endpoint, &account.refresh_jwt).await { 292 + Ok(refreshed) => { 293 + account.access_jwt = refreshed.access_jwt; 294 + account.refresh_jwt = refreshed.refresh_jwt; 295 + update_account_in_config(account)?; 296 + eprintln!("Session refreshed."); 297 + } 298 + Err(_) => { 299 + eprintln!("Refresh failed, re-authenticating..."); 300 + let session = create_session( 301 + http_client, 302 + &account.pds_endpoint, 303 + &account.handle, 304 + &account.app_password, 305 + None, 306 + ) 307 + .await 308 + .map_err(|e| XrpcCliError::ReAuthFailed { 309 + error: e.to_string(), 310 + })?; 311 + 312 + account.access_jwt = session.access_jwt; 313 + account.refresh_jwt = session.refresh_jwt; 314 + update_account_in_config(account)?; 315 + eprintln!("Re-authenticated."); 316 + } 317 + } 318 + 319 + return make_bytes_request(http_client, account, nsid, payload, additional_headers).await; 320 + } 321 + 322 + Ok(response) 323 + } 324 + 325 + /// Executes an XRPC request with automatic session refresh. 326 + /// 327 + /// Strategy: 328 + /// 1. Try with current access_jwt. 329 + /// 2. On ExpiredToken, refresh using refresh_jwt. 330 + /// 3. If refresh fails, re-authenticate with stored app_password. 331 + /// 4. On any successful refresh/re-auth, update the config file. 332 + /// 5. Retry the original request with new tokens. 333 + async fn execute_with_refresh( 334 + http_client: &reqwest::Client, 335 + account: &mut Account, 336 + nsid: &str, 337 + is_procedure: bool, 338 + query_params: &[(String, String)], 339 + json_body: &Option<serde_json::Value>, 340 + additional_headers: &HeaderMap, 341 + ) -> Result<serde_json::Value> { 342 + let response = make_request( 343 + http_client, 344 + account, 345 + nsid, 346 + is_procedure, 347 + query_params, 348 + json_body, 349 + additional_headers, 350 + ) 351 + .await?; 352 + 353 + if !is_expired_token_error(&response) { 354 + return Ok(response); 355 + } 356 + 357 + eprintln!("Session expired, refreshing..."); 358 + 359 + match refresh_session(http_client, &account.pds_endpoint, &account.refresh_jwt).await { 360 + Ok(refreshed) => { 361 + account.access_jwt = refreshed.access_jwt; 362 + account.refresh_jwt = refreshed.refresh_jwt; 363 + update_account_in_config(account)?; 364 + eprintln!("Session refreshed."); 365 + } 366 + Err(_) => { 367 + eprintln!("Refresh failed, re-authenticating..."); 368 + let session = create_session( 369 + http_client, 370 + &account.pds_endpoint, 371 + &account.handle, 372 + &account.app_password, 373 + None, 374 + ) 375 + .await 376 + .map_err(|e| XrpcCliError::ReAuthFailed { 377 + error: e.to_string(), 378 + })?; 379 + 380 + account.access_jwt = session.access_jwt; 381 + account.refresh_jwt = session.refresh_jwt; 382 + update_account_in_config(account)?; 383 + eprintln!("Re-authenticated."); 384 + } 385 + } 386 + 387 + make_request( 388 + http_client, 389 + account, 390 + nsid, 391 + is_procedure, 392 + query_params, 393 + json_body, 394 + additional_headers, 395 + ) 396 + .await 397 + } 398 + 399 + /// Updates a single account in the config file by matching on DID. 400 + fn update_account_in_config(account: &Account) -> Result<()> { 401 + let mut config = load_config()?; 402 + if let Some(existing) = config.accounts.iter_mut().find(|a| a.did == account.did) { 403 + *existing = account.clone(); 404 + } 405 + save_config(&config)?; 406 + Ok(()) 407 + } 408 + 409 + /// Writes the JSON response to a file or stdout. 410 + fn write_response(response: &serde_json::Value, out: &Option<PathBuf>) -> Result<()> { 411 + let json = serde_json::to_string_pretty(response)?; 412 + if let Some(path) = out { 413 + std::fs::write(path, json.as_bytes())?; 414 + } else { 415 + println!("{json}"); 416 + } 417 + Ok(()) 418 + } 419 + 420 + /// Writes a bytes response to a file or stdout. 421 + /// 422 + /// Attempts to parse the response as JSON for pretty-printed display. 423 + /// Falls back to writing raw bytes if the response is not valid JSON. 424 + fn write_bytes_response(response: &Bytes, out: &Option<PathBuf>) -> Result<()> { 425 + if let Some(path) = out { 426 + std::fs::write(path, response)?; 427 + } else if let Ok(json_value) = serde_json::from_slice::<serde_json::Value>(response) { 428 + println!("{}", serde_json::to_string_pretty(&json_value)?); 429 + } else { 430 + io::stdout().write_all(response)?; 431 + } 432 + Ok(()) 433 + } 434 + 435 + /// Selects the account to use for an XRPC call. 436 + fn select_account<'a>( 437 + config: &'a mut config::Config, 438 + handle: &Option<String>, 439 + ) -> Result<&'a mut Account> { 440 + if config.accounts.is_empty() { 441 + return Err(XrpcCliError::NoAccountsConfigured.into()); 442 + } 443 + 444 + if let Some(handle) = handle { 445 + let idx = config 446 + .accounts 447 + .iter() 448 + .position(|a| a.handle == *handle) 449 + .ok_or_else(|| XrpcCliError::AccountNotFound { 450 + handle: handle.clone(), 451 + })?; 452 + Ok(&mut config.accounts[idx]) 453 + } else if config.accounts.len() == 1 { 454 + Ok(&mut config.accounts[0]) 455 + } else { 456 + Err(XrpcCliError::AmbiguousAccount.into()) 457 + } 458 + } 459 + 460 + /// Handles the `login` subcommand. 461 + async fn handle_login(identifier: &str, password: Option<String>, show: bool) -> Result<()> { 462 + let password = if let Some(p) = password { 463 + SecretString::new(p.into()) 464 + } else { 465 + eprint!("Enter app password: "); 466 + io::stderr().flush()?; 467 + let p = read_password()?; 468 + if p.is_empty() { 469 + anyhow::bail!("Password cannot be empty"); 470 + } 471 + SecretString::new(p.into()) 472 + }; 473 + 474 + let http_client = build_http_client()?; 475 + 476 + eprintln!("Resolving {}...", identifier); 477 + let (did, pds_endpoint, handle) = resolve_pds(&http_client, identifier).await?; 478 + eprintln!("Resolved to {} ({})", did, pds_endpoint); 479 + 480 + eprintln!("Creating session..."); 481 + let session = create_session( 482 + &http_client, 483 + &pds_endpoint, 484 + identifier, 485 + password.expose_secret(), 486 + None, 487 + ) 488 + .await?; 489 + 490 + let account = Account { 491 + handle: handle.clone(), 492 + did: session.did.clone(), 493 + pds_endpoint: pds_endpoint.clone(), 494 + app_password: password.expose_secret().to_string(), 495 + access_jwt: session.access_jwt.clone(), 496 + refresh_jwt: session.refresh_jwt.clone(), 497 + }; 498 + 499 + let mut config = load_config()?; 500 + if let Some(existing) = config.accounts.iter_mut().find(|a| a.did == account.did) { 501 + *existing = account; 502 + } else { 503 + config.accounts.push(account); 504 + } 505 + save_config(&config)?; 506 + 507 + eprintln!("Logged in as {} ({})", handle, session.did); 508 + 509 + if show { 510 + println!( 511 + "{}", 512 + serde_json::to_string_pretty(&serde_json::json!({ 513 + "did": session.did, 514 + "pds": pds_endpoint, 515 + "accessJwt": session.access_jwt, 516 + "refreshJwt": session.refresh_jwt, 517 + }))? 518 + ); 519 + } 520 + 521 + Ok(()) 522 + } 523 + 524 + /// Handles the `logout` subcommand. 525 + fn handle_logout(handle: &str) -> Result<()> { 526 + let mut config = load_config()?; 527 + let before = config.accounts.len(); 528 + config.accounts.retain(|a| a.handle != handle); 529 + if config.accounts.len() == before { 530 + return Err(XrpcCliError::AccountNotFound { 531 + handle: handle.to_string(), 532 + } 533 + .into()); 534 + } 535 + save_config(&config)?; 536 + eprintln!("Logged out {}", handle); 537 + Ok(()) 538 + } 539 + 540 + /// Handles the `accounts` subcommand. 541 + fn handle_accounts() -> Result<()> { 542 + let config = load_config()?; 543 + if config.accounts.is_empty() { 544 + eprintln!("No accounts configured."); 545 + return Ok(()); 546 + } 547 + for account in &config.accounts { 548 + println!( 549 + "{}\t{}\t{}", 550 + account.handle, account.did, account.pds_endpoint 551 + ); 552 + } 553 + Ok(()) 554 + } 555 + 556 + /// Handles the `check-auth` subcommand. 557 + /// 558 + /// Calls `com.atproto.server.getSession` to test the current access token. 559 + /// If expired, refreshes via refresh token or re-authenticates with the 560 + /// stored app password, then persists the updated tokens. 561 + async fn handle_check_auth(handle: &Option<String>) -> Result<()> { 562 + let mut config = load_config()?; 563 + let account = select_account(&mut config, handle)?; 564 + 565 + let http_client = build_http_client()?; 566 + 567 + let response = make_request( 568 + &http_client, 569 + account, 570 + "com.atproto.server.getSession", 571 + false, 572 + &[], 573 + &None, 574 + &HeaderMap::default(), 575 + ) 576 + .await?; 577 + 578 + if is_expired_token_error(&response) { 579 + eprintln!("Session expired, refreshing..."); 580 + 581 + match refresh_session(&http_client, &account.pds_endpoint, &account.refresh_jwt).await { 582 + Ok(refreshed) => { 583 + account.access_jwt = refreshed.access_jwt; 584 + account.refresh_jwt = refreshed.refresh_jwt; 585 + update_account_in_config(account)?; 586 + eprintln!("Session refreshed."); 587 + } 588 + Err(_) => { 589 + eprintln!("Refresh failed, re-authenticating..."); 590 + let session = create_session( 591 + &http_client, 592 + &account.pds_endpoint, 593 + &account.handle, 594 + &account.app_password, 595 + None, 596 + ) 597 + .await 598 + .map_err(|e| XrpcCliError::ReAuthFailed { 599 + error: e.to_string(), 600 + })?; 601 + 602 + account.access_jwt = session.access_jwt; 603 + account.refresh_jwt = session.refresh_jwt; 604 + update_account_in_config(account)?; 605 + eprintln!("Re-authenticated."); 606 + } 607 + } 608 + } else { 609 + eprintln!("Session is valid."); 610 + } 611 + 612 + Ok(()) 613 + } 614 + 615 + /// Handles an XRPC call (no subcommand). 616 + async fn handle_xrpc_call( 617 + handle: &Option<String>, 618 + nsid: &str, 619 + params: &[String], 620 + out: &Option<PathBuf>, 621 + bytes_mode: bool, 622 + content_type: &str, 623 + ) -> Result<()> { 624 + let mut config = load_config()?; 625 + let account = select_account(&mut config, handle)?; 626 + 627 + if bytes_mode { 628 + let mut raw_input = Vec::new(); 629 + io::stdin().read_to_end(&mut raw_input).map_err(|e| { 630 + XrpcCliError::StdinBytesReadFailed { 631 + error: e.to_string(), 632 + } 633 + })?; 634 + let payload = Bytes::from(raw_input); 635 + 636 + let mut headers = HeaderMap::new(); 637 + headers.insert(CONTENT_TYPE, content_type.parse()?); 638 + 639 + let http_client = build_http_client()?; 640 + let response = 641 + execute_bytes_with_refresh(&http_client, account, nsid, payload, &headers).await?; 642 + 643 + return write_bytes_response(&response, out); 644 + } 645 + 646 + let stdin_is_pipe = !io::stdin().is_terminal(); 647 + 648 + let (is_procedure, json_body) = if stdin_is_pipe { 649 + let mut input = String::new(); 650 + io::stdin().read_to_string(&mut input)?; 651 + let value: serde_json::Value = 652 + serde_json::from_str(&input).map_err(|e| XrpcCliError::StdinJsonParseFailed { 653 + error: e.to_string(), 654 + })?; 655 + (true, Some(value)) 656 + } else { 657 + (false, None) 658 + }; 659 + 660 + let query_params: Vec<(String, String)> = if !is_procedure { 661 + params 662 + .iter() 663 + .filter_map(|arg| { 664 + let (key, value) = arg.split_once('=')?; 665 + Some((key.to_string(), value.to_string())) 666 + }) 667 + .collect() 668 + } else { 669 + vec![] 670 + }; 671 + 672 + let http_client = build_http_client()?; 673 + let response = execute_with_refresh( 674 + &http_client, 675 + account, 676 + nsid, 677 + is_procedure, 678 + &query_params, 679 + &json_body, 680 + &HeaderMap::default(), 681 + ) 682 + .await?; 683 + 684 + write_response(&response, out)?; 685 + Ok(()) 686 + } 687 + 688 + /// Resolves the target service endpoint from an audience string. 689 + /// 690 + /// Parses the audience as `DID#serviceId`, resolves the DID document, 691 + /// and returns the service endpoint URL matching the service ID fragment. 692 + /// If no fragment is provided, returns the first PDS endpoint. 693 + async fn resolve_service_endpoint( 694 + http_client: &reqwest::Client, 695 + audience: &str, 696 + ) -> Result<(String, String)> { 697 + let (did, service_id) = if let Some((did, fragment)) = audience.split_once('#') { 698 + (did.to_string(), Some(format!("#{}", fragment))) 699 + } else { 700 + (audience.to_string(), None) 701 + }; 702 + 703 + let dns_nameservers: DnsNameservers = optional_env("DNS_NAMESERVERS").try_into()?; 704 + let plc_hostname = default_env("PLC_HOSTNAME", "plc.directory"); 705 + let dns_resolver = HickoryDnsResolver::create_resolver(dns_nameservers.as_ref()); 706 + let resolved_did = resolve_subject(http_client, &dns_resolver, &did).await?; 707 + 708 + let document = if resolved_did.starts_with("did:plc:") { 709 + plc::query(http_client, &plc_hostname, &resolved_did).await? 710 + } else if resolved_did.starts_with("did:web:") { 711 + web::query(http_client, &resolved_did).await? 712 + } else { 713 + anyhow::bail!("Unsupported DID method: {}", resolved_did); 714 + }; 715 + 716 + if let Some(ref sid) = service_id { 717 + for service in &document.service { 718 + if service.id == *sid { 719 + return Ok((did, service.service_endpoint.clone())); 720 + } 721 + } 722 + Err(XrpcCliError::NoServiceEndpointFound { 723 + did, 724 + service_id: sid.clone(), 725 + } 726 + .into()) 727 + } else { 728 + let pds_endpoints = document.pds_endpoints(); 729 + let endpoint = pds_endpoints 730 + .first() 731 + .ok_or_else(|| XrpcCliError::NoPdsEndpointFound { did: did.clone() })? 732 + .to_string(); 733 + Ok((did, endpoint)) 734 + } 735 + } 736 + 737 + /// Gets a service auth token from the user's PDS. 738 + /// 739 + /// Calls `com.atproto.server.getServiceAuth` with the audience DID 740 + /// and XRPC method, returning the signed JWT token. 741 + async fn get_service_auth_token( 742 + http_client: &reqwest::Client, 743 + account: &mut Account, 744 + aud: &str, 745 + lxm: &str, 746 + ) -> Result<String> { 747 + let query_params = vec![ 748 + ("aud".to_string(), aud.to_string()), 749 + ("lxm".to_string(), lxm.to_string()), 750 + ]; 751 + let response = execute_with_refresh( 752 + http_client, 753 + account, 754 + "com.atproto.server.getServiceAuth", 755 + false, 756 + &query_params, 757 + &None, 758 + &HeaderMap::default(), 759 + ) 760 + .await?; 761 + 762 + response 763 + .get("token") 764 + .and_then(|v| v.as_str()) 765 + .map(|s| s.to_string()) 766 + .ok_or_else(|| { 767 + XrpcCliError::ServiceAuthFailed { 768 + error: format!("Unexpected response: {}", response), 769 + } 770 + .into() 771 + }) 772 + } 773 + 774 + /// Handles the `proxy` subcommand. 775 + /// 776 + /// Sends an XRPC request through the user's PDS with the `atproto-proxy` 777 + /// header set, enabling inter-service authentication. The PDS forwards the 778 + /// request to the target service identified by the audience parameter. 779 + /// 780 + /// With `--manual`, bypasses the PDS proxy mechanism and instead gets a 781 + /// service auth token via `getServiceAuth`, resolves the audience DID to 782 + /// find the target service endpoint, and sends the request directly. 783 + #[allow(clippy::too_many_arguments)] 784 + async fn handle_proxy( 785 + handle: &Option<String>, 786 + audience: &str, 787 + nsid: &str, 788 + params: &[String], 789 + out: &Option<PathBuf>, 790 + bytes_mode: bool, 791 + content_type: &str, 792 + manual: bool, 793 + ) -> Result<()> { 794 + let mut config = load_config()?; 795 + let account = select_account(&mut config, handle)?; 796 + let http_client = build_http_client()?; 797 + 798 + if manual { 799 + // Extract the DID portion (before #fragment) for the service auth audience 800 + let aud_did = audience.split_once('#').map_or(audience, |(did, _)| did); 801 + 802 + // Step 1: Get a service auth token from the user's PDS 803 + eprintln!("Getting service auth token for {}...", aud_did); 804 + let token = get_service_auth_token(&http_client, account, aud_did, nsid).await?; 805 + 806 + // Step 2: Resolve the audience DID to find the target service endpoint 807 + eprintln!("Resolving service endpoint for {}...", audience); 808 + let (_did, service_endpoint) = resolve_service_endpoint(&http_client, audience).await?; 809 + eprintln!("Target endpoint: {}", service_endpoint); 810 + 811 + // Step 3: Make the request directly to the target service 812 + let target_auth = AppPasswordAuth { 813 + access_token: token, 814 + }; 815 + 816 + if bytes_mode { 817 + let mut raw_input = Vec::new(); 818 + io::stdin().read_to_end(&mut raw_input).map_err(|e| { 819 + XrpcCliError::StdinBytesReadFailed { 820 + error: e.to_string(), 821 + } 822 + })?; 823 + let payload = Bytes::from(raw_input); 824 + 825 + let mut headers = HeaderMap::new(); 826 + headers.insert(CONTENT_TYPE, content_type.parse()?); 827 + 828 + let url = build_url( 829 + &service_endpoint, 830 + &format!("/xrpc/{}", nsid), 831 + std::iter::empty::<(&str, &str)>(), 832 + )? 833 + .to_string(); 834 + let response = post_apppassword_bytes_with_headers( 835 + &http_client, 836 + &target_auth, 837 + &url, 838 + payload, 839 + &headers, 840 + ) 841 + .await?; 842 + 843 + return write_bytes_response(&response, out); 844 + } 845 + 846 + let stdin_is_pipe = !io::stdin().is_terminal(); 847 + 848 + let (is_procedure, json_body) = if stdin_is_pipe { 849 + let mut input = String::new(); 850 + io::stdin().read_to_string(&mut input)?; 851 + let value: serde_json::Value = 852 + serde_json::from_str(&input).map_err(|e| XrpcCliError::StdinJsonParseFailed { 853 + error: e.to_string(), 854 + })?; 855 + (true, Some(value)) 856 + } else { 857 + (false, None) 858 + }; 859 + 860 + let response = if is_procedure { 861 + let body = json_body.unwrap_or(serde_json::Value::Null); 862 + let url = build_url( 863 + &service_endpoint, 864 + &format!("/xrpc/{}", nsid), 865 + std::iter::empty::<(&str, &str)>(), 866 + )? 867 + .to_string(); 868 + post_apppassword_json_with_headers( 869 + &http_client, 870 + &target_auth, 871 + &url, 872 + body, 873 + &HeaderMap::default(), 874 + ) 875 + .await? 876 + } else { 877 + let query_params: Vec<(String, String)> = params 878 + .iter() 879 + .filter_map(|arg| { 880 + let (key, value) = arg.split_once('=')?; 881 + Some((key.to_string(), value.to_string())) 882 + }) 883 + .collect(); 884 + let url = build_url( 885 + &service_endpoint, 886 + &format!("/xrpc/{}", nsid), 887 + query_params.iter().map(|(k, v)| (k.as_str(), v.as_str())), 888 + )? 889 + .to_string(); 890 + get_apppassword_json_with_headers( 891 + &http_client, 892 + &target_auth, 893 + &url, 894 + &HeaderMap::default(), 895 + ) 896 + .await? 897 + }; 898 + 899 + write_response(&response, out)?; 900 + return Ok(()); 901 + } 902 + 903 + if bytes_mode { 904 + let mut raw_input = Vec::new(); 905 + io::stdin().read_to_end(&mut raw_input).map_err(|e| { 906 + XrpcCliError::StdinBytesReadFailed { 907 + error: e.to_string(), 908 + } 909 + })?; 910 + let payload = Bytes::from(raw_input); 911 + 912 + let mut headers = HeaderMap::new(); 913 + headers.insert( 914 + reqwest::header::HeaderName::from_static("atproto-proxy"), 915 + reqwest::header::HeaderValue::from_str(audience)?, 916 + ); 917 + headers.insert(CONTENT_TYPE, content_type.parse()?); 918 + 919 + let response = 920 + execute_bytes_with_refresh(&http_client, account, nsid, payload, &headers).await?; 921 + 922 + return write_bytes_response(&response, out); 923 + } 924 + 925 + let stdin_is_pipe = !io::stdin().is_terminal(); 926 + 927 + let (is_procedure, json_body) = if stdin_is_pipe { 928 + let mut input = String::new(); 929 + io::stdin().read_to_string(&mut input)?; 930 + let value: serde_json::Value = 931 + serde_json::from_str(&input).map_err(|e| XrpcCliError::StdinJsonParseFailed { 932 + error: e.to_string(), 933 + })?; 934 + (true, Some(value)) 935 + } else { 936 + (false, None) 937 + }; 938 + 939 + let query_params: Vec<(String, String)> = if !is_procedure { 940 + params 941 + .iter() 942 + .filter_map(|arg| { 943 + let (key, value) = arg.split_once('=')?; 944 + Some((key.to_string(), value.to_string())) 945 + }) 946 + .collect() 947 + } else { 948 + vec![] 949 + }; 950 + 951 + let mut headers = HeaderMap::new(); 952 + headers.insert( 953 + reqwest::header::HeaderName::from_static("atproto-proxy"), 954 + reqwest::header::HeaderValue::from_str(audience)?, 955 + ); 956 + 957 + let response = execute_with_refresh( 958 + &http_client, 959 + account, 960 + nsid, 961 + is_procedure, 962 + &query_params, 963 + &json_body, 964 + &headers, 965 + ) 966 + .await?; 967 + 968 + write_response(&response, out)?; 969 + Ok(()) 970 + } 971 + 972 + #[tokio::main] 973 + async fn main() -> Result<()> { 974 + let args = Args::parse(); 975 + 976 + match args.command { 977 + Some(Commands::Login { 978 + identifier, 979 + password, 980 + show, 981 + }) => handle_login(&identifier, password, show).await, 982 + Some(Commands::Logout { handle }) => handle_logout(&handle), 983 + Some(Commands::Accounts) => handle_accounts(), 984 + Some(Commands::CheckAuth { handle }) => handle_check_auth(&handle).await, 985 + Some(Commands::Proxy { 986 + audience, 987 + nsid, 988 + out, 989 + bytes: bytes_mode, 990 + content_type, 991 + manual, 992 + params, 993 + }) => { 994 + handle_proxy( 995 + &args.handle, 996 + &audience, 997 + &nsid, 998 + &params, 999 + &out, 1000 + bytes_mode, 1001 + &content_type, 1002 + manual, 1003 + ) 1004 + .await 1005 + } 1006 + None => match args.nsid { 1007 + Some(nsid) => { 1008 + handle_xrpc_call( 1009 + &args.handle, 1010 + &nsid, 1011 + &args.params, 1012 + &args.out, 1013 + args.bytes, 1014 + &args.content_type, 1015 + ) 1016 + .await 1017 + } 1018 + None => { 1019 + eprintln!("Error: an XRPC method NSID or subcommand is required."); 1020 + eprintln!("Run 'atpxrpc --help' for usage information."); 1021 + std::process::exit(1); 1022 + } 1023 + }, 1024 + } 1025 + }