powerplatform-mcp
Version:
PowerPlatform Model Context Protocol server
247 lines (246 loc) • 10.7 kB
JavaScript
import { ConfidentialClientApplication } from '@azure/msal-node';
import axios from 'axios';
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');
}
}
/**
* Handle JSON-RPC error response
* @param error JSON-RPC error object
* @returns Formatted error information
*/
handleJsonRpcError(error) {
// Log the error for debugging
console.error('JSON-RPC error:', error);
// Handle specific error codes
switch (error.code) {
case -32601:
return `Method not found: ${error.message}. Please check that you're calling a supported API method.`;
case -32602:
return `Invalid params: ${error.message}. Please check your request parameters.`;
case -32603:
return `Internal error: ${error.message}. Please try again later.`;
case -32000:
return `Server error: ${error.message}. Please check server logs for more details.`;
default:
return `RPC error ${error.code}: ${error.message}`;
}
}
/**
* Make an authenticated request to the PowerPlatform API
*/
async makeRequest(endpoint) {
try {
const token = await this.getAccessToken();
const response = await axios({
method: 'GET',
url: `${this.config.organizationUrl}/${endpoint}`,
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/json',
'OData-MaxVersion': '4.0',
'OData-Version': '4.0'
}
});
return response.data;
}
catch (error) {
// Type guard for axios error with response data
if (error && error.code && typeof error.code === 'number' && error.message) {
const jsonRpcError = error.response.data.error;
const errorMessage = this.handleJsonRpcError(jsonRpcError);
throw new Error(errorMessage);
}
// For any other type of error, convert to string safely
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('PowerPlatform API request failed:', errorMessage);
throw new Error(`PowerPlatform API request failed: ${errorMessage}`);
}
}
/**
* 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}`);
}
}