* feat(auth): add revocable API tokens (bot/service auth) Add long-lived, revocable API tokens so headless clients (the introspection MCP tool, bots, CI) can authenticate without a password. Presented as "Authorization: Bearer <token>", a token authenticates as a specific user, inheriting that user's role and permissions. - migration 018 + dedicated api_tokens table (kept separate from sessions so bulk logout and the per-user session cap never touch these); only the SHA-256 hash is stored, raw token shown once at creation - auth.ResolveTokenHash: one shared bearer resolver that both AuthMiddleware and adminAuthMiddleware now call. Sessions are matched first so existing login behavior is unchanged; API tokens are a fallback only on session miss. A DB outage is returned wrapped, never mistaken for a bad token. - `server token create|list|revoke` CLI: mints directly against the DB with no HTTP and no login — the password-free bootstrap path - tests: resolver (8 cases incl. outage-not-fallthrough), db queries (6), api middleware integration (valid + revoked token) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(tools): add owncord-introspect MCP server A local MCP dev tool that lets Claude Code introspect a running OwnCord instance: read its logs, query any REST endpoint, and tail the desktop client's log file. It is a thin wrapper over the existing API plus the client log — no new product surface. - tools/mcp-introspect/index.mjs (Node/ESM, one dep: @modelcontextprotocol/sdk) exposes api_request (full read-write passthrough), server_logs (admin SSE ring-buffer stream), client_logs (reads the desktop log file) - authenticates with an API token (OWNCORD_API_TOKEN); pins the self-signed cert and skips hostname checks (the cert has no SAN) - registered in .mcp.json (secret-free ${OWNCORD_API_TOKEN}) - un-ignore tools/mcp-introspect/ so this shared dev tool is committed, while tools/livekit-server.exe and node_modules stay ignored - docs/mcp-introspect.md: how it works, tool reference, setup, troubleshooting Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(dependencies): update and add various crate versions in Cargo.lock * feat(admin): manage API tokens from the admin panel Add Owner-gated HTTP endpoints and a UI card to create, list, and revoke API tokens from the web admin panel. Previously only the `server token` CLI could manage them, which requires shell access to the host. - POST|GET|DELETE /admin/api/tokens in admin/handlers_tokens.go, wired in admin/api.go. All three are Owner-only (ownerOnlyMiddleware, like backups/updates): an HTTP token-mint endpoint is a network-reachable credential-minting surface, and API tokens deliberately survive password change + bulk logout, so a hijacked admin session must not mint one. - Reuses the same db.*APIToken calls as the CLI; create sources the actor from request context (audits who clicked, not the bound user); the raw token is returned once in the 201 body, never stored. - Add json tags to db.APITokenListItem for snake_case wire consistency. - Admin panel: "API Tokens" nav item + create modal, show-once reveal, revoke confirm in admin/static/index.html. - Tests: 7 in admin/api_test.go (+api_tokens table in the in-memory schema). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: modernize to Go 1.26 idioms + enable modernize linter Apply `golangci-lint modernize` autofixes across the server and enable the linter in .golangci.yml so these stop re-accumulating (they built up only because modernize was never in the config). Production code: slices.Contains for hand-rolled membership loops (api router, ws origin, db/account, plugin manifest); strings.SplitSeq for allocation-free line/segment iteration (db/migrate, updater, livekit_proxy); strings.Cut (config); fmt.Appendf (dm_handler); min() (event_pruner); any (ws client). Tests: range-over-int, t.Context(), WaitGroup.Go, slices.Sort, maps.Copy, new(expr), interface{}->any. - plugin/manifest.go parent-traversal check applied by hand: modernize skipped it (two conflicting rewrites); used the slices.Contains form. - Removed the now-dead ptr() test helper after newexpr inlined its callers. - Dropped dangling sort imports left by the sort.Slice->slices.Sort rewrite. No behavior change. All four tag variants build, full test suite is green, and golangci-lint (with modernize enabled) reports 0 issues. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
11 KiB
OwnCord Introspection MCP Server
tools/mcp-introspect/ is a small Model Context Protocol
(MCP) server that lets an AI agent — Claude Code — inspect a locally running OwnCord
instance: read its logs, query any REST endpoint, and tail the desktop client's log file.
It is a development tool, not part of the shipped product. It ships no data of its own and adds nothing to the server binary — it is a thin wrapper over OwnCord's existing REST API plus the client's on-disk log.
- Code:
tools/mcp-introspect/index.mjs(one file, ~230 lines) - Runtime: Node ≥ 20, ESM. One real dependency:
@modelcontextprotocol/sdk(+zod) - Registration:
/.mcp.json(committed) and.claude/settings.local.json(local)
How it works
flowchart LR
CC["Claude Code<br/>(MCP client)"] -->|"stdio<br/>JSON-RPC"| MCP["owncord-introspect<br/>index.mjs"]
MCP -->|"Bearer API token<br/>over pinned TLS"| SRV["OwnCord server<br/>https://127.0.0.1:8443"]
SRV -->|"REST JSON / SSE"| MCP
MCP -->|"readFile"| LOG[("owncord-client.log")]
Claude Code launches index.mjs as a child process (node tools/mcp-introspect/index.mjs) and
speaks MCP over stdin/stdout. When you (or the agent) call one of its tools, the server makes a
request to the local OwnCord instance — or reads a file — and returns the result.
Authentication
OwnCord has no static API key. The only credential its API accepts is a bearer token, and until recently the only way to mint one was a username/password login. This tool instead uses a long-lived API token (added alongside this tool — see API tokens):
- You mint a token once with
server token create(writes directly to the DB, no login). - The tool sends it as
Authorization: Bearer <token>on every request (OWNCORD_API_TOKEN). - Server-side,
auth.ResolveTokenHashresolves the bearer token: it checks login sessions first (unchanged behavior), then falls back to API tokens. The token authenticates as the user it was bound to (the owner by default), inheriting that user's role — which is why it can reach/admin/api/*and the log stream.
The tool never sends an Origin header: the server treats a missing Origin as a non-browser
client and skips CSRF/Origin checks, so a headless client is never blocked on that axis.
TLS (cert pinning, not CA trust)
OwnCord serves HTTPS with a self-signed certificate that has no SAN (Subject Alternative
Name). Trusting it as a CA is not enough — hostname verification against 127.0.0.1 still fails.
So the tool uses node:https and:
- pins the exact certificate bytes (
ca: Server/data/cert.pem), and - skips hostname matching (
checkServerIdentity: () => undefined).
Identity is proven by the pin — a MITM would need the identical cert. This is the same
trust-on-first-use model the desktop client's proxy already uses. (client_logs needs neither
the token nor the cert.)
The three tools
api_request — a single generic passthrough that covers the entire REST API. It issues one
https.request to https://127.0.0.1:<port><path> with the bearer token and returns
{ status, headers, body } (body is JSON-parsed when possible, else raw text). Any HTTP method is
allowed, including destructive admin routes.
server_logs — the server keeps its last 2000 log records in an in-memory ring buffer, exposed
over Server-Sent Events (an EventSource-style stream can't send an auth header, so it's
guarded by a single-use ticket). The tool runs that flow:
sequenceDiagram
participant T as MCP tool
participant S as OwnCord server
T->>S: POST /admin/api/logs/ticket (Bearer)
S-->>T: { "ticket": "<hex>" } (single-use, 30s TTL)
T->>S: GET /admin/api/logs/stream?ticket=<hex>
S-->>T: data: {ts,level,msg,source,attrs} (backfill of ring buffer)
S-->>T: data: {...} (live records, if follow_ms > 0)
T->>T: filter by level/source, apply limit, close
With follow_ms: 0 (default) it returns after the backfill burst goes quiet; with follow_ms > 0
it keeps reading live records for that long. Filtering by level/source and the limit are
applied client-side.
client_logs — reads the desktop client's rotating log file directly
(%LOCALAPPDATA%\com.owncord.client\logs\owncord-client.log); no server involved. Returns the last
N lines with optional level/substring filtering, and degrades gracefully if the file doesn't exist
yet.
Setup
1. Install dependencies
cd tools/mcp-introspect
npm install
2. Mint an API token
cd Server
./server.exe token create --label mcp-introspect
# prints the raw token ONCE — copy it now, it is never recoverable
The token defaults to the owner account (so it can reach the admin API and log stream). Bind it
to a different user with --user <name>, or set an expiry with --expires 720h. Manage tokens with
./server.exe token list and ./server.exe token revoke <id|label>.
The running server must be built from the current source for API-token auth to work — the feature is compiled into the server binary. Rebuild (
go build -o server.exe .) and restart if needed.
3. Put the token in your environment
.mcp.json passes ${OWNCORD_API_TOKEN} through to the tool, so set it once:
setx OWNCORD_API_TOKEN <paste-raw-token> # Windows user env — open a new shell afterwards
4. Enable in Claude Code
owncord-introspect is already listed in .claude/settings.local.json under
enabledMcpjsonServers. Restart Claude Code so it picks up the new MCP server.
Verify
cd tools/mcp-introspect
npx @modelcontextprotocol/inspector node index.mjs
The three tools should appear. Call api_request with { "method": "GET", "path": "/health" } and
expect {status:200, body:{...}}.
Tool reference
api_request
| Param | Type | Notes |
|---|---|---|
method |
string | GET, POST, PATCH, PUT, DELETE |
path |
string | Path beginning with / (e.g. /admin/api/stats) or a full URL |
query |
object? | Query-string params |
body |
any? | JSON body (object or string) |
headers |
object? | Extra request headers |
Returns { status, headers, body }.
// request
{ "method": "GET", "path": "/api/v1/metrics" }
// → { "status": 200, "headers": {...}, "body": { "uptime_seconds": 1820,
// "goroutines": 42, "connected_users": 1, "voice_sessions": 0, ... } }
Useful read-only endpoints: /health, /api/v1/metrics (runtime/process stats),
/admin/api/stats (user/message/channel counts), /api/v1/diagnostics/connectivity,
/admin/api/users, /admin/api/channels, /admin/api/audit-log.
server_logs
| Param | Type | Default | Notes |
|---|---|---|---|
level |
string? | — | DEBUG | INFO | WARN | ERROR |
source |
string? | — | websocket, http, admin, auth, database, storage, updater, config, server |
limit |
number? | 500 | Max records returned |
follow_ms |
number? | 0 | 0 = backfill only; >0 = also stream live for that many ms |
Returns an array of { ts, level, msg, source, attrs } (attrs is parsed from its JSON string when present; req_id/trace_id appear inside attrs).
{ "level": "ERROR", "limit": 50 } // last 50 ERROR records from the ring buffer
{ "source": "websocket", "follow_ms": 3000 } // ws logs, backfill + 3s of live tail
client_logs
| Param | Type | Default | Notes |
|---|---|---|---|
lines |
number? | 200 | Trailing lines to return |
level |
string? | — | Keep only lines tagged [LEVEL] |
grep |
string? | — | Keep only lines containing this substring |
Returns { path, found: true, lines: [...] }, or { path, found: false, note } if the client has
not run yet.
Configuration
All optional except the token (which only the two server-backed tools need).
| Env var | Default | Purpose |
|---|---|---|
OWNCORD_API_TOKEN |
(required for api_request/server_logs) |
Bearer token from server token create. |
OWNCORD_BASE_URL |
https://127.0.0.1:<server.port> |
Override the whole base URL (e.g. a non-TLS endpoint). Port is read from Server/config.yaml. |
OWNCORD_CERT_PATH |
Server/data/cert.pem |
Self-signed cert to pin. |
OWNCORD_CLIENT_LOG |
%LOCALAPPDATA%\com.owncord.client\logs\owncord-client.log |
Desktop client log path. |
Troubleshooting
| Symptom | Cause / fix |
|---|---|
OWNCORD_API_TOKEN is not set |
Mint a token and set the env var; restart the shell/Claude Code so it's inherited. |
OwnCord cert not found at … |
Start the server once to generate Server/data/cert.pem, or set OWNCORD_CERT_PATH / OWNCORD_BASE_URL. |
api_request returns 401 |
Token missing/revoked/expired, or (for /admin/*) the token's user lacks ADMINISTRATOR. Mint a fresh owner-bound token. |
api_request returns 403 on /admin/* |
The request didn't come from an allowed IP — the tool must run on the same host as the server (localhost is allowed by default). |
server_logs fails at the ticket step |
The token can't reach /admin/api/* (needs ADMINISTRATOR), or the server isn't the current build. |
client_logs → found: false |
The desktop client hasn't run yet, or the path differs — set OWNCORD_CLIENT_LOG. |
Security notes
- Local-only by design. The tool talks to
127.0.0.1and relies on the server's localhost admin IP gate. Do not expose it or point it at a remote host without understanding the trust model. api_requestis full read-write. It can call any endpoint with any method, including destructive admin routes (delete users/channels, restore backups, apply updates). There is no write allowlist — it relies on the operator/agent's discretion.- The API token is a real credential. It never expires by default and inherits the owner's
permissions. Keep it out of version control (it lives in your environment, not
.mcp.json), and revoke it withserver token revokeif leaked.
API tokens (server side)
The MCP tool depends on a server feature added at the same time: revocable, long-lived API
tokens. See the api_tokens table in schema.md and the server token
subcommands. Key points:
- Stored hashed (SHA-256), like sessions; the raw token is shown once at creation.
- Resolved by the same middleware as sessions (
auth.ResolveTokenHash) — sessions are matched first, so existing login behavior is unchanged; API tokens are a fallback. expires_at IS NULLmeans never expires;revoked_at IS NULLmeans active. Revocation takes effect immediately.- Kept in a separate table from
sessions, so bulk logout and the per-user session cap never affect them.