Skip to content

Commit 5dcf955

Browse files
authored
feat(server): MCP server with actions as tools (#4012)
<!-- Describe the problem and your solution --> ### Description This PR adds basic MCP support for executing action. It's basic support for the latest "Streamable Http", and implemented in a stateless manner (based on [their example](https://github.com/modelcontextprotocol/typescript-sdk?tab=readme-ov-file#without-session-management-stateless)). This means every message creates a new instance of an `McpServer` and `StreamableHttpServerTransport`. This way no connection management is required and it should be scalable out of the box. Any given call requires a `connection-id`. The tools available to the caller are the respective active actions of that connection-id. <!-- Issue ticket number and link (if applicable) --> <!-- Testing instructions (skip if just adding/editing providers) --> ### Testing I'll be creating a repo with a working example of a client implementation using vercel's AI SDK. Otherwise, an easy way to test is by installing [Claude for Desktop](https://claude.ai/download) and setting up your mcp servers on: `/Users/<your-user>/Library/Application Support/Claude/claude_desktop_config.json` with this format: ```json { "mcpServers": { "nango-google-drive": { "command": "npx", "args": [ "-y", "mcp-remote", "http://localhost:3003/mcp", "--header", "Authorization:<your-secret-key>", "--header", "provider-config-key:google-drive", "--header", "connection-id:<your-connection-id>" ] } } } ``` > Don't add spaces to the headers <!-- Summary by @propel-code-bot --> --- **MCP Server Implementation with Actions as Tools** This PR adds Model Context Protocol (MCP) support to Nango's server, allowing AI assistants to discover and execute Nango actions as tools. The implementation follows a stateless approach where each request creates new server instances, avoiding shared state issues. Actions are exposed as MCP-compatible tools with their schemas automatically converted from Nango's format, enabling seamless interaction between AI models and Nango integrations. **Key Changes:** • Added new /mcp endpoint with authentication and connection validation • Implemented stateless ``MCP`` server that maps Nango actions to ``MCP`` tools • Created schema conversion logic to transform action inputs to ``MCP``-compatible formats • Added action execution handler that processes tool calls and returns structured results **Affected Areas:** • Server ``API`` endpoints and routing • Action retrieval and execution flow • Server dependencies (added @modelcontextprotocol/sdk) • Type definitions for ``MCP`` requests/responses **Potential Impact:** **Functionality**: Enables AI assistants to discover and execute Nango actions through the MCP protocol, expanding integration capabilities for LLM-based applications **Performance**: Minimal impact as the implementation follows a stateless pattern that creates isolated server instances per request, avoiding shared state issues **Security**: Leverages existing authentication mechanisms and validation for connections, maintaining security boundaries **Scalability**: The stateless approach should scale well by avoiding session management overhead **Review Focus:** • Stateless implementation approach for handling ``MCP`` requests • Schema conversion logic between Nango actions and ``MCP`` tools • Error handling and compliance with ``MCP`` protocol specifications • Response formatting and result serialization <details> <summary><strong>Testing Needed</strong></summary> • Test with Claude desktop client to verify discovery and execution of tools • Verify schema conversion handles complex action input types correctly • Test error cases (invalid connections, actions, authentication) • Test concurrent requests to ensure isolated handling </details> <details> <summary><strong>Code Quality Assessment</strong></summary> **packages/server/lib/controllers/mcp/server.ts**: Well-structured implementation with proper error handling using Result pattern. Good separation of concerns between tool conversion, request handling, and execution. **packages/server/lib/controllers/mcp/mcp.ts**: Clean request validation and handler implementation. Follows MCP protocol specifications for response formatting. </details> <details> <summary><strong>Best Practices</strong></summary> **Error Handling**: • Uses Result pattern for robust error management • Validates inputs before processing **Security**: • Leverages existing authentication mechanisms • Validates connections before exposing actions **Code Organization**: • Clear separation between protocol handling and business logic • Type-safe implementation </details> <details> <summary><strong>Possible Issues</strong></summary> • Complex action schemas might not convert perfectly to MCP tool schemas • Error responses might need refinement as MCP protocol evolves • Each request creates new server instances which could be optimized in the future </details> --- *This summary was automatically generated by @propel-code-bot*
1 parent 5c4ebd6 commit 5dcf955

File tree

11 files changed

+732
-6
lines changed

11 files changed

+732
-6
lines changed

package-lock.json

Lines changed: 440 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,5 +90,8 @@
9090
},
9191
"engines": {
9292
"node": ">=18.0.0 || >=20.0.0"
93+
},
94+
"dependencies": {
95+
"@modelcontextprotocol/sdk": "^1.11.0"
9396
}
9497
}

packages/server/lib/controllers/config.controller.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import {
66
connectionService,
77
errorManager,
88
flowService,
9-
getActionsByProviderConfigKey,
109
getGlobalWebhookReceiveUrl,
1110
getProvider,
1211
getProviders,
12+
getSimplifiedActionsByProviderConfigKey,
1313
getSyncConfigsAsStandardConfig,
1414
getUniqueSyncsByProviderConfig
1515
} from '@nangohq/shared';
@@ -172,7 +172,7 @@ class ConfigController {
172172
};
173173
});
174174

175-
const actions = await getActionsByProviderConfigKey(environmentId, providerConfigKey);
175+
const actions = await getSimplifiedActionsByProviderConfigKey(environmentId, providerConfigKey);
176176
const hasWebhook = provider.webhook_routing_script;
177177
let webhookUrl: string | null = null;
178178
if (hasWebhook) {
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
2+
import { z } from 'zod';
3+
4+
import { connectionService } from '@nangohq/shared';
5+
import { zodErrorToHTTP } from '@nangohq/utils';
6+
7+
import { createMcpServerForConnection } from './server.js';
8+
import { connectionIdSchema, providerConfigKeySchema } from '../../helpers/validation.js';
9+
import { asyncWrapper } from '../../utils/asyncWrapper.js';
10+
11+
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
12+
import type { GetMcp, PostMcp } from '@nangohq/types';
13+
14+
export const validationHeaders = z
15+
.object({
16+
'connection-id': connectionIdSchema,
17+
'provider-config-key': providerConfigKeySchema
18+
})
19+
.strict();
20+
21+
export const postMcp = asyncWrapper<PostMcp>(async (req, res) => {
22+
const valHeaders = validationHeaders.safeParse({ 'connection-id': req.get('connection-id'), 'provider-config-key': req.get('provider-config-key') });
23+
if (!valHeaders.success) {
24+
res.status(400).send({ error: { code: 'invalid_headers', errors: zodErrorToHTTP(valHeaders.error) } });
25+
return;
26+
}
27+
28+
const { environment, account } = res.locals;
29+
const headers: PostMcp['Headers'] = valHeaders.data;
30+
31+
const connectionId = headers['connection-id'];
32+
const providerConfigKey = headers['provider-config-key'];
33+
34+
const { error, response: connection } = await connectionService.getConnection(connectionId, providerConfigKey, environment.id);
35+
36+
if (error || !connection) {
37+
res.status(400).send({
38+
error: { code: 'unknown_connection', message: 'Provided connection-id and provider-config-key do not match a valid connection' }
39+
});
40+
return;
41+
}
42+
43+
const result = await createMcpServerForConnection(account, environment, connection, providerConfigKey);
44+
if (result.isErr()) {
45+
res.status(500).send({ error: { code: 'Internal server error', message: result.error.message } });
46+
return;
47+
}
48+
49+
const server = result.value;
50+
const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
51+
sessionIdGenerator: undefined
52+
});
53+
54+
res.on('close', () => {
55+
void transport.close();
56+
void server.close();
57+
});
58+
59+
// Casting because 'exactOptionalPropertyTypes: true' says `?: string` is not equal to `string | undefined`
60+
await server.connect(transport as Transport);
61+
await transport.handleRequest(req, res, req.body);
62+
});
63+
64+
// We have to be explicit about not supporting SSE
65+
export const getMcp = asyncWrapper<GetMcp>((_, res) => {
66+
res.writeHead(405).end(
67+
JSON.stringify({
68+
jsonrpc: '2.0',
69+
error: {
70+
code: -32000,
71+
message: 'Method not allowed.'
72+
},
73+
id: null
74+
})
75+
);
76+
});
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types';
3+
import tracer from 'dd-trace';
4+
5+
import { OtlpSpan, defaultOperationExpiration, logContextGetter } from '@nangohq/logs';
6+
import { configService, getActionsByProviderConfigKey } from '@nangohq/shared';
7+
import { Err, Ok, truncateJson } from '@nangohq/utils';
8+
9+
import { getOrchestrator } from '../../utils/utils.js';
10+
11+
import type { CallToolRequest, CallToolResult, Tool } from '@modelcontextprotocol/sdk/types';
12+
import type { Config } from '@nangohq/shared';
13+
import type { DBConnectionDecrypted, DBEnvironment, DBSyncConfig, DBTeam, Result } from '@nangohq/types';
14+
import type { Span } from 'dd-trace';
15+
import type { JSONSchema7 } from 'json-schema';
16+
17+
export async function createMcpServerForConnection(
18+
account: DBTeam,
19+
environment: DBEnvironment,
20+
connection: DBConnectionDecrypted,
21+
providerConfigKey: string
22+
): Promise<Result<Server>> {
23+
const server = new Server(
24+
{
25+
name: 'Nango MCP server',
26+
version: '1.0.0'
27+
},
28+
{
29+
capabilities: {
30+
tools: {}
31+
}
32+
}
33+
);
34+
35+
const providerConfig = await configService.getProviderConfig(providerConfigKey, environment.id);
36+
37+
if (!providerConfig) {
38+
return Err(new Error(`Provider config ${providerConfigKey} not found`));
39+
}
40+
41+
const actions = await getActionsForProvider(environment, providerConfig);
42+
43+
server.setRequestHandler(ListToolsRequestSchema, () => {
44+
return {
45+
tools: actions.flatMap((action) => {
46+
const tool = actionToTool(action);
47+
return tool ? [tool] : [];
48+
})
49+
};
50+
});
51+
52+
server.setRequestHandler(CallToolRequestSchema, callToolRequestHandler(actions, account, environment, connection, providerConfig));
53+
54+
return Ok(server);
55+
}
56+
57+
async function getActionsForProvider(environment: DBEnvironment, providerConfig: Config): Promise<DBSyncConfig[]> {
58+
return getActionsByProviderConfigKey(environment.id, providerConfig.unique_key);
59+
}
60+
61+
function actionToTool(action: DBSyncConfig): Tool | null {
62+
const inputSchema =
63+
action.input && action.models_json_schema?.definitions && action.models_json_schema?.definitions?.[action.input]
64+
? (action.models_json_schema.definitions[action.input] as JSONSchema7)
65+
: ({ type: 'object' } as JSONSchema7);
66+
67+
if (inputSchema.type !== 'object') {
68+
// Invalid input schema, skip this action
69+
return null;
70+
}
71+
72+
const description = action.metadata.description || action.sync_name;
73+
74+
return {
75+
name: action.sync_name,
76+
inputSchema: {
77+
type: 'object',
78+
properties: inputSchema.properties,
79+
required: inputSchema.required
80+
},
81+
description
82+
};
83+
}
84+
85+
function callToolRequestHandler(
86+
actions: DBSyncConfig[],
87+
account: DBTeam,
88+
environment: DBEnvironment,
89+
connection: DBConnectionDecrypted,
90+
providerConfig: Config
91+
): (request: CallToolRequest) => Promise<CallToolResult> {
92+
return async (request: CallToolRequest) => {
93+
const active = tracer.scope().active();
94+
const span = tracer.startSpan('server.mcp.triggerAction', {
95+
childOf: active as Span
96+
});
97+
98+
const { name, arguments: toolArguments } = request.params;
99+
100+
const action = actions.find((action) => action.sync_name === name);
101+
102+
if (!action) {
103+
span.finish();
104+
throw new Error(`Action ${name} not found`);
105+
}
106+
107+
const input = toolArguments ?? {};
108+
109+
span.setTag('nango.actionName', action.sync_name)
110+
.setTag('nango.connectionId', connection.id)
111+
.setTag('nango.environmentId', environment.id)
112+
.setTag('nango.providerConfigKey', providerConfig.unique_key);
113+
114+
const logCtx = await logContextGetter.create(
115+
{ operation: { type: 'action', action: 'run' }, expiresAt: defaultOperationExpiration.action() },
116+
{
117+
account,
118+
environment,
119+
integration: { id: providerConfig.id!, name: providerConfig.unique_key, provider: providerConfig.provider },
120+
connection: { id: connection.id, name: connection.connection_id },
121+
syncConfig: { id: action.id, name: action.sync_name },
122+
meta: truncateJson({ input })
123+
}
124+
);
125+
logCtx.attachSpan(new OtlpSpan(logCtx.operation));
126+
127+
const actionResponse = await getOrchestrator().triggerAction({
128+
accountId: account.id,
129+
connection,
130+
actionName: action.sync_name,
131+
input,
132+
async: false,
133+
retryMax: 3,
134+
logCtx
135+
});
136+
137+
if (actionResponse.isOk()) {
138+
if (!('data' in actionResponse.value)) {
139+
// Shouldn't happen with sync actions.
140+
return {
141+
content: []
142+
};
143+
}
144+
145+
return {
146+
content: [
147+
{
148+
type: 'text',
149+
text: JSON.stringify(actionResponse.value.data, null, 2)
150+
}
151+
]
152+
};
153+
} else {
154+
span.setTag('nango.error', actionResponse.error);
155+
throw new Error(actionResponse.error.message);
156+
}
157+
};
158+
}

packages/server/lib/routes.public.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import multer from 'multer';
55

66
import { connectUrl, flagEnforceCLIVersion } from '@nangohq/utils';
77

8+
import { getAsyncActionResult } from './controllers/action/getAsyncActionResult.js';
89
import appAuthController from './controllers/appAuth.controller.js';
910
import { postPublicApiKeyAuthorization } from './controllers/auth/postApiKey.js';
1011
import { postPublicAppStoreAuthorization } from './controllers/auth/postAppStore.js';
@@ -37,6 +38,7 @@ import { postPublicIntegration } from './controllers/integrations/postIntegratio
3738
import { deletePublicIntegration } from './controllers/integrations/uniqueKey/deleteIntegration.js';
3839
import { getPublicIntegration } from './controllers/integrations/uniqueKey/getIntegration.js';
3940
import { patchPublicIntegration } from './controllers/integrations/uniqueKey/patchIntegration.js';
41+
import { getMcp, postMcp } from './controllers/mcp/mcp.js';
4042
import oauthController from './controllers/oauth.controller.js';
4143
import providerController from './controllers/provider.controller.js';
4244
import { getPublicProvider } from './controllers/providers/getProvider.js';
@@ -61,7 +63,6 @@ import { resourceCapping } from './middleware/resource-capping.middleware.js';
6163
import { isBinaryContentType } from './utils/utils.js';
6264

6365
import type { Request, RequestHandler } from 'express';
64-
import { getAsyncActionResult } from './controllers/action/getAsyncActionResult.js';
6566

6667
const apiAuth: RequestHandler[] = [authMiddleware.secretKeyAuth.bind(authMiddleware), rateLimiterMiddleware];
6768
const connectSessionAuth: RequestHandler[] = [authMiddleware.connectSessionAuth.bind(authMiddleware), rateLimiterMiddleware];
@@ -218,6 +219,10 @@ publicAPI.route('/sync/status').get(apiAuth, syncController.getSyncStatus.bind(s
218219
publicAPI.route('/sync/:name/variant/:variant').post(apiAuth, postSyncVariant);
219220
publicAPI.route('/sync/:name/variant/:variant').delete(apiAuth, deleteSyncVariant);
220221

222+
publicAPI.use('/mcp', jsonContentTypeMiddleware);
223+
publicAPI.route('/mcp').post(apiAuth, postMcp);
224+
publicAPI.route('/mcp').get(apiAuth, getMcp);
225+
221226
publicAPI.use('/flow', jsonContentTypeMiddleware);
222227
publicAPI.route('/flow/attributes').get(apiAuth, syncController.getFlowAttributes.bind(syncController));
223228
// @deprecated use /scripts/configs

packages/server/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
"npm": ">=6.14.11"
2424
},
2525
"dependencies": {
26+
"@modelcontextprotocol/sdk": "^1.11.2",
27+
"@nangohq/billing": "file:../billing",
2628
"@nangohq/database": "file:../database",
2729
"@nangohq/fleet": "file:../fleet",
2830
"@nangohq/keystore": "file:../keystore",
@@ -34,7 +36,6 @@
3436
"@nangohq/shared": "file:../shared",
3537
"@nangohq/utils": "file:../utils",
3638
"@nangohq/webhooks": "file:../webhooks",
37-
"@nangohq/billing": "file:../billing",
3839
"@workos-inc/node": "6.2.0",
3940
"axios": "1.9.0",
4041
"body-parser": "1.20.3",

packages/shared/lib/services/sync/config/config.service.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,29 @@ export async function getActionConfigByNameAndProviderConfigKey(environment_id:
236236
return false;
237237
}
238238

239-
export async function getActionsByProviderConfigKey(environment_id: number, unique_key: string): Promise<Action[]> {
239+
export async function getActionsByProviderConfigKey(environment_id: number, unique_key: string): Promise<DBSyncConfig[]> {
240+
const nango_config_id = await configService.getIdByProviderConfigKey(environment_id, unique_key);
241+
242+
if (!nango_config_id) {
243+
return [];
244+
}
245+
246+
const result = await schema().from<DBSyncConfig>(TABLE).where({
247+
environment_id,
248+
nango_config_id,
249+
deleted: false,
250+
active: true,
251+
type: 'action'
252+
});
253+
254+
if (result) {
255+
return result;
256+
}
257+
258+
return [];
259+
}
260+
261+
export async function getSimplifiedActionsByProviderConfigKey(environment_id: number, unique_key: string): Promise<Action[]> {
240262
const nango_config_id = await configService.getIdByProviderConfigKey(environment_id, unique_key);
241263

242264
if (!nango_config_id) {

packages/types/lib/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,5 @@ export type * from './fleet/index.js';
7575

7676
export type * from './persist/api.js';
7777
export type * from './jobs/api.js';
78+
79+
export type * from './mcp/api.js';

packages/types/lib/mcp/api.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { ApiError, Endpoint } from '../api.js';
2+
3+
export type PostMcp = Endpoint<{
4+
Method: 'POST';
5+
Path: '/mcp';
6+
Body: Record<string, unknown>;
7+
Headers: {
8+
'connection-id': string;
9+
'provider-config-key': string;
10+
};
11+
Success: Record<string, unknown>;
12+
Error: ApiError<'missing_connection_id' | 'unknown_connection'>;
13+
}>;
14+
15+
export type GetMcp = Endpoint<{
16+
Method: 'GET';
17+
Path: '/mcp';
18+
Success: Record<string, unknown>;
19+
}>;

packages/types/lib/syncConfigs/db.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import type { JSONSchema7 } from 'json-schema';
21
import type { TimestampsAndDeleted } from '../db';
32
import type { LegacySyncModelSchema, NangoConfigMetadata } from '../deploy/incomingFlow';
43
import type { NangoModel, ScriptTypeLiteral, SyncTypeLiteral } from '../nangoYaml';
4+
import type { JSONSchema7 } from 'json-schema';
55

66
export interface DBSyncConfig extends TimestampsAndDeleted {
77
id: number;

0 commit comments

Comments
 (0)