Skip to content

Commit 1ede6ac

Browse files
committed
MCP ToolTrigger Fulent Property and IsArray support (#377)
* MCP ToolTrigger Fulent Property and IsArray support * Improvement in the Error mesage * Code review comments * Code review comments * Making Stricter
1 parent 8826b61 commit 1ede6ac

21 files changed

+2251
-1290
lines changed

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,9 @@ test/end-to-end/Azure.Functions.NodejsWorker.E2E/.vs
5050
test/end-to-end/Azure.Functions.NodejsWorker.E2E/Azure.Functions.NodejsWorker.E2E/bin
5151
test/end-to-end/Azure.Functions.NodejsWorker.E2E/Azure.Functions.NodejsWorker.E2E/obj
5252
**/*-test-results.xml
53+
54+
# Azurite storage emulator files (contain secrets)
55+
__azurite_db_*.json
56+
__blobstorage__/
57+
__queuestorage__/
58+
__tablestorage__/

package-lock.json

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

package.json

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@azure/functions",
3-
"version": "4.8.1",
3+
"version": "4.8.2",
44
"description": "Microsoft Azure Functions NodeJS Framework",
55
"keywords": [
66
"azure",
@@ -28,7 +28,7 @@
2828
"README.md"
2929
],
3030
"engines": {
31-
"node": ">=18.0"
31+
"node": ">=20.0"
3232
},
3333
"scripts": {
3434
"build": "webpack --mode development",
@@ -41,6 +41,7 @@
4141
"watch": "webpack --watch --mode development"
4242
},
4343
"dependencies": {
44+
"@azure/functions-extensions-base": "0.2.0",
4445
"cookie": "^0.7.0",
4546
"long": "^4.0.0",
4647
"undici": "^5.29.0"
@@ -55,6 +56,7 @@
5556
"@types/mocha": "^9.1.1",
5657
"@types/node": "^18.0.0",
5758
"@types/semver": "^7.3.9",
59+
"@types/sinon": "^17.0.4",
5860
"@typescript-eslint/eslint-plugin": "^5.12.1",
5961
"@typescript-eslint/parser": "^5.12.1",
6062
"chai": "^4.2.0",
@@ -65,8 +67,8 @@
6567
"eslint-plugin-header": "^3.1.1",
6668
"eslint-plugin-import": "^2.29.0",
6769
"eslint-plugin-prettier": "^4.0.0",
68-
"eslint-webpack-plugin": "^3.2.0",
6970
"eslint-plugin-simple-import-sort": "^10.0.0",
71+
"eslint-webpack-plugin": "^3.2.0",
7072
"fork-ts-checker-webpack-plugin": "^7.2.13",
7173
"fs-extra": "^10.0.1",
7274
"globby": "^11.0.0",
@@ -76,9 +78,10 @@
7678
"mocha-multi-reporters": "^1.5.1",
7779
"prettier": "^2.4.1",
7880
"semver": "^7.3.5",
81+
"sinon": "^20.0.0",
7982
"ts-loader": "^9.3.1",
8083
"ts-node": "^3.3.0",
81-
"typescript": "^4.5.5",
84+
"typescript": "^4.9.5",
8285
"typescript4": "npm:typescript@~4.0.0",
8386
"webpack": "^5.74.0",
8487
"webpack-cli": "^4.10.0"

src/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the MIT License.
33

4-
export const version = '4.8.1';
4+
export const version = '4.8.2';
55

66
export const returnBindingKey = '$return';

src/converters/fromRpcTypedData.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT License.
33

44
import { RpcTypedData } from '@azure/functions-core';
5+
import { ResourceFactoryResolver } from '@azure/functions-extensions-base';
56
import { HttpRequest } from '../http/HttpRequest';
67
import { isDefined } from '../utils/nonNull';
78

@@ -30,8 +31,35 @@ export function fromRpcTypedData(data: RpcTypedData | null | undefined): unknown
3031
return data.collectionDouble.double;
3132
} else if (data.collectionSint64 && isDefined(data.collectionSint64.sint64)) {
3233
return data.collectionSint64.sint64;
33-
} else {
34-
return undefined;
34+
} else if (data.modelBindingData && isDefined(data.modelBindingData.content)) {
35+
try {
36+
const resourceFactoryResolver: ResourceFactoryResolver = ResourceFactoryResolver.getInstance();
37+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
38+
return resourceFactoryResolver.createClient(data.modelBindingData.source, data.modelBindingData);
39+
} catch (exception) {
40+
throw new Error(
41+
'Unable to create client. Please register the extensions library with your function app. ' +
42+
`Error: ${exception instanceof Error ? exception.message : String(exception)}`
43+
);
44+
}
45+
} else if (
46+
data.collectionModelBindingData &&
47+
isDefined(data.collectionModelBindingData.modelBindingData) &&
48+
data.collectionModelBindingData.modelBindingData.length > 0
49+
) {
50+
try {
51+
const resourceFactoryResolver: ResourceFactoryResolver = ResourceFactoryResolver.getInstance();
52+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
53+
return resourceFactoryResolver.createClient(
54+
data.collectionModelBindingData.modelBindingData[0]?.source,
55+
data.collectionModelBindingData.modelBindingData
56+
);
57+
} catch (exception) {
58+
throw new Error(
59+
'Unable to create client. Please register the extensions library with your function app. ' +
60+
`Error: ${exception instanceof Error ? exception.message : String(exception)}`
61+
);
62+
}
3563
}
3664
}
3765

src/converters/toCoreFunctionMetadata.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,19 @@ import * as coreTypes from '@azure/functions-core';
66
import { returnBindingKey } from '../constants';
77
import { AzFuncSystemError } from '../errors';
88
import { isTrigger } from '../utils/isTrigger';
9+
import { workerSystemLog } from '../utils/workerSystemLog';
910
import { toRpcDuration } from './toRpcDuration';
1011

1112
export function toCoreFunctionMetadata(name: string, options: GenericFunctionOptions): coreTypes.FunctionMetadata {
1213
const bindings: Record<string, coreTypes.RpcBindingInfo> = {};
1314
const bindingNames: string[] = [];
14-
1515
const trigger = options.trigger;
16+
1617
bindings[trigger.name] = {
1718
...trigger,
1819
direction: 'in',
1920
type: isTrigger(trigger.type) ? trigger.type : trigger.type + 'Trigger',
21+
properties: addSdkBindingsFlag(options.trigger?.sdkBinding, name, trigger.type, trigger.name, false),
2022
};
2123
bindingNames.push(trigger.name);
2224

@@ -25,6 +27,7 @@ export function toCoreFunctionMetadata(name: string, options: GenericFunctionOpt
2527
bindings[input.name] = {
2628
...input,
2729
direction: 'in',
30+
properties: addSdkBindingsFlag(input?.sdkBinding, name, input.type, input.name, true),
2831
};
2932
bindingNames.push(input.name);
3033
}
@@ -74,3 +77,45 @@ export function toCoreFunctionMetadata(name: string, options: GenericFunctionOpt
7477

7578
return { name, bindings, retryOptions };
7679
}
80+
81+
/**
82+
* Adds the deferred binding flags to function bindings based on the binding configuration
83+
* @param sdkBindingType Boolean indicating if this is an SDK binding
84+
* @param functionName The name of the function for logging purposes
85+
* @param triggerType The type of the trigger or binding
86+
* @param bindingOrTriggerName The name of the trigger or binding
87+
* @param isBinding Boolean indicating if this is a binding (vs a trigger)
88+
* @returns Object with supportsDeferredBinding property set to 'true' or 'false'
89+
*/
90+
export function addSdkBindingsFlag(
91+
sdkBindingType?: boolean | unknown,
92+
functionName?: string,
93+
triggerType?: string,
94+
bindingOrTriggerName?: string,
95+
isBinding?: boolean
96+
): { [key: string]: string } {
97+
// Ensure that trigger type is valid and supported
98+
if (sdkBindingType !== undefined && sdkBindingType === true) {
99+
const entityType = isBinding ? 'binding' : 'trigger';
100+
101+
// Create structured JSON log entry
102+
const logData = {
103+
operation: 'EnableDeferredBinding',
104+
properties: {
105+
functionName: functionName || 'unknown',
106+
entityType: entityType,
107+
triggerType: triggerType || 'unknown',
108+
bindingOrTriggerName: bindingOrTriggerName || 'unknown',
109+
supportsDeferredBinding: true,
110+
},
111+
message: `Enabled Deferred Binding of type '${triggerType || 'unknown'}' for function '${
112+
functionName || 'unknown'
113+
}'`,
114+
};
115+
// Log both the structured data
116+
workerSystemLog('information', JSON.stringify(logData));
117+
return { supportsDeferredBinding: 'true' };
118+
}
119+
120+
return { supportsDeferredBinding: 'false' };
121+
}

src/converters/toMcpToolTriggerOptionsToRpc.ts

Lines changed: 21 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT License.
33

44
import { McpToolProperty, McpToolTriggerOptions, McpToolTriggerOptionsToRpc } from '../../types';
5+
import { normalizeToolProperties } from '../utils/toolProperties';
56

67
// Copyright (c) .NET Foundation. All rights reserved.
78
// Licensed under the MIT License.
@@ -21,129 +22,33 @@ export function converToMcpToolTriggerOptionsToRpc(
2122
description: mcpToolTriggerOptions.description,
2223
};
2324

24-
// Check for null or undefined toolProperties
25-
if (!mcpToolTriggerOptions?.toolProperties) {
26-
return {
27-
...baseResult,
28-
toolProperties: JSON.stringify([]), // Default to an empty array
29-
};
30-
}
31-
32-
// Check if toolProperties is an array of McpToolProperty objects
33-
if (Array.isArray(mcpToolTriggerOptions.toolProperties)) {
34-
const isValid = mcpToolTriggerOptions.toolProperties.every(isMcpToolProperty);
35-
if (isValid) {
36-
return {
37-
...baseResult,
38-
toolProperties: JSON.stringify(mcpToolTriggerOptions.toolProperties),
39-
};
40-
} else {
41-
throw new Error(
42-
'Invalid toolProperties: Array contains invalid McpToolProperty, please validate the parameters.'
43-
);
25+
// Try to normalize tool properties first (handles both array and fluent formats)
26+
let normalizedProperties: McpToolProperty[] | undefined;
27+
try {
28+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
29+
normalizedProperties = normalizeToolProperties(mcpToolTriggerOptions.toolProperties);
30+
} catch (error) {
31+
// Re-throw validation errors from normalizeToolProperties
32+
if (
33+
error instanceof Error &&
34+
(error.message.includes('Property type is required') ||
35+
error.message.includes('Property type must be specified'))
36+
) {
37+
throw error;
4438
}
39+
normalizedProperties = undefined;
4540
}
4641

47-
// Handle cases where toolProperties is an object (e.g., Zod schema)
48-
if (typeof mcpToolTriggerOptions.toolProperties === 'object') {
49-
// Define the type of the ZodObject shape and ZodPropertyDef
50-
type ZodPropertyDef = {
51-
description?: string;
52-
typeName: string;
53-
};
54-
type ZodObjectShape = Record<string, { _def: ZodPropertyDef }>;
55-
56-
// Define the type of the toolProperties object
57-
type ToolProperties =
58-
| {
59-
_def?: {
60-
typeName?: string;
61-
};
62-
shape?: ZodObjectShape;
63-
}
64-
| Record<string, unknown>;
65-
66-
let isZodObject = false;
67-
68-
const toolProperties = mcpToolTriggerOptions.toolProperties as ToolProperties;
69-
70-
// Check if the object is a ZodObject
71-
if ((toolProperties?._def as { typeName?: string })?.typeName === 'ZodObject') {
72-
isZodObject = true;
73-
}
74-
75-
// Check if shape is a valid ZodObject shape
76-
const shape: ZodObjectShape | Record<string, unknown> = isZodObject
77-
? (toolProperties as { shape: ZodObjectShape }).shape
78-
: toolProperties;
79-
80-
// Extract properties from the ZodObject shape
81-
const result = Object.keys(shape).map((propertyName) => {
82-
const property = shape[propertyName] as { _def: ZodPropertyDef };
83-
const description = property?._def?.description || '';
84-
const propertyType = getPropertyType(property?._def?.typeName?.toLowerCase() || 'unknown'); // Extract type name or default to "unknown"
85-
86-
return {
87-
propertyName,
88-
propertyType,
89-
description,
90-
};
91-
});
92-
42+
// If we successfully normalized the properties, use them
43+
if (normalizedProperties !== undefined) {
9344
return {
9445
...baseResult,
95-
toolProperties: JSON.stringify(result),
46+
toolProperties: JSON.stringify(normalizedProperties),
9647
};
9748
}
98-
// Handle cases where toolProperties is not an array
99-
throw new Error('Invalid toolProperties: Expected an array of McpToolProperty objects or zod objects.');
100-
}
101-
102-
// Helper function to infer property type from zod schema
103-
function getPropertyType(zodType: string): string {
104-
switch (zodType) {
105-
case 'zodnumber':
106-
return 'number';
107-
case 'zodstring':
108-
return 'string';
109-
case 'zodboolean':
110-
return 'boolean';
111-
case 'zodarray':
112-
return 'array';
113-
case 'zodobject':
114-
return 'object';
115-
case 'zodbigint':
116-
return 'long';
117-
case 'zoddate':
118-
return 'DateTime';
119-
case 'zodtuple':
120-
return 'Tuple';
121-
default:
122-
console.warn(`Unknown zod type: ${zodType}`);
123-
return 'unknown';
124-
}
125-
}
12649

127-
/**
128-
* Type guard to check if a given object is of type McpToolProperty.
129-
*
130-
* @param property - The object to check.
131-
* @returns True if the object is of type McpToolProperty, otherwise false.
132-
*
133-
* This function ensures that the object:
134-
* - Is not null and is of type 'object'.
135-
* - Contains the required properties: 'propertyName', 'propertyValue', and 'description'.
136-
* - Each of these properties is of the correct type (string).
137-
*/
138-
function isMcpToolProperty(property: unknown): property is McpToolProperty {
139-
return (
140-
typeof property === 'object' &&
141-
property !== null &&
142-
'propertyName' in property &&
143-
'propertyType' in property &&
144-
'description' in property &&
145-
typeof (property as McpToolProperty).propertyName === 'string' &&
146-
typeof (property as McpToolProperty).propertyType === 'string' &&
147-
typeof (property as McpToolProperty).description === 'string'
50+
// Handle cases where toolProperties is not an array
51+
throw new Error(
52+
`Invalid toolProperties for tool '${mcpToolTriggerOptions.toolName}': Expected an array of McpToolProperty or ToolProps objects or ToolProps need a type defined.`
14853
);
14954
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export { InvocationContext } from './InvocationContext';
1616
export * as output from './output';
1717
export * as trigger from './trigger';
1818
export { Disposable } from './utils/Disposable';
19+
export { arg } from './utils/toolProperties';
1920

2021
export enum SqlChangeOperation {
2122
Insert = 0,

0 commit comments

Comments
 (0)