feat(api): separate API keys from MCP tokens, add travellers

An MCP token drives every assistant tool; an API key reads trips over HTTP.
Handing the same credential to a third-party integration gives it the whole
MCP surface, so the two are now separate kinds on `mcp_tokens` and the kind
sits in the WHERE clause — a token of the wrong kind is indistinguishable
from one that does not exist.

Also adds `travellers` to the trip payload: display name and an owner flag,
no ids and no email addresses. An integration key reads its owner's
itinerary; it is not a way to enumerate the people around them.

Documents the surface in the wiki, since integrators need something to read
that is not the source.
This commit is contained in:
Maurice
2026-08-28 01:29:04 +02:00
committed by Maurice
parent 9cddf683a5
commit 4b120d39c8
9 changed files with 381 additions and 20 deletions
+22
View File
@@ -4154,6 +4154,28 @@ function runMigrations(db: Database.Database): void {
db.exec("ALTER TABLE reservations ADD COLUMN ingest_state TEXT NOT NULL DEFAULT 'live'");
}
},
/*
* Separate an integration key from an MCP token (#2089).
*
* Both live in mcp_tokens and until now both opened everything a token can
* open. That was fine while /mcp was the only consumer; with a public REST
* surface it means a key somebody minted for a chat client also reads their
* trips over HTTP, and a key minted for an integration can drive every MCP
* tool. One credential, two very different blast radii.
*
* `kind` splits them, and every existing row becomes 'mcp' — that is what
* they were issued for, and silently widening a key that is already in
* somebody's config would be the opposite of what this migration is for.
*
* Appended LAST: the array is index-addressed against schema_version.
*/
() => {
const cols = db.prepare("SELECT name FROM pragma_table_info('mcp_tokens')").all() as Array<{ name: string }>;
if (!cols.some(c => c.name === 'kind')) {
db.exec("ALTER TABLE mcp_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT 'mcp'");
}
},
];
if (currentVersion < migrations.length) {
+37
View File
@@ -321,6 +321,43 @@ export class AuthController {
return { success: true };
}
/**
* Integration keys for the public API (`/api/v1`).
*
* Separate from the MCP tokens above because they open different doors: an MCP
* token drives every assistant tool, an API key reads trips over HTTP. Same
* table, different `kind`, and each surface verifies only its own — so a key
* handed to a third-party integration cannot be replayed against /mcp.
*
* Unlike MCP tokens these are not forbidden on a managed instance: the surface
* they unlock is read-only and scoped to the caller's own trips, which is the
* whole reason it exists.
*/
@Get('api-tokens')
listApiTokens(@CurrentUser() user: User) {
return { tokens: this.tokens.listApiTokens(user.id) };
}
@Post('api-tokens')
@HttpCode(201)
createApiToken(@CurrentUser() user: User, @Body() body: McpTokenCreateDto, @Req() req: Request) {
this.limit('login', req, 5);
const result = this.tokens.createApiToken(user.id, body.name);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
return { token: result.token };
}
@Delete('api-tokens/:id')
deleteApiToken(@CurrentUser() user: User, @Param('id') id: string) {
const result = this.tokens.deleteApiToken(user.id, id);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
return { success: true };
}
@Post('ws-token')
@HttpCode(200)
wsToken(@CurrentUser() user: User) {
+10 -6
View File
@@ -5,11 +5,15 @@ import { TokenService } from '../tokens/token.service';
/**
* Authenticates a caller of the public API with a long-lived `trek_…` token.
*
* The tokens are the ones users already manage under Settings → Integrations; this
* guard adds no new credential type and no new table. It reuses
* `TokenService.verifyMcpToken`, which hashes the presented token with SHA-256 and
* looks up the hash — the raw token is never stored and never compared in the
* application, so a database read cannot yield a usable credential.
* Accepts an **API key** — the kind users mint under Settings → Integrations for
* third-party software. An MCP token does not resolve here, and an API key does not
* open the MCP surface: the two are separate credentials with separate blast radii,
* and `verifyApiToken` puts the kind in the WHERE clause so a token of the wrong
* kind is indistinguishable from one that does not exist.
*
* The lookup hashes the presented key with SHA-256 and matches the hash — the raw
* key is never stored and never compared in the application, so a database read
* cannot yield a usable credential.
*
* Deliberately narrower than the MCP transport's `verifyToken`, which also accepts
* OAuth bearer tokens and a plain web-session JWT:
@@ -38,7 +42,7 @@ export class ApiTokenGuard implements CanActivate {
401,
);
}
const user = this.tokens.verifyMcpToken(token);
const user = this.tokens.verifyApiToken(token);
if (!user) {
throw new HttpException(
{ error: 'Invalid API token', code: 'API_TOKEN_INVALID' },
@@ -6,6 +6,7 @@ import type {
PublicApiInclude,
PublicApiPlace,
PublicApiReservation,
PublicApiTraveller,
PublicApiTrip,
PublicApiTripSummary,
} from '@trek/shared';
@@ -75,6 +76,9 @@ export class PublicApiService {
if (include.includes('accommodations')) {
trip.accommodations = this.buildAccommodations(tripId);
}
if (include.includes('travellers')) {
trip.travellers = this.buildTravellers(tripId);
}
return trip;
}
@@ -216,6 +220,29 @@ export class PublicApiService {
notes: r.notes ?? null,
}));
}
/**
* Who is on the trip: the owner first, then members in join order.
*
* Names only. The query selects `username` and nothing else — no ids, no email
* addresses — because an integration key is a credential for reading its owner's
* itinerary, not for enumerating the people around them. What it returns is
* exactly what those people already see on the trip in TREK.
*/
private buildTravellers(tripId: number): PublicApiTraveller[] {
const rows = this.db.all<TravellerRow>(
`SELECT u.username, 1 AS is_owner
FROM trips t JOIN users u ON u.id = t.user_id
WHERE t.id = ?
UNION ALL
SELECT u.username, 0 AS is_owner
FROM trip_members m JOIN users u ON u.id = m.user_id
WHERE m.trip_id = ?
ORDER BY is_owner DESC`,
tripId, tripId,
);
return rows.map((r) => ({ name: r.username, owner: r.is_owner === 1 }));
}
}
function toTripSummary(row: TripRow): PublicApiTripSummary {
@@ -296,6 +323,11 @@ interface ReservationRow {
notes: string | null;
}
interface TravellerRow {
username: string;
is_owner: number;
}
interface AccommodationRow {
name: string | null;
address: string | null;
+72 -10
View File
@@ -8,6 +8,12 @@ import { EphemeralTokenService } from '../auth/ephemeral-token.service';
import { revokeUserSessions } from '../../mcp/sessionManager';
import { User } from '../../types';
/**
* What a token is allowed to drive. Stored on the row so each surface can accept
* only its own: 'mcp' for the assistant tools, 'api' for the public REST surface.
*/
type TokenKind = 'mcp' | 'api';
/**
* Everything that mints or checks a token that is not the login JWT: the
* long-lived MCP tokens a user manages in settings, and the short-lived ws /
@@ -33,18 +39,44 @@ export class TokenService {
) {}
listMcpTokens(userId: number) {
return this.listTokens(userId, 'mcp');
}
/**
* Integration keys for the public API — a different credential from an MCP
* token even though both live in this table.
*
* They are split because they open different doors: an MCP token drives every
* tool the assistant exposes, an API key reads trips over HTTP. Handing a
* third-party integration something that can also delete a place is a blast
* radius nobody asked for, so `kind` keeps the two apart and each surface
* verifies the one it accepts.
*/
listApiTokens(userId: number) {
return this.listTokens(userId, 'api');
}
private listTokens(userId: number, kind: TokenKind) {
return this.db.all(
'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE user_id = ? ORDER BY created_at DESC',
userId
'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE user_id = ? AND kind = ? ORDER BY created_at DESC',
userId, kind
);
}
createMcpToken(userId: number, rawName: unknown): { error?: string; status?: number; token?: Record<string, unknown> } {
createMcpToken(userId: number, rawName: unknown) {
return this.createToken(userId, rawName, 'mcp');
}
createApiToken(userId: number, rawName: unknown) {
return this.createToken(userId, rawName, 'api');
}
private createToken(userId: number, rawName: unknown, kind: TokenKind): { error?: string; status?: number; token?: Record<string, unknown> } {
const name = rawName as string | undefined;
if (!name?.trim()) return { error: 'Token name is required', status: 400 };
if (name.trim().length > 100) return { error: 'Token name must be 100 characters or less', status: 400 };
const tokenCount = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM mcp_tokens WHERE user_id = ?', userId)!.count;
const tokenCount = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM mcp_tokens WHERE user_id = ? AND kind = ?', userId, kind)!.count;
if (tokenCount >= 10) return { error: 'Maximum of 10 tokens per user reached', status: 400 };
const rawToken = 'trek_' + randomBytes(24).toString('hex');
@@ -52,8 +84,8 @@ export class TokenService {
const tokenPrefix = rawToken.slice(0, 13);
const result = this.db.run(
'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)',
userId, name.trim(), tokenHash, tokenPrefix
'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix, kind) VALUES (?, ?, ?, ?, ?)',
userId, name.trim(), tokenHash, tokenPrefix, kind
);
const token = this.db.get(
@@ -64,8 +96,21 @@ export class TokenService {
return { token: { ...(token as object), raw_token: rawToken } };
}
deleteMcpToken(userId: number, tokenId: string): { error?: string; status?: number; success?: boolean } {
const token = this.db.get('SELECT id FROM mcp_tokens WHERE id = ? AND user_id = ?', tokenId, userId);
deleteMcpToken(userId: number, tokenId: string) {
return this.deleteToken(userId, tokenId, 'mcp');
}
deleteApiToken(userId: number, tokenId: string) {
return this.deleteToken(userId, tokenId, 'api');
}
/**
* Scoped by kind as well as by owner: without it the integrations panel would
* happily delete a token the MCP panel manages, and the user would find a key
* missing from a screen they never opened.
*/
private deleteToken(userId: number, tokenId: string, kind: TokenKind): { error?: string; status?: number; success?: boolean } {
const token = this.db.get('SELECT id FROM mcp_tokens WHERE id = ? AND user_id = ? AND kind = ?', tokenId, userId, kind);
if (!token) return { error: 'Token not found', status: 404 };
this.db.run('DELETE FROM mcp_tokens WHERE id = ?', tokenId);
// Best-effort, like the changePassword/resetPassword revocations: a session
@@ -130,13 +175,30 @@ export class TokenService {
// -------------------------------------------------------------------------
verifyMcpToken(rawToken: string): User | null {
return this.verifyToken(rawToken, 'mcp');
}
/** Verifies an integration key. An MCP token presented here does not resolve. */
verifyApiToken(rawToken: string): User | null {
return this.verifyToken(rawToken, 'api');
}
/**
* Hash, look up, and require the kind the calling surface accepts.
*
* The kind is part of the WHERE clause rather than checked afterwards, so a
* token of the wrong kind is indistinguishable from one that does not exist —
* neither the caller nor a timing measurement learns that the string was a
* real credential for somewhere else.
*/
private verifyToken(rawToken: string, kind: TokenKind): User | null {
const hash = createHash('sha256').update(rawToken).digest('hex');
const row = this.db.get<User>(`
SELECT u.id, u.username, u.email, u.role
FROM mcp_tokens mt
JOIN users u ON mt.user_id = u.id
WHERE mt.token_hash = ?
`, hash);
WHERE mt.token_hash = ? AND mt.kind = ?
`, hash, kind);
if (row) {
this.db.run('UPDATE mcp_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?', hash);
return row;
+24 -3
View File
@@ -23,7 +23,8 @@ const { db } = vi.hoisted(() => {
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);
CREATE TABLE mcp_tokens (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL,
name TEXT NOT NULL, token_hash TEXT NOT NULL, token_prefix TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_used_at DATETIME);
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_used_at DATETIME,
kind TEXT NOT NULL DEFAULT 'mcp');
CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL,
title TEXT NOT NULL, description TEXT, start_date TEXT, end_date TEXT, currency TEXT,
is_archived INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -74,18 +75,21 @@ import { PublicApiModule } from '../../src/nest/public-api/public-api.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
/** Mints a token the way TokenService does, so the guard's hash lookup is real. */
function seedToken(userId: number, raw: string): string {
db.prepare('INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)').run(
function seedToken(userId: number, raw: string, kind: 'api' | 'mcp' = 'api'): string {
db.prepare('INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix, kind) VALUES (?, ?, ?, ?, ?)').run(
userId,
'test',
createHash('sha256').update(raw).digest('hex'),
raw.slice(0, 13),
kind,
);
return raw;
}
const ADA_TOKEN = 'trek_' + 'a'.repeat(48);
const BOB_TOKEN = 'trek_' + 'b'.repeat(48);
/** A valid credential for /mcp — must not open this surface. */
const MCP_TOKEN = 'trek_' + 'c'.repeat(48);
describe('Public API v1 e2e (real guard + real SQL)', () => {
let server: Server;
@@ -104,6 +108,7 @@ describe('Public API v1 e2e (real guard + real SQL)', () => {
db.prepare("INSERT INTO users (id, username, email) VALUES (2, 'bob', 'bob@example.com')").run();
seedToken(1, ADA_TOKEN);
seedToken(2, BOB_TOKEN);
seedToken(1, MCP_TOKEN, 'mcp');
// Ada owns trip 1; Bob owns trip 2; trip 3 is Bob's but Ada is a member.
db.prepare(
@@ -156,6 +161,12 @@ describe('Public API v1 e2e (real guard + real SQL)', () => {
expect((await get('/api/v1/trips', 'trek_' + 'z'.repeat(48))).status).toBe(401);
});
it('refuses a valid MCP token — different kind, different door', async () => {
const res = await get('/api/v1/trips', MCP_TOKEN);
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'Invalid API token', code: 'API_TOKEN_INVALID' });
});
it('accepts the token via X-API-Key too', async () => {
const res = await request(server).get('/api/v1/trips').set('X-API-Key', ADA_TOKEN);
expect(res.status).toBe(200);
@@ -246,6 +257,16 @@ describe('Public API v1 e2e (real guard + real SQL)', () => {
]);
});
it('lists travellers by name, owner first, without ids or emails', async () => {
const res = await get('/api/v1/trips/3?include=travellers', ADA_TOKEN);
expect(res.status).toBe(200);
expect(res.body.travellers).toEqual([
{ name: 'bob', owner: true },
{ name: 'ada', owner: false },
]);
expect(JSON.stringify(res.body)).not.toContain('@example.com');
});
it('never exposes internal ids or foreign keys', async () => {
const res = await get('/api/v1/trips/1', ADA_TOKEN);
const serialised = JSON.stringify(res.body);
+16 -1
View File
@@ -92,6 +92,20 @@ export const publicApiDaySchema = z.object({
});
export type PublicApiDay = z.infer<typeof publicApiDaySchema>;
/**
* Someone who is on the trip.
*
* Display name only. Ids would let a consumer correlate the same person across
* instances, and email addresses have no business leaving TREK over an
* integration key — the name is what a traveller already sees on the trip.
*/
export const publicApiTravellerSchema = z.object({
name: z.string(),
/** True for the trip's owner; everyone else joined as a member. */
owner: z.boolean(),
});
export type PublicApiTraveller = z.infer<typeof publicApiTravellerSchema>;
/** Trip without its itinerary — what the list endpoint returns. */
export const publicApiTripSummarySchema = z.object({
id: z.number(),
@@ -121,6 +135,7 @@ export type PublicApiTripSummary = z.infer<typeof publicApiTripSummarySchema>;
export const publicApiTripSchema = publicApiTripSummarySchema.extend({
days: z.array(publicApiDaySchema).optional(),
accommodations: z.array(publicApiAccommodationSchema).optional(),
travellers: z.array(publicApiTravellerSchema).optional(),
});
export type PublicApiTrip = z.infer<typeof publicApiTripSchema>;
@@ -129,7 +144,7 @@ export type PublicApiTrip = z.infer<typeof publicApiTripSchema>;
* case is a full sync; a consumer that only wants notes says so and gets a payload
* a fraction of the size.
*/
export const PUBLIC_API_INCLUDES = ['days', 'places', 'notes', 'reservations', 'accommodations'] as const;
export const PUBLIC_API_INCLUDES = ['days', 'places', 'notes', 'reservations', 'accommodations', 'travellers'] as const;
export type PublicApiInclude = (typeof PUBLIC_API_INCLUDES)[number];
/**
+165
View File
@@ -0,0 +1,165 @@
# Public API
A small, versioned, read-only surface that lets other software read your trips — built for integrations like location trackers, journalling tools and home automation, where something outside TREK wants to know what you planned.
> **Not the same as the internal API.** TREK's own frontend talks to a few hundred endpoints under `/api/…`. Those answer a session cookie, carry no version and change whenever the client changes — they are not a contract, and anything built on them will break. The public API is the opposite promise: a handful of endpoints under `/api/v1/`, authenticated with a key you mint yourself, that will not change shape without a new version number.
## What it can do
Read trips. That is deliberate and complete:
- list the trips you own or are a member of
- fetch one trip with its days, places in planned order, day notes, reservations, accommodations and fellow travellers
It cannot write anything. An integration that reads your itinerary needs no ability to delete a place, and a key that can only read is a very different thing to hand to third-party software.
## Getting a key
1. Open **Settings → Integrations**
2. Under **API Keys**, click **Create key** and give it a name you will recognise later (the name is only for you — "Dawarich", "home assistant", "laptop script")
3. **Copy the key immediately.** It is shown once. TREK stores only a hash of it, so it cannot be shown again — if you lose it, delete the key and make a new one.
Keys look like `trek_` followed by 48 characters. You can hold ten at a time.
> **API keys and MCP tokens are not interchangeable.** They live on the same screen but open different doors: an [MCP token](MCP-Setup) drives every assistant tool, an API key reads trips over HTTP. A key of the wrong kind is rejected exactly like one that does not exist. Mint the kind the other software asks for.
To revoke access, delete the key. It stops working immediately, and anything using it gets a 401 on its next request.
## Using it
Send the key as a bearer token:
```bash
curl -H "Authorization: Bearer trek_your_key_here" \
https://trek.example.com/api/v1/trips
```
or, if the software you are configuring expects a header named for an API key:
```bash
curl -H "X-API-Key: trek_your_key_here" \
https://trek.example.com/api/v1/trips
```
Both are equivalent. Use whichever the other side offers.
## Endpoints
### `GET /api/v1/trips`
Every trip you can reach, newest first, without itineraries.
```json
{
"trips": [
{
"id": 12,
"title": "Tuscany",
"description": "Wine and hill towns",
"start_date": "2026-06-14",
"end_date": "2026-06-22",
"currency": "EUR",
"archived": false,
"updated_at": "2026-06-01 10:00:00"
}
]
}
```
`updated_at` moves when the trip's **own** fields change — title, dates, currency, cover. It does **not** move when a place, a day or a note is edited. Do not use it to decide whether to re-fetch; you would miss every itinerary change. Compare the payload instead.
### `GET /api/v1/trips/{id}`
One trip with its contents. Returns **404** if the trip does not exist *or* is not yours — the two are indistinguishable on purpose, so the endpoint cannot be used to find out which trip ids exist.
Pick what you need with `?include=`, comma-separated:
| Section | What it adds |
|---|---|
| `days` | the itinerary spine: one entry per day, with `date`, `day_number`, `title`, `notes` |
| `places` | stops on each day, **in planned order**, with coordinates, times, duration, category and transport mode |
| `notes` | timed free-text notes on a day |
| `reservations` | bookings on their starting day: type, title, location, time, status |
| `accommodations` | where you sleep, with the date range resolved and check-in/check-out times |
| `travellers` | who is on the trip, by display name |
Omitting `include` returns everything. A section you did not ask for is **absent** rather than empty, so your code can tell "not requested" apart from "nothing there". An unknown section name is a `400` rather than being silently ignored — a typo should not hand you a payload quietly missing what you wanted.
```bash
# only the notes, for a lightweight sync
curl -H "Authorization: Bearer trek_…" \
"https://trek.example.com/api/v1/trips/12?include=days,notes"
```
```json
{
"id": 12,
"title": "Tuscany",
"start_date": "2026-06-14",
"days": [
{
"date": "2026-06-14",
"day_number": 1,
"title": "Arrival",
"notes": null,
"places": [
{
"name": "Uffizi",
"address": "Florence",
"lat": 43.76, "lng": 11.25,
"time": "14:00", "end_time": null,
"duration_minutes": 180,
"category": "Museum",
"notes": null,
"transport_mode": "walking"
}
],
"day_notes": [{ "text": "Bring the tickets", "time": "09:00" }],
"reservations": [
{ "type": "flight", "title": "LH 1234", "location": "FRA",
"time": "2026-06-14T08:00", "end_time": null,
"status": "confirmed", "notes": null }
]
}
],
"accommodations": [
{ "name": "Hotel Alba", "address": null, "lat": null, "lng": null,
"start_date": "2026-06-14", "end_date": "2026-06-15",
"check_in": "15:00", "check_out": "11:00", "notes": null }
],
"travellers": [{ "name": "ada", "owner": true }, { "name": "bob", "owner": false }]
}
```
## Notes for integrators
**Join on dates, not ids.** Every day carries an ISO `date`, and accommodations carry a resolved date range rather than internal day ids. If your software keeps its own notion of a trip, the date is the one thing both sides can agree on. TREK's internal ids are not in the payload at all, by design — they would tie you to storage details that are free to change.
**One request, not many.** Rather than separate endpoints per section, `?include=` lets you fetch exactly what you need in a single call. Fetching a trip four times for four sections would burn your rate budget for no benefit.
**Rate limit: 120 requests per minute**, counted per user rather than per IP address. A self-hosted integration and its owner's browser often share an address, and an IP-based limit would let one starve the other. Exceeding it returns `429`; back off and retry.
**Errors** are JSON with an `error` field:
| Status | Meaning |
|---|---|
| `400` | malformed trip id, or an unknown `include` section |
| `401` | missing, malformed or unknown key — also what you get for a key of the wrong kind |
| `404` | no such trip, or not one of yours |
| `429` | rate limit exceeded |
**Versioning.** `/api/v1` may gain fields; it will not lose them or change their types. A breaking change ships as `/api/v2` and both run side by side for a transition period. Write your client to ignore fields it does not know.
## What is not here yet
- **Writing.** Read-only for now. Write access needs per-scope enforcement that this surface does not implement, and a key that only reads is a much safer thing to hand out.
- **Incremental sync.** There is no `?since=` filter, because TREK cannot yet answer it truthfully — child records carry no modification timestamp, so a filter on `updated_at` would silently hide trips whose itinerary changed. Fetch the list and compare.
- **Webhooks.** Nothing pushes; poll at a sensible interval.
If you are building something and one of these blocks you, open a discussion — the surface is meant to grow around real integrations rather than ahead of them.
## See also
- [MCP Overview](MCP-Overview) — the other machine-facing surface, for AI assistants
- [Calendar Feeds](Calendar-Feeds) — subscribe a calendar app to a trip
- [Admin: MCP Tokens](Admin-MCP-Tokens) — the instance-wide view of issued tokens
+3
View File
@@ -78,6 +78,9 @@
- [[Plugin Permissions|Plugin-Permissions]]
- [[Publishing a Plugin|Plugin-Publishing]]
## Integrations
- [[Public API|Public-API]]
## AI / MCP
- [[MCP Overview|MCP-Overview]]
- [[MCP Setup|MCP-Setup]]