From 4bb40deffdf0958a6c7251801746f881363fd8eb Mon Sep 17 00:00:00 2001 From: bluebird42 <2034043+bluebird42@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:06:40 +0200 Subject: [PATCH] Add remote MCP with space-scoped browser OAuth --- .github/workflows/security.yml | 6 + .gitignore | 3 + README.md | 2 +- config/config.exs | 5 +- config/runtime.exs | 21 + docs/mcp.md | 160 +++ examples/mcp-client/client.mjs | 133 ++ examples/mcp-client/interop.mjs | 46 + examples/mcp-client/interop_test.exs | 29 + examples/mcp-client/package-lock.json | 1218 +++++++++++++++++ examples/mcp-client/package.json | 10 + lib/mave_core/mcp/client.ex | 10 + lib/mave_core/mcp/config.ex | 36 + lib/mave_core/mcp/grant.ex | 19 + lib/mave_core/mcp/oauth.ex | 336 +++++ lib/mave_core/mcp/token.ex | 14 + lib/mave_core/mcp/tools.ex | 320 +++++ lib/mave_core/workers/mcp_prune_worker.ex | 9 + .../components/connection_components.ex | 55 + .../controllers/mcp_controller.ex | 134 ++ .../controllers/mcp_oauth_controller.ex | 70 + lib/mave_core_web/live/cli_auth_live.ex | 290 ++-- .../live/dashboard/settings/developer_tab.ex | 13 + lib/mave_core_web/live/mcp_auth_live.ex | 135 ++ .../live/mcp_connections_live.ex | 58 + lib/mave_core_web/plugs/mcp_cors.ex | 43 + lib/mave_core_web/router.ex | 44 + .../20261014120000_create_mcp_oauth.exs | 56 + test/mave_core/mcp/oauth_test.exs | 176 +++ .../controllers/mcp_controller_test.exs | 184 +++ .../mave_core_web/live/mcp_auth_live_test.exs | 103 ++ test/support/mcp_fixtures.ex | 60 + 32 files changed, 3631 insertions(+), 167 deletions(-) create mode 100644 docs/mcp.md create mode 100644 examples/mcp-client/client.mjs create mode 100644 examples/mcp-client/interop.mjs create mode 100644 examples/mcp-client/interop_test.exs create mode 100644 examples/mcp-client/package-lock.json create mode 100644 examples/mcp-client/package.json create mode 100644 lib/mave_core/mcp/client.ex create mode 100644 lib/mave_core/mcp/config.ex create mode 100644 lib/mave_core/mcp/grant.ex create mode 100644 lib/mave_core/mcp/oauth.ex create mode 100644 lib/mave_core/mcp/token.ex create mode 100644 lib/mave_core/mcp/tools.ex create mode 100644 lib/mave_core/workers/mcp_prune_worker.ex create mode 100644 lib/mave_core_web/components/connection_components.ex create mode 100644 lib/mave_core_web/controllers/mcp_controller.ex create mode 100644 lib/mave_core_web/controllers/mcp_oauth_controller.ex create mode 100644 lib/mave_core_web/live/mcp_auth_live.ex create mode 100644 lib/mave_core_web/live/mcp_connections_live.ex create mode 100644 lib/mave_core_web/plugs/mcp_cors.ex create mode 100644 priv/repo/migrations/20261014120000_create_mcp_oauth.exs create mode 100644 test/mave_core/mcp/oauth_test.exs create mode 100644 test/mave_core_web/controllers/mcp_controller_test.exs create mode 100644 test/mave_core_web/live/mcp_auth_live_test.exs create mode 100644 test/support/mcp_fixtures.ex 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} +
+
+ +
+ """ + 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"> - - <.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"> -
- - <.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"> + + <.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"> +
+ + <.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. -
-
- - -
+ 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:

+ +

+ Read-only access. This app cannot change your library. +

+
+ <.form for={@approval_form} id="mcp-auth-approval-form" phx-submit="authorize"> + + <.dash_select + field={@approval_form[:space_id]} + options={Enum.map(@spaces, &{space_label(&1), &1.id})} + /> + +

+ 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 %> + + <% 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