diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
index 7a4abd5..2b5abd8 100644
--- a/.github/workflows/security.yml
+++ b/.github/workflows/security.yml
@@ -141,6 +141,12 @@ jobs:
mix audit
- name: Run complete test suite
run: mix test
+ - name: Test the remote MCP client against Core
+ run: |
+ npm ci --prefix examples/mcp-client
+ npm run check --prefix examples/mcp-client
+ npm audit --prefix examples/mcp-client --audit-level=high
+ mix test examples/mcp-client/interop_test.exs
- name: Test release tooling
run: python3 -m unittest discover -s .github/scripts -v
diff --git a/.gitignore b/.gitignore
index a0ea5c4..d5e22bf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -72,3 +72,6 @@ npm-debug.log
# Python release tooling
__pycache__/
+
+# Remote MCP client example dependencies
+/examples/mcp-client/node_modules/
diff --git a/README.md b/README.md
index 614eba2..dc34df5 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ encoding pipeline, an embeddable player, and built-in analytics. Upload, process
and publish videos — with your media in S3-compatible storage that you control.
[Get started](#self-hosted-quickstart) · [Self-hosting guide](deploy/self-hosted/README.md) ·
-[Contribute](CONTRIBUTING.md)
+[Contribute](CONTRIBUTING.md) · [MCP and OAuth](docs/mcp.md)
## One place for your videos
diff --git a/config/config.exs b/config/config.exs
index e1986d1..2f7dffb 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -9,7 +9,7 @@ import Config
config :phoenix,
:filter_parameters,
- ~w(password token secret authorization credential signature api_key input_url source_url code)
+ ~w(password token secret authorization credential signature api_key input_url source_url code verifier refresh_token access_token)
config :mave_core,
ecto_repos: [MaveCore.Repo, MaveCore.ClickHouseRepo],
@@ -37,7 +37,8 @@ config :mave_core, Oban,
{Oban.Plugins.Cron,
crontab: [
{"* * * * *", MaveCore.Workers.FlowRecoveryWorker},
- {"23 * * * *", MaveCore.Workers.CliAuthorizationPruneWorker}
+ {"23 * * * *", MaveCore.Workers.CliAuthorizationPruneWorker},
+ {"37 * * * *", MaveCore.Workers.McpPruneWorker}
]}
],
shutdown_grace_period: :timer.hours(6),
diff --git a/config/runtime.exs b/config/runtime.exs
index 018ef35..ccf449c 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -729,3 +729,24 @@ if config_env() == :prod do
System.get_env("UA_INSPECTOR_DATABASE_PATH") ||
Application.app_dir(:mave_core, "priv/ua_inspector")
end
+
+# Optional canonical resource URL for deployments with a separate API hostname.
+if mcp_resource = System.get_env("MAVE_MCP_RESOURCE_URL") do
+ uri = URI.parse(mcp_resource)
+
+ secure =
+ uri.scheme == "https" or
+ (uri.scheme == "http" and uri.host in ["localhost", "127.0.0.1", "::1"])
+
+ unless secure and is_binary(uri.host) and uri.path == "/mcp" and
+ is_nil(uri.userinfo) and is_nil(uri.query) and is_nil(uri.fragment) do
+ raise "MAVE_MCP_RESOURCE_URL must be an HTTPS URL ending in /mcp (loopback HTTP is allowed)"
+ end
+
+ config :mave_core, :mcp, resource_url: mcp_resource
+end
+
+if origins = System.get_env("MAVE_MCP_ALLOWED_ORIGINS") do
+ config :mave_core, :mcp,
+ allowed_origins: String.split(origins, ",", trim: true) |> Enum.map(&String.trim/1)
+end
diff --git a/docs/mcp.md b/docs/mcp.md
new file mode 100644
index 0000000..fc3d8df
--- /dev/null
+++ b/docs/mcp.md
@@ -0,0 +1,160 @@
+# Remote MCP
+
+Mave Core exposes a remote [Model Context Protocol](https://modelcontextprotocol.io/specification/2025-11-25)
+server at `/mcp`. The hosted URL is `https://api.mave.io/mcp` after the release
+containing this feature has been deployed. Self-hosted installations use their
+own API origin. There is no separate MCP process to run.
+
+## Connect an assistant
+
+Add the remote MCP URL to a client supporting **Streamable HTTP and browser
+OAuth**. The client discovers Mave's authorization server, registers its callback,
+and opens the existing Mave sign-in flow. Sign in, check the displayed app and
+return address, choose a space, and approve the connection. The browser returns
+to your client automatically.
+
+The authorization screen shares its layout and components with Mave CLI login.
+An app gets access to one selected space. `mave:read` is the default;
+`mave:read mave:write` additionally permits creation, updates, imports and deletion.
+Write access is explicitly shown on the consent screen. A client asking for
+write access must request both scopes in its authorization URL.
+
+Manage or disconnect apps at **Settings → Developer → Manage connected apps**
+(or `/mcp/connections` on the dashboard). Disconnecting immediately invalidates
+all access and refresh tokens for that connection. Removing the authorizing
+user's access to the space also invalidates the connection.
+
+This flow issues dedicated MCP credentials. It does not accept or expose API
+keys, browser session cookies, CLI device tokens, or upload JWTs at `/mcp`.
+
+## Tools
+
+| Tool | Permission | Behavior |
+| --- | --- | --- |
+| `mave_get_connection` | Read | Selected space and granted scopes |
+| `mave_list_videos` | Read | Pagination, collection and uploaded filters |
+| `mave_get_video` | Read | Video metadata and available renditions |
+| `mave_list_collections` | Read | Paginated collection list |
+| `mave_create_video` | Write | Empty video or asynchronous public URL import |
+| `mave_update_video` | Write | Rename or move; `collection: null` moves to root |
+| `mave_delete_video` | Write | Delete after the user approves; requires `confirm: true` |
+| `mave_create_collection` | Write | Named collection with optional parent |
+| `mave_update_collection` | Write | Rename or move; `collection: null` moves to root |
+| `mave_delete_collection` | Write | Delete after the user approves; requires `confirm: true` |
+
+The command vocabulary and payloads follow [mave-cli](https://github.com/maveio/mave-cli).
+Operations call Core's existing tenant-scoped contexts and serializers. API-side
+space provisioning remains outside Core's MCP surface. Local file uploads,
+Vimeo migration, webhook verification and source-file downloads remain CLI
+operations. A remote server cannot read a file on the user's computer.
+
+`mave_create_video` with `input_url` starts the existing media-processing flow.
+Poll `mave_get_video` for renditions; accepted imports are not necessarily ready
+for playback. Import URLs use Core's existing public-URL validation.
+
+## Remote client example
+
+Node.js 22 or later:
+
+```sh
+cd examples/mcp-client
+npm ci
+node client.mjs https://api.mave.io/mcp tools
+node client.mjs https://api.mave.io/mcp call mave_list_videos '{"per_page":10}'
+node client.mjs https://api.mave.io/mcp --write call mave_create_collection '{"name":"Product videos"}'
+```
+
+The example uses the official TypeScript SDK, opens a browser, verifies callback
+state, and uses an ephemeral loopback callback. `--no-open` prints the link only.
+Tokens remain in memory; each CLI invocation signs in again. For a longer-lived
+application, import `connectMave` and reuse its returned SDK `Client`, then call
+`client.close()`. The SDK can refresh tokens within that session. Persistent
+clients should store tokens and issuer-bound registration in their OS credential
+store and serialize refreshes for a connection.
+
+```js
+import { connectMave } from './examples/mcp-client/client.mjs';
+const client = await connectMave({ url: 'https://api.mave.io/mcp' });
+try {
+ const result = await client.callTool({ name: 'mave_list_videos', arguments: {} });
+ console.log(result.structuredContent);
+} finally {
+ await client.close();
+}
+```
+
+## Deployment configuration
+
+Run the normal Core database migration before serving this release. The migration
+adds only MCP client, grant and token tables. It does not modify existing API keys.
+
+| Variable | Default / purpose |
+| --- | --- |
+| `MAVE_MCP_RESOURCE_URL` | Canonical HTTPS URL ending in `/mcp`; defaults to `MAVE_API_HOST` with the dashboard scheme, otherwise `MAVE_DOMAIN` + `/mcp` |
+| `MAVE_DOMAIN` | Existing dashboard origin; serves sign-in and `/oauth/mcp/authorize` |
+| `MAVE_API_HOST` | Existing API hostname restriction |
+| `MAVE_MANAGE_HOST` | Existing dashboard hostname restriction |
+| `MAVE_MCP_ALLOWED_ORIGINS` | Extra comma-separated browser origins, e.g. a locally hosted MCP Inspector; API/dashboard origins are always allowed |
+
+For a hosted deployment, set `MAVE_MCP_RESOURCE_URL=https://api.mave.io/mcp`.
+Use HTTPS publicly. HTTP is accepted only for loopback resource overrides and
+registered loopback callbacks. Origin checks apply to requests containing an
+`Origin` header; server-to-server clients normally omit it. The endpoint retains
+the existing maintenance gate, including staging restrictions.
+
+Route these API-host paths through the existing reverse proxy:
+
+- `/mcp`
+- `/.well-known/oauth-protected-resource` and `/.well-known/oauth-protected-resource/mcp`
+- `/.well-known/oauth-authorization-server`
+- `/oauth/mcp/register`, `/oauth/mcp/token`, `/oauth/mcp/revoke`
+
+The dashboard host serves `/oauth/mcp/authorize` and `/mcp/connections`. Do not
+cache authenticated MCP or OAuth responses, or log request bodies, authorization
+headers, codes, token responses or full authorization/callback URLs. Core marks
+responses `no-store`, filters credential parameters, and disables route logging
+for MCP, registration, exchange and revocation.
+
+The existing Oban worker role prunes expired MCP records hourly in bounded
+batches. Used refresh-token digests remain until their absolute expiration to
+detect replay. Abandoned client registrations expire after 90 days without grants.
+
+## Protocol and authorization
+
+The transport implements MCP `2025-11-25`, `2025-06-18` and `2025-03-26` using
+stateless POST requests with JSON responses. It negotiates `2025-11-25` for an
+unknown initialization version. It does not advertise the 2026 per-request
+envelope protocol, SSE subscriptions, server-initiated requests, tasks, prompts,
+resources, session IDs or resumable streams. GET and DELETE return 405;
+notifications receive 202. Send both `application/json` and `text/event-stream`
+in `Accept`, and `application/json` in `Content-Type`.
+
+OAuth implements authorization-code + S256 PKCE, dynamic public-client
+registration, protected-resource metadata, authorization-server metadata,
+resource indicators, refresh rotation and revocation. Client ID metadata
+documents and confidential-client authentication are not advertised.
+
+Authorization codes expire after five minutes and can be used once. Access
+tokens expire after one hour; a connection's refresh lifetime is at most 30 days.
+Reusing a consumed code or refresh token revokes the connection. Token requests
+must include the exact `resource` and `client_id`; code exchange additionally
+requires the original `redirect_uri` and PKCE verifier. Token hashes and grants
+are stored in PostgreSQL, so replicas share authorization and revocation state.
+
+Reference: [MCP authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)
+and [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports).
+
+## Development checks
+
+With the disposable services from `CONTRIBUTING.md` running:
+
+```sh
+mix test test/mave_core/mcp test/mave_core_web/controllers/mcp_controller_test.exs test/mave_core_web/live/mcp_auth_live_test.exs test/mave_core_web/live/cli_auth_live_test.exs
+npm ci --prefix examples/mcp-client
+npm run check --prefix examples/mcp-client
+mix test examples/mcp-client/interop_test.exs
+```
+
+The interoperability test starts a loopback HTTP server and exercises it with
+the official SDK, using synthetic credentials. CI runs this in addition to the
+full Core suite. No hosted service or real account is used by these tests.
diff --git a/examples/mcp-client/client.mjs b/examples/mcp-client/client.mjs
new file mode 100644
index 0000000..2e239cb
--- /dev/null
+++ b/examples/mcp-client/client.mjs
@@ -0,0 +1,133 @@
+import { createServer } from 'node:http';
+import { randomBytes, timingSafeEqual } from 'node:crypto';
+import { once } from 'node:events';
+import { spawn } from 'node:child_process';
+import { pathToFileURL } from 'node:url';
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
+import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js';
+
+/** Connect with browser OAuth. Credentials remain in memory for this session. */
+export async function connectMave({ url = 'https://api.mave.io/mcp', write = false, openBrowser = true } = {}) {
+ const endpoint = new URL(url);
+ if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(endpoint.hostname))) {
+ throw new Error('Use HTTPS, or loopback HTTP for local development.');
+ }
+ if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash) {
+ throw new Error('The MCP URL must not include credentials, query parameters, or a fragment.');
+ }
+ const state = randomBytes(32).toString('base64url');
+ const callback = Promise.withResolvers();
+ // A timeout can occur while discovery is still running; preserve the rejection
+ // for the awaiting caller without producing an unhandled rejection.
+ callback.promise.catch(() => {});
+ let completed = false;
+ const server = createServer((req, res) => {
+ const incoming = new URL(req.url, 'http://127.0.0.1');
+ const returnedState = Buffer.from(incoming.searchParams.get('state') ?? '');
+ const expectedState = Buffer.from(state);
+ if (req.method !== 'GET' || incoming.pathname !== '/callback' || completed ||
+ returnedState.length !== expectedState.length || !timingSafeEqual(returnedState, expectedState)) {
+ res.writeHead(400).end('Invalid authorization callback.');
+ return;
+ }
+ completed = true;
+ res.setHeader('Cache-Control', 'no-store');
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
+ res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'");
+ const code = incoming.searchParams.get('code');
+ if (!code || incoming.searchParams.has('error')) {
+ res.end('
Connection cancelled You can close this window.
');
+ callback.reject(new Error('Authorization was denied or did not return a code.'));
+ return;
+ }
+ res.end('Connected to Mave Connected to Mave Return to your terminal. You can close this window.
');
+ callback.resolve(code);
+ });
+ server.listen(0, '127.0.0.1');
+ await once(server, 'listening');
+ const redirectUrl = `http://127.0.0.1:${server.address().port}/callback`;
+ const scope = write ? 'mave:read mave:write' : 'mave:read';
+ let registration, tokens, verifier;
+ const provider = {
+ redirectUrl,
+ clientMetadata: { client_name: 'Mave MCP example client', redirect_uris: [redirectUrl],
+ grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'],
+ token_endpoint_auth_method: 'none', scope },
+ state: () => state,
+ clientInformation: () => registration,
+ saveClientInformation: value => { registration = value; },
+ tokens: () => tokens,
+ saveTokens: value => { tokens = value; },
+ saveCodeVerifier: value => { verifier = value; },
+ codeVerifier: () => verifier,
+ redirectToAuthorization: authorizationUrl => {
+ // The server's 401 challenge advertises the default read scope. Explicitly
+ // request write access only when the operator selected --write.
+ authorizationUrl.searchParams.set('scope', scope);
+ process.stderr.write(`Connect to Mave in your browser:\n${authorizationUrl.href}\n`);
+ if (openBrowser) {
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32.exe' : 'xdg-open';
+ const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', authorizationUrl.href] : [authorizationUrl.href];
+ const child = spawn(command, args, { stdio: 'ignore', shell: false });
+ child.on('error', () => process.stderr.write('Open the URL above to continue.\n'));
+ child.unref();
+ }
+ },
+ invalidateCredentials: kind => {
+ if (kind === 'all' || kind === 'client') registration = undefined;
+ if (kind === 'all' || kind === 'tokens') tokens = undefined;
+ if (kind === 'all' || kind === 'verifier') verifier = undefined;
+ },
+ };
+ const timer = setTimeout(() => callback.reject(new Error('Browser authorization timed out.')), 600_000);
+ let client = new Client({ name: 'mave-mcp-client', version: '0.1.0' });
+ let transport = new StreamableHTTPClientTransport(endpoint, { authProvider: provider });
+ try {
+ try {
+ await client.connect(transport);
+ } catch (error) {
+ if (!(error instanceof UnauthorizedError)) throw error;
+ await transport.finishAuth(await callback.promise);
+ await client.close();
+ client = new Client({ name: 'mave-mcp-client', version: '0.1.0' });
+ transport = new StreamableHTTPClientTransport(endpoint, { authProvider: provider });
+ await client.connect(transport);
+ }
+ return client;
+ } catch (error) {
+ await client.close().catch(() => {});
+ throw error;
+ } finally {
+ clearTimeout(timer);
+ server.close();
+ server.closeAllConnections();
+ }
+}
+
+async function main() {
+ const args = process.argv.slice(2);
+ if (args.includes('--help')) {
+ console.log('Usage: node client.mjs [https://api.mave.io/mcp] [--write] [--no-open] [tools | call TOOL JSON]');
+ return;
+ }
+ const write = args.includes('--write');
+ const openBrowser = !args.includes('--no-open');
+ const positional = args.filter(arg => !['--write', '--no-open'].includes(arg));
+ const url = /^https?:/.test(positional[0] ?? '') ? positional.shift() : undefined;
+ const [command = 'tools', name, raw = '{}'] = positional;
+ if (!['tools', 'call'].includes(command) || (command === 'call' && !name)) throw new Error('Use tools, or call TOOL JSON.');
+ const toolArguments = JSON.parse(raw);
+ const client = await connectMave({ url, write, openBrowser });
+ try {
+ const result = command === 'tools' ? await client.listTools() : await client.callTool({ name, arguments: toolArguments });
+ console.log(JSON.stringify(result, null, 2));
+ if (result.isError) process.exitCode = 1;
+ } finally {
+ await client.close();
+ }
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch(error => { console.error(error.message); process.exitCode = 1; });
+}
diff --git a/examples/mcp-client/interop.mjs b/examples/mcp-client/interop.mjs
new file mode 100644
index 0000000..8fa2346
--- /dev/null
+++ b/examples/mcp-client/interop.mjs
@@ -0,0 +1,46 @@
+import assert from 'node:assert/strict';
+import { auth } from '@modelcontextprotocol/sdk/client/auth.js';
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
+
+const url = new URL(process.env.MAVE_MCP_TEST_URL);
+assert.equal(url.hostname, '127.0.0.1', 'This test must only contact its disposable local server.');
+let registration, verifier, authorizationUrl;
+const redirectUrl = 'http://127.0.0.1:45678/callback';
+const provider = {
+ redirectUrl,
+ clientMetadata: { client_name: 'SDK OAuth interoperability', redirect_uris: [redirectUrl],
+ grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'],
+ token_endpoint_auth_method: 'none', scope: 'mave:read' },
+ state: () => 'interop-state',
+ clientInformation: () => registration,
+ saveClientInformation: value => { registration = value; },
+ tokens: () => undefined,
+ saveTokens: () => {},
+ saveCodeVerifier: value => { verifier = value; },
+ codeVerifier: () => verifier,
+ redirectToAuthorization: value => { authorizationUrl = value; },
+};
+assert.equal(await auth(provider, { serverUrl: url, scope: 'mave:read' }), 'REDIRECT');
+assert(registration.client_id);
+assert.equal(authorizationUrl.searchParams.get('resource'), url.href);
+assert.equal(authorizationUrl.searchParams.get('code_challenge_method'), 'S256');
+assert.equal(authorizationUrl.searchParams.get('state'), 'interop-state');
+assert.equal(authorizationUrl.searchParams.get('redirect_uri'), redirectUrl);
+assert(verifier.length >= 43);
+const client = new Client({ name: 'mave-interop-test', version: '1.0.0' });
+try {
+ await client.connect(new StreamableHTTPClientTransport(url, {
+ requestInit: { headers: { Authorization: `Bearer ${process.env.MAVE_MCP_TEST_TOKEN}` } },
+ }));
+ const { tools } = await client.listTools();
+ assert(tools.some(tool => tool.name === 'mave_list_videos'));
+ assert(!tools.some(tool => tool.name === 'mave_delete_video'));
+ const result = await client.callTool({ name: 'mave_list_videos', arguments: { per_page: 10 } });
+ assert.equal(result.isError, false);
+ assert.deepEqual(result.structuredContent.data, []);
+ await client.ping();
+ console.log('MCP SDK interoperability passed');
+} finally {
+ await client.close();
+}
diff --git a/examples/mcp-client/interop_test.exs b/examples/mcp-client/interop_test.exs
new file mode 100644
index 0000000..8d3e86d
--- /dev/null
+++ b/examples/mcp-client/interop_test.exs
@@ -0,0 +1,29 @@
+defmodule MaveCore.MCP.SDKInteropTest do
+ use MaveCoreWeb.ConnCase, async: false
+ import MaveCore.MCPFixtures
+
+ test "the official MCP SDK initializes, discovers and calls tools over HTTP" do
+ server = start_supervised!({Bandit, plug: MaveCoreWeb.Endpoint, ip: {127, 0, 0, 1}, port: 0})
+ {:ok, {_ip, port}} = ThousandIsland.listener_info(server)
+ url = "http://127.0.0.1:#{port}/mcp"
+ previous = Application.get_env(:mave_core, :mcp)
+ Application.put_env(:mave_core, :mcp, resource_url: url)
+
+ on_exit(fn ->
+ if previous,
+ do: Application.put_env(:mave_core, :mcp, previous),
+ else: Application.delete_env(:mave_core, :mcp)
+ end)
+
+ fixture = mcp_grant_fixture()
+
+ {output, status} =
+ System.cmd("node", ["examples/mcp-client/interop.mjs"],
+ env: [{"MAVE_MCP_TEST_URL", url}, {"MAVE_MCP_TEST_TOKEN", fixture.tokens.access_token}],
+ stderr_to_stdout: true
+ )
+
+ assert status == 0, output
+ assert output =~ "MCP SDK interoperability passed"
+ end
+end
diff --git a/examples/mcp-client/package-lock.json b/examples/mcp-client/package-lock.json
new file mode 100644
index 0000000..b813096
--- /dev/null
+++ b/examples/mcp-client/package-lock.json
@@ -0,0 +1,1218 @@
+{
+ "name": "mave-mcp-client-example",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "mave-mcp-client-example",
+ "version": "0.1.0",
+ "dependencies": {
+ "@modelcontextprotocol/sdk": "^1.29.0"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/@hono/node-server": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.5.tgz",
+ "integrity": "sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "hono": "^4 || ^5.0.0-0"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk": {
+ "version": "1.32.1",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz",
+ "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==",
+ "license": "MIT",
+ "dependencies": {
+ "@hono/node-server": "^1.19.9 || ^2.0.5",
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1",
+ "content-type": "^1.0.5",
+ "cors": "^2.8.5",
+ "cross-spawn": "^7.0.5",
+ "eventsource": "^3.0.2",
+ "eventsource-parser": "^3.0.0",
+ "express": "^5.2.1",
+ "express-rate-limit": "^8.2.1",
+ "hono": "^4.11.4",
+ "jose": "^6.1.3",
+ "json-schema-typed": "^8.0.2",
+ "pkce-challenge": "^5.0.0",
+ "raw-body": "^3.0.0",
+ "zod": "^3.25 || ^4.0",
+ "zod-to-json-schema": "^3.25.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@cfworker/json-schema": "^4.1.1",
+ "zod": "^3.25 || ^4.0"
+ },
+ "peerDependenciesMeta": {
+ "@cfworker/json-schema": {
+ "optional": true
+ },
+ "zod": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
+ "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^2.0.0",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.1",
+ "iconv-lite": "^0.7.2",
+ "on-finished": "^2.4.1",
+ "qs": "^6.15.2",
+ "raw-body": "^3.0.2",
+ "type-is": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/body-parser/node_modules/content-type": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
+ "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/cors": {
+ "version": "2.8.6",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+ "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/eventsource": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
+ "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
+ "license": "MIT",
+ "dependencies": {
+ "eventsource-parser": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/eventsource-parser": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
+ "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz",
+ "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.3.0",
+ "content-disposition": "^2.0.1",
+ "content-type": "^2.0.0",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.8",
+ "qs": "^6.16.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.1.0",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-rate-limit": {
+ "version": "8.7.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz",
+ "integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "ip-address": "^10.2.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/express/node_modules/content-type": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/hono": {
+ "version": "4.13.13",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz",
+ "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.9.0"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+ "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ip-address": {
+ "version": "10.7.4",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz",
+ "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/jose": {
+ "version": "6.2.12",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
+ "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/json-schema-typed": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
+ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
+ "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
+ "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/negotiator/node_modules/content-type": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/pkce-challenge": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
+ "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.16.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
+ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
+ "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+ "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^2.0.0",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/type-is/node_modules/content-type": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/zod": {
+ "version": "4.6.7",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
+ "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-to-json-schema": {
+ "version": "3.25.2",
+ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
+ "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "zod": "^3.25.28 || ^4"
+ }
+ }
+ }
+}
diff --git a/examples/mcp-client/package.json b/examples/mcp-client/package.json
new file mode 100644
index 0000000..3b950de
--- /dev/null
+++ b/examples/mcp-client/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "mave-mcp-client-example",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "description": "A remote Mave MCP client with browser OAuth and PKCE",
+ "engines": { "node": ">=22" },
+ "scripts": { "start": "node client.mjs", "check": "node --check client.mjs" },
+ "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0" }
+}
diff --git a/lib/mave_core/mcp/client.ex b/lib/mave_core/mcp/client.ex
new file mode 100644
index 0000000..a9c8b97
--- /dev/null
+++ b/lib/mave_core/mcp/client.ex
@@ -0,0 +1,10 @@
+defmodule MaveCore.MCP.Client do
+ @moduledoc false
+ use MaveCore.Schema
+
+ schema "mcp_clients" do
+ field :name, :string
+ field :redirect_uris, {:array, :string}
+ timestamps()
+ end
+end
diff --git a/lib/mave_core/mcp/config.ex b/lib/mave_core/mcp/config.ex
new file mode 100644
index 0000000..0ed7f96
--- /dev/null
+++ b/lib/mave_core/mcp/config.ex
@@ -0,0 +1,36 @@
+defmodule MaveCore.MCP.Config do
+ @moduledoc false
+
+ def resource_url do
+ Keyword.get(config(), :resource_url) || api_origin() <> "/mcp"
+ end
+
+ def issuer, do: resource_url() |> URI.parse() |> Map.put(:path, nil) |> URI.to_string()
+ def authorization_url, do: dashboard_origin() <> "/oauth/mcp/authorize"
+
+ def allowed_origins,
+ do: Enum.uniq([issuer(), dashboard_origin() | Keyword.get(config(), :allowed_origins, [])])
+
+ def scopes, do: ["mave:read", "mave:write"]
+
+ defp config, do: Application.get_env(:mave_core, :mcp, [])
+
+ defp dashboard_origin do
+ String.trim_trailing(
+ Application.get_env(:mave_core, :domain) || MaveCoreWeb.Endpoint.url(),
+ "/"
+ )
+ end
+
+ defp api_origin do
+ case System.get_env("MAVE_API_HOST") do
+ value when is_binary(value) and value != "" ->
+ host = value |> String.split(",") |> List.first() |> String.trim()
+ scheme = URI.parse(dashboard_origin()).scheme
+ "#{scheme}://#{host}"
+
+ _ ->
+ dashboard_origin()
+ end
+ end
+end
diff --git a/lib/mave_core/mcp/grant.ex b/lib/mave_core/mcp/grant.ex
new file mode 100644
index 0000000..3c20a84
--- /dev/null
+++ b/lib/mave_core/mcp/grant.ex
@@ -0,0 +1,19 @@
+defmodule MaveCore.MCP.Grant do
+ @moduledoc false
+ use MaveCore.Schema
+
+ schema "mcp_grants" do
+ belongs_to :client, MaveCore.MCP.Client
+ belongs_to :user, MaveCore.Accounts.User
+ belongs_to :space, MaveCore.Spaces.Space
+ field :resource, :string
+ field :scope, :string
+ field :redirect_uri, :string
+ field :code_hash, :binary
+ field :code_challenge, :string
+ field :code_expires_at, :utc_datetime_usec
+ field :consumed_at, :utc_datetime_usec
+ field :revoked_at, :utc_datetime_usec
+ timestamps()
+ end
+end
diff --git a/lib/mave_core/mcp/oauth.ex b/lib/mave_core/mcp/oauth.ex
new file mode 100644
index 0000000..46c78d5
--- /dev/null
+++ b/lib/mave_core/mcp/oauth.ex
@@ -0,0 +1,336 @@
+defmodule MaveCore.MCP.OAuth do
+ @moduledoc """
+ Space-scoped OAuth grants for remote MCP clients. Secrets are stored only as
+ SHA-256 digests. Authorization codes and refresh tokens are consumed under a
+ database lock; refresh-token reuse revokes the entire connection.
+ """
+ import Ecto.Query
+
+ alias MaveCore.Accounts
+ alias MaveCore.Accounts.User
+ alias MaveCore.Ecto.LegacyShortUUID
+ alias MaveCore.MCP.{Client, Config, Grant, Token}
+ alias MaveCore.Repo
+ alias MaveCore.Spaces.Space
+
+ @access_seconds 3_600
+ @refresh_seconds 30 * 24 * 60 * 60
+
+ def register(params) when is_map(params) do
+ with name when is_binary(name) <- params["client_name"],
+ true <- String.trim(name) != "" and String.length(name) <= 100,
+ false <- Regex.match?(~r/[\p{Cc}\p{Cf}]/u, name),
+ uris when is_list(uris) and length(uris) in 1..10 <- params["redirect_uris"],
+ true <- Enum.all?(uris, &redirect_uri?/1),
+ "none" <- Map.get(params, "token_endpoint_auth_method", "none"),
+ ["code"] <- Map.get(params, "response_types", ["code"]),
+ true <-
+ valid_grants?(Map.get(params, "grant_types", ["authorization_code", "refresh_token"])) do
+ %Client{name: String.trim(name), redirect_uris: Enum.uniq(uris)} |> Repo.insert()
+ else
+ _ -> {:error, :invalid_client_metadata}
+ end
+ end
+
+ def register(_), do: {:error, :invalid_client_metadata}
+
+ def client_metadata(client) do
+ %{
+ client_id: client.id,
+ client_name: client.name,
+ client_id_issued_at: DateTime.to_unix(client.inserted_at),
+ redirect_uris: client.redirect_uris,
+ token_endpoint_auth_method: "none",
+ grant_types: ["authorization_code", "refresh_token"],
+ response_types: ["code"]
+ }
+ end
+
+ def authorization_request(params) when is_map(params) do
+ with %Client{} = client <- get_client(params["client_id"]),
+ "code" <- params["response_type"],
+ uri when is_binary(uri) <- params["redirect_uri"],
+ true <- uri in client.redirect_uris,
+ true <- params["resource"] == Config.resource_url(),
+ "S256" <- params["code_challenge_method"],
+ challenge when is_binary(challenge) <- params["code_challenge"],
+ true <- Regex.match?(~r/\A[A-Za-z0-9_-]{43}\z/, challenge),
+ true <- valid_state?(params["state"]),
+ {:ok, scope} <- scope(Map.get(params, "scope", "mave:read")) do
+ {:ok,
+ %{
+ client: client,
+ redirect_uri: uri,
+ resource: params["resource"],
+ challenge: challenge,
+ scope: scope,
+ state: params["state"]
+ }}
+ else
+ _ -> {:error, :invalid_request}
+ end
+ end
+
+ def authorization_request(_), do: {:error, :invalid_request}
+
+ def approve(request, %User{} = user, space_id) do
+ case Enum.find(Accounts.list_user_spaces(user), &(&1.id == space_id)) do
+ %Space{} = space ->
+ code = secret()
+
+ grant = %Grant{
+ client_id: request.client.id,
+ user_id: user.id,
+ space_id: space.id,
+ resource: request.resource,
+ scope: request.scope,
+ redirect_uri: request.redirect_uri,
+ code_hash: digest(code),
+ code_challenge: request.challenge,
+ code_expires_at: DateTime.add(now(), 300, :second)
+ }
+
+ case Repo.insert(grant) do
+ {:ok, _grant} -> {:ok, redirect_url(request, %{"code" => code})}
+ {:error, _} -> {:error, :invalid_request}
+ end
+
+ _ ->
+ {:error, :access_denied}
+ end
+ end
+
+ def redirect_url(request, values) do
+ uri = URI.parse(request.redirect_uri)
+ values = if request.state, do: Map.put(values, "state", request.state), else: values
+ query = URI.decode_query(uri.query || "") |> Map.merge(values) |> URI.encode_query()
+ URI.to_string(%{uri | query: query})
+ end
+
+ def exchange(%{"grant_type" => "authorization_code"} = params) do
+ transaction(fn ->
+ with code when is_binary(code) <- params["code"],
+ %Grant{} = grant <-
+ Repo.one(from g in Grant, where: g.code_hash == ^digest(code), lock: "FOR UPDATE"),
+ true <- grant.client_id == params["client_id"],
+ true <-
+ grant.resource == params["resource"] and grant.resource == Config.resource_url(),
+ true <- grant.redirect_uri == params["redirect_uri"],
+ true <- valid_verifier?(params["code_verifier"], grant.code_challenge),
+ true <- active?(grant) and future?(grant.code_expires_at) do
+ consume_code(grant)
+ else
+ _ -> {:error, :invalid_grant}
+ end
+ end)
+ end
+
+ def exchange(%{"grant_type" => "refresh_token"} = params) do
+ transaction(fn ->
+ with refresh when is_binary(refresh) <- params["refresh_token"],
+ %Token{} = token <- Repo.get_by(Token, refresh_hash: digest(refresh)),
+ %Grant{} = grant <- lock_grant(token.grant_id),
+ true <- grant.client_id == params["client_id"],
+ true <-
+ grant.resource == params["resource"] and grant.resource == Config.resource_url(),
+ true <- active?(grant),
+ true <- Map.get(params, "scope", grant.scope) == grant.scope do
+ rotate(grant, Repo.get!(Token, token.id))
+ else
+ _ -> {:error, :invalid_grant}
+ end
+ end)
+ end
+
+ def exchange(_), do: {:error, :unsupported_grant_type}
+
+ def authenticate(access) when is_binary(access) and byte_size(access) <= 512 do
+ with %Token{} = token <- Repo.get_by(Token, access_hash: digest(access)),
+ true <- future?(token.access_expires_at) and is_nil(token.refreshed_at),
+ %Grant{} = grant <- Repo.get(Grant, token.grant_id),
+ true <- grant.resource == Config.resource_url() and active?(grant) do
+ {:ok, Repo.preload(grant, [:space, :user, :client])}
+ else
+ _ -> {:error, :invalid_token}
+ end
+ end
+
+ def authenticate(_), do: {:error, :invalid_token}
+
+ def revoke_token(params) do
+ with value when is_binary(value) <- params["token"],
+ hash = digest(value),
+ %Token{} = token <-
+ Repo.one(from t in Token, where: t.access_hash == ^hash or t.refresh_hash == ^hash),
+ %Grant{client_id: client_id} = grant <- Repo.get(Grant, token.grant_id),
+ true <- client_id == params["client_id"] do
+ revoke(grant)
+ end
+
+ :ok
+ end
+
+ def connections(%User{id: id}) do
+ Repo.all(
+ from g in Grant,
+ where: g.user_id == ^id and is_nil(g.revoked_at) and not is_nil(g.consumed_at),
+ order_by: [desc: g.inserted_at],
+ preload: [:client, :space]
+ )
+ end
+
+ def revoke_connection(%User{} = user, id) do
+ case Enum.find(connections(user), &(&1.id == id)) do
+ nil -> {:error, :not_found}
+ grant -> revoke(grant)
+ end
+ end
+
+ def can_write?(%Grant{scope: scope}), do: "mave:write" in String.split(scope)
+
+ def prune_expired do
+ cutoff = DateTime.add(now(), -86_400, :second)
+ # Retain used refresh tokens until their original lifetime ends so reuse can
+ # still be detected. Each invocation is bounded, including abandoned clients.
+ expired_tokens =
+ from t in Token, where: t.refresh_expires_at < ^cutoff, select: t.id, limit: 10_000
+
+ Repo.delete_all(from t in Token, where: t.id in subquery(expired_tokens))
+
+ grants =
+ from g in Grant,
+ as: :grant,
+ where: g.code_expires_at < ^cutoff,
+ where: not exists(from t in Token, where: t.grant_id == parent_as(:grant).id),
+ select: g.id,
+ limit: 10_000
+
+ Repo.delete_all(from g in Grant, where: g.id in subquery(grants))
+ client_cutoff = DateTime.add(now(), -90 * 86_400, :second)
+
+ clients =
+ from c in Client,
+ as: :client,
+ where: c.inserted_at < ^client_cutoff,
+ where: not exists(from g in Grant, where: g.client_id == parent_as(:client).id),
+ select: c.id,
+ limit: 10_000
+
+ Repo.delete_all(from c in Client, where: c.id in subquery(clients))
+ :ok
+ end
+
+ defp consume_code(%Grant{consumed_at: nil} = grant) do
+ Repo.update!(Ecto.Changeset.change(grant, consumed_at: now()))
+ issue_tokens(grant, DateTime.add(now(), @refresh_seconds, :second))
+ end
+
+ defp consume_code(grant) do
+ revoke(grant)
+ {:error, :invalid_grant}
+ end
+
+ defp rotate(grant, %Token{refreshed_at: nil} = token) do
+ if future?(token.refresh_expires_at) do
+ Repo.update!(Ecto.Changeset.change(token, refreshed_at: now()))
+ issue_tokens(grant, token.refresh_expires_at)
+ else
+ {:error, :invalid_grant}
+ end
+ end
+
+ defp rotate(grant, _token) do
+ revoke(grant)
+ {:error, :invalid_grant}
+ end
+
+ defp issue_tokens(grant, refresh_expires_at) do
+ access = secret()
+ refresh = secret()
+
+ Repo.insert!(%Token{
+ grant_id: grant.id,
+ access_hash: digest(access),
+ refresh_hash: digest(refresh),
+ access_expires_at: DateTime.add(now(), @access_seconds, :second),
+ refresh_expires_at: refresh_expires_at
+ })
+
+ {:ok,
+ %{
+ access_token: access,
+ refresh_token: refresh,
+ token_type: "Bearer",
+ expires_in: @access_seconds,
+ scope: grant.scope
+ }}
+ end
+
+ defp active?(%Grant{revoked_at: nil} = grant) do
+ Accounts.can_access_space?(Repo.get(User, grant.user_id), Repo.get(Space, grant.space_id))
+ end
+
+ defp active?(_), do: false
+ defp revoke(grant), do: Repo.update(Ecto.Changeset.change(grant, revoked_at: now()))
+ defp lock_grant(id), do: Repo.one(from g in Grant, where: g.id == ^id, lock: "FOR UPDATE")
+
+ defp get_client(value) when is_binary(value) do
+ case LegacyShortUUID.cast(value) do
+ {:ok, id} -> Repo.get(Client, id)
+ _ -> nil
+ end
+ end
+
+ defp get_client(_), do: nil
+
+ defp redirect_uri?(value) when is_binary(value) and byte_size(value) <= 2_048 do
+ uri = URI.parse(value)
+
+ allowed_scheme =
+ uri.scheme == "https" or
+ (uri.scheme == "http" and uri.host in ["127.0.0.1", "localhost", "::1"])
+
+ allowed_scheme and is_binary(uri.host) and uri.host != "" and
+ is_nil(uri.userinfo) and is_nil(uri.fragment) and
+ not Regex.match?(~r/[\x00-\x20\x7f\\]/, value)
+ end
+
+ defp redirect_uri?(_), do: false
+ defp valid_state?(nil), do: true
+ defp valid_state?(value), do: is_binary(value) and byte_size(value) <= 2_048
+
+ defp valid_grants?(values) when is_list(values),
+ do:
+ "authorization_code" in values and
+ Enum.all?(values, &(&1 in ["authorization_code", "refresh_token"]))
+
+ defp valid_grants?(_), do: false
+
+ defp scope(value) when is_binary(value) do
+ scopes = String.split(value) |> Enum.uniq()
+
+ if "mave:read" in scopes and Enum.all?(scopes, &(&1 in Config.scopes())),
+ do: {:ok, Enum.sort(scopes) |> Enum.join(" ")},
+ else: {:error, :invalid_scope}
+ end
+
+ defp scope(_), do: {:error, :invalid_scope}
+
+ defp valid_verifier?(value, challenge) when is_binary(value) do
+ Regex.match?(~r/\A[A-Za-z0-9._~-]{43,128}\z/, value) and
+ Plug.Crypto.secure_compare(Base.url_encode64(digest(value), padding: false), challenge)
+ end
+
+ defp valid_verifier?(_, _), do: false
+ defp secret, do: :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)
+ defp digest(value), do: :crypto.hash(:sha256, value)
+ defp now, do: DateTime.utc_now()
+ defp future?(date), do: DateTime.compare(date, now()) == :gt
+
+ defp transaction(fun) do
+ case Repo.transaction(fun) do
+ {:ok, result} -> result
+ {:error, _} -> {:error, :invalid_grant}
+ end
+ end
+end
diff --git a/lib/mave_core/mcp/token.ex b/lib/mave_core/mcp/token.ex
new file mode 100644
index 0000000..a6e1f2a
--- /dev/null
+++ b/lib/mave_core/mcp/token.ex
@@ -0,0 +1,14 @@
+defmodule MaveCore.MCP.Token do
+ @moduledoc false
+ use MaveCore.Schema
+
+ schema "mcp_tokens" do
+ belongs_to :grant, MaveCore.MCP.Grant
+ field :access_hash, :binary
+ field :refresh_hash, :binary
+ field :access_expires_at, :utc_datetime_usec
+ field :refresh_expires_at, :utc_datetime_usec
+ field :refreshed_at, :utc_datetime_usec
+ timestamps()
+ end
+end
diff --git a/lib/mave_core/mcp/tools.ex b/lib/mave_core/mcp/tools.ex
new file mode 100644
index 0000000..248ecb1
--- /dev/null
+++ b/lib/mave_core/mcp/tools.ex
@@ -0,0 +1,320 @@
+defmodule MaveCore.MCP.Tools do
+ @moduledoc "Space-scoped MCP tools matching the video and collection commands in mave-cli."
+
+ alias MaveCore.Embeds
+ alias MaveCore.Embeds.Embed
+ alias MaveCore.MCP.OAuth
+ alias MaveCore.PublicApi
+
+ def list(grant) do
+ definitions()
+ |> Enum.filter(fn tool -> tool.annotations.readOnlyHint or OAuth.can_write?(grant) end)
+ end
+
+ def call(grant, name, arguments) when is_map(arguments) do
+ with tool when not is_nil(tool) <- Enum.find(list(grant), &(&1.name == name)),
+ true <- valid_arguments?(arguments, tool.inputSchema) do
+ {:ok, execute(grant, name, arguments)}
+ else
+ _ -> {:error, :invalid_params}
+ end
+ end
+
+ def call(_, _, _), do: {:error, :invalid_params}
+
+ defp definitions do
+ id = %{type: "string", minLength: 1, maxLength: 80}
+ name = %{type: "string", minLength: 1, maxLength: 255}
+
+ collection = %{
+ type: ["string", "null"],
+ minLength: 1,
+ maxLength: 80,
+ description: "Collection ID, or null to move to the root."
+ }
+
+ pagination = %{
+ "page" => %{type: "integer", minimum: 1, maximum: 1_000_000},
+ "per_page" => %{type: "integer", minimum: 1, maximum: 100}
+ }
+
+ update = %{"id" => id, "name" => name, "collection" => collection}
+
+ deletion = %{
+ "id" => id,
+ "confirm" => %{
+ type: "boolean",
+ const: true,
+ description: "Set true only after the user approves this deletion."
+ }
+ }
+
+ [
+ tool(
+ "mave_get_connection",
+ "Show the connected Mave space and granted permissions.",
+ %{},
+ []
+ ),
+ tool(
+ "mave_list_videos",
+ "List videos in the authorized space, with pagination and optional collection/upload filters.",
+ Map.merge(pagination, %{
+ "collection" => id,
+ "uploaded" => %{type: "boolean"},
+ "show_collections" => %{type: "boolean"}
+ }),
+ []
+ ),
+ tool(
+ "mave_get_video",
+ "Read a video's metadata and available renditions. Poll this after a URL import to check processing.",
+ %{"id" => id},
+ ["id"]
+ ),
+ tool(
+ "mave_create_video",
+ "Create a video, optionally importing a public HTTP(S) input_url. Processing continues asynchronously; a successful call does not mean playback is ready.",
+ %{
+ "name" => name,
+ "collection" => id,
+ "input_url" => %{type: "string", minLength: 1, maxLength: 2_048}
+ },
+ [],
+ :create
+ ),
+ tool(
+ "mave_update_video",
+ "Rename or move a video. Supply name or collection; null collection moves it to the root.",
+ update,
+ ["id"],
+ :update
+ ),
+ tool(
+ "mave_delete_video",
+ "Delete a video in the authorized space. Requires explicit user confirmation.",
+ deletion,
+ ["id", "confirm"],
+ :delete
+ ),
+ tool("mave_list_collections", "List collections in the authorized space.", pagination, []),
+ tool(
+ "mave_create_collection",
+ "Create a named collection, optionally inside another collection.",
+ %{"name" => name, "collection" => id},
+ ["name"],
+ :create
+ ),
+ tool(
+ "mave_update_collection",
+ "Rename or move a collection. Supply name or collection; null collection moves it to the root.",
+ update,
+ ["id"],
+ :update
+ ),
+ tool(
+ "mave_delete_collection",
+ "Delete a collection in the authorized space. Requires explicit user confirmation.",
+ deletion,
+ ["id", "confirm"],
+ :delete
+ )
+ ]
+ end
+
+ defp tool(name, description, properties, required, operation \\ :read) do
+ %{
+ name: name,
+ description: description,
+ inputSchema: %{
+ type: "object",
+ properties: properties,
+ required: required,
+ additionalProperties: false
+ },
+ annotations: %{
+ readOnlyHint: operation == :read,
+ destructiveHint: operation in [:update, :delete],
+ idempotentHint: operation in [:read, :update, :delete],
+ openWorldHint: true
+ }
+ }
+ end
+
+ defp valid_arguments?(args, schema) do
+ Enum.all?(schema.required, &Map.has_key?(args, &1)) and
+ Enum.all?(args, fn {key, value} ->
+ case Map.get(schema.properties, key) do
+ nil -> false
+ property -> valid_value?(value, property)
+ end
+ end)
+ end
+
+ defp valid_value?(nil, %{type: ["string", "null"]}), do: true
+
+ defp valid_value?(value, %{type: ["string", "null"]} = spec),
+ do: valid_value?(value, %{spec | type: "string"})
+
+ defp valid_value?(value, %{type: "string"} = spec),
+ do:
+ is_binary(value) and String.length(String.trim(value)) >= spec.minLength and
+ String.length(value) <= spec.maxLength
+
+ defp valid_value?(value, %{type: "integer"} = spec),
+ do: is_integer(value) and value >= spec.minimum and value <= spec.maximum
+
+ defp valid_value?(value, %{const: expected}), do: value == expected
+ defp valid_value?(value, %{type: "boolean"}), do: is_boolean(value)
+
+ defp execute(grant, "mave_get_connection", _args),
+ do: success(%{space_id: grant.space.id, space_hash: grant.space.hash, scope: grant.scope})
+
+ defp execute(%{space: space}, "mave_list_videos", args) do
+ case PublicApi.resolve_collection_id(space, args["collection"]) do
+ {:ok, collection_id} ->
+ opts =
+ pagination(args) ++
+ [
+ collection_id: collection_id,
+ uploaded: args["uploaded"],
+ show_collections: args["show_collections"] == true
+ ]
+
+ data =
+ Enum.map(PublicApi.list_videos(space, opts), fn
+ %Embed{type: :collection} = embed -> PublicApi.collection_response(space, embed)
+ embed -> PublicApi.video_response(space, embed)
+ end)
+
+ success(
+ PublicApi.list_response(
+ data,
+ opts[:page],
+ opts[:per_page],
+ PublicApi.count_videos(space, opts)
+ )
+ )
+
+ {:error, _} ->
+ failure("This collection does not belong to the connected space.")
+ end
+ end
+
+ defp execute(%{space: space}, "mave_list_collections", args) do
+ opts = pagination(args)
+
+ data =
+ Enum.map(PublicApi.list_collections(space, opts), &PublicApi.collection_response(space, &1))
+
+ success(
+ PublicApi.list_response(
+ data,
+ opts[:page],
+ opts[:per_page],
+ PublicApi.count_collections(space, opts)
+ )
+ )
+ end
+
+ defp execute(%{space: space}, "mave_get_video", args) do
+ case PublicApi.get_embed(space, args["id"]) do
+ %Embed{type: :video} = embed -> success(PublicApi.video_response(space, embed))
+ _ -> failure("Video not found in the connected space.")
+ end
+ end
+
+ defp execute(%{space: space}, "mave_create_video", args) do
+ with :ok <- validate_input(args["input_url"]),
+ {:ok, parent} <- PublicApi.resolve_collection_embed(space, args["collection"]),
+ {:ok, embed} <-
+ Embeds.create_video_embed(space, %{
+ name: args["name"],
+ parent_folder_id: parent && parent.id
+ }),
+ :ok <- start_import(space, embed, args["input_url"]) do
+ success(PublicApi.video_response(space, PublicApi.get_embed(space, embed.hash)))
+ else
+ {:error, _} ->
+ failure(
+ "Could not create the video. Check the collection, public input URL, and space limits before retrying."
+ )
+ end
+ end
+
+ defp execute(%{space: space}, "mave_create_collection", args) do
+ with {:ok, parent} <- PublicApi.resolve_collection_embed(space, args["collection"]),
+ {:ok, embed} <-
+ Embeds.create_folder_embed(space, %{
+ name: args["name"],
+ parent_folder_id: parent && parent.id
+ }) do
+ success(PublicApi.collection_response(space, PublicApi.get_embed(space, embed.hash)))
+ else
+ {:error, _} -> failure("Could not create the collection in the connected space.")
+ end
+ end
+
+ defp execute(%{space: space}, name, args)
+ when name in ["mave_update_video", "mave_update_collection"] do
+ type = if name == "mave_update_video", do: :video, else: :collection
+
+ with %Embed{type: ^type} = embed <- PublicApi.get_embed(space, args["id"]),
+ true <- Map.has_key?(args, "name") or Map.has_key?(args, "collection"),
+ {:ok, parent} <- PublicApi.resolve_collection_embed(space, args["collection"]),
+ {:ok, _} <- update_embed(embed, parent, args) do
+ success(serialize(space, PublicApi.get_embed(space, embed.hash)))
+ else
+ _ ->
+ failure(
+ "Could not update this item. Supply name or collection and use IDs from the connected space."
+ )
+ end
+ end
+
+ defp execute(%{space: space}, name, args)
+ when name in ["mave_delete_video", "mave_delete_collection"] do
+ type = if name == "mave_delete_video", do: :video, else: :collection
+
+ with %Embed{type: ^type} = embed <- PublicApi.get_embed(space, args["id"]),
+ {:ok, _} <- Embeds.delete_embed(embed) do
+ success(%{deleted: true, id: args["id"]})
+ else
+ _ -> failure("Item not found or could not be deleted in the connected space.")
+ end
+ end
+
+ defp update_embed(embed, parent, args) do
+ MaveCore.Repo.transaction(fn ->
+ with {:ok, embed} <- maybe_rename(embed, args),
+ {:ok, embed} <- maybe_move(embed, parent, args) do
+ embed
+ else
+ {:error, reason} -> MaveCore.Repo.rollback(reason)
+ end
+ end)
+ end
+
+ defp maybe_rename(embed, %{"name" => name}), do: Embeds.rename_embed(embed, name)
+ defp maybe_rename(embed, _), do: {:ok, embed}
+ defp maybe_move(embed, parent, %{"collection" => _}), do: Embeds.move_embed(embed, parent)
+ defp maybe_move(embed, _, _), do: {:ok, embed}
+ defp serialize(space, %Embed{type: :video} = embed), do: PublicApi.video_response(space, embed)
+ defp serialize(space, embed), do: PublicApi.collection_response(space, embed)
+ defp validate_input(nil), do: :ok
+ defp validate_input(url), do: PublicApi.validate_input_url(url)
+ defp start_import(_, _, nil), do: :ok
+ defp start_import(space, embed, url), do: PublicApi.start_input_url_upload(space, embed, url)
+
+ defp pagination(args),
+ do: [page: Map.get(args, "page", 1), per_page: Map.get(args, "per_page", 15)]
+
+ defp success(data),
+ do: %{
+ content: [%{type: "text", text: Jason.encode!(data)}],
+ structuredContent: data,
+ isError: false
+ }
+
+ defp failure(message), do: %{content: [%{type: "text", text: message}], isError: true}
+end
diff --git a/lib/mave_core/workers/mcp_prune_worker.ex b/lib/mave_core/workers/mcp_prune_worker.ex
new file mode 100644
index 0000000..d2a5cdb
--- /dev/null
+++ b/lib/mave_core/workers/mcp_prune_worker.ex
@@ -0,0 +1,9 @@
+defmodule MaveCore.Workers.McpPruneWorker do
+ @moduledoc "Removes expired MCP grants, tokens, and abandoned client registrations."
+ use Oban.Worker, queue: :flow_low, max_attempts: 3
+
+ alias MaveCore.MCP.OAuth
+
+ @impl true
+ def perform(_job), do: OAuth.prune_expired()
+end
diff --git a/lib/mave_core_web/components/connection_components.ex b/lib/mave_core_web/components/connection_components.ex
new file mode 100644
index 0000000..38a62fd
--- /dev/null
+++ b/lib/mave_core_web/components/connection_components.ex
@@ -0,0 +1,55 @@
+defmodule MaveCoreWeb.ConnectionComponents do
+ @moduledoc "Shared browser authorization layout for CLI and MCP connections."
+ use MaveCoreWeb, :html
+ import MaveCoreWeb.DashboardComponents, only: [animated_icon: 1]
+
+ attr :id, :string, required: true
+ attr :title_id, :string, required: true
+ attr :title, :string, required: true
+ attr :eyebrow, :string, required: true
+ attr :footer, :string, required: true
+ slot :inner_block, required: true
+
+ def connection_card(assigns) do
+ ~H"""
+
+
+
+ <.animated_icon name="link" class="size-full p-3 opacity-25" speed="1" />
+
+
+
{@eyebrow}
+
{@title}
+
+ {render_slot(@inner_block)}
+
+ {@footer}
+
+
+
+
+ <.animated_icon
+ name="waves"
+ class="size-full rotate-180 grayscale opacity-10 contrast-200"
+ speed="0.5"
+ loop
+ />
+
+
+ """
+ end
+end
diff --git a/lib/mave_core_web/controllers/mcp_controller.ex b/lib/mave_core_web/controllers/mcp_controller.ex
new file mode 100644
index 0000000..d3af1bb
--- /dev/null
+++ b/lib/mave_core_web/controllers/mcp_controller.ex
@@ -0,0 +1,134 @@
+defmodule MaveCoreWeb.McpController do
+ @moduledoc "Stateless JSON-response Streamable HTTP transport for MCP 2025 clients."
+ use MaveCoreWeb, :controller
+
+ alias MaveCore.MCP.{Config, OAuth, Tools}
+ alias MaveCoreWeb.Plugs.EventIngestRateLimit
+
+ @versions ["2025-11-25", "2025-06-18", "2025-03-26"]
+ plug EventIngestRateLimit, bucket: :mcp, interval_ms: 60_000, max_requests: 300
+ plug :authenticate
+
+ def handle(%{method: "POST"} = conn, _params) do
+ with :ok <- accept(conn),
+ :ok <- version(conn) do
+ dispatch(conn, conn.body_params)
+ else
+ {:error, status, message} -> rpc_error(conn, nil, -32_000, message, status)
+ end
+ end
+
+ def handle(conn, _params) do
+ conn |> put_resp_header("allow", "POST, OPTIONS") |> send_resp(:method_not_allowed, "")
+ end
+
+ defp dispatch(conn, %{"jsonrpc" => "2.0", "method" => method} = request)
+ when is_binary(method) do
+ params = Map.get(request, "params", %{})
+
+ cond do
+ not is_map(params) ->
+ rpc_error(conn, request["id"], -32_602, "Invalid params")
+
+ not Map.has_key?(request, "id") ->
+ send_resp(conn, :accepted, "")
+
+ not (is_binary(request["id"]) or is_integer(request["id"])) ->
+ rpc_error(conn, nil, -32_600, "Invalid Request")
+
+ true ->
+ request(conn, request["id"], method, params)
+ end
+ end
+
+ defp dispatch(conn, _), do: rpc_error(conn, nil, -32_600, "Invalid Request")
+
+ defp request(conn, id, "initialize", %{"protocolVersion" => version}) when is_binary(version) do
+ negotiated = if version in @versions, do: version, else: hd(@versions)
+
+ result(conn, id, %{
+ protocolVersion: negotiated,
+ capabilities: %{tools: %{listChanged: false}},
+ serverInfo: %{name: "mave", version: Application.spec(:mave_core, :vsn) |> to_string()},
+ instructions:
+ "Manage videos and collections only in the connected Mave space. Confirm deletions with the user. Video imports process asynchronously."
+ })
+ end
+
+ defp request(conn, id, "initialize", _),
+ do: rpc_error(conn, id, -32_602, "Invalid initialization params")
+
+ defp request(conn, id, "ping", _), do: result(conn, id, %{})
+
+ defp request(conn, id, "tools/list", params) when map_size(params) == 0,
+ do: result(conn, id, %{tools: Tools.list(conn.assigns.mcp_grant)})
+
+ defp request(conn, id, "tools/list", _), do: rpc_error(conn, id, -32_602, "Invalid cursor")
+
+ defp request(conn, id, "tools/call", %{"name" => name} = params) when is_binary(name) do
+ case Tools.call(conn.assigns.mcp_grant, name, Map.get(params, "arguments", %{})) do
+ {:ok, response} ->
+ result(conn, id, response)
+
+ {:error, :invalid_params} ->
+ rpc_error(conn, id, -32_602, "Unknown or unauthorized tool, or invalid arguments")
+ end
+ end
+
+ defp request(conn, id, "tools/call", _), do: rpc_error(conn, id, -32_602, "Invalid tool call")
+ defp request(conn, id, _, _), do: rpc_error(conn, id, -32_601, "Method not found")
+ defp result(conn, id, result), do: json(conn, %{jsonrpc: "2.0", id: id, result: result})
+
+ defp rpc_error(conn, id, code, message, status \\ :ok),
+ do:
+ conn
+ |> put_status(status)
+ |> json(%{jsonrpc: "2.0", id: id, error: %{code: code, message: message}})
+
+ defp authenticate(conn, _) do
+ conn =
+ conn
+ |> put_resp_header("cache-control", "no-store")
+ |> put_resp_header("pragma", "no-cache")
+
+ with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
+ {:ok, grant} <- OAuth.authenticate(token) do
+ assign(conn, :mcp_grant, grant)
+ else
+ _ ->
+ conn
+ |> put_resp_header(
+ "www-authenticate",
+ ~s(Bearer resource_metadata="#{Config.issuer()}/.well-known/oauth-protected-resource/mcp", scope="mave:read")
+ )
+ |> put_status(:unauthorized)
+ |> json(%{error: "invalid_token"})
+ |> halt()
+ end
+ end
+
+ defp accept(conn) do
+ accepts = get_req_header(conn, "accept") |> Enum.join(",")
+ content_type = get_req_header(conn, "content-type") |> List.first("")
+
+ cond do
+ not String.starts_with?(content_type, "application/json") ->
+ {:error, :unsupported_media_type, "Content-Type must be application/json"}
+
+ not (String.contains?(accepts, "application/json") and
+ String.contains?(accepts, "text/event-stream")) ->
+ {:error, :not_acceptable, "Accept must include application/json and text/event-stream"}
+
+ true ->
+ :ok
+ end
+ end
+
+ defp version(conn) do
+ case get_req_header(conn, "mcp-protocol-version") do
+ [] -> :ok
+ [version] when version in @versions -> :ok
+ _ -> {:error, :bad_request, "Unsupported MCP protocol version"}
+ end
+ end
+end
diff --git a/lib/mave_core_web/controllers/mcp_oauth_controller.ex b/lib/mave_core_web/controllers/mcp_oauth_controller.ex
new file mode 100644
index 0000000..52222f5
--- /dev/null
+++ b/lib/mave_core_web/controllers/mcp_oauth_controller.ex
@@ -0,0 +1,70 @@
+defmodule MaveCoreWeb.McpOauthController do
+ use MaveCoreWeb, :controller
+
+ alias MaveCore.MCP.{Config, OAuth}
+ alias MaveCoreWeb.Plugs.EventIngestRateLimit
+
+ plug EventIngestRateLimit,
+ [bucket: :mcp_registration, interval_ms: 60_000, max_requests: 10] when action == :register
+
+ plug EventIngestRateLimit,
+ [bucket: :mcp_oauth_token, interval_ms: 60_000, max_requests: 120]
+ when action in [:token, :revoke]
+
+ plug :no_store
+
+ def resource(conn, _) do
+ json(conn, %{
+ resource: Config.resource_url(),
+ authorization_servers: [Config.issuer()],
+ scopes_supported: Config.scopes(),
+ bearer_methods_supported: ["header"],
+ resource_name: "Mave"
+ })
+ end
+
+ def metadata(conn, _) do
+ issuer = Config.issuer()
+
+ json(conn, %{
+ issuer: issuer,
+ authorization_endpoint: Config.authorization_url(),
+ token_endpoint: issuer <> "/oauth/mcp/token",
+ registration_endpoint: issuer <> "/oauth/mcp/register",
+ revocation_endpoint: issuer <> "/oauth/mcp/revoke",
+ scopes_supported: Config.scopes(),
+ response_types_supported: ["code"],
+ grant_types_supported: ["authorization_code", "refresh_token"],
+ code_challenge_methods_supported: ["S256"],
+ token_endpoint_auth_methods_supported: ["none"],
+ revocation_endpoint_auth_methods_supported: ["none"]
+ })
+ end
+
+ def register(conn, params) do
+ case OAuth.register(params) do
+ {:ok, client} -> conn |> put_status(:created) |> json(OAuth.client_metadata(client))
+ {:error, _} -> error(conn, :invalid_client_metadata)
+ end
+ end
+
+ def token(conn, params) do
+ case OAuth.exchange(params) do
+ {:ok, response} -> json(conn, response)
+ {:error, reason} -> error(conn, reason)
+ end
+ end
+
+ def revoke(conn, params) do
+ :ok = OAuth.revoke_token(params)
+ send_resp(conn, :ok, "")
+ end
+
+ defp error(conn, reason), do: conn |> put_status(:bad_request) |> json(%{error: reason})
+
+ defp no_store(conn, _),
+ do:
+ conn
+ |> put_resp_header("cache-control", "no-store")
+ |> put_resp_header("pragma", "no-cache")
+end
diff --git a/lib/mave_core_web/live/cli_auth_live.ex b/lib/mave_core_web/live/cli_auth_live.ex
index ab50960..9be9e70 100644
--- a/lib/mave_core_web/live/cli_auth_live.ex
+++ b/lib/mave_core_web/live/cli_auth_live.ex
@@ -5,6 +5,8 @@ defmodule MaveCoreWeb.CliAuthLive do
import MaveCoreWeb.DashboardComponents, except: [icon: 1]
+ import MaveCoreWeb.ConnectionComponents
+
alias MaveCore.CliAuthorizations
alias MaveCore.Spaces.Space
@@ -78,174 +80,134 @@ defmodule MaveCoreWeb.CliAuthLive do
def render(assigns) do
~H"""
-
-
-
- <.animated_icon
- name="link"
- class="size-full p-3 opacity-25"
- speed="1"
- />
-
-
-
Mave CLI
-
- {authorization_title(@state)}
-
-
-
-
- <%= case @state do %>
- <% :enter -> %>
-
- Enter the code shown in your terminal to continue.
-
- <.form for={@code_form} id="cli-auth-code-form" phx-submit="lookup">
-
- Authorization code
-
- <.dash_input
- field={@code_form[:user_code]}
- placeholder="ABCDE-F0123"
- phx-mounted={JS.focus()}
- autocomplete="one-time-code"
- />
-
- <.dash_button id="cli-auth-code-submit" variant="primary">
- Continue
-
-
-
- <% :pending -> %>
-
-
- {@authorization.client_metadata["device_name"]}
-
-
Device name provided by the CLI
-
-
-
- Authorization code
-
-
- {@authorization.user_code}
-
-
-
- Check that this code matches the one in your terminal.
-
-
- This grants Mave CLI
- read and write access to videos and collections in the selected space.
-
- <.form for={@approval_form} id="cli-auth-approval-form" phx-submit="authorize">
-
-
- Space
-
- <.dash_select
- field={@approval_form[:space_id]}
- options={space_options(@spaces)}
- />
-
-
- You can revoke access in Settings → Developer → API Keys.
-
-
- <.dash_button
- id="cli-auth-authorize"
- variant="primary"
- icon="link"
- loading_on_submit
- loading_label="Authorizing…"
- >
- Authorize Mave CLI
-
-
-
-
- <.dash_button
- id="cli-auth-deny"
- type="button"
- phx-click="deny"
- variant="ghost"
- >
- Cancel
-
-
- <% :approved -> %>
- <.result
- id="cli-auth-approved"
- message="Return to your terminal to continue. You can close this window."
- />
- <.link
- id="cli-auth-settings"
- href={
- MaveCoreWeb.DashboardRoutes.settings_path(
- %{id: @authorization.space_id},
- :developer
- )
- }
- class="mb-6 mt-4 block text-center text-sm text-blue-400 transition hover:text-blue-500"
- >
- Manage API keys
-
- <% :denied -> %>
- <.result
- id="cli-auth-denied"
- message="No credentials were created. You can close this window."
- />
- <% :expired -> %>
- <.result
- id="cli-auth-expired"
- message="Return to the CLI and start login again to receive a new code."
- />
- <% :invalid -> %>
- <.result
- id="cli-auth-invalid"
- message="Check the code in your terminal or start login again."
+ <%= case @state do %>
+ <% :enter -> %>
+
+ Enter the code shown in your terminal to continue.
+
+ <.form for={@code_form} id="cli-auth-code-form" phx-submit="lookup">
+
+ Authorization code
+
+ <.dash_input
+ field={@code_form[:user_code]}
+ placeholder="ABCDE-F0123"
+ phx-mounted={JS.focus()}
+ autocomplete="one-time-code"
+ />
+
+ <.dash_button id="cli-auth-code-submit" variant="primary">
+ Continue
+
+
+
+ <% :pending -> %>
+
+
+ {@authorization.client_metadata["device_name"]}
+
+
Device name provided by the CLI
+
+
+
+ Authorization code
+
+
+ {@authorization.user_code}
+
+
+
+ Check that this code matches the one in your terminal.
+
+
+ This grants Mave CLI
+ read and write access to videos and collections in the selected space.
+
+ <.form for={@approval_form} id="cli-auth-approval-form" phx-submit="authorize">
+
+
+ Space
+
+ <.dash_select
+ field={@approval_form[:space_id]}
+ options={space_options(@spaces)}
/>
- <.link
- navigate={~p"/cli/auth"}
- class="mb-6 mt-6 block text-center text-xs text-blue-400 transition hover:text-blue-500"
+
+
+ You can revoke access in Settings → Developer → API Keys.
+
+
+ <.dash_button
+ id="cli-auth-authorize"
+ variant="primary"
+ icon="link"
+ loading_on_submit
+ loading_label="Authorizing…"
>
- Enter another code
-
- <% end %>
-
-
-
- Only authorize a CLI login that you started yourself.
-
-
-
-
-
- <.animated_icon
- name="waves"
- class="size-full rotate-180 grayscale opacity-10 contrast-200"
- speed="0.5"
- loop
- />
-
-
+ Authorize Mave CLI
+
+
+
+
+ <.dash_button
+ id="cli-auth-deny"
+ type="button"
+ phx-click="deny"
+ variant="ghost"
+ >
+ Cancel
+
+
+ <% :approved -> %>
+ <.result
+ id="cli-auth-approved"
+ message="Return to your terminal to continue. You can close this window."
+ />
+ <.link
+ id="cli-auth-settings"
+ href={
+ MaveCoreWeb.DashboardRoutes.settings_path(
+ %{id: @authorization.space_id},
+ :developer
+ )
+ }
+ class="mb-6 mt-4 block text-center text-sm text-blue-400 transition hover:text-blue-500"
+ >
+ Manage API keys
+
+ <% :denied -> %>
+ <.result
+ id="cli-auth-denied"
+ message="No credentials were created. You can close this window."
+ />
+ <% :expired -> %>
+ <.result
+ id="cli-auth-expired"
+ message="Return to the CLI and start login again to receive a new code."
+ />
+ <% :invalid -> %>
+ <.result
+ id="cli-auth-invalid"
+ message="Check the code in your terminal or start login again."
+ />
+ <.link
+ navigate={~p"/cli/auth"}
+ class="mb-6 mt-6 block text-center text-xs text-blue-400 transition hover:text-blue-500"
+ >
+ Enter another code
+
+ <% end %>
+
"""
end
diff --git a/lib/mave_core_web/live/dashboard/settings/developer_tab.ex b/lib/mave_core_web/live/dashboard/settings/developer_tab.ex
index 0d1015c..2988be9 100644
--- a/lib/mave_core_web/live/dashboard/settings/developer_tab.ex
+++ b/lib/mave_core_web/live/dashboard/settings/developer_tab.ex
@@ -43,6 +43,19 @@ defmodule MaveCoreWeb.Dashboard.Settings.DeveloperTab do
+ <.subtitle label={gettext("Connected apps")} icon="link" />
+
+ {gettext("Manage the apps connected to your Mave spaces through MCP.")}
+ <.link
+ id="settings-mcp-connections"
+ href={~p"/mcp/connections"}
+ class="text-blue-400 hover:underline"
+ >
+ {gettext("Manage connected apps")}
+
+
+
+
<%!-- API Keys --%>
<.subtitle label={gettext("API Keys")} icon="key">
<%= if @keys != [] do %>
diff --git a/lib/mave_core_web/live/mcp_auth_live.ex b/lib/mave_core_web/live/mcp_auth_live.ex
new file mode 100644
index 0000000..747fb05
--- /dev/null
+++ b/lib/mave_core_web/live/mcp_auth_live.ex
@@ -0,0 +1,135 @@
+defmodule MaveCoreWeb.McpAuthLive do
+ @moduledoc false
+ use MaveCoreWeb, :live_view
+
+ import MaveCoreWeb.ConnectionComponents
+ import MaveCoreWeb.DashboardComponents, except: [icon: 1]
+ alias MaveCore.MCP.OAuth
+
+ def mount(params, _session, socket) do
+ request =
+ case OAuth.authorization_request(params) do
+ {:ok, request} -> request
+ _ -> nil
+ end
+
+ {:ok,
+ socket
+ |> assign(:page_title, "Connect to Mave")
+ |> assign(:request, request)
+ |> assign(:expires_at, System.monotonic_time(:second) + 600)
+ |> assign(:error, nil)
+ |> assign(
+ :approval_form,
+ to_form(%{"space_id" => socket.assigns.current_space.id}, as: :authorization)
+ )}
+ end
+
+ def handle_event("authorize", %{"authorization" => %{"space_id" => space_id}}, socket) do
+ if socket.assigns.request && System.monotonic_time(:second) < socket.assigns.expires_at do
+ case OAuth.approve(socket.assigns.request, socket.assigns.current_user, space_id) do
+ {:ok, url} ->
+ {:noreply, redirect_to_client(socket, url)}
+
+ {:error, _} ->
+ {:noreply, assign(socket, :error, "Choose a space you can access and try again.")}
+ end
+ else
+ {:noreply,
+ assign(socket, :error, "This request expired. Reconnect from your app to continue.")}
+ end
+ end
+
+ def handle_event("deny", _, %{assigns: %{request: request}} = socket)
+ when not is_nil(request) do
+ {:noreply,
+ redirect_to_client(socket, OAuth.redirect_url(request, %{"error" => "access_denied"}))}
+ end
+
+ def handle_event(_, _, socket), do: {:noreply, socket}
+
+ # The OAuth context accepts only an exact, registered HTTPS/loopback redirect URI.
+ # sobelow_skip ["XSS.Redirect"]
+ defp redirect_to_client(socket, url), do: redirect(socket, external: url)
+
+ def render(assigns) do
+ ~H"""
+ <.connection_card
+ id="mcp-auth-window"
+ title_id="mcp-auth-title"
+ title={if @request, do: "Connect to Mave", else: "Unable to connect"}
+ eyebrow="Mave · App connection"
+ footer="Only connect an app if you started this request."
+ >
+ <%= if @request do %>
+
+
+ {@request.client.name}
+
+
+ {URI.parse(@request.redirect_uri).host}
+
+
+ App name and return address supplied by the client
+
+
+ Choose the space this app can access.
+
+
This app will be able to:
+
+ View videos, collections, and video details
+
+ Create, rename, move, and delete videos and collections
+
+
+
+ Read-only access. This app cannot change your library.
+
+
+ <.form for={@approval_form} id="mcp-auth-approval-form" phx-submit="authorize">
+ Space
+ <.dash_select
+ field={@approval_form[:space_id]}
+ options={Enum.map(@spaces, &{space_label(&1), &1.id})}
+ />
+
+ {@error}
+
+
+ Your API key stays private. You can disconnect this app at any time in <.link
+ href={~p"/mcp/connections"}
+ class="text-blue-500 hover:underline"
+ >Connected apps.
+
+
+ <.dash_button
+ id="mcp-auth-authorize"
+ variant="primary"
+ icon="link"
+ loading_on_submit
+ loading_label="Connecting…"
+ >Connect to Mave
+
+
+
+ <.dash_button id="mcp-auth-deny" type="button" phx-click="deny" variant="ghost">Cancel
+
+ <% else %>
+
+ This connection request is invalid or no longer available. Return to your app and connect again.
+
+ <% end %>
+
+ """
+ end
+
+ defp space_label(%{domains: [%{domain: domain} | _]}), do: domain
+ defp space_label(space), do: "Space #{space.hash}"
+end
diff --git a/lib/mave_core_web/live/mcp_connections_live.ex b/lib/mave_core_web/live/mcp_connections_live.ex
new file mode 100644
index 0000000..4915e70
--- /dev/null
+++ b/lib/mave_core_web/live/mcp_connections_live.ex
@@ -0,0 +1,58 @@
+defmodule MaveCoreWeb.McpConnectionsLive do
+ @moduledoc false
+ use MaveCoreWeb, :live_view
+ import MaveCoreWeb.ConnectionComponents
+ import MaveCoreWeb.DashboardComponents, except: [icon: 1]
+ alias MaveCore.MCP.OAuth
+
+ def mount(_, _, socket),
+ do: {:ok, socket |> assign(:page_title, "Connected apps") |> load_connections()}
+
+ def handle_event("revoke", %{"id" => id}, socket) do
+ OAuth.revoke_connection(socket.assigns.current_user, id)
+ {:noreply, load_connections(socket)}
+ end
+
+ def render(assigns) do
+ ~H"""
+ <.connection_card
+ id="mcp-connections"
+ title_id="mcp-connections-title"
+ title="Connected apps"
+ eyebrow="Mave"
+ footer="Disconnecting an app immediately revokes its access and refresh tokens."
+ >
+
+ No apps are connected.
+
+
+ {connection.client.name}
+
+ Space {connection.space.hash} · {if OAuth.can_write?(connection),
+ do: "Read and write",
+ else: "Read only"}
+
+ <.dash_button
+ id={"mcp-revoke-#{connection.id}"}
+ type="button"
+ phx-click="revoke"
+ phx-value-id={connection.id}
+ variant="ghost"
+ >Disconnect
+
+ <.link href={~p"/"} class="mb-6 block text-center text-sm text-blue-500 hover:underline">Back to Mave
+
+ """
+ end
+
+ defp load_connections(socket),
+ do: assign(socket, :connections, OAuth.connections(socket.assigns.current_user))
+end
diff --git a/lib/mave_core_web/plugs/mcp_cors.ex b/lib/mave_core_web/plugs/mcp_cors.ex
new file mode 100644
index 0000000..562edf2
--- /dev/null
+++ b/lib/mave_core_web/plugs/mcp_cors.ex
@@ -0,0 +1,43 @@
+defmodule MaveCoreWeb.Plugs.McpCORS do
+ @moduledoc false
+ import Plug.Conn
+ alias MaveCore.MCP.Config
+
+ def init(opts), do: opts
+
+ def call(conn, _opts) do
+ case get_req_header(conn, "origin") do
+ [] ->
+ preflight(conn)
+
+ [origin] ->
+ if origin in Config.allowed_origins() do
+ conn
+ |> put_resp_header("access-control-allow-origin", origin)
+ |> put_resp_header("vary", "origin")
+ |> preflight()
+ else
+ conn |> send_resp(:forbidden, "Origin not allowed") |> halt()
+ end
+
+ _ ->
+ conn |> send_resp(:forbidden, "Origin not allowed") |> halt()
+ end
+ end
+
+ defp preflight(conn) do
+ conn =
+ conn
+ |> put_resp_header("access-control-allow-methods", "GET, POST, DELETE, OPTIONS")
+ |> put_resp_header(
+ "access-control-allow-headers",
+ "authorization, content-type, accept, mcp-protocol-version, mcp-session-id, last-event-id"
+ )
+ |> put_resp_header(
+ "access-control-expose-headers",
+ "www-authenticate, mcp-protocol-version"
+ )
+
+ if conn.method == "OPTIONS", do: conn |> send_resp(:no_content, "") |> halt(), else: conn
+ end
+end
diff --git a/lib/mave_core_web/router.ex b/lib/mave_core_web/router.ex
index 0487296..c21ceaf 100644
--- a/lib/mave_core_web/router.ex
+++ b/lib/mave_core_web/router.ex
@@ -47,6 +47,23 @@ defmodule MaveCoreWeb.Router do
plug :accepts, ["json"]
end
+ pipeline :mcp_api do
+ plug :fetch_session
+ plug :fetch_current_user_from_session
+ plug MaveCoreWeb.Plugs.Maintenance, allow_internal_users: true
+ end
+
+ pipeline :mcp_cors do
+ plug MaveCoreWeb.Plugs.McpCORS
+ end
+
+ pipeline :mcp_browser_headers do
+ plug :put_secure_browser_headers, %{
+ "referrer-policy" => "no-referrer",
+ "cache-control" => "no-store"
+ }
+ end
+
pipeline :api_key_auth do
plug MaveCoreWeb.Plugs.ApiKeyAuth
end
@@ -81,6 +98,33 @@ defmodule MaveCoreWeb.Router do
plug MaveCoreWeb.Plugs.RequireHost, env_var: "MAVE_IMAGE_HOST"
end
+ scope "/", MaveCoreWeb do
+ pipe_through [:api_host, :mcp_cors, :mcp_api]
+ match :*, "/mcp", McpController, :handle, log: false
+ end
+
+ scope "/", MaveCoreWeb do
+ pipe_through [:api_host, :public_cors, :api]
+ get "/.well-known/oauth-protected-resource/mcp", McpOauthController, :resource
+ get "/.well-known/oauth-protected-resource", McpOauthController, :resource
+ get "/.well-known/oauth-authorization-server", McpOauthController, :metadata
+ options "/oauth/mcp/:action", McpOauthController, :metadata
+ post "/oauth/mcp/register", McpOauthController, :register, log: false
+ post "/oauth/mcp/token", McpOauthController, :token, log: false
+ post "/oauth/mcp/revoke", McpOauthController, :revoke, log: false
+ end
+
+ scope "/", MaveCoreWeb do
+ pipe_through [:browser, :manage_host, :authenticated_user, :mcp_browser_headers]
+
+ live_session :mcp_auth,
+ root_layout: {MaveCoreWeb.Layouts, :auth},
+ on_mount: [{MaveCoreWeb.UserLiveAuth, :authenticated}] do
+ live "/oauth/mcp/authorize", McpAuthLive, :authorize
+ live "/mcp/connections", McpConnectionsLive, :index
+ end
+ end
+
scope "/api/v1", MaveCoreWeb do
pipe_through [:public_cors]
get "/playback/media/:id/*path", Api.PlaybackController, :show, log: false
diff --git a/priv/repo/migrations/20261014120000_create_mcp_oauth.exs b/priv/repo/migrations/20261014120000_create_mcp_oauth.exs
new file mode 100644
index 0000000..074cf8c
--- /dev/null
+++ b/priv/repo/migrations/20261014120000_create_mcp_oauth.exs
@@ -0,0 +1,56 @@
+defmodule MaveCore.Repo.Migrations.CreateMcpOauth do
+ use Ecto.Migration
+
+ def change do
+ create table(:mcp_clients, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+ add :name, :string, null: false
+ add :redirect_uris, {:array, :text}, null: false
+ timestamps(type: :utc_datetime_usec)
+ end
+
+ create table(:mcp_grants, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+
+ add :client_id, references(:mcp_clients, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
+ add :space_id, references(:spaces, type: :binary_id, on_delete: :delete_all), null: false
+ add :resource, :text, null: false
+ add :scope, :string, null: false
+ add :redirect_uri, :text, null: false
+ add :code_hash, :binary, null: false
+ add :code_challenge, :string, null: false
+ add :code_expires_at, :utc_datetime_usec, null: false
+ add :consumed_at, :utc_datetime_usec
+ add :revoked_at, :utc_datetime_usec
+ timestamps(type: :utc_datetime_usec)
+ end
+
+ create unique_index(:mcp_grants, [:code_hash])
+ create index(:mcp_grants, [:user_id])
+ create index(:mcp_grants, [:client_id])
+ create index(:mcp_grants, [:space_id])
+ create index(:mcp_grants, [:code_expires_at])
+
+ create table(:mcp_tokens, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+
+ add :grant_id, references(:mcp_grants, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :access_hash, :binary, null: false
+ add :refresh_hash, :binary, null: false
+ add :access_expires_at, :utc_datetime_usec, null: false
+ add :refresh_expires_at, :utc_datetime_usec, null: false
+ add :refreshed_at, :utc_datetime_usec
+ timestamps(type: :utc_datetime_usec)
+ end
+
+ create unique_index(:mcp_tokens, [:access_hash])
+ create unique_index(:mcp_tokens, [:refresh_hash])
+ create index(:mcp_tokens, [:grant_id])
+ create index(:mcp_tokens, [:refresh_expires_at])
+ end
+end
diff --git a/test/mave_core/mcp/oauth_test.exs b/test/mave_core/mcp/oauth_test.exs
new file mode 100644
index 0000000..5a7c1d6
--- /dev/null
+++ b/test/mave_core/mcp/oauth_test.exs
@@ -0,0 +1,176 @@
+defmodule MaveCore.MCP.OAuthTest do
+ use MaveCore.DataCase, async: true
+ import MaveCore.MCPFixtures
+ alias MaveCore.Accounts
+ alias MaveCore.MCP.{Client, Config, Grant, OAuth, Token}
+ alias MaveCore.Spaces
+
+ test "registration validates redirects and client metadata" do
+ for uri <- [
+ "http://example.com/cb",
+ "javascript:alert(1)",
+ "https://example.com/#fragment",
+ "https://user:pass@example.com/cb",
+ "https://example.com/\ncb",
+ "file:///tmp/callback"
+ ] do
+ assert {:error, :invalid_client_metadata} =
+ OAuth.register(%{"client_name" => "Test", "redirect_uris" => [uri]})
+ end
+
+ for params <- [
+ %{},
+ %{"client_name" => [], "redirect_uris" => []},
+ %{
+ "client_name" => "Test",
+ "redirect_uris" => ["https://example.com/cb"],
+ "token_endpoint_auth_method" => "client_secret_basic"
+ }
+ ] do
+ assert {:error, :invalid_client_metadata} = OAuth.register(params)
+ end
+
+ assert {:ok, _} =
+ OAuth.register(%{
+ "client_name" => "Test",
+ "redirect_uris" => ["https://example.com/callback"]
+ })
+ end
+
+ test "requires PKCE S256, a registered redirect and the exact resource" do
+ %{params: params} = mcp_request_fixture()
+
+ for {key, value} <- [
+ {"resource", "https://other.example/mcp"},
+ {"redirect_uri", "http://127.0.0.1:9999/callback"},
+ {"code_challenge_method", "plain"},
+ {"code_challenge", "short"},
+ {"client_id", "invalid"},
+ {"scope", "mave:admin"},
+ {"state", %{}}
+ ] do
+ assert {:error, :invalid_request} = OAuth.authorization_request(Map.put(params, key, value))
+ end
+
+ assert {:error, :invalid_request} =
+ OAuth.authorization_request(Map.delete(params, "resource"))
+ end
+
+ test "codes are hashed, bound to the client, redirect and verifier, and consumed once" do
+ f = mcp_code_fixture()
+ assert f.query["state"] == "client-state"
+ assert f.query["existing"] == "1"
+ grant = Repo.one!(Grant)
+ assert grant.code_hash == :crypto.hash(:sha256, f.exchange["code"])
+ refute grant.code_hash == f.exchange["code"]
+
+ for {key, value} <- [
+ {"client_id", "other"},
+ {"code_verifier", String.duplicate("x", 43)},
+ {"redirect_uri", "https://evil.example/cb"},
+ {"resource", "https://other.example/mcp"}
+ ] do
+ assert {:error, :invalid_grant} = OAuth.exchange(Map.put(f.exchange, key, value))
+ end
+
+ assert {:ok, tokens} = OAuth.exchange(f.exchange)
+ assert {:ok, %{space_id: space_id}} = OAuth.authenticate(tokens.access_token)
+ assert space_id == f.space.id
+ assert Spaces.list_user_managed_keys(f.space) == []
+ token = Repo.one!(Token)
+ assert token.access_hash == :crypto.hash(:sha256, tokens.access_token)
+ assert token.refresh_hash == :crypto.hash(:sha256, tokens.refresh_token)
+ assert {:error, :invalid_grant} = OAuth.exchange(f.exchange)
+ assert {:error, :invalid_token} = OAuth.authenticate(tokens.access_token)
+ end
+
+ test "expired codes and access tokens fail" do
+ f = mcp_code_fixture()
+
+ Repo.one!(Grant)
+ |> Ecto.Changeset.change(code_expires_at: DateTime.add(DateTime.utc_now(), -1))
+ |> Repo.update!()
+
+ assert {:error, :invalid_grant} = OAuth.exchange(f.exchange)
+ f = mcp_grant_fixture()
+
+ Repo.get_by!(Token, access_hash: :crypto.hash(:sha256, f.tokens.access_token))
+ |> Ecto.Changeset.change(access_expires_at: DateTime.add(DateTime.utc_now(), -1))
+ |> Repo.update!()
+
+ assert {:error, :invalid_token} = OAuth.authenticate(f.tokens.access_token)
+ end
+
+ test "refresh rotates tokens and replay revokes the connection" do
+ f = mcp_grant_fixture(scope: "mave:read mave:write")
+ params = refresh_params(f)
+ assert {:error, :invalid_grant} = OAuth.exchange(Map.put(params, "client_id", "other"))
+ assert {:error, :invalid_grant} = OAuth.exchange(Map.put(params, "resource", "wrong"))
+ assert {:ok, next} = OAuth.exchange(params)
+ assert next.refresh_token != f.tokens.refresh_token
+ assert {:error, :invalid_token} = OAuth.authenticate(f.tokens.access_token)
+ assert {:ok, _} = OAuth.authenticate(next.access_token)
+ assert {:error, :invalid_grant} = OAuth.exchange(params)
+ assert {:error, :invalid_token} = OAuth.authenticate(next.access_token)
+ end
+
+ test "refresh cannot add permissions or extend its absolute lifetime" do
+ f = mcp_grant_fixture()
+ original = Repo.one!(Token)
+
+ assert {:error, :invalid_grant} =
+ OAuth.exchange(Map.put(refresh_params(f), "scope", "mave:read mave:write"))
+
+ assert {:ok, next} = OAuth.exchange(refresh_params(f))
+ next_row = Repo.get_by!(Token, access_hash: :crypto.hash(:sha256, next.access_token))
+ assert next_row.refresh_expires_at == original.refresh_expires_at
+ end
+
+ test "revocation and loss of membership invalidate access and refresh tokens" do
+ f = mcp_grant_fixture()
+
+ assert :ok =
+ OAuth.revoke_token(%{"client_id" => f.client.id, "token" => f.tokens.refresh_token})
+
+ assert {:error, :invalid_token} = OAuth.authenticate(f.tokens.access_token)
+ assert {:error, :invalid_grant} = OAuth.exchange(refresh_params(f))
+ f = mcp_grant_fixture()
+ membership = Accounts.get_membership(f.user, f.space)
+ Repo.delete!(membership)
+ assert {:error, :invalid_token} = OAuth.authenticate(f.tokens.access_token)
+ assert {:error, :invalid_grant} = OAuth.exchange(refresh_params(f))
+ end
+
+ test "another user's space or connection cannot be approved or revoked" do
+ f = mcp_grant_fixture()
+ other = mcp_user_fixture()
+ assert {:error, :access_denied} = OAuth.approve(f.request, other, f.space.id)
+ assert {:error, :not_found} = OAuth.revoke_connection(other, f.grant.id)
+ assert {:ok, _} = OAuth.authenticate(f.tokens.access_token)
+ assert {:ok, _} = OAuth.revoke_connection(f.user, f.grant.id)
+ assert OAuth.connections(f.user) == []
+ assert {:error, :invalid_token} = OAuth.authenticate(f.tokens.access_token)
+ end
+
+ test "pruning removes expired records without removing active connections" do
+ f = mcp_grant_fixture()
+ old = DateTime.add(DateTime.utc_now(), -100 * 86_400)
+ Repo.get!(Client, f.client.id) |> Ecto.Changeset.change(inserted_at: old) |> Repo.update!()
+ assert :ok = OAuth.prune_expired()
+ assert {:ok, _} = OAuth.authenticate(f.tokens.access_token)
+ Repo.one!(Token) |> Ecto.Changeset.change(refresh_expires_at: old) |> Repo.update!()
+ Repo.one!(Grant) |> Ecto.Changeset.change(code_expires_at: old) |> Repo.update!()
+ assert :ok = OAuth.prune_expired()
+ assert Repo.all(Token) == []
+ assert Repo.all(Grant) == []
+ assert Repo.all(Client) == []
+ end
+
+ defp refresh_params(f),
+ do: %{
+ "grant_type" => "refresh_token",
+ "client_id" => f.client.id,
+ "refresh_token" => f.tokens.refresh_token,
+ "resource" => Config.resource_url()
+ }
+end
diff --git a/test/mave_core_web/controllers/mcp_controller_test.exs b/test/mave_core_web/controllers/mcp_controller_test.exs
new file mode 100644
index 0000000..55a5a00
--- /dev/null
+++ b/test/mave_core_web/controllers/mcp_controller_test.exs
@@ -0,0 +1,184 @@
+defmodule MaveCoreWeb.McpControllerTest do
+ use MaveCoreWeb.ConnCase, async: true
+ import MaveCore.MCPFixtures
+ alias MaveCore.MCP.{Config, OAuth}
+
+ test "unauthenticated clients discover the OAuth server", %{conn: conn} do
+ conn = post(conn, "/mcp", %{})
+ assert json_response(conn, 401)["error"] == "invalid_token"
+
+ assert hd(get_resp_header(conn, "www-authenticate")) =~
+ "/.well-known/oauth-protected-resource/mcp"
+
+ metadata =
+ build_conn() |> get("/.well-known/oauth-protected-resource/mcp") |> json_response(200)
+
+ assert metadata["resource"] == Config.resource_url()
+
+ metadata =
+ build_conn() |> get("/.well-known/oauth-authorization-server") |> json_response(200)
+
+ assert metadata["code_challenge_methods_supported"] == ["S256"]
+ assert metadata["authorization_endpoint"] == Config.authorization_url()
+ end
+
+ test "registration and form-encoded token exchange work over HTTP" do
+ conn =
+ post(build_conn(), "/oauth/mcp/register", %{
+ "client_name" => "HTTP client",
+ "redirect_uris" => ["https://client.example/callback"]
+ })
+
+ assert json_response(conn, 201)["client_id"]
+ assert get_resp_header(conn, "cache-control") == ["no-store"]
+ f = mcp_code_fixture()
+
+ conn =
+ build_conn()
+ |> put_req_header("content-type", "application/x-www-form-urlencoded")
+ |> post("/oauth/mcp/token", URI.encode_query(f.exchange))
+
+ assert %{"access_token" => access, "refresh_token" => refresh} = json_response(conn, 200)
+ assert {:ok, _} = OAuth.authenticate(access)
+
+ conn =
+ post(build_conn(), "/oauth/mcp/revoke", %{"client_id" => f.client.id, "token" => refresh})
+
+ assert response(conn, 200) == ""
+ assert {:error, :invalid_token} = OAuth.authenticate(access)
+ end
+
+ test "initialize, tools/list, ping and notifications use stateless JSON responses" do
+ f = mcp_grant_fixture()
+
+ result =
+ rpc(f, "initialize", %{
+ "protocolVersion" => "2025-11-25",
+ "capabilities" => %{},
+ "clientInfo" => %{}
+ })
+
+ assert result["result"]["protocolVersion"] == "2025-11-25"
+ assert result["result"]["capabilities"]["tools"] == %{"listChanged" => false}
+ assert rpc(f, "ping")["result"] == %{}
+ names = rpc(f, "tools/list")["result"]["tools"] |> Enum.map(& &1["name"])
+ assert "mave_list_videos" in names
+ refute "mave_delete_video" in names
+ conn = request(f, %{"jsonrpc" => "2.0", "method" => "notifications/initialized"})
+ assert response(conn, 202) == ""
+ assert get_resp_header(conn, "mcp-session-id") == []
+ end
+
+ test "read-only access rejects writes even if the caller knows the tool name" do
+ f = mcp_grant_fixture()
+
+ assert rpc(f, "tools/call", %{"name" => "mave_create_video", "arguments" => %{}})["error"][
+ "code"
+ ] == -32_602
+
+ assert MaveCore.PublicApi.count_videos(f.space) == 0
+ end
+
+ test "video and collection tools create, list, update, move and delete" do
+ f = mcp_grant_fixture(scope: "mave:read mave:write")
+ collection = call(f, "mave_create_collection", %{"name" => "Launch"})
+ video = call(f, "mave_create_video", %{"name" => "Demo", "collection" => collection["id"]})
+ assert call(f, "mave_get_video", %{"id" => video["id"]})["name"] == "Demo"
+ assert call(f, "mave_list_videos", %{"collection" => collection["id"]})["total_items"] == 1
+ assert call(f, "mave_list_collections", %{})["total_items"] == 1
+
+ assert call(f, "mave_update_video", %{
+ "id" => video["id"],
+ "name" => "New name",
+ "collection" => nil
+ })["name"] == "New name"
+
+ assert call(f, "mave_update_collection", %{"id" => collection["id"], "name" => "Renamed"})[
+ "name"
+ ] == "Renamed"
+
+ result =
+ rpc(f, "tools/call", %{
+ "name" => "mave_delete_video",
+ "arguments" => %{"id" => video["id"], "confirm" => false}
+ })
+
+ assert result["error"]["code"] == -32_602
+ assert call(f, "mave_delete_video", %{"id" => video["id"], "confirm" => true})["deleted"]
+
+ assert call(f, "mave_delete_collection", %{"id" => collection["id"], "confirm" => true})[
+ "deleted"
+ ]
+ end
+
+ test "tools cannot read, modify, delete or move to another space's items" do
+ f = mcp_grant_fixture(scope: "mave:read mave:write")
+ other = mcp_grant_fixture(scope: "mave:read mave:write")
+ video = call(other, "mave_create_video", %{})
+ folder = call(other, "mave_create_collection", %{"name" => "Private"})
+
+ for {name, args} <- [
+ {"mave_get_video", %{"id" => video["id"]}},
+ {"mave_update_video", %{"id" => video["id"], "name" => "Attack"}},
+ {"mave_delete_video", %{"id" => video["id"], "confirm" => true}},
+ {"mave_create_video", %{"collection" => folder["id"]}}
+ ] do
+ assert rpc(f, "tools/call", %{"name" => name, "arguments" => args})["result"]["isError"]
+ end
+
+ assert call(other, "mave_get_video", %{"id" => video["id"]})["id"] == video["id"]
+ end
+
+ test "invalid arguments and methods produce protocol errors without mutations" do
+ f = mcp_grant_fixture(scope: "mave:read mave:write")
+
+ for args <- [
+ %{"page" => 0},
+ %{"per_page" => 101},
+ %{"page" => "1"},
+ %{"api_url" => "https://evil.example"}
+ ] do
+ assert rpc(f, "tools/call", %{"name" => "mave_list_videos", "arguments" => args})["error"][
+ "code"
+ ] == -32_602
+ end
+
+ assert rpc(f, "unknown")["error"]["code"] == -32_601
+
+ assert request(f, [%{"jsonrpc" => "2.0", "id" => 1, "method" => "ping"}])
+ |> json_response(200)
+ |> get_in(["error", "code"]) == -32_600
+ end
+
+ test "origin, media type, version and method checks protect the transport" do
+ f = mcp_grant_fixture()
+ conn = auth_conn(f) |> put_req_header("origin", "https://evil.example") |> post("/mcp", "{}")
+ assert response(conn, 403) == "Origin not allowed"
+ conn = auth_conn(f) |> put_req_header("mcp-protocol-version", "bad") |> post("/mcp", "{}")
+ assert json_response(conn, 400)["error"]
+ conn = auth_conn(f) |> put_req_header("accept", "application/json") |> post("/mcp", "{}")
+ assert json_response(conn, 406)["error"]
+ conn = auth_conn(f) |> put_req_header("content-type", "text/plain") |> post("/mcp", "{}")
+ assert json_response(conn, 415)["error"]
+ conn = auth_conn(f) |> get("/mcp")
+ assert response(conn, 405) == ""
+ end
+
+ defp rpc(f, method, params \\ %{}),
+ do:
+ request(f, %{"jsonrpc" => "2.0", "id" => 1, "method" => method, "params" => params})
+ |> json_response(200)
+
+ defp request(f, message), do: auth_conn(f) |> post("/mcp", Jason.encode!(message))
+
+ defp auth_conn(f),
+ do:
+ build_conn()
+ |> put_req_header("authorization", "Bearer " <> f.tokens.access_token)
+ |> put_req_header("content-type", "application/json")
+ |> put_req_header("accept", "application/json, text/event-stream")
+
+ defp call(f, name, args),
+ do:
+ rpc(f, "tools/call", %{"name" => name, "arguments" => args})["result"]["structuredContent"]
+end
diff --git a/test/mave_core_web/live/mcp_auth_live_test.exs b/test/mave_core_web/live/mcp_auth_live_test.exs
new file mode 100644
index 0000000..c0b986f
--- /dev/null
+++ b/test/mave_core_web/live/mcp_auth_live_test.exs
@@ -0,0 +1,103 @@
+defmodule MaveCoreWeb.McpAuthLiveTest do
+ use MaveCoreWeb.ConnCase, async: true
+ import Phoenix.LiveViewTest
+ import MaveCore.MCPFixtures
+ alias MaveCore.Accounts
+ alias MaveCore.MCP.OAuth
+
+ test "login preserves the complete authorization request", %{conn: conn} do
+ f = mcp_request_fixture()
+ path = "/oauth/mcp/authorize?" <> URI.encode_query(f.params)
+ conn = get(conn, path)
+ assert redirected_to(conn) == "/login"
+ assert get_session(conn, :user_return_to) == path
+ end
+
+ test "reuses the CLI card, shows read-only consent and returns a usable code", %{conn: conn} do
+ f = mcp_request_fixture()
+ user = mcp_user_fixture()
+ conn = login(conn, user)
+ {:ok, view, _} = live(conn, "/oauth/mcp/authorize?" <> URI.encode_query(f.params))
+ assert has_element?(view, "#mcp-auth-window")
+ assert has_element?(view, "#mcp-client-name", "Test assistant")
+ assert has_element?(view, "#mcp-client-destination", "127.0.0.1")
+ assert has_element?(view, "#mcp-read-only")
+ assert has_element?(view, "#mcp-auth-approval-form")
+
+ view
+ |> form("#mcp-auth-approval-form",
+ authorization: %{space_id: user.current_space_membership.space.id}
+ )
+ |> render_submit()
+
+ {url, _} = assert_redirect(view)
+ query = URI.decode_query(URI.parse(url).query)
+ assert query["state"] == "client-state"
+
+ assert {:ok, tokens} =
+ OAuth.exchange(%{
+ "grant_type" => "authorization_code",
+ "client_id" => f.client.id,
+ "code" => query["code"],
+ "code_verifier" => f.verifier,
+ "resource" => f.params["resource"],
+ "redirect_uri" => f.params["redirect_uri"]
+ })
+
+ assert {:ok, _} = OAuth.authenticate(tokens.access_token)
+ end
+
+ test "write consent is explicit and rejecting returns the original state", %{conn: conn} do
+ f = mcp_request_fixture(scope: "mave:read mave:write")
+
+ {:ok, view, _} =
+ live(login(conn, mcp_user_fixture()), "/oauth/mcp/authorize?" <> URI.encode_query(f.params))
+
+ refute has_element?(view, "#mcp-read-only")
+ assert has_element?(view, "#mcp-permissions", "delete")
+ view |> element("#mcp-auth-deny") |> render_click()
+ {url, _} = assert_redirect(view)
+ assert URI.decode_query(URI.parse(url).query)["error"] == "access_denied"
+ assert URI.decode_query(URI.parse(url).query)["state"] == "client-state"
+ end
+
+ test "invalid clients render an error without redirecting", %{conn: conn} do
+ {:ok, view, _} =
+ live(
+ login(conn, mcp_user_fixture()),
+ "/oauth/mcp/authorize?client_id=unknown&redirect_uri=https://evil.example"
+ )
+
+ assert has_element?(view, "#mcp-auth-invalid")
+ refute has_element?(view, "#mcp-auth-authorize")
+ end
+
+ test "tampering with the chosen space cannot approve another tenant", %{conn: conn} do
+ f = mcp_request_fixture()
+ other = mcp_user_fixture()
+
+ {:ok, view, _} =
+ live(login(conn, mcp_user_fixture()), "/oauth/mcp/authorize?" <> URI.encode_query(f.params))
+
+ render_submit(view, "authorize", %{
+ "authorization" => %{"space_id" => other.current_space_membership.space.id}
+ })
+
+ assert has_element?(view, "#mcp-auth-error")
+ end
+
+ test "connected apps can be revoked and immediately stop working", %{conn: conn} do
+ f = mcp_grant_fixture()
+ {:ok, view, _} = live(login(conn, f.user), "/mcp/connections")
+ assert has_element?(view, "#mcp-connection-#{f.grant.id}")
+ view |> element("#mcp-revoke-#{f.grant.id}") |> render_click()
+ assert has_element?(view, "#mcp-connections-empty")
+ assert {:error, :invalid_token} = OAuth.authenticate(f.tokens.access_token)
+ end
+
+ defp login(conn, user) do
+ token = Accounts.generate_user_login_token(user)
+ {:ok, {user, persisted}} = Accounts.login_user(token)
+ init_test_session(conn, user_token: Accounts.generate_user_session_token(persisted, user))
+ end
+end
diff --git a/test/support/mcp_fixtures.ex b/test/support/mcp_fixtures.ex
new file mode 100644
index 0000000..6f780c4
--- /dev/null
+++ b/test/support/mcp_fixtures.ex
@@ -0,0 +1,60 @@
+defmodule MaveCore.MCPFixtures do
+ @moduledoc false
+ alias MaveCore.Accounts
+ alias MaveCore.MCP.{Config, OAuth}
+
+ def mcp_user_fixture do
+ {:ok, user} = Accounts.create_user("mcp-#{System.unique_integer([:positive])}@example.com")
+ user
+ end
+
+ def mcp_request_fixture(opts \\ []) do
+ {:ok, client} =
+ OAuth.register(%{
+ "client_name" => "Test assistant",
+ "redirect_uris" => ["http://127.0.0.1:4567/callback?existing=1"]
+ })
+
+ verifier = String.duplicate("v", 43)
+
+ params = %{
+ "client_id" => client.id,
+ "redirect_uri" => hd(client.redirect_uris),
+ "response_type" => "code",
+ "resource" => Config.resource_url(),
+ "state" => "client-state",
+ "scope" => Keyword.get(opts, :scope, "mave:read"),
+ "code_challenge_method" => "S256",
+ "code_challenge" => :crypto.hash(:sha256, verifier) |> Base.url_encode64(padding: false)
+ }
+
+ {:ok, request} = OAuth.authorization_request(params)
+ %{client: client, verifier: verifier, params: params, request: request}
+ end
+
+ def mcp_code_fixture(opts \\ []) do
+ fixture = mcp_request_fixture(opts)
+ user = Keyword.get_lazy(opts, :user, &mcp_user_fixture/0)
+ space = user.current_space_membership.space
+ {:ok, url} = OAuth.approve(fixture.request, user, space.id)
+ query = URI.decode_query(URI.parse(url).query)
+
+ exchange = %{
+ "grant_type" => "authorization_code",
+ "client_id" => fixture.client.id,
+ "resource" => Config.resource_url(),
+ "code" => query["code"],
+ "code_verifier" => fixture.verifier,
+ "redirect_uri" => fixture.request.redirect_uri
+ }
+
+ Map.merge(fixture, %{user: user, space: space, exchange: exchange, query: query})
+ end
+
+ def mcp_grant_fixture(opts \\ []) do
+ fixture = mcp_code_fixture(opts)
+ {:ok, tokens} = OAuth.exchange(fixture.exchange)
+ {:ok, grant} = OAuth.authenticate(tokens.access_token)
+ Map.merge(fixture, %{tokens: tokens, grant: grant})
+ end
+end