# MCCP API

The contract a program is written against for finding, downloading and uploading mods. MCCP is the
Moddable Console Capture Platform; the four letters are what it is called everywhere below, and in
every path and header in this document.

**Reading needs no account.** Listing mods, reading one and downloading its file take no token, no
sign-in and no headers at all - the same promise the website makes to somebody who has never made
an account. Start at [Find mods](#1-find-mods-no-account); you do not need the sign-in document to
write a downloader.

**Writing always names a profile.** Every call that creates or changes something reads an access
token from `Authorization: Bearer` - see [the sign-in API](/docs/accounts/api) for where one comes from -
and files the mod against the profile the token names. That is not only about abuse: a mod owned by
a profile is one its owner can edit from the website later, which a mod owned by an upload never
could be.

Base address: `https://dasmaffin.com`. Everything below hangs off `/api/mccp`.

---

## The shape of a mod

Every call that answers with a mod answers with this:

```json
{
  "id": 12,
  "guid": "5f4e0a1c-9b3d-4a77-9c2e-0d8f6a1b2c3d",
  "title": "Neon Overlay",
  "description": "Recolours the HUD.",
  "version": "1.2",
  "games": ["Half-Life 2", "Portal"],
  "requires": ["8b7a6c5d-4e3f-4211-90aa-bbccddeeff00"],
  "conflictsWith": [],
  "published": false,
  "createdAt": "2026-08-13T02:19:54Z",
  "updatedAt": "2026-08-13T02:31:07Z",
  "url": "/Mccp/12",
  "assets": [
    { "kind": "code",  "name": "neon.zip",  "bytes": 481920, "url": "/Mccp/file/33" },
    { "kind": "image", "name": "shot.png",  "bytes": 220114, "url": "/Mccp/file/34" }
  ]
}
```

`id` is this site's number for the mod and is in every address here. `guid` is the name it goes by
**outside** the site: it is what another mod writes down to say it needs this one, it never changes,
and it is the one to put in a manifest. A row number would collide with somebody else's and would
say how many mods exist; this says nothing at all.

`requires` and `conflictsWith` are GUIDs of other mods - what this one needs to work, and what it
must not be installed beside. Either may name a mod that is not on this site: a requirement is often
written before the thing it names is published, and refusing that would only push it somewhere this
site cannot read. Reading one mod resolves the whole chain for you - see
[One mod](#one-mod).

`version` is what the current download calls itself - the version of the last file uploaded, and
the heading of the changelog entry that upload wrote. Empty on a mod uploaded before versions were
asked for. It takes no account to read, so an installed copy can be compared against it.

`kind` is `code`, `css` or `image`. Every `url` is on this site and is the only address you should
ever show or store - see [Files](#7-downloading-what-was-uploaded).

---

## 1. Find mods (no account)

Three calls, none of which take a token or care whether you have one.

### Every published mod

```
GET /api/mccp
```

```json
{
  "count": 20, "total": 43, "page": 1, "size": 20, "more": true,
  "mods": [ { "id": 12, "title": "Neon Overlay", ... } ]
}
```

Each entry is [the shape of a mod](#the-shape-of-a-mod), assets and all - so one call is enough to
show a list with its download links, without a second request per row.

| Query | Meaning |
| --- | --- |
| `q` | Search the title and the games. **Accents fold both ways**: `Pokemon` finds `Pokémon` and the reverse, and so do `ø`, `ß` and the rest. Do not fold it yourself; send what was typed. |
| `game` | One game, matched **whole**. `Portal` never matches `Portal 2`. |
| `sort` | `new` (default) is what changed most recently. `top` is ups minus downs. |
| `page` | 1-based. Default 1. |
| `size` | Default 20, **capped at 100** however large a number is sent. |

`total` is every mod that matched, not the size of this page - page through it with `more`.

### One mod

```
GET /api/mccp/{id}
```

`200` with the mod. `404` if there is no such mod **or it is not published**, told apart nowhere:
a mod exists before its files do, and that window belongs to its owner.

This answer carries two fields the others do not, beside the mod's own:

```json
{
  "id": 12,
  "guid": "5f4e0a1c-...",
  "title": "Neon Overlay",
  "...": "every other field, exactly as above",

  "install": [
    { "id": 40, "guid": "8b7a6c5d-...", "title": "Overlay Core",  "...": "a whole mod" },
    { "id": 12, "guid": "5f4e0a1c-...", "title": "Neon Overlay",  "...": "a whole mod" }
  ],
  "missing": ["3c2b1a09-8877-4665-a443-221100ffeedd"]
}
```

**`install` is the whole chain, in the order to install it.** Everything the mod needs, and
everything those need, resolved as deep as it goes - each entry after everything it requires, and
**the mod you asked for is the last one**. Walk it front to back and you are never holding a mod
whose requirements have not arrived; take the last entry alone if you only want the one you asked
for. Each entry is a complete mod, download URL and all, so this one call is the whole job.

A mod needed by two others appears once. Two mods that need each other still get an order -
everything appears exactly once - because refusing to answer helps nobody.

**`missing`** lists GUIDs named as requirements that are not published here. Say so rather than
carrying on quietly: a chain with a hole in it installs cleanly and then does not work, and the
person holding the GUID is the only one who can go and find it. A mod that exists but is
unpublished is missing too, for the same reason it is a `404` above.

Every other call that answers with a mod gives you `requires` and `conflictsWith` as plain GUID
lists and does **not** resolve them. Listing twenty mods with their full chains would be a very
large answer to "what is on this site".

### Its changelog

```
GET /api/mccp/{id}/changelog
```

```json
{
  "count": 2,
  "notes": [
    { "id": 8, "title": "1.2", "text": "<p>Fixed the flicker.</p>",
      "createdAt": "2026-08-14T12:04:11Z", "editedAt": null, "automatic": false },
    { "id": 7, "title": null, "text": "Uploaded neon.ccmod.",
      "createdAt": "2026-08-13T09:31:52Z", "editedAt": null, "automatic": true }
  ]
}
```

Newest first. `text` is HTML through the same allowlist as a description, so render it the same
way - or strip the tags if your client shows plain text.

An entry's heading is the version of the upload that wrote it, which is why the changelog reads as
a list of versions. Editing a heading edits that record, so a version can be freed by deleting the
entry that used it.

Every entry starts as an upload's - `automatic` says so, and is worth showing - and turns false the
moment its author rewrites it, because from then on the words are theirs. Nothing but an upload
creates an entry; [editing one](#6-the-changelog-owner-only) is the owner's half.

### Its file

```
GET /api/mccp/{id}/download
```

The mod's own file, streamed, as an attachment with the name it was uploaded under. This is the one
call a downloader wants - no token, no reading the mod first, no working out which asset is the
download. `404` when the mod is unpublished or has no file yet.

Individual assets - screenshots, the stylesheet - are fetched from the `url` on each asset, which is
[described below](#7-downloading-what-was-uploaded).

---

## 2. Make a mod

```
POST /api/mccp
Authorization: Bearer <accessToken>
Content-Type: application/json

{ "title": "Neon Overlay", "description": "Recolours the HUD.",
  "games": ["Half-Life 2", "Portal"],
  "requires": ["8b7a6c5d-4e3f-4211-90aa-bbccddeeff00"],
  "conflictsWith": [] }
```

`201` with the mod, including the `guid` it was given. Only `title` is required.

A new mod is **not published**. It exists so that files have something to be attached to, and it is
invisible to everybody but its owner until you say otherwise in step 4.

The two GUID lists are checked **before** the mod is made, so a rejected call leaves nothing behind
and retrying it cannot give you two mods.

| Code | Meaning |
| --- | --- |
| `400` | No title, or a GUID list that broke one of the rules in [step 3](#3-edit-it) |
| `401` | No token, or one that is expired, edited, or names a login that has been deleted |

---

## 3. Edit it

```
PUT /api/mccp/{id}
Authorization: Bearer <accessToken>
Content-Type: application/json

{ "title": "Neon Overlay 2" }
```

`200` with the mod.

**Only what you send is changed.** Leaving `description` out keeps the description; you do not have
to read the mod back and resend it to avoid losing it.

**`games` is the exception, and it is deliberate.** Omitting it (or sending `null`) leaves the games
alone. Sending a list - *including an empty one* - replaces the set outright. That is the difference
between "I am editing the title" and "this is no longer for that game", and there is no other way to
remove one.

**`requires` and `conflictsWith` follow the same rule as `games`**: omitted means unchanged, a list
replaces the set, an empty list clears it.

```json
{ "requires": ["8b7a6c5d-4e3f-4211-90aa-bbccddeeff00"],
  "conflictsWith": ["3c2b1a09-8877-4665-a443-221100ffeedd"] }
```

Naming a mod that is not on this site is fine and is stored as sent - it shows as the GUID until
that mod turns up, and then it shows as the mod. What is refused, with `400` and a sentence saying
which, is:

- anything in either list that is not a GUID,
- the mod's own GUID, in either list,
- the same GUID in both lists at once,
- more than 50 in either list.

A refusal changes **neither** list, including the one that was fine. Both are checked together even
when only one was sent, or the second call could create a state the first would have been refused
for.

**`version` cannot be set here.** It is written by an upload and by nothing else - see
[step 4](#4-upload-the-files).

---

## 4. Upload the files

Three endpoints, all the same shape. **The file is the raw request body**, not multipart - you have a
file handle, and an envelope would only be taken apart at this end.

```
POST /api/mccp/{id}/code?name=neon.zip&version=1.2
POST /api/mccp/{id}/css?name=custom.css
POST /api/mccp/{id}/images?name=shot.png

Authorization: Bearer <accessToken>
Content-Type: application/zip

<the bytes>
```

`name` is what the file is called when somebody downloads it. `Content-Type` is remembered and sent
back on download, so send a real one.

**A code upload must carry a `version`, and it must be one this mod has not used before.** A code
upload is a build; the other two are not, and they take no version, write no changelog entry, and
can be repeated as often as somebody likes. Two uploads with no version are not two uploads of the
same version. It is
the version people see on the mod, the version this API hands out, and the heading of the changelog
entry the upload writes - one fact, written once. Without it the upload is a `400`; with a version
the mod has had already it is a `409`, because two different downloads answering to one version is
exactly what a version exists to prevent. Nothing is stored in either case. The other two uploads
take no version: a screenshot is not a release.

`201` with the mod, its `assets` now including what you sent.

| | Limit |
| --- | --- |
| Code | 64 MB, **one per mod** - a second replaces the first |
| Custom CSS | 256 KB, **one per mod** - a second replaces the first |
| Images | 2 MB each, **10 MB for the whole mod**, no limit on how many fit in that |

The stylesheet is stored and does nothing yet. Send it; it will start being used without the upload
changing.

| Code | Meaning |
| --- | --- |
| `401` | No token, or the mod is not yours (see below) |
| `400` | A code upload with no `version` (the other two need none) |
| `409` | That version has been used on this mod before. The body carries the `version` |
| `413` | Over a limit. The body carries `limitBytes`, and nothing is kept |
| `502` | Storage refused it. Not your fault and worth retrying |
| `503` | File storage is not configured on the server. Retrying will not help |

**Sizes are counted as the body arrives, not from `Content-Length`.** A `413` can therefore turn up
after you have finished sending. Nothing is stored when it does.

---

## 5. Publish it

```
POST /api/mccp/{id}/publish
Authorization: Bearer <accessToken>
```

`200` with the mod. Until this succeeds the mod is not on the public list, its page is a `404`, and
its files are a `404`.

`409` if no code has been uploaded, because a listing whose download is missing is a broken page with
a nice description. The body says so.

There is no unpublish in this API yet.

---

## 6. The changelog (owner only)

**Entries are written by builds, and there is no call that adds one.** Uploading the mod's file
puts a line in its changelog by itself, headed by that build's version. A screenshot or a
stylesheet writes nothing - it changes the page, not the download, and somebody reading this list
is asking what changed about the thing they installed. That is the whole rule: an entry means a
build, so a reader can trust the list. A call that posted a line with
no update behind it would make the changelog worth less than nothing to somebody deciding whether
to install this - so there is not one, and `POST` to the changelog answers `405`.

What is left is the sentence on an entry, which is its author's to write.

```
PUT /api/mccp/{id}/changelog/{noteId}
Authorization: Bearer <accessToken>
Content-Type: application/json

{ "title": "1.2", "text": "<p>Fixed the flicker on ultrawide.</p>" }
```

`200` with the entry. Send only what changes, like editing a mod - a field you leave out keeps
what it had, so an absent `text` is not an instruction to blank it. `title` is optional and usually
a version; `text` is HTML, cut at 4000 characters.

Rewriting the line an upload left is the expected thing to do with it: it stops being `automatic`
and becomes yours, and the website stops labelling it as the upload's.

```
DELETE /api/mccp/{id}/changelog/{noteId}
```

`204`. For an entry that should not be on the list at all.

`401` for both unless the token owns **that mod**. A note id from somebody else's mod put under one
of yours answers the same way: the mod is checked as well as the note.

---

## 7. Downloading what was uploaded

```
GET /Mccp/file/{assetId}
```

No token. Open to anybody, **once the mod is published** - before that it is a `404`, since this
endpoint has no session to ask who is calling.

Use the `url` from the mod, always. It is this site over https; the file service sits on a plain port
and its address is deliberately not public, so it can move without a single stored link going stale.

Images are served inline, everything else as a download.

---

## 8. Your mods

```
GET /api/mccp/mine
Authorization: Bearer <accessToken>
```

`200` with `{ "count": 2, "mods": [ ... ] }`, newest change first, published and not.

---

## 9. Delete one

```
DELETE /api/mccp/{id}
Authorization: Bearer <accessToken>
```

`204`. The files go too, from storage as well as from here. There is no undo.

---

## 10. The curated games

```
GET /api/mccp/games
```

**No token** - the list has to be drawable before anybody has signed in.

```json
{
  "count": 1,
  "games": [
    { "name": "half-life-2", "displayName": "Half-Life 2", "iconUrl": "/Mccp/game-icon/3" }
  ]
}
```

Match `name` against the games on a mod, **ignoring case**, and use `displayName` and `iconUrl` when
you find one.

**A miss is normal and is not an error.** Anybody may write any game they like, and only some of them
have been curated. When there is no match, show the string the mod gave you and no icon. There is no
endpoint that will ever tell you a game is invalid, because none of them are.

`iconUrl` is `null` when a curated game has no icon yet, so check it before using it. `displayName`
is never empty - it falls back to `name` - so you can print it without checking.

Read this once at startup and cache it. It changes when somebody adds a game, which is rare.

---

## 11. The app itself (build agent only)

Two endpoints for publishing the desktop app, and they do not use account tokens at all: the
caller is a build machine, so they take a machine key instead.

```
X-Api-Key: <one of the ApiPrivateKeys from the server settings>
```

`401` without it, with a wrong one, or when the server has none configured - and the body says
which of the first two it was: `{"error":"no_api_key"}` when no header arrived, and
`{"error":"unknown_api_key"}` when one arrived and was refused. Nothing distinguishes a wrong key
from a server with no keys, on purpose. `GET /api/mccp/key` answers the same way and changes
nothing, for testing a key without publishing a page.

### Upload a build

```
POST /api/mccp/app/{os}?name=mccp-setup.exe&version=1.2
X-Api-Key: <machine key>
Content-Type: application/octet-stream

<the bytes>
```

`os` is a short lowercase name - `windows`, `linux` - and it is a slot, not a log: **uploading
replaces that OS's build**, and the previous file is deleted from storage. Up to 64 MB. `201` with
the public download address; `413` over the limit and nothing is kept.

### Upload the changelog

```
POST /api/mccp/app/changelog
X-Api-Key: <machine key>

<plain text, up to 256 KB>
```

The newest text is what the download page shows, as text - markup is not rendered.

### What exists (public, no key)

```
GET /api/mccp/app
```

`{ "builds": [ { "os", "version", "name", "bytes", "uploadedAt", "url" } ], "changelog": "..." }` -
which is also how the app itself can check whether it is current.

The download for a person is `GET /Mccp/app/{os}` - no account, no key - and the page for humans
is `https://dasmaffin.com/Mccp/App`.

---

## 12. The developer wiki (machine key only)

MCCP has a wiki, and a wiki is a thing any module on this site can have - so it is documented once,
for all of them, at [/docs/wiki/publishing](/docs/wiki/publishing).

MCCP's is read at `/Mccp/Wiki` and published at `/Mccp/api/wiki/{path}` with a key granted
`Mccp:wiki`. The address this API used to document, `/api/mccp/wiki/{path}`, still answers exactly
as it did.

## 13. Where the files are kept (the file service only)

Not for a client. This is how the machine that holds the files tells the site where it currently
is, and it is documented here so that the endpoint is not a mystery to whoever reads the routes.

```
POST /api/mccp/storage/address
X-Api-Key: <the address key>
Content-Type: application/json

{ "baseUrl": "https://something.trycloudflare.com" }
```

`200` with `{ baseUrl, changed }`. `changed` is false when the address it reported is the one the
site already had, which is the ordinary case for a tunnel that reconnects to the same name.

The file service sits behind a Cloudflare tunnel, and without a Cloudflare account that tunnel
invents a fresh hostname every time it connects. Written into a settings file, that address is
right until the file service restarts, and then every upload answers `503` until a person notices.
So it reports its own address and the site follows.

`400` for anything that is not a plain https origin: no http, no path, no query, no credentials.
`401` for a wrong key, and for **any** key when none is configured - a gate with nothing set is
closed, not open.

The key is its own, deliberately separate from `ApiPrivateKeys`, because this one decides where
every file on the site comes from and the build agent has no business holding it.

---

## Things that will bite you if nobody says them

**A mod is not a file, it is a thing files hang off.** Make it, upload to it, then publish it. Three
calls minimum, in that order, and the first one gives you the `id` the other two need.

**Nothing is visible until you publish.** The commonest way to be confused by this API is to upload
everything, look at the website, and find nothing there. Step 4 is not optional.

**`games` replaces. Everything else merges.** Read that sentence again before writing the edit
screen. A `PUT` that sends `games: []` because your form was empty will quietly take a mod off every
list it was on.

**Capitals do not matter and spaces do.** `"Portal"`, `"portal"` and `" Portal "` are stored once as
one game. `"Portal"` and `"Portal 2"` are two different games and always will be.

**A mod may be for at most 20 games**, titles are cut at 120 characters, descriptions at 8000, and
game names at 120. Nothing is refused for being long - it is trimmed - so do not rely on the server
to tell somebody their title was too long. Say so in your own form.

**`401` means "not yours" as well as "not signed in".** A mod that does not exist and a mod belonging
to somebody else answer identically, on purpose, so that ids cannot be used to count what other
people are working on. Do not word your error as though you know which it was.

**Downloading needs no account, and your program must not add one.** Nothing in
[section 1](#1-find-mods-no-account) reads a token or a cookie. If your app asks somebody to sign in
before they can browse or install a mod, that is a door your program built, not one this API has.
Sign-in is for uploading, and for voting and commenting on the website.

**Uploading is a token away from being anonymous, and must not be.** If your program has an "upload"
button that works before somebody has signed in, that is a bug in your program - every one of these
calls will refuse it, and the person will see a failure they cannot act on. Ask them to sign in
first.

**`503` is a server that is not finished being set up.** It means nobody has configured storage yet.
Say so plainly rather than reporting it as an upload failure, and do not retry in a loop.
