# Directory Structure
```
├── .gitignore
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── src
│ ├── index.ts
│ └── PowerPlatformService.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 | .pnpm-debug.log*
9 |
10 | # Diagnostic reports (https://nodejs.org/api/report.html)
11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
12 |
13 | # Runtime data
14 | pids
15 | *.pid
16 | *.seed
17 | *.pid.lock
18 |
19 | # Directory for instrumented libs generated by jscoverage/JSCover
20 | lib-cov
21 |
22 | # Coverage directory used by tools like istanbul
23 | coverage
24 | *.lcov
25 |
26 | # nyc test coverage
27 | .nyc_output
28 |
29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
30 | .grunt
31 |
32 | # Bower dependency directory (https://bower.io/)
33 | bower_components
34 |
35 | # node-waf configuration
36 | .lock-wscript
37 |
38 | # Compiled binary addons (https://nodejs.org/api/addons.html)
39 | build/Release
40 |
41 | # Dependency directories
42 | node_modules/
43 | jspm_packages/
44 |
45 | # Snowpack dependency directory (https://snowpack.dev/)
46 | web_modules/
47 |
48 | # TypeScript cache
49 | *.tsbuildinfo
50 |
51 | # Optional npm cache directory
52 | .npm
53 |
54 | # Optional eslint cache
55 | .eslintcache
56 |
57 | # Optional stylelint cache
58 | .stylelintcache
59 |
60 | # Microbundle cache
61 | .rpt2_cache/
62 | .rts2_cache_cjs/
63 | .rts2_cache_es/
64 | .rts2_cache_umd/
65 |
66 | # Optional REPL history
67 | .node_repl_history
68 |
69 | # Output of 'npm pack'
70 | *.tgz
71 |
72 | # Yarn Integrity file
73 | .yarn-integrity
74 |
75 | # dotenv environment variable files
76 | .env
77 | .env.development.local
78 | .env.test.local
79 | .env.production.local
80 | .env.local
81 |
82 | # parcel-bundler cache (https://parceljs.org/)
83 | .cache
84 | .parcel-cache
85 |
86 | #Build output
87 | build
88 |
89 | # Next.js build output
90 | .next
91 | out
92 |
93 | # Nuxt.js build / generate output
94 | .nuxt
95 | dist
96 |
97 |
98 | # Gatsby files
99 | .cache/
100 | # Comment in the public line in if your project uses Gatsby and not Next.js
101 | # https://nextjs.org/blog/next-9-1#public-directory-support
102 | # public
103 |
104 | # vuepress build output
105 | .vuepress/dist
106 |
107 | # vuepress v2.x temp and cache directory
108 | .temp
109 | .cache
110 |
111 | # vitepress build output
112 | **/.vitepress/dist
113 |
114 | # vitepress cache directory
115 | **/.vitepress/cache
116 |
117 | # Docusaurus cache and generated files
118 | .docusaurus
119 |
120 | # Serverless directories
121 | .serverless/
122 |
123 | # FuseBox cache
124 | .fusebox/
125 |
126 | # DynamoDB Local files
127 | .dynamodb/
128 |
129 | # TernJS port file
130 | .tern-port
131 |
132 | # Stores VSCode versions used for testing VSCode extensions
133 | .vscode-test
134 |
135 | # yarn v2
136 | .yarn/cache
137 | .yarn/unplugged
138 | .yarn/build-state.yml
139 | .yarn/install-state.gz
140 | .pnp.*
141 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # PowerPlatform MCP Server
2 |
3 | A Model Context Protocol (MCP) server that provides intelligent access to PowerPlatform/Dataverse entities and records. This tool offers context-aware assistance, entity exploration and metadata access.
4 |
5 | Key features:
6 | - Rich entity metadata exploration with formatted, context-aware prompts
7 | - Advanced OData query support with intelligent filtering
8 | - Comprehensive relationship mapping and visualization
9 | - AI-assisted query building and data modeling through AI agent
10 | - Full access to entity attributes, relationships, and global option sets
11 |
12 | ## Installation
13 |
14 | You can install and run this tool in two ways:
15 |
16 | ### Option 1: Install globally
17 |
18 | ```bash
19 | npm install -g powerplatform-mcp
20 | ```
21 |
22 | Then run it:
23 |
24 | ```bash
25 | powerplatform-mcp
26 | ```
27 |
28 | ### Option 2: Run directly with npx
29 |
30 | Run without installing:
31 |
32 | ```bash
33 | npx powerplatform-mcp
34 | ```
35 |
36 | ## Configuration
37 |
38 | Before running, set the following environment variables:
39 |
40 | ```bash
41 | # PowerPlatform/Dataverse connection details
42 | POWERPLATFORM_URL=https://yourenvironment.crm.dynamics.com
43 | POWERPLATFORM_CLIENT_ID=your-azure-app-client-id
44 | POWERPLATFORM_CLIENT_SECRET=your-azure-app-client-secret
45 | POWERPLATFORM_TENANT_ID=your-azure-tenant-id
46 | ```
47 |
48 | ## Usage
49 |
50 | This is an MCP server designed to work with MCP-compatible clients like Cursor, Claude App and GitHub Copilot. Once running, it will expose tools for retrieving PowerPlatform entity metadata and records.
51 |
52 | ### Available Tools
53 |
54 | - `get-entity-metadata`: Get metadata about a PowerPlatform entity
55 | - `get-entity-attributes`: Get attributes/fields of a PowerPlatform entity
56 | - `get-entity-attribute`: Get a specific attribute/field of a PowerPlatform entity
57 | - `get-entity-relationships`: Get relationships for a PowerPlatform entity
58 | - `get-global-option-set`: Get a global option set definition
59 | - `get-record`: Get a specific record by entity name and ID
60 | - `query-records`: Query records using an OData filter expression
61 | - `use-powerplatform-prompt`: Use pre-defined prompt templates for PowerPlatform entities
62 |
63 | ## MCP Prompts
64 |
65 | The server includes a prompts feature that provides formatted, context-rich information about PowerPlatform entities.
66 |
67 | ### Available Prompt Types
68 |
69 | The `use-powerplatform-prompt` tool supports the following prompt types:
70 |
71 | 1. **ENTITY_OVERVIEW**: Comprehensive overview of an entity
72 | 2. **ATTRIBUTE_DETAILS**: Detailed information about a specific entity attribute
73 | 3. **QUERY_TEMPLATE**: OData query template for an entity with example filters
74 | 4. **RELATIONSHIP_MAP**: Visual map of entity relationships
75 |
76 | ### Examples
77 |
78 | #### Entity Overview Prompt
79 |
80 | ```javascript
81 | // Example client code
82 | await mcpClient.invoke("use-powerplatform-prompt", {
83 | promptType: "ENTITY_OVERVIEW",
84 | entityName: "account"
85 | });
86 | ```
87 |
88 | **Output:**
89 | ```
90 | ## Power Platform Entity: account
91 |
92 | This is an overview of the 'account' entity in Microsoft Power Platform/Dataverse:
93 |
94 | ### Entity Details
95 | - Display Name: Account
96 | - Schema Name: Account
97 | - Description: Business that represents a customer or potential customer
98 | - Primary Key: accountid
99 | - Primary Name: name
100 |
101 | ### Key Attributes
102 | - name: String (Account Name)
103 | - emailaddress1: String (Email)
104 | - telephone1: String (Main Phone)
105 | - address1_city: String (City)
106 | - statecode: Status (Status)
107 | - accountnumber: String (Account Number)
108 | - industrycode: OptionSetValue (Industry)
109 | - revenue: Money (Annual Revenue)
110 | - ownerid: Owner (Owner)
111 | - createdon: DateTime (Created On)
112 |
113 | ### Relationships
114 | - One-to-Many Relationships: 42
115 | - Many-to-Many Relationships: 8
116 |
117 | You can query this entity using OData filters against the plural name.
118 | ```
119 |
120 | #### Attribute Details Prompt
121 |
122 | ```javascript
123 | await mcpClient.invoke("use-powerplatform-prompt", {
124 | promptType: "ATTRIBUTE_DETAILS",
125 | entityName: "account",
126 | attributeName: "revenue"
127 | });
128 | ```
129 |
130 | **Output:**
131 | ```
132 | ## Attribute: revenue
133 |
134 | Details for the 'revenue' attribute of the 'account' entity:
135 |
136 | - Display Name: Annual Revenue
137 | - Description: Annual revenue for the account
138 | - Type: Money
139 | - Format: Currency
140 | - Is Required: No
141 | - Is Searchable: true
142 |
143 | ### Usage Notes
144 | - Data Type: Money
145 | - Required: No
146 | - Max Length: N/A
147 | ```
148 |
149 | #### Query Template Prompt
150 |
151 | ```javascript
152 | await mcpClient.invoke("use-powerplatform-prompt", {
153 | promptType: "QUERY_TEMPLATE",
154 | entityName: "account"
155 | });
156 | ```
157 |
158 | **Output:**
159 | ```
160 | ## OData Query Template for accounts
161 |
162 | Use this template to build queries against the accounts entity:
163 | accounts?$select=name,emailaddress1,telephone1, address1_city,statecode&$filter=name eq 'Example'&$orderby=name asc&$top=50
164 | ```
165 |
166 | ### Common Filter Examples
167 | - Equals: `name eq 'Contoso'`
168 | - Contains: `contains(name, 'Contoso')`
169 | - Greater than date: `createdon gt 2023-01-01T00:00:00Z`
170 | - Multiple conditions: `name eq 'Contoso' and statecode eq 0`
171 | ```
172 |
173 | #### Relationship Map Prompt
174 |
175 | ```javascript
176 | await mcpClient.invoke("use-powerplatform-prompt", {
177 | promptType: "RELATIONSHIP_MAP",
178 | entityName: "contact"
179 | });
180 | ```
181 |
182 | **Output:**
183 | ```
184 | ## Relationship Map for contact
185 |
186 | This shows all relationships for the 'contact' entity:
187 |
188 | ### One-to-Many Relationships (contact as Primary)
189 | - contact_activity_parties: contact (1) → activityparty (N)
190 | - contact_connections1: contact (1) → connection (N)
191 | - contact_connections2: contact (1) → connection (N)
192 | - contact_customer_contacts: contact (1) → contact (N)
193 | - contact_master_contact: contact (1) → contact (N)
194 |
195 | ### One-to-Many Relationships (contact as Related)
196 | - account_primary_contact: account (1) → contact (N)
197 | - customer_contacts: customer (1) → contact (N)
198 | - system_user_contacts: systemuser (1) → contact (N)
199 |
200 | ### Many-to-Many Relationships
201 | - contactleads_association: contact (N) ↔ lead (N)
202 | - contactopportunities_association: contact (N) ↔ opportunity (N)
203 | - contactquotes_association: contact (N) ↔ quote (N)
204 | ```
205 |
206 | ## Prompt Examples
207 |
208 | Here are some examples of how you can interact with assistant using the PowerPlatform MCP prompts:
209 |
210 | ### 1. Entity Understanding
211 |
212 | You can ask assistant about entities in natural language:
213 |
214 | ```
215 | User: "Tell me about the Account entity in our PowerPlatform environment"
216 |
217 | Response:
218 | [uses ENTITY_OVERVIEW prompt internally]
219 |
220 | Here's what I found about the Account entity:
221 | - It's a core business entity representing customers and potential customers
222 | - The primary identifier is 'accountid' and the primary name field is 'name'
223 | - Key fields include:
224 | * name (Account Name)
225 | * emailaddress1 (Email)
226 | * telephone1 (Main Phone)
227 | * revenue (Annual Revenue)
228 | - It has 42 one-to-many relationships and 8 many-to-many relationships
229 | ```
230 |
231 | ### 2. Building Queries
232 |
233 | You can ask for help constructing queries:
234 |
235 | ```
236 | User: "Help me write a query to find active accounts with revenue over $1M in California"
237 |
238 | Response:
239 | [uses QUERY_TEMPLATE prompt internally]
240 |
241 | Here's the OData query you need:
242 | accounts?$select=name,revenue,statecode,address1_stateorprovince
243 | &$filter=statecode eq 0
244 | and revenue gt 1000000
245 | and address1_stateorprovince eq 'California'
246 | &$orderby=revenue desc
247 |
248 | This query:
249 | 1. Selects relevant fields
250 | 2. Filters for active accounts (statecode eq 0)
251 | 3. Checks revenue > $1M
252 | 4. Filters for California location
253 | 5. Orders by revenue highest to lowest
254 | ```
255 |
256 | ### 3. Understanding Relationships
257 |
258 | You can explore entity relationships naturally:
259 |
260 | ```
261 | User: "How are Accounts and Contacts related in our system?"
262 |
263 | Response:
264 | [uses RELATIONSHIP_MAP prompt for both entities internally]
265 |
266 | Here are the key relationships between Accounts and Contacts:
267 |
268 | 1. Primary Contact (One-to-Many):
269 | - account_primary_contact: Account (1) → Contact (N)
270 | - An account can have many contacts, but one designated as primary
271 |
272 | 2. Customer Relationship:
273 | - contact_customer_accounts: Contact (N) ← Account (1)
274 | - Contacts can be associated with customer accounts
275 |
276 | 3. Additional Relationships:
277 | - accountleads_association: For lead tracking
278 | - account_connections: For relationship management
279 | ```
280 |
281 | ### 4. Field/Attribute Investigation
282 |
283 | You can ask about specific fields:
284 |
285 | ```
286 | User: "What kind of field is the revenue field on Account?"
287 |
288 | Response:
289 | [uses ATTRIBUTE_DETAILS prompt internally]
290 |
291 | The revenue field on Account is:
292 | - Data Type: Money
293 | - Format: Currency
294 | - Not required
295 | - Searchable: Yes
296 | - Used for: Tracking annual revenue for the account
297 | - Can be used in advanced find and filtered views
298 | ```
299 |
300 | ### 5. Complex Data Modeling
301 |
302 | You can get help with data modeling decisions:
303 |
304 | ```
305 | User: "I need to design a solution for tracking product reviews. Which entities should I relate to?"
306 |
307 | Response:
308 | [uses multiple ENTITY_OVERVIEW and RELATIONSHIP_MAP prompts internally]
309 |
310 | Based on the existing data model, here's a suggested approach:
311 |
312 | 1. Product (existing entity):
313 | - Already has relationships with:
314 | * Price lists
315 | * Product categories
316 | * Inventory units
317 |
318 | 2. New Review entity should relate to:
319 | - Product (N:1) - Each review is for one product
320 | - Contact (N:1) - Reviews are written by contacts
321 | - Account (N:1) - Optional link to company if B2B review
322 |
323 | Here's a sample lookup field structure...
324 | [continues with detailed implementation guidance]
325 | ```
326 |
327 | ### 6. Debugging Assistance
328 |
329 | You can get help troubleshooting:
330 |
331 | ```
332 | User: "Why am I getting 'invalid property' when querying account.customertypecode?"
333 |
334 | Response:
335 | [uses ATTRIBUTE_DETAILS prompt internally]
336 |
337 | I see the issue. The field 'customertypecode' is actually named 'customertype' in your environment. Here's the correct field information:
338 | - Logical Name: customertype
339 | - Type: OptionSetValue
340 | - Valid Values:
341 | * 1 = Competitor
342 | * 2 = Consultant
343 | * 3 = Customer
344 | * 4 = Investor
345 | * 5 = Partner
346 | * 6 = Influencer
347 |
348 | Try updating your query to use 'customertype' instead of 'customertypecode'.
349 | ```
350 |
351 | These examples show how AI assistant can leverage the MCP prompts to provide context-aware, accurate assistance for PowerPlatform development tasks. The AI understands your environment's specific configuration and can help with both simple queries and complex architectural decisions.
352 |
353 | ## License
354 |
355 | MIT
356 |
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "module": "Node16",
5 | "moduleResolution": "Node16",
6 | "outDir": "./build",
7 | "rootDir": "./src",
8 | "strict": true,
9 | "esModuleInterop": true,
10 | "skipLibCheck": true,
11 | "forceConsistentCasingInFileNames": true
12 | },
13 | "include": ["src/**/*"],
14 | "exclude": ["node_modules"]
15 | }
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "powerplatform-mcp",
3 | "version": "0.4.5",
4 | "description": "PowerPlatform Model Context Protocol server",
5 | "main": "build/index.js",
6 | "bin": {
7 | "powerplatform-mcp": "build/index.js"
8 | },
9 | "scripts": {
10 | "build": "tsc",
11 | "prepublishOnly": "npm run build"
12 | },
13 | "files": [
14 | "build",
15 | "README.md"
16 | ],
17 | "keywords": [
18 | "powerplatform",
19 | "mcp",
20 | "model-context-protocol",
21 | "dynamics",
22 | "dataverse"
23 | ],
24 | "author": "Michal Sobieraj",
25 | "license": "MIT",
26 | "type": "module",
27 | "publishConfig": {
28 | "access": "public"
29 | },
30 | "repository": {
31 | "type": "git",
32 | "url": "git+https://github.com/michsob/powerplatform-mcp.git"
33 | },
34 | "engines": {
35 | "node": ">=16.0.0"
36 | },
37 | "dependencies": {
38 | "@azure/msal-node": "^3.3.0",
39 | "@modelcontextprotocol/sdk": "^1.7.0",
40 | "axios": "^1.8.3",
41 | "zod": "^3.24.2"
42 | },
43 | "devDependencies": {
44 | "@types/node": "^22.13.10",
45 | "typescript": "^5.8.2"
46 | }
47 | }
48 |
```
--------------------------------------------------------------------------------
/src/PowerPlatformService.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { ConfidentialClientApplication } from '@azure/msal-node';
2 | import axios from 'axios';
3 |
4 | export interface PowerPlatformConfig {
5 | organizationUrl: string;
6 | clientId: string;
7 | clientSecret: string;
8 | tenantId: string;
9 | }
10 |
11 | // Interface for API responses with value collections
12 | export interface ApiCollectionResponse<T> {
13 | value: T[];
14 | [key: string]: any; // For any additional properties
15 | }
16 |
17 | export class PowerPlatformService {
18 | private config: PowerPlatformConfig;
19 | private msalClient: ConfidentialClientApplication;
20 | private accessToken: string | null = null;
21 | private tokenExpirationTime: number = 0;
22 |
23 | constructor(config: PowerPlatformConfig) {
24 | this.config = config;
25 |
26 | // Initialize MSAL client
27 | this.msalClient = new ConfidentialClientApplication({
28 | auth: {
29 | clientId: this.config.clientId,
30 | clientSecret: this.config.clientSecret,
31 | authority: `https://login.microsoftonline.com/${this.config.tenantId}`,
32 | }
33 | });
34 | }
35 |
36 | /**
37 | * Get an access token for the PowerPlatform API
38 | */
39 | private async getAccessToken(): Promise<string> {
40 | const currentTime = Date.now();
41 |
42 | // If we have a token that isn't expired, return it
43 | if (this.accessToken && this.tokenExpirationTime > currentTime) {
44 | return this.accessToken;
45 | }
46 |
47 | try {
48 | // Get a new token
49 | const result = await this.msalClient.acquireTokenByClientCredential({
50 | scopes: [`${this.config.organizationUrl}/.default`],
51 | });
52 |
53 | if (!result || !result.accessToken) {
54 | throw new Error('Failed to acquire access token');
55 | }
56 |
57 | this.accessToken = result.accessToken;
58 |
59 | // Set expiration time (subtract 5 minutes to refresh early)
60 | if (result.expiresOn) {
61 | this.tokenExpirationTime = result.expiresOn.getTime() - (5 * 60 * 1000);
62 | }
63 |
64 | return this.accessToken;
65 | } catch (error) {
66 | console.error('Error acquiring access token:', error);
67 | throw new Error('Authentication failed');
68 | }
69 | }
70 |
71 | /**
72 | * Make an authenticated request to the PowerPlatform API
73 | */
74 | private async makeRequest<T>(endpoint: string): Promise<T> {
75 | try {
76 | const token = await this.getAccessToken();
77 |
78 | const response = await axios({
79 | method: 'GET',
80 | url: `${this.config.organizationUrl}/${endpoint}`,
81 | headers: {
82 | 'Authorization': `Bearer ${token}`,
83 | 'Accept': 'application/json',
84 | 'OData-MaxVersion': '4.0',
85 | 'OData-Version': '4.0'
86 | }
87 | });
88 |
89 | return response.data as T;
90 | } catch (error) {
91 | console.error('PowerPlatform API request failed:', error);
92 | throw new Error(`PowerPlatform API request failed: ${error}`);
93 | }
94 | }
95 |
96 | /**
97 | * Get metadata about an entity
98 | * @param entityName The logical name of the entity
99 | */
100 | async getEntityMetadata(entityName: string): Promise<any> {
101 | const response = await this.makeRequest(`api/data/v9.2/EntityDefinitions(LogicalName='${entityName}')`);
102 |
103 | // Remove Privileges property if it exists
104 | if (response && typeof response === 'object' && 'Privileges' in response) {
105 | delete response.Privileges;
106 | }
107 |
108 | return response;
109 | }
110 |
111 | /**
112 | * Get metadata about entity attributes/fields
113 | * @param entityName The logical name of the entity
114 | */
115 | async getEntityAttributes(entityName: string): Promise<ApiCollectionResponse<any>> {
116 | const selectProperties = [
117 | 'LogicalName',
118 | ].join(',');
119 |
120 | // Make the request to get attributes
121 | const response = await this.makeRequest<ApiCollectionResponse<any>>(`api/data/v9.2/EntityDefinitions(LogicalName='${entityName}')/Attributes?$select=${selectProperties}&$filter=AttributeType ne 'Virtual'`);
122 |
123 | if (response && response.value) {
124 | // First pass: Filter out attributes that end with 'yominame'
125 | response.value = response.value.filter((attribute: any) => {
126 | const logicalName = attribute.LogicalName || '';
127 | return !logicalName.endsWith('yominame');
128 | });
129 |
130 | // Filter out attributes that end with 'name' if there is another attribute with the same name without the 'name' suffix
131 | const baseNames = new Set<string>();
132 | const namesAttributes = new Map<string, any>();
133 |
134 | for (const attribute of response.value) {
135 | const logicalName = attribute.LogicalName || '';
136 |
137 | if (logicalName.endsWith('name') && logicalName.length > 4) {
138 | const baseName = logicalName.slice(0, -4); // Remove 'name' suffix
139 | namesAttributes.set(baseName, attribute);
140 | } else {
141 | // This is a potential base attribute
142 | baseNames.add(logicalName);
143 | }
144 | }
145 |
146 | // Find attributes to remove that match the pattern
147 | const attributesToRemove = new Set<any>();
148 | for (const [baseName, nameAttribute] of namesAttributes.entries()) {
149 | if (baseNames.has(baseName)) {
150 | attributesToRemove.add(nameAttribute);
151 | }
152 | }
153 |
154 | response.value = response.value.filter(attribute => !attributesToRemove.has(attribute));
155 | }
156 |
157 | return response;
158 | }
159 |
160 | /**
161 | * Get metadata about a specific entity attribute/field
162 | * @param entityName The logical name of the entity
163 | * @param attributeName The logical name of the attribute
164 | */
165 | async getEntityAttribute(entityName: string, attributeName: string): Promise<any> {
166 | return this.makeRequest(`api/data/v9.2/EntityDefinitions(LogicalName='${entityName}')/Attributes(LogicalName='${attributeName}')`);
167 | }
168 |
169 | /**
170 | * Get one-to-many relationships for an entity
171 | * @param entityName The logical name of the entity
172 | */
173 | async getEntityOneToManyRelationships(entityName: string): Promise<ApiCollectionResponse<any>> {
174 | const selectProperties = [
175 | 'SchemaName',
176 | 'RelationshipType',
177 | 'ReferencedAttribute',
178 | 'ReferencedEntity',
179 | 'ReferencingAttribute',
180 | 'ReferencingEntity',
181 | 'ReferencedEntityNavigationPropertyName',
182 | 'ReferencingEntityNavigationPropertyName'
183 | ].join(',');
184 |
185 | // Only filter by ReferencingAttribute in the OData query since startswith isn't supported
186 | const response = await this.makeRequest<ApiCollectionResponse<any>>(`api/data/v9.2/EntityDefinitions(LogicalName='${entityName}')/OneToManyRelationships?$select=${selectProperties}&$filter=ReferencingAttribute ne 'regardingobjectid'`);
187 |
188 | // Filter the response to exclude relationships with ReferencingEntity starting with 'msdyn_' or 'adx_'
189 | if (response && response.value) {
190 | response.value = response.value.filter((relationship: any) => {
191 | const referencingEntity = relationship.ReferencingEntity || '';
192 | return !(referencingEntity.startsWith('msdyn_') || referencingEntity.startsWith('adx_'));
193 | });
194 | }
195 |
196 | return response;
197 | }
198 |
199 | /**
200 | * Get many-to-many relationships for an entity
201 | * @param entityName The logical name of the entity
202 | */
203 | async getEntityManyToManyRelationships(entityName: string): Promise<ApiCollectionResponse<any>> {
204 | const selectProperties = [
205 | 'SchemaName',
206 | 'RelationshipType',
207 | 'Entity1LogicalName',
208 | 'Entity2LogicalName',
209 | 'Entity1IntersectAttribute',
210 | 'Entity2IntersectAttribute',
211 | 'Entity1NavigationPropertyName',
212 | 'Entity2NavigationPropertyName'
213 | ].join(',');
214 |
215 | return this.makeRequest<ApiCollectionResponse<any>>(`api/data/v9.2/EntityDefinitions(LogicalName='${entityName}')/ManyToManyRelationships?$select=${selectProperties}`);
216 | }
217 |
218 | /**
219 | * Get all relationships (one-to-many and many-to-many) for an entity
220 | * @param entityName The logical name of the entity
221 | */
222 | async getEntityRelationships(entityName: string): Promise<{oneToMany: ApiCollectionResponse<any>, manyToMany: ApiCollectionResponse<any>}> {
223 | const [oneToMany, manyToMany] = await Promise.all([
224 | this.getEntityOneToManyRelationships(entityName),
225 | this.getEntityManyToManyRelationships(entityName)
226 | ]);
227 |
228 | return {
229 | oneToMany,
230 | manyToMany
231 | };
232 | }
233 |
234 | /**
235 | * Get a global option set definition by name
236 | * @param optionSetName The name of the global option set
237 | * @returns The global option set definition
238 | */
239 | async getGlobalOptionSet(optionSetName: string): Promise<any> {
240 | return this.makeRequest(`api/data/v9.2/GlobalOptionSetDefinitions(Name='${optionSetName}')`);
241 | }
242 |
243 | /**
244 | * Get a specific record by entity name (plural) and ID
245 | * @param entityNamePlural The plural name of the entity (e.g., 'accounts', 'contacts')
246 | * @param recordId The GUID of the record
247 | * @returns The record data
248 | */
249 | async getRecord(entityNamePlural: string, recordId: string): Promise<any> {
250 | return this.makeRequest(`api/data/v9.2/${entityNamePlural}(${recordId})`);
251 | }
252 |
253 | /**
254 | * Query records using entity name (plural) and a filter expression
255 | * @param entityNamePlural The plural name of the entity (e.g., 'accounts', 'contacts')
256 | * @param filter OData filter expression (e.g., "name eq 'test'")
257 | * @param maxRecords Maximum number of records to retrieve (default: 50)
258 | * @returns Filtered list of records
259 | */
260 | async queryRecords(entityNamePlural: string, filter: string, maxRecords: number = 50): Promise<ApiCollectionResponse<any>> {
261 | return this.makeRequest<ApiCollectionResponse<any>>(`api/data/v9.2/${entityNamePlural}?$filter=${encodeURIComponent(filter)}&$top=${maxRecords}`);
262 | }
263 | }
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4 | import { z } from "zod";
5 | import { PowerPlatformService, PowerPlatformConfig } from "./PowerPlatformService.js";
6 |
7 | // Environment configuration
8 | // These values can be set in environment variables or loaded from a configuration file
9 | const POWERPLATFORM_CONFIG: PowerPlatformConfig = {
10 | organizationUrl: process.env.POWERPLATFORM_URL || "",
11 | clientId: process.env.POWERPLATFORM_CLIENT_ID || "",
12 | clientSecret: process.env.POWERPLATFORM_CLIENT_SECRET || "",
13 | tenantId: process.env.POWERPLATFORM_TENANT_ID || "",
14 | };
15 |
16 | // Create server instance
17 | const server = new McpServer({
18 | name: "powerplatform-mcp",
19 | version: "1.0.0",
20 | });
21 |
22 | let powerPlatformService: PowerPlatformService | null = null;
23 |
24 | // Function to initialize PowerPlatformService on demand
25 | function getPowerPlatformService(): PowerPlatformService {
26 | if (!powerPlatformService) {
27 | // Check if configuration is complete
28 | const missingConfig: string[] = [];
29 | if (!POWERPLATFORM_CONFIG.organizationUrl) missingConfig.push("organizationUrl");
30 | if (!POWERPLATFORM_CONFIG.clientId) missingConfig.push("clientId");
31 | if (!POWERPLATFORM_CONFIG.clientSecret) missingConfig.push("clientSecret");
32 | if (!POWERPLATFORM_CONFIG.tenantId) missingConfig.push("tenantId");
33 |
34 | if (missingConfig.length > 0) {
35 | throw new Error(`Missing PowerPlatform configuration: ${missingConfig.join(", ")}. Set these in environment variables.`);
36 | }
37 |
38 | // Initialize service
39 | powerPlatformService = new PowerPlatformService(POWERPLATFORM_CONFIG);
40 | console.error("PowerPlatform service initialized");
41 | }
42 |
43 | return powerPlatformService;
44 | }
45 |
46 | // Pre-defined PowerPlatform Prompts
47 | const powerPlatformPrompts = {
48 | // Entity exploration prompts
49 | ENTITY_OVERVIEW: (entityName: string) =>
50 | `## Power Platform Entity: ${entityName}\n\n` +
51 | `This is an overview of the '${entityName}' entity in Microsoft Power Platform/Dataverse:\n\n` +
52 | `### Entity Details\n{{entity_details}}\n\n` +
53 | `### Attributes\n{{key_attributes}}\n\n` +
54 | `### Relationships\n{{relationships}}\n\n` +
55 | `You can query this entity using OData filters against the plural name.`,
56 |
57 | ATTRIBUTE_DETAILS: (entityName: string, attributeName: string) =>
58 | `## Attribute: ${attributeName}\n\n` +
59 | `Details for the '${attributeName}' attribute of the '${entityName}' entity:\n\n` +
60 | `{{attribute_details}}\n\n` +
61 | `### Usage Notes\n` +
62 | `- Data Type: {{data_type}}\n` +
63 | `- Required: {{required}}\n` +
64 | `- Max Length: {{max_length}}`,
65 |
66 | // Query builder prompts
67 | QUERY_TEMPLATE: (entityNamePlural: string) =>
68 | `## OData Query Template for ${entityNamePlural}\n\n` +
69 | `Use this template to build queries against the ${entityNamePlural} entity:\n\n` +
70 | `\`\`\`\n${entityNamePlural}?$select={{selected_fields}}&$filter={{filter_conditions}}&$orderby={{order_by}}&$top={{max_records}}\n\`\`\`\n\n` +
71 | `### Common Filter Examples\n` +
72 | `- Equals: \`name eq 'Contoso'\`\n` +
73 | `- Contains: \`contains(name, 'Contoso')\`\n` +
74 | `- Greater than date: \`createdon gt 2023-01-01T00:00:00Z\`\n` +
75 | `- Multiple conditions: \`name eq 'Contoso' and statecode eq 0\``,
76 |
77 | // Relationship exploration prompts
78 | RELATIONSHIP_MAP: (entityName: string) =>
79 | `## Relationship Map for ${entityName}\n\n` +
80 | `This shows all relationships for the '${entityName}' entity:\n\n` +
81 | `### One-to-Many Relationships (${entityName} as Primary)\n{{one_to_many_primary}}\n\n` +
82 | `### One-to-Many Relationships (${entityName} as Related)\n{{one_to_many_related}}\n\n` +
83 | `### Many-to-Many Relationships\n{{many_to_many}}\n\n`
84 | };
85 |
86 | // Register prompts with the server using the correct method signature
87 | // Entity Overview Prompt
88 | server.prompt(
89 | "entity-overview",
90 | "Get an overview of a Power Platform entity",
91 | {
92 | entityName: z.string().describe("The logical name of the entity")
93 | },
94 | async (args) => {
95 | try {
96 | const service = getPowerPlatformService();
97 | const entityName = args.entityName;
98 |
99 | // Get entity metadata and key attributes
100 | const [metadata, attributes] = await Promise.all([
101 | service.getEntityMetadata(entityName),
102 | service.getEntityAttributes(entityName)
103 | ]);
104 |
105 | // Format entity details
106 | const entityDetails = `- Display Name: ${metadata.DisplayName?.UserLocalizedLabel?.Label || entityName}\n` +
107 | `- Schema Name: ${metadata.SchemaName}\n` +
108 | `- Description: ${metadata.Description?.UserLocalizedLabel?.Label || 'No description'}\n` +
109 | `- Primary Key: ${metadata.PrimaryIdAttribute}\n` +
110 | `- Primary Name: ${metadata.PrimaryNameAttribute}`;
111 |
112 | // Get key attributes
113 | const keyAttributes = attributes.value
114 | .map((attr: any) => {
115 | const attrType = attr["@odata.type"] || attr.odata?.type || "Unknown type";
116 | return `- ${attr.LogicalName}: ${attrType}`;
117 | })
118 | .join('\n');
119 |
120 | // Get relationships summary
121 | const relationships = await service.getEntityRelationships(entityName);
122 | const oneToManyCount = relationships.oneToMany.value.length;
123 | const manyToManyCount = relationships.manyToMany.value.length;
124 |
125 | const relationshipsSummary = `- One-to-Many Relationships: ${oneToManyCount}\n` +
126 | `- Many-to-Many Relationships: ${manyToManyCount}`;
127 |
128 | let promptContent = powerPlatformPrompts.ENTITY_OVERVIEW(entityName);
129 | promptContent = promptContent
130 | .replace('{{entity_details}}', entityDetails)
131 | .replace('{{key_attributes}}', keyAttributes)
132 | .replace('{{relationships}}', relationshipsSummary);
133 |
134 | return {
135 | messages: [
136 | {
137 | role: "assistant",
138 | content: {
139 | type: "text",
140 | text: promptContent
141 | }
142 | }
143 | ]
144 | };
145 | } catch (error: any) {
146 | console.error(`Error handling entity-overview prompt:`, error);
147 | return {
148 | messages: [
149 | {
150 | role: "assistant",
151 | content: {
152 | type: "text",
153 | text: `Error: ${error.message}`
154 | }
155 | }
156 | ]
157 | };
158 | }
159 | }
160 | );
161 |
162 | // Attribute Details Prompt
163 | server.prompt(
164 | "attribute-details",
165 | "Get detailed information about a specific entity attribute/field",
166 | {
167 | entityName: z.string().describe("The logical name of the entity"),
168 | attributeName: z.string().describe("The logical name of the attribute"),
169 | },
170 | async (args) => {
171 | try {
172 | const service = getPowerPlatformService();
173 | const { entityName, attributeName } = args;
174 |
175 | // Get attribute details
176 | const attribute = await service.getEntityAttribute(entityName, attributeName);
177 |
178 | // Format attribute details
179 | const attrDetails = `- Display Name: ${attribute.DisplayName?.UserLocalizedLabel?.Label || attributeName}\n` +
180 | `- Description: ${attribute.Description?.UserLocalizedLabel?.Label || 'No description'}\n` +
181 | `- Type: ${attribute.AttributeType}\n` +
182 | `- Format: ${attribute.Format || 'N/A'}\n` +
183 | `- Is Required: ${attribute.RequiredLevel?.Value || 'No'}\n` +
184 | `- Is Searchable: ${attribute.IsValidForAdvancedFind || false}`;
185 |
186 | let promptContent = powerPlatformPrompts.ATTRIBUTE_DETAILS(entityName, attributeName);
187 | promptContent = promptContent
188 | .replace('{{attribute_details}}', attrDetails)
189 | .replace('{{data_type}}', attribute.AttributeType)
190 | .replace('{{required}}', attribute.RequiredLevel?.Value || 'No')
191 | .replace('{{max_length}}', attribute.MaxLength || 'N/A');
192 |
193 | return {
194 | messages: [
195 | {
196 | role: "assistant",
197 | content: {
198 | type: "text",
199 | text: promptContent
200 | }
201 | }
202 | ]
203 | };
204 | } catch (error: any) {
205 | console.error(`Error handling attribute-details prompt:`, error);
206 | return {
207 | messages: [
208 | {
209 | role: "assistant",
210 | content: {
211 | type: "text",
212 | text: `Error: ${error.message}`
213 | }
214 | }
215 | ]
216 | };
217 | }
218 | }
219 | );
220 |
221 | // Query Template Prompt
222 | server.prompt(
223 | "query-template",
224 | "Get a template for querying a Power Platform entity",
225 | {
226 | entityName: z.string().describe("The logical name of the entity"),
227 | },
228 | async (args) => {
229 | try {
230 | const service = getPowerPlatformService();
231 | const entityName = args.entityName;
232 |
233 | // Get entity metadata to determine plural name
234 | const metadata = await service.getEntityMetadata(entityName);
235 | const entityNamePlural = metadata.EntitySetName;
236 |
237 | // Get a few important fields for the select example
238 | const attributes = await service.getEntityAttributes(entityName);
239 | const selectFields = attributes.value
240 | .filter((attr: any) => attr.IsValidForRead === true && !attr.AttributeOf)
241 | .slice(0, 5) // Just take first 5 for example
242 | .map((attr: any) => attr.LogicalName)
243 | .join(',');
244 |
245 | let promptContent = powerPlatformPrompts.QUERY_TEMPLATE(entityNamePlural);
246 | promptContent = promptContent
247 | .replace('{{selected_fields}}', selectFields)
248 | .replace('{{filter_conditions}}', `${metadata.PrimaryNameAttribute} eq 'Example'`)
249 | .replace('{{order_by}}', `${metadata.PrimaryNameAttribute} asc`)
250 | .replace('{{max_records}}', '50');
251 |
252 | return {
253 | messages: [
254 | {
255 | role: "assistant",
256 | content: {
257 | type: "text",
258 | text: promptContent
259 | }
260 | }
261 | ]
262 | };
263 | } catch (error: any) {
264 | console.error(`Error handling query-template prompt:`, error);
265 | return {
266 | messages: [
267 | {
268 | role: "assistant",
269 | content: {
270 | type: "text",
271 | text: `Error: ${error.message}`
272 | }
273 | }
274 | ]
275 | };
276 | }
277 | }
278 | );
279 |
280 | // Relationship Map Prompt
281 | server.prompt(
282 | "relationship-map",
283 | "Get a list of relationships for a Power Platform entity",
284 | {
285 | entityName: z.string().describe("The logical name of the entity"),
286 | },
287 | async (args) => {
288 | try {
289 | const service = getPowerPlatformService();
290 | const entityName = args.entityName;
291 |
292 | // Get relationships
293 | const relationships = await service.getEntityRelationships(entityName);
294 |
295 | // Format one-to-many relationships where this entity is primary
296 | const oneToManyPrimary = relationships.oneToMany.value
297 | .filter((rel: any) => rel.ReferencingEntity !== entityName)
298 | .map((rel: any) => `- ${rel.SchemaName}: ${entityName} (1) → ${rel.ReferencingEntity} (N)`)
299 | .join('\n');
300 |
301 | // Format one-to-many relationships where this entity is related
302 | const oneToManyRelated = relationships.oneToMany.value
303 | .filter((rel: any) => rel.ReferencingEntity === entityName)
304 | .map((rel: any) => `- ${rel.SchemaName}: ${rel.ReferencedEntity} (1) → ${entityName} (N)`)
305 | .join('\n');
306 |
307 | // Format many-to-many relationships
308 | const manyToMany = relationships.manyToMany.value
309 | .map((rel: any) => {
310 | const otherEntity = rel.Entity1LogicalName === entityName ? rel.Entity2LogicalName : rel.Entity1LogicalName;
311 | return `- ${rel.SchemaName}: ${entityName} (N) ↔ ${otherEntity} (N)`;
312 | })
313 | .join('\n');
314 |
315 | let promptContent = powerPlatformPrompts.RELATIONSHIP_MAP(entityName);
316 | promptContent = promptContent
317 | .replace('{{one_to_many_primary}}', oneToManyPrimary || 'None found')
318 | .replace('{{one_to_many_related}}', oneToManyRelated || 'None found')
319 | .replace('{{many_to_many}}', manyToMany || 'None found');
320 |
321 | return {
322 | messages: [
323 | {
324 | role: "assistant",
325 | content: {
326 | type: "text",
327 | text: promptContent
328 | }
329 | }
330 | ]
331 | };
332 | } catch (error: any) {
333 | console.error(`Error handling relationship-map prompt:`, error);
334 | return {
335 | messages: [
336 | {
337 | role: "assistant",
338 | content: {
339 | type: "text",
340 | text: `Error: ${error.message}`
341 | }
342 | }
343 | ]
344 | };
345 | }
346 | }
347 | );
348 |
349 | // PowerPlatform entity metadata
350 | server.tool(
351 | "get-entity-metadata",
352 | "Get metadata about a PowerPlatform entity",
353 | {
354 | entityName: z.string().describe("The logical name of the entity"),
355 | },
356 | async ({ entityName }) => {
357 | try {
358 | // Get or initialize PowerPlatformService
359 | const service = getPowerPlatformService();
360 | const metadata = await service.getEntityMetadata(entityName);
361 |
362 | // Format the metadata as a string for text display
363 | const metadataStr = JSON.stringify(metadata, null, 2);
364 |
365 | return {
366 | content: [
367 | {
368 | type: "text",
369 | text: `Entity metadata for '${entityName}':\n\n${metadataStr}`,
370 | },
371 | ],
372 | };
373 | } catch (error: any) {
374 | console.error("Error getting entity metadata:", error);
375 | return {
376 | content: [
377 | {
378 | type: "text",
379 | text: `Failed to get entity metadata: ${error.message}`,
380 | },
381 | ],
382 | };
383 | }
384 | }
385 | );
386 |
387 | // PowerPlatform entity attributes
388 | server.tool(
389 | "get-entity-attributes",
390 | "Get attributes/fields of a PowerPlatform entity",
391 | {
392 | entityName: z.string().describe("The logical name of the entity"),
393 | },
394 | async ({ entityName }) => {
395 | try {
396 | // Get or initialize PowerPlatformService
397 | const service = getPowerPlatformService();
398 | const attributes = await service.getEntityAttributes(entityName);
399 |
400 | // Format the attributes as a string for text display
401 | const attributesStr = JSON.stringify(attributes, null, 2);
402 |
403 | return {
404 | content: [
405 | {
406 | type: "text",
407 | text: `Attributes for entity '${entityName}':\n\n${attributesStr}`,
408 | },
409 | ],
410 | };
411 | } catch (error: any) {
412 | console.error("Error getting entity attributes:", error);
413 | return {
414 | content: [
415 | {
416 | type: "text",
417 | text: `Failed to get entity attributes: ${error.message}`,
418 | },
419 | ],
420 | };
421 | }
422 | }
423 | );
424 |
425 | // PowerPlatform specific entity attribute
426 | server.tool(
427 | "get-entity-attribute",
428 | "Get a specific attribute/field of a PowerPlatform entity",
429 | {
430 | entityName: z.string().describe("The logical name of the entity"),
431 | attributeName: z.string().describe("The logical name of the attribute")
432 | },
433 | async ({ entityName, attributeName }) => {
434 | try {
435 | // Get or initialize PowerPlatformService
436 | const service = getPowerPlatformService();
437 | const attribute = await service.getEntityAttribute(entityName, attributeName);
438 |
439 | // Format the attribute as a string for text display
440 | const attributeStr = JSON.stringify(attribute, null, 2);
441 |
442 | return {
443 | content: [
444 | {
445 | type: "text",
446 | text: `Attribute '${attributeName}' for entity '${entityName}':\n\n${attributeStr}`,
447 | },
448 | ],
449 | };
450 | } catch (error: any) {
451 | console.error("Error getting entity attribute:", error);
452 | return {
453 | content: [
454 | {
455 | type: "text",
456 | text: `Failed to get entity attribute: ${error.message}`,
457 | },
458 | ],
459 | };
460 | }
461 | }
462 | );
463 |
464 | // PowerPlatform entity relationships
465 | server.tool(
466 | "get-entity-relationships",
467 | "Get relationships (one-to-many and many-to-many) for a PowerPlatform entity",
468 | {
469 | entityName: z.string().describe("The logical name of the entity"),
470 | },
471 | async ({ entityName }) => {
472 | try {
473 | // Get or initialize PowerPlatformService
474 | const service = getPowerPlatformService();
475 | const relationships = await service.getEntityRelationships(entityName);
476 |
477 | // Format the relationships as a string for text display
478 | const relationshipsStr = JSON.stringify(relationships, null, 2);
479 |
480 | return {
481 | content: [
482 | {
483 | type: "text",
484 | text: `Relationships for entity '${entityName}':\n\n${relationshipsStr}`,
485 | },
486 | ],
487 | };
488 | } catch (error: any) {
489 | console.error("Error getting entity relationships:", error);
490 | return {
491 | content: [
492 | {
493 | type: "text",
494 | text: `Failed to get entity relationships: ${error.message}`,
495 | },
496 | ],
497 | };
498 | }
499 | }
500 | );
501 |
502 | // PowerPlatform global option set
503 | server.tool(
504 | "get-global-option-set",
505 | "Get a global option set definition by name",
506 | {
507 | optionSetName: z.string().describe("The name of the global option set"),
508 | },
509 | async ({ optionSetName }) => {
510 | try {
511 | // Get or initialize PowerPlatformService
512 | const service = getPowerPlatformService();
513 | const optionSet = await service.getGlobalOptionSet(optionSetName);
514 |
515 | // Format the option set as a string for text display
516 | const optionSetStr = JSON.stringify(optionSet, null, 2);
517 |
518 | return {
519 | content: [
520 | {
521 | type: "text",
522 | text: `Global option set '${optionSetName}':\n\n${optionSetStr}`,
523 | },
524 | ],
525 | };
526 | } catch (error: any) {
527 | console.error("Error getting global option set:", error);
528 | return {
529 | content: [
530 | {
531 | type: "text",
532 | text: `Failed to get global option set: ${error.message}`,
533 | },
534 | ],
535 | };
536 | }
537 | }
538 | );
539 |
540 | // PowerPlatform record by ID
541 | server.tool(
542 | "get-record",
543 | "Get a specific record by entity name (plural) and ID",
544 | {
545 | entityNamePlural: z.string().describe("The plural name of the entity (e.g., 'accounts', 'contacts')"),
546 | recordId: z.string().describe("The GUID of the record"),
547 | },
548 | async ({ entityNamePlural, recordId }) => {
549 | try {
550 | // Get or initialize PowerPlatformService
551 | const service = getPowerPlatformService();
552 | const record = await service.getRecord(entityNamePlural, recordId);
553 |
554 | // Format the record as a string for text display
555 | const recordStr = JSON.stringify(record, null, 2);
556 |
557 | return {
558 | content: [
559 | {
560 | type: "text",
561 | text: `Record from '${entityNamePlural}' with ID '${recordId}':\n\n${recordStr}`,
562 | },
563 | ],
564 | };
565 | } catch (error: any) {
566 | console.error("Error getting record:", error);
567 | return {
568 | content: [
569 | {
570 | type: "text",
571 | text: `Failed to get record: ${error.message}`,
572 | },
573 | ],
574 | };
575 | }
576 | }
577 | );
578 |
579 | // PowerPlatform query records with filter
580 | server.tool(
581 | "query-records",
582 | "Query records using an OData filter expression",
583 | {
584 | entityNamePlural: z.string().describe("The plural name of the entity (e.g., 'accounts', 'contacts')"),
585 | filter: z.string().describe("OData filter expression (e.g., \"name eq 'test'\" or \"createdon gt 2023-01-01\")"),
586 | maxRecords: z.number().optional().describe("Maximum number of records to retrieve (default: 50)"),
587 | },
588 | async ({ entityNamePlural, filter, maxRecords }) => {
589 | try {
590 | // Get or initialize PowerPlatformService
591 | const service = getPowerPlatformService();
592 | const records = await service.queryRecords(entityNamePlural, filter, maxRecords || 50);
593 |
594 | // Format the records as a string for text display
595 | const recordsStr = JSON.stringify(records, null, 2);
596 | const recordCount = records.value?.length || 0;
597 |
598 | return {
599 | content: [
600 | {
601 | type: "text",
602 | text: `Retrieved ${recordCount} records from '${entityNamePlural}' with filter '${filter}':\n\n${recordsStr}`,
603 | },
604 | ],
605 | };
606 | } catch (error: any) {
607 | console.error("Error querying records:", error);
608 | return {
609 | content: [
610 | {
611 | type: "text",
612 | text: `Failed to query records: ${error.message}`,
613 | },
614 | ],
615 | };
616 | }
617 | }
618 | );
619 |
620 | // PowerPlatform MCP Prompts
621 | server.tool(
622 | "use-powerplatform-prompt",
623 | "Use a predefined prompt template for PowerPlatform entities",
624 | {
625 | promptType: z.enum([
626 | "ENTITY_OVERVIEW",
627 | "ATTRIBUTE_DETAILS",
628 | "QUERY_TEMPLATE",
629 | "RELATIONSHIP_MAP"
630 | ]).describe("The type of prompt template to use"),
631 | entityName: z.string().describe("The logical name of the entity"),
632 | attributeName: z.string().optional().describe("The logical name of the attribute (required for ATTRIBUTE_DETAILS prompt)"),
633 | },
634 | async ({ promptType, entityName, attributeName }) => {
635 | try {
636 | // Get or initialize PowerPlatformService
637 | const service = getPowerPlatformService();
638 |
639 | let promptContent = "";
640 | let replacements: Record<string, string> = {};
641 |
642 | switch (promptType) {
643 | case "ENTITY_OVERVIEW": {
644 | // Get entity metadata and key attributes
645 | const [metadata, attributes] = await Promise.all([
646 | service.getEntityMetadata(entityName),
647 | service.getEntityAttributes(entityName)
648 | ]);
649 |
650 | // Format entity details
651 | const entityDetails = `- Display Name: ${metadata.DisplayName?.UserLocalizedLabel?.Label || entityName}\n` +
652 | `- Schema Name: ${metadata.SchemaName}\n` +
653 | `- Description: ${metadata.Description?.UserLocalizedLabel?.Label || 'No description'}\n` +
654 | `- Primary Key: ${metadata.PrimaryIdAttribute}\n` +
655 | `- Primary Name: ${metadata.PrimaryNameAttribute}`;
656 |
657 | // Get key attributes
658 | const keyAttributes = attributes.value
659 | //.slice(0, 10) // Limit to first 10 important attributes
660 | .map((attr: any) => {
661 | const attrType = attr["@odata.type"] || attr.odata?.type || "Unknown type";
662 | return `- ${attr.LogicalName}: ${attrType}`;
663 | })
664 | .join('\n');
665 |
666 | // Get relationships summary
667 | const relationships = await service.getEntityRelationships(entityName);
668 | const oneToManyCount = relationships.oneToMany.value.length;
669 | const manyToManyCount = relationships.manyToMany.value.length;
670 |
671 | const relationshipsSummary = `- One-to-Many Relationships: ${oneToManyCount}\n` +
672 | `- Many-to-Many Relationships: ${manyToManyCount}`;
673 |
674 | promptContent = powerPlatformPrompts.ENTITY_OVERVIEW(entityName);
675 | replacements = {
676 | '{{entity_details}}': entityDetails,
677 | '{{key_attributes}}': keyAttributes,
678 | '{{relationships}}': relationshipsSummary
679 | };
680 | break;
681 | }
682 |
683 | case "ATTRIBUTE_DETAILS": {
684 | if (!attributeName) {
685 | throw new Error("attributeName is required for ATTRIBUTE_DETAILS prompt");
686 | }
687 |
688 | // Get attribute details
689 | const attribute = await service.getEntityAttribute(entityName, attributeName);
690 |
691 | // Format attribute details
692 | const attrDetails = `- Display Name: ${attribute.DisplayName?.UserLocalizedLabel?.Label || attributeName}\n` +
693 | `- Description: ${attribute.Description?.UserLocalizedLabel?.Label || 'No description'}\n` +
694 | `- Type: ${attribute.AttributeType}\n` +
695 | `- Format: ${attribute.Format || 'N/A'}\n` +
696 | `- Is Required: ${attribute.RequiredLevel?.Value || 'No'}\n` +
697 | `- Is Searchable: ${attribute.IsValidForAdvancedFind || false}`;
698 |
699 | promptContent = powerPlatformPrompts.ATTRIBUTE_DETAILS(entityName, attributeName);
700 | replacements = {
701 | '{{attribute_details}}': attrDetails,
702 | '{{data_type}}': attribute.AttributeType,
703 | '{{required}}': attribute.RequiredLevel?.Value || 'No',
704 | '{{max_length}}': attribute.MaxLength || 'N/A'
705 | };
706 | break;
707 | }
708 |
709 | case "QUERY_TEMPLATE": {
710 | // Get entity metadata to determine plural name
711 | const metadata = await service.getEntityMetadata(entityName);
712 | const entityNamePlural = metadata.EntitySetName;
713 |
714 | // Get a few important fields for the select example
715 | const attributes = await service.getEntityAttributes(entityName);
716 | const selectFields = attributes.value
717 | .slice(0, 5) // Just take first 5 for example
718 | .map((attr: any) => attr.LogicalName)
719 | .join(',');
720 |
721 | promptContent = powerPlatformPrompts.QUERY_TEMPLATE(entityNamePlural);
722 | replacements = {
723 | '{{selected_fields}}': selectFields,
724 | '{{filter_conditions}}': `${metadata.PrimaryNameAttribute} eq 'Example'`,
725 | '{{order_by}}': `${metadata.PrimaryNameAttribute} asc`,
726 | '{{max_records}}': '50'
727 | };
728 | break;
729 | }
730 |
731 | case "RELATIONSHIP_MAP": {
732 | // Get relationships
733 | const relationships = await service.getEntityRelationships(entityName);
734 |
735 | // Format one-to-many relationships where this entity is primary
736 | const oneToManyPrimary = relationships.oneToMany.value
737 | .filter((rel: any) => rel.ReferencingEntity !== entityName)
738 | //.slice(0, 10) // Limit to 10 for readability
739 | .map((rel: any) => `- ${rel.SchemaName}: ${entityName} (1) → ${rel.ReferencingEntity} (N)`)
740 | .join('\n');
741 |
742 | // Format one-to-many relationships where this entity is related
743 | const oneToManyRelated = relationships.oneToMany.value
744 | .filter((rel: any) => rel.ReferencingEntity === entityName)
745 | //.slice(0, 10) // Limit to 10 for readability
746 | .map((rel: any) => `- ${rel.SchemaName}: ${rel.ReferencedEntity} (1) → ${entityName} (N)`)
747 | .join('\n');
748 |
749 | // Format many-to-many relationships
750 | const manyToMany = relationships.manyToMany.value
751 | //.slice(0, 10) // Limit to 10 for readability
752 | .map((rel: any) => {
753 | const otherEntity = rel.Entity1LogicalName === entityName ? rel.Entity2LogicalName : rel.Entity1LogicalName;
754 | return `- ${rel.SchemaName}: ${entityName} (N) ↔ ${otherEntity} (N)`;
755 | })
756 | .join('\n');
757 |
758 | promptContent = powerPlatformPrompts.RELATIONSHIP_MAP(entityName);
759 | replacements = {
760 | '{{one_to_many_primary}}': oneToManyPrimary || 'None found',
761 | '{{one_to_many_related}}': oneToManyRelated || 'None found',
762 | '{{many_to_many}}': manyToMany || 'None found'
763 | };
764 | break;
765 | }
766 | }
767 |
768 | // Replace all placeholders in the template
769 | for (const [placeholder, value] of Object.entries(replacements)) {
770 | promptContent = promptContent.replace(placeholder, value);
771 | }
772 |
773 | return {
774 | content: [
775 | {
776 | type: "text",
777 | text: promptContent,
778 | },
779 | ],
780 | };
781 | } catch (error: any) {
782 | console.error("Error using PowerPlatform prompt:", error);
783 | return {
784 | content: [
785 | {
786 | type: "text",
787 | text: `Failed to use PowerPlatform prompt: ${error.message}`,
788 | },
789 | ],
790 | };
791 | }
792 | }
793 | );
794 |
795 | async function main() {
796 | const transport = new StdioServerTransport();
797 | await server.connect(transport);
798 | console.error("Initializing PowerPlatform MCP Server...");
799 | }
800 |
801 | main().catch((error) => {
802 | console.error("Fatal error in main():", error);
803 | process.exit(1);
804 | });
```