Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@
"@mongodb-js/mcp-metrics": "workspace:*",
"@mongodb-js/mcp-tools-atlas": "workspace:*",
"@mongodb-js/mcp-tools-atlas-local": "workspace:*",
"@mongodb-js/mcp-apps": "workspace:*",
"@mongodb-js/mcp-types": "workspace:*",
"@mongodb-js/mcp-ui": "workspace:*",
"@mongosh/arg-parser": "^5.0.2",
Expand Down
38 changes: 38 additions & 0 deletions packages/apps/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "@mongodb-js/mcp-apps",
"version": "1.11.0-prerelease.1",
"private": true,
"type": "module",
"description": "MCP Apps for MongoDB MCP server",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"compile": "vite build --config vite.apps.config.ts && tsc --build tsconfig.json"
},
"dependencies": {
"@mongodb-js/mcp-types": "workspace:*"
},
"devDependencies": {
"@modelcontextprotocol/ext-apps": "^1.2.0",
"@types/node": "^25.6.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"tsx": "^4.20.5",
"typescript": "^5.9.2",
"vite": "^8.0.8",
"vite-plugin-node-polyfills": "^0.26.0",
"vite-plugin-singlefile": "^2.3.2"
}
}
71 changes: 71 additions & 0 deletions packages/apps/src/apps/ConnectForm/ConnectForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React, { useState, type ReactElement } from "react";
import { useApp } from "@modelcontextprotocol/ext-apps/react";

export const ConnectForm = (): ReactElement => {
const {
app,
isConnected,
error: hostError,
} = useApp({
appInfo: { name: "connect-form", version: "1.0.0" },
capabilities: {},
});

const [connectionString, setConnectionString] = useState("mongodb://localhost:27017");
const [status, setStatus] = useState<"idle" | "connecting" | "success" | "error">("idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);

const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault();
if (!app) return;

setStatus("connecting");
setErrorMessage(null);

try {
const result = await app.callServerTool({ name: "connect", arguments: { connectionString } });
if (result.isError) {
const text = result.content.find((c) => c.type === "text");
setStatus("error");
setErrorMessage(text && "text" in text ? text.text : "Connection failed");
} else {
setStatus("success");
}
} catch (err) {
setStatus("error");
setErrorMessage(err instanceof Error ? err.message : "Connection failed");
}
};

if (hostError) {
return <div>Failed to connect to MCP host: {hostError.message}</div>;
}

return (
<div className="connect-form">
<h2>Connect to MongoDB</h2>
{status === "success" ? (
<p className="success">Connected successfully!</p>
) : (
<>
<form onSubmit={handleSubmit}>
<label htmlFor="connection-string">Connection String</label>
<input
id="connection-string"
type="text"
value={connectionString}
onChange={(e) => setConnectionString(e.target.value)}
placeholder="mongodb://localhost:27017"
required
disabled={!isConnected || status === "connecting"}
/>
<button type="submit" disabled={!isConnected || status === "connecting"}>
{status === "connecting" ? "Connecting…" : "Connect"}
</button>
</form>
{status === "error" && errorMessage && <p className="error">{errorMessage}</p>}
</>
)}
</div>
);
};
1 change: 1 addition & 0 deletions packages/apps/src/apps/ConnectForm/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ConnectForm } from "./ConnectForm.js";
60 changes: 60 additions & 0 deletions packages/apps/src/build/mount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/// <reference types="vite/client" />
import React from "react";
import { createRoot } from "react-dom/client";

type AppModule = Record<string, React.ComponentType>;

// Auto-import all app components using Vite's glob import
// Each app folder must have an index.ts that exports the component matching the folder name
const appModules: Record<string, AppModule> = import.meta.glob("../apps/*/index.ts", {
eager: true,
});

const apps: Record<string, React.ComponentType> = {};

for (const [path, module] of Object.entries(appModules)) {
const match = path.match(/\.\.\/apps\/([^/]+)\/index\.ts$/);
if (match) {
const appName = match[1];
if (!appName) continue;
const Component = module[appName];
if (Component) {
apps[appName] = Component;
} else {
console.warn(
`[mount] App "${appName}" not found in ${path}. ` +
`Make sure to export it as: export { ${appName} } from "./${appName}.js"`
);
}
}
}

function mount(): void {
const container = document.getElementById("root");
if (!container) {
console.error("[mount] No #root element found");
return;
}

const componentName = container.dataset.component;
if (!componentName) {
console.error("[mount] No data-component attribute found on #root");
return;
}

const Component = apps[componentName];
if (!Component) {
console.error(`[mount] Unknown app: ${componentName}`);
console.error(`[mount] Available apps: ${Object.keys(apps).join(", ")}`);
return;
}

const root = createRoot(container);
root.render(
<React.StrictMode>
<Component />
</React.StrictMode>
);
}

mount();
12 changes: 12 additions & 0 deletions packages/apps/src/build/template.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{TITLE}}</title>
</head>
<body>
<div id="root" data-component="{{COMPONENT_NAME}}"></div>
<script type="module" src="{{MOUNT_PATH}}"></script>
</body>
</html>
3 changes: 3 additions & 0 deletions packages/apps/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { AppRegistry } from "./registry/registry.js";
export type { AppRegistryOptions, IAppRegistry } from "@mongodb-js/mcp-types";
export { appLoaders } from "./lib/loaders.js";
7 changes: 7 additions & 0 deletions packages/apps/src/lib/apps/connect-form.ts

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions packages/apps/src/lib/loaders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* AUTO-GENERATED FILE - DO NOT EDIT MANUALLY
* Generated by: pnpm generate:apps
*
* Lazy loaders for app modules. Each loader returns a Promise<string> with the HTML.
*/
export const appLoaders: Record<string, () => Promise<string>> = {
"connect-form": async () => {
const mod = await import("./apps/connect-form.js");
return mod.ConnectFormHtml;
}
};
49 changes: 49 additions & 0 deletions packages/apps/src/registry/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { AppRegistryOptions, IAppRegistry } from "@mongodb-js/mcp-types";

// The type assertion is needed because the file is auto-generated and may not exist during type checking
type AppLoaders = Record<string, (() => Promise<string>) | undefined>;

import { appLoaders as _appLoaders } from "../lib/loaders.js";
const appLoaders = _appLoaders as AppLoaders;

/**
* App Registry that manages bundled app HTML strings.
*/
export class AppRegistry implements IAppRegistry {
private loaders: AppLoaders;
private cache: Map<string, string> = new Map();

constructor(options?: AppRegistryOptions) {
this.loaders = options?.loaders ?? appLoaders;
}

/**
* Gets the HTML string for an app, or null if none exists.
*/
async get(appName: string): Promise<string | null> {
const cached = this.cache.get(appName);
if (cached !== undefined) {
return cached;
}

const loader = this.loaders[appName];
if (!loader) {
return null;
}

try {
const html = await loader();
this.cache.set(appName, html);
return html;
} catch {
return null;
}
}

/**
* Returns the names of all registered apps.
*/
appNames(): string[] {
return Object.keys(this.loaders);
}
}
21 changes: 21 additions & 0 deletions packages/apps/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"extends": "../../tsconfig.build.json",
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"rootDir": "./src",
"outDir": "./dist",
"baseUrl": ".",
"erasableSyntaxOnly": true,
"composite": true
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": [
"dist",
"node_modules",
"src/**/*.test.ts",
"src/**/*.test.tsx",
"src/build/mount.tsx",
"src/apps/**"
]
}
Loading