Skip to content

Commit 2c486c2

Browse files
authored
Support for MCP Tool Triggers (#4790)
* Support for creating self-hosted mcp server projects * Add file that I forgot to include * Work for creating MCP triggers via Functions mcp extensions
1 parent 3e0a9f1 commit 2c486c2

File tree

10 files changed

+124
-11
lines changed

10 files changed

+124
-11
lines changed

src/commands/createFunction/FunctionCreateStepBase.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,26 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
import { AzExtFsExtra, AzureWizardExecuteStepWithActivityOutput, callWithTelemetryAndErrorHandling, nonNullValue, type IActionContext } from '@microsoft/vscode-azext-utils';
6+
import { AzExtFsExtra, AzureWizardExecuteStepWithActivityOutput, callWithTelemetryAndErrorHandling, nonNullProp, nonNullValue, type IActionContext } from '@microsoft/vscode-azext-utils';
77
import * as path from 'path';
88
import { Uri, window, workspace, type Progress } from 'vscode';
9-
import { hostFileName } from '../../constants';
9+
import { hostFileName, McpProjectType, mcpProjectTypeSetting } from '../../constants';
1010
import { ext } from '../../extensionVariables';
1111
import { type IHostJsonV2 } from '../../funcConfig/host';
1212
import { localize } from '../../localize';
1313
import { type FunctionTemplateBase } from '../../templates/IFunctionTemplate';
14+
import { addLocalMcpServer, checkIfMcpServerExists, getLocalServerName, getOrCreateMcpJson, saveMcpJson } from '../../utils/mcpUtils';
1415
import { verifyTemplateIsV1 } from '../../utils/templateVersionUtils';
1516
import { verifyExtensionBundle } from '../../utils/verifyExtensionBundle';
1617
import { getContainingWorkspace } from '../../utils/workspace';
18+
import { updateWorkspaceSetting } from '../../vsCodeConfig/settings';
1719
import { type IFunctionWizardContext } from './IFunctionWizardContext';
1820

1921
interface ICachedFunction {
2022
projectPath: string;
2123
newFilePath: string;
2224
isHttpTrigger: boolean;
25+
isMcpTrigger: boolean;
2326
}
2427

2528
const cacheKey: string = 'azFuncPostFunctionCreate';
@@ -68,9 +71,23 @@ export abstract class FunctionCreateStepBase<T extends IFunctionWizardContext> e
6871
progress.report({ message: localize('creatingFunction', 'Creating new {0}...', template.name) });
6972

7073
const newFilePath: string = await this.executeCore(context);
74+
if (context.functionTemplate?.isMcpTrigger) {
75+
// indicate that this is a MCP Extension Server project
76+
await updateWorkspaceSetting(mcpProjectTypeSetting, McpProjectType.McpExtensionServer, context.workspacePath);
77+
// add the local server to the mcp.json if it doesn't already exist
78+
const workspace = nonNullProp(context, 'workspaceFolder');
79+
const mcpJson = await getOrCreateMcpJson(workspace);
80+
const serverName = getLocalServerName(workspace);
81+
// only add if it doesn't already exist
82+
if (!checkIfMcpServerExists(mcpJson, serverName)) {
83+
const newMcpJson = await addLocalMcpServer(mcpJson, serverName, McpProjectType.McpExtensionServer);
84+
await saveMcpJson(workspace, newMcpJson);
85+
}
86+
}
87+
7188
await verifyExtensionBundle(context, template);
7289

73-
const cachedFunc: ICachedFunction = { projectPath: context.projectPath, newFilePath, isHttpTrigger: template.isHttpTrigger };
90+
const cachedFunc: ICachedFunction = { projectPath: context.projectPath, newFilePath, isHttpTrigger: template.isHttpTrigger, isMcpTrigger: template.isMcpTrigger };
7491
const hostFilePath: string = path.join(context.projectPath, hostFileName);
7592
if (await AzExtFsExtra.pathExists(hostFilePath)) {
7693
if (verifyTemplateIsV1(context.functionTemplate) && context.functionTemplate?.isDynamicConcurrent) {
@@ -80,6 +97,24 @@ export abstract class FunctionCreateStepBase<T extends IFunctionWizardContext> e
8097
snapshotPersistenceEnabled: true
8198
}
8299
await AzExtFsExtra.writeJSON(hostFilePath, hostJson);
100+
} else if (context.functionTemplate?.isMcpTrigger) {
101+
const hostJson = await AzExtFsExtra.readJSON<IHostJsonV2>(hostFilePath);
102+
hostJson.extensions = hostJson.extensions ?? {};
103+
if (!hostJson.extensions.mcp) {
104+
hostJson.extensions.mcp = {
105+
instructions: "Some test instructions on how to use the server",
106+
serverName: "TestServer",
107+
serverVersion: "2.0.0",
108+
encryptClientState: true,
109+
messageOptions: {
110+
useAbsoluteUriForEndpoint: false
111+
},
112+
system: {
113+
webhookAuthorizationLevel: "System"
114+
}
115+
}
116+
}
117+
await AzExtFsExtra.writeJSON(hostFilePath, hostJson);
83118
}
84119
}
85120

@@ -105,7 +140,15 @@ function runPostFunctionCreateSteps(func: ICachedFunction): void {
105140

106141
// If function creation created a new file, open it in an editor...
107142
if (func.newFilePath && getContainingWorkspace(func.projectPath)) {
108-
if (await AzExtFsExtra.pathExists(func.newFilePath)) {
143+
const mcpJsonFilePath: string = path.join(func.projectPath, '.vscode', 'mcp.json');
144+
if (await AzExtFsExtra.pathExists(func.newFilePath) && await AzExtFsExtra.pathExists(mcpJsonFilePath) && func.isMcpTrigger) {
145+
// show the func new file path and the mcp json file in a split editor
146+
const templateFile = await workspace.openTextDocument(Uri.file(func.newFilePath));
147+
const mcpJsonFile = await workspace.openTextDocument(Uri.file(mcpJsonFilePath));
148+
await window.showTextDocument(templateFile, { viewColumn: 1, preserveFocus: false });
149+
await window.showTextDocument(mcpJsonFile, { viewColumn: 2, preserveFocus: true });
150+
}
151+
else if (await AzExtFsExtra.pathExists(func.newFilePath)) {
109152
await window.showTextDocument(await workspace.openTextDocument(Uri.file(func.newFilePath)));
110153
}
111154
}

src/commands/createFunction/actionStepsV2/WriteToFileExecuteStep.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,15 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
import { AzExtFsExtra, nonNullProp } from "@microsoft/vscode-azext-utils";
6+
import { AzExtFsExtra, nonNullProp, nonNullValue } from "@microsoft/vscode-azext-utils";
77
import * as path from 'path';
88
import { Uri, window, workspace } from "vscode";
9+
import { hostFileName, McpProjectType, mcpProjectTypeSetting } from "../../../constants";
10+
import { type IHostJsonV2 } from "../../../funcConfig/host";
11+
import { type FunctionTemplateBase } from "../../../templates/IFunctionTemplate";
912
import { isDocumentOpened } from "../../../utils/textUtils";
13+
import { verifyExtensionBundle } from "../../../utils/verifyExtensionBundle";
14+
import { updateWorkspaceSetting } from "../../../vsCodeConfig/settings";
1015
import { type FunctionV2WizardContext } from "../IFunctionWizardContext";
1116
import { getFileExtensionFromLanguage } from "../scriptSteps/ScriptFunctionCreateStep";
1217
import { ActionSchemaStepBase } from "./ActionSchemaStepBase";
@@ -25,6 +30,33 @@ export class WriteToFileExecuteStep<T extends FunctionV2WizardContext> extends A
2530
const source = context[sourceKey] as string;
2631

2732
await AzExtFsExtra.writeFile(filePath, source);
33+
if (context.functionTemplate?.isMcpTrigger) {
34+
// indicate that this is a MCP Extension Server project
35+
await updateWorkspaceSetting(mcpProjectTypeSetting, McpProjectType.McpExtensionServer, context.workspacePath);
36+
const template: FunctionTemplateBase = nonNullValue(context.functionTemplate);
37+
await verifyExtensionBundle(context, template);
38+
39+
const hostFilePath: string = path.join(context.projectPath, hostFileName);
40+
if (await AzExtFsExtra.pathExists(hostFilePath)) {
41+
const hostJson = await AzExtFsExtra.readJSON<IHostJsonV2>(hostFilePath);
42+
hostJson.extensions = hostJson.extensions ?? {};
43+
if (!hostJson.extensions.mcp) {
44+
hostJson.extensions.mcp = {
45+
instructions: "Some test instructions on how to use the server",
46+
serverName: "TestServer",
47+
serverVersion: "2.0.0",
48+
encryptClientState: true,
49+
messageOptions: {
50+
useAbsoluteUriForEndpoint: false
51+
},
52+
system: {
53+
webhookAuthorizationLevel: "System"
54+
}
55+
}
56+
}
57+
await AzExtFsExtra.writeJSON(hostFilePath, hostJson);
58+
}
59+
}
2860
}
2961

3062
protected async getFilePath(context: T): Promise<string> {

src/funcConfig/function.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6+
import { type IFunctionTemplate } from "../templates/IFunctionTemplate";
7+
8+
69
export interface IFunctionJson {
710
disabled?: boolean;
811
scriptFile?: string;
@@ -32,12 +35,20 @@ export enum HttpAuthLevel {
3235
*/
3336
export class ParsedFunctionJson {
3437
public readonly data: IFunctionJson;
38+
public readonly template: IFunctionTemplate | undefined;
3539

3640
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
3741
public constructor(data: any) {
3842
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
39-
if (typeof data === 'object' && data !== null && (data.bindings === undefined || data.bindings instanceof Array)) {
40-
this.data = <IFunctionJson>data;
43+
if (typeof data === 'object' && data !== null && (data.functions?.bindings !== undefined || data.functions?.bindings instanceof Array)) {
44+
// this is to preserve the old template structure where function.json was nested under 'functions'
45+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
46+
this.data = <IFunctionJson>data.functions;
47+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
48+
} else if (typeof data === 'object' && data !== null && (data.metadata) !== undefined) {
49+
// for Node.js programming model v4, there is no function.json so use the template metadata
50+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
51+
this.template = <IFunctionTemplate>data.metadata;
4152
} else {
4253
this.data = {};
4354
}
@@ -60,13 +71,30 @@ export class ParsedFunctionJson {
6071
}
6172

6273
public get isHttpTrigger(): boolean {
74+
if (this.template?.triggerType) {
75+
return /^http/i.test(this.template.triggerType);
76+
}
6377
return !!this.triggerBinding && !!this.triggerBinding.type && /^http/i.test(this.triggerBinding.type);
6478
}
6579

6680
public get isTimerTrigger(): boolean {
81+
if (this.template?.triggerType) {
82+
return /^timer/i.test(this.template.triggerType);
83+
}
6784
return !!this.triggerBinding && !!this.triggerBinding.type && /^timer/i.test(this.triggerBinding.type);
6885
}
6986

87+
public get isMcpTrigger(): boolean {
88+
if (this.template?.triggerType) {
89+
return /^mcptooltrigger/i.test(this.template.triggerType) || /^mcptrigger/i.test(this.template.triggerType);
90+
}
91+
if (this.triggerBinding && this.triggerBinding.type) {
92+
return /^mcptooltrigger/i.test(this.triggerBinding.type) || /^mcptrigger/i.test(this.triggerBinding.type);
93+
}
94+
95+
return false;
96+
}
97+
7098
public get authLevel(): HttpAuthLevel | undefined {
7199
if (this.triggerBinding && this.triggerBinding.authLevel) {
72100
return HttpAuthLevel[this.triggerBinding.authLevel.toLowerCase() as HttpAuthLevel];

src/templates/IFunctionTemplate.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,6 @@ export interface FunctionTemplateBase {
4444
language: ProjectLanguage;
4545
isHttpTrigger: boolean;
4646
isTimerTrigger: boolean;
47+
isMcpTrigger: boolean;
4748
templateSchemaVersion: TemplateSchemaVersion
4849
}

src/templates/dotnet/getDotnetVerifiedTemplateIds.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ export function getDotnetVerifiedTemplateIds(version: string): RegExp[] {
2121
//TODO: Add unit test for EventGridBlobTrigger
2222
'EventGridBlobTrigger',
2323
'SqlInputBinding',
24-
'SqlOutputBinding'
24+
'SqlOutputBinding',
25+
'McpToolTrigger'
2526
];
2627

2728
if (version === FuncVersion.v1) {

src/templates/dotnet/parseDotnetTemplates.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ function parseDotnetTemplate(rawTemplate: IRawTemplate): IFunctionTemplate {
7777
return {
7878
isHttpTrigger: /^http/i.test(rawTemplate.Name) || /webhook$/i.test(rawTemplate.Name),
7979
isTimerTrigger: /^timer/i.test(rawTemplate.Name),
80+
isMcpTrigger: /^mcptooltrigger/i.test(rawTemplate.Name),
8081
isSqlBindingTemplate: sqlBindingTemplateRegex.test(rawTemplate.Name),
8182
id: rawTemplate.Identity,
8283
name: rawTemplate.Name,

src/templates/script/getScriptVerifiedTemplateIds.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ export function getScriptVerifiedTemplateIds(version: string): (string | RegExp)
3737
'IoTHubTrigger',
3838
//TODO: Add unit test for EventGridBlobTrigger
3939
'EventGridBlobTrigger',
40-
'SqlTrigger'
40+
'SqlTrigger',
41+
'McpToolTrigger',
42+
'MCPTrigger'
4143
]);
4244

4345
// These languages are only supported in v2+ - same functions as JavaScript, with a few minor exceptions that aren't worth distinguishing here

src/templates/script/parseScriptTemplates.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ export function parseScriptTemplate(rawTemplate: IRawTemplate, resources: IResou
197197
return undefined;
198198
}
199199

200-
const functionJson: ParsedFunctionJson = new ParsedFunctionJson(rawTemplate.function);
200+
const functionJson: ParsedFunctionJson = new ParsedFunctionJson(rawTemplate);
201201

202202
let language: ProjectLanguage = rawTemplate.metadata.language;
203203
// The templateApiZip only supports script languages, and thus incorrectly defines 'C#Script' as 'C#', etc.
@@ -251,6 +251,7 @@ export function parseScriptTemplate(rawTemplate: IRawTemplate, resources: IResou
251251
functionJson,
252252
isHttpTrigger: functionJson.isHttpTrigger,
253253
isTimerTrigger: functionJson.isTimerTrigger,
254+
isMcpTrigger: functionJson.isMcpTrigger,
254255
isSqlBindingTemplate: sqlBindingTemplateRegex.test(rawTemplate.id),
255256
id: rawTemplate.id,
256257
name: getResourceValue(resources, rawTemplate.metadata.name),

src/templates/script/parseScriptTemplatesV2.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,12 +121,16 @@ export function parseScriptTemplates(rawTemplates: RawTemplateV2[], rawBindings:
121121
}
122122
const isHttpTrigger = !!templateV2.id?.toLowerCase().includes('httptrigger-');
123123
const isTimerTrigger = !!templateV2.id?.toLowerCase().includes('timertrigger-');
124+
// python and node.js use 2 different IDs for Mcp Triggers... because of course they do
125+
const isMcpTrigger = !!templateV2.id?.toLowerCase().includes('mcptooltrigger') ||
126+
templateV2.id?.toLowerCase().includes('mcptrigger');
124127

125128
templates.push(Object.assign(templateV2, {
126129
wizards: parsedJobs,
127130
id: nonNullValue(templateV2.id),
128131
isHttpTrigger,
129132
isTimerTrigger,
133+
isMcpTrigger,
130134
templateSchemaVersion: TemplateSchemaVersion.v2
131135
}));
132136
}

src/utils/bundleFeedUtils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { nugetUtils } from './nugetUtils';
1414

1515
export namespace bundleFeedUtils {
1616
export const defaultBundleId: string = 'Microsoft.Azure.Functions.ExtensionBundle';
17-
export const defaultVersionRange: string = '[1.*, 2.0.0)';
17+
export const defaultVersionRange: string = '[4.*, 5.0.0)';
1818

1919
interface IBundleFeed {
2020
defaultVersionRange: string;

0 commit comments

Comments
 (0)