Skip to content

Commit 5f0fd1f

Browse files
authored
feat(designerv2): port v1 add MCP servers logic (addMcpServer, connection handling) (#8705)
feat(designerv2): portal v1 code to v2 for adding mcp servers
1 parent c0bc9fc commit 5f0fd1f

File tree

2 files changed

+279
-22
lines changed

2 files changed

+279
-22
lines changed
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { describe, vi, beforeEach, it, expect } from 'vitest';
2+
import { trySetDefaultConnectionForNode } from '../add';
3+
import * as connectionsModule from '../../../queries/connections';
4+
import * as connectionActionsModule from '../connections';
5+
import type { Connection, Connector } from '@microsoft/logic-apps-shared';
6+
7+
vi.mock('../../../queries/connections', () => ({
8+
getConnectionsForConnector: vi.fn(),
9+
}));
10+
11+
vi.mock('@microsoft/logic-apps-shared', async () => {
12+
const actual = await vi.importActual('@microsoft/logic-apps-shared');
13+
return {
14+
...actual,
15+
ConnectionService: vi.fn(() => ({
16+
setupConnectionIfNeeded: vi.fn().mockResolvedValue(undefined),
17+
})),
18+
UserPreferenceService: vi.fn(() => ({
19+
getMostRecentlyUsedConnectionId: vi.fn().mockResolvedValue(undefined),
20+
})),
21+
LoggerService: vi.fn(() => ({
22+
log: vi.fn(),
23+
})),
24+
};
25+
});
26+
27+
vi.mock('../connections', () => ({
28+
updateNodeConnection: vi.fn(() => vi.fn()),
29+
}));
30+
31+
vi.mock('../../../state/connection/connectionSlice', () => ({
32+
initEmptyConnectionMap: vi.fn(() => ({ type: 'mock/initEmptyConnectionMap' })),
33+
}));
34+
35+
vi.mock('../../../state/panel/panelSlice', () => ({
36+
openPanel: vi.fn(() => ({ type: 'mock/openPanel' })),
37+
}));
38+
39+
const mockGetConnectionsForConnector = vi.mocked(connectionsModule.getConnectionsForConnector);
40+
const mockUpdateNodeConnection = vi.mocked(connectionActionsModule.updateNodeConnection);
41+
42+
describe('trySetDefaultConnectionForNode', () => {
43+
const mockDispatch = vi.fn();
44+
45+
const createMockConnector = (id: string): Connector =>
46+
({
47+
id,
48+
name: 'test-connector',
49+
type: 'Microsoft.Web/connections',
50+
properties: {
51+
displayName: 'Test Connector',
52+
iconUri: 'https://example.com/icon.png',
53+
brandColor: '#0078d4',
54+
},
55+
}) as Connector;
56+
57+
const createMockConnection = (id: string, status = 'Connected'): Connection =>
58+
({
59+
id,
60+
name: `connection-${id}`,
61+
type: 'Microsoft.Web/connections',
62+
properties: {
63+
displayName: `Connection ${id}`,
64+
overallStatus: status,
65+
},
66+
}) as Connection;
67+
68+
beforeEach(() => {
69+
vi.clearAllMocks();
70+
mockUpdateNodeConnection.mockClear();
71+
});
72+
73+
describe('preferredConnectionId parameter', () => {
74+
it('should use preferred connection when provided and found in available connections', async () => {
75+
const connector = createMockConnector('/connectors/test');
76+
const connection1 = createMockConnection('conn-1');
77+
const connection2 = createMockConnection('conn-2');
78+
const connection3 = createMockConnection('conn-3');
79+
80+
mockGetConnectionsForConnector.mockResolvedValue([connection1, connection2, connection3]);
81+
82+
await trySetDefaultConnectionForNode('test-node', connector, mockDispatch, true, undefined, 'conn-2');
83+
84+
// Verify updateNodeConnection was called with the preferred connection (conn-2)
85+
expect(mockUpdateNodeConnection).toHaveBeenCalledWith({
86+
nodeId: 'test-node',
87+
connection: connection2,
88+
connector,
89+
});
90+
});
91+
92+
it('should fall back to first available connection when preferredConnectionId is not found', async () => {
93+
const connector = createMockConnector('/connectors/test');
94+
const connection1 = createMockConnection('conn-1');
95+
const connection2 = createMockConnection('conn-2');
96+
97+
mockGetConnectionsForConnector.mockResolvedValue([connection1, connection2]);
98+
99+
await trySetDefaultConnectionForNode('test-node', connector, mockDispatch, true, undefined, 'non-existent-conn');
100+
101+
// Should fall back to first connection since preferred wasn't found
102+
expect(mockUpdateNodeConnection).toHaveBeenCalledWith({
103+
nodeId: 'test-node',
104+
connection: connection1,
105+
connector,
106+
});
107+
});
108+
109+
it('should use first available connection when preferredConnectionId is not provided', async () => {
110+
const connector = createMockConnector('/connectors/test');
111+
const connection1 = createMockConnection('conn-1');
112+
const connection2 = createMockConnection('conn-2');
113+
114+
mockGetConnectionsForConnector.mockResolvedValue([connection1, connection2]);
115+
116+
await trySetDefaultConnectionForNode('test-node', connector, mockDispatch, true, undefined, undefined);
117+
118+
// Should use first connection when no preferred is provided
119+
expect(mockUpdateNodeConnection).toHaveBeenCalledWith({
120+
nodeId: 'test-node',
121+
connection: connection1,
122+
connector,
123+
});
124+
});
125+
126+
it('should filter out error connections before selecting preferred connection', async () => {
127+
const connector = createMockConnector('/connectors/test');
128+
const errorConnection = createMockConnection('conn-error', 'Error');
129+
const goodConnection = createMockConnection('conn-good');
130+
131+
mockGetConnectionsForConnector.mockResolvedValue([errorConnection, goodConnection]);
132+
133+
// Try to use the error connection as preferred - it should be filtered out
134+
await trySetDefaultConnectionForNode('test-node', connector, mockDispatch, true, undefined, 'conn-error');
135+
136+
// Should use the good connection since error connection was filtered
137+
expect(mockUpdateNodeConnection).toHaveBeenCalledWith({
138+
nodeId: 'test-node',
139+
connection: goodConnection,
140+
connector,
141+
});
142+
});
143+
144+
it('should apply connectionFilterFunc before selecting preferred connection', async () => {
145+
const connector = createMockConnector('/connectors/test');
146+
const connection1 = createMockConnection('conn-1');
147+
const connection2 = createMockConnection('conn-2');
148+
149+
mockGetConnectionsForConnector.mockResolvedValue([connection1, connection2]);
150+
151+
// Filter function that excludes conn-1
152+
const filterFunc = (c: Connection) => c.id !== 'conn-1';
153+
154+
// Try to use conn-1 as preferred, but it should be filtered out
155+
await trySetDefaultConnectionForNode('test-node', connector, mockDispatch, true, filterFunc, 'conn-1');
156+
157+
// Should use conn-2 since conn-1 was filtered out
158+
expect(mockUpdateNodeConnection).toHaveBeenCalledWith({
159+
nodeId: 'test-node',
160+
connection: connection2,
161+
connector,
162+
});
163+
});
164+
165+
it('should open connection panel when no connections are available and connection is required', async () => {
166+
const connector = createMockConnector('/connectors/test');
167+
168+
mockGetConnectionsForConnector.mockResolvedValue([]);
169+
170+
await trySetDefaultConnectionForNode('test-node', connector, mockDispatch, true, undefined, 'any-conn');
171+
172+
// updateNodeConnection should not be called
173+
expect(mockUpdateNodeConnection).not.toHaveBeenCalled();
174+
175+
// Should dispatch initEmptyConnectionMap and openPanel
176+
expect(mockDispatch).toHaveBeenCalledTimes(2);
177+
expect(mockDispatch).toHaveBeenCalledWith(expect.objectContaining({ type: 'mock/initEmptyConnectionMap' }));
178+
expect(mockDispatch).toHaveBeenCalledWith(expect.objectContaining({ type: 'mock/openPanel' }));
179+
});
180+
});
181+
});

libs/designer-v2/src/lib/core/actions/bjsworkflow/add.ts

Lines changed: 98 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { addResultSchema } from '../../state/staticresultschema/staticresultsSli
1717
import type { NodeTokens, VariableDeclaration } from '../../state/tokens/tokensSlice';
1818
import { initializeTokensAndVariables } from '../../state/tokens/tokensSlice';
1919
import { WorkflowKind, type NodesMetadata, type WorkflowState } from '../../state/workflow/workflowInterfaces';
20-
import { addAgentTool, addNode, setFocusNode, setWorkflowKind } from '../../state/workflow/workflowSlice';
20+
import { addAgentTool, addNode, addMcpServer, setFocusNode, setWorkflowKind } from '../../state/workflow/workflowSlice';
2121
import type { AppDispatch, RootState } from '../../store';
2222
import { getBrandColorFromManifest, getIconUriFromManifest } from '../../utils/card';
2323
import { getTriggerNodeId, isTriggerNode } from '../../utils/graph';
@@ -49,6 +49,7 @@ import {
4949
UserPreferenceService,
5050
LoggerService,
5151
LogEntryLevel,
52+
removeConnectionPrefix,
5253
} from '@microsoft/logic-apps-shared';
5354
import type {
5455
Connection,
@@ -63,7 +64,7 @@ import type { Dispatch } from '@reduxjs/toolkit';
6364
import { createAsyncThunk } from '@reduxjs/toolkit';
6465
import { batch } from 'react-redux';
6566
import { operationSupportsSplitOn } from '../../utils/outputs';
66-
import { isA2AWorkflow } from '../../state/workflow/helper';
67+
import { isA2AWorkflow, isManagedMcpOperation } from '../../state/workflow/helper';
6768
import { openKindChangeDialog } from '../../state/modal/modalSlice';
6869
import constants from '../../../common/constants';
6970
import { addOperationRunAfter, removeOperationRunAfter } from './runafter';
@@ -79,15 +80,16 @@ type AddOperationPayload = {
7980
actionMetadata?: Record<string, any>;
8081
isAddingHandoff?: boolean;
8182
isAddingMcpServer?: boolean;
83+
connectionId?: string;
8284
};
8385

8486
export const addOperation = createAsyncThunk('addOperation', async (payload: AddOperationPayload, { dispatch, getState }) => {
85-
batch(() => {
86-
const { operation, nodeId: actionId, presetParameterValues, actionMetadata, isAddingHandoff = false, isTrigger } = payload;
87-
if (!operation) {
88-
throw new Error('Operation does not exist'); // Just an optional catch, should never happen
89-
}
87+
const { operation, nodeId: actionId, presetParameterValues, actionMetadata, isAddingHandoff = false, isTrigger, connectionId } = payload;
88+
if (!operation) {
89+
throw new Error('Operation does not exist'); // Just an optional catch, should never happen
90+
}
9091

92+
batch(() => {
9193
const workflowState = (getState() as RootState).workflow;
9294

9395
// Catch workflow kind conflicts before adding node
@@ -134,17 +136,20 @@ export const addOperation = createAsyncThunk('addOperation', async (payload: Add
134136
const agentToolMetadata = (getState() as RootState).panel.discoveryContent.agentToolMetadata;
135137
const newToolId = (getState() as RootState).panel.discoveryContent.relationshipIds.subgraphId;
136138

137-
if (isAddingAgentTool) {
138-
if (newToolId && newToolGraphId) {
139-
if (agentToolMetadata?.newAdditiveSubgraphId && agentToolMetadata?.subGraphManifest) {
140-
initializeSubgraphFromManifest(agentToolMetadata.newAdditiveSubgraphId, agentToolMetadata?.subGraphManifest, dispatch);
139+
if (payload.isAddingMcpServer) {
140+
dispatch(addMcpServer(newPayload as any));
141+
} else {
142+
if (isAddingAgentTool) {
143+
if (newToolId && newToolGraphId) {
144+
if (agentToolMetadata?.newAdditiveSubgraphId && agentToolMetadata?.subGraphManifest) {
145+
initializeSubgraphFromManifest(agentToolMetadata.newAdditiveSubgraphId, agentToolMetadata?.subGraphManifest, dispatch);
146+
}
147+
dispatch(addAgentTool({ toolId: newToolId, graphId: newToolGraphId }));
141148
}
142-
dispatch(addAgentTool({ toolId: newToolId, graphId: newToolGraphId }));
143149
}
150+
dispatch(addNode(newPayload as any));
144151
}
145152

146-
dispatch(addNode(newPayload as any));
147-
148153
const nodeOperationInfo = {
149154
connectorId: operation.properties.api.id, // 'api' could be different based on type, could be 'function' or 'config' see old designer 'connectionOperation.ts' this is still pending for danielle
150155
operationId: operation.name,
@@ -160,7 +165,8 @@ export const addOperation = createAsyncThunk('addOperation', async (payload: Add
160165
dispatch,
161166
presetParameterValues,
162167
actionMetadata,
163-
!isAddingHandoff
168+
!isAddingHandoff,
169+
connectionId
164170
);
165171

166172
// Adjust workflow for kind change if needed
@@ -208,7 +214,7 @@ export const addOperation = createAsyncThunk('addOperation', async (payload: Add
208214
}
209215

210216
dispatch(setFocusNode(nodeId));
211-
if (isAddingAgentTool && newToolId) {
217+
if (isAddingAgentTool && newToolId && !payload.isAddingMcpServer) {
212218
dispatch(
213219
setAlternateSelectedNode({
214220
nodeId: newToolId,
@@ -246,7 +252,8 @@ export const initializeOperationDetails = async (
246252
dispatch: Dispatch,
247253
presetParameterValues?: Record<string, any>,
248254
actionMetadata?: Record<string, any>,
249-
openPanel = true
255+
openPanel = true,
256+
connectionId?: string
250257
): Promise<void> => {
251258
const state = getState();
252259
const isTrigger = isTriggerNode(nodeId, state.workflow.nodesMetadata);
@@ -265,7 +272,68 @@ export const initializeOperationDetails = async (
265272
let manifest: OperationManifest | undefined = undefined;
266273
let swagger: SwaggerParser | undefined = undefined;
267274
let parsedManifest: ManifestParser | undefined = undefined;
268-
if (operationManifestService.isSupported(type, kind)) {
275+
const isManagedMcpClient = isManagedMcpOperation({ type, kind });
276+
277+
if (isManagedMcpClient) {
278+
// managed mcp client ignores the swagger and uses a fixed set of parameters similar to manifest based native mcp client
279+
const { connector: swaggerConnector, parsedSwagger } = await getConnectorWithSwagger(connectorId);
280+
connector = swaggerConnector;
281+
const iconUri = getIconUriFromConnector(connector);
282+
const brandColor = getBrandColorFromConnector(connector);
283+
284+
const swaggerOperation = parsedSwagger.getOperationByOperationId(operationId);
285+
const operationPath = swaggerOperation ? removeConnectionPrefix(swaggerOperation.path) : undefined;
286+
if (operationPath) {
287+
dispatch(initializeOperationInfo({ id: nodeId, ...operationInfo, operationPath }));
288+
}
289+
290+
const nativeMcpOperationInfo = { connectorId: 'connectionProviders/mcpclient', operationId: 'nativemcpclient' };
291+
const nativeMcpManifest = await getOperationManifest(nativeMcpOperationInfo);
292+
isConnectionRequired = isConnectionRequiredForOperation(nativeMcpManifest);
293+
const { inputs: nodeInputs, dependencies: inputDependencies } = getInputParametersFromManifest(
294+
nodeId,
295+
operationInfo,
296+
nativeMcpManifest,
297+
presetParameterValues
298+
);
299+
300+
const { outputs: nodeOutputs, dependencies: outputDependencies } = getOutputParametersFromManifest(
301+
nodeId,
302+
nativeMcpManifest,
303+
isTrigger,
304+
nodeInputs,
305+
operationInfo,
306+
dispatch,
307+
operationSupportsSplitOn(isTrigger) ? getSplitOnValue(nativeMcpManifest, undefined, undefined, undefined) : undefined
308+
);
309+
parsedManifest = new ManifestParser(nativeMcpManifest, operationManifestService.isAliasingSupported(type, kind));
310+
311+
const nodeDependencies = {
312+
inputs: inputDependencies,
313+
outputs: outputDependencies,
314+
};
315+
let settings = getOperationSettings(
316+
isTrigger,
317+
operationInfo,
318+
nativeMcpManifest,
319+
/* swagger */ undefined,
320+
/* operation */ undefined,
321+
state.workflow.workflowKind
322+
);
323+
settings = addDefaultSecureSettings(settings, connector?.properties.isSecureByDefault ?? false);
324+
const updatedOutputs = nodeOutputs;
325+
initData = {
326+
id: nodeId,
327+
nodeInputs,
328+
nodeOutputs: updatedOutputs,
329+
nodeDependencies,
330+
settings,
331+
operationMetadata: { iconUri, brandColor },
332+
actionMetadata,
333+
};
334+
dispatch(initializeNodes({ nodes: [initData] }));
335+
addTokensAndVariables(nodeId, type, { ...initData, manifest: nativeMcpManifest }, state, dispatch);
336+
} else if (operationManifestService.isSupported(type, kind)) {
269337
manifest = await getOperationManifest(operationInfo);
270338
isConnectionRequired = isConnectionRequiredForOperation(manifest);
271339
connector = manifest.properties?.connector;
@@ -394,8 +462,13 @@ export const initializeOperationDetails = async (
394462

395463
if (isConnectionRequired) {
396464
try {
397-
await trySetDefaultConnectionForNode(nodeId, connector as Connector, dispatch, isConnectionRequired, (c: Connection) =>
398-
AgentUtils.filterDynamicConnectionFeatures(c, nodeId, state)
465+
await trySetDefaultConnectionForNode(
466+
nodeId,
467+
connector as Connector,
468+
dispatch as AppDispatch,
469+
isConnectionRequired,
470+
(c: Connection) => AgentUtils.filterDynamicConnectionFeatures(c, nodeId, state),
471+
connectionId
399472
);
400473
} catch (e: any) {
401474
dispatch(
@@ -487,14 +560,17 @@ export const trySetDefaultConnectionForNode = async (
487560
connector: Connector,
488561
dispatch: AppDispatch,
489562
isConnectionRequired: boolean,
490-
connectionFilterFunc?: (connection: Connection) => boolean
563+
connectionFilterFunc?: (connection: Connection) => boolean,
564+
preferredConnectionId?: string
491565
) => {
492566
const connectorId = connector.id;
493567
const connections = (await getConnectionsForConnector(connectorId)).filter(
494568
(c) => c.properties.overallStatus !== 'Error' && (!connectionFilterFunc || connectionFilterFunc(c))
495569
);
496570
if (connections.length > 0) {
497-
const connection = (await tryGetMostRecentlyUsedConnectionId(connectorId, connections)) ?? connections[0];
571+
// Use preferred connection if provided and found, otherwise fall back to most recently used or first available
572+
const preferredConnection = preferredConnectionId ? connections.find((c) => c.id === preferredConnectionId) : undefined;
573+
const connection = preferredConnection ?? (await tryGetMostRecentlyUsedConnectionId(connectorId, connections)) ?? connections[0];
498574
await ConnectionService().setupConnectionIfNeeded(connection);
499575
dispatch(updateNodeConnection({ nodeId, connection, connector }));
500576
} else if (isConnectionRequired) {

0 commit comments

Comments
 (0)