MCP & OAuth Integration
Inkweld exposes creative writing project data to AI assistants through the Model Context Protocol (MCP). Authentication is handled via OAuth 2.1 with PKCE, enabling secure third-party access to user projects.
Architecture
AI Client (Claude, etc.)
│
├── Discovery: GET /.well-known/oauth-protected-resource
│ └── Finds authorization_servers → AS metadata
│
├── DCR: POST /oauth/register
│ └── Registers as OAuth client (one-time)
│
├── Auth: Browser redirect → /oauth/authorize
│ └── User selects projects + grants consent
│
├── Token: POST /oauth/token (PKCE exchange)
│ └── Receives JWT access_token + refresh_token
│
└── MCP: POST /api/v1/ai/mcp (Streamable HTTP)
└── JSON-RPC with Bearer token
MCP Endpoint
| Property | Value |
|---|---|
| URL | /api/v1/ai/mcp |
| Transport | Streamable HTTP (stateless, POST for JSON-RPC) |
| Protocol Version | 2026-07-28 |
| Auth | Bearer token (OAuth JWT). There are no long-lived API keys |
The server implements the stateless MCP protocol (2026-07-28): modern
clients have no initialize handshake and no Mcp-Session-Id header. Every
request carries its protocol version, client info, and client capabilities in
_meta, and the server identifies itself in each result's _meta.
GET/DELETE on the endpoint return 405 Method Not Allowed. Stateless
2026-07-28 clients should call server/discover first to learn the supported
protocol versions and capabilities.
For backward compatibility, the server is dual-era: legacy clients (those
using the pre-stateless initialize handshake, e.g. MCP Inspector 0.22.0) are
still served the classic initialize response with an Mcp-Session-Id header,
while requests that carry their protocol version in _meta are served
statelessly.
Available Tools
Read Operations
| Tool | Description |
|---|---|
list_projects | List authorized projects (call first to find project keys) |
get_project_schemas | Get a project's element schemas |
get_project_tree | Get the element tree structure for a project |
search_elements | Full-text search across project elements |
search_worldbuilding | Search worldbuilding content with optional full data |
search_relationships | Find relationships for a specific element |
get_element_full | Get complete element data including worldbuilding |
get_document_content | Get prose content from a document element |
get_relationships_graph | Get all relationships as a graph structure |
get_project_metadata | Get project metadata and settings |
get_publish_plans | Get saved publish/export configurations |
list_project_media | List project media files and media:// URLs |
list_image_profiles | List enabled image profiles and profile IDs for generation |
get_media_content | Read media:// content as image, text, or base64 |
Write Operations
| Tool | Description |
|---|---|
create_element | Create new story elements |
update_element | Modify existing elements |
delete_element | Remove elements from the tree |
replace_all_elements | Replace the whole element tree in one call (destructive, see below) |
move_elements | Move elements to a new parent |
reorder_element | Change element position within siblings |
sort_elements | Sort children alphabetically |
update_worldbuilding | Update worldbuilding data for an element |
update_document_content | Replace prose content of a document element |
create_relationship | Create relationships between elements |
delete_relationship | Remove a relationship |
tag_element | Add, remove, or set tags on an element |
create_snapshot | Create a snapshot of a document's current state |
replace_all_elements (requires write:elements) deletes every existing element and replaces them with the supplied list, so it is meant for bulk restructuring or initial setup, not routine edits. The elements must be in positional order: children immediately follow their parent and each has level = parent level + 1. The tool normalizes order values and parentIds from that ordering. Prefer create_element, move_elements and reorder_element when you only need to change part of the tree.
When creating a worldbuilding entry via create_element, pass schemaId along with type: "WORLDBUILDING" so the element is template-bound (for example, character-v1 or location-v1). If schemaId is omitted, the element is treated as generic worldbuilding.
Image Operations
| Tool | Description |
|---|---|
apply_image | Unified image tool: generate (AI), upload (base64), or reference existing media — and optionally assign to project cover or element image |
Call list_image_profiles first and pass profileId when using source: "generate".
Discovering Projects
Every other tool takes a project argument in username/slug form, so start with project discovery. Some MCP clients (for example Claude's connector) only surface tools, not resources, which is why discovery and schema access are available as tools too.
| Tool | Arguments | Required permission | Returns |
|---|---|---|---|
list_projects | none | none | totalProjects and, per project: username, slug, projectKey, role, permissions, permissionSummary |
get_project_schemas | project (required) | read:schemas | total and schemas: the worldbuilding element types (for example Character, Location) with id, name, icon, description, tabs/fields and defaults |
list_projects only reports the caller's own grants, so it is always available to an authorized session. Example call and result:
{ "name": "list_projects", "arguments": {} }
{
"totalProjects": 1,
"projects": [
{
"username": "alice",
"slug": "my-novel",
"projectKey": "alice/my-novel",
"role": "editor",
"permissions": ["read:project", "read:elements", "write:elements"],
"permissionSummary": "..."
}
]
}
Call get_project_schemas before create_element or update_worldbuilding to learn the valid schema ids and field keys:
{ "name": "get_project_schemas", "arguments": { "project": "alice/my-novel" } }
Every worldbuilding element also has two identity fields outside its schema, description and image (listed as identityFields in the result). update_worldbuilding resolves a bare key against the element's schema first: if the schema defines a description field, "description" writes that field. Use "identity.description" / "identity.image" to write the identity field, and "worldbuilding.<key>" to force a schema field.
Available Resources
Resources list the projects the user has authorized access to:
- Project List —
inkweld://projectslists all authorized projects (same data aslist_projects) - Individual Projects —
inkweld://project/{user}/{slug}provides project details and available permissions - Sub-resources —
elements,worldbuildingandschemasunder each project;schemasreturns the same data asget_project_schemas
Call list_projects first, then use tools like search_elements, create_element and update_element to work with project content. Pass the project key (e.g., alice/my-novel) as the project parameter to specify which project to operate on.
OAuth 2.1 Flow
Discovery Endpoints
| Endpoint | RFC | Purpose |
|---|---|---|
/.well-known/oauth-protected-resource | RFC 9728 | Protected Resource Metadata |
/.well-known/oauth-authorization-server | RFC 8414 | Authorization Server Metadata |
/.well-known/openid-configuration | OIDC | OpenID Connect discovery (fallback) |
Supported Standards
- OAuth 2.1 with mandatory PKCE (S256)
- Dynamic Client Registration (RFC 7591) — no pre-registration needed
- Token Rotation — refresh tokens are rotated on each use with a grace period
- JWT Access Tokens — HS256 signed, contain session and user info
Scopes
| Scope | Description |
|---|---|
mcp:tools | Access to MCP tools |
mcp:resources | Access to MCP resources |
read:project | Read project metadata |
read:elements | Read story elements |
read:worldbuilding | Read worldbuilding data |
read:schemas | Read element schemas |
write:elements | Create/modify elements |
write:worldbuilding | Modify worldbuilding data |
Grant Model
During the consent flow, users select which projects to share and choose an access level per project:
| Role | Permissions |
|---|---|
| Viewer | read:project, read:elements, read:worldbuilding, read:schemas |
| Editor | All viewer permissions + write:elements, write:worldbuilding |
| Admin | All permissions |
Users may instead choose "Access all my projects" plus a default access level. In that mode the session (mcp_oauth_sessions.access_all_projects / default_role) expands to every project the user owns (current and future) at the default role, instead of an explicit project_collaborators row per project. Explicit per-project grants are still honoured as overrides where present. This choice is carried from the consent page through the authorization code (mcp_oauth_codes) to the session.
Dual-Runtime Support
The MCP/OAuth system runs on both:
- Bun (local development) — uses LevelDB for Yjs document storage
- Cloudflare Workers (production) — uses Durable Objects for Yjs documents
Environment variables (BASE_URL, DATABASE_KEY, etc.) are accessed via c.env on Workers with a process.env fallback for Bun.
Connected Apps Management
Users manage authorized OAuth clients from User Settings → Authorized Apps (also deep-linkable at /settings). Both entry points render the same shared ProjectGrantListComponent used by the consent page, so there is a single implementation of the project + access-level UI:
- View all connected apps
- Change access levels per project
- Toggle "Access all my projects" and set a default access level
- Add / revoke individual project access
- Revoke an app entirely
The OAuth consent page and the Authorized Apps management tab both use the shared ProjectGrantListComponent (frontend/src/app/components/project-grant-list/) so project-grant editing stays consistent across the two flows.
Configuration
Required Environment Variables
| Variable | Purpose |
|---|---|
DATABASE_KEY | JWT signing secret (min 32 chars). Falls back to SESSION_SECRET when unset, so most deployments only set SESSION_SECRET |
BASE_URL | Public server URL, used as the OAuth issuer and in discovery metadata. Optional: the discovery endpoints fall back to the request's own origin, but set it explicitly behind a reverse proxy or on Cloudflare Workers, where the origin the server sees may not be the public one |
ALLOWED_ORIGINS | Comma-separated frontend URLs (the first is used for the consent page redirect) |
See the Configuration reference for details. MCP_ENABLED turns MCP off entirely.
Token Lifetimes
| Token | Lifetime |
|---|---|
| Access Token | 1 hour |
| Refresh Token | 30 days |
| Authorization Code | 5 minutes |