PowerPortalsPro.Mcp
9.0.0
{ "servers": { "PowerPortalsPro.Mcp": { "type": "stdio", "command": "dnx", "args": ["PowerPortalsPro.Mcp@9.0.0", "--yes"] } } }
.vscode/mcp.json settings file.
dotnet tool install --global PowerPortalsPro.Mcp --version 9.0.0
dotnet new tool-manifest
dotnet tool install --local PowerPortalsPro.Mcp --version 9.0.0
#tool dotnet:?package=PowerPortalsPro.Mcp&version=9.0.0
nuke :add-package PowerPortalsPro.Mcp --version 9.0.0
Power Portals Pro — MCP Server
A Model Context Protocol server that gives AI coding agents accurate, first-hand knowledge of Power Portals Pro while you build a portal.
Without it, an agent writing Power Portals Pro code has to guess: parameter names it has never seen, and — worse — Dataverse view GUIDs, table and column logical names and option-set values that exist only in your environment and appear in no documentation anywhere. This server answers both.
What it knows
The framework, embedded in the package and version-matched to the release:
- The full component API for both stacks — every parameter and prop with its type, default, permitted values and description.
- Working, verbatim code samples, paired Blazor and React.
- The conceptual guides from the documentation site.
- The conventions from
AGENTS.md, and the Blazor-to-React parity matrix.
Your environment, read live and read-only:
- Tables, columns and the Power Portals Pro editor each column type maps to.
- Saved views with their real GUIDs — the thing no document can tell an agent.
- Relationship names for
SubGridandManyToManyLookupEdit, and option-set values with labels. - Which portal roles exist, which of them new users start with, and which URLs are licensed.
- The records themselves, in any table —
read_recordstakes a table name or a FetchXML query (a saved view's FetchXML runs verbatim), andget_recordfetches one row by id. Metadata says what the shape is; this says what is actually in it — which option-set value a row really carries, whether a lookup is populated, whether the row your code just wrote came out right. An agent that cannot look will guess instead.
Reading is read-only and it reads as you — the service principal or the signed-in user, not a portal user. None of the portal's own table permissions, column permissions or row filters apply, so it shows what is in the environment and never what a given portal user would be shown. No tool creates, changes or deletes a business record.
The framework's own configuration
The exception to read-only is the framework's own configuration, and it is deliberately narrow:
the tables below, and no others. Three are the framework's own; the rest are the plumbing a portal
needs to run, and each is admitted for a stated reason rather than as a category. systemuser is
only ever the application users that make an app registration usable — never a person's account —
and queue/mailbox are only ever the sending address a portal's email goes out from.
| Table | Tools |
|---|---|
| Portal Role | list_portal_roles, create_portal_role, update_portal_role, delete_portal_role |
| Portal Role Configuration | create_portal_role_configuration, update_portal_role_configuration, delete_portal_role_configuration |
| Power Portals Pro Website | list_portal_websites, create_portal_website, update_portal_website, delete_portal_website |
| System User (application users only) | get_application_user, create_dataverse_application_user, assign_application_user_role, remove_application_user_role, assign_application_user_column_profile, remove_application_user_column_profile |
| Queue and Mailbox (the sending address only) | create_email_queue |
| Security Role (privileges only, never the role itself) | describe_role_table_privileges, add_table_privileges_to_role |
Writes that never touch Dataverse at all — the App Service, Key Vault and managed identity in your Azure subscription, and the app registrations in your Entra tenant — are covered under deployment below. Every one of them asks before it acts.
Why roles. A portal role is half code and half data — the developer decides what the name
"Dealer" grants, an administrator decides who holds it, and nothing joins the two but the string. A
typo between them produces a role that is registered, assignable and grants nothing, with no error
anywhere. Letting the agent that just wrote the AddPortalRole call maintain the matching record
under the same name closes that gap, and check_setup catches the mismatches already there: it
compares every role name written in the project — registrations, role checks and default roles —
with the records, and reports any that are missing or inactive. FetchXML criteria is checked against
the rules the portal applies before the row is written, because the portal fails closed on bad
criteria and a broken rule looks exactly like a rule that correctly matched nobody. Administrators
maintain the same records on the portal's own Role Configuration page, which both templates include.
Why application users. An Entra app registration gets a token; a Dataverse application user decides what that token may do. A registration without one authenticates perfectly and is then refused by every call — which reads as a broken connection, not a missing object — and an application user without a security role is created, visible, apparently fine, and permitted to do nothing. Both halves are infrastructure rather than customer data, the same category as importing the managed solution. Creating one needs System Administrator on the environment.
Why column security profiles. A security role can never grant a secured column — only a
column security profile can, and System Administrator is the one role that gets past column
security by carrying a profile implicitly. Power Pages secures two contact columns the framework
writes as itself, adx_identity_logonenabled and adx_identity_passwordhash, so an application
user moved onto any narrower role keeps signing people in and then fails at the first portal
registration, with password sign-in unable to read stored hashes. The managed solution ships a
Power Portals Pro Application User column security profile covering exactly those two;
membership is not something a solution can carry, so assign_application_user_column_profile
adds each application user to it — the app registration's, and the managed identity's a deployed
app connects as.
Why security roles. The role that ships in the managed solution covers the framework's own tables and stops there, because no role authored here can know a consumer's tables — and under the application user needs access to them under both security models. Dataverse-native security is not an exemption: impersonation intersects privileges rather than delegating them, so the application user's role is a ceiling on what any portal user can do, however their own roles are configured. Granting that access by hand means finding one table in a grid of several hundred and clicking the correct arc of a circle eight times, which is done wrong quietly: a table missed surfaces much later as an empty grid or a save that fails with a privilege GUID in the message. This is the sharpest edge in the list — granting a privilege grants access to business records, even though no business record is touched — so it is also the most tightly bounded. Privileges are only ever added: a privilege already held at an equal or deeper scope is left exactly as it is, and nothing here revokes, creates or deletes a role. Consumers author their own role for their own tables and assign both: the shipped role can technically be edited, but it is our component and an upgrade can reapply it.
Why websites. Every data endpoint calls the licence check, which looks the request's host up in the website table. No matching active record means every endpoint returns 402 — a failure that names nothing an administrator can act on, and one that reliably strikes the first time a portal runs against a new environment or from a new URL. Licence keys can be written but are never read back: reads report only whether a key is set.
Deletes ask first. Where your client supports elicitation, the server asks you directly and the
deletion proceeds only if you confirm — the agent cannot answer on your behalf. Where it does not,
the tool refuses unless the caller passes the record's own name back in a confirm argument, which
at least puts the name in the tool call you approve. Deactivating is offered as the reversible
alternative everywhere it would do, and the tools say so.
Installing the managed solution
list_solutions shows what the environment has against what has been published, and says plainly
whether PowerPortalsProCore is missing or behind. install_solution downloads a release and
imports it — the latest by default, or any version you name — and get_solution_import_status
follows it.
This exists because of the scope problem. pac auth selects one environment for the whole machine,
so pac solution import can land in a different customer's org than the repository you are sitting
in, and nothing in the command would tell you. This server's binding is per folder, so the
environment follows the code. Before an import starts you are shown which environment is about to
change and asked to confirm; where your client cannot prompt, the tool refuses until the environment
URL is passed back as confirm.
Imports take minutes, so the tool starts one and returns a job id rather than blocking — a tool call that hangs for ten minutes is one your client gives up on while the import is still running. Unmanaged customizations are never overwritten: an import that genuinely conflicts fails and says so, rather than silently discarding someone's work in the maker portal.
The solutions are public, and can still be downloaded by hand from the releases page if you would rather import them yourself.
publish_customizations is the companion for the other half of that loop: when the portal is
serving stale metadata after a change in the maker portal — a new column not appearing, a renamed
label still showing the old text — this is the same action as the maker portal's "Publish all
customizations". It publishes everything pending in the environment rather than only your own
changes, which is worth knowing on a shared development environment. Importing a managed solution
publishes what it brings, so it is not needed after install_solution.
Upgrading to a new release
get_upgrade_plan answers the question an upgrade starts with: what version is this project on,
what would it be on, and what breaks in between. It reads the version the project references,
resolves the newest published release, and returns the releases in between with every breaking
change in them and its migration instructions — plus the files that name a version, which are
never only the project files: the React client's package.json, and the server pin in .mcp.json
that nothing else would catch.
That last one is why the tool exists in this shape. Upgrade the packages and leave .mcp.json
pinned, and nothing fails — the server carries on answering for the version you left, so the first
thing it tells you about the release you moved to is that it does not exist. The plan names the
right form of the version in each place, too: a release is tagged with four parts and its packages
are published with three, so the release version written into package.json fails at install on a
version npm will not parse.
The tool writes nothing. install_solution upgrades the Dataverse side, the file edits are yours,
and the plan is a sequence you can stop halfway through.
One limit worth knowing: the knowledge index ships inside this package, so a pinned server only knows about releases up to its own version. Asked to plan a move to something newer, it says so rather than reporting a clean upgrade — re-pin, restart the client, and ask again.
Install
There are two scopes, and both are worth having.
Once per machine, so it is available in any folder — including an empty one you are about to scaffold a project into, which is exactly where it can tell you which template to use and what the options mean:
claude mcp add --scope user powerportalspro -- dnx PowerPortalsPro.Mcp --yes
Visual Studio reads a user-level .mcp.json from your profile folder and Cursor uses
~/.cursor/mcp.json; both take the same servers block shown below.
Per project. Projects created by the dotnet new powerportalspro templates are wired up
already, with the version pinned to the packages that project uses. To add it to an existing
project, run this in the project folder:
claude mcp add --scope project powerportalspro -- dnx PowerPortalsPro.Mcp --yes
That writes the .mcp.json below for you. Note what it does not do: it leaves the server
unpinned, so the folder gets the newest published server rather than the one matching the
PowerPortalsPro.* packages the project references. That is the right default for a project
tracking the current release, and the wrong one for a project deliberately held on an older
version — pin it by appending the version, the way the templates do:
claude mcp add --scope project powerportalspro -- dnx PowerPortalsPro.Mcp@8.0.0 --yes
Or write the file yourself, at the root of your solution as .mcp.json:
{
"servers": {
"powerportalspro": {
"type": "stdio",
"command": "dnx",
"args": ["PowerPortalsPro.Mcp", "--yes"]
}
}
}
Visual Studio 2022 17.14+ and Visual Studio 2026 read .mcp.json from the solution directory.
For Claude Code the same file works with the servers listed under mcpServers instead. For VS Code
the file is .vscode/mcp.json, and for Cursor it is .cursor/mcp.json; both use the servers
shape shown above.
dnx ships with the .NET 10 SDK, which you already have — every Power Portals Pro project is
hosted by ASP.NET Core, React SPAs included.
Both entries use the same server name, so a client that understands both scopes uses the project's: version-matched answers inside a project, latest ones everywhere else.
Updating
How you update depends on how you installed it.
Installed with dnx, unpinned — the machine-wide entry above — needs nothing run. dnx
resolves the latest version from your NuGet feeds each time your client starts the server, so a new
release arrives the next time you restart the client.
Pinned in a project's .mcp.json. The templates write PowerPortalsPro.Mcp@<version>, matched
to the framework packages that project uses. Change that version when you upgrade the packages, so
the answers keep describing the framework the project actually runs.
Installed as a global tool (dotnet tool install --global PowerPortalsPro.Mcp, which puts
ppp-mcp on your PATH):
dotnet tool update --global PowerPortalsPro.Mcp
Close your MCP client first. A running server holds its own files open, and the update fails with Access to the path … is denied until nothing is using them. Start the client again afterwards to load the new version.
Connecting to Dataverse
The documentation tools work with no configuration at all. The metadata tools need a connection, and that connection belongs to the folder, not to the machine. Run this once in a project:
dnx PowerPortalsPro.Mcp login
It signs you in, writes .ppp/mcp.json in the folder, and stores the token encrypted in your user
profile. Other projects are unaffected — a consultant can have agents working against three
customers at the same time, each in its own folder, each signed in to its own environment.
dnx PowerPortalsPro.Mcp login --environment https://acme.crm.dynamics.com
dnx PowerPortalsPro.Mcp login --pac-profile "Acme Dev" # take the environment from a pac profile
dnx PowerPortalsPro.Mcp login --browser # browser instead of a device code
dnx PowerPortalsPro.Mcp status # what is this folder bound to?
dnx PowerPortalsPro.Mcp environments # pac profiles available to mirror
dnx PowerPortalsPro.Mcp logout [--forget]
Sign-in uses the device-code flow by default, so it works over SSH, in a container and in WSL, and
you can finish it on your phone. Pass --browser if you would rather.
What goes where
| Where | Committable | |
|---|---|---|
| Which environment this folder uses | .ppp/mcp.json in the repository |
Yes — it holds no secrets, and it points a teammate at the right environment |
| Your token | Encrypted in your user profile — DPAPI on Windows, Keychain on macOS, libsecret on Linux | Never written to the repository |
The token cache is keyed by environment, so a second clone of the same customer's repository does not ask you to sign in again, while a different customer is always a separate sign-in.
If your machine has no usable keystore, the sign-in refuses rather than writing a refresh token to disk in the clear; use a client secret there instead.
Other ways to connect
Resolved in this order, so the most explicit statement of intent wins:
PPP_D365_URL/PPP_D365_CLIENT_ID/PPP_D365_CLIENT_SECRETin the server'senvblock — a service principal, for CI or a shared container.- The folder's sign-in from
ppp-mcp login. - The project's own settings — the same
D365:Url,D365:ClientIdandD365:ClientSecret(orD365:Secret) the portal reads from user secrets andappsettings. A real Power Portals Pro project therefore often needs no sign-in at all.
About the Power Platform CLI
pac has no command that emits an access token, and it keeps credentials in its own store, so its
session cannot be reused directly. What --pac-profile does is take the environment and account
from a profile you already have and sign in as that account — usually without prompting, since you
already have a session.
We never run pac auth select. That switches a single machine-wide active profile, which would
move every other project at once; it is the behaviour this design exists to avoid.
Deploying to Azure
The server can also stand up and deploy the App Service a portal runs on. Bind the folder to a subscription once:
dnx PowerPortalsPro.Mcp azure login
It does not use the Azure CLI, and never reads or changes its signed-in state. az login sets
one active subscription for every process on the machine — the same machine-wide behaviour as
pac auth select, and the same reason this design avoids it. The subscription binds to the folder,
so several projects can target different customers at once.
azure_status, list_app_services, describe_app_service |
What is there. Reader is enough. |
get_deployment_plan |
The steps, and every plan tier with its current price from Azure's public price list. Needs no sign-in. |
check_deployment |
What the project needs vs. what is actually deployed. |
create_app_service, configure_app_service, set_app_service_settings, deploy_to_app_service |
The changes. Each one confirms first. |
create_managed_identity, enable_managed_identity, create_key_vault |
An identity for the app, and a vault its secrets live in rather than its configuration. |
check_deployment is the one to know. Deployments of this framework fail in a handful of ways and
every one of them is invisible where it goes wrong: a configuration key that only ever existed in
your user secrets, so the site never starts; ASPNETCORE_ENVIRONMENT left on Development, so a
React portal's deep links redirect to localhost; WebSockets off, so a Blazor circuit silently
degrades to long polling; a Portal Website record naming a different host, so every data endpoint
returns 402 while the pages render perfectly. All of those are a comparison between two things the
server can already see, and it needs nothing but Reader — so it is safe to run against production,
which is where it is needed.
Every tool that changes anything asks you first, and tells you the cost. The confirmation
carries the real monthly figure for your region, read live from Azure's retail price list, and says
plainly that retail list ignores any Enterprise Agreement, reservation or dev/test rate you hold.
Where your client supports elicitation the server asks you directly and the agent cannot answer on
your behalf; where it does not, the tool refuses unless the caller echoes the resource name back in
a confirm argument.
New resources default to B1 — for every environment, production included. It is the cheapest
tier that can stay warm, and scaling up is one call with no redeploy; provisioning a premium plan
because something is "production" starts a bill nobody revisits. Application setting values are
never read back, with a short allowlist of documented non-secrets such as ASPNETCORE_ENVIRONMENT
and D365__Url — a portal's settings hold the Dataverse client secret and every sign-in provider's
secret, and those must not reach a transcript.
dnx PowerPortalsPro.Mcp azure login --subscription "Contoso Production"
dnx PowerPortalsPro.Mcp azure login --tenant <id> # when you are a guest in the customer's tenant
dnx PowerPortalsPro.Mcp azure status
dnx PowerPortalsPro.Mcp azure subscriptions
dnx PowerPortalsPro.Mcp azure logout [--forget]
Secrets in Key Vault, not in configuration
Storing a client secret as a literal application setting works, and every deployment starts there. What it costs is that the secret is then readable by anyone with Contributor on the app, shows up in an export of the configuration, and is one copy-paste from a chat window.
create_managed_identity makes a user-assigned identity that holds no credential;
enable_managed_identity attaches it to the app; create_key_vault creates a vault and grants that
identity read access, and the signed-in account write access. After that the secret tools take
destination: "key-vault" — a freshly minted client secret goes from Graph straight into the vault,
and the app setting becomes a @Microsoft.KeyVault(...) reference that App Service resolves at
startup. The value never becomes a literal setting.
Set D365:ManagedIdentityId to that identity's client id and the Dataverse connection stops
using a client secret at all — there is then nothing left to expire. The identity needs a Dataverse
application user, created from the same client id, which create_dataverse_application_user does.
Two things worth knowing. Owner on the subscription does not grant access to secret values — Key Vault separates its control plane from its data plane deliberately, which is why the roles are assigned explicitly even to you. And a vault name is reserved for 90 days after deletion: soft delete cannot be turned off, so recreating a vault under the same name inside that window needs an explicit purge. It is the only resource these tools create that is not cheaply undoable.
User-assigned, not system-assigned. A system-assigned identity is a property of the app: it is
created and destroyed with it, and a deployment slot gets a different one. Either event mints a new
identity and orphans everything built on the old one — a Dataverse application user most of all,
which then matches no token while looking perfectly healthy. A user-assigned identity is a resource
of its own and survives the app being torn down and rebuilt, which for a portal that gets rebuilt
from a template more than once is the difference between setting this up once and setting it up
every time. enable_managed_identity will turn on a system-assigned identity if you omit the
identity id, and says what you are giving up when it does.
The portal's email sender
D365:EmailSenderEmailAddress is a required setting, and the framework resolves it to a Dataverse
user, then a queue, then a team. Use a queue: it is the only option needing no work in Exchange,
because the mailbox Dataverse creates for it points at the organisation's existing Exchange Online
profile — nothing to provision, no licence for the address. Pointing it at a real person instead puts
their name on every automated message, sends replies to their inbox, and breaks the portal when they
leave.
create_email_queue creates the queue and approves the address — both halves of it, since the
queue's own approval and the mailbox's isemailaddressapprovedbyo365admin are separate flags on
separate records and a queue is created pending on both. Test & Enable Mailbox in the Power
Platform admin centre remains a single manual click.
That click matters more than it looks: until it has run, Dataverse accepts the email, records it and never delivers it — which from the portal's side is indistinguishable from a successful send. Nothing throws and nothing is logged. If registration mail is not arriving, check the mailbox's outgoing status first; Not Run means this step was missed.
Entra app registrations
The two registrations a portal needs, and the jobs that follow them:
list_app_registrations Client ids, reply URLs and secret expiry dates
create_dataverse_app_registration The backend identity the portal reads Dataverse as
create_signin_app_registration The app behind "Sign in with Microsoft"
add_app_registration_redirect_uri Add the production reply URL after a deployment
add_app_registration_secret Rotate a secret before it lapses
They work on registrations that already exist. Pass clientId to either create_* tool and it
adopts that registration instead of creating one — adds a secret, merges in any new reply URLs,
writes the credentials where you asked — which is the common case in a tenant that has been running
for a while. The confirmation says "add a secret to the EXISTING registration" rather than "create",
so you are never approving one operation and getting another. The maintenance tools take clientId
too, and prefer it: display names are not unique, and a tenant with three registrations called
"Portal" is normal.
These tools hold no rights of their own. Every call goes out on a delegated token for the account you signed in with, issued to a public client that holds no secret — so each operation succeeds exactly when you could have performed it by hand in the portal, and is refused with a 403 naming the directory role you would need when you could not. Sign in as an account holding Application Developer or above and they work; sign in as anyone else and they cannot do harm. No API permissions are requested and no admin consent is granted, because neither registration needs any.
A created client secret is not returned by default. It goes from the Graph response straight into the project's user secrets, an App Service application setting, or both, and the tool reports where it went and when it expires rather than what it was.
Sometimes a secret has to reach somewhere this server cannot write — a colleague's machine, a CI
secret store, a Key Vault in another subscription. Pass destination: "return" and the value is
shown so you can copy it. The confirmation says so before you agree, and the result carries the
warning it deserves: the secret is then in the transcript, so treat it as exposed if that transcript
is stored, and rotate it with add_app_registration_secret if in doubt. Forbidding this outright
would only be routed around by copying the value out of the Entra portal instead.
Secrets are minted for 24 months by default — the longest Entra's portal offers — because the failure this framework actually suffers is a lapsed secret rather than a stolen one: the portal stops starting on a date nobody noted, with an error that never mentions Entra.
That 24 months is the portal's UI cap, not the API's. Graph accepts any expiry, so expiryMonths
takes anything up to 120 (10 years) if you would rather rotate once every five years than risk a
lapse. It is not the default, because a credential no administrator could have created by hand is a
surprising thing to find — and going past 24 is exactly the argument most likely to have been
mistyped, so the confirmation says how many years you have asked for and that the portal could not
produce it.
If the tenant has an application management policy capping credential lifetimes or forbidding password credentials, Graph refuses with a plain 400 whose message names neither the restriction nor the limit. The tools recognise that shape and say which knob to turn instead of reporting a malformed request.
Two things are deliberately absent. Deleting a registration can take down sign-in for every application in a tenant and is recoverable only inside a 30-day window. Granting admin consent is the operation that turns one narrow delegated act into a standing tenant-wide permission. Neither is needed to get a portal running, so neither is offered.
Creating the Dataverse application user for a new client id is a separate tool rather than a
separate chore: create_dataverse_application_user makes the systemuser row and assigns a
security role in one confirmed step, and list_security_roles shows what the environment offers.
Which role depends on whether you would mind the portal's credential reaching data the portal never
shows. System Administrator is fine on a demo or a sandbox and a poor idea anywhere else; the
alternative is two additive roles — Power Portals Pro Application User from the managed
solution, which covers the framework's own needs, plus a custom role of your own for your tables,
filled in with add_table_privileges_to_role. Impersonation intersects privileges rather than
delegating them, so the application user's role is a ceiling on what any portal visitor can do.
Licensing
The server itself is not licence-gated — it is documentation and metadata, and the documentation half is the same content published at demo.powerportalspro.com. The framework's own website-licence check is unaffected and still applies to a running portal.
Use of the Power Portals Pro packages is governed by the EULA included in this package.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
This package has no dependencies.