Inkcast
OfficialPublishes, cross-posts, schedules and drafts markdown stories on DEV (dev.to, or any Forem community via a custom API URL). Applies DEV's formatting rules (lowercase alphanumeric tags, no title in the body) and supports series, organization destinations and canonical URLs, with per-platform result reporting and automatic retries on rate limits or server errors.
Publishes, cross-posts and schedules markdown stories to Medium. Handles Medium-specific formatting (title/subtitle embedded in the body, 3-tag limit), supports publishing to publications by name, slug or id, and per-post options such as tags, canonical URL, cover image and publish status (public, draft or unlisted). Without an integration token it returns Medium's manual "Import a story" steps, which also set the canonical link.
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
Inkcast
Write once in markdown. Publish everywhere — from your AI assistant.
Docs · Start here: bootstrap · API reference · Licensing · Commercial licences · Contributing · Security
Inkcast is an MCP server that lets Claude Code, or any MCP client, publish, cross-post and schedule your writing on Medium, DEV and more. You manage it all from the terminal, with a canonical link back to your own blog on every copy.
> Publish docs/blog/why-we-built-an-agent.md to Medium and DEV with the subtitle
"Lessons from year one" and the canonical URL pointing to our blog
● inkcast_publish (dry run) → per-platform preview checked
● inkcast_publish → “Why we built an agent” — Lessons from year one
- medium: published → https://medium.com/@ada/why-we-built-an-agent-…
- devto: published → https://dev.to/ada/why-we-built-an-agent-…See it in use: screenshots, from adding Inkcast to an AI assistant to deleting an account.
Why it's built this way
Principle | What it means in practice |
Plug-in platforms | Each platform is a class behind one small interface. Medium and DEV ship built in; others install as Python packages through the |
Pluggable sign-in | OpenID Connect (Keycloak recommended), a GitHub OAuth app, or a single password for local use. With Keycloak, Google/GitHub/Microsoft social login and account linking happen in Keycloak, not in Inkcast. |
Secrets never touch the AI | Platform keys are entered on a one-time browser page, never pasted into chat. They are encrypted at rest. OAuth codes and tokens are stored only as SHA-256 hashes. |
Explicit consent | Every MCP client that registers itself is shown a consent page before it gets a token. Codes are single-use; refresh tokens rotate. |
Platform-correct output | Each plug-in knows its platform's rules. Medium needs the title and subtitle inside the body and uses only 3 tags. DEV wants lowercase alphanumeric tags and no title in the body. Writers in a Medium publication can only create drafts. |
Own scheduler | Scheduled posts live in Inkcast's database and go out on time. Rate limits and server errors are retried automatically. |
Git-native | Push markdown to a repository and the GitHub Action publishes new stories and updates changed ones in place. It never posts the same file twice. |
One codebase, many services | One image runs any service, selected by |
More detail on the design is in docs/architecture.md.
Related MCP server: Substack MCP Server
Platforms
Platform | Status | Credentials | Notes |
Medium | ✅ built in | Integration token | Medium stopped issuing new tokens on 1 Jan 2025; tokens created earlier still work. Without one, Inkcast gives you Medium's Import a story steps instead. That import also sets the canonical link. |
DEV (dev.to) | ✅ built in | API key | Works with any Forem community via |
YouTube, Facebook Pages | 🧭 designed | OAuth per platform | Video upload, audience and AI-disclosure fields, platform review gates. See video platforms and batch review. |
X, Instagram, TikTok | 🧭 designed | OAuth per platform | Needs per-platform OAuth, a media pipeline, async publishing and platform review gates. See the social epic. |
Hashnode, WordPress, Ghost, … | 📋 planned | See docs/issues/. |
Tools
Tool | What it does |
| Lists the platforms and which ones you've connected. |
| Returns a one-time link where you enter a platform's credentials. Opening the link only shows the form; a key the platform accepts uses the link up, and a refused one leaves it good for another try. |
| Deletes your stored credentials for a platform. |
| Lists Medium publications, or finds DEV organizations. |
| Publishes now, schedules, or creates drafts on one or more platforms. Supports |
| Lists your scheduled, published, draft, failed, unconfirmed and cancelled posts. |
| Cancels a scheduled, recovering or unconfirmed post. A recovering or unconfirmed one (the platform never said whether it arrived) may already be on the platform; the reply says so. |
| Sends an unconfirmed post again, now, as one new post, on your decision; the old one is cancelled. Inkcast never sends an unconfirmed post again on its own. |
| Syncs files from a repository: publishes new ones, updates changed ones in place, skips the rest. |
| Gives you a link to create a personal access token for CI. You see the token; the AI never does. |
| Gives you a one-time link to delete your account. Opening the link only shows a page (a link previewer cannot use it up); its Continue button starts a fresh sign-in, you read what goes and what stays, and confirm at the sign-in provider (Keycloak); Inkcast then erases your data. The AI deletes nothing. |
Publish from Git
# .github/workflows/publish.yml in your blog repository
- uses: inkcasthq/inkcast/action@<commit-sha>
with:
url: ${{ vars.INKCAST_URL }}
token: ${{ secrets.INKCAST_TOKEN }} # personal access token, scope stories:sync
paths: "posts/**/*.md"On pull requests it runs a dry run and can comment the preview. On main it publishes. The full guide, the CLI for other CI systems (inkcast sync, and inkcast resend / inkcast cancel for a post that waits for your decision), and what each result means are in docs/git-publishing.md.
REST API and docs
Every tool is also a REST endpoint under /api/v1. Authenticate with a Bearer token: an OAuth access token (scope inkcast), or a personal access token with narrower scopes.
URL | What |
| Swagger UI |
| ReDoc reference |
| OpenAPI 3.1 document |
Both doc pages render from vendored, hash-locked bundles in src/inkcast/static/vendor:
Pinned versions: Swagger UI 5.32.11 and ReDoc 2.5.3, pinned in
ASSETS.lock.No CDN: nothing is fetched at run or build time, so the pages work on air-gapped clusters.
Strict CSP: the pages carry a strict Content-Security-Policy with no inline scripts.
Long-lived caching: assets are served with an ETag and a one-year
immutablecache under a content-hash URL.
scripts/vendor-assets.sh verifies the bundles offline and upgrades them from npm, checking npm's published integrity digest, never a CDN.
Quick start (local, single user)
python3 -m venv .venv # Python ≥ 3.12 (the image runs 3.14); everything below runs from it
.venv/bin/pip install .
export INKCAST_LOGIN=password INKCAST_PASSWORD='a long passphrase here' \
INKCAST_SECRET_KEY=$(.venv/bin/python -c 'import secrets;print(secrets.token_urlsafe(48))')
.venv/bin/inkcast # serves http://localhost:8787/mcpThen set it up in Claude Code:
Add the server once:
claude mcp add --transport http inkcast http://localhost:8787/mcpRun
/mcp, select inkcast, choose Authenticate, sign in and approve.Ask "connect DEV to Inkcast", open the link Claude gives you, and paste your DEV API key.
Ask "publish posts/hello.md to DEV".
Claude reads the file and passes its text to Inkcast. Inkcast never touches your filesystem, so it works the same locally or hosted.
Hosted (team or SaaS) with Keycloak
1. Set up Keycloak
Create a realm, e.g.
inkcast.Clients → Create client, OpenID Connect, client ID
inkcast:Client authentication on, Standard flow on.
Valid redirect URI:
https://inkcast.example.com/auth/callback.Under Advanced, set PKCE method to S256.
Copy the client secret.
Realm roles → Create
inkcast-user. Assign it to the people who may use Inkcast. For open SaaS sign-up, add it to the realm's default roles. For Delete my account (inkcast_delete_account,POST /api/v1/account/deletion-link): enable thedelete_accountrequired action, add theaccountclient'sdelete-accountrole to the default roles, record theDELETE_ACCOUNTevent (Realm settings → Events), set the client's Home URL (baseUrl) tohttps://inkcast.example.com/account/returnso Keycloak's info pages (the "deleted" page among them) link back, and run the user sync (INKCAST_KEYCLOAK_SYNC_CLIENT_ID, below): Keycloak deletes the account on the person's own authority and Inkcast erases their data once the sync's read-only service account confirms the account is gone. Deleting a user in the Keycloak admin console is a deletion too: Inkcast cuts the person off at once and erases their dataINKCAST_KEYCLOAK_ERASE_AFTER_DAYSlater (30 by default), so a mistaken deletion restored from a backup under the same id meanwhile keeps everything. Disabling a user only cuts them off.deploy/keycloak/realm-inkcast.jsonhas all of it.Optional social login: Identity providers → Google / GitHub / Microsoft…. Keycloak's first broker login flow links a social account to an existing user with the same verified email. Inkcast always sees one stable user ID per person.
2. Run Inkcast on OpenShift or Kubernetes
Permission checks run in OpenFGA (design), on a PostgreSQL that the CloudNativePG operator runs: install the operator first (its release manifest, server-side; the version CI tests is in scripts/versions.env).
oc new-project inkcast
oc create secret generic inkcast-secrets \
--from-literal=secret-key="$(openssl rand -base64 48)" --from-literal=oidc-client-secret=… \
--from-literal=fga-api-token="$(openssl rand -base64 48)" # OpenFGA's preshared key; Inkcast reads the same entry
# edit deploy/kustomize/base/kustomization.yaml (public URL, OIDC issuer, image) and the Route host, then:
oc apply -k deploy/kustomize/overlays/openshift # or: kubectl apply -k deploy/kustomize/overlays/kubernetes
oc -n inkcast rollout status deploy/inkcast --timeout=15m # Ready on its own: no id to write or pin by handThe store and the model are written by the pods themselves: the init container authz-model of Inkcast's pod runs inkcast authz write-model with the pod's own image before the server starts, and the server then pins the store INKCAST_FGA_STORE_NAME names and its version equal to the packaged model. Every pod start runs it, so a release, a rollback or a restart writes what its own image packages, and a run that finds the version writes nothing; pods starting together converge on one store. On a fresh install the init container fails and is restarted by the kubelet until OpenFGA's own pod has migrated the schema (its init container migrate, #337: no Job to wait for, delete or re-apply) and serves (oc -n inkcast logs deploy/inkcast -c authz-model says why), so the pod is Ready up to five minutes after that (the kubelet's back-off): the Deployment's progress deadline is 15 minutes, as is the wait above. A model change travels with the image; a rollback is the image's revert. To hold one version by hand instead, merge INKCAST_FGA_STORE_ID and INKCAST_FGA_MODEL_ID into inkcast-config: both set, they win, nothing is resolved and the init container writes nothing. To keep the in-process engine instead (a small self-host), leave components/openfga out of your overlay and set INKCAST_AUTHZ_ENGINE=local with INKCAST_AUTHZ_ALLOW_LOCAL=true.
The lab: staging and prod side by side (OpenShift Local). Both environments run on one CloudNativePG cluster in its own namespace, inkcast-db (deploy/kustomize/shared/inkcast-db: one Postgres pod for both, a staging and a prod database each owned by its own login role, a NetworkPolicy that admits 5432 from OpenFGA's pods in inkcast-staging and inkcast-prod and nothing else), so the lab's memory holds both. environments/staging and environments/production set those namespaces and drop the component's per-namespace Cluster; a self-host using the overlays alone keeps it. In order:
oc apply -k deploy/kustomize/shared/env-secrets-admission # first: each environment's env-secrets creates only its own Secrets
oc apply -k deploy/kustomize/shared/inkcast-db # the namespace, the Cluster, both databases, the policy, Roles
oc apply -k deploy/kustomize/shared/keycloak-access # each environment's Role for its realm's Secret in keycloak
oc -n inkcast-db wait --for=condition=Ready cluster/inkcast-pg --timeout=10m
oc new-project inkcast-staging && oc new-project inkcast-prod
scripts/deploy/ghcr-pull-secret.sh staging <github-username> <token-file> # the private image: see below
scripts/deploy/ghcr-pull-secret.sh prod <github-username> <token-file>
oc apply -k deploy/kustomize/environments/staging # then environments/production the same way
oc -n inkcast-staging rollout status deploy/openfga --timeout=10m # env-secrets, then migrate, then the server
oc -n inkcast-db get database # APPLIED true for both, once their roles have their Secrets
oc apply -k deploy/kustomize/shared/keycloak-realms # Mailpit and both realms (below): before or after the environments
oc -n keycloak get keycloakrealmimports # each import waits for its realm Secret, then runs. Done for both
oc -n inkcast-staging rollout status deploy/inkcast --timeout=15m # the authz-model init container, then Ready
bash scripts/dev/shared-db-proof.sh # proves the isolation: pg_hba, the policy, owners, ghcr-pull
bash scripts/lab/root-secret-copy.sh # the root Secret's copy: OADP, the lab's store, one backup proven (ADR-0020)
# both scripts/lab and scripts/ops run Python from the checkout's .venv (below), never the host's python3; INKCAST_PYTHON names anotherThe proof sends no password and prints none. It checks five things:
each role owns its own database;
each side's OpenFGA is migrated in its own database;
a role asking for the other side's database, or connecting without TLS, is rejected by pg_hba before authentication;
a pod outside OpenFGA gets no answer on 5432;
each environment pulled the image with
ghcr-pull.
The last measured run is docs/epics/E3-tenancy-authorization/evidence/shared-db-crc-2026-11-04.txt.
oc delete -k deploy/kustomize/shared/inkcast-db deletes the Cluster and, with it, its volume: both environments' authorization data. databaseReclaimPolicy: retain keeps a database when its Database object is deleted, not when the Cluster is.
The realms as code. Each environment signs people in against its own realm on the lab's Keycloak (namespace keycloak, CR keycloak, run by the operator): inkcast-staging and inkcast-prod, each a KeycloakRealmImport in deploy/kustomize/shared/keycloak-realms that scripts/gen-realm-imports.py renders from the one realm file, deploy/keycloak/realm-inkcast.json, unchanged but for its name, the environment's public URL and the mail; --check fails the tests when the realm file, an environment or the Mailpit Service moved without regenerating. The import Job reads the client secrets from Secret inkcast-<side>-realm through spec.placeholders, so nothing secret is in Git. The imports can be applied before or after the environments. The Keycloak CR must exist, and the operator creates each import Job only after a replica of its StatefulSet is ready (KeycloakRealmImportController.java:63-76 and :111-123 at 26.6.7). Each placeholder is an environment entry of the Job's pod from that Secret (optional: false). A missing Secret, or a missing key, keeps the container in CreateContainerConfigError. The kubelet retries it until the Secret holds every named key, and the import then completes on its own. The Secret must hold the intended values before the container starts: a later change to the Secret does not repair an imported realm, because the import runs with --override=false and skips a realm that exists (ImportUtils.java:105-114). In the measured run the import was Done 40 s after the Secret, with no restart of the Keycloak server (OpenShift Local, 2026-10-29: docs/research/realm-import-before-secret-2026-10.md). The values that must agree, per side (tests/test_realm_imports.py checks each row):
Value | Set in | Must agree with |
|
| the Route's host in the same file; the client's |
|
| the import's |
|
| the realm file's two clients: sign-in, and the read-only service account of the user sync, on in both environments |
| the base Deployment; |
|
| the generated import | the Mailpit Service beside it; only Keycloak's pods may reach it |
An import creates its realm once and never changes it (kc.sh import --override=false; the operator's guide: "The Realm Import CR only supports creation of new realms"). A re-apply with the realm in place changes nothing. Change a realm that has people through the admin console or API, and the realm file in the same pull request. Deleting a realm deletes every account in it, and the user sync then treats each person as deleted (cut off, then erased after INKCAST_KEYCLOAK_ERASE_AFTER_DAYS; someone who signs up again is a new account), so delete the realm and its KeycloakRealmImport and apply again only for a realm with no accounts to keep. The verification and password-reset mail of both realms lands in Mailpit and nowhere else: oc -n keycloak port-forward svc/mailpit 8025:8025, then http://localhost:8025.
No Secret to make: the cluster generates them. The init container env-secrets of OpenFGA's pod (deploy/kustomize/components/env-secrets, running inkcast env-secrets from the release's image) makes each environment's Secrets at every start of that pod, before OpenFGA reads them: inkcast-<side>/inkcast-secrets (Inkcast's secret-key, OpenFGA's preshared key fga-api-token, and the Keycloak clients' oidc-client-secret and keycloak-sync-secret), inkcast-<side>/openfga-db-app (the database URI), and the other side of each pair: inkcast-db/inkcast-pg-openfga-<side>, the role's password that CloudNativePG applies, and keycloak/inkcast-<side>-realm, the client secrets the realm import reads (identity-client-secret, sync-client-secret). An absent value is generated (Python's secrets); a value on one side of a pair is copied to the other, so a namespace deleted and applied again gets its values back; a value is never replaced; two sides that disagree stop the new pod, naming the pair, and the kubelet retries it until the wrong key is removed; OpenFGA's Deployment is strategy: Recreate (its schema migration runs under a database lock, authorization.md §8), so the previous pod is already gone and OpenFGA is unavailable until then. Its output names Secrets and keys, never a value. Its ServiceAccount inkcast-env-secrets reads and patches only its own Secrets, by name, in inkcast-<side>, inkcast-db and keycloak (the Roles in components/env-secrets, shared/inkcast-db and shared/keycloak-access; until the shared ones are applied, the pod stops naming them). Kubernetes RBAC cannot narrow create to a name, so the admission policy in shared/env-secrets-admission, applied first, does: each side's account creates only its own four Secrets, so staging's run cannot choose production's values. The init container runs only when its pod starts, and an apply that changes nothing starts no pod, so the CronJob inkcast-env-secrets of the same component runs the same command, as the same account with the same token and image, every ten minutes (concurrencyPolicy: Forbid, each run bounded; cronjob.yaml gives each number's reason): a copy lost on one side of a pair (inkcast-db/inkcast-pg-openfga-<side> deleted, say) is back within ten minutes with no restart and no apply (#342), a run that finds everything in place changes nothing, and a release's apply moves its image digest with the init container's. The CronJob runs --repair-only and never makes a new value: a value held only in inkcast-secrets (secret-key, fga-api-token), or a pair lost on both sides, cannot come back by itself, since a value made anew would cut the running pods off (every sealed credential unreadable, the new key refused by the running OpenFGA); the run fails, naming what is lost and what to do (the runbook). A deleted inkcast-secrets is restored from the cluster backup's copy: an OADP Schedule per environment copies the Secrets that carry inkcast.io/root-secret: "true" (which inkcast env-secrets sets on it) into a storage location of their own, apart from the data backups (deploy/kustomize/shared/oadp; on the lab, the in-cluster store of shared/seaweedfs stands in for an off-cluster, KMS-encrypted bucket; the runbook's step 1 has the restore, ADR-0020 the decision).
To generate a value again, remove it from both sides of its pair, then oc -n inkcast-<side> rollout restart deploy/openfga: the init container makes a new one (the CronJob never does: its run reports it lost and fails). A copy lost on one side needs no restart: the CronJob's next run copies it within ten minutes (at once: oc -n inkcast-<side> create job --from=cronjob/inkcast-env-secrets env-secrets-now; the controller removes that Job with the history it keeps). The database password (delete both inkcast-db/inkcast-pg-openfga-<side> and inkcast-<side>/openfga-db-app) changes in PostgreSQL as soon as it is written (the Secret's cnpg.io/reload label), and the new OpenFGA pod connects with it; PostgreSQL checks a password when a connection opens, so the previous pod keeps the connections it has and fails to open new ones until it is replaced. A Keycloak client secret (the key in both keycloak/inkcast-<side>-realm and inkcast-secrets) changes in Keycloak only when the realm is imported again, and Inkcast's sign-in fails until then. secret-key seals the platform credentials stored at rest: a new one makes every sealed credential unreadable, so generate it again only for an environment with none. Credentials are passwords (cert-manager client certificates instead: #299). The URI OpenFGA reads is postgres://openfga_<side>:…@inkcast-pg-rw.inkcast-db.svc:5432/openfga_<side>?sslmode=require: always TLS, with the server's certificate unverified until the cluster's CA travels with #299; the server admits each role over TLS to its own database and rejects it anywhere else (pg_hba in cluster.yaml), so a staging role cannot open openfga_prod.
The image pull Secret. ghcr.io/inkcasthq/inkcast is a private package, so each app namespace needs the Secret ghcr-pull (kubernetes.io/dockerconfigjson, for ghcr.io) until it is public. Make a classic personal access token with read:packages only (not the command-line tool's own token, which carries far more), save it to a file, and run the script: the token goes from the file to oc on stdin, never in an argument or on the screen; a run with a new file rotates it. The environments' manifests name the Secret both ways, and either alone pulls: on the ServiceAccounts of every pod running the image, inkcast and inkcast-env-secrets (OpenFGA's pod, whose init container runs it, and the CronJob inkcast-env-secrets; the field oc secrets link --for=pull would write; it also covers a pod added later, such as the restore Job of scripts/ops/restore.sh), and on each workload's pod spec (the Deployment, the backup CronJob, OpenFGA's Deployment and the env-secrets CronJob), which covers a pod whose ServiceAccount is not wired. The overlays carry neither: a self-host pulls its own image. When the package goes public: remove the three ghcr-pull patches from deploy/kustomize/environments/staging and production and the three ghcr-pull entries of deploy/kustomize/components/env-secrets in a pull request, apply both environments, then oc -n inkcast-staging delete secret ghcr-pull and the same in inkcast-prod. A Secret that is still named but missing only makes the kubelet warn, so the order is not critical.
What the manifests set up:
Pod security: restricted, i.e. arbitrary non-root UID, read-only root filesystem, no capabilities. OpenFGA and its database run the same way.
Network: default-deny NetworkPolicy, plus optional FQDN egress allow-lists (Cilium, or OVN EgressFirewall).
Probes and metrics:
/livezand/readyzprobes, with Prometheus metrics on a separate, unrouted port.Exposure: an edge-TLS Route with HSTS on OpenShift, or a Gateway API HTTPRoute on Kubernetes.
Outbound trust on OpenShift: Inkcast trusts the cluster's CA bundle, so a Keycloak whose certificate comes from your own CA verifies. The bundle is the ConfigMap
inkcast-trusted-ca, which the Cluster Network Operator fills (the system CAs plusproxy/cluster.spec.trustedCA);SSL_CERT_FILEnames it.
Optional components add a ServiceMonitor, an ExternalSecret and a Kyverno image-signature policy. The cloud-native design is in docs/architecture.md; the build and deploy pipeline is in docs/cicd.md.
Without Kubernetes:
docker run -d --env-file .env -v inkcast-data:/data -p 8787:8787 ghcr.io/inkcasthq/inkcast:0.1.0Put it behind HTTPS. Then everyone adds it with:
claude mcp add --transport http inkcast https://inkcast.example.com/mcpNo Keycloak yet? Set INKCAST_LOGIN=github with a GitHub OAuth app. Its callback URL is https://inkcast.example.com/auth/callback. Restrict access with GITHUB_ALLOWED_ORGS or GITHUB_ALLOWED_USERS.
Writing a story
Front matter is optional. Arguments in your request override it, and a per-platform block overrides both for that platform:
---
title: Why we built an agent
subtitle: Lessons from year one # Medium: shown under the title · DEV: description
tags: [ai, agents, developer-tools] # each platform applies its own limits, with warnings
canonical_url: https://blog.example.com/why-we-built-an-agent
cover_image: https://blog.example.com/img/cover.png
platforms: [medium, devto]
publish_at: 2026-10-21T09:00:00-05:00 # omit to publish now; a UTC offset is required; up to 730 days ahead
image_base_url: https://blog.example.com/why-we-built-an-agent/ # fixes relative image paths
medium:
title: A punchier title for Medium
destination: Better Programming # publication by name, slug or id
publish_status: unlisted # public | draft | unlisted
devto:
destination: my-org # organization username or id
series: Building agents
---
# Why we built an agent ← used as the title if none is given; never duplicated
Your story…Some behaviour to know:
Partial failure: a failure on one platform doesn't stop the others. The reply lists the result per platform.
Preview first:
dry_runshows exactly what each platform will receive.
Configuration
All settings are environment variables; see .env.example. Each service checks only the settings it needs, and refuses to start with exit code 78 and a list of what to fix.
Variable | Default | |
|
| which service(s) this process runs, CamelCase and comma-separated ( |
|
|
|
| Gateway only: e.g. | |
|
| seconds between scheduler ticks (each tick claims and sends the posts due), |
|
| seconds past due before a scheduled post counts as overdue (metric and the critical |
|
| sends in all for a scheduled post whose send certainly never happened (a connection failure, a 429) before it is marked |
|
| seconds between those sends, comma-separated: 1 to 9 whole numbers, each |
|
| hours a post whose send was interrupted (the process stopped mid-send) stays |
|
| days the backup CronJob keeps a backup ( |
|
| seconds between InsightsService passes that fold new events into KPI rollups ( |
|
| seconds a stopping process gives in-flight requests and sends to finish: more than |
|
| days a domain event is kept in the outbox, counted from when it was written (the replay window), |
|
| days hourly KPI buckets and their |
| with | |
|
| seconds between reads of Keycloak's USER admin events and DELETE_ACCOUNT user events ( |
|
| seconds between full re-reads of every Keycloak person Inkcast knows (also at start): from |
|
| days a person Keycloak no longer has (deleted by an administrator, or a self-deletion whose |
|
| the sync's circuit breaker: a reconcile that finds at least |
|
| the share (1–100) of the people read that must be newly gone or disabled, exceeded, for the breaker to trip |
|
| the least number of people newly gone or disabled (1 or more) for the breaker to trip: both thresholds must be met, so one or two deletions or disables in a small realm are acted on as usual |
|
| the permission engine (design): |
| with | |
| with | |
| the packaged model | with |
|
|
|
|
| where secrets come from: |
| 10 MB | larger requests get 413 before any handler runs |
|
| per-client limit on unauthenticated writes ( |
|
| days a personal access token lasts when the request doesn't say ( |
|
| the most a request may ask for, |
|
| whether a token may never expire (REST |
|
| days a newly issued MCP refresh token lasts, |
|
| minutes a one-time link lasts, |
|
|
Development
python3 -m venv .venv # every Python command runs from it: CONTRIBUTING.md, "Run the checks locally"
.venv/bin/pip install -e . 'ruff==0.12.0' 'playwright==1.56.0' && .venv/bin/python -m playwright install chromium
PYTHONPATH=src:tests .venv/bin/python -m unittest discover -s tests -vCI runs on every pull request (docs/cicd.md):
lint and the vendored-asset lock;
the test suite on Python 3.12–3.15;
rendering of every manifest overlay;
an image build and vulnerability scan;
a kind deployment of the real image under Pod Security
restricted, both as one pod and as split services behind the Gateway;CodeQL.
Merges to main publish a signed image (cosign keyless), with an SBOM and SLSA provenance, and open a staging promotion PR.
The end-to-end test starts fake versions of a Keycloak-style identity provider, the Medium API and the DEV API. An unmodified MCP SDK client then:
Signs in the way Claude Code does: discovery, dynamic client registration, PKCE, then the consent page.
Connects both platforms through the connect page.
Cross-posts and schedules stories.
Runs the scheduler, including a rate-limit retry, a Medium-writer draft and a DEV organization post.
Lists and cancels posts.
The whole end-to-end suite runs twice: once in a single process, and once with every service in its own server behind the built-in Gateway.
Other tests cover:
the REST API, OpenAPI document and docs pages, including a real-browser render under the CSP;
duplicate-safe publishing (an ambiguous 5xx is never retried);
Git publishing: personal access tokens and scopes, the ledger (create → unchanged → update or stale), idempotency, re-scheduling, the CLI and the browser token flow;
architecture rules (no service imports another), the config gate, log redaction and secrets handling;
probes and metrics;
the vendored-asset lock.
Real Keycloak, Medium and DEV have not been exercised by the test suite yet. Try a dry_run and a draft first.
Status
Early: v0.2 in progress. Services from one codebase, Git publishing, and SQLite (one node). The path to the hosted SaaS — Postgres, workspaces, an admin portal on Keycloak, API tokens on sign-up, a mobile app — is in docs/product-spec.md and docs/issues/.
Documentation
Everything is indexed in docs/README.md: product spec, architecture, CI/CD, plug-in guide, roadmap and legal.
License
Inkcast is source-available: PolyForm Shield 1.0.0, plus an additional permission for consultants.
Licence | |
Publishing your own content, self-hosting, modifying, building plug-ins | Free |
Consultants setting it up for one client's own content | Free |
Publishing for clients (agencies), selling Inkcast-powered services, embedding it in your product or SaaS, hosting or white-labelling it | Commercial |
Details and a quick check: LICENSING.md. Contributions require the CLA. "Inkcast" is a trademark of ScalarOps Technologies LLC (TRADEMARKS.md).
Copyright 2026 ScalarOps Technologies LLC.
This server cannot be deployed
Maintenance
Related MCP Connectors
Publish and share access-controlled Markdown documents from any MCP-enabled AI tool.
A blog CMS that AI agents write into over MCP: Markdown drafts, versions, media, human approval.
Blog MCP server for AI agents. One signup call returns a blog, an API key and a live URL; then your agent publishes markdown posts and gets public URLs back. Your agent does the writing; you get a dashboard for stats.
Connect any AI agent to 11+ social platforms: schedule, publish & track posts via hosted MCP.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables AI assistants to publish posts to Medium with support for drafts, tags, canonical URLs, and follower notifications. Supports various publishing statuses and SEO optimization features.4 npm2MIT
- AlicenseNot gradedqualityDmaintenanceEnables programmatic management of Substack content, including creating drafts, publishing posts, and uploading images. It supports specialized features like live blogging and posting to Substack Notes through MCP-compatible AI tools.MIT
- AlicenseAqualityCmaintenanceCross-publishes a single markdown draft to 5 CMS platforms (dev.to, Hashnode, Ghost, WordPress, Medium) and 4 social networks (Bluesky, Mastodon, LinkedIn, X) from any MCP client, with built-in SEO scoring, schema.org JSON-LD, and canonical URL handling so cross-posts compound search rankings instead of competing.429112 npm5MIT
- AlicenseAqualityDmaintenanceEnables publishing markdown blog posts directly to Dev.to as drafts or live articles through a single MCP tool, with support for tags, series, canonical URL, and cover image.1MIT