Skip to content
Merged

MCP #280

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 69 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,74 @@
# Repository guidance

The Mongoose connection passed to Studio is owned by the host application.
The project's name is Mongoose Studio. Do not refer to the project as "Studio" - the repository is named "studio" to maintain parity between the npm package name `@mongoosejs/studio` and the GitHub repository `mongoosejs/studio`.

The Mongoose connection passed to Mongoose Studio is owned by the host application.
Do not change connection-level settings, options, configuration, models, middleware, plugins, collection objects, or methods on the application connection to implement Studio behavior.
Apply Studio-specific behavior to individual operations whenever possible.
Apply Mongoose-Studio-specific behavior to individual operations whenever possible.
When isolation requires a derived connection, copy any Mongoose state that `useDb()` shares with its source before changing the derived connection.
Sandbox wrappers must remain confined to sandbox-owned models and collections.

# Frontend Guidelines

Conceptually, the frontend's job is to render the data and provide a user interface for accessing API calls.
The frontend should minimize transformations and renaming of data provided by the backend: default to displaying the raw data as-is.

## API Client

`src/client/api.js` exposes one wrapper function per backend action; every wrapper POSTs to `${baseUrl}/<endpoint>` and resolves with the response payload directly (`res.data`), not the axios response. The endpoint list lives in `src/endpoints.js`, which `build.js` generates from the files in `backend/actions/` — do not edit it by hand. Flat action files become flat wrappers like `api.addToCart`, while capitalized `Model/action.js` directories become namespaced wrappers like `api.quote.createQuote`, so adding an action file automatically makes its wrapper available on the next build.
Comment on lines +16 to +18

## Vue User Input

Prefer explicit DOM event handlers such as `@change`, `@input`, and `@update:modelValue` over `watch` for reacting to user input. Use `watch` only when the behavior is truly derived from state changes that do not have a single clear DOM event source.

## Vue Templates

Prefer safe property access directly in HTML over computed properties or methods for simple display values. For example, use `{{ user?.name ?? '-' }}` instead of creating a `userName` computed property. Keep display behavior local unless the logic is reused or too complex to read inline.

Prefer using mustache interpolation (`{{ expr }}`) over `v-text` for rendering text values in Vue templates.

## Component API Ownership

Components that present an async action must own the API request for that action. Do not emit an event to a parent solely so the parent can make the HTTP request. Emit events after the component finishes the request when the parent needs to close a modal, refresh a list, or update surrounding state.

## Async Buttons

For a button that triggers an async action, use the `<async-button>` component instead of tracking a `saveStatus`/`loading` flag by hand. It manages the in-progress state, disables itself while running, and shows a spinner. Do not add a component-level status property, a `try/finally` to reset it, or `.finally()` on the API call just to toggle a button label.

```html
<!-- ❌ Incorrect: hand-rolled status + disabled + label swap -->
<button :disabled="saveStatus === 'saving'" @click="save">
<span v-if="saveStatus === 'saving'">Saving...</span>
<span v-else>Save</span>
</button>

<!-- ✅ Correct -->
<async-button @click="save">Save</async-button>
```

The click handler is just an `async` method that does the work; let errors propagate to the global handler. Keep any real `:disabled` condition (e.g. invalid input) — `async-button` ORs it with its own in-progress state.

## Avoid trivial helpers

Minimize scattered helpers on the frontend - functions and computed properties carry real cognitive overhead and need to earn their existence.
Optimize for locality and terseness over abstraction.

For example, instead of:

```
computed: {
displayName() {
return `${user.firstName} ${user.lastName}`;
}
}
```

and:

<div>{{ displayName }}</div>

Write:

<div>{{ user.firstName }} {{ user.lastName }}</div>

No single-expression helper functions or computed properties unless there's heavy repetition (25+ instances).
7 changes: 0 additions & 7 deletions CLAUDE.md

This file was deleted.

24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ opts.anthropicAPIKey = process.env.ANTHROPIC_API_KEY;
opts.googleGeminiAPIKey = process.env.GOOGLE_GEMINI_API_KEY;
// Apply a maximum execution time to all read operations, including reads in scripts.
// MongoDB does not support maxTimeMS for inserts and index operations.
// This is also a ceiling: a request may ask for a lower limit, never a higher one.
opts.maxTimeMS = 10000;

// Mount Mongoose Studio on '/studio'
Expand All @@ -55,6 +56,29 @@ app.use('/studio', await studio('/studio/api', mongoose, {
}));
```

The Express integration also exposes a Streamable HTTP MCP endpoint at `/studio/mcp`.
Set `mcp: false` to turn it off, along with the OAuth protected resource metadata that advertises it.
It provides authorized Dashboard and Model actions, plus `Script.createScript`, as MCP tools.
With a Pro API key, pass the logged-in Studio access token in the `Authorization` header (either directly or as a bearer token); tools receive that user's roles and permissions.
Without workspace authentication, the MCP endpoint uses the same localhost and `bindIp` restrictions as the rest of Studio.

#### Connecting ChatGPT or Claude with OAuth

With a Pro API key, `/studio/mcp` is also an OAuth 2.1 protected resource, so MCP clients such as ChatGPT and Claude can connect to it directly.
Point the client at your `/studio/mcp` URL and it will discover the Mongoose Studio authorization server, walk the user through signing in and approving access, and receive a short-lived access token.
Users review and revoke these connections from their Mongoose Studio account page, and the access an AI client gets can never exceed the access the user who approved it already has.

Mongoose Studio advertises itself to MCP clients using its externally reachable URL.
When MCP and a Pro API key are enabled, set `publicUrl` to the URL where Mongoose Studio is mounted:

```javascript
opts.publicUrl = 'https://app.example.com/studio';
```

Mongoose Studio finds the authorization server through the mothership it is already configured to use, so it needs no separate authorization server configuration.

See `docs/mcp-oauth.md` for how the flow and the delegated access policy work.

### Next.js

First, add `withMongooseStudio` to your `next.config.js` file:
Expand Down
20 changes: 14 additions & 6 deletions backend/actions/ChatMessage/executeScript.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ const Archetype = require('archetype');
const authorize = require('../../authorize');
const createSandbox = require('../../sandbox/createSandbox');
const mongoose = require('mongoose');
const omitNullish = require('../../helpers/omitNullish');
const readOperationOptions = require('../../helpers/readOperationOptions');
const operationOptions = require('../../helpers/operationOptions');

const ExecuteScriptParams = new Archetype({
initiatedById: {
Expand All @@ -22,6 +23,13 @@ const ExecuteScriptParams = new Archetype({
},
roles: {
$type: ['string']
},
maxTimeMS: {
$type: 'number'
},
readPreference: {
$type: 'string',
$enum: ['secondary', 'secondaryPreferred', 'primary']
}
}).compile('ExecuteScriptParams');

Expand All @@ -32,18 +40,18 @@ module.exports = ({ db, studioConnection, options }) => async function executeSc

await authorize('ChatMessage.executeScript', roles);

const operationOptions = omitNullish({ maxTimeMS: options?.maxTimeMS });
const chatMessage = await ChatMessage.findById(chatMessageId).setOptions(operationOptions);
const dbOptions = operationOptions(options, params);
const chatMessage = await ChatMessage.findById(chatMessageId).setOptions(dbOptions);
if (!chatMessage) {
throw new Error('Chat message not found');
}
const chatThread = await ChatThread.findById(chatMessage.chatThreadId).setOptions(operationOptions).orFail();
const chatThread = await ChatThread.findById(chatMessage.chatThreadId).setOptions(dbOptions).orFail();

if (initiatedById && chatThread.userId?.toString() !== initiatedById.toString()) {
throw new Error('Unauthorized');
}

const sandbox = createSandbox({ db, maxTimeMS: options?.maxTimeMS });
const sandbox = createSandbox(db, { ...readOperationOptions(options, params) });

let output;
let error;
Expand Down Expand Up @@ -76,7 +84,7 @@ module.exports = ({ db, studioConnection, options }) => async function executeSc
dryRun: !!dryRun
}
}
).setOptions(omitNullish({ maxTimeMS: options?.maxTimeMS }));
).setOptions(operationOptions(options, params));

throw new Error(`Script execution failed: ${error}`);
} finally {
Expand Down
11 changes: 7 additions & 4 deletions backend/actions/ChatThread/createChatMessage.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const callLLM = require('../../integrations/callLLM');
const getAgentTools = require('../../chatAgent/getAgentTools');
const getModelDescriptions = require('../../helpers/getModelDescriptions');
const mongoose = require('mongoose');
const omitNullish = require('../../helpers/omitNullish');
const operationOptions = require('../../helpers/operationOptions');

const CreateChatMessageParams = new Archetype({
chatThreadId: {
Expand All @@ -26,6 +26,9 @@ const CreateChatMessageParams = new Archetype({
},
roles: {
$type: ['string']
},
maxTimeMS: {
$type: 'number'
}
}).compile('CreateChatMessageParams');

Expand All @@ -37,16 +40,16 @@ module.exports = ({ db, studioConnection, options }) => async function createCha
await authorize('ChatThread.createChatMessage', roles);

// Check that the user owns the thread
const operationOptions = omitNullish({ maxTimeMS: options?.maxTimeMS });
const chatThread = await ChatThread.findOne({ _id: chatThreadId }).setOptions(operationOptions);
const dbOptions = operationOptions(options, params);
const chatThread = await ChatThread.findOne({ _id: chatThreadId }).setOptions(dbOptions);
if (!chatThread) {
throw new Error('Chat thread not found');
}
if (initiatedById != null && chatThread.userId.toString() !== initiatedById.toString()) {
throw new Error('Not authorized');
}

const messages = await ChatMessage.find({ chatThreadId }).setOptions(operationOptions).sort({ createdAt: 1 });
const messages = await ChatMessage.find({ chatThreadId }).setOptions(dbOptions).sort({ createdAt: 1 });
const llmMessages = messages.map(m => ({
role: m.role,
content: [{
Expand Down
11 changes: 7 additions & 4 deletions backend/actions/ChatThread/getChatThread.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
const Archetype = require('archetype');
const authorize = require('../../authorize');
const mongoose = require('mongoose');
const omitNullish = require('../../helpers/omitNullish');
const operationOptions = require('../../helpers/operationOptions');

const GetChatThreadParams = new Archetype({
chatThreadId: {
Expand All @@ -17,6 +17,9 @@ const GetChatThreadParams = new Archetype({
},
$workspaceId: {
$type: mongoose.Types.ObjectId
},
maxTimeMS: {
$type: 'number'
}
}).compile('GetChatThreadParams');

Expand All @@ -27,8 +30,8 @@ module.exports = ({ db, studioConnection, options }) => async function getChatTh

await authorize('ChatThread.getChatThread', roles);

const operationOptions = omitNullish({ maxTimeMS: options?.maxTimeMS });
const chatThread = await ChatThread.findById(chatThreadId).setOptions(operationOptions);
const dbOptions = operationOptions(options, params);
const chatThread = await ChatThread.findById(chatThreadId).setOptions(dbOptions);

if (!chatThread) {
throw new Error('Chat thread not found');
Expand All @@ -41,7 +44,7 @@ module.exports = ({ db, studioConnection, options }) => async function getChatTh
}

const chatMessages = await ChatMessage.find({ chatThreadId })
.setOptions(operationOptions)
.setOptions(dbOptions)
.sort({ createdAt: -1 });

return {
Expand Down
7 changes: 5 additions & 2 deletions backend/actions/ChatThread/listChatThreads.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@
const Archetype = require('archetype');
const authorize = require('../../authorize');
const mongoose = require('mongoose');
const omitNullish = require('../../helpers/omitNullish');
const operationOptions = require('../../helpers/operationOptions');

const ListChatThreadsParams = new Archetype({
initiatedById: {
$type: mongoose.Types.ObjectId
},
roles: {
$type: ['string']
},
maxTimeMS: {
$type: 'number'
}
}).compile('ListChatThreadsParams');

Expand All @@ -25,7 +28,7 @@ module.exports = ({ db, studioConnection, options }) => async function listChatT

// Get all chat threads
const chatThreads = await ChatThread.find(query)
.setOptions(omitNullish({ maxTimeMS: options?.maxTimeMS }))
.setOptions(operationOptions(options, params))
.sort({ updatedAt: -1 }); // Sort by most recently updated

return {
Expand Down
7 changes: 5 additions & 2 deletions backend/actions/ChatThread/shareChatThread.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
const Archetype = require('archetype');
const authorize = require('../../authorize');
const mongoose = require('mongoose');
const omitNullish = require('../../helpers/omitNullish');
const operationOptions = require('../../helpers/operationOptions');

const ShareChatThreadParams = new Archetype({
chatThreadId: {
Expand All @@ -20,6 +20,9 @@ const ShareChatThreadParams = new Archetype({
},
$workspaceId: {
$type: mongoose.Types.ObjectId
},
maxTimeMS: {
$type: 'number'
}
}).compile('ShareChatThreadParams');

Expand All @@ -30,7 +33,7 @@ module.exports = ({ studioConnection, options }) => async function shareChatThre
await authorize('ChatThread.shareChatThread', roles);

const chatThread = await ChatThread.findById(chatThreadId).
setOptions(omitNullish({ maxTimeMS: options?.maxTimeMS })).
setOptions(operationOptions(options, params)).
orFail();
if (initiatedById != null && chatThread.userId?.toString() !== initiatedById.toString()) {
throw new Error('Not authorized');
Expand Down
11 changes: 7 additions & 4 deletions backend/actions/ChatThread/streamChatMessage.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const runChatAgent = require('../../chatAgent/runChatAgent');
const streamLLM = require('../../integrations/streamLLM');
const getModelDescriptions = require('../../helpers/getModelDescriptions');
const mongoose = require('mongoose');
const omitNullish = require('../../helpers/omitNullish');
const operationOptions = require('../../helpers/operationOptions');

const CreateChatMessageParams = new Archetype({
chatThreadId: {
Expand All @@ -26,6 +26,9 @@ const CreateChatMessageParams = new Archetype({
},
roles: {
$type: ['string']
},
maxTimeMS: {
$type: 'number'
}
}).compile('CreateChatMessageParams');

Expand All @@ -37,16 +40,16 @@ module.exports = ({ db, studioConnection, options }) => async function* streamCh
await authorize('ChatThread.createChatMessage', roles);

// Check that the user owns the thread
const operationOptions = omitNullish({ maxTimeMS: options?.maxTimeMS });
const chatThread = await ChatThread.findOne({ _id: chatThreadId }).setOptions(operationOptions);
const dbOptions = operationOptions(options, params);
const chatThread = await ChatThread.findOne({ _id: chatThreadId }).setOptions(dbOptions);
if (!chatThread) {
throw new Error('Chat thread not found');
}
if (initiatedById != null && chatThread.userId.toString() !== initiatedById.toString()) {
throw new Error('Not authorized');
}

const messages = await ChatMessage.find({ chatThreadId }).setOptions(operationOptions).sort({ createdAt: 1 });
const messages = await ChatMessage.find({ chatThreadId }).setOptions(dbOptions).sort({ createdAt: 1 });
const llmMessages = messages.map(m => ({
role: m.role,
content: [{
Expand Down
7 changes: 5 additions & 2 deletions backend/actions/ChatThread/toggleAgentMode.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
const Archetype = require('archetype');
const authorize = require('../../authorize');
const mongoose = require('mongoose');
const omitNullish = require('../../helpers/omitNullish');
const operationOptions = require('../../helpers/operationOptions');

const ToggleAgentModeParams = new Archetype({
chatThreadId: {
Expand All @@ -17,6 +17,9 @@ const ToggleAgentModeParams = new Archetype({
},
roles: {
$type: ['string']
},
maxTimeMS: {
$type: 'number'
}
}).compile('ToggleAgentModeParams');

Expand All @@ -27,7 +30,7 @@ module.exports = ({ studioConnection, options }) => async function toggleAgentMo
await authorize('ChatThread.toggleAgentMode', roles);

const chatThread = await ChatThread.findById(chatThreadId).
setOptions(omitNullish({ maxTimeMS: options?.maxTimeMS })).
setOptions(operationOptions(options, params)).
orFail();
if (initiatedById != null && chatThread.userId?.toString() !== initiatedById.toString()) {
throw new Error('Not authorized');
Expand Down
3 changes: 3 additions & 0 deletions backend/actions/Dashboard/createDashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,6 @@ module.exports = ({ studioConnection }) => async function createDashboard(params

return { dashboard };
};

module.exports.paramsType = CreateDashboardParams;
module.exports.tags = [];
Loading
Loading