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.
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.
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:
- Install the package —
npm install mm-key-vault - Call
initKeyVault()once on the client (in auseEffectfor React/Next.js) - 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:
npm install mm-key-vault # or yarn add mm-key-vault # or pnpm add mm-key-vault
React is a peer dependency:
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:
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:
"use client";
import { useEffect } from "react";
import { initKeyVault } from "mm-key-vault";
export function KeyVaultProvider() {
useEffect(() => {
initKeyVault();
}, []);
return null;
}
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)
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:
<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.
initKeyVault({
position: "bottom-right", // bottom-left | top-right | top-left | center
offsetX: "20px", // any CSS length
offsetY: "20px", // any CSS length
zIndex: 9999, // number
});
| Option | Type | Default | Description |
|---|---|---|---|
| position | "bottom-right" | "bottom-left" | "top-right" | "top-left" | "center" | bottom-right | Where the floating button anchors on the page. |
| offsetX | string | 20px | Horizontal offset from the chosen edge. |
| offsetY | string | 20px | Vertical offset from the chosen edge. |
| zIndex | number | 9999 | CSS 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:
{
"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.
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:
{
"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):
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
Create a new account. The backend sends a verification email; the user must verify before they can log in.
Request body
| Field | Type | Req | Notes |
|---|---|---|---|
| string | required | Valid email, 5–60 chars. | |
| password | string | required | 8–16 chars with upper, lower, number, special. |
| callback | string | optional | Full URL the verification link redirects back to. |
cURL
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
{
"status": 1,
"message": "Account created. Verification email sent.",
"data": {
"user_id": "u_abc123",
"email": "you@example.com",
"email_verified": false
}
}
4xx — error (example)
{
"detail": "Email already registered"
}
Exchange email + password for a bearer token. Use the token in Authorization: Bearer … on every protected call.
Request body
| Field | Type | Req | Notes |
|---|---|---|---|
| string | required | ||
| password | string | required |
cURL
curl -X POST https://kvbackend.masterymade.com/auth/login \
-H "Content-Type: application/json" \
-d '{ "email": "you@example.com", "password": "Str0ng!Pass" }'
200 — success
{
"status": 1,
"message": "Login successful",
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"user_id": "u_abc123",
"email": "you@example.com"
}
}
data.access_token, falling back to data.token or a top-level token — so all three shapes work.403 — email not verified
{
"detail": "Email not verified. Please verify your email before logging in."
}
Consume a verification token (delivered by email) to mark the account as verified.
Request body
| Field | Type | Req | Notes |
|---|---|---|---|
| token | string | required | Token from the verification email link. |
cURL
curl -X POST https://kvbackend.masterymade.com/auth/verify-email \
-H "Content-Type: application/json" \
-d '{ "token": "VERIFY_TOKEN_FROM_EMAIL" }'
200 — success
{
"status": 1,
"message": "Email verified successfully"
}
Re-send the verification email. The widget enforces a 60-second cooldown on the UI side.
Request body
| Field | Type | Req | Notes |
|---|---|---|---|
| string | required | ||
| callback | string | optional | Where to send the user after verifying. |
cURL
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
{
"status": 1,
"message": "Verification link sent! Please check your email."
}
Send a one-time password-reset link to the user's email.
Request body
| Field | Type | Req | Notes |
|---|---|---|---|
| string | required | ||
| callback | string | optional | Where the reset link should point. |
cURL
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
{
"status": 1,
"message": "If your email is registered, you will receive a password reset link."
}
Consume a reset token (passed as a query string) and set a new password.
Query string
| Param | Type | Req | Notes |
|---|---|---|---|
| token | string | required | One-time token from the reset email. |
Request body
| Field | Type | Req | Notes |
|---|---|---|---|
| new_password | string | required | Same rules as signup password. |
| callback | string | optional |
cURL
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
{
"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:
-
OAuth ready
(
auth_type: "oauth",implemented: true) — the widget hides the API key input, shows a "Verify [platform] account" banner, and swaps the Save button for "Connect [Platform] account →". Clicking it redirects the browser tooauth_url, which contains a signedstate_tokenthe backend uses to associate the OAuth callback with the current user. -
Manual only
(
auth_type: "manual") — the standard "paste your key" flow. The responsemessageis rendered as a subtle hint under the platform field. -
OAuth coming soon
(
auth_type: "oauth",implemented: false,manual_key_enabled: true) — the message is shown as a "coming soon" note and manual key entry is still allowed as a fallback.
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.
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.
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
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
| Field | Type | Req | Notes |
|---|---|---|---|
| platform | string | required | Platform slug (e.g. google, stripe, facebook). |
cURL
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)
| Field | Type | Notes |
|---|---|---|
| platform | string | Echo of the resolved slug. |
| auth_type | "oauth" | "manual" | Primary auth path for this platform. |
| implemented | boolean | Whether the OAuth flow is fully live (vs "coming soon"). |
| oauth_enabled | boolean | Backend supports OAuth for this platform. |
| manual_key_enabled | boolean | Manual API-key entry is allowed as an option. |
| oauth_url | string | null | URL to redirect the browser to for OAuth sign-in (contains a signed state_token). |
| message | string | Human-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.
{
"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.
{
"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.
{
"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."
}
}
Paginated list of the current user's platforms. Supports search, sort, and "load more" pagination.
Query string
| Param | Type | Default | Notes |
|---|---|---|---|
| search | string | — | Substring match against the platform name. |
| page | number | 1 | 1-based page index. |
| limit | number | 10 | Items per page. |
| sort_by | "platform" | "created_at" | "updated_at" | created_at | |
| order | "asc" | "desc" | asc |
cURL
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
{
"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" }
]
}
}
api_key value — only platform metadata. Use GET /auth/get_key/:id to retrieve a single key on demand (for copy/edit flows).Store a new platform + API key under the current user.
Request body
| Field | Type | Req | Notes |
|---|---|---|---|
| platform | string | required | 2–60 chars. Must be unique per user. |
| api_key | string | required | 2–200 chars. |
cURL
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
{
"status": 1,
"message": "Platform key added successfully",
"data": { "platform": "openai" }
}
409 — duplicate
{
"detail": "Platform already exists"
}
Return the raw API key for a single platform. Called on-demand for Copy and Edit flows — never during listing.
Path
| Param | Type | Notes |
|---|---|---|
| id | number | The platform's id from list_platforms. |
cURL
curl https://kvbackend.masterymade.com/auth/get_key/17 \
-H "Authorization: Bearer YOUR_TOKEN"
200 — success
{
"status": 1,
"message": "Key fetched successfully",
"data": {
"platform": "openai",
"api_key": "sk-abcdef..."
}
}
Update the platform name and/or the stored API key for an existing entry.
Path
| Param | Type | Notes |
|---|---|---|
| id | number | The platform's id. |
Request body
| Field | Type | Req | Notes |
|---|---|---|---|
| platform | string | required | 2–60 chars. |
| api_key | string | optional | 2–200 chars. Omit to keep the existing value. |
cURL
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
{
"status": 1,
"message": "Platform key updated successfully"
}
Permanently delete a stored platform + key. The widget guards this behind a confirm dialog.
cURL
curl -X DELETE https://kvbackend.masterymade.com/auth/delete_key/17 \
-H "Authorization: Bearer YOUR_TOKEN"
200 — success
{
"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.
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
| Header | Notes |
|---|---|
| Authorization | Bearer mm_pub_... — your publishable key, never your secret key. |
Request body
| Field | Type | Req | Notes |
|---|---|---|---|
| platform | string | required | Platform slug (e.g. google). |
| end_user_id | string | required | Your opaque id for this visitor. Max 255 chars. |
cURL
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:
| Field | Type | Notes |
|---|---|---|
| connected | boolean | Whether 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.
-
Two different tokens are in play. Don't conflate them
— the login JWT (from
POST /auth/login) goes in theAuthorization: Bearerheader on all three endpoints below. The API token (mm_live_…) is the thing these endpoints create — you never send it to them. -
No response envelope. Unlike
/auth/add_keyor/auth/list_platforms, these three return raw JSON — not{status, message, data}. AndGETreturns a bare array, not an object. Parse the body directly. -
Email must be verified. All three endpoints return
403if 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.
Mint a new personal API token. Returns the full
mm_live_… value once — copy it into the third-party app's
.env.
Request body
| Field | Type | Req | Notes |
|---|---|---|---|
| name | string | optional | User's own label (e.g. "My chat app"). Max 80 chars. |
cURL
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)
{
"token": "mm_live_a1b2c3dEf4GhIjKlMnOpQrStUvWxYz0123456789ab",
"prefix": "mm_live_a1b2c3",
"name": "My chat app",
"warning": "Copy this now. It will not be shown again."
}
Response fields
| Field | Type | Notes |
|---|---|---|
| token | string | The full plaintext token. Copy this into the third-party app. |
| prefix | string | First 14 chars. Safe to display in lists and logs. |
| name | string | null | Echoes back what was sent. |
| warning | string | Stale — do not render verbatim (see callout below). |
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)
{ "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".
List the user's active (non-revoked) tokens, newest first. Returns a bare JSON array — not wrapped in the standard envelope. Revoked tokens are omitted.
Query
No query params. No pagination, no filtering.
cURL
curl https://kvbackend.masterymade.com/auth/tokens \
-H "Authorization: Bearer YOUR_LOGIN_JWT"
200 — success (bare array)
[
{
"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
| Field | Type | Notes |
|---|---|---|
| id | number | Use this for the DELETE call — not the token, not the prefix. |
| token | string | Full plaintext value — retrievable again here, not only at creation. |
| prefix | string | Safe-to-display short form (first 14 chars). |
| name | string | null | User's label. |
| last_used_at | ISO 8601 | null | null until the token is used for the first time. |
| created_at | ISO 8601 |
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.
Revoke a token. The id comes from the list
response — not the mm_live_… string, and
not the prefix.
Path
| Param | Type | Notes |
|---|---|---|
| id | number | The id from GET /auth/tokens. |
cURL
curl -X DELETE https://kvbackend.masterymade.com/auth/tokens/3 \
-H "Authorization: Bearer YOUR_LOGIN_JWT"
200 — success (raw)
{ "revoked": true }
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.
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.
import { initKeyVault, type KeyVaultConfig } from "mm-key-vault";
const config: KeyVaultConfig = {
position: "bottom-right",
offsetX: "24px",
offsetY: "24px",
zIndex: 9999,
};
initKeyVault(config);
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.
import { destroyKeyVault } from "mm-key-vault";
destroyKeyVault();
Convenience React component — calls initKeyVault on mount and destroyKeyVault on unmount. Drop it once near your app root.
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.
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
https://mcp.masterymade.com
The mental model
Every MCP call needs two things to be true:
- You know the user's email. Your app collects it (via KeyVault login), then sends it on every MCP call.
- 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.
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
{
"success": true,
"connected": true,
"action": "save_document",
"platform": "google",
"data": { "...": "action-specific payload" },
"message": "Human-readable summary"
}
Not connected (user must connect the platform)
{
"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:
account_not_connected— email exists but this platform isn't connected.user_not_found— KeyVault has never seen this email.
Unsupported platform
{
"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)
{
"success": false,
"connected": true,
"reason": "provider_error",
"message": "Could not save to Notion: ..."
}
How to branch (pseudo-code)
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 |
|---|---|---|---|---|---|
| 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.
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
curl https://mcp.masterymade.com/mcp/tools/manifest
Response (abridged)
{
"server": "MetaMCP",
"connect_url": "https://kv.masterymade.com/",
"supported_platforms": ["google", "notion"],
"scraper_platforms": ["apify"],
"tools": [
{ "name": "save_document", "method": "POST", "path": "...", "input": { ... } },
...
]
}
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
| Param | Type | Req | Notes |
|---|---|---|---|
| string | required | Logged-in user's email. There is no platform param — it checks all of them. |
cURL
curl "https://mcp.masterymade.com/mcp/tools/connections?email=mukesh@yopmail.com"
Success response
{
"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."
}
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.
Create a new document / page and write content
into it. Same call for Google Docs and Notion — just change platform.
Request body
| Field | Type | Req | Notes |
|---|---|---|---|
| string | required | Logged-in user's email. | |
| platform | "google" | "notion" | required | |
| title | string | required | Document / page title. |
| content | string | required | Body text (e.g. chat transcript). |
| parent_page_id | string | optional | Notion only. Which page to create under. If omitted, uses the first accessible page. |
cURL — Google Docs
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)
{
"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)
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.
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
| Param | Type | Req | Notes |
|---|---|---|---|
| string | required | ||
| platform | "google" | "notion" | required |
cURL
curl "https://mcp.masterymade.com/mcp/tools/list_documents?email=mukesh@yopmail.com&platform=google"
Success response
{
"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)."
}
Read back the plain-text content of one document / page.
Query
| Param | Type | Req | Notes |
|---|---|---|---|
| string | required | ||
| platform | "google" | "notion" | required | |
| document_id | string | required | From a save or list result. |
cURL
curl "https://mcp.masterymade.com/mcp/tools/read_document?email=mukesh@yopmail.com&platform=google&document_id=1bzCTzH194-..."
Success response
{
"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."
}
Run an Apify scraper using the user's Apify API key from KeyVault and return the extracted content.
Request body
| Field | Type | Req | Notes |
|---|---|---|---|
| string | required | ||
| platform | string | optional | Defaults to apify. |
| url | string | * | Required unless you pass a custom actor + input. |
| max_pages | integer | optional | How many pages to crawl. Default 1. |
| actor | string | advanced | Any Apify actor id (e.g. apify/instagram-scraper). |
| input | object | advanced | Custom input JSON for that actor. |
Simple example — scrape a URL
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
{
"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).
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 }
}'
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.
[
{ "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 tool | MetaMCP 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 status — GET /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.
connect_url."
Quick reference (copy / paste)
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/).