powerplatform-mcp
Version:
PowerPlatform Model Context Protocol server
452 lines (451 loc) • 22.1 kB
JavaScript
/**
* PluginService
*
* Service for plugin assemblies, types, steps, images, trace logs, and step registration.
*/
export class PluginService {
client;
constructor(client) {
this.client = client;
}
/**
* Get all plugin assemblies in the environment
*/
async getPluginAssemblies(includeManaged = false, maxRecords = 100) {
const managedFilter = includeManaged ? '' : '$filter=ismanaged eq false&';
const assemblies = await this.client.get(`api/data/v9.2/pluginassemblies?${managedFilter}$select=pluginassemblyid,name,version,culture,publickeytoken,isolationmode,sourcetype,major,minor,createdon,modifiedon,ismanaged,ishidden&$expand=modifiedby($select=fullname)&$orderby=name&$top=${maxRecords}`);
// Filter out hidden assemblies and format results
const formattedAssemblies = assemblies.value
.filter((assembly) => {
const isHidden = assembly.ishidden?.Value !== undefined
? assembly.ishidden.Value
: assembly.ishidden;
return !isHidden;
})
.map((assembly) => ({
pluginassemblyid: assembly.pluginassemblyid,
name: assembly.name,
version: assembly.version,
isolationMode: assembly.isolationmode === 1
? 'None'
: assembly.isolationmode === 2
? 'Sandbox'
: 'External',
isManaged: assembly.ismanaged,
modifiedOn: assembly.modifiedon,
modifiedBy: assembly.modifiedby?.fullname,
major: assembly.major,
minor: assembly.minor,
}));
return {
totalCount: formattedAssemblies.length,
assemblies: formattedAssemblies,
};
}
/**
* Get a plugin assembly by name with all related plugin types, steps, and images
*/
async getPluginAssemblyComplete(assemblyName, includeDisabled = false) {
// Get the plugin assembly
const assemblies = await this.client.get(`api/data/v9.2/pluginassemblies?$filter=name eq '${assemblyName}'&$select=pluginassemblyid,name,version,culture,publickeytoken,isolationmode,sourcetype,major,minor,createdon,modifiedon,ismanaged,ishidden,description&$expand=modifiedby($select=fullname)`);
if (!assemblies.value || assemblies.value.length === 0) {
throw new Error(`Plugin assembly '${assemblyName}' not found`);
}
const assembly = assemblies.value[0];
const assemblyId = assembly.pluginassemblyid;
// Get plugin types
const pluginTypes = await this.client.get(`api/data/v9.2/plugintypes?$filter=_pluginassemblyid_value eq ${assemblyId}&$select=plugintypeid,typename,friendlyname,name,assemblyname,description,workflowactivitygroupname`);
// Get all steps for each plugin type
const pluginTypeIds = pluginTypes.value.map((pt) => pt.plugintypeid);
let allSteps = [];
if (pluginTypeIds.length > 0) {
const statusFilter = includeDisabled ? '' : ' and statuscode eq 1';
const typeFilter = pluginTypeIds
.map((id) => `_plugintypeid_value eq ${id}`)
.join(' or ');
const steps = await this.client.get(`api/data/v9.2/sdkmessageprocessingsteps?$filter=(${typeFilter})${statusFilter}&$select=sdkmessageprocessingstepid,name,stage,mode,rank,statuscode,asyncautodelete,filteringattributes,supporteddeployment,configuration,description,invocationsource,_plugintypeid_value,_sdkmessagefilterid_value,_impersonatinguserid_value,_eventhandler_value&$expand=sdkmessageid($select=name),plugintypeid($select=typename),impersonatinguserid($select=fullname),modifiedby($select=fullname),sdkmessagefilterid($select=primaryobjecttypecode)&$orderby=stage,rank`);
allSteps = steps.value;
}
// Get all images for these steps
const stepIds = allSteps.map((s) => s.sdkmessageprocessingstepid);
let allImages = [];
if (stepIds.length > 0) {
const imageFilter = stepIds
.map((id) => `_sdkmessageprocessingstepid_value eq ${id}`)
.join(' or ');
const images = await this.client.get(`api/data/v9.2/sdkmessageprocessingstepimages?$filter=${imageFilter}&$select=sdkmessageprocessingstepimageid,name,imagetype,messagepropertyname,entityalias,attributes,_sdkmessageprocessingstepid_value`);
allImages = images.value;
}
// Attach images to their respective steps
const stepsWithImages = allSteps.map((step) => ({
...step,
images: allImages.filter((img) => img._sdkmessageprocessingstepid_value ===
step.sdkmessageprocessingstepid),
}));
// Validation checks
const validation = {
hasDisabledSteps: allSteps.some((s) => s.statuscode !== 1),
hasAsyncSteps: allSteps.some((s) => s.mode === 1),
hasSyncSteps: allSteps.some((s) => s.mode === 0),
stepsWithoutFilteringAttributes: stepsWithImages
.filter((s) => {
const sdkmsg = s.sdkmessageid;
const msgName = sdkmsg?.name;
return ((msgName === 'Update' || msgName === 'Delete') &&
!s.filteringattributes);
})
.map((s) => s.name),
stepsWithoutImages: stepsWithImages
.filter((s) => {
const sdkmsg = s.sdkmessageid;
const msgName = sdkmsg?.name;
return (s.images.length === 0 &&
(msgName === 'Update' || msgName === 'Delete'));
})
.map((s) => s.name),
potentialIssues: [],
};
if (validation.stepsWithoutFilteringAttributes.length > 0) {
validation.potentialIssues.push(`${validation.stepsWithoutFilteringAttributes.length} Update/Delete steps without filtering attributes (performance concern)`);
}
if (validation.stepsWithoutImages.length > 0) {
validation.potentialIssues.push(`${validation.stepsWithoutImages.length} Update/Delete steps without images (may need entity data)`);
}
return {
assembly,
pluginTypes: pluginTypes.value,
steps: stepsWithImages,
validation,
};
}
/**
* Get all plugins that execute on a specific entity
*/
async getEntityPluginPipeline(entityName, messageFilter, includeDisabled = false) {
const statusFilter = includeDisabled ? '' : ' and statuscode eq 1';
const msgFilter = messageFilter
? ` and sdkmessageid/name eq '${messageFilter}'`
: '';
const steps = await this.client.get(`api/data/v9.2/sdkmessageprocessingsteps?$filter=sdkmessagefilterid/primaryobjecttypecode eq '${entityName}'${statusFilter}${msgFilter}&$select=sdkmessageprocessingstepid,name,stage,mode,rank,statuscode,asyncautodelete,filteringattributes,supporteddeployment,configuration,description,_plugintypeid_value,_sdkmessagefilterid_value,_impersonatinguserid_value&$expand=sdkmessageid($select=name),plugintypeid($select=typename),impersonatinguserid($select=fullname),sdkmessagefilterid($select=primaryobjecttypecode)&$orderby=stage,rank`);
// Get assembly information for each plugin type
const pluginTypeIds = [
...new Set(steps.value
.map((s) => s._plugintypeid_value)
.filter((id) => id != null)),
];
const assemblyMap = new Map();
for (const typeId of pluginTypeIds) {
const pluginType = await this.client.get(`api/data/v9.2/plugintypes(${typeId})?$expand=pluginassemblyid($select=name,version)`);
assemblyMap.set(typeId, pluginType.pluginassemblyid);
}
// Get images for all steps
const stepIds = steps.value.map((s) => s.sdkmessageprocessingstepid);
let allImages = [];
if (stepIds.length > 0) {
const imageFilter = stepIds
.map((id) => `_sdkmessageprocessingstepid_value eq ${id}`)
.join(' or ');
const images = await this.client.get(`api/data/v9.2/sdkmessageprocessingstepimages?$filter=${imageFilter}&$select=sdkmessageprocessingstepimageid,name,imagetype,messagepropertyname,entityalias,attributes,_sdkmessageprocessingstepid_value`);
allImages = images.value;
}
// Format steps
const formattedSteps = steps.value.map((step) => {
const assembly = assemblyMap.get(step._plugintypeid_value);
const images = allImages.filter((img) => img._sdkmessageprocessingstepid_value ===
step.sdkmessageprocessingstepid);
return {
sdkmessageprocessingstepid: step.sdkmessageprocessingstepid,
name: step.name,
stage: step.stage,
stageName: step.stage === 10
? 'PreValidation'
: step.stage === 20
? 'PreOperation'
: 'PostOperation',
mode: step.mode,
modeName: step.mode === 0 ? 'Synchronous' : 'Asynchronous',
rank: step.rank,
message: step.sdkmessageid?.name,
pluginType: step.plugintypeid?.typename,
assemblyName: assembly?.name,
assemblyVersion: assembly?.version,
filteringAttributes: step.filteringattributes
? step.filteringattributes.split(',')
: [],
statuscode: step.statuscode,
enabled: step.statuscode === 1,
deployment: step.supporteddeployment === 0
? 'Server'
: step.supporteddeployment === 1
? 'Offline'
: 'Both',
impersonatingUser: step.impersonatinguserid
?.fullname,
hasPreImage: images.some((img) => img.imagetype === 0 || img.imagetype === 2),
hasPostImage: images.some((img) => img.imagetype === 1 || img.imagetype === 2),
images,
};
});
// Organize by message
const messageGroups = new Map();
formattedSteps.forEach((step) => {
if (!messageGroups.has(step.message)) {
messageGroups.set(step.message, {
messageName: step.message,
stages: {
preValidation: [],
preOperation: [],
postOperation: [],
},
});
}
const msg = messageGroups.get(step.message);
if (step.stage === 10)
msg.stages.preValidation.push(step);
else if (step.stage === 20)
msg.stages.preOperation.push(step);
else if (step.stage === 40)
msg.stages.postOperation.push(step);
});
return {
entity: entityName,
messages: Array.from(messageGroups.values()),
steps: formattedSteps,
executionOrder: formattedSteps.map((s) => s.name),
};
}
/**
* Get plugin trace logs with filtering
*/
async getPluginTraceLogs(options) {
const { entityName, messageName, correlationId, pluginStepId, exceptionOnly = false, hoursBack = 24, maxRecords = 50, } = options;
// Build filter
const filters = [];
const dateThreshold = new Date();
dateThreshold.setHours(dateThreshold.getHours() - hoursBack);
filters.push(`createdon gt ${dateThreshold.toISOString()}`);
if (entityName)
filters.push(`primaryentity eq '${entityName}'`);
if (messageName)
filters.push(`messagename eq '${messageName}'`);
if (correlationId)
filters.push(`correlationid eq '${correlationId}'`);
if (pluginStepId)
filters.push(`_sdkmessageprocessingstepid_value eq ${pluginStepId}`);
if (exceptionOnly)
filters.push(`exceptiondetails ne null`);
const filterString = filters.join(' and ');
const logs = await this.client.get(`api/data/v9.2/plugintracelogs?$filter=${filterString}&$orderby=createdon desc&$top=${maxRecords}`);
// Parse logs for better readability
const parsedLogs = logs.value.map((log) => ({
...log,
modeName: log.mode === 0 ? 'Synchronous' : 'Asynchronous',
operationTypeName: this.getOperationTypeName(log.operationtype),
parsed: {
hasException: !!log.exceptiondetails,
exceptionType: log.exceptiondetails
? this.extractExceptionType(log.exceptiondetails)
: null,
exceptionMessage: log.exceptiondetails
? this.extractExceptionMessage(log.exceptiondetails)
: null,
stackTrace: log.exceptiondetails,
},
}));
return {
totalCount: parsedLogs.length,
logs: parsedLogs,
};
}
/**
* Get all plugin SDK message processing steps across all assemblies.
* Useful for cross-environment comparison of plugin registrations.
*/
async getAllPluginSteps(options) {
// Dataverse OData's $top is a hard cap that does NOT emit @odata.nextLink; proper
// pagination uses the `Prefer: odata.maxpagesize=N` header. Default raised well above
// the old 500 cap — orgs routinely have 3000-5000+ steps (most of them OOTB).
const { includeDisabled = true, maxRecords = 10000 } = options ?? {};
const statusFilter = includeDisabled ? '' : 'statuscode eq 1 and ';
const pageSize = Math.min(maxRecords, 500);
const allRecords = [];
let nextUrl = `api/data/v9.2/sdkmessageprocessingsteps?$filter=${statusFilter}ishidden/Value eq false&$select=sdkmessageprocessingstepid,name,stage,mode,rank,statuscode,filteringattributes,ismanaged,modifiedon&$expand=sdkmessageid($select=name),plugintypeid($select=typename,assemblyname)&$orderby=name`;
const preferHeader = { Prefer: `odata.maxpagesize=${pageSize}` };
while (nextUrl && allRecords.length < maxRecords) {
const page = await this.client.get(nextUrl, preferHeader);
allRecords.push(...page.value);
const odataNext = page['@odata.nextLink'];
if (odataNext && allRecords.length < maxRecords) {
const baseUrl = this.client.organizationUrl;
nextUrl = odataNext.startsWith(baseUrl)
? odataNext.substring(baseUrl.length + 1)
: odataNext;
}
else {
nextUrl = null;
}
}
const trimmed = allRecords.slice(0, maxRecords);
const steps = trimmed.map((step) => {
const sdkmsg = step.sdkmessageid;
const pluginType = step.plugintypeid;
return {
stepId: step.sdkmessageprocessingstepid,
name: step.name,
messageName: sdkmsg?.name ?? 'Unknown',
stage: step.stage,
stageName: step.stage === 10 ? 'PreValidation'
: step.stage === 20 ? 'PreOperation'
: 'PostOperation',
mode: step.mode,
modeName: step.mode === 0 ? 'Synchronous' : 'Asynchronous',
statuscode: step.statuscode,
enabled: step.statuscode === 1,
rank: step.rank,
filteringAttributes: step.filteringattributes ?? null,
pluginTypeName: pluginType?.typename ?? null,
assemblyName: pluginType?.assemblyname ?? null,
isManaged: step.ismanaged,
modifiedOn: step.modifiedon,
};
});
return {
totalCount: steps.length,
steps,
};
}
/**
* Look up a plugin type by its fully qualified class name (e.g. 'miejskinajem.Plugins.Hospitable.SyncProperties').
* Returns the plugin type record or null if not found.
*/
async getPluginType(typeName) {
const result = await this.client.get(`api/data/v9.2/plugintypes?$filter=typename eq '${typeName}'&$select=plugintypeid,typename,friendlyname,name,assemblyname&$top=1`);
return result.value && result.value.length > 0 ? result.value[0] : null;
}
/**
* Get plugin packages in the environment.
*/
async getPluginPackages(includeManaged = false, maxRecords = 100) {
const managedFilter = includeManaged ? '' : 'ismanaged eq false and ';
const result = await this.client.get(`api/data/v9.2/pluginpackages?$filter=${managedFilter}statecode eq 0&$select=pluginpackageid,name,uniquename,version,ismanaged,modifiedon&$orderby=name&$top=${maxRecords}`);
return {
totalCount: result.value.length,
packages: result.value,
};
}
/**
* Register a new plugin package by uploading a .nupkg file.
* @param name Display name for the package
* @param uniqueName Unique name for the package
* @param version Package version (e.g. "1.0.0")
* @param content Base64-encoded .nupkg file content
* @param solutionName Optional solution to add the package to
*/
async registerPluginPackage(options) {
const body = {
name: options.name,
uniquename: options.uniqueName,
version: options.version,
content: options.content,
};
const headers = options.solutionName ? { 'MSCRM.SolutionUniqueName': options.solutionName } : undefined;
const result = await this.client.post('api/data/v9.2/pluginpackages', body, headers);
return { pluginPackageId: result?.entityId ?? 'created' };
}
/**
* Update an existing plugin package with new content.
* @param pluginPackageId The ID of the existing plugin package
* @param content Base64-encoded .nupkg file content
* @param version Optional new version string
*/
async updatePluginPackage(options) {
const body = {
content: options.content,
};
if (options.version) {
body.version = options.version;
}
await this.client.patch(`api/data/v9.2/pluginpackages(${options.pluginPackageId})`, body);
}
/**
* Look up an SDK message by name (e.g. 'Create', 'Update', 'br_SyncProperties').
* Returns the message record or null if not found.
*/
async getSdkMessage(messageName) {
const result = await this.client.get(`api/data/v9.2/sdkmessages?$filter=name eq '${messageName}'&$select=sdkmessageid,name,categoryname,isactive,isprivate&$top=1`);
return result.value && result.value.length > 0 ? result.value[0] : null;
}
/**
* Register a plugin step (SDK message processing step).
* @param options Step configuration
*/
async createPluginStep(options) {
const body = {
name: options.name,
'plugintypeid@odata.bind': `/plugintypes(${options.pluginTypeId})`,
'sdkmessageid@odata.bind': `/sdkmessages(${options.sdkMessageId})`,
stage: options.stage,
mode: options.mode,
rank: options.rank ?? 1,
supporteddeployment: options.supportedDeployment ?? 0,
};
if (options.description)
body.description = options.description;
if (options.configuration)
body.configuration = options.configuration;
if (options.sdkMessageFilterId) {
body['sdkmessagefilterid@odata.bind'] = `/sdkmessagefilters(${options.sdkMessageFilterId})`;
}
const headers = options.solutionName ? { 'MSCRM.SolutionUniqueName': options.solutionName } : undefined;
const result = await this.client.post('api/data/v9.2/sdkmessageprocessingsteps', body, headers);
return { stepId: result?.entityId ?? 'created' };
}
/**
* Register a PreImage or PostImage on an existing SDK message processing step.
* Images let a plugin read the row state before/after the operation.
* @param options Image configuration
*/
async createPluginStepImage(options) {
const name = options.name ?? 'PreImage';
const body = {
name,
entityalias: options.entityAlias ?? name,
imagetype: options.imageType ?? 0,
messagepropertyname: options.messagePropertyName ?? 'Target',
'sdkmessageprocessingstepid@odata.bind': `/sdkmessageprocessingsteps(${options.stepId})`,
};
if (options.attributes) {
body.attributes = options.attributes;
}
const result = await this.client.post('api/data/v9.2/sdkmessageprocessingstepimages', body);
return { imageId: result?.entityId ?? 'created' };
}
getOperationTypeName(operationType) {
const types = {
0: 'None',
1: 'Create',
2: 'Update',
3: 'Delete',
4: 'Retrieve',
5: 'RetrieveMultiple',
6: 'Associate',
7: 'Disassociate',
};
return types[operationType] || 'Unknown';
}
extractExceptionType(exceptionDetails) {
const match = exceptionDetails.match(/^([^:]+):/);
return match ? match[1].trim() : null;
}
extractExceptionMessage(exceptionDetails) {
const lines = exceptionDetails.split('\n');
if (lines.length > 0) {
const firstLine = lines[0];
const colonIndex = firstLine.indexOf(':');
if (colonIndex > 0) {
return firstLine.substring(colonIndex + 1).trim();
}
}
return null;
}
}