Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,6 @@ npm-debug.log

# Python release tooling
__pycache__/

# Remote MCP client example dependencies
/examples/mcp-client/node_modules/
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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),
Expand Down
21 changes: 21 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
160 changes: 160 additions & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
@@ -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.
133 changes: 133 additions & 0 deletions examples/mcp-client/client.mjs
Original file line number Diff line number Diff line change
@@ -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('<h1>Connection cancelled</h1><p>You can close this window.</p>');
callback.reject(new Error('Authorization was denied or did not return a code.'));
return;
}
res.end('<!doctype html><title>Connected to Mave</title><body style="font:16px system-ui;background:#f5f5f4;color:#44403c;display:grid;place-content:center;min-height:90vh;text-align:center"><h1>Connected to Mave</h1><p>Return to your terminal. You can close this window.</p>');
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; });
}
Loading
Loading