Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
74ded7b
Initial plan
Copilot Dec 29, 2025
092abad
Add support for custom MCP server URLs in ToolingManifest.json
Copilot Dec 29, 2025
2b89568
Address code review feedback: cleanup unused variables and improve ty…
Copilot Dec 29, 2025
c00b41a
Revert url field to required in MCPServerConfig interface
Copilot Dec 29, 2025
4d75806
Validate full URL in tests using Utility.BuildMcpServerUrl
Copilot Dec 29, 2025
bd15415
Use mcpServerUniqueName as fallback when mcpServerName is not set
Copilot Dec 29, 2025
9e810dd
Merge branch 'main' into copilot/add-custom-mcp-url-support
pontemonti Dec 30, 2025
98f17bb
Replace 'any' type with proper MCPServerManifestEntry interface
Copilot Dec 30, 2025
d8ca535
Fix test environment variable handling to match existing pattern
Copilot Dec 30, 2025
4c37d49
Add --experimental-vm-modules flag to test scripts for ESM support
Copilot Dec 30, 2025
de9a135
Use cross-env for cross-platform NODE_OPTIONS compatibility
Copilot Dec 30, 2025
328a5af
Update packages/agents-a365-tooling/src/McpToolServerConfigurationSer…
pontemonti Dec 30, 2025
4a03d0b
Add test coverage for custom headers in manifest
Copilot Dec 30, 2025
a209e79
Merge branch 'main' into copilot/add-custom-mcp-url-support
pontemonti Jan 21, 2026
62cf978
Merge branch 'main' into copilot/add-custom-mcp-url-support
pontemonti Jan 22, 2026
6651506
Refactor MCPServerManifestEntry type definition and update test scrip…
Jan 23, 2026
10f495d
Merge branch 'main' into copilot/add-custom-mcp-url-support
pontemonti Jan 23, 2026
f7303ae
Remove unused MCP server configuration comments
pontemonti Jan 26, 2026
4cb92a0
Merge branch 'main' into copilot/add-custom-mcp-url-support
pontemonti Jan 26, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import path from 'path';
import axios from 'axios';
import { TurnContext } from '@microsoft/agents-hosting';
import { OperationResult, OperationError } from '@microsoft/agents-a365-runtime';
import { MCPServerConfig, McpClientTool, ToolOptions } from './contracts';
import { MCPServerConfig, MCPServerManifestEntry, McpClientTool, ToolOptions } from './contracts';
import { ChatHistoryMessage, ChatMessageRequest } from './models/index';
import { Utility } from './Utility';

Expand Down Expand Up @@ -237,6 +237,10 @@ export class McpToolServerConfigurationService {
* }
* ]
* }
*
* Each server entry can optionally include a "url" field to specify a custom MCP server URL.
* If the "url" field is not provided, the URL will be automatically constructed using the server name.
* The server name is determined by using "mcpServerName" if present, otherwise "mcpServerUniqueName".
Comment thread
pontemonti marked this conversation as resolved.
*/
private async getMCPServerConfigsFromManifest(): Promise<MCPServerConfig[]> {
let manifestPath = path.join(process.cwd(), 'ToolingManifest.json');
Expand All @@ -255,10 +259,16 @@ export class McpToolServerConfigurationService {
const manifestData = JSON.parse(jsonContent);
const mcpServers = manifestData.mcpServers || [];

return mcpServers.map((s: MCPServerConfig) => {
return mcpServers.map((s: MCPServerManifestEntry) => {
// Use mcpServerName if available, otherwise fall back to mcpServerUniqueName
const serverName = s.mcpServerName || s.mcpServerUniqueName;
if (!serverName) {
throw new Error('Either mcpServerName or mcpServerUniqueName must be provided in manifest entry');
}
return {
mcpServerName: s.mcpServerName,
url: Utility.BuildMcpServerUrl(s.mcpServerName)
mcpServerName: serverName,
url: s.url || Utility.BuildMcpServerUrl(serverName),
Comment thread
pontemonti marked this conversation as resolved.
headers: s.headers
};
});
Comment thread
pontemonti marked this conversation as resolved.
} catch (err: unknown) {
Expand Down
8 changes: 8 additions & 0 deletions packages/agents-a365-tooling/src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ export interface MCPServerConfig {
headers?: Record<string, string>;
}

export type MCPServerManifestEntry = {
url?: string;
headers?: Record<string, string>;
} & (
| { mcpServerName: string; mcpServerUniqueName?: string }
| { mcpServerUniqueName: string; mcpServerName?: string }
);

export interface McpClientTool {
name: string;
description?: string;
Expand Down
15 changes: 15 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ catalog:
"@types/node": "^20.17.0"
"@typescript-eslint/eslint-plugin": "^8.47.0"
"@typescript-eslint/parser": "^8.47.0"
"cross-env": "^7.0.3"
"dotenv": "^17.2.2"
"eslint": "^9.39.1"
"jest": "^30.2.0"
Expand Down
11 changes: 6 additions & 5 deletions tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"test": "node --experimental-vm-modules $(npm root)/../node_modules/.bin/jest --config jest.config.cjs --passWithNoTests --testPathIgnorePatterns=/integration/",
"test:watch": "node --experimental-vm-modules $(npm root)/../node_modules/.bin/jest --config jest.config.cjs --watch --testPathIgnorePatterns=/integration/",
"test:coverage": "node --experimental-vm-modules $(npm root)/../node_modules/.bin/jest --config jest.config.cjs --coverage --passWithNoTests --testPathIgnorePatterns=/integration/",
"test:verbose": "node --experimental-vm-modules $(npm root)/../node_modules/.bin/jest --config jest.config.cjs --verbose --passWithNoTests --testPathIgnorePatterns=/integration/",
"test:ci": "node --experimental-vm-modules $(npm root)/../node_modules/.bin/jest --config jest.config.cjs --coverage --ci --maxWorkers=2 --passWithNoTests --testPathIgnorePatterns=/integration/"
"test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --config jest.config.cjs --passWithNoTests --testPathIgnorePatterns=/integration/",
"test:watch": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --config jest.config.cjs --watch --testPathIgnorePatterns=/integration/",
"test:coverage": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --config jest.config.cjs --coverage --passWithNoTests --testPathIgnorePatterns=/integration/",
"test:verbose": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --config jest.config.cjs --verbose --passWithNoTests --testPathIgnorePatterns=/integration/",
"test:ci": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --config jest.config.cjs --coverage --ci --maxWorkers=2 --passWithNoTests --testPathIgnorePatterns=/integration/"
},
"keywords": [
"agents",
Expand Down Expand Up @@ -51,6 +51,7 @@
"@types/node": "catalog:",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"cross-env": "catalog:",
"eslint": "catalog:",
"jest": "catalog:",
"js-yaml": "catalog:",
Expand Down
266 changes: 266 additions & 0 deletions tests/tooling/McpToolServerConfigurationService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
import { McpToolServerConfigurationService } from '../../packages/agents-a365-tooling/src/McpToolServerConfigurationService';
import { Utility } from '../../packages/agents-a365-tooling/src/Utility';
import fs from 'fs';

describe('McpToolServerConfigurationService', () => {
let service: McpToolServerConfigurationService;
const originalEnv = process.env;

beforeEach(() => {
service = new McpToolServerConfigurationService();
process.env = { ...originalEnv };
// Set to development mode to read from manifest
process.env.NODE_ENV = 'Development';
});

afterEach(() => {
process.env = originalEnv;
jest.restoreAllMocks();
Comment thread
pontemonti marked this conversation as resolved.
});

describe('listToolServers with custom URLs', () => {
it('should use custom URL when provided in manifest', async () => {
// Arrange
const manifestContent = {
mcpServers: [
{
mcpServerName: 'customServer',
url: 'http://localhost:3000/custom-mcp'
}
]
};

jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(manifestContent));

// Act
const servers = await service.listToolServers('test-agent-id', 'mock-auth-token');

// Assert
expect(servers).toHaveLength(1);
expect(servers[0].mcpServerName).toBe('customServer');
expect(servers[0].url).toBe('http://localhost:3000/custom-mcp');
});

it('should build URL when not provided in manifest', async () => {
// Arrange
const manifestContent = {
mcpServers: [
{
mcpServerName: 'mcp_MailTools'
}
]
};

jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(manifestContent));

// Act
const servers = await service.listToolServers('test-agent-id', 'mock-auth-token');

// Assert
expect(servers).toHaveLength(1);
expect(servers[0].mcpServerName).toBe('mcp_MailTools');
// In development mode, should use mock server URL
expect(servers[0].url).toBe(Utility.BuildMcpServerUrl('mcp_MailTools'));
});

it('should handle mix of custom and default URLs in manifest', async () => {
// Arrange
const manifestContent = {
mcpServers: [
{
mcpServerName: 'customServer',
url: 'https://custom.example.com/mcp'
},
{
mcpServerName: 'mcp_MailTools'
},
{
mcpServerName: 'anotherCustom',
url: 'http://localhost:5000/mcp-server'
}
]
};

jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(manifestContent));

// Act
const servers = await service.listToolServers('test-agent-id', 'mock-auth-token');

// Assert
expect(servers).toHaveLength(3);

// First server has custom URL
expect(servers[0].mcpServerName).toBe('customServer');
expect(servers[0].url).toBe('https://custom.example.com/mcp');

// Second server uses default URL building
expect(servers[1].mcpServerName).toBe('mcp_MailTools');
expect(servers[1].url).toBe(Utility.BuildMcpServerUrl('mcp_MailTools'));

// Third server has custom URL
expect(servers[2].mcpServerName).toBe('anotherCustom');
expect(servers[2].url).toBe('http://localhost:5000/mcp-server');
});

it('should return empty array when manifest file does not exist', async () => {
// Arrange
jest.spyOn(fs, 'existsSync').mockReturnValue(false);
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});

// Act
const servers = await service.listToolServers('test-agent-id', 'mock-auth-token');

// Assert
expect(servers).toHaveLength(0);
expect(consoleWarnSpy).toHaveBeenCalled();
});

it('should handle empty mcpServers array in manifest', async () => {
// Arrange
const manifestContent = {
mcpServers: []
};

jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(manifestContent));

// Act
const servers = await service.listToolServers('test-agent-id', 'mock-auth-token');

// Assert
expect(servers).toHaveLength(0);
});

it('should handle missing mcpServers property in manifest', async () => {
// Arrange
const manifestContent = {};

jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(manifestContent));

// Act
const servers = await service.listToolServers('test-agent-id', 'mock-auth-token');

// Assert
expect(servers).toHaveLength(0);
});

it('should use mcpServerUniqueName as fallback when mcpServerName is not provided', async () => {
// Arrange
const manifestContent = {
mcpServers: [
{
mcpServerUniqueName: 'mcp_UniqueServer'
}
]
};

jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(manifestContent));

// Act
const servers = await service.listToolServers('test-agent-id', 'mock-auth-token');

// Assert
expect(servers).toHaveLength(1);
expect(servers[0].mcpServerName).toBe('mcp_UniqueServer');
expect(servers[0].url).toBe(Utility.BuildMcpServerUrl('mcp_UniqueServer'));
});

it('should prefer mcpServerName over mcpServerUniqueName when both are provided', async () => {
// Arrange
const manifestContent = {
mcpServers: [
{
mcpServerName: 'mcp_PreferredName',
mcpServerUniqueName: 'mcp_FallbackName'
}
]
};

jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(manifestContent));

// Act
const servers = await service.listToolServers('test-agent-id', 'mock-auth-token');

// Assert
expect(servers).toHaveLength(1);
expect(servers[0].mcpServerName).toBe('mcp_PreferredName');
expect(servers[0].url).toBe(Utility.BuildMcpServerUrl('mcp_PreferredName'));
});

it('should return empty array and log error when neither mcpServerName nor mcpServerUniqueName is provided', async () => {
// Arrange
const manifestContent = {
mcpServers: [
{
url: 'http://localhost:3000/custom-mcp'
}
]
};

jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(manifestContent));
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});

// Act
const servers = await service.listToolServers('test-agent-id', 'mock-auth-token');

// Assert
expect(servers).toHaveLength(0);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Either mcpServerName or mcpServerUniqueName must be provided')
);
});

it('should preserve custom headers when provided in manifest', async () => {
// Arrange
const manifestContent = {
mcpServers: [
{
mcpServerName: 'serverWithHeaders',
url: 'http://localhost:3000/custom-mcp',
headers: {
'Authorization': 'Bearer token123',
'X-Custom-Header': 'custom-value'
}
},
{
mcpServerName: 'serverWithoutHeaders',
url: 'http://localhost:4000/another-mcp'
}
]
};

jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(manifestContent));

// Act
const servers = await service.listToolServers('test-agent-id', 'mock-auth-token');

// Assert
expect(servers).toHaveLength(2);

// First server should have headers preserved
expect(servers[0].mcpServerName).toBe('serverWithHeaders');
expect(servers[0].url).toBe('http://localhost:3000/custom-mcp');
expect(servers[0].headers).toEqual({
'Authorization': 'Bearer token123',
'X-Custom-Header': 'custom-value'
});

// Second server should have undefined headers
expect(servers[1].mcpServerName).toBe('serverWithoutHeaders');
expect(servers[1].url).toBe('http://localhost:4000/another-mcp');
expect(servers[1].headers).toBeUndefined();
});
});
});
Comment thread
pontemonti marked this conversation as resolved.