Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

M. Instance (mastodon-custom)

mastodon-custom is the any-instance Mastodon provider. Instead of using a server-registered app, the server calls the target instance’s POST /api/v1/apps during the connect handshake, stores the returned client id/secret encrypted, and then runs the normal Mastodon OAuth flow on that instance. Users only supply the instance URL.

  • Rust source: crates/rustiz-providers/src/mastodon.rs (Mastodon::custom)
  • Upstream port: mastodon.custom.provider.ts
  • Editor: Normal
  • Connection mode: externalUrl
  • Shared code: the posting, media and pending logic is identical to mastodon; this page covers the differences.

Overview

PropertyValue
Identifiermastodon-custom
Display nameM. Instance
Connection modeexternalUrl (the server registers an OAuth app on the instance)
Editornormal
Channel kindsocial
Comments / threadsYes. supports_comments = true
Pending / async publishingYes. supports_pending = true
AnalyticsNo
Post analyticsNo
MentionsNo
is_between_stepsfalse (the instance URL is collected before the OAuth login, not as a second step)
convert_to_jpegfalse
max_concurrent_job5
Character limit (max_length)500 characters, hard-coded
OAuth scopeswrite:statuses, profile, write:media
Redirect URI{FRONTEND_URL}/integrations/social/mastodon (same as mastodon)

The display name “M. Instance” is deliberate: it is the provider for “some Mastodon instance”, not a hosted mastodon.social app.

Server Environment Variables

required_env() returns nothing:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    if self.custom {
        &[]
    } else {
        &["MASTODON_CLIENT_ID", "MASTODON_CLIENT_SECRET"]
    }
}
}
VariableRequiredDefaultPurpose
MASTODON_URLNohttps://mastodon.socialFallback instance only. Used by instance_url() if the channel’s encrypted customInstanceDetails cannot be decrypted or has no instanceUrl, and as default_instance() for edge cases.
FRONTEND_URLYes in practicehttp://localhost:5000Base of the redirect URI registered on every instance
JWT_SECRETYesemptyEncrypts the per-channel {instanceUrl, client_id, client_secret} blob; changing it breaks existing channels

There is no MASTODON_CUSTOM_* env var and no MASTODON_BASE_URL override. The instance is per channel, supplied as externalUrl at connect time.

rustiz providers --status reports mastodon-custom as configured even with no environment at all, because it needs none. It also reports the connection mode as externalUrl; agents use that to know they must pass externalUrl.

Step-by-Step Developer App Setup

There is no developer portal step for the operator. Each user’s instance registers the app on demand.

What the server does during connect

oauth::begin() sees externalUrl, calls Mastodon::external_url(url), which posts a multipart form to:

POST {externalUrl}/api/v1/apps

with exactly:

FieldValue
client_nameRustiz (hard-coded)
redirect_uris{FRONTEND_URL}/integrations/social/mastodon
scopeswrite:statuses profile write:media (space-separated)
website{FRONTEND_URL}

The instance returns client_id and client_secret. Rustiz stores them in the OAuth state row (encrypted with JWT_SECRET) and builds the authorize URL on that instance:

{externalUrl}/oauth/authorize?client_id=…&response_type=code
  &redirect_uri={urlencoded {FRONTEND_URL}/integrations/social/mastodon}
  &scope=write:statuses+profile+write:media
  &state=…

After the human authorizes, oauth::callback() decrypts the client info and calls Mastodon::authenticate() with ClientInformation {client_id, client_secret, instance_url}. The token exchange is a multipart POST to {externalUrl}/oauth/token with client_id, client_secret, code, grant_type=authorization_code, redirect_uri and scope. The resulting integration stores custom_instance_details as an encrypted JSON object:

{
  "instanceUrl": "https://fosstodon.org",
  "client_id": "…",
  "client_secret": "…"
}

instance_url() later decrypts this and reads instanceUrl (trimming a trailing slash). It never reads the client credentials at publish time.

Redirect URI (exact)

The redirect URI is hard-coded in Mastodon::redirect_uri():

{FRONTEND_URL}/integrations/social/mastodon

Key consequences:

  • It is mastodon, not mastodon-custom. The OAuth app registered on the user’s instance must use /integrations/social/mastodon.
  • There is no trailing slash and no redirectmeto.com wrapper.
  • The callback route is GET /integrations/social/{provider} and ignores the path provider when resolving state; it looks up the state row, which stores provider = "mastodon-custom". This is why the shared path works.
  • If FRONTEND_URL is http://localhost:5000, the registered redirect is http://localhost:5000/integrations/social/mastodon. Some instances refuse plain-http redirects except for loopback; most accept localhost.

Scopes requested and checked

The app is registered with and the authorize URL requests:

write:statuses profile write:media
ScopeWhy
write:statusesCreate the status/replies
profileverify_credentials during connect
write:mediaUpload attachments and poll processing

The profile scope was added in Mastodon 4.3. It only grants GET /api/v1/accounts/verify_credentials (and GET /oauth/userinfo in 4.4+), which is exactly what the connect flow needs. An instance older than 4.3 does not know the scope: app registration or the authorize request can fail with invalid_scope, or the token may be issued without access to verify_credentials. The provider has no fallback to read:accounts; on an older instance, change Mastodon::scopes() to read:accounts and re-register the app.

check_scopes() is never called. The port does not compare the granted scopes with info.scopes. If the instance strips a scope (some do), the connection succeeds and the missing operation fails later.

Sandbox / testing vs production

Mastodon has no sandbox, no test app and no app review. For mastodon-custom:

  • Test with a test account on any instance, or a local Mastodon instance.
  • Each connect attempt creates a new application on the instance. Repeated failed attempts leave orphaned apps in the user’s Preferences → Development list. Delete them there; Rustiz has no cleanup call.
  • Some instances disable app creation, require admin approval, or run in authorized-fetch mode. The connect call fails at POST /api/v1/apps; there is no review process to appeal to.
  • Production uses the same flow. No credentials are submitted to Mastodon or to the instance operator in advance.

Do not set MASTODON_CLIENT_ID / MASTODON_CLIENT_SECRET for this provider

They are not read when custom == true. Setting them is harmless but misleading; rustiz providers --status ignores them for this provider.

Connecting a Channel (CLI & MCP)

CLI

rustiz channels connect mastodon-custom \
  --external-url https://fosstodon.org \
  --open
  • --external-url is required. Without it, the server returns: mastodon-custom needs \externalUrl`, for example {“provider”:“mastodon-custom”,“externalUrl”:“https://mastodon.social”}.`
  • The URL is trimmed of trailing slashes before POST /api/v1/apps and before storing it. https://fosstodon.org/ and https://fosstodon.org are equivalent.
  • With a local FRONTEND_URL, the CLI serves the callback and waits for the channel. With a public HTTPS FRONTEND_URL, it prints the login URL and exits; poll rustiz channels connect-status <connectionId>.

MCP connectChannelTool

{
  "provider": "mastodon-custom",
  "externalUrl": "https://fosstodon.org"
}

The tool returns an OAuth start:

{
  "connected": false,
  "pending": true,
  "requiresHuman": true,
  "connectionId": "…",
  "url": "https://fosstodon.org/oauth/authorize?...",
  "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}

Poll with:

{ "connectionId": "…" }

integrationSchema describes it as:

{
  "type": "externalUrl",
  "headless": false,
  "requiresHuman": true,
  "requiresExternalUrl": true
}

There is no triggerTool / channelSetupTool follow-up. The instance URL is collected up front; the only human step is the OAuth login.

What not to send

  • Do not send customFields. connect.rs gives customFields precedence over every other flow. For mastodon-custom, connect_custom() would call authenticate() without a ClientInformation; the custom branch requires one and returns Missing instance details. The error is confusing because the flow never reaches external_url().
  • externalUrl must be the instance origin (https://fosstodon.org), not an API path (https://fosstodon.org/api/v1) and not an OAuth URL.
  • The instance must speak the Mastodon API. GoToSocial, Pleroma and Akkoma mostly work, but POST /api/v1/apps response shape and media processing semantics vary. This provider is documented for Mastodon.

Post Settings & Media Validation

Identical to the standard provider:

settings_schema()

The trait default, with no fields:

{ "type": "object", "properties": {}, "additionalProperties": true }

__type is added by the engine; visibility, CW and sensitive media are not configurable.

validate_settings()

Not overridden; every object is accepted.

check_validity()

Not overridden; no pre-flight media checks. The instance enforces its own limits.

Media requirements

  • Up to 4 images, 16 MB each; or 1 video/audio/GIF, 99 MB.
  • Alt text goes in the description multipart field, only when non-empty.
  • Media is uploaded to {instance}/api/v1/media (v1), then polled through GET /api/v1/media/:id until 200.
  • Instance-specific limits can be lower (some small instances cap uploads at a few MB or disable video). Rustiz does not read the instance configuration.

Threads

supports_comments = true: each extra entry becomes a reply with in_reply_to_id pointing at the previous status. Every reply is visibility=public. Each reply uploads its own media.

Plugs

No @Plug / @PostPlug methods; both default to Unsupported.

Implementation Notes & Gotchas

  • The app is created per connect attempt, not per instance. Two users on the same instance get two separate OAuth apps, both named Rustiz and both with the same redirect URI. This is intentional (no shared secret between tenants) but clutters the instance’s application list.
  • The app name and website are hard-coded. client_name="Rustiz" and website=FRONTEND_URL. To white-label, change the Rust code; there is no env var.
  • instance_url() silently falls back. If JWT_SECRET changes, the encrypted customInstanceDetails cannot be decrypted; instance_url() returns MASTODON_URL (default https://mastodon.social) and the channel’s token from the original instance is sent there. Expect a 401, not a clear “wrong instance” error. Back up JWT_SECRET.
  • Trailing slash handling. external_url() trims the URL; instance_url() trims it again; auth_url() trims it a third time. https://host/ works, but https://host/api/v1 does not.
  • No environment readiness check. provider_status() sees no required env vars, so the UI/agent can start a connection even when FRONTEND_URL is not publicly reachable. The login will fail at the callback.
  • A bad app-registration response is caught before the human sees it. oauth::begin() builds the authorize URL and connect.rs oauth_url_configured() rejects an empty client_id (or undefined) in the query string, deletes the pending state and returns “its OAuth client id is empty”. If an instance accepts POST /api/v1/apps but returns an unexpected shape, this is the error you see.
  • OAuth state TTL is one hour. The state row stores the encrypted client id/secret. If the human takes longer than an hour, the callback returns This connection link expired, please start again, and a new app is created on the instance on the next attempt.
  • Same posting code as mastodon. The pending handshake, media polling, Idempotency-Key on finalize, public-only visibility, missing comment idempotency, and hard-coded 500-character limit all apply. See the mastodon page for the full list.
  • Rate limits are per instance. max_concurrent_job = 5 is a conservative default; a large instance may allow more, a small one less. Mastodon’s documented default is 300 requests per 5 minutes per account and 30 media uploads per 30 minutes.
  • externalUrl is only used at connect time. Publishing uses the stored instanceUrl, so moving the instance to a new hostname requires reconnecting the channel (or editing the encrypted blob, which is not supported).
  • Error mapping is shared. The same three body substrings (Your login is currently disabled, not finished processing, could not be found) are converted to refresh/bad-body errors.

References