Compare commits

...
Author SHA1 Message Date
Tanish Manku eff94be8c0 artcraft-mcp: include realized cost on generate_image responses
Lets the LLM truthfully answer 'what did that just cost?' without
remembering the pre-flight estimate. The backend has no per-job cost
endpoint or credit-transactions API; the only way to know the realized
cost is to diff the credit balance before and after.

generate_image::run now:
- Captures get_session_credits before submit.
- Runs generation + poll as before.
- Captures get_session_credits after success.
- Computes credits_charged = balance_before - balance_after.
- Attaches { credits_charged, credits_balance_after } to the response.

Best-effort: if either credit lookup fails the generation still
succeeds and the cost just isn't reported. Caveat noted in comments:
concurrent generations settling inside this window can over-count the
diff.

The tool's text content gains a 'Cost: N credits charged. Balance
after: M credits.' line when cost info is available — picked up by
both the user reading the chat and the LLM answering follow-up
questions about cost.
2026-06-15 16:56:13 -07:00
Tanish Manku 54246fe957 artcraft-mcp: update Cargo.lock for workspace registration
Records rmcp + transitive deps added when the crate joined the parent
workspace.
2026-06-15 14:32:51 -07:00
Tanish Manku ff98e9b5b0 artcraft-mcp: fold crate into the parent workspace
Drop the standalone [workspace] block from the crate's Cargo.toml and
add 'crates/cli/artcraft-mcp' to the root workspace members list.

Kept this as the final commit on the branch — until this lands the
crate was a separate workspace, which kept the root Cargo.toml
untouched during the spike (avoiding conflicts with parallel work on
main) but meant 'cargo build' at the repo root didn't include it.

After this commit, 'cargo check -p artcraft-mcp' works from the repo
root and the crate is a first-class workspace member.
2026-06-15 14:32:34 -07:00
Tanish Manku 168977c44d artcraft-mcp: return all images from batch generations
When num_images>1, the omni_gen response is still a single job with a
single inline result; the rest of the batch is reachable through the
result's maybe_batch_token via GET /v1/media_files/batch_gen_redux/{token}.

Previously we only surfaced the single inline result, so 'num_images: 4'
returned just 1 image. Now after poll detects CompleteSuccess we follow
the batch token to fetch every member and return them all.

Inline rendering:
- Single result: 768 px thumbnail (unchanged).
- Batch: 512 px per image — 4 thumbnails at 512 px JPEG stay well under
  Claude Desktop's 1 MB tool-result cap.

Per-image inline fetch failures are logged and skipped (still return
the URL as text), so a single bad thumbnail doesn't poison the whole
response.

Multi-image text output is one URL per line, prefixed 'Image N:'.
2026-06-15 14:29:02 -07:00
Tanish Manku f872ca4a6e artcraft-mcp: structured error UX + connection check
Make failure modes legible to both the LLM and the user:

errors.rs:
- ErrorCode enum: artcraft_not_installed, not_logged_in,
  session_expired, backend_unavailable, invalid_params,
  generation_timeout, generation_failed, reference_image_failed,
  internal.
- ToolError { error_code, message, remediation, details? } that
  serializes as JSON in CallToolResult::error content blocks.
- not_logged_in() detects whether the app is installed
  (/Applications/ArtCraft.app or the cache dir) and returns the
  appropriate code + remediation text.

creds.rs:
- load_session() now returns Result<_, ToolError>. The user sees a
  proper sign-in remediation instead of a generic anyhow message.

tools/check_artcraft_connection.rs (new):
- Always succeeds, never errors. Reports desktop_app_installed,
  signed_in, session_valid, error_code, remediation. Detects expired
  sessions by making a cheap authenticated get_session_credits call.

tools/list_image_models.rs:
- 401/403 from the API now maps to session_expired.

All existing tools converted from anyhow::Result to Result<_, ToolError>
with appropriate error codes per failure path.

server.rs:
- Updated instructions teach Claude how to react to each error code
  (open desktop app and sign in, download from getartcraft.com, etc.)
  and to call check_artcraft_connection when troubleshooting.
- error_result() now serializes the full ToolError struct as JSON
  inside the error content block.

main.rs:
- New '--check' / '-c' CLI flag prints a human-readable diagnostic to
  stdout and exits, useful when the user can't see the Artcraft tools
  in Claude and wants to debug from a terminal without round-tripping
  through MCP.
2026-06-15 14:24:46 -07:00
Tanish Manku 5160019733 artcraft-mcp: 3 new tools + expanded generate_image
Split main.rs (398 lines) into modules: main.rs, server.rs, creds.rs,
inline_image.rs, tools/{generate_image,list_image_models,
get_user_status,estimate_image_cost}.rs.

New tools:

- list_image_models — GET /v1/omni_gen/models/image; returns each
  model's constraints (aspect_ratio_options, resolution_options,
  quality_options, batch_size_max, image_refs_supported, image_refs_max,
  text_prompt_max_length, etc.) so Claude can pick valid params before
  calling generate_image. Optional provider filter.

- get_user_status — parallel GETs to /v1/credits/namespace/artcraft +
  /v1/subscriptions/namespace/artcraft. Returns credits (free, monthly,
  banked, total) and active subscription info.

- estimate_image_cost — POST /v1/generate/cost_estimate/image. Takes
  model + num_reference_images + aspect_ratio + resolution + quality +
  num_images. Returns cost_in_credits, cost_in_usd_cents, is_free,
  is_unlimited, is_rate_limited, has_watermark — so Claude can confirm
  with the user before spending credits.

Expanded generate_image:

- New optional params: aspect_ratio, resolution, quality, num_images,
  reference_image_urls.
- reference_image_urls fetches each https URL, validates as image/*
  (PNG/JPEG/GIF, max 20 MB), uploads via /v1/media_files/..., and
  passes the resulting MediaFileToken to omni_gen. Marked as
  intermediate system files so they don't appear in the user's gallery.

Constraint validation is left to backend's structured errors; Claude
is expected to consult list_image_models first.
2026-06-15 14:17:17 -07:00
Tanish Manku d5b26784cb artcraft-mcp: optional model parameter on generate_image
The tool now accepts an optional `model` arg (snake_case id matching
CommonImageModel's serde rename, e.g. nano_banana_pro,
flux_pro_1p1_ultra, gpt_image_2, midjourney_8, seedream_4, etc.).
Defaults to nano_banana_pro when omitted. Unknown ids return a
structured error so the LLM can correct itself.

Valid model ids are listed in the JSON schema description so Claude
can pick one before calling the tool.
2026-06-08 12:46:17 -07:00
Tanish Manku 617f02ec15 artcraft-mcp: inline a 768px thumbnail, not the full-res image
Claude Desktop rejects tool results over 1 MB; base64 adds ~33%
overhead so the raw payload must stay under ~750 KB. Full-res PNGs
from Artcraft (often 1024+ px) routinely exceed that.

Use media_links.maybe_thumbnail_template at width 768 for the inline
preview when the API returns one; fall back to the full cdn_url
otherwise. The full URL is still returned as text so the user can
open the original at full resolution.
2026-06-08 12:37:23 -07:00
Tanish Manku afa4c9acb8 artcraft-mcp: return inline image content for in-chat preview
Previously the tool returned only Content::text(cdn_url), which Claude
Desktop renders as a "show image" link. Switch to fetching the
generated image bytes from the CDN, base64-encoding them, and
returning both Content::image and Content::text so the chat shows the
image inline while keeping the URL clickable.

On fetch failure (timeout, CDN error, etc.) we fall back to the
URL-only response so the tool still succeeds.
2026-06-08 12:30:31 -07:00
Tanish Manku b233825aac artcraft-mcp: send v4 UUID as idempotency_token
The OmniGenImageCostAndGenerateRequest doc comment claims the server
auto-generates an idempotency_token when None is passed, but the
backend actually rejects requests without one ("idempotency token does
not match the expected format"). Match the desktop app's behavior at
handle_artcraft_via_omni_endpoint.rs:30 by sending a fresh hyphenated
v4 UUID per call.
2026-06-08 12:25:47 -07:00
Tanish Manku e409f3b11d artcraft-mcp: load creds from tauri-plugin-http cookie jar
Production desktop v0.32 keeps Storyteller cookies only in the vendored
tauri-plugin-http jar at <app_cache_dir>/.cookies; persist_all_to_disk()
for the text-file path in the dev source is commented out, so shipped
builds never populate those files.

Add a JSON-parsing fallback that reads the .cookies jar, filters to
api.storyteller.ai, and extracts the session + visitor cookies. The
text-file path stays as a secondary fallback for forward-compat.

macOS-only path resolution via directories::ProjectDirs for the
"ai.artcraft.app" bundle id. Linux/Windows resolutions differ from
Tauri's app_cache_dir() and are deferred.
2026-06-08 12:22:54 -07:00
Tanish Manku 9bafc23a41 artcraft-mcp: MVP stdio server with generate_image tool
Standalone crate at crates/cli/artcraft-mcp/ exposing a single MCP tool
that submits an image generation via /v1/omni_gen/generate/image, polls
/v1/jobs/session until the job completes, and returns the CDN URL.

Reads Storyteller cookies from ~/Artcraft/credentials/ written by the
desktop app — no separate sign-in needed. 90s poll timeout.

Kept as its own [workspace] during the spike so the parent workspace
Cargo.toml stays untouched and parallel work on main doesn't conflict.
Workspace registration deferred to a final commit once the spike is
validated end-to-end against Claude Desktop.
2026-06-08 12:01:24 -07:00
15 changed files with 1507 additions and 12 deletions
Generated
+227 -12
View File
@@ -750,6 +750,28 @@ dependencies = [
"worldlabs_consumer_client",
]
[[package]]
name = "artcraft-mcp"
version = "0.0.1"
dependencies = [
"anyhow",
"artcraft_api_defs",
"artcraft_client",
"base64 0.22.1",
"directories",
"enums",
"reqwest 0.12.23",
"rmcp",
"schemars 1.2.1",
"serde",
"serde_json",
"tokens",
"tokio",
"tracing",
"tracing-subscriber",
"uuid 1.22.0",
]
[[package]]
name = "artcraft_api_defs"
version = "0.0.1"
@@ -1179,9 +1201,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.80"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
@@ -2959,6 +2981,16 @@ dependencies = [
"darling_macro 0.20.10",
]
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core 0.23.0",
"darling_macro 0.23.0",
]
[[package]]
name = "darling_core"
version = "0.13.4"
@@ -2987,6 +3019,19 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim 0.11.1",
"syn 2.0.117",
]
[[package]]
name = "darling_macro"
version = "0.13.4"
@@ -3009,6 +3054,17 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core 0.23.0",
"quote",
"syn 2.0.117",
]
[[package]]
name = "dashmap"
version = "6.1.0"
@@ -3628,9 +3684,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "dyn-clone"
version = "1.0.16"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "545b22097d44f8a9581187cdf93de7a71e4722bf51200cfaba810865b49a495d"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "easyenv"
@@ -6899,6 +6955,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]]
name = "matches"
version = "0.1.10"
@@ -7515,6 +7580,15 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num"
version = "0.4.3"
@@ -8443,6 +8517,12 @@ version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c"
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]]
name = "pathdiff"
version = "0.2.3"
@@ -9582,6 +9662,26 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "ref-cast"
version = "1.0.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "regex"
version = "1.12.3"
@@ -9883,6 +9983,41 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "rmcp"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e"
dependencies = [
"async-trait",
"base64 0.22.1",
"chrono",
"futures 0.3.32",
"pastey",
"pin-project-lite",
"rmcp-macros",
"schemars 1.2.1",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "rmcp-macros"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d"
dependencies = [
"darling 0.23.0",
"proc-macro2",
"quote",
"serde_json",
"syn 2.0.117",
]
[[package]]
name = "rodio"
version = "0.20.1"
@@ -10340,13 +10475,27 @@ checksum = "09c024468a378b7e36765cd36702b7a90cc3cba11654f6685c8f233408e89e92"
dependencies = [
"dyn-clone",
"indexmap 1.9.3",
"schemars_derive",
"schemars_derive 0.8.21",
"serde",
"serde_json",
"url",
"uuid 1.22.0",
]
[[package]]
name = "schemars"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
dependencies = [
"chrono",
"dyn-clone",
"ref-cast",
"schemars_derive 1.2.1",
"serde",
"serde_json",
]
[[package]]
name = "schemars_derive"
version = "0.8.21"
@@ -10359,6 +10508,18 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "schemars_derive"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.117",
]
[[package]]
name = "schnellru"
version = "0.2.4"
@@ -10998,6 +11159,15 @@ dependencies = [
"keccak",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shared_env_var_config"
version = "0.0.1"
@@ -12339,7 +12509,7 @@ dependencies = [
"glob",
"heck 0.5.0",
"json-patch",
"schemars",
"schemars 0.8.21",
"semver 1.0.23",
"serde",
"serde_json",
@@ -12399,7 +12569,7 @@ dependencies = [
"anyhow",
"glob",
"plist",
"schemars",
"schemars 0.8.21",
"serde",
"serde_json",
"tauri-utils",
@@ -12435,7 +12605,7 @@ dependencies = [
"dunce",
"glob",
"percent-encoding",
"schemars",
"schemars 0.8.21",
"serde",
"serde_json",
"serde_repr",
@@ -12457,7 +12627,7 @@ dependencies = [
"http 1.3.1",
"regex",
"reqwest 0.12.23",
"schemars",
"schemars 0.8.21",
"serde",
"serde_json",
"tauri",
@@ -12503,7 +12673,7 @@ dependencies = [
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
"open",
"schemars",
"schemars 0.8.21",
"serde",
"serde_json",
"tauri",
@@ -12608,7 +12778,7 @@ dependencies = [
"proc-macro2",
"quote",
"regex",
"schemars",
"schemars 0.8.21",
"semver 1.0.23",
"serde",
"serde-untagged",
@@ -12755,6 +12925,15 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "thumbnail_generator"
version = "0.1.0"
@@ -13266,6 +13445,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
[[package]]
@@ -13768,6 +13977,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "value-bag"
version = "1.12.0"
@@ -15116,7 +15331,7 @@ dependencies = [
"regex-syntax",
"rsa",
"rustls-pki-types",
"schemars",
"schemars 0.8.21",
"scopeguard",
"security-framework 2.10.0",
"semver 1.0.23",
+1
View File
@@ -30,6 +30,7 @@ members = [
"crates/api_clients/worldlabs_consumer_client",
# CLI tools for operations, etc.
"crates/cli/artcraft-mcp",
"crates/cli/devenv/dev-database-seed",
"crates/cli/devenv/dev-upload-media-file",
"crates/cli/production/db-backfill",
+2
View File
@@ -0,0 +1,2 @@
/target
/Cargo.lock
+43
View File
@@ -0,0 +1,43 @@
[package]
name = "artcraft-mcp"
version = "0.0.1"
edition = "2021"
publish = false
[[bin]]
name = "artcraft-mcp"
path = "src/main.rs"
[dependencies]
# MCP SDK
rmcp = { version = "1.7.0", features = ["server", "macros", "transport-io", "schemars"] }
schemars = "1.0"
# Async + logging
tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread", "io-std", "time"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
# Serde + errors
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
anyhow = "1.0.102"
# Home directory resolution (matches desktop crate)
directories = "6.0.0"
# Idempotency token generation — backend rejects requests without a v4 UUID
uuid = { version = "1.8.0", features = ["v4"] }
# Fetch generated image bytes + base64-encode for MCP image content
reqwest = { version = "0.12.23", default-features = false, features = ["rustls-tls", "gzip"] }
base64 = "0.22.1"
# Internal crates — path deps into the parent workspace.
# Cargo resolves their own `.workspace = true` deps against the root
# workspace at storyteller-rust/Cargo.toml (Cargo walks up from each
# path-dep target), so we don't have to mirror those.
artcraft_client = { path = "../../api_clients/artcraft/artcraft_client" }
artcraft_api_defs = { path = "../../api_clients/artcraft/artcraft_api_defs" }
enums = { path = "../../schema/public/enums" }
tokens = { path = "../../schema/public/tokens" }
+134
View File
@@ -0,0 +1,134 @@
use std::path::PathBuf;
use anyhow::{anyhow, Result};
use serde::Deserialize;
use artcraft_client::credentials::storyteller_avt_cookie::StorytellerAvtCookie;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::credentials::storyteller_session_cookie::StorytellerSessionCookie;
use artcraft_client::utils::api_host::ApiHost;
use crate::errors::ToolError;
const STORYTELLER_API_HOST: &str = "api.storyteller.ai";
/// Shared entry for every tool: returns the API host plus loaded session
/// cookies, or a structured ToolError (NOT_LOGGED_IN / ARTCRAFT_NOT_INSTALLED)
/// with a clear remediation if the desktop app hasn't been signed in.
pub fn load_session() -> Result<(ApiHost, StorytellerCredentialSet), ToolError> {
let creds = load_storyteller_credentials().map_err(|e| ToolError::internal(format!("{:#}", e)))?;
if creds.is_empty() {
return Err(ToolError::not_logged_in());
}
Ok((ApiHost::Storyteller, creds))
}
fn load_storyteller_credentials() -> Result<StorytellerCredentialSet> {
// Production desktop app keeps Storyteller cookies in the Tauri HTTP
// plugin's cookie jar at ~/Library/Caches/ai.artcraft.app/.cookies.
// The text-file path under ~/Artcraft/credentials/ is wired up in the
// dev source but the `persist_all_to_disk()` call is commented out,
// so shipped builds never populate those files. Try the cookie jar
// first, then fall back to the text files for forward-compat.
if let Some(creds) = try_load_from_tauri_cookies_jar()? {
if !creds.is_empty() {
tracing::info!("loaded storyteller cookies from tauri http plugin jar");
return Ok(creds);
}
}
let creds_dir = home_dir()?.join("Artcraft").join("credentials");
let session = read_trimmed(&creds_dir.join("artcraft_session.txt"))?
.map(StorytellerSessionCookie::new);
let avt =
read_trimmed(&creds_dir.join("artcraft_avt.txt"))?.map(StorytellerAvtCookie::new);
Ok(StorytellerCredentialSet::initialize(avt, session))
}
fn try_load_from_tauri_cookies_jar() -> Result<Option<StorytellerCredentialSet>> {
let Some(path) = tauri_cookies_jar_path() else {
return Ok(None);
};
if !path.exists() {
return Ok(None);
}
let raw = std::fs::read_to_string(&path)?;
let entries: Vec<CookieEntry> = serde_json::from_str(&raw)?;
let mut session = None;
let mut visitor = None;
for entry in entries {
if !entry.domain_is(STORYTELLER_API_HOST) {
continue;
}
let Some((name, value)) = split_name_value(&entry.raw_cookie) else {
continue;
};
match name {
"session" if session.is_none() => {
session = Some(StorytellerSessionCookie::new(value.to_string()));
}
"visitor" if visitor.is_none() => {
visitor = Some(StorytellerAvtCookie::new(value.to_string()));
}
_ => {}
}
}
Ok(Some(StorytellerCredentialSet::initialize(visitor, session)))
}
#[derive(Deserialize)]
struct CookieEntry {
raw_cookie: String,
#[serde(default)]
domain: serde_json::Value,
}
impl CookieEntry {
fn domain_is(&self, host: &str) -> bool {
if let Some(s) = self.domain.get("HostOnly").and_then(|v| v.as_str()) {
return s.eq_ignore_ascii_case(host);
}
if let Some(s) = self.domain.get("Suffix").and_then(|v| v.as_str()) {
return host.eq_ignore_ascii_case(s) || host.ends_with(&format!(".{}", s));
}
false
}
}
fn split_name_value(raw_cookie: &str) -> Option<(&str, &str)> {
let head = raw_cookie.split(';').next()?.trim();
let eq = head.find('=')?;
Some((&head[..eq], &head[eq + 1..]))
}
fn tauri_cookies_jar_path() -> Option<PathBuf> {
// Tauri's app_cache_dir() on macOS is ~/Library/Caches/<bundle_id>.
// Bundle id is ai.artcraft.app — see Info.plist of the installed app.
// ProjectDirs::from("ai", "artcraft", "app").cache_dir() resolves to
// the same path on macOS. Linux/Windows resolutions differ from
// Tauri's; deferred until we need them.
let dirs = directories::ProjectDirs::from("ai", "artcraft", "app")?;
Some(dirs.cache_dir().join(".cookies"))
}
fn read_trimmed(path: &PathBuf) -> Result<Option<String>> {
if !path.exists() {
return Ok(None);
}
let raw = std::fs::read_to_string(path)?;
let trimmed = raw.trim();
if trimmed.is_empty() {
Ok(None)
} else {
Ok(Some(trimmed.to_string()))
}
}
fn home_dir() -> Result<PathBuf> {
directories::UserDirs::new()
.map(|d| d.home_dir().to_path_buf())
.ok_or_else(|| anyhow!("could not determine home directory"))
}
+148
View File
@@ -0,0 +1,148 @@
use std::path::Path;
use serde::Serialize;
use serde_json::Value;
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
/// Artcraft desktop app hasn't been installed (or hasn't been run).
ArtcraftNotInstalled,
/// Desktop app is present but the user isn't signed in.
NotLoggedIn,
/// Session cookies present but the backend rejected them (likely expired).
SessionExpired,
/// Storyteller backend unreachable or returned an unexpected error.
BackendUnavailable,
/// Caller-provided parameters didn't pass validation.
InvalidParams,
/// Generation didn't complete inside our wait window.
GenerationTimeout,
/// Generation completed with a failure status.
GenerationFailed,
/// Reference image fetch / validation / upload failed.
ReferenceImageFailed,
/// Catch-all for unexpected errors.
Internal,
}
#[derive(Debug, Serialize)]
pub struct ToolError {
pub error_code: ErrorCode,
pub message: String,
pub remediation: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<Value>,
}
impl ToolError {
pub fn not_logged_in() -> Self {
if is_artcraft_app_installed() {
Self {
error_code: ErrorCode::NotLoggedIn,
message: "You're not signed in to Artcraft.".to_string(),
remediation:
"Open /Applications/ArtCraft.app and sign in to your account, then ask me again."
.to_string(),
details: None,
}
} else {
Self {
error_code: ErrorCode::ArtcraftNotInstalled,
message: "The Artcraft desktop app isn't installed.".to_string(),
remediation:
"Download it from https://getartcraft.com, install, sign in, then ask me again."
.to_string(),
details: None,
}
}
}
pub fn session_expired() -> Self {
Self {
error_code: ErrorCode::SessionExpired,
message: "Your Artcraft session has expired or been invalidated.".to_string(),
remediation: "Open the Artcraft desktop app and sign in again, then ask me to retry."
.to_string(),
details: None,
}
}
pub fn backend(message: impl Into<String>) -> Self {
Self {
error_code: ErrorCode::BackendUnavailable,
message: message.into(),
remediation:
"This usually clears up on its own. Try again in a minute. If it persists, check the Artcraft desktop app — if it's also failing the backend is having issues."
.to_string(),
details: None,
}
}
pub fn invalid_params(message: impl Into<String>) -> Self {
Self {
error_code: ErrorCode::InvalidParams,
message: message.into(),
remediation:
"Call list_image_models to see valid model ids and per-model constraints, then retry with corrected parameters."
.to_string(),
details: None,
}
}
pub fn generation_timeout(token: String) -> Self {
Self {
error_code: ErrorCode::GenerationTimeout,
message: "Generation did not complete within 90 seconds.".to_string(),
remediation:
"The job may still be running on the Artcraft backend — check the desktop app. Or try a faster model (call list_image_models to compare options)."
.to_string(),
details: Some(serde_json::json!({ "inference_job_token": token })),
}
}
pub fn generation_failed(message: impl Into<String>) -> Self {
Self {
error_code: ErrorCode::GenerationFailed,
message: message.into(),
remediation:
"Adjust the prompt or parameters and retry. Use list_image_models to confirm the model's constraints."
.to_string(),
details: None,
}
}
pub fn reference_image(message: impl Into<String>) -> Self {
Self {
error_code: ErrorCode::ReferenceImageFailed,
message: message.into(),
remediation:
"Reference images must be publicly-reachable https:// URLs returning image/png, image/jpeg, or image/gif (max 20 MB). Check the URL and retry."
.to_string(),
details: None,
}
}
pub fn internal(message: impl Into<String>) -> Self {
Self {
error_code: ErrorCode::Internal,
message: message.into(),
remediation:
"If this keeps happening, restart Claude Desktop and inspect ~/Library/Logs/Claude/mcp-server-artcraft.log for details."
.to_string(),
details: None,
}
}
}
/// Filesystem signals that the desktop app has been installed and at
/// least launched once. We treat either condition as "installed".
pub fn is_artcraft_app_installed() -> bool {
Path::new("/Applications/ArtCraft.app").exists() || cookie_jar_dir_exists()
}
fn cookie_jar_dir_exists() -> bool {
directories::ProjectDirs::from("ai", "artcraft", "app")
.map(|d| d.cache_dir().exists())
.unwrap_or(false)
}
@@ -0,0 +1,42 @@
use std::time::Duration;
use anyhow::Result;
use base64::Engine as _;
/// Fetch an image URL and return (base64 data, mime type) ready to hand
/// to rmcp's `Content::image`. Used to inline-render the generated image
/// in chat instead of a "show image" link.
pub async fn fetch_image_for_inline(url: &str) -> Result<(String, String)> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.gzip(true)
.build()?;
let response = client.get(url).send().await?.error_for_status()?;
let mime = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|s| s.split(';').next().unwrap_or(s).trim().to_string())
.unwrap_or_else(|| guess_mime_from_url(url).to_string());
let bytes = response.bytes().await?;
let data_b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
Ok((data_b64, mime))
}
fn guess_mime_from_url(url: &str) -> &'static str {
let lower = url.split('?').next().unwrap_or(url).to_ascii_lowercase();
if lower.ends_with(".png") {
"image/png"
} else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
"image/jpeg"
} else if lower.ends_with(".webp") {
"image/webp"
} else if lower.ends_with(".gif") {
"image/gif"
} else {
"image/png"
}
}
+52
View File
@@ -0,0 +1,52 @@
mod creds;
mod errors;
mod inline_image;
mod server;
mod tools;
use anyhow::Result;
use rmcp::transport::stdio;
use rmcp::ServiceExt;
use tracing_subscriber::EnvFilter;
use crate::server::ArtcraftServer;
#[tokio::main]
async fn main() -> Result<()> {
if std::env::args().any(|a| a == "--check" || a == "-c") {
return run_check_mode().await;
}
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_ansi(false)
.with_env_filter(
EnvFilter::try_from_env("ARTCRAFT_MCP_LOG").unwrap_or_else(|_| EnvFilter::new("info")),
)
.init();
let service = ArtcraftServer::new().serve(stdio()).await?;
service.waiting().await?;
Ok(())
}
/// Diagnostic mode (`artcraft-mcp --check`). Prints a human-readable
/// status report to stdout — handy for users who don't see the Artcraft
/// tools in Claude and want to figure out why without round-tripping
/// through the MCP transport.
async fn run_check_mode() -> Result<()> {
let report = tools::check_artcraft_connection::run().await;
match report {
Ok(value) => {
println!("artcraft-mcp check:");
println!("{}", serde_json::to_string_pretty(&value).unwrap_or_default());
Ok(())
}
Err(err) => {
let body = serde_json::to_string_pretty(&err).unwrap_or_else(|_| format!("{:?}", err));
println!("artcraft-mcp check FAILED:");
println!("{}", body);
std::process::exit(1);
}
}
}
+189
View File
@@ -0,0 +1,189 @@
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{
CallToolResult, Content, Implementation, ProtocolVersion, ServerCapabilities, ServerInfo,
};
use rmcp::{tool, tool_handler, tool_router};
use rmcp::{ErrorData as McpError, ServerHandler};
use crate::errors::ToolError;
use crate::inline_image::fetch_image_for_inline;
use crate::tools::{
check_artcraft_connection, estimate_image_cost, generate_image, get_user_status,
list_image_models,
};
/// Inline preview thumbnail width. Claude Desktop caps tool results at
/// ~1 MB; base64 adds ~33% overhead, so the total raw payload needs to
/// stay under ~700 KB. We scale the per-image width down for batches
/// so all images fit in the same response.
const INLINE_THUMBNAIL_WIDTH_SINGLE: u32 = 768;
const INLINE_THUMBNAIL_WIDTH_BATCH: u32 = 512;
const SERVER_INSTRUCTIONS: &str = "Artcraft MCP server.
Suggested workflow:
(1) list_image_models — see available models and per-model constraints.
(2) get_user_status — check credits / plan when the user asks.
(3) estimate_image_cost — preview cost before charging the user.
(4) generate_image — create the image.
Error handling: every failed tool call returns a JSON body with
`error_code`, `message`, and `remediation`. Surface the remediation to
the user. Common codes:
- not_logged_in / artcraft_not_installed: tell the user to open
/Applications/ArtCraft.app and sign in (or download from
https://getartcraft.com if not installed).
- session_expired: tell the user to open the desktop app and sign in
again.
- backend_unavailable: usually transient; suggest retrying in a minute.
- invalid_params: re-check list_image_models and retry with corrected
parameters.
- generation_timeout: the job may still be running; check the desktop
app's task queue.
If you suspect the MCP connection itself is misbehaving (no tools
returning, weird transport errors), call check_artcraft_connection.
It always succeeds and reports desktop_app_installed, signed_in,
session_valid, plus any remediation.";
#[derive(Clone)]
pub struct ArtcraftServer {
tool_router: ToolRouter<ArtcraftServer>,
}
#[tool_router]
impl ArtcraftServer {
pub fn new() -> Self {
Self {
tool_router: Self::tool_router(),
}
}
#[tool(
description = "Diagnostic health check for the Artcraft MCP connection. Always succeeds. Returns { status: \"ok\" | \"not_installed\" | \"not_logged_in\" | \"session_invalid\" | \"error\", desktop_app_installed, signed_in, session_valid, error_code, remediation }. Call this when troubleshooting or when the user asks if the integration is working."
)]
async fn check_artcraft_connection(&self) -> Result<CallToolResult, McpError> {
match check_artcraft_connection::run().await {
Ok(value) => Ok(json_success(&value)),
Err(err) => Ok(error_result(&err)),
}
}
#[tool(
description = "List all image models available through Artcraft along with per-model constraints (aspect_ratio_options, resolution_options, quality_options, batch_size_max, image_refs_supported, image_refs_max, text_prompt_max_length, etc.). Call this BEFORE generate_image so you can pick valid parameters. `provider` is optional; defaults to \"artcraft\". Pass \"all\" to see every known model."
)]
async fn list_image_models(
&self,
Parameters(args): Parameters<list_image_models::Args>,
) -> Result<CallToolResult, McpError> {
Ok(json_or_error(list_image_models::run(args).await))
}
#[tool(
description = "Get the signed-in user's current credit balance and active subscription plan. No arguments. Returns { credits: { free, monthly, banked, total }, subscription: { product_slug, next_bill_at, subscription_end_at, ... } | null }."
)]
async fn get_user_status(&self) -> Result<CallToolResult, McpError> {
Ok(json_or_error(get_user_status::run().await))
}
#[tool(
description = "Pre-flight cost estimate for an image generation. Takes the same model/aspect_ratio/resolution/quality/num_images you'd pass to generate_image, plus num_reference_images (0 for text-to-image, >0 for image-edit). Returns cost_in_credits, cost_in_usd_cents, is_free, is_unlimited, is_rate_limited, has_watermark. Use this to confirm cost with the user before generating."
)]
async fn estimate_image_cost(
&self,
Parameters(args): Parameters<estimate_image_cost::Args>,
) -> Result<CallToolResult, McpError> {
Ok(json_or_error(estimate_image_cost::run(args).await))
}
#[tool(
description = "Generate an image with Artcraft. Required: `prompt`. Optional: `model` (snake_case id — call list_image_models for valid values; defaults to nano_banana_pro), `aspect_ratio`, `resolution`, `quality`, `num_images`, `reference_image_urls` (https URLs for image-edit / img2img — only valid on models with image_refs_supported=true). Each parameter must satisfy the constraints reported by list_image_models for the chosen model. Blocks up to 90 seconds for completion. Returns the generated images inline, plus their full-resolution CDN URLs and the realized cost (credits charged + remaining balance). The cost is derived by diffing the credit balance before and after generation."
)]
async fn generate_image(
&self,
Parameters(args): Parameters<generate_image::Args>,
) -> Result<CallToolResult, McpError> {
match generate_image::run(args).await {
Ok(images) => Ok(success_images_result(images).await),
Err(err) => Ok(error_result(&err)),
}
}
}
#[tool_handler]
impl ServerHandler for ArtcraftServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::from_build_env())
.with_protocol_version(ProtocolVersion::V_2024_11_05)
.with_instructions(SERVER_INSTRUCTIONS.to_string())
}
}
async fn success_images_result(images: generate_image::GeneratedImages) -> CallToolResult {
let width = if images.images.len() > 1 {
INLINE_THUMBNAIL_WIDTH_BATCH
} else {
INLINE_THUMBNAIL_WIDTH_SINGLE
};
let width_str = width.to_string();
let mut content: Vec<Content> = Vec::with_capacity(images.images.len() * 2 + 1);
for image in &images.images {
let inline_url = image
.maybe_thumbnail_template
.as_ref()
.map(|t| t.replace("{WIDTH}", &width_str))
.unwrap_or_else(|| image.cdn_url.clone());
match fetch_image_for_inline(&inline_url).await {
Ok((data_b64, mime)) => content.push(Content::image(data_b64, mime)),
Err(err) => {
tracing::warn!("inline fetch failed for {}: {:#}", image.cdn_url, err);
}
}
}
let urls_text = if images.images.len() == 1 {
images.images[0].cdn_url.clone()
} else {
images
.images
.iter()
.enumerate()
.map(|(i, img)| format!("Image {}: {}", i + 1, img.cdn_url))
.collect::<Vec<_>>()
.join("\n")
};
let text = match &images.cost {
Some(cost) => format!(
"{}\n\nCost: {} credits charged. Balance after: {} credits.",
urls_text, cost.credits_charged, cost.credits_balance_after
),
None => urls_text,
};
content.push(Content::text(text));
CallToolResult::success(content)
}
fn json_or_error(result: Result<serde_json::Value, ToolError>) -> CallToolResult {
match result {
Ok(value) => json_success(&value),
Err(err) => error_result(&err),
}
}
fn json_success(value: &serde_json::Value) -> CallToolResult {
let body = serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string());
CallToolResult::success(vec![Content::text(body)])
}
fn error_result(err: &ToolError) -> CallToolResult {
let body = serde_json::to_string_pretty(err)
.unwrap_or_else(|_| format!("{{\"message\": \"{}\"}}", err.message.replace('"', "\\\"")));
CallToolResult::error(vec![Content::text(body)])
}
@@ -0,0 +1,62 @@
use serde_json::{json, Value};
use artcraft_client::endpoints::credits::get_session_credits::get_session_credits;
use enums::common::payments_namespace::PaymentsNamespace;
use crate::creds::load_session;
use crate::errors::{is_artcraft_app_installed, ToolError};
/// Health check that always succeeds (returns Ok) and reports a
/// structured diagnostic. Safe to call any time — useful when Claude
/// suspects something is off or when the user explicitly asks
/// "is the connection working?".
pub async fn run() -> Result<Value, ToolError> {
let app_installed = is_artcraft_app_installed();
match load_session() {
Err(err) => Ok(json!({
"status": status_from_error_code(&err.error_code),
"desktop_app_installed": app_installed,
"signed_in": false,
"session_valid": null,
"error_code": err.error_code,
"remediation": err.remediation,
})),
Ok((api_host, creds)) => {
// Cheap authenticated call. If it succeeds the session is good;
// a failure here usually means the cookie expired since sign-in.
match get_session_credits(&api_host, Some(&creds), PaymentsNamespace::Artcraft).await {
Ok(_) => Ok(json!({
"status": "ok",
"desktop_app_installed": app_installed,
"signed_in": true,
"session_valid": true,
"error_code": null,
"remediation": null,
})),
Err(api_err) => {
let expired = ToolError::session_expired();
Ok(json!({
"status": "session_invalid",
"desktop_app_installed": app_installed,
"signed_in": true,
"session_valid": false,
"error_code": expired.error_code,
"remediation": expired.remediation,
"details": { "api_error": format!("{:?}", api_err) },
}))
}
}
}
}
}
fn status_from_error_code(code: &crate::errors::ErrorCode) -> &'static str {
use crate::errors::ErrorCode::*;
match code {
ArtcraftNotInstalled => "not_installed",
NotLoggedIn => "not_logged_in",
SessionExpired => "session_invalid",
_ => "error",
}
}
@@ -0,0 +1,98 @@
use serde::Deserialize;
use serde_json::{json, Value};
use artcraft_api_defs::generate::cost_estimate::estimate_image_cost::{
EstimateImageCostRequest, GenerationMode,
};
use artcraft_client::endpoints::generate::cost_estimate::image::estimate_image_cost::estimate_image_cost;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_image_model::CommonImageModel;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation_provider::GenerationProvider;
use crate::creds::load_session;
use crate::errors::ToolError;
#[derive(Debug, Deserialize, rmcp::schemars::JsonSchema)]
pub struct Args {
/// Model id (snake_case). Call list_image_models for valid ids.
pub model: String,
/// Number of reference images. 0 (default) → text_to_image; >0 →
/// image_edit with that many input images.
#[serde(default)]
pub num_reference_images: Option<u32>,
/// Optional aspect ratio (snake_case). See model's aspect_ratio_options.
#[serde(default)]
pub aspect_ratio: Option<String>,
/// Optional resolution (snake_case). See model's resolution_options.
#[serde(default)]
pub resolution: Option<String>,
/// Optional quality preset (snake_case). See model's quality_options.
#[serde(default)]
pub quality: Option<String>,
/// Number of images per generation. Defaults to 1.
#[serde(default)]
pub num_images: Option<u16>,
}
pub async fn run(args: Args) -> Result<Value, ToolError> {
let (api_host, creds) = load_session()?;
let model = parse_enum::<CommonImageModel>("model", &args.model)?;
let aspect_ratio = parse_optional_enum::<CommonAspectRatio>("aspect_ratio", args.aspect_ratio.as_deref())?;
let resolution = parse_optional_enum::<CommonResolution>("resolution", args.resolution.as_deref())?;
let quality = parse_optional_enum::<CommonQuality>("quality", args.quality.as_deref())?;
let generation_mode = match args.num_reference_images.unwrap_or(0) {
0 => GenerationMode::TextToImage,
n => GenerationMode::ImageEdit { count: n },
};
let request = EstimateImageCostRequest {
model,
provider: GenerationProvider::Artcraft,
generation_mode,
aspect_ratio,
resolution,
quality,
image_batch_count: args.num_images,
};
let response = estimate_image_cost(&api_host, Some(&creds), request)
.await
.map_err(|e| ToolError::backend(format!("cost estimate failed: {:?}", e)))?;
Ok(json!({
"cost_in_credits": response.cost_in_credits,
"cost_in_usd_cents": response.cost_in_usd_cents,
"is_free": response.is_free,
"is_unlimited": response.is_unlimited,
"is_rate_limited": response.is_rate_limited,
"has_watermark": response.has_watermark,
}))
}
fn parse_enum<T: serde::de::DeserializeOwned>(field: &str, raw: &str) -> Result<T, ToolError> {
serde_json::from_value::<T>(serde_json::Value::String(raw.to_string())).map_err(|_| {
ToolError::invalid_params(format!(
"invalid {}: {}. Call list_image_models for valid values.",
field, raw
))
})
}
fn parse_optional_enum<T: serde::de::DeserializeOwned>(
field: &str,
raw: Option<&str>,
) -> Result<Option<T>, ToolError> {
match raw.map(str::trim).filter(|s| !s.is_empty()) {
None => Ok(None),
Some(s) => parse_enum::<T>(field, s).map(Some),
}
}
@@ -0,0 +1,390 @@
use std::collections::HashSet;
use std::time::Duration;
use rmcp::schemars;
use serde::Deserialize;
use tokio::time::{sleep, Instant};
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::endpoints::credits::get_session_credits::get_session_credits;
use artcraft_client::endpoints::jobs::list_session_jobs::{list_session_jobs, States};
use artcraft_client::endpoints::media_files::list_batch_generated_redux_media_files::list_batch_generated_redux_media_files;
use artcraft_client::endpoints::media_files::upload_image_media_file_from_bytes::{
upload_image_media_file_from_bytes, ImageType, UploadImageBytesArgs,
};
use artcraft_client::endpoints::omni_gen::generate::image::omni_gen_image::omni_gen_image_generate;
use enums::common::payments_namespace::PaymentsNamespace;
use artcraft_client::utils::api_host::ApiHost;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_image_model::CommonImageModel;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::job_status_plus::JobStatusPlus;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::creds::load_session;
use crate::errors::ToolError;
const POLL_INTERVAL: Duration = Duration::from_secs(3);
const POLL_TIMEOUT: Duration = Duration::from_secs(90);
const DEFAULT_MODEL: CommonImageModel = CommonImageModel::NanoBananaPro;
const MAX_REFERENCE_IMAGE_BYTES: usize = 20 * 1024 * 1024;
const REFERENCE_FETCH_TIMEOUT: Duration = Duration::from_secs(30);
pub struct GeneratedImage {
pub cdn_url: String,
pub maybe_thumbnail_template: Option<String>,
}
pub struct GeneratedImages {
pub images: Vec<GeneratedImage>,
/// None if either credit lookup failed; otherwise the realized cost
/// of this generation derived by diffing the user's credit balance
/// from just-before-submit to just-after-success.
pub cost: Option<CostInfo>,
}
pub struct CostInfo {
pub credits_charged: u64,
pub credits_balance_after: u64,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct Args {
/// Text prompt describing the image to generate.
pub prompt: String,
/// Optional model id (snake_case). Call list_image_models for valid
/// ids and per-model constraints. Defaults to nano_banana_pro.
#[serde(default)]
pub model: Option<String>,
/// Optional aspect ratio (snake_case). Must appear in the chosen
/// model's aspect_ratio_options. See list_image_models.
#[serde(default)]
pub aspect_ratio: Option<String>,
/// Optional resolution (snake_case). Must appear in the chosen
/// model's resolution_options. See list_image_models.
#[serde(default)]
pub resolution: Option<String>,
/// Optional quality preset (snake_case). Must appear in the model's
/// quality_options. See list_image_models.
#[serde(default)]
pub quality: Option<String>,
/// Number of images to generate in this batch. Defaults to 1.
/// Hard cap is per-model (batch_size_max from list_image_models).
#[serde(default)]
pub num_images: Option<u16>,
/// Optional https:// URLs of reference images for image-edit (img2img)
/// generation. Each is fetched, validated as image/*, uploaded as a
/// media file, and passed to the model. Only valid when the chosen
/// model has image_refs_supported=true. Max 20 MB per image.
#[serde(default)]
pub reference_image_urls: Option<Vec<String>>,
}
pub async fn run(args: Args) -> Result<GeneratedImages, ToolError> {
let prompt = args.prompt.trim();
if prompt.is_empty() {
return Err(ToolError::invalid_params("prompt is empty"));
}
let model = resolve_model(args.model.as_deref())?;
let aspect_ratio =
parse_optional_enum::<CommonAspectRatio>("aspect_ratio", args.aspect_ratio.as_deref())?;
let resolution =
parse_optional_enum::<CommonResolution>("resolution", args.resolution.as_deref())?;
let quality = parse_optional_enum::<CommonQuality>("quality", args.quality.as_deref())?;
let (api_host, creds) = load_session()?;
let image_media_tokens = match args.reference_image_urls.as_deref() {
None => None,
Some(urls) if urls.is_empty() => None,
Some(urls) => Some(upload_reference_images(urls, &api_host, &creds).await?),
};
let request = OmniGenImageCostAndGenerateRequest {
idempotency_token: Some(uuid::Uuid::new_v4().hyphenated().to_string()),
model: Some(model),
prompt: Some(prompt.to_string()),
image_media_tokens,
resolution,
aspect_ratio,
quality,
image_batch_count: args.num_images.or(Some(1)),
adjust_horizontal_angle: None,
adjust_vertical_angle: None,
adjust_zoom: None,
};
// Snapshot credits before submit so we can compute the realized cost
// by diffing against the balance after success. Best-effort — if the
// credits API hiccups we still complete the generation, we just don't
// report a cost.
let credits_before = get_session_credits(&api_host, Some(&creds), PaymentsNamespace::Artcraft)
.await
.map(|c| c.sum_total_credits)
.map_err(|e| tracing::warn!("pre-submit credits lookup failed: {:?}", e))
.ok();
tracing::info!("submitting omni_gen image request, model={:?}", model);
let submit = omni_gen_image_generate(&api_host, Some(&creds), request)
.await
.map_err(|e| ToolError::backend(format!("submit failed: {:?}", e)))?;
if !submit.success {
return Err(ToolError::backend("submit returned success=false"));
}
tracing::info!("submitted; inference_job_token={:?}", submit.inference_job_token);
let mut result = poll_for_image_url(&api_host, &creds, &submit.inference_job_token).await?;
// Compute realized cost. If concurrent generations on other tools
// settle in this window the diff can over-count, so we annotate
// the value in the log; the LLM gets the number either way.
let credits_after = get_session_credits(&api_host, Some(&creds), PaymentsNamespace::Artcraft)
.await
.map(|c| c.sum_total_credits)
.map_err(|e| {
tracing::warn!("post-success credits lookup failed: {:?}", e);
})
.ok();
if let (Some(before), Some(after)) = (credits_before, credits_after) {
let charged = before.saturating_sub(after);
tracing::info!(
"realized cost: {} credits (balance {} -> {})",
charged,
before,
after
);
result.cost = Some(CostInfo {
credits_charged: charged,
credits_balance_after: after,
});
}
Ok(result)
}
async fn poll_for_image_url(
api_host: &ApiHost,
creds: &StorytellerCredentialSet,
target: &InferenceJobToken,
) -> Result<GeneratedImages, ToolError> {
let deadline = Instant::now() + POLL_TIMEOUT;
let mut include_states = HashSet::new();
include_states.insert(JobStatusPlus::CompleteSuccess);
include_states.insert(JobStatusPlus::CompleteFailure);
include_states.insert(JobStatusPlus::Dead);
loop {
if Instant::now() >= deadline {
return Err(ToolError::generation_timeout(format!("{:?}", target)));
}
let response = list_session_jobs(
api_host,
Some(creds),
States::Include(include_states.clone()),
)
.await
.map_err(|e| ToolError::backend(format!("poll failed: {:?}", e)))?;
if let Some(job) = response.jobs.iter().find(|j| &j.job_token == target) {
match job.status.status {
JobStatusPlus::CompleteSuccess => {
let result = job.maybe_result.as_ref().ok_or_else(|| {
ToolError::backend("job marked complete but result missing")
})?;
// Batched generation returns one media file per result, all
// tied together by maybe_batch_token. The single result on
// the job is just one of them — fetch the rest via the batch
// endpoint.
if let Some(batch_token) = &result.maybe_batch_token {
let batch = list_batch_generated_redux_media_files(
api_host,
Some(creds),
batch_token,
)
.await
.map_err(|e| ToolError::backend(format!("batch fetch failed: {:?}", e)))?;
let images: Vec<GeneratedImage> = batch
.media_files
.iter()
.map(|m| GeneratedImage {
cdn_url: m.media_links.cdn_url.to_string(),
maybe_thumbnail_template: m.media_links.maybe_thumbnail_template.clone(),
})
.collect();
tracing::info!("batch fetch returned {} images", images.len());
// Defensive: backend could in theory return zero items.
if images.is_empty() {
return Ok(GeneratedImages {
images: vec![GeneratedImage {
cdn_url: result.media_links.cdn_url.to_string(),
maybe_thumbnail_template: result
.media_links
.maybe_thumbnail_template
.clone(),
}],
cost: None,
});
}
return Ok(GeneratedImages { images, cost: None });
}
return Ok(GeneratedImages {
images: vec![GeneratedImage {
cdn_url: result.media_links.cdn_url.to_string(),
maybe_thumbnail_template: result.media_links.maybe_thumbnail_template.clone(),
}],
cost: None,
});
}
JobStatusPlus::CompleteFailure | JobStatusPlus::Dead => {
let msg = job
.status
.maybe_failure_message
.clone()
.unwrap_or_else(|| format!("{:?}", job.status.status));
return Err(ToolError::generation_failed(msg));
}
_ => {}
}
}
sleep(POLL_INTERVAL).await;
}
}
async fn upload_reference_images(
urls: &[String],
api_host: &ApiHost,
creds: &StorytellerCredentialSet,
) -> Result<Vec<MediaFileToken>, ToolError> {
let client = reqwest::Client::builder()
.timeout(REFERENCE_FETCH_TIMEOUT)
.gzip(true)
.build()
.map_err(|e| ToolError::internal(format!("client build failed: {:?}", e)))?;
let mut tokens = Vec::with_capacity(urls.len());
for url in urls {
let token = upload_one_reference(url, &client, api_host, creds).await?;
tokens.push(token);
}
Ok(tokens)
}
async fn upload_one_reference(
url: &str,
client: &reqwest::Client,
api_host: &ApiHost,
creds: &StorytellerCredentialSet,
) -> Result<MediaFileToken, ToolError> {
if !url.starts_with("https://") {
return Err(ToolError::reference_image(format!(
"URL must use https://: {}",
url
)));
}
let response = client
.get(url)
.send()
.await
.map_err(|e| ToolError::reference_image(format!("fetch failed for {}: {:?}", url, e)))?
.error_for_status()
.map_err(|e| ToolError::reference_image(format!("HTTP error for {}: {:?}", url, e)))?;
let mime_raw = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|s| s.split(';').next().unwrap_or(s).trim().to_ascii_lowercase())
.unwrap_or_default();
let image_type = match mime_raw.as_str() {
"image/png" => ImageType::Png,
"image/jpeg" | "image/jpg" => ImageType::Jpeg,
"image/gif" => ImageType::Gif,
other => {
return Err(ToolError::reference_image(format!(
"unsupported content-type {:?} for {}",
other, url
)))
}
};
let bytes = response
.bytes()
.await
.map_err(|e| ToolError::reference_image(format!("read body failed for {}: {:?}", url, e)))?;
if bytes.len() > MAX_REFERENCE_IMAGE_BYTES {
return Err(ToolError::reference_image(format!(
"reference image is {} bytes (cap {}): {}",
bytes.len(),
MAX_REFERENCE_IMAGE_BYTES,
url
)));
}
tracing::info!("uploading reference image: {} bytes from {}", bytes.len(), url);
let upload = upload_image_media_file_from_bytes(UploadImageBytesArgs {
api_host,
maybe_creds: Some(creds),
image_bytes: bytes.to_vec(),
image_type,
is_intermediate_system_file: true,
maybe_generation_provider: None,
})
.await
.map_err(|e| ToolError::backend(format!("media upload failed: {:?}", e)))?;
if !upload.success {
return Err(ToolError::backend("media upload returned success=false"));
}
Ok(upload.media_file_token)
}
fn resolve_model(raw: Option<&str>) -> Result<CommonImageModel, ToolError> {
match raw.map(str::trim).filter(|s| !s.is_empty()) {
None => Ok(DEFAULT_MODEL),
Some(s) => parse_enum::<CommonImageModel>("model", s),
}
}
fn parse_optional_enum<T: serde::de::DeserializeOwned>(
field: &str,
raw: Option<&str>,
) -> Result<Option<T>, ToolError> {
match raw.map(str::trim).filter(|s| !s.is_empty()) {
None => Ok(None),
Some(s) => parse_enum::<T>(field, s).map(Some),
}
}
fn parse_enum<T: serde::de::DeserializeOwned>(field: &str, raw: &str) -> Result<T, ToolError> {
serde_json::from_value::<T>(serde_json::Value::String(raw.to_string())).map_err(|_| {
ToolError::invalid_params(format!(
"invalid {}: {}. Call list_image_models for valid values.",
field, raw
))
})
}
@@ -0,0 +1,33 @@
use serde_json::{json, Value};
use tokio::try_join;
use artcraft_client::endpoints::credits::get_session_credits::get_session_credits;
use artcraft_client::endpoints::subscriptions::get_session_subscription::get_session_subscription;
use enums::common::payments_namespace::PaymentsNamespace;
use crate::creds::load_session;
use crate::errors::ToolError;
pub async fn run() -> Result<Value, ToolError> {
let (api_host, creds) = load_session()?;
let credits_fut = get_session_credits(&api_host, Some(&creds), PaymentsNamespace::Artcraft);
let subscription_fut =
get_session_subscription(&api_host, Some(&creds), PaymentsNamespace::Artcraft);
let (credits, subscription) = try_join!(credits_fut, subscription_fut)
.map_err(|e| ToolError::backend(format!("user status fetch failed: {:?}", e)))?;
let subscription_value = serde_json::to_value(&subscription.active_subscription)
.map_err(|e| ToolError::internal(format!("failed to serialize subscription: {:?}", e)))?;
Ok(json!({
"credits": {
"free": credits.free_credits,
"monthly": credits.monthly_credits,
"banked": credits.banked_credits,
"total": credits.sum_total_credits,
},
"subscription": subscription_value,
}))
}
@@ -0,0 +1,81 @@
use serde::Deserialize;
use serde_json::Value;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use crate::creds::load_session;
use crate::errors::ToolError;
const PATH: &str = "/v1/omni_gen/models/image";
#[derive(Debug, Deserialize, rmcp::schemars::JsonSchema)]
pub struct Args {
/// Provider filter. "artcraft" (default) lists only models reachable
/// via the Artcraft account. "all" lists every known model across
/// all providers — most won't be callable without per-provider creds.
#[serde(default)]
pub provider: Option<String>,
}
pub async fn run(args: Args) -> Result<Value, ToolError> {
let (api_host, creds) = load_session()?;
let path = match args
.provider
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
Some(p) => format!("{}?provider={}", PATH, p),
None => PATH.to_string(),
};
// artcraft_client's basic_json_get_request is pub(crate) so we can't
// reuse it from outside the crate — do a direct reqwest GET with the
// same cookie-header convention.
get_json(&api_host, &path, &creds).await
}
async fn get_json(
api_host: &ApiHost,
path: &str,
creds: &StorytellerCredentialSet,
) -> Result<Value, ToolError> {
let url = format!("{}{}", api_host.to_api_hostname_and_scheme(), path);
let client = reqwest::Client::builder()
.gzip(true)
.build()
.map_err(|e| ToolError::internal(format!("client build failed: {:?}", e)))?;
let mut request = client.get(&url).header("Accept", "application/json");
if let Some(cookie) = creds.maybe_as_cookie_header() {
request = request.header("Cookie", cookie);
}
let response = request
.send()
.await
.map_err(|e| ToolError::backend(format!("request to {} failed: {:?}", url, e)))?;
let status = response.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return Err(ToolError::session_expired());
}
if !status.is_success() {
return Err(ToolError::backend(format!(
"HTTP {} from {}",
status.as_u16(),
url
)));
}
let body = response
.text()
.await
.map_err(|e| ToolError::backend(format!("reading response body failed: {:?}", e)))?;
serde_json::from_str(&body)
.map_err(|e| ToolError::backend(format!("response was not JSON: {:?}", e)))
}
+5
View File
@@ -0,0 +1,5 @@
pub mod check_artcraft_connection;
pub mod estimate_image_cost;
pub mod generate_image;
pub mod get_user_status;
pub mod list_image_models;