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
| Property | Value |
|---|---|
| Identifier | mastodon-custom |
| Display name | M. Instance |
| Connection mode | externalUrl (the server registers an OAuth app on the instance) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true |
| Pending / async publishing | Yes. supports_pending = true |
| Analytics | No |
| Post analytics | No |
| Mentions | No |
is_between_steps | false (the instance URL is collected before the OAuth login, not as a second step) |
convert_to_jpeg | false |
max_concurrent_job | 5 |
Character limit (max_length) | 500 characters, hard-coded |
| OAuth scopes | write: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"]
}
}
}
| Variable | Required | Default | Purpose |
|---|---|---|---|
MASTODON_URL | No | https://mastodon.social | Fallback 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_URL | Yes in practice | http://localhost:5000 | Base of the redirect URI registered on every instance |
JWT_SECRET | Yes | empty | Encrypts 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:
| Field | Value |
|---|---|
client_name | Rustiz (hard-coded) |
redirect_uris | {FRONTEND_URL}/integrations/social/mastodon |
scopes | write: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, notmastodon-custom. The OAuth app registered on the user’s instance must use/integrations/social/mastodon. - There is no trailing slash and no
redirectmeto.comwrapper. - The callback route is
GET /integrations/social/{provider}and ignores the path provider when resolving state; it looks up the state row, which storesprovider = "mastodon-custom". This is why the shared path works. - If
FRONTEND_URLishttp://localhost:5000, the registered redirect ishttp://localhost:5000/integrations/social/mastodon. Some instances refuse plain-http redirects except for loopback; most acceptlocalhost.
Scopes requested and checked
The app is registered with and the authorize URL requests:
write:statuses profile write:media
| Scope | Why |
|---|---|
write:statuses | Create the status/replies |
profile | verify_credentials during connect |
write:media | Upload 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-urlis 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/appsand before storing it.https://fosstodon.org/andhttps://fosstodon.orgare equivalent. - With a local
FRONTEND_URL, the CLI serves the callback and waits for the channel. With a public HTTPSFRONTEND_URL, it prints the login URL and exits; pollrustiz 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.rsgivescustomFieldsprecedence over every other flow. Formastodon-custom,connect_custom()would callauthenticate()without aClientInformation; the custom branch requires one and returnsMissing instance details. The error is confusing because the flow never reachesexternal_url(). externalUrlmust 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/appsresponse 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
descriptionmultipart field, only when non-empty. - Media is uploaded to
{instance}/api/v1/media(v1), then polled throughGET /api/v1/media/:iduntil200. - 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
Rustizand 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"andwebsite=FRONTEND_URL. To white-label, change the Rust code; there is no env var. instance_url()silently falls back. IfJWT_SECRETchanges, the encryptedcustomInstanceDetailscannot be decrypted;instance_url()returnsMASTODON_URL(defaulthttps://mastodon.social) and the channel’s token from the original instance is sent there. Expect a401, not a clear “wrong instance” error. Back upJWT_SECRET.- Trailing slash handling.
external_url()trims the URL;instance_url()trims it again;auth_url()trims it a third time.https://host/works, buthttps://host/api/v1does not. - No environment readiness check.
provider_status()sees no required env vars, so the UI/agent can start a connection even whenFRONTEND_URLis 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 andconnect.rsoauth_url_configured()rejects an emptyclient_id(orundefined) in the query string, deletes the pending state and returns “its OAuth client id is empty”. If an instance acceptsPOST /api/v1/appsbut 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-Keyon finalize, public-only visibility, missing comment idempotency, and hard-coded 500-character limit all apply. See themastodonpage for the full list. - Rate limits are per instance.
max_concurrent_job = 5is 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. externalUrlis only used at connect time. Publishing uses the storedinstanceUrl, 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
- Mastodon app registration: https://docs.joinmastodon.org/methods/apps/
- Mastodon OAuth: https://docs.joinmastodon.org/spec/oauth/
- Mastodon media: https://docs.joinmastodon.org/methods/media/
- Postiz Mastodon setup (single-instance example): https://docs.postiz.com/self-host/providers/mastodon
- Rustiz provider table: Project README