rapidoc
Version:
RapiDoc - Open API spec viewer with built in console
290 lines (276 loc) • 11.7 kB
JavaScript
/**
* RapiDoc MCP runtime: executes the operation tools of an MCP manifest (see manifest.js) with
* `fetch`, so the same code runs in Node, browsers and edge workers. Workflow tools are run by
* workflow-runtime.js, plugged in through the `runWorkflow` option, so pages and exports without
* workflows carry no Arazzo code.
*
* This file is copied verbatim into every exported MCP server project as `runtime.js`, so it
* imports nothing and relies only on `fetch`, `URLSearchParams`, `TextEncoder` and `btoa`.
*/
/** @param {string} text UTF-8 text as base64 */
function base64(text) {
const bytes = new TextEncoder().encode(text);
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary);
}
function serialize(value) {
if (typeof value === "string") return value;
if (value && typeof value === "object") return JSON.stringify(value);
return String(value);
}
function parseJson(text) {
try {
return JSON.parse(text);
} catch {
return undefined;
}
}
/** The environment variable name that holds a scheme's credential: `petstore_auth` → `PETSTORE_AUTH`. */
export function envNameFor(schemeId) {
const name = String(schemeId || "")
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
.replace(/[^a-zA-Z0-9]+/g, "_")
.replace(/^_|_$/g, "")
.toUpperCase();
return name || "API_KEY";
}
/**
* Credentials for every security scheme in the manifest, read from environment variables:
* `<SCHEME>` (the scheme id in upper snake case), else `API_KEY` for any scheme. HTTP Basic takes
* `<SCHEME>` as `user:password`, or `<SCHEME>_USERNAME` and `<SCHEME>_PASSWORD`.
*
* @param {object} manifest
* @param {Record<string, string|undefined>} env such as `process.env`
* @returns {Record<string, string|{ username: string, password: string }>}
*/
export function credentialsFromEnv(manifest, env = {}) {
const credentials = {};
for (const source of Object.values(manifest.sources || {})) {
for (const [id, scheme] of Object.entries(source.securitySchemes || {})) {
const key = envNameFor(id);
if (scheme.type === "http" && String(scheme.scheme).toLowerCase() === "basic") {
if (env[`${key}_USERNAME`] !== undefined) {
credentials[id] = {
username: env[`${key}_USERNAME`],
password: env[`${key}_PASSWORD`] || "",
};
continue;
}
}
const value = env[key] ?? env.API_KEY;
if (value !== undefined && value !== "") credentials[id] = value;
}
}
return credentials;
}
/**
* Adds the credentials a security requirement asks for. `security` is a list of alternatives,
* each `{ schemeId: scopes }`; the first alternative every credential of which is known is used.
* With none fully known, nothing is sent when an alternative is empty (anonymous access), else
* the first alternative is applied as far as credentials go, so the API's 401 says what is missing.
*/
function applySecurity(security, schemes, credentials, target) {
if (!Array.isArray(security) || !security.length) return;
const known = (alt) => Object.keys(alt).every((id) => credentials[id] !== undefined);
let chosen = security.find(known);
if (!chosen) {
if (security.some((alt) => !Object.keys(alt).length)) return;
chosen = security[0];
}
for (const id of Object.keys(chosen)) {
const scheme = schemes?.[id];
const credential = credentials[id];
if (!scheme || credential === undefined) continue;
const token =
typeof credential === "string" ? credential : credential?.token || credential?.accessToken;
if (scheme.type === "apiKey") {
const value = serialize(credential);
if (scheme.in === "query") target.query.set(scheme.name, value);
else if (scheme.in === "cookie")
target.cookies.push(`${scheme.name}=${encodeURIComponent(value)}`);
else target.headers[scheme.name || "Authorization"] = value;
} else if (scheme.type === "http" && String(scheme.scheme).toLowerCase() === "basic") {
const pair =
typeof credential === "string"
? credential
: `${credential?.username ?? ""}:${credential?.password ?? ""}`;
target.headers.Authorization = `Basic ${base64(pair)}`;
} else if (token) {
// http bearer, oauth2 and openIdConnect all send a bearer token
target.headers.Authorization = `Bearer ${token}`;
}
}
}
/**
* The HTTP request a tool call makes, without sending it.
*
* @param {object} manifest
* @param {object} operation a tool's or resource's `operation`
* @param {object} args the tool arguments
* @param {object} [options]
* @param {object} [options.credentials] by security scheme id
* @param {Record<string, string>} [options.baseUrls] by source name, overriding the manifest
* @returns {{ url: string, method: string, headers: Record<string, string>, body?: string }}
*/
export function buildRequest(manifest, operation, args = {}, options = {}) {
const source = manifest.sources?.[operation.source] || {};
const base = String(options.baseUrls?.[operation.source] || source.baseUrl || "").replace(
/\/$/,
"",
);
const headers = {};
const query = new URLSearchParams();
const cookies = [];
let path = operation.path || "";
let body;
for (const p of operation.params || []) {
const value = args[p.arg || p.name];
if (value === undefined || value === null) continue;
if (p.in === "path") {
path = path.split(`{${p.name}}`).join(encodeURIComponent(serialize(value)));
} else if (p.in === "query") {
for (const v of Array.isArray(value) ? value : [value]) query.append(p.name, serialize(v));
} else if (p.in === "header") {
headers[p.name] = serialize(value);
} else if (p.in === "cookie") {
cookies.push(`${p.name}=${encodeURIComponent(serialize(value))}`);
} else if (p.in === "body") {
body = { contentType: p.contentType || "application/json", value };
}
}
const missing = path.match(/\{[^}]+\}/g);
if (missing) throw new Error(`Missing path parameter ${missing.join(", ")}`);
applySecurity(operation.security, source.securitySchemes, options.credentials || {}, {
headers,
query,
cookies,
});
const request = { url: base + path, method: operation.method || "GET", headers };
const qs = query.toString();
if (qs) request.url += (request.url.includes("?") ? "&" : "?") + qs;
if (cookies.length) headers.Cookie = cookies.join("; ");
if (!Object.keys(headers).some((h) => h.toLowerCase() === "accept")) {
headers.Accept = "application/json";
}
if (body) {
if (body.contentType === "application/x-www-form-urlencoded") {
const form = new URLSearchParams();
for (const [k, v] of Object.entries(body.value || {})) form.append(k, serialize(v));
request.body = form.toString();
headers["Content-Type"] = body.contentType;
} else {
request.body = typeof body.value === "string" ? body.value : JSON.stringify(body.value);
headers["Content-Type"] = body.contentType.startsWith("multipart/")
? "application/json"
: body.contentType;
}
}
return request;
}
function errorResult(text) {
return { isError: true, content: [{ type: "text", text }] };
}
/**
* @param {object} manifest from buildMcpManifest()
* @param {object} [options]
* @param {typeof fetch} [options.fetch] defaults to the global fetch
* @param {object} [options.credentials] by security scheme id: an API key or token string, or
* `{ username, password }` for HTTP Basic
* @param {Record<string, string>} [options.baseUrls] by source name, overriding the manifest
* @param {Function} [options.runWorkflow] `(runtime, tool, args, hooks) => Promise<result>` from
* workflow-runtime.js; without it a workflow tool answers with an error result
* @returns {{ manifest: object, listTools(): object[], listResources(): object[],
* callTool(name: string, args?: object, hooks?: object): Promise<object>,
* readResource(uri: string): Promise<object>, execute(operation: object, args: object,
* extra?: { headers?: object }): Promise<object> }} MCP results: `callTool` returns `{ content, structuredContent?,
* isError? }`, `readResource` returns `{ contents }`. `execute` sends one operation and returns
* `{ request, response, headers, text, data }` or `{ request?, error }`, for workflow runners.
*/
export function createMcpRuntime(manifest, options = {}) {
const fetchImpl = options.fetch || globalThis.fetch;
const requestOptions = {
credentials: options.credentials || {},
baseUrls: options.baseUrls || {},
};
const tools = new Map((manifest.tools || []).map((t) => [t.name, t]));
const resources = new Map((manifest.resources || []).map((r) => [r.uri, r]));
async function send(operation, args, extra = {}) {
let request;
try {
request = buildRequest(manifest, operation, args, requestOptions);
if (extra.headers) request.headers = { ...request.headers, ...extra.headers };
} catch (err) {
return { error: errorResult(err.message) };
}
let response;
try {
response = await fetchImpl(request.url, {
method: request.method,
headers: request.headers,
body: request.body,
});
} catch (err) {
return { request, error: errorResult(`Request failed: ${err.message}`) };
}
const text = await response.text();
const headers = {};
if (typeof response.headers?.forEach === "function") {
response.headers.forEach((v, k) => (headers[k] = v));
}
return { request, response, headers, text, data: parseJson(text) };
}
const runtime = {
manifest,
execute: send,
listTools() {
return [...tools.values()].map(({ name, description, inputSchema, annotations }) => ({
name,
description,
inputSchema,
annotations,
}));
},
listResources() {
return [...resources.values()].map(({ name, uri, description, mimeType }) => ({
name,
uri,
description,
mimeType,
}));
},
async callTool(name, args = {}, hooks = {}) {
const tool = tools.get(name);
if (!tool) return errorResult(`Unknown tool "${name}"`);
if (tool.workflow) {
if (!options.runWorkflow) {
return errorResult(
`Tool "${name}" is a workflow; this runtime was created without workflow support`,
);
}
return options.runWorkflow(runtime, tool, args, hooks);
}
if (!tool.operation) return errorResult(`Tool "${name}" has nothing to run`);
const { error, response, text, data } = await send(tool.operation, args || {});
if (error) return error;
const pretty = data === undefined ? text : JSON.stringify(data, null, 2);
if (!response.ok) {
return errorResult(
`HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}\n${pretty}`,
);
}
const result = { content: [{ type: "text", text: pretty }] };
if (data && typeof data === "object" && !Array.isArray(data)) result.structuredContent = data;
return result;
},
async readResource(uri) {
const resource = resources.get(uri);
if (!resource) throw new Error(`Unknown resource "${uri}"`);
const { error, response, text } = await send(resource.operation, {});
if (error) throw new Error(error.content[0].text);
if (!response.ok) throw new Error(`HTTP ${response.status} reading ${uri}: ${text}`);
return { contents: [{ uri, mimeType: resource.mimeType || "application/json", text }] };
},
};
return runtime;
}