···7788## [Unreleased]
991010+## [0.14.1] - 2026-03-08
1111+### Added
1212+- `atpxrpc` CLI tool for persistent XRPC session management with proxy support
1313+- `--out` argument for atpxrpc proxy and base commands for output control
1414+- `--bytes` and `--manual` proxy flags to atpxrpc CLI
1515+- 5 new tools to atpmcp MCP server
1616+1717+### Changed
1818+- Updated GitHub Actions workflow to build atpxrpc and atpmcp release binaries
1919+- Updated README to reflect full Rust crates workspace
2020+1021## [0.14.0] - 2026-02-21
11221223## [0.13.0] - 2025-09-21
···183194- Core DID document handling
184195- Cryptographic key operations for P-256 curves
185196197197+[0.14.1]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.14.1
186198[0.14.0]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.14.0
187199[0.13.0]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.13.0
188200[0.12.0]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.12.0
···204216[0.7.0]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.7.0
205217[0.6.0]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.6.0
206218[0.5.0]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.5.0
207207-[0.4.0]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.4.0
219219+[0.4.0]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.4.0
+6
CLAUDE.md
···3333- **Verify records**: `cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- <record>` (verifies all signatures)
3434- **Verify attestation**: `cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- <record> <attestation>` (verifies specific attestation)
35353636+#### DASL Operations
3737+- **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)
3838+3639#### Record Operations
3740- **Generate CID**: `cat record.json | cargo run --features clap --bin atproto-record-cid` (reads JSON from stdin, outputs CID)
3841···156159- **`src/url.rs`**: URL utilities for AT Protocol services
157160158161### CLI Tools (require --features clap)
162162+163163+#### DASL Operations (atproto-dasl)
164164+- **`crates/atproto-dasl/src/bin/atpcid.rs`**: Compute CIDs from input data (auto-detects DAG-CBOR record vs RAW CID)
159165160166#### Identity Management (atproto-identity)
161167- **`src/bin/atproto-identity-resolve.rs`**: Resolve AT Protocol handles and DIDs to canonical identifiers
···11-# AT Protocol Identity Rust Components
11+# AT Protocol Rust Crates
2233-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.
33+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.
4455**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.
6677## Components
8899-This workspace contains 15 specialized crates that work together to provide complete AT Protocol application development capabilities:
99+This workspace contains 17 crates that work together to provide complete AT Protocol application development capabilities:
10101111### Data Foundations
1212···3434- **[`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.*
3535- **[`atproto-xrpcs`](crates/atproto-xrpcs/)** - XRPC service framework providing JWT authorization extractors, DID resolution integration, and Axum middleware for building AT Protocol services.
3636- **[`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.*
3737+- **[`atpxrpc`](crates/atpxrpc/)** - XRPC CLI client with persistent session management for making authenticated AT Protocol API calls. *Includes 1 CLI tool.*
37383839### Real-time Event Processing
39404041- **[`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.*
4142- **[`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.*
42434444+### Developer Tools
4545+4646+- **[`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.*
4747+4348### Utilities
44494550- **[`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.*
···50555156```toml
5257[dependencies]
5353-atproto-dasl = "0.14.0"
5454-atproto-identity = "0.14.0"
5555-atproto-attestation = "0.14.0"
5656-atproto-record = "0.14.0"
5757-atproto-repo = "0.14.0"
5858-atproto-lexicon = "0.14.0"
5959-atproto-oauth = "0.14.0"
6060-atproto-oauth-aip = "0.14.0"
6161-atproto-client = "0.14.0"
6262-atproto-extras = "0.14.0"
6363-atproto-tap = "0.14.0"
5858+atproto-dasl = "0.14.1"
5959+atproto-identity = "0.14.1"
6060+atproto-attestation = "0.14.1"
6161+atproto-record = "0.14.1"
6262+atproto-repo = "0.14.1"
6363+atproto-lexicon = "0.14.1"
6464+atproto-oauth = "0.14.1"
6565+atproto-oauth-aip = "0.14.1"
6666+atproto-client = "0.14.1"
6767+atproto-extras = "0.14.1"
6868+atproto-tap = "0.14.1"
6469# Add others as needed
6570```
6671···235240236241## Command Line Tools
237242238238-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:
243243+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:
239244240245```bash
241246# Build with CLI support
···290295291296# Rich text utilities (atproto-extras crate)
292297cargo run --package atproto-extras --features clap,cli,hickory-dns --bin atproto-extras-parse-facets -- "Hello @alice.bsky.social"
298298+299299+# XRPC CLI client (atpxrpc crate)
300300+cargo run --package atpxrpc --bin atpxrpc -- --help
301301+302302+# MCP server (atpmcp crate)
303303+cargo build -p atpmcp
293304```
294305295306## Development
···11+# atpmcp
22+33+A local MCP (Model Context Protocol) server for AT Protocol DAG-CBOR CID generation.
44+55+## Overview
66+77+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.
88+99+## Tools
1010+1111+- **`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.
1212+1313+## Building
1414+1515+```bash
1616+cargo build -p atpmcp
1717+```
1818+1919+## Claude Code Integration
2020+2121+Add the following to your Claude Code MCP configuration (`~/.claude/claude_code_config.json`):
2222+2323+```json
2424+{
2525+ "mcpServers": {
2626+ "atpmcp": {
2727+ "command": "/path/to/atpmcp"
2828+ }
2929+ }
3030+}
3131+```
3232+3333+Replace `/path/to/atpmcp` with the absolute path to the compiled binary (e.g., `target/release/atpmcp` after running `cargo build -p atpmcp --release`).
3434+3535+Or, if `atpmcp` is already in your PATH:
3636+3737+```bash
3838+tmp=$(jq '.mcpServers.atpmcp = {"command": "atpmcp"}' ~/.claude/claude_code_config.json) && echo "$tmp" > ~/.claude/claude_code_config.json
3939+```
4040+4141+Once configured, Claude Code will have access to the `create_record_cid` tool and can compute CIDs for AT Protocol records during conversations.
4242+4343+### Example usage in Claude Code
4444+4545+Ask Claude to compute a CID:
4646+4747+> What is the CID for this AT Protocol record?
4848+> ```json
4949+> {
5050+> "$type": "app.bsky.feed.post",
5151+> "text": "Hello AT Protocol!",
5252+> "createdAt": "2024-01-01T00:00:00.000Z"
5353+> }
5454+> ```
5555+5656+Claude will call the `create_record_cid` tool and return the CID string.
5757+5858+## Protocol Details
5959+6060+The server implements the MCP stdio transport:
6161+6262+- **stdin**: Receives newline-delimited JSON-RPC 2.0 messages
6363+- **stdout**: Sends newline-delimited JSON-RPC 2.0 responses
6464+- **stderr**: Tracing/logging output (controlled by `RUST_LOG` environment variable)
6565+6666+Supported methods:
6767+6868+| Method | Description |
6969+|--------|-------------|
7070+| `initialize` | MCP handshake, returns server capabilities |
7171+| `ping` | Health check |
7272+| `tools/list` | Lists available tools |
7373+| `tools/call` | Invokes a tool by name |
7474+7575+## License
7676+7777+MIT License
+52
crates/atpmcp/src/errors.rs
···11+//! Structured error types for atpmcp MCP server operations.
22+//!
33+//! All errors follow the project convention of prefixed error codes
44+//! with descriptive messages: `error-atpmcp-{domain}-{number}`
55+66+use thiserror::Error;
77+88+/// Errors that can occur during tool operations.
99+#[derive(Debug, Error)]
1010+pub enum ToolError {
1111+ /// DAG-CBOR serialization of the JSON value failed.
1212+ #[error("error-atpmcp-tool-1 Failed to serialize record to DAG-CBOR: {reason}")]
1313+ SerializationFailed {
1414+ /// Description of the serialization failure.
1515+ reason: String,
1616+ },
1717+1818+ /// Lexicon schema validation failed.
1919+ #[error("error-atpmcp-tool-2 Lexicon schema validation failed: {reason}")]
2020+ ValidationFailed {
2121+ /// Description of the validation failure.
2222+ reason: String,
2323+ },
2424+2525+ /// Handle resolution failed.
2626+ #[error("error-atpmcp-tool-3 Handle resolution failed: {reason}")]
2727+ HandleResolutionFailed {
2828+ /// Description of the resolution failure.
2929+ reason: String,
3030+ },
3131+3232+ /// Identity resolution failed.
3333+ #[error("error-atpmcp-tool-4 Identity resolution failed: {reason}")]
3434+ IdentityResolutionFailed {
3535+ /// Description of the resolution failure.
3636+ reason: String,
3737+ },
3838+3939+ /// Facet parsing failed.
4040+ #[error("error-atpmcp-tool-5 Facet parsing failed: {reason}")]
4141+ FacetParsingFailed {
4242+ /// Description of the parsing failure.
4343+ reason: String,
4444+ },
4545+4646+ /// Record retrieval failed.
4747+ #[error("error-atpmcp-tool-6 Record retrieval failed: {reason}")]
4848+ RecordRetrievalFailed {
4949+ /// Description of the retrieval failure.
5050+ reason: String,
5151+ },
5252+}
+965
crates/atpmcp/src/main.rs
···11+//! AT Protocol MCP server for identity, record, and lexicon operations.
22+//!
33+//! This binary implements a Model Context Protocol (MCP) server that
44+//! communicates over stdio using JSON-RPC 2.0. It provides tools for
55+//! CID computation, lexicon validation, identity resolution, facet
66+//! parsing, and record retrieval.
77+//!
88+//! # MCP Protocol
99+//!
1010+//! The server uses the MCP stdio transport:
1111+//! - stdin: receives newline-delimited JSON-RPC 2.0 messages
1212+//! - stdout: sends newline-delimited JSON-RPC 2.0 responses
1313+//! - stderr: used for tracing/logging output
1414+//!
1515+//! # Tools
1616+//!
1717+//! - `create_record_cid` — Compute the DAG-CBOR CID for a JSON record
1818+//! - `validate_lexicon_schema` — Validate a lexicon schema object
1919+//! - `resolve_handle_to_did` — Resolve an AT Protocol handle to a DID
2020+//! - `resolve_identity` — Resolve a DID to its full DID document
2121+//! - `parse_facets` — Parse rich text facets from plain text
2222+//! - `get_record` — Retrieve an AT Protocol record by AT-URI
2323+2424+mod errors;
2525+2626+use std::io::{BufRead, BufReader, Write};
2727+use std::sync::Arc;
2828+2929+use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver};
3030+use errors::ToolError;
3131+use serde::{Deserialize, Serialize};
3232+use serde_json::Value;
3333+use tracing_subscriber::EnvFilter;
3434+3535+/// MCP protocol version supported by this server.
3636+const PROTOCOL_VERSION: &str = "2025-11-25";
3737+3838+/// JSON-RPC error code for parse errors.
3939+const PARSE_ERROR: i64 = -32700;
4040+4141+/// JSON-RPC error code for invalid requests.
4242+const INVALID_REQUEST: i64 = -32600;
4343+4444+/// JSON-RPC error code for method not found.
4545+const METHOD_NOT_FOUND: i64 = -32601;
4646+4747+/// JSON-RPC error code for invalid params.
4848+const INVALID_PARAMS: i64 = -32602;
4949+5050+// -- JSON-RPC types --
5151+5252+/// An incoming JSON-RPC 2.0 request or notification.
5353+#[derive(Debug, Deserialize)]
5454+struct JsonRpcMessage {
5555+ /// Must be "2.0".
5656+ #[allow(dead_code)]
5757+ jsonrpc: String,
5858+ /// Request ID. Absent for notifications.
5959+ id: Option<Value>,
6060+ /// Method name.
6161+ method: String,
6262+ /// Optional parameters.
6363+ params: Option<Value>,
6464+}
6565+6666+/// An outgoing JSON-RPC 2.0 response.
6767+#[derive(Debug, Serialize)]
6868+struct JsonRpcResponse {
6969+ jsonrpc: &'static str,
7070+ id: Value,
7171+ #[serde(skip_serializing_if = "Option::is_none")]
7272+ result: Option<Value>,
7373+ #[serde(skip_serializing_if = "Option::is_none")]
7474+ error: Option<JsonRpcError>,
7575+}
7676+7777+/// A JSON-RPC 2.0 error object.
7878+#[derive(Debug, Serialize)]
7979+struct JsonRpcError {
8080+ code: i64,
8181+ message: String,
8282+ #[serde(skip_serializing_if = "Option::is_none")]
8383+ data: Option<Value>,
8484+}
8585+8686+impl JsonRpcResponse {
8787+ /// Create a success response.
8888+ fn success(id: Value, result: Value) -> Self {
8989+ Self {
9090+ jsonrpc: "2.0",
9191+ id,
9292+ result: Some(result),
9393+ error: None,
9494+ }
9595+ }
9696+9797+ /// Create an error response.
9898+ fn error(id: Value, code: i64, message: impl Into<String>) -> Self {
9999+ Self {
100100+ jsonrpc: "2.0",
101101+ id,
102102+ result: None,
103103+ error: Some(JsonRpcError {
104104+ code,
105105+ message: message.into(),
106106+ data: None,
107107+ }),
108108+ }
109109+ }
110110+}
111111+112112+// -- MCP handlers --
113113+114114+/// Handle the `initialize` request.
115115+fn handle_initialize() -> Value {
116116+ serde_json::json!({
117117+ "protocolVersion": PROTOCOL_VERSION,
118118+ "capabilities": {
119119+ "tools": {}
120120+ },
121121+ "serverInfo": {
122122+ "name": "atpmcp",
123123+ "version": env!("CARGO_PKG_VERSION")
124124+ }
125125+ })
126126+}
127127+128128+/// Handle the `tools/list` request.
129129+fn handle_tools_list() -> Value {
130130+ serde_json::json!({
131131+ "tools": [
132132+ {
133133+ "name": "create_record_cid",
134134+ "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.",
135135+ "inputSchema": {
136136+ "type": "object",
137137+ "properties": {
138138+ "record": {
139139+ "type": "object",
140140+ "description": "The JSON record object to compute a CID for."
141141+ }
142142+ },
143143+ "required": ["record"],
144144+ "additionalProperties": false
145145+ }
146146+ },
147147+ {
148148+ "name": "validate_lexicon_schema",
149149+ "description": "Validate a lexicon schema object. Accepts a JSON object representing a lexicon schema and validates its structure, version, NSID, and definitions.",
150150+ "inputSchema": {
151151+ "type": "object",
152152+ "properties": {
153153+ "schema": {
154154+ "type": "object",
155155+ "description": "The lexicon schema to validate."
156156+ }
157157+ },
158158+ "required": ["schema"],
159159+ "additionalProperties": false
160160+ }
161161+ },
162162+ {
163163+ "name": "resolve_handle_to_did",
164164+ "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').",
165165+ "inputSchema": {
166166+ "type": "object",
167167+ "properties": {
168168+ "handle": {
169169+ "type": "string",
170170+ "description": "The AT Protocol handle to resolve."
171171+ }
172172+ },
173173+ "required": ["handle"],
174174+ "additionalProperties": false
175175+ }
176176+ },
177177+ {
178178+ "name": "resolve_identity",
179179+ "description": "Resolve a DID to its full DID document. Accepts a DID string and returns the complete DID document as JSON.",
180180+ "inputSchema": {
181181+ "type": "object",
182182+ "properties": {
183183+ "did": {
184184+ "type": "string",
185185+ "description": "The DID to resolve."
186186+ },
187187+ "plc_directory_hostname": {
188188+ "type": "string",
189189+ "description": "PLC directory hostname override, defaults to 'plc.directory'."
190190+ }
191191+ },
192192+ "required": ["did"],
193193+ "additionalProperties": false
194194+ }
195195+ },
196196+ {
197197+ "name": "parse_facets",
198198+ "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.",
199199+ "inputSchema": {
200200+ "type": "object",
201201+ "properties": {
202202+ "text": {
203203+ "type": "string",
204204+ "description": "The plain text to parse for facets."
205205+ }
206206+ },
207207+ "required": ["text"],
208208+ "additionalProperties": false
209209+ }
210210+ },
211211+ {
212212+ "name": "get_record",
213213+ "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.",
214214+ "inputSchema": {
215215+ "type": "object",
216216+ "properties": {
217217+ "uri": {
218218+ "type": "string",
219219+ "description": "The AT-URI of the record to retrieve (e.g. 'at://did:plc:abc123/app.bsky.feed.post/rkey')."
220220+ },
221221+ "cid": {
222222+ "type": "string",
223223+ "description": "Specific version CID to retrieve."
224224+ },
225225+ "plc_directory_hostname": {
226226+ "type": "string",
227227+ "description": "PLC directory hostname override, defaults to 'plc.directory'."
228228+ }
229229+ },
230230+ "required": ["uri"],
231231+ "additionalProperties": false
232232+ }
233233+ }
234234+ ]
235235+ })
236236+}
237237+238238+/// Handle the `tools/call` request.
239239+async fn handle_tools_call(
240240+ id: Value,
241241+ params: Option<Value>,
242242+ resolver: &InnerIdentityResolver,
243243+) -> JsonRpcResponse {
244244+ let Some(params) = params else {
245245+ return JsonRpcResponse::error(id, INVALID_PARAMS, "Missing params");
246246+ };
247247+248248+ let Some(name) = params.get("name").and_then(Value::as_str) else {
249249+ return JsonRpcResponse::error(id, INVALID_PARAMS, "Missing tool name");
250250+ };
251251+252252+ let arguments = params.get("arguments").cloned().unwrap_or(Value::Null);
253253+254254+ match name {
255255+ "create_record_cid" => JsonRpcResponse::success(id, handle_create_record_cid(arguments)),
256256+ "validate_lexicon_schema" => {
257257+ JsonRpcResponse::success(id, handle_validate_lexicon_schema(arguments))
258258+ }
259259+ "resolve_handle_to_did" => {
260260+ JsonRpcResponse::success(id, handle_resolve_handle_to_did(arguments, resolver).await)
261261+ }
262262+ "resolve_identity" => {
263263+ JsonRpcResponse::success(id, handle_resolve_identity(arguments, resolver).await)
264264+ }
265265+ "parse_facets" => {
266266+ JsonRpcResponse::success(id, handle_parse_facets(arguments, resolver).await)
267267+ }
268268+ "get_record" => JsonRpcResponse::success(id, handle_get_record(arguments, resolver).await),
269269+ _ => JsonRpcResponse::error(id, METHOD_NOT_FOUND, format!("Unknown tool: {name}")),
270270+ }
271271+}
272272+273273+/// Execute the `create_record_cid` tool.
274274+fn handle_create_record_cid(arguments: Value) -> Value {
275275+ let record = arguments.get("record").cloned().unwrap_or(Value::Null);
276276+277277+ if !record.is_object() {
278278+ return tool_error("The 'record' argument must be a JSON object.");
279279+ }
280280+281281+ match atproto_dasl::compute_cid_for(&record) {
282282+ Ok(cid) => tool_success(&cid.to_string()),
283283+ Err(error) => {
284284+ let tool_err = ToolError::SerializationFailed {
285285+ reason: error.to_string(),
286286+ };
287287+ tracing::error!(error = ?error, "DAG-CBOR serialization failed.");
288288+ tool_error(&tool_err.to_string())
289289+ }
290290+ }
291291+}
292292+293293+/// Execute the `validate_lexicon_schema` tool.
294294+fn handle_validate_lexicon_schema(arguments: Value) -> Value {
295295+ let schema = arguments.get("schema").cloned().unwrap_or(Value::Null);
296296+297297+ if !schema.is_object() {
298298+ return tool_error("The 'schema' argument must be a JSON object.");
299299+ }
300300+301301+ match atproto_lexicon::validation::schema_file::SchemaFile::from_value(schema) {
302302+ Ok(schema_file) => tool_success(&format!("Lexicon schema '{}' is valid.", schema_file.id)),
303303+ Err(error) => {
304304+ let tool_err = ToolError::ValidationFailed {
305305+ reason: error.to_string(),
306306+ };
307307+ tracing::error!(error = ?error, "Lexicon schema validation failed.");
308308+ tool_error(&tool_err.to_string())
309309+ }
310310+ }
311311+}
312312+313313+/// Execute the `resolve_handle_to_did` tool.
314314+async fn handle_resolve_handle_to_did(arguments: Value, resolver: &InnerIdentityResolver) -> Value {
315315+ let Some(handle) = arguments.get("handle").and_then(Value::as_str) else {
316316+ return tool_error("The 'handle' argument must be a string.");
317317+ };
318318+319319+ match resolver.resolve(handle).await {
320320+ Ok(document) => tool_success(&document.id),
321321+ Err(error) => {
322322+ let tool_err = ToolError::HandleResolutionFailed {
323323+ reason: error.to_string(),
324324+ };
325325+ tracing::error!(error = ?error, handle = %handle, "Handle resolution failed.");
326326+ tool_error(&tool_err.to_string())
327327+ }
328328+ }
329329+}
330330+331331+/// Execute the `resolve_identity` tool.
332332+async fn handle_resolve_identity(
333333+ arguments: Value,
334334+ default_resolver: &InnerIdentityResolver,
335335+) -> Value {
336336+ let Some(did) = arguments.get("did").and_then(Value::as_str) else {
337337+ return tool_error("The 'did' argument must be a string.");
338338+ };
339339+340340+ let plc_hostname = arguments
341341+ .get("plc_directory_hostname")
342342+ .and_then(Value::as_str)
343343+ .unwrap_or("plc.directory");
344344+345345+ let resolver = if plc_hostname == "plc.directory" {
346346+ default_resolver
347347+ } else {
348348+ &InnerIdentityResolver {
349349+ dns_resolver: default_resolver.dns_resolver.clone(),
350350+ http_client: default_resolver.http_client.clone(),
351351+ plc_hostname: plc_hostname.to_string(),
352352+ }
353353+ };
354354+355355+ match resolver.resolve(did).await {
356356+ Ok(document) => match serde_json::to_string(&document) {
357357+ Ok(json) => tool_success(&json),
358358+ Err(error) => {
359359+ let tool_err = ToolError::IdentityResolutionFailed {
360360+ reason: error.to_string(),
361361+ };
362362+ tracing::error!(error = ?error, "Failed to serialize DID document.");
363363+ tool_error(&tool_err.to_string())
364364+ }
365365+ },
366366+ Err(error) => {
367367+ let tool_err = ToolError::IdentityResolutionFailed {
368368+ reason: error.to_string(),
369369+ };
370370+ tracing::error!(error = ?error, did = %did, "Identity resolution failed.");
371371+ tool_error(&tool_err.to_string())
372372+ }
373373+ }
374374+}
375375+376376+/// Execute the `parse_facets` tool.
377377+async fn handle_parse_facets(arguments: Value, resolver: &InnerIdentityResolver) -> Value {
378378+ let Some(text) = arguments.get("text").and_then(Value::as_str) else {
379379+ return tool_error("The 'text' argument must be a string.");
380380+ };
381381+382382+ let limits = atproto_extras::FacetLimits::default();
383383+384384+ match atproto_extras::parse_facets_from_text(text, resolver, &limits).await {
385385+ Some(facets) => match serde_json::to_string(&facets) {
386386+ Ok(json) => tool_success(&json),
387387+ Err(error) => {
388388+ let tool_err = ToolError::FacetParsingFailed {
389389+ reason: error.to_string(),
390390+ };
391391+ tracing::error!(error = ?error, "Failed to serialize facets.");
392392+ tool_error(&tool_err.to_string())
393393+ }
394394+ },
395395+ None => tool_success("[]"),
396396+ }
397397+}
398398+399399+/// Execute the `get_record` tool.
400400+async fn handle_get_record(arguments: Value, default_resolver: &InnerIdentityResolver) -> Value {
401401+ let Some(uri) = arguments.get("uri").and_then(Value::as_str) else {
402402+ return tool_error("The 'uri' argument must be a string.");
403403+ };
404404+405405+ let cid = arguments.get("cid").and_then(Value::as_str);
406406+407407+ let plc_hostname = arguments
408408+ .get("plc_directory_hostname")
409409+ .and_then(Value::as_str)
410410+ .unwrap_or("plc.directory");
411411+412412+ let aturi = match uri.parse::<atproto_record::aturi::ATURI>() {
413413+ Ok(aturi) => aturi,
414414+ Err(error) => {
415415+ let tool_err = ToolError::RecordRetrievalFailed {
416416+ reason: format!("Invalid AT-URI: {error}"),
417417+ };
418418+ tracing::error!(error = ?error, uri = %uri, "Failed to parse AT-URI.");
419419+ return tool_error(&tool_err.to_string());
420420+ }
421421+ };
422422+423423+ let resolver = if plc_hostname == "plc.directory" {
424424+ default_resolver
425425+ } else {
426426+ &InnerIdentityResolver {
427427+ dns_resolver: default_resolver.dns_resolver.clone(),
428428+ http_client: default_resolver.http_client.clone(),
429429+ plc_hostname: plc_hostname.to_string(),
430430+ }
431431+ };
432432+433433+ let document = match resolver.resolve(&aturi.authority).await {
434434+ Ok(doc) => doc,
435435+ Err(error) => {
436436+ let tool_err = ToolError::RecordRetrievalFailed {
437437+ reason: format!("Failed to resolve identity: {error}"),
438438+ };
439439+ tracing::error!(error = ?error, authority = %aturi.authority, "Identity resolution failed.");
440440+ return tool_error(&tool_err.to_string());
441441+ }
442442+ };
443443+444444+ let pds_endpoints = document.pds_endpoints();
445445+ let Some(pds_url) = pds_endpoints.first() else {
446446+ let tool_err = ToolError::RecordRetrievalFailed {
447447+ reason: "No PDS endpoint found in DID document".to_string(),
448448+ };
449449+ tracing::error!(did = %aturi.authority, "No PDS endpoint found.");
450450+ return tool_error(&tool_err.to_string());
451451+ };
452452+453453+ match atproto_client::com::atproto::repo::get_record(
454454+ &default_resolver.http_client,
455455+ &atproto_client::client::Auth::None,
456456+ pds_url,
457457+ &aturi.authority,
458458+ &aturi.collection,
459459+ &aturi.record_key,
460460+ cid,
461461+ )
462462+ .await
463463+ {
464464+ Ok(atproto_client::com::atproto::repo::GetRecordResponse::Record { value, .. }) => {
465465+ match serde_json::to_string(&value) {
466466+ Ok(json) => tool_success(&json),
467467+ Err(error) => {
468468+ let tool_err = ToolError::RecordRetrievalFailed {
469469+ reason: error.to_string(),
470470+ };
471471+ tracing::error!(error = ?error, "Failed to serialize record.");
472472+ tool_error(&tool_err.to_string())
473473+ }
474474+ }
475475+ }
476476+ Ok(atproto_client::com::atproto::repo::GetRecordResponse::Error(err)) => {
477477+ let error_str = err.error.as_deref().unwrap_or("unknown");
478478+ let message_str = err.message.as_deref().unwrap_or("unknown");
479479+ let tool_err = ToolError::RecordRetrievalFailed {
480480+ reason: format!("{error_str}: {message_str}"),
481481+ };
482482+ tracing::error!(error = %error_str, message = %message_str, "Record retrieval returned error.");
483483+ tool_error(&tool_err.to_string())
484484+ }
485485+ Err(error) => {
486486+ let tool_err = ToolError::RecordRetrievalFailed {
487487+ reason: error.to_string(),
488488+ };
489489+ tracing::error!(error = ?error, uri = %uri, "Record retrieval failed.");
490490+ tool_error(&tool_err.to_string())
491491+ }
492492+ }
493493+}
494494+495495+/// Build a successful tool call result.
496496+fn tool_success(text: &str) -> Value {
497497+ serde_json::json!({
498498+ "content": [{ "type": "text", "text": text }],
499499+ "isError": false
500500+ })
501501+}
502502+503503+/// Build an error tool call result.
504504+fn tool_error(text: &str) -> Value {
505505+ serde_json::json!({
506506+ "content": [{ "type": "text", "text": text }],
507507+ "isError": true
508508+ })
509509+}
510510+511511+/// Route an incoming JSON-RPC message and return an optional response.
512512+///
513513+/// Returns `None` for notifications (messages without an `id`).
514514+async fn route_message(
515515+ msg: JsonRpcMessage,
516516+ resolver: &InnerIdentityResolver,
517517+) -> Option<JsonRpcResponse> {
518518+ let id = match msg.id {
519519+ Some(id) => id,
520520+ None => {
521521+ // Notification - no response.
522522+ tracing::debug!(method = %msg.method, "Received notification.");
523523+ return None;
524524+ }
525525+ };
526526+527527+ let response = match msg.method.as_str() {
528528+ "initialize" => JsonRpcResponse::success(id, handle_initialize()),
529529+ "ping" => JsonRpcResponse::success(id, serde_json::json!({})),
530530+ "tools/list" => JsonRpcResponse::success(id, handle_tools_list()),
531531+ "tools/call" => handle_tools_call(id, msg.params, resolver).await,
532532+ _ => JsonRpcResponse::error(
533533+ id,
534534+ METHOD_NOT_FOUND,
535535+ format!("Method not found: {}", msg.method),
536536+ ),
537537+ };
538538+539539+ Some(response)
540540+}
541541+542542+#[tokio::main]
543543+async fn main() -> anyhow::Result<()> {
544544+ tracing_subscriber::fmt()
545545+ .with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into()))
546546+ .with_writer(std::io::stderr)
547547+ .with_ansi(false)
548548+ .init();
549549+550550+ tracing::info!("Starting atpmcp MCP server");
551551+552552+ let dns_resolver = HickoryDnsResolver::create_resolver(&[]);
553553+ let http_client = reqwest::Client::new();
554554+ let resolver = InnerIdentityResolver {
555555+ dns_resolver: Arc::new(dns_resolver),
556556+ http_client,
557557+ plc_hostname: "plc.directory".to_string(),
558558+ };
559559+560560+ let stdin = std::io::stdin();
561561+ let reader = BufReader::new(stdin.lock());
562562+ let mut stdout = std::io::stdout().lock();
563563+564564+ for line in reader.lines() {
565565+ let line = match line {
566566+ Ok(l) => l,
567567+ Err(error) => {
568568+ tracing::error!(error = ?error, "Failed to read from stdin.");
569569+ break;
570570+ }
571571+ };
572572+573573+ let line = line.trim().to_string();
574574+ if line.is_empty() {
575575+ continue;
576576+ }
577577+578578+ let msg: JsonRpcMessage = match serde_json::from_str(&line) {
579579+ Ok(m) => m,
580580+ Err(error) => {
581581+ tracing::warn!(error = ?error, "Failed to parse JSON-RPC message.");
582582+ let response = JsonRpcResponse::error(Value::Null, PARSE_ERROR, "Parse error");
583583+ let out = serde_json::to_string(&response)?;
584584+ writeln!(stdout, "{out}")?;
585585+ stdout.flush()?;
586586+ continue;
587587+ }
588588+ };
589589+590590+ if msg.jsonrpc != "2.0" {
591591+ let id = msg.id.unwrap_or(Value::Null);
592592+ let response = JsonRpcResponse::error(id, INVALID_REQUEST, "Invalid JSON-RPC version");
593593+ let out = serde_json::to_string(&response)?;
594594+ writeln!(stdout, "{out}")?;
595595+ stdout.flush()?;
596596+ continue;
597597+ }
598598+599599+ if let Some(response) = route_message(msg, &resolver).await {
600600+ let out = serde_json::to_string(&response)?;
601601+ writeln!(stdout, "{out}")?;
602602+ stdout.flush()?;
603603+ }
604604+ }
605605+606606+ tracing::info!("atpmcp MCP server shutting down");
607607+ Ok(())
608608+}
609609+610610+#[cfg(test)]
611611+mod tests {
612612+ use super::*;
613613+614614+ fn create_test_resolver() -> InnerIdentityResolver {
615615+ let dns_resolver = HickoryDnsResolver::create_resolver(&[]);
616616+ InnerIdentityResolver {
617617+ dns_resolver: Arc::new(dns_resolver),
618618+ http_client: reqwest::Client::new(),
619619+ plc_hostname: "plc.directory".to_string(),
620620+ }
621621+ }
622622+623623+ #[test]
624624+ fn test_handle_initialize() {
625625+ let result = handle_initialize();
626626+ assert_eq!(result["protocolVersion"], PROTOCOL_VERSION);
627627+ assert_eq!(result["serverInfo"]["name"], "atpmcp");
628628+ assert!(result["capabilities"]["tools"].is_object());
629629+ }
630630+631631+ #[test]
632632+ fn test_handle_tools_list() {
633633+ let result = handle_tools_list();
634634+ let tools = result["tools"].as_array().unwrap();
635635+ assert_eq!(tools.len(), 6);
636636+ assert_eq!(tools[0]["name"], "create_record_cid");
637637+ assert_eq!(tools[1]["name"], "validate_lexicon_schema");
638638+ assert_eq!(tools[2]["name"], "resolve_handle_to_did");
639639+ assert_eq!(tools[3]["name"], "resolve_identity");
640640+ assert_eq!(tools[4]["name"], "parse_facets");
641641+ assert_eq!(tools[5]["name"], "get_record");
642642+ for tool in tools {
643643+ assert!(tool["inputSchema"].is_object());
644644+ }
645645+ }
646646+647647+ // -- create_record_cid tests --
648648+649649+ #[test]
650650+ fn test_create_record_cid_success() {
651651+ let args = serde_json::json!({
652652+ "record": {
653653+ "$type": "app.bsky.feed.post",
654654+ "text": "Hello world",
655655+ "createdAt": "2025-01-19T10:00:00.000Z"
656656+ }
657657+ });
658658+ let result = handle_create_record_cid(args);
659659+ assert_eq!(result["isError"], false);
660660+661661+ let content = result["content"].as_array().unwrap();
662662+ assert_eq!(content.len(), 1);
663663+ assert_eq!(content[0]["type"], "text");
664664+665665+ let cid_str = content[0]["text"].as_str().unwrap();
666666+ assert!(!cid_str.is_empty());
667667+ }
668668+669669+ #[test]
670670+ fn test_create_record_cid_deterministic() {
671671+ let args = serde_json::json!({
672672+ "record": { "text": "deterministic test" }
673673+ });
674674+675675+ let r1 = handle_create_record_cid(args.clone());
676676+ let r2 = handle_create_record_cid(args);
677677+678678+ let cid1 = r1["content"][0]["text"].as_str().unwrap();
679679+ let cid2 = r2["content"][0]["text"].as_str().unwrap();
680680+ assert_eq!(cid1, cid2);
681681+ }
682682+683683+ #[test]
684684+ fn test_create_record_cid_different_inputs() {
685685+ let args1 = serde_json::json!({ "record": { "text": "hello" } });
686686+ let args2 = serde_json::json!({ "record": { "text": "world" } });
687687+688688+ let r1 = handle_create_record_cid(args1);
689689+ let r2 = handle_create_record_cid(args2);
690690+691691+ let cid1 = r1["content"][0]["text"].as_str().unwrap();
692692+ let cid2 = r2["content"][0]["text"].as_str().unwrap();
693693+ assert_ne!(cid1, cid2);
694694+ }
695695+696696+ #[test]
697697+ fn test_create_record_cid_non_object() {
698698+ let args = serde_json::json!({ "record": "not an object" });
699699+ let result = handle_create_record_cid(args);
700700+ assert_eq!(result["isError"], true);
701701+ }
702702+703703+ #[test]
704704+ fn test_create_record_cid_missing_record() {
705705+ let args = serde_json::json!({});
706706+ let result = handle_create_record_cid(args);
707707+ assert_eq!(result["isError"], true);
708708+ }
709709+710710+ // -- validate_lexicon_schema tests --
711711+712712+ #[test]
713713+ fn test_validate_lexicon_schema_success() {
714714+ let args = serde_json::json!({
715715+ "schema": {
716716+ "lexicon": 1,
717717+ "id": "com.example.test",
718718+ "defs": {
719719+ "main": {
720720+ "type": "record",
721721+ "key": "tid",
722722+ "record": {
723723+ "type": "object",
724724+ "required": ["text"],
725725+ "properties": {
726726+ "text": { "type": "string" }
727727+ }
728728+ }
729729+ }
730730+ }
731731+ }
732732+ });
733733+ let result = handle_validate_lexicon_schema(args);
734734+ assert_eq!(result["isError"], false);
735735+ let text = result["content"][0]["text"].as_str().unwrap();
736736+ assert!(text.contains("com.example.test"));
737737+ assert!(text.contains("valid"));
738738+ }
739739+740740+ #[test]
741741+ fn test_validate_lexicon_schema_invalid_version() {
742742+ let args = serde_json::json!({
743743+ "schema": {
744744+ "lexicon": 99,
745745+ "id": "com.example.test",
746746+ "defs": {
747747+ "main": {
748748+ "type": "record",
749749+ "key": "tid",
750750+ "record": {
751751+ "type": "object",
752752+ "required": ["text"],
753753+ "properties": {
754754+ "text": { "type": "string" }
755755+ }
756756+ }
757757+ }
758758+ }
759759+ }
760760+ });
761761+ let result = handle_validate_lexicon_schema(args);
762762+ assert_eq!(result["isError"], true);
763763+ }
764764+765765+ #[test]
766766+ fn test_validate_lexicon_schema_non_object() {
767767+ let args = serde_json::json!({ "schema": "not an object" });
768768+ let result = handle_validate_lexicon_schema(args);
769769+ assert_eq!(result["isError"], true);
770770+ }
771771+772772+ #[test]
773773+ fn test_validate_lexicon_schema_missing_schema() {
774774+ let args = serde_json::json!({});
775775+ let result = handle_validate_lexicon_schema(args);
776776+ assert_eq!(result["isError"], true);
777777+ }
778778+779779+ #[test]
780780+ fn test_validate_lexicon_schema_missing_main_def() {
781781+ let args = serde_json::json!({
782782+ "schema": {
783783+ "lexicon": 1,
784784+ "id": "com.example.test",
785785+ "defs": {}
786786+ }
787787+ });
788788+ let result = handle_validate_lexicon_schema(args);
789789+ assert_eq!(result["isError"], true);
790790+ }
791791+792792+ // -- resolve_handle_to_did tests --
793793+794794+ #[tokio::test]
795795+ async fn test_resolve_handle_to_did_missing_handle() {
796796+ let resolver = create_test_resolver();
797797+ let args = serde_json::json!({});
798798+ let result = handle_resolve_handle_to_did(args, &resolver).await;
799799+ assert_eq!(result["isError"], true);
800800+ }
801801+802802+ #[tokio::test]
803803+ async fn test_resolve_handle_to_did_non_string_handle() {
804804+ let resolver = create_test_resolver();
805805+ let args = serde_json::json!({ "handle": 123 });
806806+ let result = handle_resolve_handle_to_did(args, &resolver).await;
807807+ assert_eq!(result["isError"], true);
808808+ }
809809+810810+ // -- resolve_identity tests --
811811+812812+ #[tokio::test]
813813+ async fn test_resolve_identity_missing_did() {
814814+ let resolver = create_test_resolver();
815815+ let args = serde_json::json!({});
816816+ let result = handle_resolve_identity(args, &resolver).await;
817817+ assert_eq!(result["isError"], true);
818818+ }
819819+820820+ #[tokio::test]
821821+ async fn test_resolve_identity_non_string_did() {
822822+ let resolver = create_test_resolver();
823823+ let args = serde_json::json!({ "did": 123 });
824824+ let result = handle_resolve_identity(args, &resolver).await;
825825+ assert_eq!(result["isError"], true);
826826+ }
827827+828828+ // -- parse_facets tests --
829829+830830+ #[tokio::test]
831831+ async fn test_parse_facets_missing_text() {
832832+ let resolver = create_test_resolver();
833833+ let args = serde_json::json!({});
834834+ let result = handle_parse_facets(args, &resolver).await;
835835+ assert_eq!(result["isError"], true);
836836+ }
837837+838838+ #[tokio::test]
839839+ async fn test_parse_facets_non_string_text() {
840840+ let resolver = create_test_resolver();
841841+ let args = serde_json::json!({ "text": 123 });
842842+ let result = handle_parse_facets(args, &resolver).await;
843843+ assert_eq!(result["isError"], true);
844844+ }
845845+846846+ #[tokio::test]
847847+ async fn test_parse_facets_empty_text() {
848848+ let resolver = create_test_resolver();
849849+ let args = serde_json::json!({ "text": "no facets here" });
850850+ let result = handle_parse_facets(args, &resolver).await;
851851+ assert_eq!(result["isError"], false);
852852+ assert_eq!(result["content"][0]["text"], "[]");
853853+ }
854854+855855+ #[tokio::test]
856856+ async fn test_parse_facets_with_url() {
857857+ let resolver = create_test_resolver();
858858+ let args = serde_json::json!({ "text": "Check out https://example.com" });
859859+ let result = handle_parse_facets(args, &resolver).await;
860860+ assert_eq!(result["isError"], false);
861861+ let text = result["content"][0]["text"].as_str().unwrap();
862862+ assert!(text.contains("https://example.com"));
863863+ }
864864+865865+ #[tokio::test]
866866+ async fn test_parse_facets_with_hashtag() {
867867+ let resolver = create_test_resolver();
868868+ let args = serde_json::json!({ "text": "Hello #rust" });
869869+ let result = handle_parse_facets(args, &resolver).await;
870870+ assert_eq!(result["isError"], false);
871871+ let text = result["content"][0]["text"].as_str().unwrap();
872872+ assert!(text.contains("rust"));
873873+ }
874874+875875+ // -- get_record tests --
876876+877877+ #[tokio::test]
878878+ async fn test_get_record_missing_uri() {
879879+ let resolver = create_test_resolver();
880880+ let args = serde_json::json!({});
881881+ let result = handle_get_record(args, &resolver).await;
882882+ assert_eq!(result["isError"], true);
883883+ }
884884+885885+ #[tokio::test]
886886+ async fn test_get_record_non_string_uri() {
887887+ let resolver = create_test_resolver();
888888+ let args = serde_json::json!({ "uri": 123 });
889889+ let result = handle_get_record(args, &resolver).await;
890890+ assert_eq!(result["isError"], true);
891891+ }
892892+893893+ #[tokio::test]
894894+ async fn test_get_record_invalid_aturi() {
895895+ let resolver = create_test_resolver();
896896+ let args = serde_json::json!({ "uri": "not-a-valid-aturi" });
897897+ let result = handle_get_record(args, &resolver).await;
898898+ assert_eq!(result["isError"], true);
899899+ let text = result["content"][0]["text"].as_str().unwrap();
900900+ assert!(text.contains("Invalid AT-URI"));
901901+ }
902902+903903+ // -- message routing tests --
904904+905905+ #[tokio::test]
906906+ async fn test_route_notification_returns_none() {
907907+ let resolver = create_test_resolver();
908908+ let msg = JsonRpcMessage {
909909+ jsonrpc: "2.0".to_string(),
910910+ id: None,
911911+ method: "notifications/initialized".to_string(),
912912+ params: None,
913913+ };
914914+ assert!(route_message(msg, &resolver).await.is_none());
915915+ }
916916+917917+ #[tokio::test]
918918+ async fn test_route_unknown_method() {
919919+ let resolver = create_test_resolver();
920920+ let msg = JsonRpcMessage {
921921+ jsonrpc: "2.0".to_string(),
922922+ id: Some(Value::Number(1.into())),
923923+ method: "unknown/method".to_string(),
924924+ params: None,
925925+ };
926926+ let resp = route_message(msg, &resolver).await.unwrap();
927927+ assert!(resp.error.is_some());
928928+ assert_eq!(resp.error.unwrap().code, METHOD_NOT_FOUND);
929929+ }
930930+931931+ #[tokio::test]
932932+ async fn test_route_tools_call() {
933933+ let resolver = create_test_resolver();
934934+ let msg = JsonRpcMessage {
935935+ jsonrpc: "2.0".to_string(),
936936+ id: Some(Value::Number(1.into())),
937937+ method: "tools/call".to_string(),
938938+ params: Some(serde_json::json!({
939939+ "name": "create_record_cid",
940940+ "arguments": {
941941+ "record": { "text": "test" }
942942+ }
943943+ })),
944944+ };
945945+ let resp = route_message(msg, &resolver).await.unwrap();
946946+ assert!(resp.result.is_some());
947947+ assert!(resp.error.is_none());
948948+ }
949949+950950+ #[tokio::test]
951951+ async fn test_route_tools_call_unknown_tool() {
952952+ let resolver = create_test_resolver();
953953+ let msg = JsonRpcMessage {
954954+ jsonrpc: "2.0".to_string(),
955955+ id: Some(Value::Number(1.into())),
956956+ method: "tools/call".to_string(),
957957+ params: Some(serde_json::json!({
958958+ "name": "nonexistent_tool",
959959+ "arguments": {}
960960+ })),
961961+ };
962962+ let resp = route_message(msg, &resolver).await.unwrap();
963963+ assert!(resp.error.is_some());
964964+ }
965965+}
+1-1
crates/atproto-attestation/Cargo.toml
···11[package]
22name = "atproto-attestation"
33-version = "0.14.0"
33+version = "0.14.1"
44description = "AT Protocol attestation utilities for creating and verifying record signatures"
55readme = "README.md"
66homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1-1
crates/atproto-client/Cargo.toml
···11[package]
22name = "atproto-client"
33-version = "0.14.0"
33+version = "0.14.1"
44description = "HTTP client for AT Protocol services with OAuth and identity integration"
55readme = "README.md"
66homepage = "https://tangled.org/ngerakines.me/atproto-crates"
···11+//! Command-line tool for generating CIDs from input data.
22+//!
33+//! Computes either a DAG-CBOR record CID or a RAW CID depending on the input
44+//! content. JSON objects (starting with `{`) produce DAG-CBOR record CIDs,
55+//! while all other input produces RAW CIDs. This behavior can be overridden
66+//! with the `--raw` or `--record` flags.
77+//!
88+//! # Example Usage
99+//!
1010+//! ```bash
1111+//! # DAG-CBOR record CID from a JSON argument
1212+//! atpcid '{"text":"hello"}'
1313+//!
1414+//! # Multiple DAG-CBOR record CIDs
1515+//! atpcid '{"text":"hello"}' '{"text":"world"}'
1616+//!
1717+//! # DAG-CBOR record CID from stdin
1818+//! echo '{"text":"hello"}' | atpcid --
1919+//!
2020+//! # RAW CID from binary stdin
2121+//! cat image.jpg | atpcid --
2222+//!
2323+//! # Force RAW CID on JSON input
2424+//! atpcid --raw '{"text":"hello"}'
2525+//!
2626+//! # Force DAG-CBOR record CID from stdin
2727+//! cat data.json | atpcid --record --
2828+//!
2929+//! # RAW CIDs from files
3030+//! atpcid --files --raw image1.jpg image2.jpg
3131+//!
3232+//! # DAG-CBOR record CID from a JSON file
3333+//! atpcid --files --record data.json
3434+//! ```
3535+3636+use atproto_dasl::{compute_cid_for, compute_raw_cid};
3737+use clap::Parser;
3838+use std::io::{self, Read};
3939+use std::path::Path;
4040+4141+/// Compute a CID from input data.
4242+///
4343+/// Accepts one or more input strings as positional arguments, or reads from
4444+/// stdin when no arguments are provided (use `--` to separate from flags).
4545+/// JSON objects produce DAG-CBOR record CIDs; other input produces RAW CIDs.
4646+/// Use `--raw` or `--record` to override auto-detection.
4747+#[derive(Parser)]
4848+#[command(
4949+ name = "atpcid",
5050+ version,
5151+ about = "Compute CIDs from input data",
5252+ long_about = "
5353+Compute a CID (Content Identifier) from input data.
5454+5555+By default, the tool auto-detects the CID type based on the input:
5656+ - Input starting with '{' is treated as a JSON record and produces a
5757+ DAG-CBOR record CID (CIDv1, codec 0x71, SHA-256).
5858+ - All other input produces a RAW CID (CIDv1, codec 0x55, SHA-256).
5959+6060+Use --raw or --record to override auto-detection.
6161+6262+INPUT MODES:
6363+ Positional arguments are processed as individual inputs, each producing
6464+ one CID. Use -- to read from stdin instead of positional arguments.
6565+ Use --files to read file contents from positional argument paths.
6666+6767+EXAMPLES:
6868+ # DAG-CBOR record CID from a JSON argument
6969+ atpcid '{\"text\":\"hello\"}'
7070+7171+ # Multiple CIDs from multiple arguments
7272+ atpcid '{\"text\":\"hello\"}' '{\"text\":\"world\"}'
7373+7474+ # DAG-CBOR record CID from stdin
7575+ echo '{\"text\":\"hello\"}' | atpcid --
7676+7777+ # RAW CID from binary stdin
7878+ cat image.jpg | atpcid --
7979+8080+ # Force RAW CID on JSON input
8181+ atpcid --raw '{\"text\":\"hello\"}'
8282+8383+ # Force DAG-CBOR record CID from stdin
8484+ cat data.json | atpcid --record --
8585+8686+ # RAW CIDs from files
8787+ atpcid --files --raw image1.jpg image2.jpg
8888+8989+ # DAG-CBOR record CID from a JSON file
9090+ atpcid --files --record data.json
9191+"
9292+)]
9393+struct Args {
9494+ /// Input data strings, each producing one CID. When empty, reads from stdin.
9595+ /// With --files, each argument is treated as a file path.
9696+ inputs: Vec<String>,
9797+9898+ /// Force output as a RAW CID (codec 0x55)
9999+ #[arg(long, conflicts_with = "record")]
100100+ raw: bool,
101101+102102+ /// Force output as a DAG-CBOR record CID (codec 0x71)
103103+ #[arg(long, conflicts_with = "raw")]
104104+ record: bool,
105105+106106+ /// Treat positional arguments as file paths and read their contents
107107+ #[arg(long)]
108108+ files: bool,
109109+}
110110+111111+fn compute_and_print(data: &[u8], raw: bool, record: bool) -> anyhow::Result<()> {
112112+ let use_record = if raw {
113113+ false
114114+ } else if record {
115115+ true
116116+ } else {
117117+ first_non_whitespace_byte(data) == Some(b'{')
118118+ };
119119+120120+ let cid = if use_record {
121121+ let text = std::str::from_utf8(data)?;
122122+ let json_value: serde_json::Value = serde_json::from_str(text)?;
123123+ compute_cid_for(&json_value)?
124124+ } else {
125125+ compute_raw_cid(data)
126126+ };
127127+128128+ println!("{cid}");
129129+130130+ Ok(())
131131+}
132132+133133+fn first_non_whitespace_byte(data: &[u8]) -> Option<u8> {
134134+ data.iter().find(|b| !b.is_ascii_whitespace()).copied()
135135+}
136136+137137+fn has_double_dash() -> bool {
138138+ std::env::args_os().any(|arg| arg == "--")
139139+}
140140+141141+fn main() -> anyhow::Result<()> {
142142+ let read_stdin = has_double_dash();
143143+ let args = Args::parse();
144144+145145+ anyhow::ensure!(
146146+ !(read_stdin && args.files),
147147+ "-- and --files cannot be used together"
148148+ );
149149+150150+ if args.files {
151151+ anyhow::ensure!(
152152+ !args.inputs.is_empty(),
153153+ "--files requires at least one file path"
154154+ );
155155+ for path in &args.inputs {
156156+ let data = std::fs::read(Path::new(path))?;
157157+ compute_and_print(&data, args.raw, args.record)?;
158158+ }
159159+ } else if read_stdin {
160160+ let mut buf = Vec::new();
161161+ io::stdin().read_to_end(&mut buf)?;
162162+ compute_and_print(&buf, args.raw, args.record)?;
163163+ } else {
164164+ anyhow::ensure!(
165165+ !args.inputs.is_empty(),
166166+ "no input provided; pass data as arguments, use -- for stdin, or use --files"
167167+ );
168168+ for input in &args.inputs {
169169+ compute_and_print(input.as_bytes(), args.raw, args.record)?;
170170+ }
171171+ }
172172+173173+ Ok(())
174174+}
···11[package]
22name = "atproto-xrpcs-helloworld"
33-version = "0.14.0"
33+version = "0.14.1"
44description = "Complete example implementation of an AT Protocol XRPC service with DID web functionality and JWT authentication"
55edition.workspace = true
66rust-version.workspace = true
+1-1
crates/atproto-xrpcs/Cargo.toml
···11[package]
22name = "atproto-xrpcs"
33-version = "0.14.0"
33+version = "0.14.1"
44description = "Core building blocks for implementing AT Protocol XRPC services with JWT authorization"
55edition.workspace = true
66rust-version.workspace = true
···11+//! Configuration file management for atpxrpc.
22+//!
33+//! Handles reading and writing the persistent config file that stores
44+//! authenticated account sessions at `~/.config/atpxrpc/config.json`.
55+66+use crate::errors::XrpcCliError;
77+use anyhow::Result;
88+use serde::{Deserialize, Serialize};
99+use std::path::PathBuf;
1010+1111+/// Persistent configuration containing authenticated accounts.
1212+#[derive(Serialize, Deserialize, Default)]
1313+pub struct Config {
1414+ /// List of authenticated accounts.
1515+ pub accounts: Vec<Account>,
1616+}
1717+1818+/// A stored account with session credentials.
1919+#[derive(Serialize, Deserialize, Clone)]
2020+pub struct Account {
2121+ /// The account handle (e.g., "alice.bsky.social").
2222+ pub handle: String,
2323+ /// The resolved DID for the account.
2424+ pub did: String,
2525+ /// The PDS endpoint URL.
2626+ pub pds_endpoint: String,
2727+ /// The app password used for re-authentication.
2828+ pub app_password: String,
2929+ /// The current JWT access token.
3030+ pub access_jwt: String,
3131+ /// The current JWT refresh token.
3232+ pub refresh_jwt: String,
3333+}
3434+3535+/// Returns the path to the config file.
3636+///
3737+/// Checks `ATPXRPC_CONFIG` env var first, then falls back to
3838+/// `~/.config/atpxrpc/config.json` via `dirs::config_dir()`.
3939+pub fn config_path() -> Result<PathBuf> {
4040+ if let Ok(path) = std::env::var("ATPXRPC_CONFIG") {
4141+ return Ok(PathBuf::from(path));
4242+ }
4343+ let config_dir = dirs::config_dir().ok_or(XrpcCliError::ConfigDirNotFound)?;
4444+ Ok(config_dir.join("atpxrpc").join("config.json"))
4545+}
4646+4747+/// Loads the config from disk.
4848+///
4949+/// Returns a default empty config if the file doesn't exist.
5050+pub fn load_config() -> Result<Config> {
5151+ let path = config_path()?;
5252+ if !path.exists() {
5353+ return Ok(Config::default());
5454+ }
5555+ let content = std::fs::read_to_string(&path).map_err(|_| XrpcCliError::ConfigReadFailed {
5656+ path: path.display().to_string(),
5757+ })?;
5858+ let config: Config =
5959+ serde_json::from_str(&content).map_err(|_| XrpcCliError::ConfigReadFailed {
6060+ path: path.display().to_string(),
6161+ })?;
6262+ Ok(config)
6363+}
6464+6565+/// Saves the config to disk, creating parent directories as needed.
6666+///
6767+/// Sets file permissions to 0600 on Unix to protect stored credentials.
6868+pub fn save_config(config: &Config) -> Result<()> {
6969+ let path = config_path()?;
7070+ if let Some(parent) = path.parent() {
7171+ std::fs::create_dir_all(parent).map_err(|_| XrpcCliError::ConfigWriteFailed {
7272+ path: path.display().to_string(),
7373+ })?;
7474+ }
7575+ let content =
7676+ serde_json::to_string_pretty(config).map_err(|_| XrpcCliError::ConfigWriteFailed {
7777+ path: path.display().to_string(),
7878+ })?;
7979+ std::fs::write(&path, content).map_err(|_| XrpcCliError::ConfigWriteFailed {
8080+ path: path.display().to_string(),
8181+ })?;
8282+8383+ #[cfg(unix)]
8484+ {
8585+ use std::os::unix::fs::PermissionsExt;
8686+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).map_err(|_| {
8787+ XrpcCliError::ConfigWriteFailed {
8888+ path: path.display().to_string(),
8989+ }
9090+ })?;
9191+ }
9292+9393+ Ok(())
9494+}
+91
crates/atpxrpc/src/errors.rs
···11+//! Structured error types for the atpxrpc CLI tool.
22+//!
33+//! All errors follow the project convention of prefixed error codes
44+//! with descriptive messages using the format `error-atpxrpc-{domain}-{number}`.
55+66+use thiserror::Error;
77+88+/// Error types specific to the atpxrpc CLI tool.
99+#[derive(Debug, Error)]
1010+pub enum XrpcCliError {
1111+ /// Occurs when the config directory cannot be determined
1212+ #[error("error-atpxrpc-config-1 Cannot determine config directory")]
1313+ ConfigDirNotFound,
1414+1515+ /// Occurs when reading the config file fails
1616+ #[error("error-atpxrpc-config-2 Failed to read config file: {path}")]
1717+ ConfigReadFailed {
1818+ /// The config file path
1919+ path: String,
2020+ },
2121+2222+ /// Occurs when writing the config file fails
2323+ #[error("error-atpxrpc-config-3 Failed to write config file: {path}")]
2424+ ConfigWriteFailed {
2525+ /// The config file path
2626+ path: String,
2727+ },
2828+2929+ /// Occurs when no accounts are configured
3030+ #[error("error-atpxrpc-account-1 No accounts configured, run 'atpxrpc login' first")]
3131+ NoAccountsConfigured,
3232+3333+ /// Occurs when the specified handle is not found in config
3434+ #[error("error-atpxrpc-account-2 Account not found: {handle}")]
3535+ AccountNotFound {
3636+ /// The handle that was not found
3737+ handle: String,
3838+ },
3939+4040+ /// Occurs when multiple accounts exist but no --handle was specified
4141+ #[error(
4242+ "error-atpxrpc-account-3 Multiple accounts configured, use --handle to specify which account"
4343+ )]
4444+ AmbiguousAccount,
4545+4646+ /// Occurs when re-authentication with app password fails
4747+ #[error("error-atpxrpc-session-1 Re-authentication failed: {error}")]
4848+ ReAuthFailed {
4949+ /// The underlying error
5050+ error: String,
5151+ },
5252+5353+ /// Occurs when stdin JSON parsing fails
5454+ #[error("error-atpxrpc-input-1 Failed to parse JSON from stdin: {error}")]
5555+ StdinJsonParseFailed {
5656+ /// The parse error details
5757+ error: String,
5858+ },
5959+6060+ /// Occurs when reading raw bytes from stdin fails
6161+ #[error("error-atpxrpc-input-2 Failed to read bytes from stdin: {error}")]
6262+ StdinBytesReadFailed {
6363+ /// The read error details
6464+ error: String,
6565+ },
6666+6767+ /// Occurs when no PDS endpoint is found in DID document
6868+ #[error("error-atpxrpc-resolve-1 No PDS endpoint found in DID document for: {did}")]
6969+ NoPdsEndpointFound {
7070+ /// The DID that was resolved
7171+ did: String,
7272+ },
7373+7474+ /// Occurs when getting a service auth token fails
7575+ #[error("error-atpxrpc-session-2 Failed to get service auth token: {error}")]
7676+ ServiceAuthFailed {
7777+ /// The underlying error
7878+ error: String,
7979+ },
8080+8181+ /// Occurs when no matching service endpoint is found in a DID document
8282+ #[error(
8383+ "error-atpxrpc-resolve-2 No service endpoint found for {service_id} in DID document for: {did}"
8484+ )]
8585+ NoServiceEndpointFound {
8686+ /// The DID that was resolved
8787+ did: String,
8888+ /// The service ID that was not found
8989+ service_id: String,
9090+ },
9191+}
+1025
crates/atpxrpc/src/main.rs
···11+//! AT Protocol XRPC client with persistent session management.
22+//!
33+//! Provides a streamlined CLI for making XRPC calls using stored
44+//! app password sessions with automatic token refresh.
55+66+mod config;
77+mod errors;
88+99+use anyhow::Result;
1010+use atproto_client::client::{
1111+ AppPasswordAuth, get_apppassword_json_with_headers, post_apppassword_bytes_with_headers,
1212+ post_apppassword_json_with_headers,
1313+};
1414+use atproto_client::com::atproto::server::{create_session, refresh_session};
1515+use atproto_identity::{
1616+ config::{CertificateBundles, DnsNameservers, default_env, optional_env, version},
1717+ plc,
1818+ resolve::{HickoryDnsResolver, resolve_subject},
1919+ url::build_url,
2020+ web,
2121+};
2222+use bytes::Bytes;
2323+use clap::{Parser, Subcommand};
2424+use config::{Account, load_config, save_config};
2525+use errors::XrpcCliError;
2626+use reqwest::header::{CONTENT_TYPE, HeaderMap};
2727+use rpassword::read_password;
2828+use secrecy::{ExposeSecret, SecretString};
2929+use std::io::{self, IsTerminal, Read, Write};
3030+use std::path::PathBuf;
3131+3232+/// AT Protocol XRPC client with persistent session management.
3333+#[derive(Parser)]
3434+#[command(
3535+ name = "atpxrpc",
3636+ version,
3737+ about = "Make AT Protocol XRPC calls with persistent session management",
3838+ args_conflicts_with_subcommands = true,
3939+ long_about = "
4040+A command-line tool for making XRPC calls to AT Protocol services.
4141+Manages authentication sessions in a local config file, automatically
4242+refreshing expired tokens as needed.
4343+4444+SUBCOMMANDS:
4545+ login Log in with a handle and app password
4646+ logout Remove a stored account
4747+ accounts List all stored accounts
4848+4949+XRPC CALLS (default when no subcommand matches):
5050+ atpxrpc [--handle <handle>] <nsid> [key=value ...]
5151+5252+ Query (GET): Pass key=value pairs as positional arguments.
5353+ Procedure (POST): Pipe JSON to stdin.
5454+ Bytes (POST): Use --bytes to send raw bytes from stdin.
5555+5656+EXAMPLES:
5757+ atpxrpc login alice.bsky.social xxxx-xxxx-xxxx-xxxx
5858+ atpxrpc com.atproto.repo.describeRepo repo=alice.bsky.social
5959+ atpxrpc com.atproto.repo.getRecord repo=did:plc:... collection=app.bsky.feed.post rkey=abc
6060+ jo repo=did:plc:... collection=app.bsky.feed.post record[text]=Hello | atpxrpc com.atproto.repo.createRecord
6161+ atpxrpc --handle bob.bsky.social com.atproto.repo.listRecords repo=did:plc:...
6262+ cat image.png | atpxrpc --bytes --content-type image/png com.atproto.repo.uploadBlob
6363+6464+ENVIRONMENT VARIABLES:
6565+ ATPXRPC_CONFIG Override config file path
6666+ PLC_HOSTNAME PLC directory hostname (default: plc.directory)
6767+ USER_AGENT HTTP user agent string
6868+ CERTIFICATE_BUNDLES Additional CA certificate bundles
6969+ DNS_NAMESERVERS Custom DNS nameserver addresses
7070+"
7171+)]
7272+struct Args {
7373+ #[command(subcommand)]
7474+ command: Option<Commands>,
7575+7676+ /// Handle of the account to use for the request
7777+ #[arg(long, global = true)]
7878+ handle: Option<String>,
7979+8080+ /// XRPC method NSID (e.g., com.atproto.repo.getRecord)
8181+ nsid: Option<String>,
8282+8383+ /// Write response body to a file instead of stdout
8484+ #[arg(long)]
8585+ out: Option<PathBuf>,
8686+8787+ /// Send raw bytes from stdin instead of JSON (forces POST)
8888+ #[arg(long)]
8989+ bytes: bool,
9090+9191+ /// Content-Type header for --bytes mode
9292+ #[arg(long, default_value = "application/octet-stream")]
9393+ content_type: String,
9494+9595+ /// Query parameters as key=value pairs
9696+ params: Vec<String>,
9797+}
9898+9999+#[derive(Subcommand)]
100100+enum Commands {
101101+ /// Log in with a handle and app password, storing the session
102102+ Login {
103103+ /// Handle or DID to authenticate as
104104+ identifier: String,
105105+ /// App password (prompted securely if not provided)
106106+ #[arg(env = "ATPROTO_PASSWORD")]
107107+ password: Option<String>,
108108+ /// Print session details as JSON to stdout after login
109109+ #[arg(long)]
110110+ show: bool,
111111+ },
112112+ /// Remove a stored account from the config
113113+ Logout {
114114+ /// Handle of the account to remove
115115+ handle: String,
116116+ },
117117+ /// List all stored accounts
118118+ Accounts,
119119+ /// Refresh the access token for an account if expired
120120+ CheckAuth {
121121+ /// Handle of the account to check (optional if only one account)
122122+ handle: Option<String>,
123123+ },
124124+ /// Send a proxied XRPC request through the PDS using the atproto-proxy header
125125+ Proxy {
126126+ /// Service audience in DID#serviceId format (e.g., did:web:api.bsky.app#bsky_fg)
127127+ audience: String,
128128+ /// XRPC method NSID (e.g., app.bsky.feed.getAuthorFeed)
129129+ nsid: String,
130130+ /// Write response body to a file instead of stdout
131131+ #[arg(long)]
132132+ out: Option<PathBuf>,
133133+ /// Send raw bytes from stdin instead of JSON (forces POST)
134134+ #[arg(long)]
135135+ bytes: bool,
136136+ /// Content-Type header for --bytes mode
137137+ #[arg(long, default_value = "application/octet-stream")]
138138+ content_type: String,
139139+ /// Use manual service auth instead of the atproto-proxy header.
140140+ /// Gets a service auth token via getServiceAuth, resolves the
141141+ /// audience DID, and sends the request directly to the target service.
142142+ #[arg(long)]
143143+ manual: bool,
144144+ /// Query parameters as key=value pairs (for GET), or pipe JSON to stdin (for POST)
145145+ params: Vec<String>,
146146+ },
147147+}
148148+149149+/// Builds the shared HTTP client from environment configuration.
150150+fn build_http_client() -> Result<reqwest::Client> {
151151+ let certificate_bundles: CertificateBundles = optional_env("CERTIFICATE_BUNDLES").try_into()?;
152152+ let default_user_agent = format!(
153153+ "atpxrpc ({}; +https://tangled.org/ngerakines.me/atproto-crates)",
154154+ version()?
155155+ );
156156+ let user_agent = default_env("USER_AGENT", &default_user_agent);
157157+158158+ let mut client_builder = reqwest::Client::builder();
159159+ for ca_certificate in certificate_bundles.as_ref() {
160160+ let cert = std::fs::read(ca_certificate)?;
161161+ let cert = reqwest::Certificate::from_pem(&cert)?;
162162+ client_builder = client_builder.add_root_certificate(cert);
163163+ }
164164+ client_builder = client_builder.user_agent(user_agent);
165165+ Ok(client_builder.build()?)
166166+}
167167+168168+/// Resolves an identifier to a DID and PDS endpoint.
169169+async fn resolve_pds(
170170+ http_client: &reqwest::Client,
171171+ identifier: &str,
172172+) -> Result<(String, String, String)> {
173173+ let dns_nameservers: DnsNameservers = optional_env("DNS_NAMESERVERS").try_into()?;
174174+ let plc_hostname = default_env("PLC_HOSTNAME", "plc.directory");
175175+ let dns_resolver = HickoryDnsResolver::create_resolver(dns_nameservers.as_ref());
176176+177177+ let did = resolve_subject(http_client, &dns_resolver, identifier).await?;
178178+179179+ let document = if did.starts_with("did:plc:") {
180180+ plc::query(http_client, &plc_hostname, &did).await?
181181+ } else if did.starts_with("did:web:") {
182182+ web::query(http_client, &did).await?
183183+ } else {
184184+ anyhow::bail!("Unsupported DID method: {}", did);
185185+ };
186186+187187+ let pds_endpoints = document.pds_endpoints();
188188+ let pds_endpoint = pds_endpoints
189189+ .first()
190190+ .ok_or_else(|| XrpcCliError::NoPdsEndpointFound { did: did.clone() })?
191191+ .to_string();
192192+193193+ let handle = document
194194+ .handles()
195195+ .map(|h| h.to_string())
196196+ .unwrap_or_else(|| identifier.to_string());
197197+198198+ Ok((did, pds_endpoint, handle))
199199+}
200200+201201+/// Checks if an XRPC response indicates an expired token.
202202+fn is_expired_token_error(response: &serde_json::Value) -> bool {
203203+ response
204204+ .get("error")
205205+ .and_then(|v| v.as_str())
206206+ .is_some_and(|e| e == "ExpiredToken")
207207+}
208208+209209+/// Makes a single XRPC request using the given account credentials.
210210+async fn make_request(
211211+ http_client: &reqwest::Client,
212212+ account: &Account,
213213+ nsid: &str,
214214+ is_procedure: bool,
215215+ query_params: &[(String, String)],
216216+ json_body: &Option<serde_json::Value>,
217217+ additional_headers: &HeaderMap,
218218+) -> Result<serde_json::Value> {
219219+ let app_auth = AppPasswordAuth {
220220+ access_token: account.access_jwt.clone(),
221221+ };
222222+223223+ if is_procedure {
224224+ let body = json_body.clone().unwrap_or(serde_json::Value::Null);
225225+ let url = build_url(
226226+ &account.pds_endpoint,
227227+ &format!("/xrpc/{}", nsid),
228228+ std::iter::empty::<(&str, &str)>(),
229229+ )?
230230+ .to_string();
231231+ post_apppassword_json_with_headers(http_client, &app_auth, &url, body, additional_headers)
232232+ .await
233233+ } else {
234234+ let url = build_url(
235235+ &account.pds_endpoint,
236236+ &format!("/xrpc/{}", nsid),
237237+ query_params.iter().map(|(k, v)| (k.as_str(), v.as_str())),
238238+ )?
239239+ .to_string();
240240+ get_apppassword_json_with_headers(http_client, &app_auth, &url, additional_headers).await
241241+ }
242242+}
243243+244244+/// Makes a single XRPC bytes request using the given account credentials.
245245+async fn make_bytes_request(
246246+ http_client: &reqwest::Client,
247247+ account: &Account,
248248+ nsid: &str,
249249+ payload: Bytes,
250250+ additional_headers: &HeaderMap,
251251+) -> Result<Bytes> {
252252+ let app_auth = AppPasswordAuth {
253253+ access_token: account.access_jwt.clone(),
254254+ };
255255+ let url = build_url(
256256+ &account.pds_endpoint,
257257+ &format!("/xrpc/{}", nsid),
258258+ std::iter::empty::<(&str, &str)>(),
259259+ )?
260260+ .to_string();
261261+ post_apppassword_bytes_with_headers(http_client, &app_auth, &url, payload, additional_headers)
262262+ .await
263263+}
264264+265265+/// Executes an XRPC bytes request with automatic session refresh.
266266+///
267267+/// Detects `ExpiredToken` errors by attempting to parse the raw response
268268+/// bytes as JSON. AT Protocol errors are always JSON, so a non-JSON
269269+/// response is treated as a successful result.
270270+async fn execute_bytes_with_refresh(
271271+ http_client: &reqwest::Client,
272272+ account: &mut Account,
273273+ nsid: &str,
274274+ payload: Bytes,
275275+ additional_headers: &HeaderMap,
276276+) -> Result<Bytes> {
277277+ let response = make_bytes_request(
278278+ http_client,
279279+ account,
280280+ nsid,
281281+ payload.clone(),
282282+ additional_headers,
283283+ )
284284+ .await?;
285285+286286+ if let Ok(json_value) = serde_json::from_slice::<serde_json::Value>(&response)
287287+ && is_expired_token_error(&json_value)
288288+ {
289289+ eprintln!("Session expired, refreshing...");
290290+291291+ match refresh_session(http_client, &account.pds_endpoint, &account.refresh_jwt).await {
292292+ Ok(refreshed) => {
293293+ account.access_jwt = refreshed.access_jwt;
294294+ account.refresh_jwt = refreshed.refresh_jwt;
295295+ update_account_in_config(account)?;
296296+ eprintln!("Session refreshed.");
297297+ }
298298+ Err(_) => {
299299+ eprintln!("Refresh failed, re-authenticating...");
300300+ let session = create_session(
301301+ http_client,
302302+ &account.pds_endpoint,
303303+ &account.handle,
304304+ &account.app_password,
305305+ None,
306306+ )
307307+ .await
308308+ .map_err(|e| XrpcCliError::ReAuthFailed {
309309+ error: e.to_string(),
310310+ })?;
311311+312312+ account.access_jwt = session.access_jwt;
313313+ account.refresh_jwt = session.refresh_jwt;
314314+ update_account_in_config(account)?;
315315+ eprintln!("Re-authenticated.");
316316+ }
317317+ }
318318+319319+ return make_bytes_request(http_client, account, nsid, payload, additional_headers).await;
320320+ }
321321+322322+ Ok(response)
323323+}
324324+325325+/// Executes an XRPC request with automatic session refresh.
326326+///
327327+/// Strategy:
328328+/// 1. Try with current access_jwt.
329329+/// 2. On ExpiredToken, refresh using refresh_jwt.
330330+/// 3. If refresh fails, re-authenticate with stored app_password.
331331+/// 4. On any successful refresh/re-auth, update the config file.
332332+/// 5. Retry the original request with new tokens.
333333+async fn execute_with_refresh(
334334+ http_client: &reqwest::Client,
335335+ account: &mut Account,
336336+ nsid: &str,
337337+ is_procedure: bool,
338338+ query_params: &[(String, String)],
339339+ json_body: &Option<serde_json::Value>,
340340+ additional_headers: &HeaderMap,
341341+) -> Result<serde_json::Value> {
342342+ let response = make_request(
343343+ http_client,
344344+ account,
345345+ nsid,
346346+ is_procedure,
347347+ query_params,
348348+ json_body,
349349+ additional_headers,
350350+ )
351351+ .await?;
352352+353353+ if !is_expired_token_error(&response) {
354354+ return Ok(response);
355355+ }
356356+357357+ eprintln!("Session expired, refreshing...");
358358+359359+ match refresh_session(http_client, &account.pds_endpoint, &account.refresh_jwt).await {
360360+ Ok(refreshed) => {
361361+ account.access_jwt = refreshed.access_jwt;
362362+ account.refresh_jwt = refreshed.refresh_jwt;
363363+ update_account_in_config(account)?;
364364+ eprintln!("Session refreshed.");
365365+ }
366366+ Err(_) => {
367367+ eprintln!("Refresh failed, re-authenticating...");
368368+ let session = create_session(
369369+ http_client,
370370+ &account.pds_endpoint,
371371+ &account.handle,
372372+ &account.app_password,
373373+ None,
374374+ )
375375+ .await
376376+ .map_err(|e| XrpcCliError::ReAuthFailed {
377377+ error: e.to_string(),
378378+ })?;
379379+380380+ account.access_jwt = session.access_jwt;
381381+ account.refresh_jwt = session.refresh_jwt;
382382+ update_account_in_config(account)?;
383383+ eprintln!("Re-authenticated.");
384384+ }
385385+ }
386386+387387+ make_request(
388388+ http_client,
389389+ account,
390390+ nsid,
391391+ is_procedure,
392392+ query_params,
393393+ json_body,
394394+ additional_headers,
395395+ )
396396+ .await
397397+}
398398+399399+/// Updates a single account in the config file by matching on DID.
400400+fn update_account_in_config(account: &Account) -> Result<()> {
401401+ let mut config = load_config()?;
402402+ if let Some(existing) = config.accounts.iter_mut().find(|a| a.did == account.did) {
403403+ *existing = account.clone();
404404+ }
405405+ save_config(&config)?;
406406+ Ok(())
407407+}
408408+409409+/// Writes the JSON response to a file or stdout.
410410+fn write_response(response: &serde_json::Value, out: &Option<PathBuf>) -> Result<()> {
411411+ let json = serde_json::to_string_pretty(response)?;
412412+ if let Some(path) = out {
413413+ std::fs::write(path, json.as_bytes())?;
414414+ } else {
415415+ println!("{json}");
416416+ }
417417+ Ok(())
418418+}
419419+420420+/// Writes a bytes response to a file or stdout.
421421+///
422422+/// Attempts to parse the response as JSON for pretty-printed display.
423423+/// Falls back to writing raw bytes if the response is not valid JSON.
424424+fn write_bytes_response(response: &Bytes, out: &Option<PathBuf>) -> Result<()> {
425425+ if let Some(path) = out {
426426+ std::fs::write(path, response)?;
427427+ } else if let Ok(json_value) = serde_json::from_slice::<serde_json::Value>(response) {
428428+ println!("{}", serde_json::to_string_pretty(&json_value)?);
429429+ } else {
430430+ io::stdout().write_all(response)?;
431431+ }
432432+ Ok(())
433433+}
434434+435435+/// Selects the account to use for an XRPC call.
436436+fn select_account<'a>(
437437+ config: &'a mut config::Config,
438438+ handle: &Option<String>,
439439+) -> Result<&'a mut Account> {
440440+ if config.accounts.is_empty() {
441441+ return Err(XrpcCliError::NoAccountsConfigured.into());
442442+ }
443443+444444+ if let Some(handle) = handle {
445445+ let idx = config
446446+ .accounts
447447+ .iter()
448448+ .position(|a| a.handle == *handle)
449449+ .ok_or_else(|| XrpcCliError::AccountNotFound {
450450+ handle: handle.clone(),
451451+ })?;
452452+ Ok(&mut config.accounts[idx])
453453+ } else if config.accounts.len() == 1 {
454454+ Ok(&mut config.accounts[0])
455455+ } else {
456456+ Err(XrpcCliError::AmbiguousAccount.into())
457457+ }
458458+}
459459+460460+/// Handles the `login` subcommand.
461461+async fn handle_login(identifier: &str, password: Option<String>, show: bool) -> Result<()> {
462462+ let password = if let Some(p) = password {
463463+ SecretString::new(p.into())
464464+ } else {
465465+ eprint!("Enter app password: ");
466466+ io::stderr().flush()?;
467467+ let p = read_password()?;
468468+ if p.is_empty() {
469469+ anyhow::bail!("Password cannot be empty");
470470+ }
471471+ SecretString::new(p.into())
472472+ };
473473+474474+ let http_client = build_http_client()?;
475475+476476+ eprintln!("Resolving {}...", identifier);
477477+ let (did, pds_endpoint, handle) = resolve_pds(&http_client, identifier).await?;
478478+ eprintln!("Resolved to {} ({})", did, pds_endpoint);
479479+480480+ eprintln!("Creating session...");
481481+ let session = create_session(
482482+ &http_client,
483483+ &pds_endpoint,
484484+ identifier,
485485+ password.expose_secret(),
486486+ None,
487487+ )
488488+ .await?;
489489+490490+ let account = Account {
491491+ handle: handle.clone(),
492492+ did: session.did.clone(),
493493+ pds_endpoint: pds_endpoint.clone(),
494494+ app_password: password.expose_secret().to_string(),
495495+ access_jwt: session.access_jwt.clone(),
496496+ refresh_jwt: session.refresh_jwt.clone(),
497497+ };
498498+499499+ let mut config = load_config()?;
500500+ if let Some(existing) = config.accounts.iter_mut().find(|a| a.did == account.did) {
501501+ *existing = account;
502502+ } else {
503503+ config.accounts.push(account);
504504+ }
505505+ save_config(&config)?;
506506+507507+ eprintln!("Logged in as {} ({})", handle, session.did);
508508+509509+ if show {
510510+ println!(
511511+ "{}",
512512+ serde_json::to_string_pretty(&serde_json::json!({
513513+ "did": session.did,
514514+ "pds": pds_endpoint,
515515+ "accessJwt": session.access_jwt,
516516+ "refreshJwt": session.refresh_jwt,
517517+ }))?
518518+ );
519519+ }
520520+521521+ Ok(())
522522+}
523523+524524+/// Handles the `logout` subcommand.
525525+fn handle_logout(handle: &str) -> Result<()> {
526526+ let mut config = load_config()?;
527527+ let before = config.accounts.len();
528528+ config.accounts.retain(|a| a.handle != handle);
529529+ if config.accounts.len() == before {
530530+ return Err(XrpcCliError::AccountNotFound {
531531+ handle: handle.to_string(),
532532+ }
533533+ .into());
534534+ }
535535+ save_config(&config)?;
536536+ eprintln!("Logged out {}", handle);
537537+ Ok(())
538538+}
539539+540540+/// Handles the `accounts` subcommand.
541541+fn handle_accounts() -> Result<()> {
542542+ let config = load_config()?;
543543+ if config.accounts.is_empty() {
544544+ eprintln!("No accounts configured.");
545545+ return Ok(());
546546+ }
547547+ for account in &config.accounts {
548548+ println!(
549549+ "{}\t{}\t{}",
550550+ account.handle, account.did, account.pds_endpoint
551551+ );
552552+ }
553553+ Ok(())
554554+}
555555+556556+/// Handles the `check-auth` subcommand.
557557+///
558558+/// Calls `com.atproto.server.getSession` to test the current access token.
559559+/// If expired, refreshes via refresh token or re-authenticates with the
560560+/// stored app password, then persists the updated tokens.
561561+async fn handle_check_auth(handle: &Option<String>) -> Result<()> {
562562+ let mut config = load_config()?;
563563+ let account = select_account(&mut config, handle)?;
564564+565565+ let http_client = build_http_client()?;
566566+567567+ let response = make_request(
568568+ &http_client,
569569+ account,
570570+ "com.atproto.server.getSession",
571571+ false,
572572+ &[],
573573+ &None,
574574+ &HeaderMap::default(),
575575+ )
576576+ .await?;
577577+578578+ if is_expired_token_error(&response) {
579579+ eprintln!("Session expired, refreshing...");
580580+581581+ match refresh_session(&http_client, &account.pds_endpoint, &account.refresh_jwt).await {
582582+ Ok(refreshed) => {
583583+ account.access_jwt = refreshed.access_jwt;
584584+ account.refresh_jwt = refreshed.refresh_jwt;
585585+ update_account_in_config(account)?;
586586+ eprintln!("Session refreshed.");
587587+ }
588588+ Err(_) => {
589589+ eprintln!("Refresh failed, re-authenticating...");
590590+ let session = create_session(
591591+ &http_client,
592592+ &account.pds_endpoint,
593593+ &account.handle,
594594+ &account.app_password,
595595+ None,
596596+ )
597597+ .await
598598+ .map_err(|e| XrpcCliError::ReAuthFailed {
599599+ error: e.to_string(),
600600+ })?;
601601+602602+ account.access_jwt = session.access_jwt;
603603+ account.refresh_jwt = session.refresh_jwt;
604604+ update_account_in_config(account)?;
605605+ eprintln!("Re-authenticated.");
606606+ }
607607+ }
608608+ } else {
609609+ eprintln!("Session is valid.");
610610+ }
611611+612612+ Ok(())
613613+}
614614+615615+/// Handles an XRPC call (no subcommand).
616616+async fn handle_xrpc_call(
617617+ handle: &Option<String>,
618618+ nsid: &str,
619619+ params: &[String],
620620+ out: &Option<PathBuf>,
621621+ bytes_mode: bool,
622622+ content_type: &str,
623623+) -> Result<()> {
624624+ let mut config = load_config()?;
625625+ let account = select_account(&mut config, handle)?;
626626+627627+ if bytes_mode {
628628+ let mut raw_input = Vec::new();
629629+ io::stdin().read_to_end(&mut raw_input).map_err(|e| {
630630+ XrpcCliError::StdinBytesReadFailed {
631631+ error: e.to_string(),
632632+ }
633633+ })?;
634634+ let payload = Bytes::from(raw_input);
635635+636636+ let mut headers = HeaderMap::new();
637637+ headers.insert(CONTENT_TYPE, content_type.parse()?);
638638+639639+ let http_client = build_http_client()?;
640640+ let response =
641641+ execute_bytes_with_refresh(&http_client, account, nsid, payload, &headers).await?;
642642+643643+ return write_bytes_response(&response, out);
644644+ }
645645+646646+ let stdin_is_pipe = !io::stdin().is_terminal();
647647+648648+ let (is_procedure, json_body) = if stdin_is_pipe {
649649+ let mut input = String::new();
650650+ io::stdin().read_to_string(&mut input)?;
651651+ let value: serde_json::Value =
652652+ serde_json::from_str(&input).map_err(|e| XrpcCliError::StdinJsonParseFailed {
653653+ error: e.to_string(),
654654+ })?;
655655+ (true, Some(value))
656656+ } else {
657657+ (false, None)
658658+ };
659659+660660+ let query_params: Vec<(String, String)> = if !is_procedure {
661661+ params
662662+ .iter()
663663+ .filter_map(|arg| {
664664+ let (key, value) = arg.split_once('=')?;
665665+ Some((key.to_string(), value.to_string()))
666666+ })
667667+ .collect()
668668+ } else {
669669+ vec![]
670670+ };
671671+672672+ let http_client = build_http_client()?;
673673+ let response = execute_with_refresh(
674674+ &http_client,
675675+ account,
676676+ nsid,
677677+ is_procedure,
678678+ &query_params,
679679+ &json_body,
680680+ &HeaderMap::default(),
681681+ )
682682+ .await?;
683683+684684+ write_response(&response, out)?;
685685+ Ok(())
686686+}
687687+688688+/// Resolves the target service endpoint from an audience string.
689689+///
690690+/// Parses the audience as `DID#serviceId`, resolves the DID document,
691691+/// and returns the service endpoint URL matching the service ID fragment.
692692+/// If no fragment is provided, returns the first PDS endpoint.
693693+async fn resolve_service_endpoint(
694694+ http_client: &reqwest::Client,
695695+ audience: &str,
696696+) -> Result<(String, String)> {
697697+ let (did, service_id) = if let Some((did, fragment)) = audience.split_once('#') {
698698+ (did.to_string(), Some(format!("#{}", fragment)))
699699+ } else {
700700+ (audience.to_string(), None)
701701+ };
702702+703703+ let dns_nameservers: DnsNameservers = optional_env("DNS_NAMESERVERS").try_into()?;
704704+ let plc_hostname = default_env("PLC_HOSTNAME", "plc.directory");
705705+ let dns_resolver = HickoryDnsResolver::create_resolver(dns_nameservers.as_ref());
706706+ let resolved_did = resolve_subject(http_client, &dns_resolver, &did).await?;
707707+708708+ let document = if resolved_did.starts_with("did:plc:") {
709709+ plc::query(http_client, &plc_hostname, &resolved_did).await?
710710+ } else if resolved_did.starts_with("did:web:") {
711711+ web::query(http_client, &resolved_did).await?
712712+ } else {
713713+ anyhow::bail!("Unsupported DID method: {}", resolved_did);
714714+ };
715715+716716+ if let Some(ref sid) = service_id {
717717+ for service in &document.service {
718718+ if service.id == *sid {
719719+ return Ok((did, service.service_endpoint.clone()));
720720+ }
721721+ }
722722+ Err(XrpcCliError::NoServiceEndpointFound {
723723+ did,
724724+ service_id: sid.clone(),
725725+ }
726726+ .into())
727727+ } else {
728728+ let pds_endpoints = document.pds_endpoints();
729729+ let endpoint = pds_endpoints
730730+ .first()
731731+ .ok_or_else(|| XrpcCliError::NoPdsEndpointFound { did: did.clone() })?
732732+ .to_string();
733733+ Ok((did, endpoint))
734734+ }
735735+}
736736+737737+/// Gets a service auth token from the user's PDS.
738738+///
739739+/// Calls `com.atproto.server.getServiceAuth` with the audience DID
740740+/// and XRPC method, returning the signed JWT token.
741741+async fn get_service_auth_token(
742742+ http_client: &reqwest::Client,
743743+ account: &mut Account,
744744+ aud: &str,
745745+ lxm: &str,
746746+) -> Result<String> {
747747+ let query_params = vec![
748748+ ("aud".to_string(), aud.to_string()),
749749+ ("lxm".to_string(), lxm.to_string()),
750750+ ];
751751+ let response = execute_with_refresh(
752752+ http_client,
753753+ account,
754754+ "com.atproto.server.getServiceAuth",
755755+ false,
756756+ &query_params,
757757+ &None,
758758+ &HeaderMap::default(),
759759+ )
760760+ .await?;
761761+762762+ response
763763+ .get("token")
764764+ .and_then(|v| v.as_str())
765765+ .map(|s| s.to_string())
766766+ .ok_or_else(|| {
767767+ XrpcCliError::ServiceAuthFailed {
768768+ error: format!("Unexpected response: {}", response),
769769+ }
770770+ .into()
771771+ })
772772+}
773773+774774+/// Handles the `proxy` subcommand.
775775+///
776776+/// Sends an XRPC request through the user's PDS with the `atproto-proxy`
777777+/// header set, enabling inter-service authentication. The PDS forwards the
778778+/// request to the target service identified by the audience parameter.
779779+///
780780+/// With `--manual`, bypasses the PDS proxy mechanism and instead gets a
781781+/// service auth token via `getServiceAuth`, resolves the audience DID to
782782+/// find the target service endpoint, and sends the request directly.
783783+#[allow(clippy::too_many_arguments)]
784784+async fn handle_proxy(
785785+ handle: &Option<String>,
786786+ audience: &str,
787787+ nsid: &str,
788788+ params: &[String],
789789+ out: &Option<PathBuf>,
790790+ bytes_mode: bool,
791791+ content_type: &str,
792792+ manual: bool,
793793+) -> Result<()> {
794794+ let mut config = load_config()?;
795795+ let account = select_account(&mut config, handle)?;
796796+ let http_client = build_http_client()?;
797797+798798+ if manual {
799799+ // Extract the DID portion (before #fragment) for the service auth audience
800800+ let aud_did = audience.split_once('#').map_or(audience, |(did, _)| did);
801801+802802+ // Step 1: Get a service auth token from the user's PDS
803803+ eprintln!("Getting service auth token for {}...", aud_did);
804804+ let token = get_service_auth_token(&http_client, account, aud_did, nsid).await?;
805805+806806+ // Step 2: Resolve the audience DID to find the target service endpoint
807807+ eprintln!("Resolving service endpoint for {}...", audience);
808808+ let (_did, service_endpoint) = resolve_service_endpoint(&http_client, audience).await?;
809809+ eprintln!("Target endpoint: {}", service_endpoint);
810810+811811+ // Step 3: Make the request directly to the target service
812812+ let target_auth = AppPasswordAuth {
813813+ access_token: token,
814814+ };
815815+816816+ if bytes_mode {
817817+ let mut raw_input = Vec::new();
818818+ io::stdin().read_to_end(&mut raw_input).map_err(|e| {
819819+ XrpcCliError::StdinBytesReadFailed {
820820+ error: e.to_string(),
821821+ }
822822+ })?;
823823+ let payload = Bytes::from(raw_input);
824824+825825+ let mut headers = HeaderMap::new();
826826+ headers.insert(CONTENT_TYPE, content_type.parse()?);
827827+828828+ let url = build_url(
829829+ &service_endpoint,
830830+ &format!("/xrpc/{}", nsid),
831831+ std::iter::empty::<(&str, &str)>(),
832832+ )?
833833+ .to_string();
834834+ let response = post_apppassword_bytes_with_headers(
835835+ &http_client,
836836+ &target_auth,
837837+ &url,
838838+ payload,
839839+ &headers,
840840+ )
841841+ .await?;
842842+843843+ return write_bytes_response(&response, out);
844844+ }
845845+846846+ let stdin_is_pipe = !io::stdin().is_terminal();
847847+848848+ let (is_procedure, json_body) = if stdin_is_pipe {
849849+ let mut input = String::new();
850850+ io::stdin().read_to_string(&mut input)?;
851851+ let value: serde_json::Value =
852852+ serde_json::from_str(&input).map_err(|e| XrpcCliError::StdinJsonParseFailed {
853853+ error: e.to_string(),
854854+ })?;
855855+ (true, Some(value))
856856+ } else {
857857+ (false, None)
858858+ };
859859+860860+ let response = if is_procedure {
861861+ let body = json_body.unwrap_or(serde_json::Value::Null);
862862+ let url = build_url(
863863+ &service_endpoint,
864864+ &format!("/xrpc/{}", nsid),
865865+ std::iter::empty::<(&str, &str)>(),
866866+ )?
867867+ .to_string();
868868+ post_apppassword_json_with_headers(
869869+ &http_client,
870870+ &target_auth,
871871+ &url,
872872+ body,
873873+ &HeaderMap::default(),
874874+ )
875875+ .await?
876876+ } else {
877877+ let query_params: Vec<(String, String)> = params
878878+ .iter()
879879+ .filter_map(|arg| {
880880+ let (key, value) = arg.split_once('=')?;
881881+ Some((key.to_string(), value.to_string()))
882882+ })
883883+ .collect();
884884+ let url = build_url(
885885+ &service_endpoint,
886886+ &format!("/xrpc/{}", nsid),
887887+ query_params.iter().map(|(k, v)| (k.as_str(), v.as_str())),
888888+ )?
889889+ .to_string();
890890+ get_apppassword_json_with_headers(
891891+ &http_client,
892892+ &target_auth,
893893+ &url,
894894+ &HeaderMap::default(),
895895+ )
896896+ .await?
897897+ };
898898+899899+ write_response(&response, out)?;
900900+ return Ok(());
901901+ }
902902+903903+ if bytes_mode {
904904+ let mut raw_input = Vec::new();
905905+ io::stdin().read_to_end(&mut raw_input).map_err(|e| {
906906+ XrpcCliError::StdinBytesReadFailed {
907907+ error: e.to_string(),
908908+ }
909909+ })?;
910910+ let payload = Bytes::from(raw_input);
911911+912912+ let mut headers = HeaderMap::new();
913913+ headers.insert(
914914+ reqwest::header::HeaderName::from_static("atproto-proxy"),
915915+ reqwest::header::HeaderValue::from_str(audience)?,
916916+ );
917917+ headers.insert(CONTENT_TYPE, content_type.parse()?);
918918+919919+ let response =
920920+ execute_bytes_with_refresh(&http_client, account, nsid, payload, &headers).await?;
921921+922922+ return write_bytes_response(&response, out);
923923+ }
924924+925925+ let stdin_is_pipe = !io::stdin().is_terminal();
926926+927927+ let (is_procedure, json_body) = if stdin_is_pipe {
928928+ let mut input = String::new();
929929+ io::stdin().read_to_string(&mut input)?;
930930+ let value: serde_json::Value =
931931+ serde_json::from_str(&input).map_err(|e| XrpcCliError::StdinJsonParseFailed {
932932+ error: e.to_string(),
933933+ })?;
934934+ (true, Some(value))
935935+ } else {
936936+ (false, None)
937937+ };
938938+939939+ let query_params: Vec<(String, String)> = if !is_procedure {
940940+ params
941941+ .iter()
942942+ .filter_map(|arg| {
943943+ let (key, value) = arg.split_once('=')?;
944944+ Some((key.to_string(), value.to_string()))
945945+ })
946946+ .collect()
947947+ } else {
948948+ vec![]
949949+ };
950950+951951+ let mut headers = HeaderMap::new();
952952+ headers.insert(
953953+ reqwest::header::HeaderName::from_static("atproto-proxy"),
954954+ reqwest::header::HeaderValue::from_str(audience)?,
955955+ );
956956+957957+ let response = execute_with_refresh(
958958+ &http_client,
959959+ account,
960960+ nsid,
961961+ is_procedure,
962962+ &query_params,
963963+ &json_body,
964964+ &headers,
965965+ )
966966+ .await?;
967967+968968+ write_response(&response, out)?;
969969+ Ok(())
970970+}
971971+972972+#[tokio::main]
973973+async fn main() -> Result<()> {
974974+ let args = Args::parse();
975975+976976+ match args.command {
977977+ Some(Commands::Login {
978978+ identifier,
979979+ password,
980980+ show,
981981+ }) => handle_login(&identifier, password, show).await,
982982+ Some(Commands::Logout { handle }) => handle_logout(&handle),
983983+ Some(Commands::Accounts) => handle_accounts(),
984984+ Some(Commands::CheckAuth { handle }) => handle_check_auth(&handle).await,
985985+ Some(Commands::Proxy {
986986+ audience,
987987+ nsid,
988988+ out,
989989+ bytes: bytes_mode,
990990+ content_type,
991991+ manual,
992992+ params,
993993+ }) => {
994994+ handle_proxy(
995995+ &args.handle,
996996+ &audience,
997997+ &nsid,
998998+ ¶ms,
999999+ &out,
10001000+ bytes_mode,
10011001+ &content_type,
10021002+ manual,
10031003+ )
10041004+ .await
10051005+ }
10061006+ None => match args.nsid {
10071007+ Some(nsid) => {
10081008+ handle_xrpc_call(
10091009+ &args.handle,
10101010+ &nsid,
10111011+ &args.params,
10121012+ &args.out,
10131013+ args.bytes,
10141014+ &args.content_type,
10151015+ )
10161016+ .await
10171017+ }
10181018+ None => {
10191019+ eprintln!("Error: an XRPC method NSID or subcommand is required.");
10201020+ eprintln!("Run 'atpxrpc --help' for usage information.");
10211021+ std::process::exit(1);
10221022+ }
10231023+ },
10241024+ }
10251025+}