@mcp-consultant-tools/powerplatform-data
Version:
MCP server for PowerPlatform data CRUD operations (operational use)
1,000 lines • 108 kB
JavaScript
import { ConfidentialClientApplication } from '@azure/msal-node';
import axios from 'axios';
import { bestPracticesValidator } from './utils/bestPractices.js';
import { iconManager } from './utils/iconManager.js';
import { auditLogger } from '@mcp-consultant-tools/core';
import { rateLimiter } from './utils/rate-limiter.js';
export class PowerPlatformService {
config;
msalClient;
accessToken = null;
tokenExpirationTime = 0;
constructor(config) {
this.config = config;
// Initialize MSAL client
this.msalClient = new ConfidentialClientApplication({
auth: {
clientId: this.config.clientId,
clientSecret: this.config.clientSecret,
authority: `https://login.microsoftonline.com/${this.config.tenantId}`,
}
});
}
/**
* Get an access token for the PowerPlatform API
*/
async getAccessToken() {
const currentTime = Date.now();
// If we have a token that isn't expired, return it
if (this.accessToken && this.tokenExpirationTime > currentTime) {
return this.accessToken;
}
try {
// Get a new token
const result = await this.msalClient.acquireTokenByClientCredential({
scopes: [`${this.config.organizationUrl}/.default`],
});
if (!result || !result.accessToken) {
throw new Error('Failed to acquire access token');
}
this.accessToken = result.accessToken;
// Set expiration time (subtract 5 minutes to refresh early)
if (result.expiresOn) {
this.tokenExpirationTime = result.expiresOn.getTime() - (5 * 60 * 1000);
}
return this.accessToken;
}
catch (error) {
console.error('Error acquiring access token:', error);
throw new Error('Authentication failed');
}
}
/**
* Make an authenticated request to the PowerPlatform API
* Extended to support all HTTP methods for write operations
*/
async makeRequest(endpoint, method = 'GET', data, additionalHeaders) {
try {
const token = await this.getAccessToken();
const headers = {
'Authorization': `Bearer ${token}`,
'Accept': 'application/json',
'OData-MaxVersion': '4.0',
'OData-Version': '4.0',
...additionalHeaders
};
// Add Content-Type for POST/PUT/PATCH requests
if (method !== 'GET' && method !== 'DELETE' && data) {
headers['Content-Type'] = 'application/json';
}
const response = await axios({
method,
url: `${this.config.organizationUrl}/${endpoint}`,
headers,
data
});
return response.data;
}
catch (error) {
const errorDetails = error.response?.data?.error || error.response?.data || error.message;
console.error('PowerPlatform API request failed:', {
endpoint,
method,
status: error.response?.status,
statusText: error.response?.statusText,
error: errorDetails
});
throw new Error(`PowerPlatform API request failed: ${error.message} - ${JSON.stringify(errorDetails)}`);
}
}
/**
* Get metadata about an entity
* @param entityName The logical name of the entity
*/
async getEntityMetadata(entityName) {
const response = await this.makeRequest(`api/data/v9.2/EntityDefinitions(LogicalName='${entityName}')`);
// Remove Privileges property if it exists
if (response && typeof response === 'object' && 'Privileges' in response) {
delete response.Privileges;
}
return response;
}
/**
* Get metadata about entity attributes/fields
* @param entityName The logical name of the entity
*/
async getEntityAttributes(entityName) {
const selectProperties = [
'LogicalName',
].join(',');
// Make the request to get attributes
const response = await this.makeRequest(`api/data/v9.2/EntityDefinitions(LogicalName='${entityName}')/Attributes?$select=${selectProperties}&$filter=AttributeType ne 'Virtual'`);
if (response && response.value) {
// First pass: Filter out attributes that end with 'yominame'
response.value = response.value.filter((attribute) => {
const logicalName = attribute.LogicalName || '';
return !logicalName.endsWith('yominame');
});
// Filter out attributes that end with 'name' if there is another attribute with the same name without the 'name' suffix
const baseNames = new Set();
const namesAttributes = new Map();
for (const attribute of response.value) {
const logicalName = attribute.LogicalName || '';
if (logicalName.endsWith('name') && logicalName.length > 4) {
const baseName = logicalName.slice(0, -4); // Remove 'name' suffix
namesAttributes.set(baseName, attribute);
}
else {
// This is a potential base attribute
baseNames.add(logicalName);
}
}
// Find attributes to remove that match the pattern
const attributesToRemove = new Set();
for (const [baseName, nameAttribute] of namesAttributes.entries()) {
if (baseNames.has(baseName)) {
attributesToRemove.add(nameAttribute);
}
}
response.value = response.value.filter(attribute => !attributesToRemove.has(attribute));
}
return response;
}
/**
* Get metadata about a specific entity attribute/field
* @param entityName The logical name of the entity
* @param attributeName The logical name of the attribute
*/
async getEntityAttribute(entityName, attributeName) {
return this.makeRequest(`api/data/v9.2/EntityDefinitions(LogicalName='${entityName}')/Attributes(LogicalName='${attributeName}')`);
}
/**
* Get one-to-many relationships for an entity
* @param entityName The logical name of the entity
*/
async getEntityOneToManyRelationships(entityName) {
const selectProperties = [
'SchemaName',
'RelationshipType',
'ReferencedAttribute',
'ReferencedEntity',
'ReferencingAttribute',
'ReferencingEntity',
'ReferencedEntityNavigationPropertyName',
'ReferencingEntityNavigationPropertyName'
].join(',');
// Only filter by ReferencingAttribute in the OData query since startswith isn't supported
const response = await this.makeRequest(`api/data/v9.2/EntityDefinitions(LogicalName='${entityName}')/OneToManyRelationships?$select=${selectProperties}&$filter=ReferencingAttribute ne 'regardingobjectid'`);
// Filter the response to exclude relationships with ReferencingEntity starting with 'msdyn_' or 'adx_'
if (response && response.value) {
response.value = response.value.filter((relationship) => {
const referencingEntity = relationship.ReferencingEntity || '';
return !(referencingEntity.startsWith('msdyn_') || referencingEntity.startsWith('adx_'));
});
}
return response;
}
/**
* Get many-to-many relationships for an entity
* @param entityName The logical name of the entity
*/
async getEntityManyToManyRelationships(entityName) {
const selectProperties = [
'SchemaName',
'RelationshipType',
'Entity1LogicalName',
'Entity2LogicalName',
'Entity1IntersectAttribute',
'Entity2IntersectAttribute',
'Entity1NavigationPropertyName',
'Entity2NavigationPropertyName'
].join(',');
return this.makeRequest(`api/data/v9.2/EntityDefinitions(LogicalName='${entityName}')/ManyToManyRelationships?$select=${selectProperties}`);
}
/**
* Get all relationships (one-to-many and many-to-many) for an entity
* @param entityName The logical name of the entity
*/
async getEntityRelationships(entityName) {
const [oneToMany, manyToMany] = await Promise.all([
this.getEntityOneToManyRelationships(entityName),
this.getEntityManyToManyRelationships(entityName)
]);
return {
oneToMany,
manyToMany
};
}
/**
* Get a global option set definition by name
* @param optionSetName The name of the global option set
* @returns The global option set definition
*/
async getGlobalOptionSet(optionSetName) {
return this.makeRequest(`api/data/v9.2/GlobalOptionSetDefinitions(Name='${optionSetName}')`);
}
/**
* Get a specific record by entity name (plural) and ID
* @param entityNamePlural The plural name of the entity (e.g., 'accounts', 'contacts')
* @param recordId The GUID of the record
* @returns The record data
*/
async getRecord(entityNamePlural, recordId) {
return this.makeRequest(`api/data/v9.2/${entityNamePlural}(${recordId})`);
}
/**
* Query records using entity name (plural) and a filter expression
* @param entityNamePlural The plural name of the entity (e.g., 'accounts', 'contacts')
* @param filter OData filter expression (e.g., "name eq 'test'")
* @param maxRecords Maximum number of records to retrieve (default: 50)
* @returns Filtered list of records
*/
async queryRecords(entityNamePlural, filter, maxRecords = 50) {
return this.makeRequest(`api/data/v9.2/${entityNamePlural}?$filter=${encodeURIComponent(filter)}&$top=${maxRecords}`);
}
/**
* Create a new record in Dataverse
* @param entityNamePlural The plural name of the entity (e.g., 'accounts', 'contacts')
* @param data Record data as JSON object (field names must match logical names)
* @returns Created record with ID and OData context
*/
async createRecord(entityNamePlural, data) {
const timer = auditLogger.startTimer();
try {
// Validate data is not empty
if (!data || Object.keys(data).length === 0) {
throw new Error('Record data cannot be empty');
}
// Make POST request to create record
const response = await this.makeRequest(`api/data/v9.2/${entityNamePlural}`, 'POST', data, {
'Prefer': 'return=representation', // Return the created record
});
// Audit logging
auditLogger.log({
operation: 'create-record',
operationType: 'CREATE',
componentType: 'Record',
componentName: entityNamePlural,
success: true,
executionTimeMs: timer(),
});
return response;
}
catch (error) {
// Audit failed operation
auditLogger.log({
operation: 'create-record',
operationType: 'CREATE',
componentType: 'Record',
componentName: entityNamePlural,
success: false,
error: error.message,
executionTimeMs: timer(),
});
throw error;
}
}
/**
* Update an existing record in Dataverse
* @param entityNamePlural The plural name of the entity (e.g., 'accounts', 'contacts')
* @param recordId The GUID of the record to update
* @param data Partial record data to update (only fields being changed)
* @returns Updated record (if Prefer header used) or void
*/
async updateRecord(entityNamePlural, recordId, data) {
const timer = auditLogger.startTimer();
try {
// Validate data is not empty
if (!data || Object.keys(data).length === 0) {
throw new Error('Update data cannot be empty');
}
// Validate recordId is a valid GUID
const guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!guidRegex.test(recordId)) {
throw new Error(`Invalid record ID format: ${recordId}. Must be a valid GUID.`);
}
// Make PATCH request to update record
const response = await this.makeRequest(`api/data/v9.2/${entityNamePlural}(${recordId})`, 'PATCH', data, {
'Prefer': 'return=representation', // Return the updated record
});
// Audit logging
auditLogger.log({
operation: 'update-record',
operationType: 'UPDATE',
componentType: 'Record',
componentName: `${entityNamePlural}(${recordId})`,
success: true,
executionTimeMs: timer(),
});
return response;
}
catch (error) {
// Audit failed operation
auditLogger.log({
operation: 'update-record',
operationType: 'UPDATE',
componentType: 'Record',
componentName: `${entityNamePlural}(${recordId})`,
success: false,
error: error.message,
executionTimeMs: timer(),
});
throw error;
}
}
/**
* Delete a record from Dataverse
* @param entityNamePlural The plural name of the entity (e.g., 'accounts', 'contacts')
* @param recordId The GUID of the record to delete
* @returns Void (successful deletion returns 204 No Content)
*/
async deleteRecord(entityNamePlural, recordId) {
const timer = auditLogger.startTimer();
try {
// Validate recordId is a valid GUID
const guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!guidRegex.test(recordId)) {
throw new Error(`Invalid record ID format: ${recordId}. Must be a valid GUID.`);
}
// Make DELETE request
await this.makeRequest(`api/data/v9.2/${entityNamePlural}(${recordId})`, 'DELETE');
// Audit logging
auditLogger.log({
operation: 'delete-record',
operationType: 'DELETE',
componentType: 'Record',
componentName: `${entityNamePlural}(${recordId})`,
success: true,
executionTimeMs: timer(),
});
}
catch (error) {
// Audit failed operation
auditLogger.log({
operation: 'delete-record',
operationType: 'DELETE',
componentType: 'Record',
componentName: `${entityNamePlural}(${recordId})`,
success: false,
error: error.message,
executionTimeMs: timer(),
});
throw error;
}
}
/**
* Get all plugin assemblies in the environment
* @param includeManaged Include managed assemblies (default: false)
* @param maxRecords Maximum number of assemblies to return (default: 100)
* @returns List of plugin assemblies with basic information
*/
async getPluginAssemblies(includeManaged = false, maxRecords = 100) {
const managedFilter = includeManaged ? '' : '$filter=ismanaged eq false&';
const assemblies = await this.makeRequest(`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 the results with more readable properties
// Note: ishidden is a ManagedProperty object with a Value property
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
* @param assemblyName The name of the plugin assembly
* @param includeDisabled Include disabled steps (default: false)
* @returns Complete plugin assembly information with validation
*/
async getPluginAssemblyComplete(assemblyName, includeDisabled = false) {
// Get the plugin assembly (excluding content_binary which is large and not useful for review)
const assemblies = await this.makeRequest(`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 for this assembly
const pluginTypes = await this.makeRequest(`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';
// Build filter for all plugin type IDs
const typeFilter = pluginTypeIds.map((id) => `_plugintypeid_value eq ${id}`).join(' or ');
const steps = await this.makeRequest(`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) {
// Build filter for all step IDs
const imageFilter = stepIds.map((id) => `_sdkmessageprocessingstepid_value eq ${id}`).join(' or ');
const images = await this.makeRequest(`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) => (s.sdkmessageid?.name === 'Update' || s.sdkmessageid?.name === 'Delete') && !s.filteringattributes)
.map((s) => s.name),
stepsWithoutImages: stepsWithImages
.filter((s) => s.images.length === 0 && (s.sdkmessageid?.name === 'Update' || s.sdkmessageid?.name === 'Delete'))
.map((s) => s.name),
potentialIssues: []
};
// Add potential issues
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, organized by message and execution order
* @param entityName The logical name of the entity
* @param messageFilter Optional filter by message name (e.g., "Create", "Update")
* @param includeDisabled Include disabled steps (default: false)
* @returns Complete plugin pipeline for the entity
*/
async getEntityPluginPipeline(entityName, messageFilter, includeDisabled = false) {
const statusFilter = includeDisabled ? '' : ' and statuscode eq 1';
const msgFilter = messageFilter ? ` and sdkmessageid/name eq '${messageFilter}'` : '';
// Get all steps for this entity
const steps = await this.makeRequest(`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 (filter out nulls)
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.makeRequest(`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.makeRequest(`api/data/v9.2/sdkmessageprocessingstepimages?$filter=${imageFilter}&$select=sdkmessageprocessingstepimageid,name,imagetype,messagepropertyname,entityalias,attributes,_sdkmessageprocessingstepid_value`);
allImages = images.value;
}
// Format steps with all information
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: 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
* @param options Filtering options for trace logs
* @returns Filtered trace logs with parsed exception details
*/
async getPluginTraceLogs(options) {
const { entityName, messageName, correlationId, pluginStepId, exceptionOnly = false, hoursBack = 24, maxRecords = 50 } = options;
// Build filter
const filters = [];
// Date filter
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.makeRequest(`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
};
}
// Helper methods for trace log parsing
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;
}
/**
* Get all Power Automate flows (cloud flows) in the environment
* @param activeOnly Only return activated flows (default: false)
* @param maxRecords Maximum number of flows to return (default: 100)
* @returns List of Power Automate flows with basic information
*/
async getFlows(activeOnly = false, maxRecords = 100) {
// Category 5 = Modern Flow (Power Automate cloud flows)
// StateCode: 0=Draft, 1=Activated, 2=Suspended
// Type: 1=Definition, 2=Activation
const stateFilter = activeOnly ? ' and statecode eq 1' : '';
const flows = await this.makeRequest(`api/data/v9.2/workflows?$filter=category eq 5${stateFilter}&$select=workflowid,name,statecode,statuscode,description,createdon,modifiedon,type,ismanaged,iscrmuiworkflow,primaryentity,clientdata&$expand=modifiedby($select=fullname)&$orderby=modifiedon desc&$top=${maxRecords}`);
// Format the results for better readability
const formattedFlows = flows.value.map((flow) => ({
workflowid: flow.workflowid,
name: flow.name,
description: flow.description,
state: flow.statecode === 0 ? 'Draft' : flow.statecode === 1 ? 'Activated' : 'Suspended',
statecode: flow.statecode,
statuscode: flow.statuscode,
type: flow.type === 1 ? 'Definition' : flow.type === 2 ? 'Activation' : 'Template',
primaryEntity: flow.primaryentity,
isManaged: flow.ismanaged,
ownerId: flow._ownerid_value,
modifiedOn: flow.modifiedon,
modifiedBy: flow.modifiedby?.fullname,
createdOn: flow.createdon,
hasDefinition: !!flow.clientdata
}));
return {
totalCount: formattedFlows.length,
flows: formattedFlows
};
}
/**
* Get a specific Power Automate flow with its complete definition
* @param flowId The GUID of the flow (workflowid)
* @returns Complete flow information including the flow definition JSON
*/
async getFlowDefinition(flowId) {
const flow = await this.makeRequest(`api/data/v9.2/workflows(${flowId})?$select=workflowid,name,statecode,statuscode,description,createdon,modifiedon,type,category,ismanaged,iscrmuiworkflow,primaryentity,clientdata,xaml&$expand=modifiedby($select=fullname),createdby($select=fullname)`);
// Parse the clientdata (flow definition) if it exists
let flowDefinition = null;
if (flow.clientdata) {
try {
flowDefinition = JSON.parse(flow.clientdata);
}
catch (error) {
console.error('Failed to parse flow definition JSON:', error);
flowDefinition = { parseError: 'Failed to parse flow definition', raw: flow.clientdata };
}
}
return {
workflowid: flow.workflowid,
name: flow.name,
description: flow.description,
state: flow.statecode === 0 ? 'Draft' : flow.statecode === 1 ? 'Activated' : 'Suspended',
statecode: flow.statecode,
statuscode: flow.statuscode,
type: flow.type === 1 ? 'Definition' : flow.type === 2 ? 'Activation' : 'Template',
category: flow.category,
primaryEntity: flow.primaryentity,
isManaged: flow.ismanaged,
ownerId: flow._ownerid_value,
createdOn: flow.createdon,
createdBy: flow.createdby?.fullname,
modifiedOn: flow.modifiedon,
modifiedBy: flow.modifiedby?.fullname,
flowDefinition: flowDefinition
};
}
/**
* Get flow run history for a specific Power Automate flow
* @param flowId The GUID of the flow (workflowid)
* @param maxRecords Maximum number of runs to return (default: 100)
* @returns List of flow runs with status, start time, duration, and error details
*/
async getFlowRuns(flowId, maxRecords = 100) {
// Flow runs are stored in the flowruns entity (not flowsession)
// Status: "Succeeded", "Failed", "Faulted", "TimedOut", "Cancelled", "Running", etc.
const flowRuns = await this.makeRequest(`api/data/v9.2/flowruns?$filter=_workflow_value eq ${flowId}&$select=flowrunid,name,status,starttime,endtime,duration,errormessage,errorcode,triggertype&$orderby=starttime desc&$top=${maxRecords}`);
// Format the results for better readability
const formattedRuns = flowRuns.value.map((run) => {
// Parse error message if it's JSON
let parsedError = run.errormessage;
if (run.errormessage) {
try {
parsedError = JSON.parse(run.errormessage);
}
catch (e) {
// Keep as string if not valid JSON
}
}
return {
flowrunid: run.flowrunid,
name: run.name,
status: run.status,
startedOn: run.starttime,
completedOn: run.endtime,
duration: run.duration,
errorMessage: parsedError || null,
errorCode: run.errorcode || null,
triggerType: run.triggertype || null
};
});
return {
flowId: flowId,
totalCount: formattedRuns.length,
runs: formattedRuns
};
}
/**
* Get all classic Dynamics workflows in the environment
* @param activeOnly Only return activated workflows (default: false)
* @param maxRecords Maximum number of workflows to return (default: 100)
* @returns List of classic workflows with basic information
*/
async getWorkflows(activeOnly = false, maxRecords = 100) {
// Category 0 = Classic Workflow
// StateCode: 0=Draft, 1=Activated, 2=Suspended
// Type: 1=Definition, 2=Activation
const stateFilter = activeOnly ? ' and statecode eq 1' : '';
const workflows = await this.makeRequest(`api/data/v9.2/workflows?$filter=category eq 0${stateFilter}&$select=workflowid,name,statecode,statuscode,description,createdon,modifiedon,type,ismanaged,iscrmuiworkflow,primaryentity,mode,subprocess,ondemand,triggeroncreate,triggerondelete,syncworkflowlogonfailure&$expand=ownerid($select=fullname),modifiedby($select=fullname)&$orderby=modifiedon desc&$top=${maxRecords}`);
// Format the results for better readability
const formattedWorkflows = workflows.value.map((workflow) => ({
workflowid: workflow.workflowid,
name: workflow.name,
description: workflow.description,
state: workflow.statecode === 0 ? 'Draft' : workflow.statecode === 1 ? 'Activated' : 'Suspended',
statecode: workflow.statecode,
statuscode: workflow.statuscode,
type: workflow.type === 1 ? 'Definition' : workflow.type === 2 ? 'Activation' : 'Template',
mode: workflow.mode === 0 ? 'Background' : 'Real-time',
primaryEntity: workflow.primaryentity,
isManaged: workflow.ismanaged,
isOnDemand: workflow.ondemand,
triggerOnCreate: workflow.triggeroncreate,
triggerOnDelete: workflow.triggerondelete,
isSubprocess: workflow.subprocess,
owner: workflow.ownerid?.fullname,
modifiedOn: workflow.modifiedon,
modifiedBy: workflow.modifiedby?.fullname,
createdOn: workflow.createdon
}));
return {
totalCount: formattedWorkflows.length,
workflows: formattedWorkflows
};
}
/**
* Get a specific classic workflow with its complete XAML definition
* @param workflowId The GUID of the workflow (workflowid)
* @returns Complete workflow information including the XAML definition
*/
async getWorkflowDefinition(workflowId) {
const workflow = await this.makeRequest(`api/data/v9.2/workflows(${workflowId})?$select=workflowid,name,statecode,statuscode,description,createdon,modifiedon,type,category,ismanaged,iscrmuiworkflow,primaryentity,mode,subprocess,ondemand,triggeroncreate,triggerondelete,triggeronupdateattributelist,syncworkflowlogonfailure,xaml&$expand=ownerid($select=fullname),modifiedby($select=fullname),createdby($select=fullname)`);
return {
workflowid: workflow.workflowid,
name: workflow.name,
description: workflow.description,
state: workflow.statecode === 0 ? 'Draft' : workflow.statecode === 1 ? 'Activated' : 'Suspended',
statecode: workflow.statecode,
statuscode: workflow.statuscode,
type: workflow.type === 1 ? 'Definition' : workflow.type === 2 ? 'Activation' : 'Template',
category: workflow.category,
mode: workflow.mode === 0 ? 'Background' : 'Real-time',
primaryEntity: workflow.primaryentity,
isManaged: workflow.ismanaged,
isOnDemand: workflow.ondemand,
triggerOnCreate: workflow.triggeroncreate,
triggerOnDelete: workflow.triggerondelete,
triggerOnUpdateAttributes: workflow.triggeronupdateattributelist ? workflow.triggeronupdateattributelist.split(',') : [],
isSubprocess: workflow.subprocess,
syncWorkflowLogOnFailure: workflow.syncworkflowlogonfailure,
owner: workflow.ownerid?.fullname,
createdOn: workflow.createdon,
createdBy: workflow.createdby?.fullname,
modifiedOn: workflow.modifiedon,
modifiedBy: workflow.modifiedby?.fullname,
xaml: workflow.xaml
};
}
/**
* Get all business rules in the environment
* @param activeOnly Only return activated business rules (default: false)
* @param maxRecords Maximum number of business rules to return (default: 100)
* @returns List of business rules with basic information
*/
async getBusinessRules(activeOnly = false, maxRecords = 100) {
// Category 2 = Business Rule
// StateCode: 0=Draft, 1=Activated, 2=Suspended
// Type: 1=Definition
const stateFilter = activeOnly ? ' and statecode eq 1' : '';
const businessRules = await this.makeRequest(`api/data/v9.2/workflows?$filter=category eq 2${stateFilter}&$select=workflowid,name,statecode,statuscode,description,createdon,modifiedon,type,ismanaged,primaryentity&$expand=ownerid($select=fullname),modifiedby($select=fullname)&$orderby=modifiedon desc&$top=${maxRecords}`);
// Format the results for better readability
const formattedBusinessRules = businessRules.value.map((rule) => ({
workflowid: rule.workflowid,
name: rule.name,
description: rule.description,
state: rule.statecode === 0 ? 'Draft' : rule.statecode === 1 ? 'Activated' : 'Suspended',
statecode: rule.statecode,
statuscode: rule.statuscode,
type: rule.type === 1 ? 'Definition' : rule.type === 2 ? 'Activation' : 'Template',
primaryEntity: rule.primaryentity,
isManaged: rule.ismanaged,
owner: rule.ownerid?.fullname,
modifiedOn: rule.modifiedon,
modifiedBy: rule.modifiedby?.fullname,
createdOn: rule.createdon
}));
return {
totalCount: formattedBusinessRules.length,
businessRules: formattedBusinessRules
};
}
/**
* Get a specific business rule with its complete XAML definition
* @param workflowId The GUID of the business rule (workflowid)
* @returns Complete business rule information including the XAML definition
*/
async getBusinessRule(workflowId) {
const businessRule = await this.makeRequest(`api/data/v9.2/workflows(${workflowId})?$select=workflowid,name,statecode,statuscode,description,createdon,modifiedon,type,category,ismanaged,primaryentity,xaml&$expand=ownerid($select=fullname),modifiedby($select=fullname),createdby($select=fullname)`);
// Verify it's actually a business rule
if (businessRule.category !== 2) {
throw new Error(`Workflow ${workflowId} is not a business rule (category: ${businessRule.category})`);
}
return {
workflowid: businessRule.workflowid,
name: businessRule.name,
description: businessRule.description,
state: businessRule.statecode === 0 ? 'Draft' : businessRule.statecode === 1 ? 'Activated' : 'Suspended',
statecode: businessRule.statecode,
statuscode: businessRule.statuscode,
type: businessRule.type === 1 ? 'Definition' : businessRule.type === 2 ? 'Activation' : 'Template',
category: businessRule.category,
primaryEntity: businessRule.primaryentity,
isManaged: businessRule.ismanaged,
owner: businessRule.ownerid?.fullname,
createdOn: businessRule.createdon,
createdBy: businessRule.createdby?.fullname,
modifiedOn: businessRule.modifiedon,
modifiedBy: businessRule.modifiedby?.fullname,
xaml: businessRule.xaml
};
}
// ==================== MODEL-DRIVEN APP OPERATIONS ====================
/**
* Get all model-driven apps in the environment
* @param activeOnly Only return active apps (default: false)
* @param maxRecords Maximum number of apps to return (default: 100)
* @returns List of model-driven apps with basic information
*/
async getApps(activeOnly = false, maxRecords = 100, includeUnpublished = true, solutionUniqueName) {
// Build filter conditions
const filters = [];
// StateCode: 0=Active, 1=Inactive
if (activeOnly) {
filters.push('statecode eq 0');
}
// Published status: publishedon null = unpublished
if (!includeUnpublished) {
filters.push('publishedon ne null');
}
const filterString = filters.length > 0 ? `&$filter=${filters.join(' and ')}` : '';
const apps = await this.makeRequest(`api/data/v9.2/appmodules?$select=appmoduleid,name,uniquename,description,webresourceid,clienttype,formfactor,navigationtype,url,isfeatured,isdefault,publishedon,statecode,statuscode,_publisherid_value,createdon,modifiedon&$orderby=modifiedon desc&$top=${maxRecords}${filterString}`);
// If solution filter specified, filter results by solution
let filteredApps = apps.value;
if (solutionUniqueName) {
// Query solution components to find apps in the specified solution
const solution = await this.makeRequest(`api/data/v9.2/solutions?$filter=uniquename eq '${solutionUniqueName}'&$select=solutionid`);
if (solution.value.length > 0) {
const solutionId = solution.value[0].solutionid;
// Query solution components for app modules
const solutionComponents = await this.makeRequest(`api/data/v9.2/solutioncomponents?$filter=_solutionid_value eq ${solutionId} and componenttype eq 80&$select=objectid`);
const appIdsInSolution = new Set(solutionComponents.value.map((c) => c.objectid.toLowerCase()));
filteredApps = apps.value.filter((app) => appIdsInSolution.has(app.appmoduleid.toLowerCase()));
}
}
// Format the results for better readability
const formattedApps = filteredApps.map((app) => ({
appmoduleid: app.appmoduleid,
name: app.name,
uniquename: app.uniquename,
description: app.description,
webresourceid: app.webresourceid,
clienttype: app.clienttype,
formfactor: app.formfactor,
navigationtype: app.navigationtype,
url: app.url,
isfeatured: app.isfeatured,
isdefault: app.isdefault,
state: app.statecode === 0 ? 'Active' : 'Inactive',
statecode: app.statecode,
statuscode: app.statuscode,
publishedon: app.publishedon,
published: app.publishedon ? true : false,
publisherid: app._publisherid_value || null,
createdon: app.createdon,
modifiedon: app.modifiedon
}));
return {
totalCount: formattedApps.length,
apps: formattedApps,
filters: {
activeOnly,
includeUnpublished,
solutionUniqueName: solutionUniqueName || 'all'
}
};
}
/**
* Get a specific model-driven app by ID
* @param appId The GUID of the app (appmoduleid)
* @returns Complete app information including publisher details
*/
async getApp(appId) {
const app = await this.makeRequest(`api/data/v9.2/appmodules(${appId})?$select=appmoduleid,name,uniquename,description,webresourceid,clienttype,formfactor,navigationtype,url,isfeatured,isdefault,publishedon,statecode,statuscode,configxml,createdon,modifiedon,_publisherid_value,_createdby_value,_modifiedby_value`);
return {
appmoduleid: app.appmoduleid,
name: app.name,
uniquename: app.uniquename,
description: app.description,
webresourceid: app.webresourceid,
clienttype: app.clienttype,
formfactor: app.formfactor,
navigationtype: app.navigationtype === 0 ? 'Single Session' : 'Multi Session',
url: app.url,
isfeatured: app.isfeatured,
isdefault: app.isdefault,
state: app.statecode === 0 ? 'Active' : 'Inactive',
statecode: app.statecode,
statuscode: app.statuscode,
publishedon: app.publishedon,
createdon: app.createdon,
modifiedon: app.modifiedon,
createdBy: app._createdby_value || null,
modifiedBy: app._modifiedby_value || null,
publisherid: app._publisherid_value || null
};
}
/**
* Get all components (entities, forms, views, sitemaps) associated with an app
* @param appId The GUID of the app (appmoduleid)
* @returns List of app components with type information
*/
async getAppComponents(appId) {
const components = await this.makeRequest(`api/data/v9.2/appmodulecomponents?$filter=_appmoduleidunique_value eq ${appId}&$select=appmodulecomponentid,objectid,componenttype,rootappmodulecomponentid,createdon,modifiedon&$orderby=componenttype asc`);
// Map component type numbers to friendly names
const componentTypeMap = {
1: 'Entity',
24: 'Form',
26: 'View',
29: 'Business Process Flow',
48: 'Ribbon Command',
59: 'Chart/Dashboard',
60: 'System Form',
62: 'SiteMap'
};
const formattedComponents = components.value.map((component) => ({
appmodulecomponentid: component.appmodulecomponentid,
objectid: component.objectid,
componenttype: component.componenttype,
componenttypeName: componentTypeMap[component.componenttype] || `Unknown (${component.componenttype})`,
rootappmodulecomponentid: component.rootappmodulecomponentid,
createdon: component.createdon,
modifiedon: component.modifiedon
}));
// Group by component type for easier reading
const groupedByType = {};
formattedComponents.forEach((comp) => {
const typeName = comp.componenttypeName;
if (!groupedByType[typeName]) {
groupedByType[typeName] = [];
}
groupedByType[typeName].push(comp);
});
return {
totalCount: formattedComponents.length,
components: formattedComponents,
groupedByType
};
}
/**
* Get the sitemap for a specific app
* @param appId Th