KeyVault Documentation

KeyVault is a drop-in floating widget that gives your users a secure place to store, search, edit and copy API keys — without leaving your app. This page covers everything you need to install it on your site and to call the backend directly.

Base URL
All API endpoints live under https://kvbackend.masterymade.com. The widget bundle is hosted at https://kv.masterymade.com/dist/mm-key-vault.esm.js.
Building a chat app or AI assistant? KeyVault stores the keys — MetaMCP lets your backend actually use them: save chats to Google Docs / Notion, scrape the web with Apify, all with one email + one HTTP call. Jump to MCP integration ↓
Need to hand a user's access to a third-party app? Users mint personal API tokens (mm_live_…) from inside the widget and paste them into an external app's .env — no OAuth setup required, revokable any time. Jump to Personal API tokens ↓

Quick start

Three steps from zero to a working widget:

  1. Install the package — npm install mm-key-vault
  2. Call initKeyVault() once on the client (in a useEffect for React/Next.js)
  3. The floating button appears in the bottom-right; users sign up, then store/copy keys

For a no-build option, you can also load the ESM bundle directly via a <script type="module"> — see the Vanilla HTML example below.

Installation

Install via npm, yarn or pnpm:

bash
npm install mm-key-vault
# or
yarn add mm-key-vault
# or
pnpm add mm-key-vault

React is a peer dependency:

bash
npm install react react-dom

Framework integration

React (Vite / CRA)

Call initKeyVault() inside a useEffect so it only runs in the browser. Mount the component anywhere near your app root:

tsx
import { useEffect } from "react";
import { initKeyVault } from "mm-key-vault";

export function KeyVaultBoot() {
    useEffect(() => {
        initKeyVault();
    }, []);
    return null;
}

// Then in your root component:
// <KeyVaultBoot />

Next.js (App Router, v13+)

Create a client component, then drop it into your app/layout.tsx:

tsx — components/KeyVaultProvider.tsx
"use client";
import { useEffect } from "react";
import { initKeyVault } from "mm-key-vault";

export function KeyVaultProvider() {
    useEffect(() => {
        initKeyVault();
    }, []);
    return null;
}
tsx — app/layout.tsx
import { KeyVaultProvider } from "@/components/KeyVaultProvider";

export default function RootLayout({ children }: { children: React.ReactNode }) {
    return (
        <html lang="en">
            <body>
                {children}
                <KeyVaultProvider />
            </body>
        </html>
    );
}

Next.js (Pages Router)

tsx — pages/_app.tsx
import { useEffect } from "react";
import type { AppProps } from "next/app";
import { initKeyVault } from "mm-key-vault";

export default function App({ Component, pageProps }: AppProps) {
    useEffect(() => {
        initKeyVault();
    }, []);
    return <Component {...pageProps} />;
}

Vanilla HTML (no build step)

Use the hosted ESM bundle. Browsers need an import map so the widget can resolve react:

html
<script type="importmap">
{
    "imports": {
        "react":             "https://esm.sh/react@18",
        "react-dom":         "https://esm.sh/react-dom@18",
        "react-dom/client":  "https://esm.sh/react-dom@18/client",
        "react/jsx-runtime": "https://esm.sh/react@18/jsx-runtime"
    }
}
</script>

<script type="module">
    import { initKeyVault } from
        "https://kv.masterymade.com/dist/mm-key-vault.esm.js";
    initKeyVault();
</script>

Configuration

All options are optional. Defaults below.

ts
initKeyVault({
    position: "bottom-right",   // bottom-left | top-right | top-left | center
    offsetX:  "20px",           // any CSS length
    offsetY:  "20px",           // any CSS length
    zIndex:   9999,             // number
});
OptionTypeDefaultDescription
position"bottom-right" | "bottom-left" | "top-right" | "top-left" | "center"bottom-rightWhere the floating button anchors on the page.
offsetXstring20pxHorizontal offset from the chosen edge.
offsetYstring20pxVertical offset from the chosen edge.
zIndexnumber9999CSS z-index of the widget root.

Five more options — publishableKey, endUserId, platforms, onConnect, onConnectError — activate an entirely different mode of the same widget. See Tenant mode.


Response envelope

All endpoints return the same JSON shape on success:

json — envelope
{
    "status": 1,
    "message": "Human-readable success message",
    "data": { ... }     // shape depends on endpoint; may be absent
}

The widget treats status === 1 as success. A non-1 status (commonly 0) along with a message field is treated as a logical failure even when the HTTP status is 200.

Authorization

Protected endpoints expect a bearer token in the Authorization header. You get the token from POST /auth/login.

http
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

The SDK manages this for you — the token is read from sessionStorage (or localStorage if the user ticked "Keep me logged in") and added to every request automatically.

Error handling

On a non-2xx HTTP response, the backend returns a body like:

json — error
{
    "detail": "Email already registered"
}

The SDK exposes a typed ApiError that carries the HTTP status, so you can branch on specific codes (e.g. 403 for un-verified emails):

ts
import { login, ApiError } from "mm-key-vault";

try {
    await login({ email, password });
} catch (err) {
    if (err instanceof ApiError && err.status === 403) {
        // email not verified — prompt resend
    } else {
        // show err.message
    }
}

Authentication endpoints

POST /auth/signup No auth

Create a new account. The backend sends a verification email; the user must verify before they can log in.

Request body

FieldTypeReqNotes
emailstringrequiredValid email, 5–60 chars.
passwordstringrequired8–16 chars with upper, lower, number, special.
callbackstringoptionalFull URL the verification link redirects back to.

cURL

bash
curl -X POST https://kvbackend.masterymade.com/auth/signup \
    -H "Content-Type: application/json" \
    -d '{
        "email": "you@example.com",
        "password": "Str0ng!Pass",
        "callback": "https://yoursite.com/verify"
    }'

200 — success

json
{
    "status": 1,
    "message": "Account created. Verification email sent.",
    "data": {
        "user_id": "u_abc123",
        "email": "you@example.com",
        "email_verified": false
    }
}

4xx — error (example)

json
{
    "detail": "Email already registered"
}
POST /auth/login No auth

Exchange email + password for a bearer token. Use the token in Authorization: Bearer … on every protected call.

Request body

FieldTypeReqNotes
emailstringrequired
passwordstringrequired

cURL

bash
curl -X POST https://kvbackend.masterymade.com/auth/login \
    -H "Content-Type: application/json" \
    -d '{ "email": "you@example.com", "password": "Str0ng!Pass" }'

200 — success

json
{
    "status": 1,
    "message": "Login successful",
    "data": {
        "access_token": "eyJhbGciOiJIUzI1NiIs...",
        "user_id": "u_abc123",
        "email": "you@example.com"
    }
}
The SDK reads the token from data.access_token, falling back to data.token or a top-level token — so all three shapes work.

403 — email not verified

json
{
    "detail": "Email not verified. Please verify your email before logging in."
}
POST /auth/verify-email No auth

Consume a verification token (delivered by email) to mark the account as verified.

Request body

FieldTypeReqNotes
tokenstringrequiredToken from the verification email link.

cURL

bash
curl -X POST https://kvbackend.masterymade.com/auth/verify-email \
    -H "Content-Type: application/json" \
    -d '{ "token": "VERIFY_TOKEN_FROM_EMAIL" }'

200 — success

json
{
    "status": 1,
    "message": "Email verified successfully"
}
POST /auth/resend-verification No auth

Re-send the verification email. The widget enforces a 60-second cooldown on the UI side.

Request body

FieldTypeReqNotes
emailstringrequired
callbackstringoptionalWhere to send the user after verifying.

cURL

bash
curl -X POST https://kvbackend.masterymade.com/auth/resend-verification \
    -H "Content-Type: application/json" \
    -d '{ "email": "you@example.com", "callback": "https://yoursite.com/verify" }'

200 — success

json
{
    "status": 1,
    "message": "Verification link sent! Please check your email."
}
POST /auth/forgot-password No auth

Send a one-time password-reset link to the user's email.

Request body

FieldTypeReqNotes
emailstringrequired
callbackstringoptionalWhere the reset link should point.

cURL

bash
curl -X POST https://kvbackend.masterymade.com/auth/forgot-password \
    -H "Content-Type: application/json" \
    -d '{ "email": "you@example.com", "callback": "https://yoursite.com/reset" }'

200 — success

json
{
    "status": 1,
    "message": "If your email is registered, you will receive a password reset link."
}
POST /auth/reset-password No auth

Consume a reset token (passed as a query string) and set a new password.

Query string

ParamTypeReqNotes
tokenstringrequiredOne-time token from the reset email.

Request body

FieldTypeReqNotes
new_passwordstringrequiredSame rules as signup password.
callbackstringoptional

cURL

bash
curl -X POST "https://kvbackend.masterymade.com/auth/reset-password?token=RESET_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{ "new_password": "N3w!Strong" }'

200 — success

json
{
    "status": 1,
    "message": "Password updated. You can now log in with the new password."
}

Platform authentication flow

Before creating or editing a key, the widget calls POST /platform/resolve to decide whether the user should authenticate via OAuth (e.g. "Sign in with Google") or by pasting an API key. This runs on a 400 ms debounce as the user types the platform name in the Add / Edit form.

The response drives three effective UI states:

Bypass safeguard — if resolve fails (network error, backend unavailable) the widget silently falls back to the standard manual key input so the user is never blocked.

Tenant mode

Everything above assumes a person is logging into their own masterymade.com account. Tenant mode is a second, separate way to use the same widget: you (the business) mint a publishable key from your own dashboard, embed the widget on your own site, and let your own visitors connect a platform on your behalf — none of them ever create a masterymade.com account.

ts
import { initKeyVault } from "mm-key-vault";

initKeyVault({
    publishableKey: "mm_pub_...",   // minted from YOUR dashboard — never your secret key
    endUserId: currentVisitorId,    // your own id for whoever is using the widget right now
    platforms: ["google"],          // which platform(s) to offer
    onConnect: (platform) => { /* refresh your own UI */ },
    onConnectError: (platform, error) => { /* show your own error toast */ },
});

All three of publishableKey, endUserId, and a non-empty platforms array are required together to activate tenant mode — passing only some of them falls back to the normal personal login widget (with a console warning). endUserId is opaque: any stable string you already use to identify that visitor. We don't manage that identity, only scope their connected platforms to it.

Connecting opens in a popup, not a full-page redirect — your visitor never leaves your site. The popup closes itself automatically once the platform is connected (or if it fails), and the widget updates in place.

Manual (non-OAuth) API keys can't be entered through the widget in tenant mode — storing one requires your secret key (mm_live_...), which must never be in a browser. If a configured platform isn't OAuth-ready, the widget shows "Not available here" instead of a key-entry form. Submit a manual key on a visitor's behalf from your own backend instead, via POST /tenant/credentials (secret-key-authed, server-to-server only — not part of this widget).

Keys endpoints

POST /platform/resolve Bearer auth

Ask the backend how to authenticate against a given platform. The widget calls this on a 400 ms debounce as the user types the platform name, then branches the UI between OAuth (auto-login) and manual key entry. See Platform authentication flow for the full workflow.

Request body

FieldTypeReqNotes
platformstringrequiredPlatform slug (e.g. google, stripe, facebook).

cURL

bash
curl -X POST https://kvbackend.masterymade.com/platform/resolve \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer YOUR_TOKEN" \
    -d '{ "platform": "google" }'

Response shape (data)

FieldTypeNotes
platformstringEcho of the resolved slug.
auth_type"oauth" | "manual"Primary auth path for this platform.
implementedbooleanWhether the OAuth flow is fully live (vs "coming soon").
oauth_enabledbooleanBackend supports OAuth for this platform.
manual_key_enabledbooleanManual API-key entry is allowed as an option.
oauth_urlstring | nullURL to redirect the browser to for OAuth sign-in (contains a signed state_token).
messagestringHuman-readable hint the widget displays.

Case 1 — OAuth implemented (auto-login, e.g. Google)

auth_type: "oauth" + implemented: true + manual_key_enabled: false. The widget hides the API key input, shows a "Verify google account" banner, and replaces Save with "Connect Google account →" — click redirects to oauth_url.

json — response
{
    "user_id": "61df93e7-599c-4a52-9cde-b1b360ca87e3",
    "success": true,
    "message": "Platform resolved successfully",
    "data": {
        "platform": "google",
        "auth_type": "oauth",
        "implemented": true,
        "oauth_enabled": true,
        "manual_key_enabled": false,
        "oauth_url": "http://mcp.masterymade.com/mcp_routes/auth/google-login?state_token=eyJhbGciOi...",
        "message": "Connect your google account"
    }
}

Case 2 — Manual only (custom platform)

auth_type: "manual". Widget keeps the standard API key input and Save button; the message shows as a subtle hint under the platform field.

json — response
{
    "user_id": "61df93e7-599c-4a52-9cde-b1b360ca87e3",
    "success": true,
    "message": "Platform resolved successfully",
    "data": {
        "platform": "test",
        "auth_type": "manual",
        "implemented": true,
        "oauth_enabled": false,
        "manual_key_enabled": true,
        "oauth_url": null,
        "message": "Enter your test API key"
    }
}

Case 3 — OAuth coming soon (e.g. Facebook)

auth_type: "oauth" + implemented: false + manual_key_enabled: true. Widget shows the "coming soon" message and still allows the user to paste a key manually.

json — response
{
    "user_id": "61df93e7-599c-4a52-9cde-b1b360ca87e3",
    "success": true,
    "message": "Platform resolved successfully",
    "data": {
        "platform": "facebook",
        "auth_type": "oauth",
        "implemented": false,
        "oauth_enabled": true,
        "manual_key_enabled": true,
        "oauth_url": "http://mcp.masterymade.com/mcp_routes/auth/facebook-login?state_token=eyJhbGciOi...",
        "message": "OAuth login for facebook is coming soon. You can add an API key manually for now."
    }
}
GET /auth/list_platforms Bearer auth

Paginated list of the current user's platforms. Supports search, sort, and "load more" pagination.

Query string

ParamTypeDefaultNotes
searchstringSubstring match against the platform name.
pagenumber11-based page index.
limitnumber10Items per page.
sort_by"platform" | "created_at" | "updated_at"created_at
order"asc" | "desc"asc

cURL

bash
curl "https://kvbackend.masterymade.com/auth/list_platforms?page=1&limit=10&sort_by=created_at&order=desc" \
    -H "Authorization: Bearer YOUR_TOKEN"

200 — success

json
{
    "status": 1,
    "message": "Platforms fetched successfully",
    "data": {
        "user_id": "u_abc123",
        "current_page": 1,
        "total_pages": 3,
        "has_next_page": true,
        "has_previous_page": false,
        "limit": 10,
        "total_database_count": 24,
        "result": [
            { "id": 17, "platform": "openai",  "created_at": "2026-05-10T08:45:00Z", "updated_at": "2026-05-12T11:20:00Z" },
            { "id": 16, "platform": "stripe",  "created_at": "2026-05-09T14:00:00Z" },
            { "id": 15, "platform": "shopify", "created_at": "2026-05-08T10:15:00Z" }
        ]
    }
}
The listing endpoint never returns the raw api_key value — only platform metadata. Use GET /auth/get_key/:id to retrieve a single key on demand (for copy/edit flows).
POST /auth/add_key Bearer auth

Store a new platform + API key under the current user.

Request body

FieldTypeReqNotes
platformstringrequired2–60 chars. Must be unique per user.
api_keystringrequired2–200 chars.

cURL

bash
curl -X POST https://kvbackend.masterymade.com/auth/add_key \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer YOUR_TOKEN" \
    -d '{ "platform": "openai", "api_key": "sk-abcdef..." }'

200 — success

json
{
    "status": 1,
    "message": "Platform key added successfully",
    "data": { "platform": "openai" }
}

409 — duplicate

json
{
    "detail": "Platform already exists"
}
GET /auth/get_key/{id} Bearer auth

Return the raw API key for a single platform. Called on-demand for Copy and Edit flows — never during listing.

Path

ParamTypeNotes
idnumberThe platform's id from list_platforms.

cURL

bash
curl https://kvbackend.masterymade.com/auth/get_key/17 \
    -H "Authorization: Bearer YOUR_TOKEN"

200 — success

json
{
    "status": 1,
    "message": "Key fetched successfully",
    "data": {
        "platform": "openai",
        "api_key": "sk-abcdef..."
    }
}
PUT /auth/update_key/{id} Bearer auth

Update the platform name and/or the stored API key for an existing entry.

Path

ParamTypeNotes
idnumberThe platform's id.

Request body

FieldTypeReqNotes
platformstringrequired2–60 chars.
api_keystringoptional2–200 chars. Omit to keep the existing value.

cURL

bash
curl -X PUT https://kvbackend.masterymade.com/auth/update_key/17 \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer YOUR_TOKEN" \
    -d '{ "platform": "openai", "api_key": "sk-newvalue..." }'

200 — success

json
{
    "status": 1,
    "message": "Platform key updated successfully"
}
DELETE /auth/delete_key/{id} Bearer auth

Permanently delete a stored platform + key. The widget guards this behind a confirm dialog.

cURL

bash
curl -X DELETE https://kvbackend.masterymade.com/auth/delete_key/17 \
    -H "Authorization: Bearer YOUR_TOKEN"

200 — success

json
{
    "status": 1,
    "message": "Platform key deleted successfully"
}

Tenant endpoints

See Tenant mode above for the full picture. The widget only ever calls the one endpoint below — publishable-key-authed, safe to call from a browser. POST /tenant/credentials also exists (submit a manual key on a visitor's behalf) but is secret-key-authed and server-to-server only; the widget never calls it.

POST /tenant/connect/start Publishable key

Same three-case shape as /platform/resolve, but authenticated with a publishable key instead of a personal session token, and scoped to one of your own end-users. CORS-open — safe to call directly from your own site's browser JS.

Headers

HeaderNotes
AuthorizationBearer mm_pub_... — your publishable key, never your secret key.

Request body

FieldTypeReqNotes
platformstringrequiredPlatform slug (e.g. google).
end_user_idstringrequiredYour opaque id for this visitor. Max 255 chars.

cURL

bash
curl -X POST https://kvbackend.masterymade.com/tenant/connect/start \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer mm_pub_..." \
    -d '{ "platform": "google", "end_user_id": "visitor-123" }'

Response shape (data)

Same fields as /platform/resolve's response, plus:

FieldTypeNotes
connectedbooleanWhether this end-user already has this platform connected.

Personal API tokens

In addition to the OAuth / API-key credentials that KeyVault stores on behalf of platforms (Google, Notion, etc.), a user can also mint their own personal API tokens. The user creates a token from inside the widget (the API Tokens button in the dashboard header) and pastes it into a third-party app's .env. That token is the only credential the third-party app ever holds — the backend exchanges it for the user's identity on every request.

3 things that differ from the rest of the KeyVault API
  1. Two different tokens are in play. Don't conflate them — the login JWT (from POST /auth/login) goes in the Authorization: Bearer header on all three endpoints below. The API token (mm_live_…) is the thing these endpoints create — you never send it to them.
  2. No response envelope. Unlike /auth/add_key or /auth/list_platforms, these three return raw JSON — not {status, message, data}. And GET returns a bare array, not an object. Parse the body directly.
  3. Email must be verified. All three endpoints return 403 if the user hasn't verified their email.

Two tokens, side by side

Token Comes from Used to
Login JWT POST /auth/login Authorize calls to KeyVault — including all three /auth/tokens endpoints.
API token mm_live_… POST /auth/tokens Give to a third-party app; it uses this to impersonate the user.

Tokens endpoints

All three live under /auth/tokens and require the login JWT in the Authorization: Bearer header.

POST /auth/tokens Bearer auth

Mint a new personal API token. Returns the full mm_live_… value once — copy it into the third-party app's .env.

Request body

FieldTypeReqNotes
namestringoptionalUser's own label (e.g. "My chat app"). Max 80 chars.

cURL

bash
curl -X POST https://kvbackend.masterymade.com/auth/tokens \
    -H "Authorization: Bearer YOUR_LOGIN_JWT" \
    -H "Content-Type: application/json" \
    -d '{ "name": "My chat app" }'

200 — success (raw JSON, no envelope)

json
{
    "token": "mm_live_a1b2c3dEf4GhIjKlMnOpQrStUvWxYz0123456789ab",
    "prefix": "mm_live_a1b2c3",
    "name": "My chat app",
    "warning": "Copy this now. It will not be shown again."
}

Response fields

FieldTypeNotes
tokenstringThe full plaintext token. Copy this into the third-party app.
prefixstringFirst 14 chars. Safe to display in lists and logs.
namestring | nullEchoes back what was sent.
warningstringStale — do not render verbatim (see callout below).
Ignore the warning field. Its copy — "It will not be shown again" — is no longer true. The list endpoint (GET /auth/tokens) returns the full token on every call, so it is retrievable again. Rendering the string verbatim will contradict your own UI. Write your own copy (or omit it).

4xx — errors (FastAPI standard shape)

json — error
{ "detail": "Verify your email before creating API tokens" }
  • 401 — login JWT missing / malformed / expired → bounce to login.
  • 403 — email not verified → prompt to verify; offer resend.
  • 422 — malformed request body.
  • 500 — token creation failed server-side → generic "try again".
GET /auth/tokens Bearer auth

List the user's active (non-revoked) tokens, newest first. Returns a bare JSON arraynot wrapped in the standard envelope. Revoked tokens are omitted.

Query

No query params. No pagination, no filtering.

cURL

bash
curl https://kvbackend.masterymade.com/auth/tokens \
    -H "Authorization: Bearer YOUR_LOGIN_JWT"

200 — success (bare array)

json
[
    {
        "id": 3,
        "token": "mm_live_a1b2c3dEf4GhIjKlMnOpQrStUvWxYz0123456789ab",
        "prefix": "mm_live_a1b2c3",
        "name": "My chat app",
        "last_used_at": "2026-07-12T09:14:22.481Z",
        "created_at": "2026-07-01T11:02:10.000Z"
    }
]

Row shape

FieldTypeNotes
idnumberUse this for the DELETE call — not the token, not the prefix.
tokenstringFull plaintext value — retrievable again here, not only at creation.
prefixstringSafe-to-display short form (first 14 chars).
namestring | nullUser's label.
last_used_atISO 8601 | nullnull until the token is used for the first time.
created_atISO 8601
UI note: every row carries the full live credential. Render it masked by default — show prefix + bullets with a reveal / copy button — rather than printing the whole value in the table (screenshares, shoulder-surfing, screenshots). Nothing enforces this server-side — it's entirely on the frontend. The KeyVault widget does exactly this.

A user with no tokens gets [] — render an empty state, not an error.

DELETE /auth/tokens/{id} Bearer auth

Revoke a token. The id comes from the list response — not the mm_live_… string, and not the prefix.

Path

ParamTypeNotes
idnumberThe id from GET /auth/tokens.

cURL

bash
curl -X DELETE https://kvbackend.masterymade.com/auth/tokens/3 \
    -H "Authorization: Bearer YOUR_LOGIN_JWT"

200 — success (raw)

json
{ "revoked": true }
Revocation is immediate and permanent. Any third-party app holding this token starts failing on its very next request. There is no un-revoke. Confirm with the user before calling — the KeyVault widget guards this behind a confirmation dialog.

404 — id doesn't exist or isn't this user's → refresh the list, it's stale.


SDK Reference

What the npm package mm-key-vault exports.

fn initKeyVault(config?)

Mounts the widget host into document.body inside a Shadow Root, then renders the floating button. Safe to call only on the client — wrap it in useEffect for React/Next.js. Calling it twice logs a warning and no-ops.

ts
import { initKeyVault, type KeyVaultConfig } from "mm-key-vault";

const config: KeyVaultConfig = {
    position: "bottom-right",
    offsetX: "24px",
    offsetY: "24px",
    zIndex: 9999,
};

initKeyVault(config);
fn destroyKeyVault()

Removes the widget host element from the DOM and resets internal state so initKeyVault can be called again. Useful for SPA route changes or testing.

ts
import { destroyKeyVault } from "mm-key-vault";

destroyKeyVault();
component <KeyVaultProvider />

Convenience React component — calls initKeyVault on mount and destroyKeyVault on unmount. Drop it once near your app root.

tsx
import { KeyVaultProvider } from "mm-key-vault";

export default function App() {
    return (
        <>
            <YourRoutes />
            <KeyVaultProvider position="bottom-right" offsetX="24px" />
        </>
    );
}

MetaMCP Tools

KeyVault (this widget) stores a user's API keys in the browser. MetaMCP is the server-side companion — it holds all the real logic that actually uses those keys. Your backend calls MetaMCP; MetaMCP fetches the right credential from KeyVault, runs the action (save to Google Docs, create a Notion page, scrape a URL via Apify), and returns the result — all with a single email + one HTTP call.

Two-part architecture
Widget = user-facing, stores keys in the browser.
MetaMCP = server-side, uses those keys on the user's behalf.
Your app never talks to Google / Notion / Apify directly, and it never handles OAuth tokens.

MCP Base URL

base url
https://mcp.masterymade.com

The mental model

Every MCP call needs two things to be true:

  1. You know the user's email. Your app collects it (via KeyVault login), then sends it on every MCP call.
  2. The user has connected the platform. "Connected" means the user's Google / Notion / Apify credential is stored in KeyVault. Users connect at https://kv.masterymade.com/.

For every MCP request, if the credential is missing you get a friendly "connect here" payload — not an error. Show the message or a button to connect_url.

You do not handle tokens, OAuth, or API keys. You send an email. MetaMCP resolves the credential. If it's missing, you get a friendly payload with a link the user can click to connect.

MCP response envelope

Every MCP endpoint returns HTTP 200 with a JSON body in one of these shapes. Branch on the JSON fields, not on the HTTP status.

Success

json — success
{
    "success": true,
    "connected": true,
    "action": "save_document",
    "platform": "google",
    "data": { "...": "action-specific payload" },
    "message": "Human-readable summary"
}

Not connected (user must connect the platform)

json — not connected
{
    "success": false,
    "connected": false,
    "action": "save_document",
    "platform": "notion",
    "reason": "account_not_connected",
    "connect_url": "https://kv.masterymade.com/",
    "message": "Your Notion account is not connected. Please connect it here: https://kv.masterymade.com/"
}

reason is one of:

Unsupported platform

json — unsupported
{
    "success": false,
    "connected": null,
    "reason": "unsupported_platform",
    "message": "'dropbox' is not a supported platform for this action. Supported: google, notion."
}

Provider error (credential OK, but the provider call failed)

json — provider error
{
    "success": false,
    "connected": true,
    "reason": "provider_error",
    "message": "Could not save to Notion: ..."
}

How to branch (pseudo-code)

python
if body["success"]:
    use body["data"]
elif body["connected"] is False:
    show body["message"] + body["connect_url"]   # ask user to connect
else:
    show body["message"]                          # real error / unsupported

Platforms & capabilities

Platform Key type in KeyVault save_document list_documents read_document scrape
google OAuth (auto-refreshed) ✓ Google Docs
notion Manual API key (ntn_…) ✓ Notion page
apify Manual API key ✓ Web scraping

MCP endpoints

All tools live under the prefix /mcp/tools.

GET /mcp/tools/manifest No auth

Machine-readable description of every tool. Call this once on startup to learn what platforms and tools are available — great for building a dynamic client that doesn't hard-code endpoints.

cURL

bash
curl https://mcp.masterymade.com/mcp/tools/manifest

Response (abridged)

json
{
    "server": "MetaMCP",
    "connect_url": "https://kv.masterymade.com/",
    "supported_platforms": ["google", "notion"],
    "scraper_platforms": ["apify"],
    "tools": [
        { "name": "save_document", "method": "POST", "path": "...", "input": { ... } },
        ...
    ]
}
GET /mcp/tools/connections No auth

Answers "which integrations does this user have connected?" — checks every platform at once. Use it to show connection badges in your UI, or to decide whether to offer a save / scrape action before the user asks.

Query

ParamTypeReqNotes
emailstringrequiredLogged-in user's email. There is no platform param — it checks all of them.

cURL

bash
curl "https://mcp.masterymade.com/mcp/tools/connections?email=mukesh@yopmail.com"

Success response

json
{
    "success": true,
    "connected": true,
    "action": "list_connections",
    "connect_url": "https://kv.masterymade.com/",
    "connections": [
        { "platform": "apify",  "label": "Apify",       "connected": true },
        { "platform": "google", "label": "Google Docs", "connected": true },
        { "platform": "notion", "label": "Notion",      "connected": true }
    ],
    "message": "Connected integrations: Apify, Google Docs, Notion."
}
Never errors. Top-level connected is true if at least one platform is connected. Per-platform connected can be true, false, or null (upstream failed — an error field is added). If the email is unknown, all platforms are false with reason: "user_not_found". Always HTTP 200.
POST /mcp/tools/save_document No auth

Create a new document / page and write content into it. Same call for Google Docs and Notion — just change platform.

Request body

FieldTypeReqNotes
emailstringrequiredLogged-in user's email.
platform"google" | "notion"required
titlestringrequiredDocument / page title.
contentstringrequiredBody text (e.g. chat transcript).
parent_page_idstringoptionalNotion only. Which page to create under. If omitted, uses the first accessible page.

cURL — Google Docs

bash
curl -X POST https://mcp.masterymade.com/mcp/tools/save_document \
    -H 'Content-Type: application/json' \
    -d '{
        "email": "mukesh@yopmail.com",
        "platform": "google",
        "title": "My chat with the assistant",
        "content": "User: what is python?\nAssistant: Python is a programming language."
    }'

Success response (same shape for Google & Notion)

json
{
    "success": true,
    "connected": true,
    "action": "save_document",
    "platform": "google",
    "data": {
        "document_id": "1bzCTzH194-MXn7s5uRgfRnat8WD0gt_6Nx5HENT9Mf8",
        "title": "My chat with the assistant",
        "url": "https://docs.google.com/document/d/1bzCTzH194-.../edit"
    },
    "message": "Saved to Google Docs: https://docs.google.com/document/d/1bzCTzH194-.../edit"
}

cURL — Notion (same call, just change platform)

bash
curl -X POST https://mcp.masterymade.com/mcp/tools/save_document \
    -H 'Content-Type: application/json' \
    -d '{"email":"mukesh@yopmail.com","platform":"notion","title":"My chat","content":"..."}'

Notion returns a Notion page URL in data.url. If Notion isn't connected you get the "not connected" payload with the connect link.

GET /mcp/tools/list_documents No auth

List the documents / pages this app has saved for the user, newest first. For Google, only docs created by this app are returned; for Notion, pages the integration can see.

Query

ParamTypeReqNotes
emailstringrequired
platform"google" | "notion"required

cURL

bash
curl "https://mcp.masterymade.com/mcp/tools/list_documents?email=mukesh@yopmail.com&platform=google"

Success response

json
{
    "success": true,
    "connected": true,
    "action": "list_documents",
    "platform": "google",
    "data": [
        {
            "document_id": "1bzCTzH194-...",
            "title": "My chat with the assistant",
            "modified_time": "2026-07-02T08:46:58.600Z",
            "url": "https://docs.google.com/document/d/1bzCTzH194-.../edit"
        }
    ],
    "message": "Found 1 saved document(s)."
}
GET /mcp/tools/read_document No auth

Read back the plain-text content of one document / page.

Query

ParamTypeReqNotes
emailstringrequired
platform"google" | "notion"required
document_idstringrequiredFrom a save or list result.

cURL

bash
curl "https://mcp.masterymade.com/mcp/tools/read_document?email=mukesh@yopmail.com&platform=google&document_id=1bzCTzH194-..."

Success response

json
{
    "success": true,
    "connected": true,
    "action": "read_document",
    "platform": "google",
    "data": {
        "document_id": "1bzCTzH194-...",
        "title": "My chat with the assistant",
        "content": "User: what is python?\nAssistant: Python is a programming language.",
        "url": "https://docs.google.com/document/d/1bzCTzH194-.../edit"
    },
    "message": "Document loaded."
}
POST /mcp/tools/scrape No auth

Run an Apify scraper using the user's Apify API key from KeyVault and return the extracted content.

Request body

FieldTypeReqNotes
emailstringrequired
platformstringoptionalDefaults to apify.
urlstring*Required unless you pass a custom actor + input.
max_pagesintegeroptionalHow many pages to crawl. Default 1.
actorstringadvancedAny Apify actor id (e.g. apify/instagram-scraper).
inputobjectadvancedCustom input JSON for that actor.

Simple example — scrape a URL

bash
curl -X POST https://mcp.masterymade.com/mcp/tools/scrape \
    -H 'Content-Type: application/json' \
    -d '{"email":"mukesh@yopmail.com","url":"https://example.com","max_pages":1}'

Success response

json
{
    "success": true,
    "connected": true,
    "action": "scrape",
    "platform": "apify",
    "data": [
        {
            "url": "https://example.com",
            "title": "Example Domain",
            "text": "This domain is for use in illustrative examples..."
        }
    ],
    "message": "Scraped 1 result(s)."
}

Advanced — run any Apify actor

When actor + input are provided, MetaMCP returns the actor's raw dataset items in data. Without a custom actor, it uses apify/website-content-crawler and normalizes each item to { url, title, text } (text capped for LLM use).

bash
curl -X POST https://mcp.masterymade.com/mcp/tools/scrape \
    -H 'Content-Type: application/json' \
    -d '{
        "email": "mukesh@yopmail.com",
        "actor": "apify/website-content-crawler",
        "input": { "startUrls": [{"url":"https://news.ycombinator.com"}], "maxCrawlPages": 3 }
    }'
Timeout: scrapes can take 30–120 s. Use a generous HTTP timeout (~200 s) for this endpoint. The document endpoints are fast.

Chat-app integration pattern

This is how a chat / AI assistant app should use MCP. The golden rule: normal questions ("what is python?") must not call MCP. Only call it when the user actually asks to save / list / read / scrape. The cleanest way is LLM function-calling — give the model tool definitions and let it decide when to invoke them.

1. Define the tools for your LLM

Expose 4 tools. Do not expose email to the model — inject it server-side from the logged-in session.

jsonc — OpenAI tool defs
[
    { "type": "function", "function": {
        "name": "save_chat",
        "description": "Save the current conversation to Google Docs or Notion.",
        "parameters": { "type": "object",
            "properties": {
                "platform": { "type": "string", "enum": ["google","notion"] },
                "title":    { "type": "string" },
                "content":  { "type": "string" }
            },
            "required": ["platform","title","content"]
        }
    }},
    { "type": "function", "function": {
        "name": "list_saved_chats",
        "description": "List chats the user saved before.",
        "parameters": { "type": "object",
            "properties": { "platform": { "type": "string", "enum": ["google","notion"] } },
            "required": ["platform"]
        }
    }},
    { "type": "function", "function": {
        "name": "read_saved_chat",
        "description": "Read back a saved chat by document id.",
        "parameters": { "type": "object",
            "properties": {
                "platform":    { "type": "string", "enum": ["google","notion"] },
                "document_id": { "type": "string" }
            },
            "required": ["platform","document_id"]
        }
    }},
    { "type": "function", "function": {
        "name": "scrape_website",
        "description": "Scrape / extract the content of a web page using Apify.",
        "parameters": { "type": "object",
            "properties": {
                "url":       { "type": "string" },
                "max_pages": { "type": "integer" }
            },
            "required": ["url"]
        }
    }}
]

2. Map each tool call to an MCP endpoint

When the model emits a tool call, translate it (adding email yourself):

LLM toolMetaMCP call
save_chat POST /mcp/tools/save_document with {email, platform, title, content}
list_saved_chats GET /mcp/tools/list_documents?email=&platform=
read_saved_chat GET /mcp/tools/read_document?email=&platform=&document_id=
scrape_website POST /mcp/tools/scrape with {email, platform:"apify", url, max_pages}

Connections statusGET /mcp/tools/connections?email= is usually called by your app directly (not as an LLM tool) on load, to show which integrations are connected. If you also want the assistant to answer "what am I connected to?", expose it as a tool too.

Feed the JSON response back to the model as the tool result and let it write the final reply. If connected: false, the model relays message + connect_url.

System-prompt tip: tell the model — "never invent connection status or links; always call the tool first and use its exact connect_url."

Quick reference (copy / paste)

MCP endpoints
GET  /mcp/tools/manifest
GET  /mcp/tools/connections     ?email=
POST /mcp/tools/save_document   {email, platform, title, content, parent_page_id?}
GET  /mcp/tools/list_documents  ?email=&platform=
GET  /mcp/tools/read_document   ?email=&platform=&document_id=
POST /mcp/tools/scrape          {email, url, max_pages?, actor?, input?}

platform: google | notion (documents), apify (scrape).
Every response: check success; if connected == false, send the user to connect_url (https://kv.masterymade.com/).